fix(serve): validate in-process serve options before startup (#17683)

This commit is contained in:
Neil
2026-09-01 23:11:38 -07:00
parent 2e18d9bf62
commit e663823f64
13 changed files with 599 additions and 137 deletions
+3 -24
View File
@@ -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
View File
@@ -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
+10 -8
View File
@@ -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}'`)
+19 -30
View File
@@ -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(
+50
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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([
+9 -3
View File
@@ -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)
+161
View File
@@ -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.'
)
})
})
+134
View File
@@ -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
}
+23
View File
@@ -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)
})
})
+89
View File
@@ -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
}