Skip to content

Commit 98cd033

Browse files
committed
Refactor CLI command schema and strict flag validation
1 parent 64d27c1 commit 98cd033

6 files changed

Lines changed: 982 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 { DaemonFlags } 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 = DaemonFlags;
4923

5024
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
5125
const selector = {

src/utils/__tests__/args.test.ts

Lines changed: 68 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 } 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,71 @@ 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('compat mode warns and strips unsupported pilot-command flags', () => {
79+
const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
80+
assert.equal(parsed.command, 'press');
81+
assert.equal(parsed.flags.snapshotDepth, undefined);
82+
assert.equal(parsed.warnings.length, 1);
83+
assert.match(parsed.warnings[0], /not supported for command press/);
84+
});
85+
86+
test('strict mode rejects unsupported pilot-command flags', () => {
87+
assert.throws(
88+
() => parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: true }),
89+
(error) =>
90+
error instanceof AppError &&
91+
error.code === 'INVALID_ARGS' &&
92+
error.message.includes('not supported for command press'),
93+
);
94+
});
95+
96+
test('snapshot command accepts command-specific flags', () => {
97+
const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { strictFlags: true });
98+
assert.equal(parsed.command, 'snapshot');
99+
assert.equal(parsed.flags.snapshotInteractiveOnly, true);
100+
assert.equal(parsed.flags.snapshotCompact, true);
101+
assert.equal(parsed.flags.snapshotDepth, 3);
102+
assert.equal(parsed.flags.snapshotScope, 'Login');
103+
});
104+
105+
test('unknown short flags are rejected', () => {
106+
assert.throws(
107+
() => parseArgs(['press', '10', '20', '-x'], { strictFlags: true }),
108+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Unknown flag: -x',
109+
);
110+
});
111+
112+
test('all commands participate in strict command-flag validation', () => {
113+
assert.throws(
114+
() => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),
115+
(error) =>
116+
error instanceof AppError &&
117+
error.code === 'INVALID_ARGS' &&
118+
error.message.includes('not supported for command open'),
119+
);
120+
});
121+
122+
test('invalid enum/range errors are deterministic', () => {
123+
assert.throws(
124+
() => parseArgs(['snapshot', '--backend', 'foo'], { strictFlags: true }),
125+
(error) =>
126+
error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid backend: foo',
127+
);
128+
assert.throws(
129+
() => parseArgs(['snapshot', '--depth', '-1'], { strictFlags: true }),
130+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid depth: -1',
131+
);
64132
});
65133

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

0 commit comments

Comments
 (0)