mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(serve): validate in-process serve options before startup (#17683)
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { specPaths, type CommandSpec } from './command-spec'
|
||||
import { levenshtein } from '../shared/edit-distance'
|
||||
|
||||
export { levenshtein } from '../shared/edit-distance'
|
||||
|
||||
// Why: rank the live registry so typo recovery cannot drift from accepted paths.
|
||||
|
||||
@@ -46,30 +49,6 @@ export type CommandErrorData = {
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
if (m === 0) {
|
||||
return n
|
||||
}
|
||||
if (n === 0) {
|
||||
return m
|
||||
}
|
||||
let prev = Array.from({ length: n + 1 }, (_, index) => index)
|
||||
let curr = Array.from({ length: n + 1 }, () => 0)
|
||||
for (let i = 1; i <= m; i += 1) {
|
||||
curr[0] = i
|
||||
for (let j = 1; j <= n; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
const swap = prev
|
||||
prev = curr
|
||||
curr = swap
|
||||
}
|
||||
return prev[n]
|
||||
}
|
||||
|
||||
// Why: one bounded near-match ranking keeps command and flag recovery consistent.
|
||||
function rankByDistance(scored: { label: string; distance: number }[]): string[] {
|
||||
return scored
|
||||
|
||||
+19
-32
@@ -3,6 +3,7 @@ import type { CommandHandler } from '../dispatch'
|
||||
import { formatCliStatus, formatStatus, printResult } from '../format'
|
||||
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
|
||||
import { stripElectronRunAsNode } from '../runtime/launch'
|
||||
import { getServeOptionValidationError } from '../../shared/serve-option-validation'
|
||||
|
||||
function envRecord(): Record<string, string> {
|
||||
// Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI
|
||||
@@ -92,43 +93,29 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
printResult(result, json, formatCliStatus)
|
||||
},
|
||||
serve: async ({ flags, json }) => {
|
||||
if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --mobile-pairing or --no-pairing, not both.'
|
||||
)
|
||||
}
|
||||
if (flags.get('recipe-json') === true && flags.get('no-pairing') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Recipe JSON output requires runtime pairing; remove --no-pairing.'
|
||||
)
|
||||
}
|
||||
if (flags.get('recipe-json') === true && flags.get('mobile-pairing') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Recipe JSON output requires runtime pairing; remove --mobile-pairing.'
|
||||
)
|
||||
}
|
||||
const projectRoot =
|
||||
typeof flags.get('project-root') === 'string' ? (flags.get('project-root') as string) : null
|
||||
if (flags.get('recipe-json') === true && !projectRoot) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Recipe JSON output requires --project-root.'
|
||||
)
|
||||
const projectRootValue = flags.get('project-root')
|
||||
const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null
|
||||
const noPairing = flags.get('no-pairing') === true
|
||||
const mobilePairing = flags.get('mobile-pairing') === true
|
||||
const recipeJson = flags.get('recipe-json') === true
|
||||
const validationError = getServeOptionValidationError({
|
||||
noPairing,
|
||||
mobilePairing,
|
||||
recipeJson,
|
||||
projectRoot
|
||||
})
|
||||
if (validationError) {
|
||||
throw new RuntimeClientError('invalid_argument', validationError)
|
||||
}
|
||||
const port = getOptionalServePort(flags)
|
||||
const pairingAddressValue = flags.get('pairing-address')
|
||||
const exitCode = await serveOrcaApp({
|
||||
json,
|
||||
port,
|
||||
pairingAddress:
|
||||
typeof flags.get('pairing-address') === 'string'
|
||||
? (flags.get('pairing-address') as string)
|
||||
: null,
|
||||
noPairing: flags.get('no-pairing') === true,
|
||||
mobilePairing: flags.get('mobile-pairing') === true,
|
||||
recipeJson: flags.get('recipe-json') === true,
|
||||
pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null,
|
||||
noPairing,
|
||||
mobilePairing,
|
||||
recipeJson,
|
||||
projectRoot
|
||||
})
|
||||
process.exitCode = exitCode
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite',
|
||||
|
||||
expect(normalizeServeModeArgv(argv)).toEqual(expected)
|
||||
if (takesValue) {
|
||||
// The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token.
|
||||
// The equals form is the other shape `orca serve` accepts; normalize it to the internal shape.
|
||||
expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected)
|
||||
} else {
|
||||
// A boolean with an attached value is not a truthy assertion: the CLI reads these as
|
||||
@@ -53,17 +53,19 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite',
|
||||
})
|
||||
|
||||
it('emits the same --serve-* names the CLI spawns with and the main process reads', () => {
|
||||
// Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so
|
||||
// both ends of the contract are only readable statically. Without this leg the rewrite could
|
||||
// emit a name nothing reads and every behavioural assertion above would still pass.
|
||||
// Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes
|
||||
// the rewrite/parser contract fail loudly if either side drifts.
|
||||
const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8')
|
||||
const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const start = mainSource.indexOf('function getServeOptions(')
|
||||
const serveOptionsSource = readFileSync(
|
||||
join(process.cwd(), 'src/main/startup/serve-options.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const start = serveOptionsSource.indexOf('export function getServeOptions(')
|
||||
// Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = mainSource.indexOf('\n}', start)
|
||||
const end = serveOptionsSource.indexOf('\n}', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const getServeOptionsBody = mainSource.slice(start, end)
|
||||
const getServeOptionsBody = serveOptionsSource.slice(start, end)
|
||||
|
||||
for (const flag of translatedFlags) {
|
||||
expect(launchSource).toContain(`'--serve-${flag}'`)
|
||||
|
||||
@@ -20,11 +20,9 @@ import {
|
||||
} from './cli-command-filesystem-transaction'
|
||||
import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants'
|
||||
import { buildWindowsForwarder } from './cli-dev-launcher'
|
||||
import { isMissingError, isPermissionError } from './cli-install-errors'
|
||||
import { isPermissionError } from './cli-install-errors'
|
||||
import { isPathInsideOrEqual } from './cli-install-path-format'
|
||||
|
||||
const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3
|
||||
|
||||
export class CliCommandInstallation extends CliCommandInspection {
|
||||
protected async installSymlink(status: CliInstallStatus): Promise<void> {
|
||||
const commandPath = status.commandPath
|
||||
@@ -194,34 +192,25 @@ export class CliCommandInstallation extends CliCommandInspection {
|
||||
})
|
||||
| null
|
||||
> {
|
||||
for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) {
|
||||
const before = await readEntrySnapshot(commandPath)
|
||||
if (!before) {
|
||||
return null
|
||||
}
|
||||
let target: string | null = null
|
||||
try {
|
||||
target = before.isSymbolicLink ? await readlink(commandPath) : null
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const after = await readEntrySnapshot(commandPath)
|
||||
if (after && hasSameSnapshot(before, after)) {
|
||||
const resolvedTarget = target ? resolve(dirname(commandPath), target) : null
|
||||
return {
|
||||
fileSha256: null,
|
||||
rawSymlinkTarget: target,
|
||||
snapshot: after,
|
||||
managed: Boolean(
|
||||
resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath)
|
||||
)
|
||||
}
|
||||
}
|
||||
const inspected = await inspectStableCommand(commandPath, () =>
|
||||
this.inspectSymlink(commandPath, launcherPath)
|
||||
)
|
||||
if (!inspected.snapshot) {
|
||||
return null
|
||||
}
|
||||
const resolvedTarget = inspected.rawSymlinkTarget
|
||||
? resolve(dirname(commandPath), inspected.rawSymlinkTarget)
|
||||
: inspected.status.currentTarget
|
||||
return {
|
||||
fileSha256: inspected.fileSha256,
|
||||
rawSymlinkTarget: inspected.rawSymlinkTarget,
|
||||
snapshot: inspected.snapshot,
|
||||
managed: Boolean(
|
||||
resolvedTarget &&
|
||||
(this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) ||
|
||||
(this.appImagePath && resolve(resolvedTarget) === resolve(this.appImagePath)))
|
||||
)
|
||||
}
|
||||
throw new Error(`The command at ${commandPath} changed while Orca inspected it.`)
|
||||
}
|
||||
|
||||
private async restoreQuarantinedCommand(
|
||||
|
||||
@@ -370,6 +370,56 @@ describe('CliInstaller', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'removes a legacy AppImage wrapper only when it names the current AppImage',
|
||||
async () => {
|
||||
const fixture = await makeFixture()
|
||||
const homePath = join(fixture.root, 'home')
|
||||
const commandDir = join(homePath, '.local', 'bin')
|
||||
const legacyCommandPath = join(commandDir, 'orca')
|
||||
const appImagePath = join(fixture.root, 'Orca.AppImage')
|
||||
const foreignAppImagePath = join(fixture.root, 'Other.AppImage')
|
||||
const cacheRootPath = join(fixture.root, 'cache')
|
||||
await mkdir(commandDir, { recursive: true })
|
||||
await writeFile(appImagePath, '#!/usr/bin/env bash\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
await writeFile(foreignAppImagePath, '#!/usr/bin/env bash\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(appImagePath), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
|
||||
const installer = new CliInstaller({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
userDataPath: fixture.userDataPath,
|
||||
appPath: fixture.appPath,
|
||||
appImagePath,
|
||||
appImageCacheRootPath: cacheRootPath,
|
||||
appImageExtractRunner: fakeAppImageExtractRunner,
|
||||
homePath,
|
||||
processPathEnv: commandDir
|
||||
})
|
||||
|
||||
await installer.install()
|
||||
await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(foreignAppImagePath), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
await installer.remove()
|
||||
await expect(readFile(legacyCommandPath, 'utf8')).resolves.toBe(
|
||||
buildLegacyAppImageCliWrapper(foreignAppImagePath)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the privilegedRunner is injectable so the EACCES→osascript path can be
|
||||
// exercised in integration without spawning osascript in unit tests.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
|
||||
+2
-40
@@ -169,6 +169,7 @@ import {
|
||||
} from './startup/main-process-error-guards'
|
||||
import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
|
||||
import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
|
||||
import { getServeOptions, type ServeOptions } from './startup/serve-options'
|
||||
import {
|
||||
ensureVirtualDisplayForHeadlessServe,
|
||||
hasUsableLinuxDisplay,
|
||||
@@ -2080,45 +2081,6 @@ const syntheticTitleSpinnerByPaneKey = new Map<
|
||||
>()
|
||||
let syntheticTitleSpinnerTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
type ServeOptions = {
|
||||
json: boolean
|
||||
wsPort?: number
|
||||
pairingAddress: string | null
|
||||
noPairing: boolean
|
||||
mobilePairing: boolean
|
||||
recipeJson: boolean
|
||||
projectRoot: string | null
|
||||
}
|
||||
|
||||
function getServeOptions(argv = process.argv): ServeOptions {
|
||||
const valueAfter = (flag: string): string | null => {
|
||||
const index = argv.indexOf(flag)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
return value && !value.startsWith('--') ? value : null
|
||||
}
|
||||
const rawPort = valueAfter('--serve-port')
|
||||
let wsPort: number | undefined
|
||||
if (rawPort) {
|
||||
const parsedPort = Number(rawPort)
|
||||
if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
|
||||
throw new Error(`Invalid --serve-port value: ${rawPort}`)
|
||||
}
|
||||
wsPort = parsedPort
|
||||
}
|
||||
return {
|
||||
json: argv.includes('--serve-json'),
|
||||
...(wsPort !== undefined ? { wsPort } : {}),
|
||||
pairingAddress: valueAfter('--serve-pairing-address'),
|
||||
noPairing: argv.includes('--serve-no-pairing'),
|
||||
mobilePairing: argv.includes('--serve-mobile-pairing'),
|
||||
recipeJson: argv.includes('--serve-recipe-json'),
|
||||
projectRoot: valueAfter('--serve-project-root')
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledWebClientRoot(): string | undefined {
|
||||
const appPath = app.getAppPath()
|
||||
const roots = [
|
||||
@@ -3376,7 +3338,7 @@ void app.whenReady().then(async () => {
|
||||
const devWsPort = is.dev && !isE2E ? 6769 : undefined
|
||||
let serveOptions: ServeOptions | null = null
|
||||
try {
|
||||
serveOptions = isServeMode ? getServeOptions() : null
|
||||
serveOptions = isServeMode ? getServeOptions(process.argv) : null
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
app.exit(1)
|
||||
|
||||
@@ -146,6 +146,14 @@ describe('serve-mode-argv', () => {
|
||||
).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0'])
|
||||
})
|
||||
|
||||
it('keeps equals-form values that start with a flag marker intact', () => {
|
||||
expect(normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng'])).toEqual([
|
||||
'/AppRun',
|
||||
'--serve',
|
||||
'--serve-pairing-address=--no-pairng'
|
||||
])
|
||||
})
|
||||
|
||||
it('translates serve flags in the mixed `--serve --port` form', () => {
|
||||
// Why: leaving these untranslated silently kept pairing enabled despite --no-pairing.
|
||||
expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([
|
||||
|
||||
@@ -136,8 +136,7 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] {
|
||||
next.push(...argv.slice(i))
|
||||
break
|
||||
}
|
||||
// Why: the CLI accepts `--port=6768` as well as `--port 6768`, but
|
||||
// getServeOptions only reads the next token, so `=` must be split apart.
|
||||
// Why: keep the internal argv shape canonical even though getServeOptions accepts both forms.
|
||||
const eq = token.indexOf('=')
|
||||
const name = eq === -1 ? token : token.slice(0, eq)
|
||||
// Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true`
|
||||
@@ -155,7 +154,14 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] {
|
||||
continue
|
||||
}
|
||||
if (eq !== -1) {
|
||||
next.push(valueFlag, token.slice(eq + 1))
|
||||
const value = token.slice(eq + 1)
|
||||
// Preserve the unambiguous `=` form when its value starts with `--`; splitting
|
||||
// it would make the value look like a second option to the direct parser.
|
||||
if (value.startsWith('--')) {
|
||||
next.push(`${valueFlag}=${value}`)
|
||||
} else {
|
||||
next.push(valueFlag, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
next.push(valueFlag)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getServeOptions } from './serve-options'
|
||||
import { normalizeServeModeArgv } from './serve-mode-argv'
|
||||
|
||||
describe('getServeOptions', () => {
|
||||
it('parses a valid launch', () => {
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-no-pairing'])
|
||||
).toEqual({
|
||||
json: false,
|
||||
wsPort: 6768,
|
||||
pairingAddress: null,
|
||||
noPairing: true,
|
||||
mobilePairing: false,
|
||||
recipeJson: false,
|
||||
projectRoot: null
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts equals-form values in the normalized shape', () => {
|
||||
expect(
|
||||
getServeOptions([
|
||||
'/AppRun',
|
||||
'--serve',
|
||||
'--serve-port=6768',
|
||||
'--serve-pairing-address=127.0.0.1',
|
||||
'--serve-project-root=/tmp/repo'
|
||||
])
|
||||
).toMatchObject({
|
||||
wsPort: 6768,
|
||||
pairingAddress: '127.0.0.1',
|
||||
projectRoot: '/tmp/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the final occurrence of each value flag', () => {
|
||||
expect(
|
||||
getServeOptions([
|
||||
'/AppRun',
|
||||
'--serve',
|
||||
'--serve-port',
|
||||
'6768',
|
||||
'--serve-port=6769',
|
||||
'--serve-pairing-address',
|
||||
'first.example',
|
||||
'--serve-pairing-address=last.example',
|
||||
'--serve-project-root',
|
||||
'/first',
|
||||
'--serve-project-root=/last'
|
||||
])
|
||||
).toMatchObject({
|
||||
wsPort: 6769,
|
||||
pairingAddress: 'last.example',
|
||||
projectRoot: '/last'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies missing or invalid values only to the final occurrence', () => {
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-port', '--serve-port', '6768']).wsPort
|
||||
).toBe(6768)
|
||||
expect(() =>
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port'])
|
||||
).toThrow('Missing value for --serve-port.')
|
||||
expect(() =>
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port=bad'])
|
||||
).toThrow('Invalid --serve-port value: bad')
|
||||
})
|
||||
|
||||
it('uses the final value of mixed boolean aliases', () => {
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-no-pairing', '--no-pairing=false']).noPairing
|
||||
).toBe(false)
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--no-pairing=false', '--serve-no-pairing']).noPairing
|
||||
).toBe(true)
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-mobile-pairing', '--mobile-pairing=0'])
|
||||
.mobilePairing
|
||||
).toBe(false)
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-recipe-json', '--recipe-json=false'])
|
||||
.recipeJson
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps JSON enabled for an equals-form global flag', () => {
|
||||
expect(getServeOptions(['/AppRun', '--serve', '--json=false']).json).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an equals-form value that resembles a pairing flag', () => {
|
||||
const argv = normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng'])
|
||||
expect(getServeOptions(argv).pairingAddress).toBe('--no-pairng')
|
||||
})
|
||||
|
||||
it('shares cross-flag validation with the CLI-form launch', () => {
|
||||
const argv = normalizeServeModeArgv([
|
||||
'/opt/orca/orca-ide',
|
||||
'serve',
|
||||
'--no-pairing',
|
||||
'--mobile-pairing'
|
||||
])
|
||||
expect(() => getServeOptions(argv)).toThrow(/either --mobile-pairing or --no-pairing/i)
|
||||
})
|
||||
|
||||
it('rejects recipe JSON without runtime pairing and a project root', () => {
|
||||
expect(() =>
|
||||
getServeOptions([
|
||||
'/AppRun',
|
||||
'--serve',
|
||||
'--serve-recipe-json',
|
||||
'--serve-no-pairing',
|
||||
'--serve-project-root',
|
||||
'/tmp/repo'
|
||||
])
|
||||
).toThrow(/requires runtime pairing.*--no-pairing/i)
|
||||
expect(() => getServeOptions(['/AppRun', '--serve', '--serve-recipe-json'])).toThrow(
|
||||
/requires --project-root/i
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a security-shaped typo while allowing Chromium switches', () => {
|
||||
const normalized = normalizeServeModeArgv(['/AppRun', 'serve', '--no-pairng'])
|
||||
expect(() => getServeOptions(normalized)).toThrow(/Unknown flag --no-pairng.*--no-pairing/i)
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--disable-gpu', '--disable-features=Vulkan'])
|
||||
.noPairing
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('still rejects a flag-shaped space value, as the CLI does', () => {
|
||||
expect(() =>
|
||||
getServeOptions(['/AppRun', '--serve', '--serve-pairing-address', '--no-pairng'])
|
||||
).toThrow(/Unknown flag --no-pairng.*--no-pairing/i)
|
||||
})
|
||||
|
||||
it('ignores serve-looking arguments after the terminator', () => {
|
||||
expect(
|
||||
getServeOptions(['/AppRun', '--serve', '--', '--serve-port', '1', '--serve-no-pairing'])
|
||||
).toEqual({
|
||||
json: false,
|
||||
pairingAddress: null,
|
||||
noPairing: false,
|
||||
mobilePairing: false,
|
||||
recipeJson: false,
|
||||
projectRoot: null
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a port value', () => {
|
||||
expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port'])).toThrow(
|
||||
'Missing value for --serve-port.'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['', '--serve-json', '--'])('rejects an unusable port value %j', (value) => {
|
||||
expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port', value])).toThrow(
|
||||
'Missing value for --serve-port.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
getServeFlagTypoError,
|
||||
getServeOptionValidationError
|
||||
} from '../../shared/serve-option-validation'
|
||||
|
||||
export type ServeOptions = {
|
||||
json: boolean
|
||||
wsPort?: number
|
||||
pairingAddress: string | null
|
||||
noPairing: boolean
|
||||
mobilePairing: boolean
|
||||
recipeJson: boolean
|
||||
projectRoot: string | null
|
||||
}
|
||||
|
||||
function optionsBeforeTerminator(argv: readonly string[]): readonly string[] {
|
||||
const terminatorIndex = argv.indexOf('--')
|
||||
return terminatorIndex === -1 ? argv : argv.slice(0, terminatorIndex)
|
||||
}
|
||||
|
||||
function optionName(token: string): string {
|
||||
const equalsIndex = token.indexOf('=')
|
||||
return equalsIndex === -1 ? token : token.slice(0, equalsIndex)
|
||||
}
|
||||
|
||||
function lastValueOccurrence(
|
||||
argv: readonly string[],
|
||||
flags: readonly string[]
|
||||
): string | null | undefined {
|
||||
const flagNames = new Set(flags)
|
||||
let value: string | null | undefined
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index]!
|
||||
const name = optionName(token)
|
||||
if (!flagNames.has(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const equalsIndex = token.indexOf('=')
|
||||
if (equalsIndex !== -1) {
|
||||
const assigned = token.slice(equalsIndex + 1)
|
||||
value = assigned || null
|
||||
continue
|
||||
}
|
||||
|
||||
const next = argv[index + 1]
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
value = next || null
|
||||
index += 1
|
||||
} else {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function valueAfter(
|
||||
argv: readonly string[],
|
||||
flags: readonly string[],
|
||||
required: boolean,
|
||||
displayFlag: string
|
||||
): string | null {
|
||||
const value = lastValueOccurrence(argv, flags)
|
||||
if (value === undefined || value === null) {
|
||||
if (required && value !== undefined) {
|
||||
throw new Error(`Missing value for ${displayFlag}.`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function lastBooleanValue(argv: readonly string[], flags: readonly string[]): boolean {
|
||||
const flagNames = new Set(flags)
|
||||
let value = false
|
||||
for (const token of argv) {
|
||||
const name = optionName(token)
|
||||
if (!flagNames.has(name)) {
|
||||
continue
|
||||
}
|
||||
// CLI boolean flags are true only in bare form; `--flag=...` is a string value.
|
||||
value = !token.includes('=')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function hasFlag(argv: readonly string[], flags: readonly string[]): boolean {
|
||||
const flagNames = new Set(flags)
|
||||
return argv.some((token) => flagNames.has(optionName(token)))
|
||||
}
|
||||
|
||||
export function getServeOptions(argv: readonly string[]): ServeOptions {
|
||||
const optionsArgv = optionsBeforeTerminator(argv)
|
||||
const typoError = getServeFlagTypoError(optionsArgv)
|
||||
if (typoError) {
|
||||
throw new Error(typoError)
|
||||
}
|
||||
|
||||
const rawPort = valueAfter(optionsArgv, ['--serve-port', '--port'], true, '--serve-port')
|
||||
let wsPort: number | undefined
|
||||
if (rawPort) {
|
||||
const parsedPort = Number(rawPort)
|
||||
if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
|
||||
throw new Error(`Invalid --serve-port value: ${rawPort}`)
|
||||
}
|
||||
wsPort = parsedPort
|
||||
}
|
||||
|
||||
const options: ServeOptions = {
|
||||
// The CLI uses `flags.has('json')`, so even `--json=false` enables JSON output.
|
||||
json: hasFlag(optionsArgv, ['--serve-json', '--json']),
|
||||
...(wsPort !== undefined ? { wsPort } : {}),
|
||||
pairingAddress: valueAfter(
|
||||
optionsArgv,
|
||||
['--serve-pairing-address', '--pairing-address'],
|
||||
false,
|
||||
'--serve-pairing-address'
|
||||
),
|
||||
noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']),
|
||||
mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']),
|
||||
recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']),
|
||||
projectRoot: valueAfter(
|
||||
optionsArgv,
|
||||
['--serve-project-root', '--project-root'],
|
||||
false,
|
||||
'--serve-project-root'
|
||||
)
|
||||
}
|
||||
const validationError = getServeOptionValidationError(options)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
if (m === 0) {
|
||||
return n
|
||||
}
|
||||
if (n === 0) {
|
||||
return m
|
||||
}
|
||||
let previous = Array.from({ length: n + 1 }, (_, index) => index)
|
||||
let current = Array.from({ length: n + 1 }, () => 0)
|
||||
for (let i = 1; i <= m; i += 1) {
|
||||
current[0] = i
|
||||
for (let j = 1; j <= n; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost)
|
||||
}
|
||||
const swap = previous
|
||||
previous = current
|
||||
current = swap
|
||||
}
|
||||
return previous[n]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getServeFlagTypoError, getServeOptionValidationError } from './serve-option-validation'
|
||||
|
||||
const validOptions = {
|
||||
noPairing: false,
|
||||
mobilePairing: false,
|
||||
recipeJson: false,
|
||||
projectRoot: null
|
||||
}
|
||||
|
||||
describe('getServeOptionValidationError', () => {
|
||||
it('accepts compatible options', () => {
|
||||
expect(getServeOptionValidationError(validOptions)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ noPairing: true, mobilePairing: true }, /either --mobile-pairing or --no-pairing/i],
|
||||
[
|
||||
{ recipeJson: true, noPairing: true, projectRoot: '/tmp/repo' },
|
||||
/requires runtime pairing.*--no-pairing/i
|
||||
],
|
||||
[
|
||||
{ recipeJson: true, mobilePairing: true, projectRoot: '/tmp/repo' },
|
||||
/requires runtime pairing.*--mobile-pairing/i
|
||||
],
|
||||
[{ recipeJson: true }, /requires --project-root/i]
|
||||
])('rejects incompatible options', (override, expected) => {
|
||||
expect(
|
||||
getServeOptionValidationError({ ...validOptions, ...override } as typeof validOptions)
|
||||
).toMatch(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getServeFlagTypoError', () => {
|
||||
it('accepts exact serve flags and arbitrary Chromium switches', () => {
|
||||
expect(
|
||||
getServeFlagTypoError([
|
||||
'/opt/orca/orca-ide',
|
||||
'--serve',
|
||||
'--serve-no-pairing',
|
||||
'--disable-gpu',
|
||||
'--disable-features=Vulkan',
|
||||
'--no-parent'
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['--no-pair', '--no-pairng', '--no-paring', '--mobile-pairng'])(
|
||||
'suggests the intended pairing flag for %s',
|
||||
(flag) => {
|
||||
expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', flag])).toMatch(
|
||||
/Unknown flag .*Did you mean --(?:no-pairing|mobile-pairing)\?/i
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not reinterpret tokens after --', () => {
|
||||
expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', '--', '--no-pairng'])).toBeNull()
|
||||
})
|
||||
|
||||
it('does not inspect an equals-form value as a flag', () => {
|
||||
expect(
|
||||
getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address=--no-pairng'])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps flag-shaped space values subject to typo validation', () => {
|
||||
expect(
|
||||
getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address', '--no-pairng'])
|
||||
).toMatch(/Unknown flag --no-pairng.*--no-pairing/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { levenshtein } from './edit-distance'
|
||||
|
||||
export type ServeOptionValidationInput = {
|
||||
noPairing: boolean
|
||||
mobilePairing: boolean
|
||||
recipeJson: boolean
|
||||
projectRoot: string | null | undefined
|
||||
}
|
||||
|
||||
export function getServeOptionValidationError(options: ServeOptionValidationInput): string | null {
|
||||
if (options.noPairing && options.mobilePairing) {
|
||||
return 'Use either --mobile-pairing or --no-pairing, not both.'
|
||||
}
|
||||
if (options.recipeJson && options.noPairing) {
|
||||
return 'Recipe JSON output requires runtime pairing; remove --no-pairing.'
|
||||
}
|
||||
if (options.recipeJson && options.mobilePairing) {
|
||||
return 'Recipe JSON output requires runtime pairing; remove --mobile-pairing.'
|
||||
}
|
||||
if (options.recipeJson && !options.projectRoot) {
|
||||
return 'Recipe JSON output requires --project-root.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SERVE_SECURITY_FLAG_NAMES = [
|
||||
'--no-pairing',
|
||||
'--serve-no-pairing',
|
||||
'--mobile-pairing',
|
||||
'--serve-mobile-pairing',
|
||||
'--recipe-json',
|
||||
'--serve-recipe-json',
|
||||
'--pairing-address',
|
||||
'--serve-pairing-address'
|
||||
] as const
|
||||
|
||||
const SERVE_VALUE_FLAG_NAMES = new Set([
|
||||
'--port',
|
||||
'--serve-port',
|
||||
'--pairing-address',
|
||||
'--serve-pairing-address',
|
||||
'--project-root',
|
||||
'--serve-project-root',
|
||||
'--pairing-code',
|
||||
'--environment'
|
||||
])
|
||||
|
||||
function flagName(token: string): string {
|
||||
const equalsIndex = token.indexOf('=')
|
||||
return equalsIndex === -1 ? token : token.slice(0, equalsIndex)
|
||||
}
|
||||
|
||||
/** Reject only near-miss pairing flags; Electron/Chromium switches stay open-ended. */
|
||||
export function getServeFlagTypoError(argv: readonly string[]): string | null {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index]!
|
||||
if (token === '--') {
|
||||
break
|
||||
}
|
||||
if (!token.startsWith('--')) {
|
||||
continue
|
||||
}
|
||||
const name = flagName(token)
|
||||
let suggestion: string | null = null
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (const candidate of SERVE_SECURITY_FLAG_NAMES) {
|
||||
const distance = levenshtein(name, candidate)
|
||||
const maxDistance = candidate.startsWith(name) ? 3 : 2
|
||||
if (distance > 0 && distance <= maxDistance && distance < bestDistance) {
|
||||
suggestion = candidate
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
if (suggestion) {
|
||||
return `Unknown flag ${name}. Did you mean ${suggestion}?`
|
||||
}
|
||||
|
||||
// A value that is not flag-shaped belongs to the preceding known value flag.
|
||||
// A `--`-prefixed space token remains a flag, matching parseArgs; use `=` when
|
||||
// a value itself starts with `--`.
|
||||
if (!token.includes('=') && SERVE_VALUE_FLAG_NAMES.has(name)) {
|
||||
const value = argv[index + 1]
|
||||
if (value !== undefined && !value.startsWith('--')) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user