Skip to content

Commit 57062c8

Browse files
authored
Refactor CLI command schema and strict flag validation (#57)
* Refactor CLI command schema and strict flag validation * Tighten parser positional handling and ref flag validation * Fix strict no-command flags and negative positional parsing * simplify command schema internals and ref flag guards
1 parent 64d27c1 commit 57062c8

8 files changed

Lines changed: 935 additions & 287 deletions

File tree

src/cli.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseArgs, usage } from './utils/args.ts';
1+
import { parseArgs, toDaemonFlags, usage } from './utils/args.ts';
22
import { asAppError, AppError } from './utils/errors.ts';
33
import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
44
import { readVersion } from './utils/version.ts';
@@ -10,6 +10,9 @@ import path from 'node:path';
1010

1111
export async function runCli(argv: string[]): Promise<void> {
1212
const parsed = parseArgs(argv);
13+
for (const warning of parsed.warnings) {
14+
process.stderr.write(`Warning: ${warning}\n`);
15+
}
1316

1417
if (parsed.flags.version) {
1518
process.stdout.write(`${readVersion()}\n`);
@@ -22,6 +25,7 @@ export async function runCli(argv: string[]): Promise<void> {
2225
}
2326

2427
const { command, positionals, flags } = parsed;
28+
const daemonFlags = toDaemonFlags(flags);
2529
const sessionName = flags.session ?? process.env.AGENT_DEVICE_SESSION ?? 'default';
2630
const logTailStopper = flags.verbose && !flags.json ? startDaemonLogTail() : null;
2731
try {
@@ -34,7 +38,7 @@ export async function runCli(argv: string[]): Promise<void> {
3438
session: sessionName,
3539
command: 'session_list',
3640
positionals: [],
37-
flags: {},
41+
flags: daemonFlags,
3842
});
3943
if (!response.ok) throw new AppError(response.error.code as any, response.error.message);
4044
if (flags.json) printJson({ success: true, data: response.data ?? {} });
@@ -47,7 +51,7 @@ export async function runCli(argv: string[]): Promise<void> {
4751
session: sessionName,
4852
command: command!,
4953
positionals,
50-
flags,
54+
flags: daemonFlags,
5155
});
5256

5357
if (response.ok) {

src/core/capabilities.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo):
5050
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
5151
return byPlatform[kind] === true;
5252
}
53+
54+
export function listCapabilityCommands(): string[] {
55+
return Object.keys(COMMAND_CAPABILITY_MATRIX).sort();
56+
}

src/core/dispatch.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,9 @@ import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
1717
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
1818
import { setIosSetting } from '../platforms/ios/index.ts';
1919
import type { RawSnapshotNode } from '../utils/snapshot.ts';
20+
import type { CliFlags } from '../utils/command-schema.ts';
2021

21-
export type CommandFlags = {
22-
session?: string;
23-
platform?: 'ios' | 'android';
24-
device?: string;
25-
udid?: string;
26-
serial?: string;
27-
out?: string;
28-
activity?: string;
29-
verbose?: boolean;
30-
snapshotInteractiveOnly?: boolean;
31-
snapshotCompact?: boolean;
32-
snapshotDepth?: number;
33-
snapshotScope?: string;
34-
snapshotRaw?: boolean;
35-
snapshotBackend?: 'ax' | 'xctest';
36-
saveScript?: boolean;
37-
relaunch?: boolean;
38-
noRecord?: boolean;
39-
appsFilter?: 'launchable' | 'user-installed' | 'all';
40-
appsMetadata?: boolean;
41-
count?: number;
42-
intervalMs?: number;
43-
holdMs?: number;
44-
jitterPx?: number;
45-
pauseMs?: number;
46-
pattern?: 'one-way' | 'ping-pong';
47-
replayUpdate?: boolean;
48-
};
22+
export type CommandFlags = Omit<CliFlags, 'json' | 'help' | 'version'>;
4923

