Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ test('tap dispatches as press with positionals and flags preserved', async () =>
assert.deepEqual(result.calls[0]?.positionals, ['@e3']);
});

test('relaunch dispatches as open with the relaunch flag injected', async () => {
const result = await runCliCapture(['relaunch', 'com.example.app', '--json']);
assert.doesNotMatch(result.stderr, /Unknown command/);
// Canonicalization: the daemon call must record open, never relaunch.
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.command, 'open');
assert.deepEqual(result.calls[0]?.positionals, ['com.example.app']);
assert.equal(result.calls[0]?.flags?.relaunch, true);
});

test('launch dispatches as a plain open without forcing a relaunch', async () => {
const result = await runCliCapture(['launch', 'com.example.app', '--json']);
assert.doesNotMatch(result.stderr, /Unknown command/);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.command, 'open');
assert.deepEqual(result.calls[0]?.positionals, ['com.example.app']);
assert.notEqual(result.calls[0]?.flags?.relaunch, true);
});

// From #1052 (credit: @vku2018): the alias must compose with the bare-ref
// hint — `tap e3` normalizes to press, then gets the @e3 suggestion.
test('tap with a bare ref gets the @ref hint, not an unknown-command error', async () => {
Expand Down
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseRawArgs, usage, usageForCommand } from './cli/parser/args.ts';
import { suggestCommandFor } from './cli/parser/command-suggestions.ts';
import { asAppError, AppError, normalizeError } from './kernel/errors.ts';
import { printHumanError, printJson } from './utils/output.ts';
import { readVersion } from './utils/version.ts';
Expand Down Expand Up @@ -146,7 +147,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
process.stdout.write(commandHelp);
process.exit(0);
}
printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`));
printHumanError(new AppError('INVALID_ARGS', formatUnknownHelpTargetMessage(helpTarget)));
process.stdout.write(`${await usage()}\n`);
process.exit(1);
}
Expand Down Expand Up @@ -462,6 +463,13 @@ function isDebugRequested(argv: string[]): boolean {
}
}

function formatUnknownHelpTargetMessage(helpTarget: string): string {
const hint = suggestCommandFor(helpTarget);
return hint
? `Unknown command: ${helpTarget}. Did you mean ${hint}?`
: `Unknown command: ${helpTarget}`;
}

function formatUnhandledCommandMessage(command: string): string {
if (isKnownCliCommandName(command)) {
// Registered-but-unhandled means catalog/dispatch drift — make it visible
Expand Down
48 changes: 48 additions & 0 deletions src/cli/parser/__tests__/args-parse-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,3 +891,51 @@ test('apps defaults to user-installed filter and allows overrides', () => {
/Unknown flag: --user-installed/,
);
});

// `relaunch` and `launch` are true command aliases for open (like tap ->
// press): normalization is pre-dispatch, so command identity stays `open`
// everywhere downstream (daemon requests, telemetry).
test('relaunch is a true alias for open with --relaunch injected', () => {
const parsed = parseArgs(['relaunch', 'com.example.app'], { strictFlags: true });
assert.equal(parsed.command, 'open');
assert.deepEqual(parsed.positionals, ['com.example.app']);
assert.equal(parsed.flags.relaunch, true);
});

test('launch is a true alias for a plain open without --relaunch', () => {
// Aliasing launch to a forced restart would silently destroy app state.
const parsed = parseArgs(['launch', 'com.example.app'], { strictFlags: true });
assert.equal(parsed.command, 'open');
assert.deepEqual(parsed.positionals, ['com.example.app']);
assert.equal(parsed.flags.relaunch, undefined);
});

test('relaunch with an explicit --relaunch stays idempotent', () => {
const parsed = parseArgs(['relaunch', 'com.example.app', '--relaunch'], { strictFlags: true });
assert.equal(parsed.command, 'open');
assert.deepEqual(parsed.positionals, ['com.example.app']);
assert.equal(parsed.flags.relaunch, true);
});

test('command aliases normalize case-insensitively', () => {
const relaunch = parseArgs(['RELAUNCH', 'com.example.app'], { strictFlags: true });
assert.equal(relaunch.command, 'open');
assert.equal(relaunch.flags.relaunch, true);

const launch = parseArgs(['Launch', 'com.example.app'], { strictFlags: true });
assert.equal(launch.command, 'open');
assert.equal(launch.flags.relaunch, undefined);

const tap = parseArgs(['TAP', '@e3'], { strictFlags: true });
assert.equal(tap.command, 'press');
assert.deepEqual(tap.positionals, ['@e3']);
});

test('relaunch passes non-command args through to open validation untouched', () => {
// URL targets parse fine here; the daemon's existing "open --relaunch does
// not support URL targets" guidance owns the semantic rejection.
const parsed = parseArgs(['relaunch', 'rne://url'], { strictFlags: true });
assert.equal(parsed.command, 'open');
assert.deepEqual(parsed.positionals, ['rne://url']);
assert.equal(parsed.flags.relaunch, true);
});
192 changes: 192 additions & 0 deletions src/cli/parser/__tests__/command-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { isKnownCliCommandName } from '../../../command-catalog.ts';
import { keyboardCliReader } from '../../../commands/system/index.ts';
import { AppError } from '../../../kernel/errors.ts';
import { parseArgs } from '../args.ts';
import type { CliFlags } from '../cli-flags.ts';
import { listCommandAliasSuggestionEntries, suggestCommandFor } from '../command-suggestions.ts';

// Guards against the curated alias map drifting to a command that no longer
// exists (renamed, removed, gated) in the live command registry.
test('every curated alias suggestion target resolves to a registered command', () => {
for (const [guess, suggestion] of listCommandAliasSuggestionEntries()) {
assert.ok(
isKnownCliCommandName(suggestion.command),
`alias suggestion for "${guess}" points at unregistered command "${suggestion.command}"`,
);
assert.ok(
suggestion.example === suggestion.command ||
suggestion.example.startsWith(`${suggestion.command} `),
`alias suggestion example for "${guess}" ("${suggestion.example}") must start with its command ("${suggestion.command}")`,
);
}
});

// Guards the full example shapes, not just the leading command token: every
// example must parse as a valid invocation (placeholders substituted), so a
// renamed flag (e.g. open --relaunch) or a dropped subcommand fails here.
test('every curated alias suggestion example parses as a valid invocation', () => {
for (const [guess, suggestion] of listCommandAliasSuggestionEntries()) {
const tokens = suggestion.example
.split(' ')
.map((token) => (token.startsWith('<') ? 'com.example.app' : token));
assert.doesNotThrow(
() => parseArgs(tokens, { strictFlags: true }),
`alias suggestion example for "${guess}" ("${suggestion.example}") no longer parses`,
);
}
});

test('the keyboard dismiss example uses a real keyboard action', () => {
const baseFlags: CliFlags = { json: false, help: false, version: false };
assert.doesNotThrow(() => keyboardCliReader(['dismiss'], baseFlags));
});

// `launch`/`relaunch` (and `tap`) are true aliases normalized before the
// unknown-command check, so they must never appear in the suggestion map —
// a stale entry there would be dead code masking the alias.
test('true aliases are not listed in the curated suggestion map', () => {
const guesses = new Set(listCommandAliasSuggestionEntries().map(([guess]) => guess));
for (const alias of ['launch', 'relaunch', 'tap']) {
assert.ok(!guesses.has(alias), `"${alias}" is a true alias and must not be a suggestion`);
}
});

for (const guess of ['start', 'restart']) {
test(`${guess} suggests the canonical open --relaunch shape`, () => {
assert.throws(
() => parseArgs([guess, 'com.example.app']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('Did you mean open <app> --relaunch?'),
);
});
}

test('touch suggests press', () => {
assert.throws(
() => parseArgs(['touch', '100', '200']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: touch. Did you mean press?',
);
});

test('dismiss suggests keyboard dismiss', () => {
assert.throws(
() => parseArgs(['dismiss']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: dismiss. Did you mean keyboard dismiss?',
);
});

test('input and settext suggest fill', () => {
for (const guess of ['input', 'settext', 'entertext']) {
assert.throws(
() => parseArgs([guess, '@e1', 'hello']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === `Unknown command: ${guess}. Did you mean fill?`,
);
}
});

test('screencap and capture suggest screenshot', () => {
for (const guess of ['screencap', 'capture']) {
assert.throws(
() => parseArgs([guess]),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === `Unknown command: ${guess}. Did you mean screenshot?`,
);
}
});

test('curated suggestions are case-insensitive', () => {
assert.equal(suggestCommandFor('RESTART'), 'open <app> --relaunch');
assert.equal(suggestCommandFor('Restart'), 'open <app> --relaunch');
assert.equal(suggestCommandFor('Touch'), 'press');
assert.equal(suggestCommandFor('DISMISS'), 'keyboard dismiss');
});

test('known command names in the wrong case suggest their lowercase form', () => {
assert.equal(suggestCommandFor('OPEN'), 'open');
assert.equal(suggestCommandFor('Press'), 'press');
});

test('nonsense command names fall back to nearest-name suggestion or a plain error, never a crash', () => {
assert.throws(
() => parseArgs(['frobnicate']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: frobnicate',
);
});

test('near-miss typos of real commands are suggested via edit distance', () => {
assert.throws(
() => parseArgs(['presss', '100', '200']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: presss. Did you mean press?',
);
});

test('a prefix match wins outright instead of bundling weaker edit-distance ties', () => {
// Without the prefix rule, `clos` would suggest "one of: close, logs".
assert.throws(
() => parseArgs(['clos']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: clos. Did you mean close?',
);
});

test('1-2 character tokens never get a nearest-name suggestion', () => {
// `ls` is one edit from `is`, but suggesting `is` would be a false positive.
assert.throws(
() => parseArgs(['ls']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown command: ls',
);
});

test('suggestCommandFor never throws for arbitrary input', () => {
const inputs = ['', ' ', '@#$%', 'a'.repeat(200), 'RELAUNCH', 'Relaunch'];
for (const input of inputs) {
assert.doesNotThrow(() => suggestCommandFor(input));
}
});

test('unknown flag that looks like an app/bundle id hints at the open positional', () => {
assert.throws(
() => parseArgs(['launch', '--bundle-id', 'com.example.app']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message ===
'Unknown flag: --bundle-id. The app or bundle id is a positional argument, e.g. open <app> --relaunch.',
);
});

test('unrelated unknown flags are unaffected', () => {
assert.throws(
() => parseArgs(['press', '100', '200', '--not-a-real-flag']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown flag: --not-a-real-flag',
);
});
Loading
Loading