diff --git a/src/cli-client-commands.ts b/src/cli-client-commands.ts index f462196e..74f9d78e 100644 --- a/src/cli-client-commands.ts +++ b/src/cli-client-commands.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { CliFlags } from './utils/command-schema.ts'; +import type { CommandName } from './core/command-names.ts'; import { formatScreenshotDiffText, formatSnapshotText, printJson } from './utils/output.ts'; import { AppError } from './utils/errors.ts'; import { @@ -247,7 +248,7 @@ const clientCommandHandlers: Partial> = { } return true; }, -}; +} satisfies Partial>; async function runDeployCommand( command: 'install' | 'reinstall', diff --git a/src/client.ts b/src/client.ts index 3f6d145c..58fa1b59 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime } from './client-metro.ts'; import { AppError } from './utils/errors.ts'; +import type { CommandName } from './core/command-names.ts'; import { buildFlags, buildMeta, @@ -44,7 +45,7 @@ export function createAgentDeviceClient( const transport = deps.transport ?? sendToDaemon; const execute = async ( - command: string, + command: CommandName, positionals: string[] = [], options: InternalRequestOptions = {}, ): Promise> => { diff --git a/src/core/batch.ts b/src/core/batch.ts index 830bcac8..8b3f5c7b 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,11 +1,12 @@ import { AppError } from '../utils/errors.ts'; import type { BatchStep, CommandFlags } from './dispatch.ts'; +import type { CommandName } from './command-names.ts'; export const DEFAULT_BATCH_MAX_STEPS = 100; const BATCH_BLOCKED_COMMANDS = new Set(['batch', 'replay']); export type NormalizedBatchStep = { - command: string; + command: CommandName; positionals: string[]; flags: Partial; runtime?: unknown; @@ -13,7 +14,7 @@ export type NormalizedBatchStep = { export type BatchStepResult = { step: number; - command: string; + command: CommandName; ok: true; data: Record; durationMs: number; @@ -82,7 +83,7 @@ export function validateAndNormalizeBatchSteps( throw new AppError('INVALID_ARGS', `Batch step ${index + 1} runtime must be an object.`); } normalized.push({ - command, + command: command as CommandName, positionals: positionals as string[], flags: (step.flags ?? {}) as Partial, runtime: step.runtime, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 1c436bd2..d5742c84 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '../utils/device.ts'; +import type { CommandName } from './command-names.ts'; type KindMatrix = { simulator?: boolean; @@ -144,7 +145,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, }, -}; +} satisfies Partial>; export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean { const capability = COMMAND_CAPABILITY_MATRIX[command]; diff --git a/src/core/command-names.ts b/src/core/command-names.ts new file mode 100644 index 00000000..e3b3a282 --- /dev/null +++ b/src/core/command-names.ts @@ -0,0 +1,31 @@ +/** + * Canonical command name type used throughout agent-device. + * + * CLI command names are derived from `COMMAND_SCHEMAS` keys. + * Daemon-internal names are listed as a small separate union. + * + * `DaemonRequest.command` intentionally stays `string` because it represents + * untrusted input from wire boundaries. The value of `CommandName` comes from + * typed lookup-table keys and internal function parameters. + */ + +import type { CliCommandName } from '../utils/command-schema.ts'; + +const DAEMON_INTERNAL_COMMANDS = [ + 'install_source', + 'lease_allocate', + 'lease_heartbeat', + 'lease_release', + 'release_materialized_paths', + 'session_list', +] as const; + +type DaemonInternalCommandName = (typeof DAEMON_INTERNAL_COMMANDS)[number]; + +export type CommandName = CliCommandName | DaemonInternalCommandName; + +/** CLI aliases that are normalized before reaching command dispatch. */ +export const COMMAND_ALIASES: Partial> = { + 'long-press': 'longpress', + metrics: 'perf', +}; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0453fedc..1a25d4bd 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import pathModule from 'node:path'; import { AppError } from '../utils/errors.ts'; import type { DeviceInfo } from '../utils/device.ts'; +import type { CommandName } from './command-names.ts'; import { dismissAndroidKeyboard, getAndroidKeyboardState, @@ -30,7 +31,7 @@ export { resolveTargetDevice } from './dispatch-resolve.ts'; export { shouldUseIosTapSeries, shouldUseIosDragSeries }; export type BatchStep = { - command: string; + command: CommandName; positionals?: string[]; flags?: Partial; runtime?: unknown; @@ -42,7 +43,7 @@ export type CommandFlags = Omit, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 7820f4db..d735f97d 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -5,6 +5,7 @@ import { type BatchStep, type CommandFlags, } from '../../core/dispatch.ts'; +import type { CommandName } from '../../core/command-names.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { AppError, asAppError, normalizeError } from '../../utils/errors.ts'; import { normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts'; @@ -117,7 +118,7 @@ async function runSessionOrSelectorDispatch(params: { ensureReady: typeof ensureDeviceReady; resolveDevice: typeof resolveTargetDevice; dispatch: typeof dispatchCommand; - command: string; + command: CommandName; positionals: string[]; recordPositionals?: string[]; deriveNextSession?: ( diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index cff8d59c..ce4a6e08 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -2,6 +2,7 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; import { AppError, normalizeError } from '../utils/errors.ts'; import type { DaemonInstallSource, DaemonRequest, DaemonResponse } from './types.ts'; +import type { CommandName } from '../core/command-names.ts'; import { normalizeTenantId } from './config.ts'; import { clearRequestCanceled, @@ -78,7 +79,7 @@ const LEASE_RPC_METHOD_TO_COMMAND: Record< 'agent-device.lease.heartbeat': 'lease_heartbeat', 'agent_device.lease.release': 'lease_release', 'agent-device.lease.release': 'lease_release', -}; +} satisfies Record; const SUPPORTED_RPC_METHODS = new Set([ ...COMMAND_RPC_METHODS, ...INSTALL_FROM_SOURCE_RPC_METHODS, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 2faf453e..27754bc3 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -27,14 +27,15 @@ import { } from '../utils/diagnostics.ts'; import { resolveLeaseScope } from './lease-context.ts'; import type { LeaseRegistry } from './lease-registry.ts'; +import type { CommandName } from '../core/command-names.ts'; -const selectorValidationExemptCommands = new Set([ +const selectorValidationExemptCommands = new Set([ 'session_list', 'devices', 'ensure-simulator', 'release_materialized_paths', -]); -const leaseAdmissionExemptCommands = new Set([ +] as const satisfies readonly CommandName[]); +const leaseAdmissionExemptCommands = new Set([ 'session_list', 'devices', 'ensure-simulator', @@ -42,7 +43,7 @@ const leaseAdmissionExemptCommands = new Set([ 'lease_allocate', 'lease_heartbeat', 'lease_release', -]); +] as const satisfies readonly CommandName[]); function contextFromFlags( logPath: string, @@ -344,7 +345,7 @@ export function createRequestHandler( command === 'screenshot' && resolvedOut ? { ...(lockedReq.flags ?? {}), out: resolvedOut } : (lockedReq.flags ?? {}); - const data = await dispatch(session.device, command, resolvedPositionals, resolvedOut, { + const data = await dispatch(session.device, command as CommandName, resolvedPositionals, resolvedOut, { ...contextFromFlags( logPath, lockedReq.flags, diff --git a/src/utils/args.ts b/src/utils/args.ts index 5eafb090..ff30c856 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -10,6 +10,7 @@ import { type FlagKey, } from './command-schema.ts'; import { isFlagSupportedForCommand } from './cli-option-schema.ts'; +import { COMMAND_ALIASES } from '../core/command-names.ts'; type ParsedArgs = { command: string | null; @@ -304,9 +305,7 @@ export function usageForCommand(command: string): string | null { } function normalizeCommandAlias(command: string): string { - if (command === 'long-press') return 'longpress'; - if (command === 'metrics') return 'perf'; - return command; + return COMMAND_ALIASES[command] ?? command; } function mergeDefinedFlags>(target: T, source: Partial): T { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 3cf64188..e3b885f9 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -743,7 +743,7 @@ export const GLOBAL_FLAG_KEYS = new Set([ 'noRecord', ]); -const COMMAND_SCHEMAS: Record = { +const commandSchemaEntries = { boot: { description: 'Ensure target device/simulator is booted and ready', positionalArgs: [], @@ -1049,7 +1049,11 @@ const COMMAND_SCHEMAS: Record = { allowedFlags: [], skipCapabilityCheck: true, }, -}; +} satisfies Record; + +export type CliCommandName = keyof typeof commandSchemaEntries; + +const COMMAND_SCHEMAS: Record = commandSchemaEntries; const flagDefinitionByName = new Map(); const flagDefinitionsByKey = new Map();