5024
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
5125
const selector = {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { unsupportedRefSnapshotFlags } from '../interaction.ts';
4+
5+
test('unsupportedRefSnapshotFlags returns unsupported snapshot flags for @ref flows', () => {
6+
const unsupported = unsupportedRefSnapshotFlags({
7+
snapshotDepth: 2,
8+
snapshotScope: 'Login',
9+
snapshotRaw: true,
10+
snapshotBackend: 'ax',
11+
});
12+
assert.deepEqual(unsupported, ['--depth', '--scope', '--raw', '--backend']);
13+
});
14+
15+
test('unsupportedRefSnapshotFlags returns empty when no ref-unsupported flags are present', () => {
16+
const unsupported = unsupportedRefSnapshotFlags({
17+
platform: 'ios',
18+
session: 'default',
19+
verbose: true,
20+
});
21+
assert.deepEqual(unsupported, []);
22+
});

src/daemon/handlers/interaction.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export async function handleInteractionCommands(params: {
4141
}
4242
const refInput = req.positionals?.[0] ?? '';
4343
if (refInput.startsWith('@')) {
44+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('click', req.flags);
45+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
4446
if (!session.snapshot) {
4547
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
4648
}
@@ -126,6 +128,8 @@ export async function handleInteractionCommands(params: {
126128
if (command === 'fill') {
127129
const session = sessionStore.get(sessionName);
128130
if (req.positionals?.[0]?.startsWith('@')) {
131+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags);
132+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
129133
if (!session?.snapshot) {
130134
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
131135
}
@@ -258,6 +262,8 @@ export async function handleInteractionCommands(params: {
258262
}
259263
const refInput = req.positionals?.[1] ?? '';
260264
if (refInput.startsWith('@')) {
265+
const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags);
266+
if (invalidRefFlagsResponse) return invalidRefFlagsResponse;
261267
if (!session.snapshot) {
262268
return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
263269
}
@@ -511,3 +517,34 @@ async function captureSnapshotForSession(
511517
sessionStore.set(session.name, session);
512518
return session.snapshot;
513519
}
520+
521+
const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [
522+
['snapshotDepth', '--depth'],
523+
['snapshotScope', '--scope'],
524+
['snapshotRaw', '--raw'],
525+
['snapshotBackend', '--backend'],
526+
];
527+
528+
function refSnapshotFlagGuardResponse(
529+
command: 'click' | 'fill' | 'get',
530+
flags: CommandFlags | undefined,
531+
): DaemonResponse | null {
532+
const unsupported = unsupportedRefSnapshotFlags(flags);
533+
if (unsupported.length === 0) return null;
534+
return {
535+
ok: false,
536+
error: {
537+
code: 'INVALID_ARGS',
538+
message: `${command} @ref does not support ${unsupported.join(', ')}.`,
539+
},
540+
};
541+
}
542+
543+
export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] {
544+
if (!flags) return [];
545+
const unsupported: string[] = [];
546+
for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) {
547+
if (flags[key] !== undefined) unsupported.push(label);
548+
}
549+
return unsupported;
550+
}

src/utils/__tests__/args.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import { parseArgs, usage } from '../args.ts';
4+
import { AppError } from '../errors.ts';
5+
import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
6+
import { listCapabilityCommands } from '../../core/capabilities.ts';
47

58
test('parseArgs recognizes --relaunch', () => {
69
const parsed = parseArgs(['open', 'settings', '--relaunch']);
@@ -61,6 +64,107 @@ test('parseArgs rejects invalid swipe pattern', () => {
6164

6265
test('usage includes --relaunch flag', () => {
6366
assert.match(usage(), /--relaunch/);
67+
assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
68+
assert.match(usage(), /--metadata/);
69+
});
70+
71+
test('every capability command has a parser schema entry', () => {
72+
const schemaCommands = new Set(getCliCommandNames());
73+
for (const command of listCapabilityCommands()) {
74+
assert.equal(schemaCommands.has(command), true, `Missing schema for command: ${command}`);
75+
}
76+
});
77+
78+
test('schema capability mappings match capability source-of-truth', () => {
79+
assert.deepEqual(getSchemaCapabilityKeys(), listCapabilityCommands());
80+
});
81+
82+
test('compat mode warns and strips unsupported pilot-command flags', () => {
83+
const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
84+
assert.equal(parsed.command, 'press');
85+
assert.equal(parsed.flags.snapshotDepth, undefined);
86+
assert.equal(parsed.warnings.length, 1);
87+
assert.match(parsed.warnings[0], /not supported for command press/);
88+
});
89+
90+
test('strict mode rejects unsupported pilot-command flags', () => {
91+
assert.throws(
92+
() => parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: true }),
93+
(error) =>
94+
error instanceof AppError &&
95+
error.code === 'INVALID_ARGS' &&
96+
error.message.includes('not supported for command press'),
97+
);
98+
});
99+
100+
test('snapshot command accepts command-specific flags', () => {
101+
const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { strictFlags: true });
102+
assert.equal(parsed.command, 'snapshot');
103+
assert.equal(parsed.flags.snapshotInteractiveOnly, true);
104+
assert.equal(parsed.flags.snapshotCompact, true);
105+
assert.equal(parsed.flags.snapshotDepth, 3);
106+
assert.equal(parsed.flags.snapshotScope, 'Login');
107+
});
108+
109+
test('unknown short flags are rejected', () => {
110+
assert.throws(
111+
() => parseArgs(['press', '10', '20', '-x'], { strictFlags: true }),
112+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Unknown flag: -x',
113+
);
114+
});
115+
116+
test('negative numeric positionals are accepted without -- separator', () => {
117+
const typed = parseArgs(['type', '-123'], { strictFlags: true });
118+
assert.equal(typed.command, 'type');
119+
assert.deepEqual(typed.positionals, ['-123']);
120+
121+
const typedMulti = parseArgs(['type', '-123', '-456'], { strictFlags: true });
122+
assert.equal(typedMulti.command, 'type');
123+
assert.deepEqual(typedMulti.positionals, ['-123', '-456']);
124+
125+
const pressed = parseArgs(['press', '-10', '20'], { strictFlags: true });
126+
assert.equal(pressed.command, 'press');
127+
assert.deepEqual(pressed.positionals, ['-10', '20']);
128+
});
129+
130+
test('command-specific flags without command fail in strict mode', () => {
131+
assert.throws(
132+
() => parseArgs(['--depth', '3'], { strictFlags: true }),
133+
(error) =>
134+
error instanceof AppError &&
135+
error.code === 'INVALID_ARGS' &&
136+
error.message.includes('requires a command that supports it'),
137+
);
138+
});
139+
140+
test('command-specific flags without command warn and strip in compat mode', () => {
141+
const parsed = parseArgs(['--depth', '3'], { strictFlags: false });
142+
assert.equal(parsed.command, null);
143+
assert.equal(parsed.flags.snapshotDepth, undefined);
144+
assert.equal(parsed.warnings.length, 1);
145+
assert.match(parsed.warnings[0], /requires a command that supports/);
146+
});
147+
148+
test('all commands participate in strict command-flag validation', () => {
149+
assert.throws(
150+
() => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),
151+
(error) =>
152+
error instanceof AppError &&
153+
error.code === 'INVALID_ARGS' &&
154+
error.message.includes('not supported for command open'),
155+
);
156+
});
157+
158+
test('invalid enum/range errors are deterministic', () => {
159+
assert.throws(
160+
() => parseArgs(['snapshot', '--backend', 'foo'], { strictFlags: true }),
161+
(error) =>
162+
error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid backend: foo',
163+
);
164+
assert.throws(
165+
() => parseArgs(['snapshot', '--depth', '-1'], { strictFlags: true }),
166+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid depth: -1',
167+
);
64168
});
65169

66170
test('usage includes swipe and press series options', () => {

0 commit comments

Comments
 (0)