diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index b79a361ab..20b8014e6 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -15,6 +15,9 @@ import { listCommandFamilyMetadata, } from '../family/registry.ts'; import { listExecutableCommandNames } from '../command-surface.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; test('MCP exposed command names have metadata and executable command definitions', () => { const mcpExposedNames = listMcpExposedCommandNames().sort(); @@ -98,3 +101,23 @@ test('command family facets keep daemon writers as an explicit projection subset ); } }); + +test('command family facets reject duplicate daemon writer keys', () => { + const metadata = defineFieldCommandMetadata('example', 'Example command.', {}); + const definition = defineExecutableCommand(metadata, async () => ({})); + const writer = () => ({ command: 'example', positionals: [], options: {} }); + + const facet = defineCommandFacet({ + name: 'example', + metadata, + definition, + cliReader: () => ({}), + daemonWriter: writer, + extraDaemonWriters: { example: writer }, + }); + + assert.throws( + () => defineCommandFamilyFromFacets({ name: 'test', commands: [facet] }), + /Duplicate command family daemon writer: example/, + ); +}); diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 0d3fd930c..5739ac2b1 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -2,7 +2,7 @@ import type { BatchRunOptions } from '../../client-types.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { commonInputFromFlags } from '../cli-grammar/common.ts'; import type { CliReader } from '../cli-grammar/types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonToClientOptions } from '../command-input.ts'; import { batchCliOutputFormatters } from './output.ts'; @@ -15,34 +15,35 @@ const batchCommandDefinition = defineExecutableCommand(batchCommandMetadata, (cl client.batch.run(toBatchOptions(input)), ); -const batchCliSchemas = { - batch: { - usageOverride: 'batch [--steps | --steps-file ]', - listUsageOverride: 'batch --steps | --steps-file ', - helpDescription: 'Execute multiple commands in one daemon request', - summary: 'Run multiple commands', - allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], - }, -} as const satisfies Record; - -const batchCliReaders = { - batch: ((_positionals, flags) => ({ - ...commonInputFromFlags(flags), - steps: flags.batchSteps ?? [], - onError: flags.batchOnError, - maxSteps: flags.batchMaxSteps, - out: flags.out, - })) satisfies CliReader, -} as const; - -export const batchCommandFamily = defineCommandFamily({ +const batchCliSchema = { + usageOverride: 'batch [--steps | --steps-file ]', + listUsageOverride: 'batch --steps | --steps-file ', + helpDescription: 'Execute multiple commands in one daemon request', + summary: 'Run multiple commands', + allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], +} as const satisfies CommandSchemaOverride; + +const batchCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + steps: flags.batchSteps ?? [], + onError: flags.batchOnError, + maxSteps: flags.batchMaxSteps, + out: flags.out, +}); + +const batchCommandFacet = defineCommandFacet({ + name: 'batch', + metadata: batchCommandMetadata, + definition: batchCommandDefinition, + cliSchema: batchCliSchema, + cliReader: batchCliReader, + cliOutputFormatter: batchCliOutputFormatters.batch, +}); + +export const batchCommandFamily = defineCommandFamilyFromFacets({ name: 'batch', clientSurface: false, - metadata: [batchCommandMetadata], - definitions: [batchCommandDefinition], - cliSchemas: batchCliSchemas, - cliReaders: batchCliReaders, - cliOutputFormatters: batchCliOutputFormatters, + commands: [batchCommandFacet], }); export { createBatchDaemonWriter }; diff --git a/src/commands/debugging/index.ts b/src/commands/debugging/index.ts index 9000c1b8f..510090e34 100644 --- a/src/commands/debugging/index.ts +++ b/src/commands/debugging/index.ts @@ -1,7 +1,7 @@ import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { enumField, requiredField, stringField } from '../command-input.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { commonInputFromFlags } from '../cli-grammar/common.ts'; @@ -25,15 +25,11 @@ export const debugCommandMetadata = defineFieldCommandMetadata( }, ); -const debuggingCommandMetadata = [debugCommandMetadata] as const; - export const debugCommandDefinition = defineExecutableCommand( debugCommandMetadata, (client, input) => client.debug.symbols(input), ); -const debuggingCommandDefinitions = [debugCommandDefinition] as const; - const debugCliSchema = { usageOverride: 'debug symbols --artifact (--dsym | --search-path ) [--out ]', @@ -46,10 +42,6 @@ const debugCliSchema = { allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], } as const satisfies CommandSchemaOverride; -const debuggingCliSchemas = { - [DEBUG_COMMAND_NAME]: debugCliSchema, -} as const satisfies Record; - export const debugCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readDebugAction(positionals[0]), @@ -59,17 +51,18 @@ export const debugCliReader: CliReader = (positionals, flags) => ({ out: flags.out, }); -const debuggingCliReaders = { - debug: debugCliReader, -} satisfies Record; +const debugCommandFacet = defineCommandFacet({ + name: DEBUG_COMMAND_NAME, + metadata: debugCommandMetadata, + definition: debugCommandDefinition, + cliSchema: debugCliSchema, + cliReader: debugCliReader, + cliOutputFormatter: debuggingCliOutputFormatters.debug, +}); -export const debuggingCommandFamily = defineCommandFamily({ +export const debuggingCommandFamily = defineCommandFamilyFromFacets({ name: 'debugging', - metadata: debuggingCommandMetadata, - definitions: debuggingCommandDefinitions, - cliSchemas: debuggingCliSchemas, - cliReaders: debuggingCliReaders, - cliOutputFormatters: debuggingCliOutputFormatters, + commands: [debugCommandFacet], }); function readDebugAction(value: string | undefined): 'symbols' { diff --git a/src/commands/family/registry.ts b/src/commands/family/registry.ts index 50530bb26..c19701a12 100644 --- a/src/commands/family/registry.ts +++ b/src/commands/family/registry.ts @@ -70,7 +70,7 @@ function mergeFamilyRecords( ): Record { const records: Record = {}; for (const family of commandFamilies) { - const record = (family as CommandFamilyFacet)[key] as + const record = family[key] as | Readonly> | undefined; if (!record) continue; diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index 992e59e9c..6d3d32a23 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -31,6 +31,7 @@ export type CommandFacet = { cliSchema?: CommandSchemaOverride; cliReader: CliReader; daemonWriter?: DaemonWriter; + extraDaemonWriters?: Readonly>; cliOutputFormatter?: CliOutputFormatter; }; @@ -44,20 +45,6 @@ type CommandFacetDefinitions = { type CommandFacetName = TCommands[number]['name']; -type CommandFamilyMetadataName = - TMetadata[number]['name']; - -export function defineCommandFamily< - const TMetadata extends readonly AnyCommandMetadata[], - const TDefinitions extends readonly AnyCommandDefinition>[], - const TFamily extends CommandFamilyFacet> & { - metadata: TMetadata; - definitions: TDefinitions; - }, ->(family: TFamily): TFamily { - return family; -} - export function defineCommandFacet< const TCommandName extends string, const TCommand extends CommandFacet, @@ -75,24 +62,55 @@ export function defineCommandFamilyFromFacets< const cliOutputFormatters: Record = {}; for (const command of family.commands) { - if (command.cliSchema) cliSchemas[command.name] = command.cliSchema; - cliReaders[command.name] = command.cliReader; - if (command.daemonWriter) daemonWriters[command.name] = command.daemonWriter; + if (command.cliSchema) { + addRecordEntry(cliSchemas, 'CLI schema', command.name, command.cliSchema); + } + addRecordEntry(cliReaders, 'CLI reader', command.name, command.cliReader); + if (command.daemonWriter) { + addRecordEntry(daemonWriters, 'daemon writer', command.name, command.daemonWriter); + } + if (command.extraDaemonWriters) { + for (const [name, writer] of Object.entries(command.extraDaemonWriters)) { + addRecordEntry(daemonWriters, 'daemon writer', name, writer); + } + } if (command.cliOutputFormatter) { - cliOutputFormatters[command.name] = command.cliOutputFormatter; + addRecordEntry( + cliOutputFormatters, + 'CLI output formatter', + command.name, + command.cliOutputFormatter, + ); } } - return defineCommandFamily({ + return { name: family.name, clientSurface: family.clientSurface, metadata: family.commands.map((command) => command.metadata) as CommandFacetMetadata, definitions: family.commands.map( (command) => command.definition, ) as CommandFacetDefinitions, - cliSchemas, + cliSchemas: cliSchemas as Partial, CommandSchemaOverride>>, cliReaders: cliReaders as Record, CliReader>, daemonWriters, - cliOutputFormatters, - }); + cliOutputFormatters: cliOutputFormatters as Partial< + Record, CliOutputFormatter> + >, + } satisfies CommandFamilyFacet> & { + metadata: CommandFacetMetadata; + definitions: CommandFacetDefinitions; + }; +} + +function addRecordEntry( + record: Record, + label: string, + name: string, + value: TValue, +): void { + if (Object.hasOwn(record, name)) { + throw new Error(`Duplicate command family ${label}: ${name}`); + } + record[name] = value; } diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index f3d0682c4..ad3cd4aa6 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -19,7 +19,7 @@ import type { } from '../../client-types.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonToClientOptions, @@ -127,42 +127,56 @@ const interactionCliSchemas = { type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number]; type InteractionCommandName = InteractionCommandMetadata['name']; +const { gesture: _gestureDaemonWriter, ...gestureProjectionAliasDaemonWriters } = + gestureDaemonWriters; -const interactionCommandDefinitions = [ - defineExecutableCommand(metadata('click'), (client, input) => - client.interactions.click(toClickOptions(input)), - ), - defineExecutableCommand(metadata('press'), (client, input) => - client.interactions.press(toPressOptions(input)), - ), - defineExecutableCommand(metadata('fill'), (client, input) => - client.interactions.fill(toFillOptions(input)), - ), - defineExecutableCommand(metadata('longpress'), (client, input) => - client.interactions.longPress(toLongPressOptions(input)), - ), - defineExecutableCommand(metadata('swipe'), (client, input) => - client.interactions.swipe(input as SwipeOptions), - ), - defineExecutableCommand(metadata('focus'), (client, input) => - client.interactions.focus(input as FocusOptions), - ), - defineExecutableCommand(metadata('type'), (client, input) => - client.interactions.type(input as TypeTextOptions), - ), - defineExecutableCommand(metadata('scroll'), (client, input) => - client.interactions.scroll(input as ScrollOptions), - ), - defineExecutableCommand(metadata('get'), (client, input) => - client.interactions.get(toGetOptions(input)), - ), - defineExecutableCommand(metadata('is'), (client, input) => - client.interactions.is(input as IsOptions), - ), - defineExecutableCommand(metadata('find'), (client, input) => - client.interactions.find(input as FindOptions), - ), - defineExecutableCommand(metadata('gesture'), async (client, input) => { +const clickCommandDefinition = defineExecutableCommand(metadata('click'), (client, input) => + client.interactions.click(toClickOptions(input)), +); + +const pressCommandDefinition = defineExecutableCommand(metadata('press'), (client, input) => + client.interactions.press(toPressOptions(input)), +); + +const fillCommandDefinition = defineExecutableCommand(metadata('fill'), (client, input) => + client.interactions.fill(toFillOptions(input)), +); + +const longPressCommandDefinition = defineExecutableCommand(metadata('longpress'), (client, input) => + client.interactions.longPress(toLongPressOptions(input)), +); + +const swipeCommandDefinition = defineExecutableCommand(metadata('swipe'), (client, input) => + client.interactions.swipe(input as SwipeOptions), +); + +const focusCommandDefinition = defineExecutableCommand(metadata('focus'), (client, input) => + client.interactions.focus(input as FocusOptions), +); + +const typeCommandDefinition = defineExecutableCommand(metadata('type'), (client, input) => + client.interactions.type(input as TypeTextOptions), +); + +const scrollCommandDefinition = defineExecutableCommand(metadata('scroll'), (client, input) => + client.interactions.scroll(input as ScrollOptions), +); + +const getCommandDefinition = defineExecutableCommand(metadata('get'), (client, input) => + client.interactions.get(toGetOptions(input)), +); + +const isCommandDefinition = defineExecutableCommand(metadata('is'), (client, input) => + client.interactions.is(input as IsOptions), +); + +const findCommandDefinition = defineExecutableCommand(metadata('find'), (client, input) => + client.interactions.find(input as FindOptions), +); + +const gestureCommandDefinition = defineExecutableCommand( + metadata('gesture'), + async (client, input) => { switch (input.kind) { case 'pan': return await client.interactions.pan(toPanOptions(input)); @@ -177,26 +191,140 @@ const interactionCommandDefinitions = [ case 'transform': return await client.interactions.transformGesture(toTransformOptions(input)); } - }), -] as const; + }, +); + +const clickCommandFacet = defineCommandFacet({ + name: 'click', + metadata: metadata('click'), + definition: clickCommandDefinition, + cliSchema: interactionCliSchemas.click, + cliReader: interactionCliReaders.click, + daemonWriter: interactionDaemonWriters.click, + cliOutputFormatter: interactionCliOutputFormatters.click, +}); + +const pressCommandFacet = defineCommandFacet({ + name: 'press', + metadata: metadata('press'), + definition: pressCommandDefinition, + cliSchema: interactionCliSchemas.press, + cliReader: interactionCliReaders.press, + daemonWriter: interactionDaemonWriters.press, + cliOutputFormatter: interactionCliOutputFormatters.press, +}); + +const fillCommandFacet = defineCommandFacet({ + name: 'fill', + metadata: metadata('fill'), + definition: fillCommandDefinition, + cliSchema: interactionCliSchemas.fill, + cliReader: interactionCliReaders.fill, + daemonWriter: interactionDaemonWriters.fill, +}); + +const longPressCommandFacet = defineCommandFacet({ + name: 'longpress', + metadata: metadata('longpress'), + definition: longPressCommandDefinition, + cliSchema: interactionCliSchemas.longpress, + cliReader: interactionCliReaders.longpress, + daemonWriter: interactionDaemonWriters.longpress, +}); + +const swipeCommandFacet = defineCommandFacet({ + name: 'swipe', + metadata: metadata('swipe'), + definition: swipeCommandDefinition, + cliSchema: interactionCliSchemas.swipe, + cliReader: interactionCliReaders.swipe, + daemonWriter: interactionDaemonWriters.swipe, +}); + +const focusCommandFacet = defineCommandFacet({ + name: 'focus', + metadata: metadata('focus'), + definition: focusCommandDefinition, + cliSchema: interactionCliSchemas.focus, + cliReader: interactionCliReaders.focus, + daemonWriter: interactionDaemonWriters.focus, +}); -export const interactionCommandFamily = defineCommandFamily({ +const typeCommandFacet = defineCommandFacet({ + name: 'type', + metadata: metadata('type'), + definition: typeCommandDefinition, + cliSchema: interactionCliSchemas.type, + cliReader: interactionCliReaders.type, + daemonWriter: interactionDaemonWriters.type, +}); + +const scrollCommandFacet = defineCommandFacet({ + name: 'scroll', + metadata: metadata('scroll'), + definition: scrollCommandDefinition, + cliSchema: interactionCliSchemas.scroll, + cliReader: interactionCliReaders.scroll, + daemonWriter: interactionDaemonWriters.scroll, +}); + +const getCommandFacet = defineCommandFacet({ + name: 'get', + metadata: metadata('get'), + definition: getCommandDefinition, + cliSchema: interactionCliSchemas.get, + cliReader: interactionCliReaders.get, + daemonWriter: interactionDaemonWriters.get, + cliOutputFormatter: interactionCliOutputFormatters.get, +}); + +const isCommandFacet = defineCommandFacet({ + name: 'is', + metadata: metadata('is'), + definition: isCommandDefinition, + cliSchema: interactionCliSchemas.is, + cliReader: selectorCliReaders.is, + daemonWriter: selectorDaemonWriters.is, + cliOutputFormatter: interactionCliOutputFormatters.is, +}); + +const findCommandFacet = defineCommandFacet({ + name: 'find', + metadata: metadata('find'), + definition: findCommandDefinition, + cliSchema: interactionCliSchemas.find, + cliReader: selectorCliReaders.find, + daemonWriter: selectorDaemonWriters.find, + cliOutputFormatter: interactionCliOutputFormatters.find, +}); + +const gestureCommandFacet = defineCommandFacet({ + name: 'gesture', + metadata: metadata('gesture'), + definition: gestureCommandDefinition, + cliSchema: interactionCliSchemas.gesture, + cliReader: gestureCliReaders.gesture, + daemonWriter: gestureDaemonWriters.gesture, + extraDaemonWriters: gestureProjectionAliasDaemonWriters, +}); + +export const interactionCommandFamily = defineCommandFamilyFromFacets({ name: 'interaction', clientSurface: false, - metadata: interactionCommandMetadata, - definitions: interactionCommandDefinitions, - cliSchemas: interactionCliSchemas, - cliReaders: { - ...interactionCliReaders, - ...gestureCliReaders, - ...selectorCliReaders, - }, - daemonWriters: { - ...interactionDaemonWriters, - ...gestureDaemonWriters, - ...selectorDaemonWriters, - }, - cliOutputFormatters: interactionCliOutputFormatters, + commands: [ + clickCommandFacet, + pressCommandFacet, + fillCommandFacet, + longPressCommandFacet, + swipeCommandFacet, + focusCommandFacet, + typeCommandFacet, + scrollCommandFacet, + getCommandFacet, + isCommandFacet, + findCommandFacet, + gestureCommandFacet, + ], }); function metadata( diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts new file mode 100644 index 000000000..ea4eadfc7 --- /dev/null +++ b/src/commands/management/app.ts @@ -0,0 +1,167 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { AppCloseOptions } from '../../client-types.ts'; +import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; +import { + booleanField, + booleanSchema, + enumField, + jsonSchemaField, + stringArrayField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; +import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const appsCommandMetadata = defineFieldCommandMetadata('apps', 'List installed apps.', { + appsFilter: enumField(['user-installed', 'all']), +}); + +const openCommandMetadata = defineFieldCommandMetadata( + 'open', + 'Open an app, deep link, URL, or platform surface.', + { + app: stringField('App name, bundle id, package, or URL.'), + url: stringField('Optional URL passed with an app shell.'), + surface: enumField(SESSION_SURFACES), + activity: stringField('Android activity name.'), + launchConsole: stringField('Launch console mode.'), + launchArgs: stringArrayField( + 'Launch arguments forwarded verbatim to the platform launch command.', + ), + relaunch: booleanField('Force relaunch.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), + noRecord: booleanField('Do not record this action.'), + }, +); + +const closeCommandMetadata = defineFieldCommandMetadata( + 'close', + 'Close an app or end the active session.', + { + app: stringField('Optional app to close.'), + shutdown: booleanField('Shutdown the session/device where supported.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + }, +); + +const appsCommandDefinition = defineExecutableCommand(appsCommandMetadata, (client, input) => + client.apps.list(input), +); + +const openCommandDefinition = defineExecutableCommand(openCommandMetadata, (client, input) => + client.apps.open(input), +); + +const closeCommandDefinition = defineExecutableCommand(closeCommandMetadata, (client, input) => + input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), +); + +const appsCliSchema = { + helpDescription: 'List user-installed apps; use --all to include system/OEM apps', + summary: 'List installed apps', + allowedFlags: ['appsFilter'], + defaults: { appsFilter: DEFAULT_APPS_FILTER }, +} as const satisfies CommandSchemaOverride; + +const openCliSchema = { + helpDescription: + 'Boot device/simulator; optionally launch app or deep link URL. Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar.', + summary: 'Open an app, deep link or URL, save replays', + positionalArgs: ['appOrUrl?', 'url?'], + allowedFlags: [ + 'activity', + 'launchConsole', + 'launchArgs', + 'deviceHub', + 'saveScript', + 'noRecord', + 'relaunch', + 'surface', + ], +} as const satisfies CommandSchemaOverride; + +const closeCliSchema = { + positionalArgs: ['app?'], + allowedFlags: ['saveScript', 'shutdown'], +} as const satisfies CommandSchemaOverride; + +const appsCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + appsFilter: assertResolvedAppsFilter(flags.appsFilter), +}); + +const openCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + url: positionals[1], + surface: flags.surface, + activity: flags.activity, + launchConsole: flags.launchConsole, + launchArgs: flags.launchArgs, + relaunch: flags.relaunch, + saveScript: flags.saveScript, + deviceHub: flags.deviceHub, + noRecord: flags.noRecord, +}); + +const closeCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + shutdown: flags.shutdown, + saveScript: flags.saveScript, +}); + +const appsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.apps); +const openDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.open, openPositionals); +const closeDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.close, (input) => + optionalString(input.app), +); + +export const appsCommandFacet = defineCommandFacet({ + name: 'apps', + metadata: appsCommandMetadata, + definition: appsCommandDefinition, + cliSchema: appsCliSchema, + cliReader: appsCliReader, + daemonWriter: appsDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.apps, +}); + +export const openCommandFacet = defineCommandFacet({ + name: 'open', + metadata: openCommandMetadata, + definition: openCommandDefinition, + cliSchema: openCliSchema, + cliReader: openCliReader, + daemonWriter: openDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.open, +}); + +export const closeCommandFacet = defineCommandFacet({ + name: 'close', + metadata: closeCommandMetadata, + definition: closeCommandDefinition, + cliSchema: closeCliSchema, + cliReader: closeCliReader, + daemonWriter: closeDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.close, +}); + +function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { + const { app: _app, ...rest } = input; + return rest; +} + +function openPositionals(input: CommandInput): string[] { + if (!input.app) return []; + return input.url ? [input.app, input.url] : [input.app]; +} diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts new file mode 100644 index 000000000..1ace4695c --- /dev/null +++ b/src/commands/management/device.ts @@ -0,0 +1,93 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { booleanField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const devicesCommandMetadata = defineFieldCommandMetadata('devices', 'List available devices.', {}); + +const bootCommandMetadata = defineFieldCommandMetadata( + 'boot', + 'Boot or prepare a selected device without using CLI positional arguments.', + { + headless: booleanField('Boot without showing simulator UI when supported.'), + }, +); + +const shutdownCommandMetadata = defineFieldCommandMetadata( + 'shutdown', + 'Shutdown a selected simulator or emulator.', + {}, +); + +const devicesCommandDefinition = defineExecutableCommand(devicesCommandMetadata, (client, input) => + client.devices.list(input), +); + +const bootCommandDefinition = defineExecutableCommand(bootCommandMetadata, (client, input) => + client.devices.boot(input), +); + +const shutdownCommandDefinition = defineExecutableCommand( + shutdownCommandMetadata, + (client, input) => client.devices.shutdown(input), +); + +const bootCliSchema = { + summary: 'Boot target device/simulator', + allowedFlags: ['headless'], +} as const satisfies CommandSchemaOverride; + +const shutdownCliSchema = { + summary: 'Shutdown target simulator/emulator', +} as const satisfies CommandSchemaOverride; + +const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); + +const bootCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + headless: flags.headless, +}); + +const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices); +const bootDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.boot); +const shutdownDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.shutdown); + +const devicesCommandFacet = defineCommandFacet({ + name: 'devices', + metadata: devicesCommandMetadata, + definition: devicesCommandDefinition, + cliReader: commonCliReader, + daemonWriter: devicesDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.devices, +}); + +const bootCommandFacet = defineCommandFacet({ + name: 'boot', + metadata: bootCommandMetadata, + definition: bootCommandDefinition, + cliSchema: bootCliSchema, + cliReader: bootCliReader, + daemonWriter: bootDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.boot, +}); + +const shutdownCommandFacet = defineCommandFacet({ + name: 'shutdown', + metadata: shutdownCommandMetadata, + definition: shutdownCommandDefinition, + cliSchema: shutdownCliSchema, + cliReader: commonCliReader, + daemonWriter: shutdownDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.shutdown, +}); + +export const deviceManagementCommandFacets = [ + devicesCommandFacet, + bootCommandFacet, + shutdownCommandFacet, +] as const; diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index 846c2d146..fabaac020 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -1,463 +1,21 @@ -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - AppCloseOptions, - AppPushOptions, - AppTriggerEventOptions, -} from '../../client-types.ts'; -import type { DaemonInstallSource } from '../../contracts.ts'; -import { SESSION_SURFACES } from '../../core/session-surface.ts'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { AppError } from '../../utils/errors.ts'; -import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; -import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; -import { defineCommandFamily } from '../family/types.ts'; -import { defineExecutableCommand } from '../command-contract.ts'; -import { - booleanField, - booleanSchema, - enumField, - integerField, - jsonSchemaField, - looseObjectField, - looseObjectSchema, - requiredField, - stringArrayField, - stringField, - stringSchema, -} from '../command-input.ts'; -import { - commonInputFromFlags, - direct, - optionalString, - readJsonObject, - request, - requiredDaemonString, - requiredString, -} from '../cli-grammar/common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; -import { defineFieldCommandMetadata } from '../field-command-contract.ts'; -import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; -import { managementCliOutputFormatters } from './output.ts'; - -const PREPARE_ACTION_VALUES = ['ios-runner'] as const; - -const managementCommandDescriptions = { - devices: 'List available devices.', - boot: 'Boot or prepare a selected device without using CLI positional arguments.', - shutdown: 'Shutdown a selected simulator or emulator.', - apps: 'List installed apps.', - session: 'List active sessions or print daemon state directory.', - open: 'Open an app, deep link, URL, or platform surface.', - prepare: 'Prepare platform helper infrastructure.', - close: 'Close an app or end the active session.', - install: 'Install an app binary.', - reinstall: 'Reinstall an app binary.', - 'install-from-source': 'Install an app from a structured source.', - push: 'Deliver a push payload.', - 'trigger-app-event': 'Trigger an app-defined event.', -} as const; - -const managementCommandMetadata = [ - defineFieldCommandMetadata('devices', managementCommandDescriptions.devices, {}), - defineFieldCommandMetadata('boot', managementCommandDescriptions.boot, { - headless: booleanField('Boot without showing simulator UI when supported.'), - }), - defineFieldCommandMetadata('shutdown', managementCommandDescriptions.shutdown, {}), - defineFieldCommandMetadata('prepare', managementCommandDescriptions.prepare, { - action: requiredField(enumField(PREPARE_ACTION_VALUES)), - timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), - }), - defineFieldCommandMetadata('apps', managementCommandDescriptions.apps, { - appsFilter: enumField(['user-installed', 'all']), - }), - defineFieldCommandMetadata('session', managementCommandDescriptions.session, { - action: enumField( - ['list', 'state-dir'], - 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', - ), - }), - defineFieldCommandMetadata('open', managementCommandDescriptions.open, { - app: stringField('App name, bundle id, package, or URL.'), - url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SESSION_SURFACES), - activity: stringField('Android activity name.'), - launchConsole: stringField('Launch console mode.'), - launchArgs: stringArrayField( - 'Launch arguments forwarded verbatim to the platform launch command.', - ), - relaunch: booleanField('Force relaunch.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), - noRecord: booleanField('Do not record this action.'), - }), - defineFieldCommandMetadata('close', managementCommandDescriptions.close, { - app: stringField('Optional app to close.'), - shutdown: booleanField('Shutdown the session/device where supported.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - }), - defineFieldCommandMetadata('install', managementCommandDescriptions.install, { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }), - defineFieldCommandMetadata('reinstall', managementCommandDescriptions.reinstall, { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }), - defineFieldCommandMetadata( - 'install-from-source', - managementCommandDescriptions['install-from-source'], - { - source: requiredField( - jsonSchemaField(looseObjectSchema('Install source object.')), - ), - retainPaths: booleanField(), - retentionMs: integerField(), - }, - ), - defineFieldCommandMetadata('push', managementCommandDescriptions.push, { - app: requiredField(stringField()), - payload: requiredField( - jsonSchemaField>({ - oneOf: [stringSchema(), looseObjectSchema()], - }), - ), - }), - defineFieldCommandMetadata( - 'trigger-app-event', - managementCommandDescriptions['trigger-app-event'], - { - event: requiredField(stringField()), - payload: looseObjectField(), - }, - ), -] as const; - -type ManagementCommandMetadata = (typeof managementCommandMetadata)[number]; -type ManagementCommandName = ManagementCommandMetadata['name']; - -const managementCommandDefinitions = [ - defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), - defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), - defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), - defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), - defineExecutableCommand(metadata('session'), async (client, { action, ...input }) => - action === 'state-dir' - ? { stateDir: await client.sessions.stateDir(input) } - : { sessions: await client.sessions.list(input) }, - ), - defineExecutableCommand(metadata('open'), (client, input) => client.apps.open(input)), - defineExecutableCommand(metadata('close'), (client, input) => - input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), - ), - defineExecutableCommand(metadata('install'), (client, input) => client.apps.install(input)), - defineExecutableCommand(metadata('reinstall'), (client, input) => client.apps.reinstall(input)), - defineExecutableCommand(metadata('install-from-source'), (client, input) => - client.apps.installFromSource(input), - ), - defineExecutableCommand(metadata('push'), (client, input) => client.apps.push(input)), - defineExecutableCommand(metadata('trigger-app-event'), (client, input) => - client.apps.triggerEvent(input), - ), - defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), -] as const; - -const managementCliSchemas = { - boot: { - summary: 'Boot target device/simulator', - allowedFlags: ['headless'], - }, - shutdown: { - summary: 'Shutdown target simulator/emulator', - }, - prepare: { - usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', - listUsageOverride: 'prepare', - helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', - summary: - 'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation', - positionalArgs: ['ios-runner'], - allowedFlags: ['timeoutMs'], - }, - open: { - helpDescription: - 'Boot device/simulator; optionally launch app or deep link URL. Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar.', - summary: 'Open an app, deep link or URL, save replays', - positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: [ - 'activity', - 'launchConsole', - 'launchArgs', - 'deviceHub', - 'saveScript', - 'noRecord', - 'relaunch', - 'surface', - ], - }, - close: { - positionalArgs: ['app?'], - allowedFlags: ['saveScript', 'shutdown'], - }, - reinstall: { - positionalArgs: ['app', 'path'], - }, - install: { - positionalArgs: ['app', 'path'], - }, - 'install-from-source': { - usageOverride: - 'install-from-source | install-from-source --github-actions-artifact ', - listUsageOverride: 'install-from-source', - helpDescription: - 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', - summary: 'Install app builds from URLs, remote source specs, or CI artifacts', - positionalArgs: ['url?'], - allowedFlags: [ - 'header', - 'githubActionsArtifact', - 'installSource', - 'retainPaths', - 'retentionMs', - ], - }, - apps: { - helpDescription: 'List user-installed apps; use --all to include system/OEM apps', - summary: 'List installed apps', - allowedFlags: ['appsFilter'], - defaults: { appsFilter: DEFAULT_APPS_FILTER }, - }, - push: { - listUsageOverride: 'push', - helpDescription: 'Deliver push notification payloads to an installed app.', - summary: 'Deliver push notification payloads to an installed app', - positionalArgs: ['bundleOrPackage', 'payloadOrJson'], - }, - 'trigger-app-event': { - usageOverride: 'trigger-app-event [payloadJson]', - listUsageOverride: 'trigger-app-event', - helpDescription: - 'Invoke app-defined automation or test events with an optional structured payload.', - summary: 'Invoke app-defined automation/test events with optional structured payloads', - positionalArgs: ['event', 'payloadJson?'], - }, - session: { - usageOverride: 'session list | session state-dir', - listUsageOverride: 'session', - helpDescription: 'List active sessions or print the effective daemon state directory', - positionalArgs: ['list|state-dir?'], - }, -} as const satisfies Record; - -function metadata( - name: TName, -): Extract { - const definition = managementCommandMetadata.find((item) => item.name === name); - if (!definition) throw new Error(`Missing management command metadata for ${name}`); - return definition as Extract; -} - -function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { - const { app: _app, ...rest } = input; - return rest; -} - -const appCliReaders = { - devices: (_positionals, flags) => commonInputFromFlags(flags), - apps: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - appsFilter: assertResolvedAppsFilter(flags.appsFilter), - }), - session: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readSessionAction(positionals[0]), - }), - boot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - headless: flags.headless, - }), - shutdown: (_positionals, flags) => commonInputFromFlags(flags), - prepare: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: requiredString(positionals[0], 'prepare requires subcommand'), - timeoutMs: flags.timeoutMs, - }), - open: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - url: positionals[1], - surface: flags.surface, - activity: flags.activity, - launchConsole: flags.launchConsole, - launchArgs: flags.launchArgs, - relaunch: flags.relaunch, - saveScript: flags.saveScript, - deviceHub: flags.deviceHub, - noRecord: flags.noRecord, - }), - close: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - shutdown: flags.shutdown, - saveScript: flags.saveScript, - }), - install: installInputFromCli, - reinstall: installInputFromCli, - 'install-from-source': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - source: resolveInstallSource(positionals, flags), - retainPaths: flags.retainPaths, - retentionMs: flags.retentionMs, - }), - push: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], 'push requires bundleOrPackage'), - payload: requiredString(positionals[1], 'push requires payloadOrJson'), - }), - 'trigger-app-event': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - event: requiredString(positionals[0], 'trigger-app-event requires event'), - payload: positionals[1] - ? readJsonObject(positionals[1], 'trigger-app-event payload') - : undefined, - }), -} satisfies Record; - -const appDaemonWriters = { - devices: direct(PUBLIC_COMMANDS.devices), - boot: direct(PUBLIC_COMMANDS.boot), - shutdown: direct(PUBLIC_COMMANDS.shutdown), - prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ - requiredDaemonString(input.action, 'prepare requires subcommand'), - ]), - apps: direct(PUBLIC_COMMANDS.apps), - open: direct(PUBLIC_COMMANDS.open, openPositionals), - close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), - install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), - reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), - 'install-from-source': (input) => - request(INTERNAL_COMMANDS.installSource, [], { - ...input, - installSource: input.source, - retainMaterializedPaths: input.retainPaths, - materializedPathRetentionMs: input.retentionMs, - }), - push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), - 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => - triggerEventPositionals(input as AppTriggerEventOptions), - ), -} satisfies Record; - -export const managementCommandFamily = defineCommandFamily({ +import { defineCommandFamilyFromFacets } from '../family/types.ts'; +import { appsCommandFacet, closeCommandFacet, openCommandFacet } from './app.ts'; +import { deviceManagementCommandFacets } from './device.ts'; +import { installManagementCommandFacets } from './install.ts'; +import { prepareCommandFacet } from './prepare.ts'; +import { pushManagementCommandFacets } from './push.ts'; +import { sessionCommandFacet } from './session.ts'; + +export const managementCommandFamily = defineCommandFamilyFromFacets({ name: 'management', - metadata: managementCommandMetadata, - definitions: managementCommandDefinitions, - cliSchemas: managementCliSchemas, - cliReaders: appCliReaders, - daemonWriters: appDaemonWriters, - cliOutputFormatters: managementCliOutputFormatters, + commands: [ + ...deviceManagementCommandFacets, + prepareCommandFacet, + appsCommandFacet, + sessionCommandFacet, + openCommandFacet, + closeCommandFacet, + ...installManagementCommandFacets, + ...pushManagementCommandFacets, + ], }); - -function installInputFromCli( - positionals: string[], - flags: CliFlags, - command = 'install', -): Record { - return { - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], `${command} requires app`), - appPath: requiredString(positionals[1], `${command} requires path`), - }; -} - -function readSessionAction(value: string | undefined): 'list' | 'state-dir' { - const action = value ?? 'list'; - if (action === 'list') return action; - if (action === 'state-dir') return action; - throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); -} - -function openPositionals(input: CommandInput): string[] { - if (!input.app) return []; - return input.url ? [input.app, input.url] : [input.app]; -} - -function requiredPair(first: unknown, second: unknown): string[] { - return [ - requiredDaemonString(first, 'missing first positional'), - requiredDaemonString(second, 'missing second positional'), - ]; -} - -function pushPositionals(input: AppPushOptions): string[] { - return [ - input.app, - typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), - ]; -} - -function triggerEventPositionals(input: AppTriggerEventOptions): string[] { - return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; -} - -// fallow-ignore-next-line complexity -function resolveInstallSource(positionals: string[], flags: CliFlags) { - const url = positionals[0]?.trim(); - if (positionals.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source accepts either one positional or --github-actions-artifact', - ); - } - const githubArtifactSource = flags.githubActionsArtifact - ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) - : undefined; - const configuredSource = flags.installSource; - const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); - if (sourceCount !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', - ); - } - if (!url && flags.header && flags.header.length > 0) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source --header is only supported for URL sources', - ); - } - if (githubArtifactSource) return githubArtifactSource; - if (configuredSource) return configuredSource; - return { - kind: 'url' as const, - url: url!, - headers: parseInstallSourceHeaders(flags.header), - }; -} - -function parseInstallSourceHeaders( - headerFlags: CliFlags['header'], -): Record | undefined { - if (!headerFlags || headerFlags.length === 0) return undefined; - const headers: Record = {}; - for (const rawHeader of headerFlags) { - const separator = rawHeader.indexOf(':'); - if (separator <= 0) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Expected "name:value".`, - ); - } - const name = rawHeader.slice(0, separator).trim(); - const value = rawHeader.slice(separator + 1).trim(); - if (!name) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Header name cannot be empty.`, - ); - } - headers[name] = value; - } - return headers; -} diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts new file mode 100644 index 000000000..5bfb3ba3b --- /dev/null +++ b/src/commands/management/install.ts @@ -0,0 +1,230 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { DaemonInstallSource } from '../../contracts.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; +import { + booleanField, + jsonSchemaField, + looseObjectSchema, + requiredField, + integerField, + stringField, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + commonInputFromFlags, + direct, + request, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const installCommandMetadata = defineFieldCommandMetadata('install', 'Install an app binary.', { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), +}); + +const reinstallCommandMetadata = defineFieldCommandMetadata( + 'reinstall', + 'Reinstall an app binary.', + { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }, +); + +const installFromSourceCommandMetadata = defineFieldCommandMetadata( + 'install-from-source', + 'Install an app from a structured source.', + { + source: requiredField( + jsonSchemaField(looseObjectSchema('Install source object.')), + ), + retainPaths: booleanField(), + retentionMs: integerField(), + }, +); + +const installCommandDefinition = defineExecutableCommand(installCommandMetadata, (client, input) => + client.apps.install(input), +); + +const reinstallCommandDefinition = defineExecutableCommand( + reinstallCommandMetadata, + (client, input) => client.apps.reinstall(input), +); + +const installFromSourceCommandDefinition = defineExecutableCommand( + installFromSourceCommandMetadata, + (client, input) => client.apps.installFromSource(input), +); + +const installCliSchema = { + positionalArgs: ['app', 'path'], +} as const satisfies CommandSchemaOverride; + +const reinstallCliSchema = { + positionalArgs: ['app', 'path'], +} as const satisfies CommandSchemaOverride; + +const installFromSourceCliSchema = { + usageOverride: + 'install-from-source | install-from-source --github-actions-artifact ', + listUsageOverride: 'install-from-source', + helpDescription: + 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', + summary: 'Install app builds from URLs, remote source specs, or CI artifacts', + positionalArgs: ['url?'], + allowedFlags: ['header', 'githubActionsArtifact', 'installSource', 'retainPaths', 'retentionMs'], +} as const satisfies CommandSchemaOverride; + +const installCliReader: CliReader = (positionals, flags) => + installInputFromCli(positionals, flags, 'install'); + +const reinstallCliReader: CliReader = (positionals, flags) => + installInputFromCli(positionals, flags, 'reinstall'); + +const installFromSourceCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + source: resolveInstallSource(positionals, flags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, +}); + +const installDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.install, (input) => + requiredPair(input.app, input.appPath), +); + +const reinstallDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.reinstall, (input) => + requiredPair(input.app, input.appPath), +); + +const installFromSourceDaemonWriter: DaemonWriter = (input) => + request(INTERNAL_COMMANDS.installSource, [], { + ...input, + installSource: input.source, + retainMaterializedPaths: input.retainPaths, + materializedPathRetentionMs: input.retentionMs, + }); + +const installCommandFacet = defineCommandFacet({ + name: 'install', + metadata: installCommandMetadata, + definition: installCommandDefinition, + cliSchema: installCliSchema, + cliReader: installCliReader, + daemonWriter: installDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.install, +}); + +const reinstallCommandFacet = defineCommandFacet({ + name: 'reinstall', + metadata: reinstallCommandMetadata, + definition: reinstallCommandDefinition, + cliSchema: reinstallCliSchema, + cliReader: reinstallCliReader, + daemonWriter: reinstallDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.reinstall, +}); + +const installFromSourceCommandFacet = defineCommandFacet({ + name: 'install-from-source', + metadata: installFromSourceCommandMetadata, + definition: installFromSourceCommandDefinition, + cliSchema: installFromSourceCliSchema, + cliReader: installFromSourceCliReader, + daemonWriter: installFromSourceDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters['install-from-source'], +}); + +export const installManagementCommandFacets = [ + installCommandFacet, + reinstallCommandFacet, + installFromSourceCommandFacet, +] as const; + +function installInputFromCli( + positionals: string[], + flags: CliFlags, + command: 'install' | 'reinstall', +): Record { + return { + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], `${command} requires app`), + appPath: requiredString(positionals[1], `${command} requires path`), + }; +} + +function requiredPair(first: unknown, second: unknown): string[] { + return [ + requiredDaemonString(first, 'missing first positional'), + requiredDaemonString(second, 'missing second positional'), + ]; +} + +// fallow-ignore-next-line complexity +function resolveInstallSource(positionals: string[], flags: CliFlags) { + const url = positionals[0]?.trim(); + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source accepts either one positional or --github-actions-artifact', + ); + } + const githubArtifactSource = flags.githubActionsArtifact + ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) + : undefined; + const configuredSource = flags.installSource; + const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); + if (sourceCount !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', + ); + } + if (!url && flags.header && flags.header.length > 0) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source --header is only supported for URL sources', + ); + } + if (githubArtifactSource) return githubArtifactSource; + if (configuredSource) return configuredSource; + return { + kind: 'url' as const, + url: url!, + headers: parseInstallSourceHeaders(flags.header), + }; +} + +function parseInstallSourceHeaders( + headerFlags: CliFlags['header'], +): Record | undefined { + if (!headerFlags || headerFlags.length === 0) return undefined; + const headers: Record = {}; + for (const rawHeader of headerFlags) { + const separator = rawHeader.indexOf(':'); + if (separator <= 0) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Expected "name:value".`, + ); + } + const name = rawHeader.slice(0, separator).trim(); + const value = rawHeader.slice(separator + 1).trim(); + if (!name) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Header name cannot be empty.`, + ); + } + headers[name] = value; + } + return headers; +} diff --git a/src/commands/management/prepare.ts b/src/commands/management/prepare.ts new file mode 100644 index 000000000..2053badcf --- /dev/null +++ b/src/commands/management/prepare.ts @@ -0,0 +1,60 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { enumField, integerField, requiredField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + commonInputFromFlags, + direct, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const PREPARE_ACTION_VALUES = ['ios-runner'] as const; + +const prepareCommandMetadata = defineFieldCommandMetadata( + 'prepare', + 'Prepare platform helper infrastructure.', + { + action: requiredField(enumField(PREPARE_ACTION_VALUES)), + timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), + }, +); + +const prepareCommandDefinition = defineExecutableCommand(prepareCommandMetadata, (client, input) => + client.command.prepare(input), +); + +const prepareCliSchema = { + usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', + listUsageOverride: 'prepare', + helpDescription: + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', + summary: + 'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation', + positionalArgs: ['ios-runner'], + allowedFlags: ['timeoutMs'], +} as const satisfies CommandSchemaOverride; + +const prepareCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: requiredString(positionals[0], 'prepare requires subcommand'), + timeoutMs: flags.timeoutMs, +}); + +const prepareDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.prepare, (input) => [ + requiredDaemonString(input.action, 'prepare requires subcommand'), +]); + +export const prepareCommandFacet = defineCommandFacet({ + name: 'prepare', + metadata: prepareCommandMetadata, + definition: prepareCommandDefinition, + cliSchema: prepareCliSchema, + cliReader: prepareCliReader, + daemonWriter: prepareDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.prepare, +}); diff --git a/src/commands/management/push.ts b/src/commands/management/push.ts new file mode 100644 index 000000000..04067e63c --- /dev/null +++ b/src/commands/management/push.ts @@ -0,0 +1,115 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { AppPushOptions, AppTriggerEventOptions } from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { + jsonSchemaField, + looseObjectField, + looseObjectSchema, + requiredField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + commonInputFromFlags, + direct, + readJsonObject, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; + +const pushCommandMetadata = defineFieldCommandMetadata('push', 'Deliver a push payload.', { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField>({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), +}); + +const triggerAppEventCommandMetadata = defineFieldCommandMetadata( + 'trigger-app-event', + 'Trigger an app-defined event.', + { + event: requiredField(stringField()), + payload: looseObjectField(), + }, +); + +const pushCommandDefinition = defineExecutableCommand(pushCommandMetadata, (client, input) => + client.apps.push(input), +); + +const triggerAppEventCommandDefinition = defineExecutableCommand( + triggerAppEventCommandMetadata, + (client, input) => client.apps.triggerEvent(input), +); + +const pushCliSchema = { + listUsageOverride: 'push', + helpDescription: 'Deliver push notification payloads to an installed app.', + summary: 'Deliver push notification payloads to an installed app', + positionalArgs: ['bundleOrPackage', 'payloadOrJson'], +} as const satisfies CommandSchemaOverride; + +const triggerAppEventCliSchema = { + usageOverride: 'trigger-app-event [payloadJson]', + listUsageOverride: 'trigger-app-event', + helpDescription: + 'Invoke app-defined automation or test events with an optional structured payload.', + summary: 'Invoke app-defined automation/test events with optional structured payloads', + positionalArgs: ['event', 'payloadJson?'], +} as const satisfies CommandSchemaOverride; + +const pushCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], 'push requires bundleOrPackage'), + payload: requiredString(positionals[1], 'push requires payloadOrJson'), +}); + +const triggerAppEventCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + event: requiredString(positionals[0], 'trigger-app-event requires event'), + payload: positionals[1] ? readJsonObject(positionals[1], 'trigger-app-event payload') : undefined, +}); + +const pushDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.push, (input) => + pushPositionals(input as AppPushOptions), +); + +const triggerAppEventDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => + triggerEventPositionals(input as AppTriggerEventOptions), +); + +const pushCommandFacet = defineCommandFacet({ + name: 'push', + metadata: pushCommandMetadata, + definition: pushCommandDefinition, + cliSchema: pushCliSchema, + cliReader: pushCliReader, + daemonWriter: pushDaemonWriter, +}); + +const triggerAppEventCommandFacet = defineCommandFacet({ + name: 'trigger-app-event', + metadata: triggerAppEventCommandMetadata, + definition: triggerAppEventCommandDefinition, + cliSchema: triggerAppEventCliSchema, + cliReader: triggerAppEventCliReader, + daemonWriter: triggerAppEventDaemonWriter, +}); + +export const pushManagementCommandFacets = [pushCommandFacet, triggerAppEventCommandFacet] as const; + +function pushPositionals(input: AppPushOptions): string[] { + return [ + input.app, + typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), + ]; +} + +function triggerEventPositionals(input: AppTriggerEventOptions): string[] { + return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; +} diff --git a/src/commands/management/session.ts b/src/commands/management/session.ts new file mode 100644 index 000000000..8023bd693 --- /dev/null +++ b/src/commands/management/session.ts @@ -0,0 +1,56 @@ +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { enumField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags } from '../cli-grammar/common.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const sessionCommandMetadata = defineFieldCommandMetadata( + 'session', + 'List active sessions or print daemon state directory.', + { + action: enumField( + ['list', 'state-dir'], + 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', + ), + }, +); + +const sessionCommandDefinition = defineExecutableCommand( + sessionCommandMetadata, + async (client, { action, ...input }) => + action === 'state-dir' + ? { stateDir: await client.sessions.stateDir(input) } + : { sessions: await client.sessions.list(input) }, +); + +const sessionCliSchema = { + usageOverride: 'session list | session state-dir', + listUsageOverride: 'session', + helpDescription: 'List active sessions or print the effective daemon state directory', + positionalArgs: ['list|state-dir?'], +} as const satisfies CommandSchemaOverride; + +const sessionCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readSessionAction(positionals[0]), +}); + +export const sessionCommandFacet = defineCommandFacet({ + name: 'session', + metadata: sessionCommandMetadata, + definition: sessionCommandDefinition, + cliSchema: sessionCliSchema, + cliReader: sessionCliReader, + cliOutputFormatter: managementCliOutputFormatters.session, +}); + +function readSessionAction(value: string | undefined): 'list' | 'state-dir' { + const action = value ?? 'list'; + if (action === 'list') return action; + if (action === 'state-dir') return action; + throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); +} diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index aac8fab47..02c3fac6a 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -16,7 +16,7 @@ import { stringField, stringSchema, } from '../command-input.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import type { CliReader } from '../cli-grammar/types.ts'; @@ -80,10 +80,6 @@ const metroCliSchema = { allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], } as const satisfies CommandSchemaOverride; -const metroCliSchemas = { - [METRO_COMMAND_NAME]: metroCliSchema, -} as const satisfies Record; - export const metroCliReader: CliReader = (positionals, flags) => { const action = (positionals[0] ?? '').toLowerCase(); if (action !== 'prepare' && action !== 'reload') { @@ -130,17 +126,18 @@ export const metroCliReader: CliReader = (positionals, flags) => { }; }; -const metroCliReaders = { - metro: metroCliReader, -} satisfies Record; +const metroCommandFacet = defineCommandFacet({ + name: METRO_COMMAND_NAME, + metadata: metroCommandMetadata, + definition: metroCommandDefinition, + cliSchema: metroCliSchema, + cliReader: metroCliReader, + cliOutputFormatter: metroCliOutputFormatters.metro, +}); -export const metroCommandFamily = defineCommandFamily({ +export const metroCommandFamily = defineCommandFamilyFromFacets({ name: 'metro', - metadata: [metroCommandMetadata], - definitions: [metroCommandDefinition], - cliSchemas: metroCliSchemas, - cliReaders: metroCliReaders, - cliOutputFormatters: metroCliOutputFormatters, + commands: [metroCommandFacet], }); function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 60b647ce2..bfb7b865b 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -3,7 +3,7 @@ import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts. import { AppError } from '../../utils/errors.ts'; import { parseStringMember } from '../../utils/string-enum.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { booleanField, enumField, integerField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -46,8 +46,6 @@ export const networkCommandMetadata = defineFieldCommandMetadata( }, ); -const observabilityCommandMetadata = [logsCommandMetadata, networkCommandMetadata] as const; - export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata, (client, input) => client.observability.logs(input), ); @@ -57,8 +55,6 @@ export const networkCommandDefinition = defineExecutableCommand( (client, input) => client.observability.network(input), ); -const observabilityCommandDefinitions = [logsCommandDefinition, networkCommandDefinition] as const; - const logsCliSchema = { usageOverride: 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', @@ -80,11 +76,6 @@ const networkCliSchema = { allowedFlags: ['networkInclude'], } as const satisfies CommandSchemaOverride; -const observabilityCliSchemas = { - [LOGS_COMMAND_NAME]: logsCliSchema, - [NETWORK_COMMAND_NAME]: networkCliSchema, -} as const satisfies Record; - export const logsCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readLogsAction(positionals[0]), @@ -99,11 +90,6 @@ export const networkCliReader: CliReader = (positionals, flags) => ({ include: flags.networkInclude ?? readNetworkInclude(positionals[2]), }); -const observabilityCliReaders = { - logs: logsCliReader, - network: networkCliReader, -} satisfies Record; - export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input) => logsPositionals(input as LogsOptions), ); @@ -114,19 +100,29 @@ export const networkDaemonWriter: DaemonWriter = (input) => networkInclude: input.include, }); -const observabilityDaemonWriters = { - logs: logsDaemonWriter, - network: networkDaemonWriter, -} satisfies Record; +const logsCommandFacet = defineCommandFacet({ + name: LOGS_COMMAND_NAME, + metadata: logsCommandMetadata, + definition: logsCommandDefinition, + cliSchema: logsCliSchema, + cliReader: logsCliReader, + daemonWriter: logsDaemonWriter, + cliOutputFormatter: observabilityCliOutputFormatters.logs, +}); + +const networkCommandFacet = defineCommandFacet({ + name: NETWORK_COMMAND_NAME, + metadata: networkCommandMetadata, + definition: networkCommandDefinition, + cliSchema: networkCliSchema, + cliReader: networkCliReader, + daemonWriter: networkDaemonWriter, + cliOutputFormatter: observabilityCliOutputFormatters.network, +}); -export const observabilityCommandFamily = defineCommandFamily({ +export const observabilityCommandFamily = defineCommandFamilyFromFacets({ name: 'observability', - metadata: observabilityCommandMetadata, - definitions: observabilityCommandDefinitions, - cliSchemas: observabilityCliSchemas, - cliReaders: observabilityCliReaders, - daemonWriters: observabilityDaemonWriters, - cliOutputFormatters: observabilityCliOutputFormatters, + commands: [logsCommandFacet, networkCommandFacet], }); function logsPositionals(input: { action?: string; message?: string }): string[] { diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index ccf17d8db..cd79c6f25 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -2,7 +2,7 @@ import type { PerfOptions } from '../../client-types.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { enumField, stringField } from '../command-input.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { @@ -45,14 +45,10 @@ export const perfCommandMetadata = defineFieldCommandMetadata( }, ); -const perfCommandMetadataList = [perfCommandMetadata] as const; - export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => client.observability.perf(input), ); -const perfCommandDefinitions = [perfCommandDefinition] as const; - const perfCliSchema = { usageOverride: 'perf metrics --json\n agent-device perf frames --json\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start --kind xctrace [--template ] --out \n agent-device perf cpu profile stop --kind xctrace --out \n agent-device perf cpu profile report --kind xctrace --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start --kind simpleperf --out \n agent-device perf cpu profile stop --kind simpleperf\n agent-device perf cpu profile report --kind simpleperf --out \n agent-device perf trace start|stop --kind perfetto [--out ]', @@ -64,10 +60,6 @@ const perfCliSchema = { allowedFlags: ['kind', 'perfTemplate', 'out'], } as const satisfies CommandSchemaOverride; -const perfCliSchemas = { - [PERF_COMMAND_NAME]: perfCliSchema, -} as const satisfies Record; - export const perfCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), ...readPerfPositionals(positionals, { @@ -77,26 +69,23 @@ export const perfCliReader: CliReader = (positionals, flags) => ({ }), }); -const perfCliReaders = { - perf: perfCliReader, -} satisfies Record; - export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) => perfPositionals(input as PerfOptions), ); -const perfDaemonWriters = { - perf: perfDaemonWriter, -} satisfies Record; +const perfCommandFacet = defineCommandFacet({ + name: PERF_COMMAND_NAME, + metadata: perfCommandMetadata, + definition: perfCommandDefinition, + cliSchema: perfCliSchema, + cliReader: perfCliReader, + daemonWriter: perfDaemonWriter, + cliOutputFormatter: perfCliOutputFormatters.perf, +}); -export const perfCommandFamily = defineCommandFamily({ +export const perfCommandFamily = defineCommandFamilyFromFacets({ name: 'perf', - metadata: perfCommandMetadataList, - definitions: perfCommandDefinitions, - cliSchemas: perfCliSchemas, - cliReaders: perfCliReaders, - daemonWriters: perfDaemonWriters, - cliOutputFormatters: perfCliOutputFormatters, + commands: [perfCommandFacet], }); function perfPositionals(input: PerfOptions): string[] { diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index 19b036a8d..d2274c55c 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -1,6 +1,6 @@ import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { enumField, requiredField } from '../command-input.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -31,34 +31,27 @@ const reactNativeCliSchema = { positionalArgs: ['dismiss-overlay'], } as const satisfies CommandSchemaOverride; -const reactNativeCliSchemas = { - [REACT_NATIVE_COMMAND_NAME]: reactNativeCliSchema, -} as const satisfies Record; - export const reactNativeCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readReactNativeAction(positionals[0]), }); -const reactNativeCliReaders = { - 'react-native': reactNativeCliReader, -} satisfies Record; - export const reactNativeDaemonWriter: DaemonWriter = direct(REACT_NATIVE_COMMAND_NAME, (input) => [ requiredDaemonString(input.action, 'react-native requires action'), ]); -const reactNativeDaemonWriters = { - 'react-native': reactNativeDaemonWriter, -} satisfies Record; +const reactNativeCommandFacet = defineCommandFacet({ + name: REACT_NATIVE_COMMAND_NAME, + metadata: reactNativeCommandMetadata, + definition: reactNativeCommandDefinition, + cliSchema: reactNativeCliSchema, + cliReader: reactNativeCliReader, + daemonWriter: reactNativeDaemonWriter, +}); -export const reactNativeCommandFamily = defineCommandFamily({ +export const reactNativeCommandFamily = defineCommandFamilyFromFacets({ name: 'react-native', - metadata: [reactNativeCommandMetadata], - definitions: [reactNativeCommandDefinition], - cliSchemas: reactNativeCliSchemas, - cliReaders: reactNativeCliReaders, - daemonWriters: reactNativeDaemonWriters, + commands: [reactNativeCommandFacet], }); function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index 0f4196fd3..a931a7c4b 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -2,7 +2,7 @@ import type { RecordOptions } from '../../client-types.ts'; import { RECORDING_EXPORT_QUALITIES } from '../../core/recording-export-quality.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, @@ -45,8 +45,6 @@ export const traceCommandMetadata = defineFieldCommandMetadata( }, ); -const recordingCommandMetadata = [recordCommandMetadata, traceCommandMetadata] as const; - export const recordCommandDefinition = defineExecutableCommand( recordCommandMetadata, (client, input) => client.recording.record(input as RecordOptions), @@ -57,8 +55,6 @@ export const traceCommandDefinition = defineExecutableCommand( (client, input) => client.recording.trace(input), ); -const recordingCommandDefinitions = [recordCommandDefinition, traceCommandDefinition] as const; - const recordCliSchema = { usageOverride: 'record start [path] [--fps ] [--max-size ] [--quality ] [--hide-touches] | record stop', @@ -79,11 +75,6 @@ const traceCliSchema = { positionalArgs: ['start|stop', 'path?'], } as const satisfies CommandSchemaOverride; -const recordingCliSchemas = { - [RECORD_COMMAND_NAME]: recordCliSchema, - [TRACE_COMMAND_NAME]: traceCliSchema, -} as const satisfies Record; - export const recordCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readRecordingAction(positionals[0], RECORD_COMMAND_NAME), @@ -100,11 +91,6 @@ export const traceCliReader: CliReader = (positionals, flags) => ({ path: positionals[1], }); -const recordingCliReaders = { - record: recordCliReader, - trace: traceCliReader, -} satisfies Record; - export const recordDaemonWriter: DaemonWriter = direct(RECORD_COMMAND_NAME, (input) => recordingPositionals(input as RecordOptions), ); @@ -113,19 +99,28 @@ export const traceDaemonWriter: DaemonWriter = direct(TRACE_COMMAND_NAME, (input recordingPositionals(input as RecordOptions), ); -const recordingDaemonWriters = { - record: recordDaemonWriter, - trace: traceDaemonWriter, -} satisfies Record; +const recordCommandFacet = defineCommandFacet({ + name: RECORD_COMMAND_NAME, + metadata: recordCommandMetadata, + definition: recordCommandDefinition, + cliSchema: recordCliSchema, + cliReader: recordCliReader, + daemonWriter: recordDaemonWriter, + cliOutputFormatter: recordingCliOutputFormatters.record, +}); + +const traceCommandFacet = defineCommandFacet({ + name: TRACE_COMMAND_NAME, + metadata: traceCommandMetadata, + definition: traceCommandDefinition, + cliSchema: traceCliSchema, + cliReader: traceCliReader, + daemonWriter: traceDaemonWriter, +}); -export const recordingCommandFamily = defineCommandFamily({ +export const recordingCommandFamily = defineCommandFamilyFromFacets({ name: 'recording', - metadata: recordingCommandMetadata, - definitions: recordingCommandDefinitions, - cliSchemas: recordingCliSchemas, - cliReaders: recordingCliReaders, - daemonWriters: recordingDaemonWriters, - cliOutputFormatters: recordingCliOutputFormatters, + commands: [recordCommandFacet, traceCommandFacet], }); function recordingPositionals(input: RecordOptions): string[] { diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 25c441180..09569ba95 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -1,5 +1,5 @@ import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, @@ -58,8 +58,6 @@ export const testCommandMetadata = defineFieldCommandMetadata( }, ); -const replayCommandMetadataList = [replayCommandMetadata, testCommandMetadata] as const; - export const replayCommandDefinition = defineExecutableCommand( replayCommandMetadata, (client, input) => client.replay.run(input), @@ -69,8 +67,6 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata client.replay.test(input), ); -const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; - const replayCliSchema = { usageOverride: 'replay | replay export [--format maestro] [--out ]', helpDescription: @@ -102,11 +98,6 @@ const testCliSchema = { ], } as const satisfies CommandSchemaOverride; -const replayCliSchemas = { - [REPLAY_COMMAND_NAME]: replayCliSchema, - [TEST_COMMAND_NAME]: testCliSchema, -} as const satisfies Record; - export const replayCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), path: requiredString(positionals[0], 'replay requires path'), @@ -149,23 +140,27 @@ export const testDaemonWriter: DaemonWriter = (input) => replayShellEnv: collectReplayClientShellEnv(process.env), }); -const replayCliReaders = { - replay: replayCliReader, - test: testCliReader, -} satisfies Record; +const replayCommandFacet = defineCommandFacet({ + name: REPLAY_COMMAND_NAME, + metadata: replayCommandMetadata, + definition: replayCommandDefinition, + cliSchema: replayCliSchema, + cliReader: replayCliReader, + daemonWriter: replayDaemonWriter, +}); -const replayDaemonWriters = { - replay: replayDaemonWriter, - test: testDaemonWriter, -} satisfies Record; +const testCommandFacet = defineCommandFacet({ + name: TEST_COMMAND_NAME, + metadata: testCommandMetadata, + definition: testCommandDefinition, + cliSchema: testCliSchema, + cliReader: testCliReader, + daemonWriter: testDaemonWriter, +}); -export const replayCommandFamily = defineCommandFamily({ +export const replayCommandFamily = defineCommandFamilyFromFacets({ name: 'replay', - metadata: replayCommandMetadataList, - definitions: replayCommandDefinitions, - cliSchemas: replayCliSchemas, - cliReaders: replayCliReaders, - daemonWriters: replayDaemonWriters, + commands: [replayCommandFacet, testCommandFacet], }); function readReplayBackend(input: CommandInput): string | undefined { diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 06fbfcf35..fb157f8bc 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -4,7 +4,7 @@ import { BACK_MODES } from '../../core/back-mode.ts'; import { parseDeviceRotation, DEVICE_ROTATIONS } from '../../core/device-rotation.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { defineCommandFamily } from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { compactRecord, enumField, requiredField, stringField } from '../command-input.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -84,16 +84,6 @@ const clipboardCommandMetadata = defineFieldCommandMetadata( }, ); -const systemCommandMetadata = [ - appStateCommandMetadata, - backCommandMetadata, - homeCommandMetadata, - rotateCommandMetadata, - appSwitcherCommandMetadata, - keyboardCommandMetadata, - clipboardCommandMetadata, -] as const; - const appStateCommandDefinition = defineExecutableCommand( appStateCommandMetadata, (client, input) => client.command.appState(input), @@ -126,16 +116,6 @@ const clipboardCommandDefinition = defineExecutableCommand( (client, input) => client.command.clipboard(input as ClipboardCommandOptions), ); -const systemCommandDefinitions = [ - appStateCommandDefinition, - backCommandDefinition, - homeCommandDefinition, - rotateCommandDefinition, - appSwitcherCommandDefinition, - keyboardCommandDefinition, - clipboardCommandDefinition, -] as const; - const appStateCliSchema = { helpDescription: 'Show foreground app/activity', } as const satisfies CommandSchemaOverride; @@ -167,14 +147,6 @@ const clipboardCliSchema = { allowsExtraPositionals: true, } as const satisfies CommandSchemaOverride; -const systemCliSchemas = { - [APPSTATE_COMMAND_NAME]: appStateCliSchema, - [BACK_COMMAND_NAME]: backCliSchema, - [ROTATE_COMMAND_NAME]: rotateCliSchema, - [KEYBOARD_COMMAND_NAME]: keyboardCliSchema, - [CLIPBOARD_COMMAND_NAME]: clipboardCliSchema, -} as const satisfies Record; - export const appStateCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); export const homeCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); export const appSwitcherCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); @@ -199,16 +171,6 @@ export const clipboardCliReader: CliReader = (positionals, flags) => ({ ...readClipboardInput(positionals), }); -const systemCliReaders = { - appstate: appStateCliReader, - home: homeCliReader, - 'app-switcher': appSwitcherCliReader, - back: backCliReader, - rotate: rotateCliReader, - keyboard: keyboardCliReader, - clipboard: clipboardCliReader, -} satisfies Record; - export const appStateDaemonWriter: DaemonWriter = direct(APPSTATE_COMMAND_NAME); export const backDaemonWriter: DaemonWriter = (input) => @@ -230,24 +192,85 @@ export const clipboardDaemonWriter: DaemonWriter = direct(CLIPBOARD_COMMAND_NAME clipboardPositionals(input as ClipboardCommandOptions), ); -const systemDaemonWriters = { - appstate: appStateDaemonWriter, - back: backDaemonWriter, - home: homeDaemonWriter, - rotate: rotateDaemonWriter, - 'app-switcher': appSwitcherDaemonWriter, - keyboard: keyboardDaemonWriter, - clipboard: clipboardDaemonWriter, -} satisfies Record; - -export const systemCommandFamily = defineCommandFamily({ +const appStateCommandFacet = defineCommandFacet({ + name: APPSTATE_COMMAND_NAME, + metadata: appStateCommandMetadata, + definition: appStateCommandDefinition, + cliSchema: appStateCliSchema, + cliReader: appStateCliReader, + daemonWriter: appStateDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.appstate, +}); + +const backCommandFacet = defineCommandFacet({ + name: BACK_COMMAND_NAME, + metadata: backCommandMetadata, + definition: backCommandDefinition, + cliSchema: backCliSchema, + cliReader: backCliReader, + daemonWriter: backDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.back, +}); + +const homeCommandFacet = defineCommandFacet({ + name: HOME_COMMAND_NAME, + metadata: homeCommandMetadata, + definition: homeCommandDefinition, + cliReader: homeCliReader, + daemonWriter: homeDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.home, +}); + +const rotateCommandFacet = defineCommandFacet({ + name: ROTATE_COMMAND_NAME, + metadata: rotateCommandMetadata, + definition: rotateCommandDefinition, + cliSchema: rotateCliSchema, + cliReader: rotateCliReader, + daemonWriter: rotateDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.rotate, +}); + +const appSwitcherCommandFacet = defineCommandFacet({ + name: APP_SWITCHER_COMMAND_NAME, + metadata: appSwitcherCommandMetadata, + definition: appSwitcherCommandDefinition, + cliReader: appSwitcherCliReader, + daemonWriter: appSwitcherDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters['app-switcher'], +}); + +const keyboardCommandFacet = defineCommandFacet({ + name: KEYBOARD_COMMAND_NAME, + metadata: keyboardCommandMetadata, + definition: keyboardCommandDefinition, + cliSchema: keyboardCliSchema, + cliReader: keyboardCliReader, + daemonWriter: keyboardDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.keyboard, +}); + +const clipboardCommandFacet = defineCommandFacet({ + name: CLIPBOARD_COMMAND_NAME, + metadata: clipboardCommandMetadata, + definition: clipboardCommandDefinition, + cliSchema: clipboardCliSchema, + cliReader: clipboardCliReader, + daemonWriter: clipboardDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.clipboard, +}); + +export const systemCommandFamily = defineCommandFamilyFromFacets({ name: 'system', - metadata: systemCommandMetadata, - definitions: systemCommandDefinitions, - cliSchemas: systemCliSchemas, - cliReaders: systemCliReaders, - daemonWriters: systemDaemonWriters, - cliOutputFormatters: systemCliOutputFormatters, + commands: [ + appStateCommandFacet, + backCommandFacet, + homeCommandFacet, + rotateCommandFacet, + appSwitcherCommandFacet, + keyboardCommandFacet, + clipboardCommandFacet, + ], }); function readBackMode(value: unknown): BackMode | undefined { diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index 0d8b0e199..e35490f14 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -709,7 +709,8 @@ test('installAndroidApp installs .aab via bundletool build-apks + install-apks', ' fi', ' shift', ' done', - ' mkdir -p "$(dirname "$out")"', + ' # PATH is narrowed to the fake tools dir; test output paths are absolute.', + ' /bin/mkdir -p "${out%/*}"', ' printf "apks" > "$out"', ' exit 0', 'fi', @@ -726,7 +727,7 @@ test('installAndroidApp installs .aab via bundletool build-apks + install-apks', const previousPath = process.env.PATH; const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; const previousBundletoolJar = process.env.AGENT_DEVICE_BUNDLETOOL_JAR; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.PATH = tmpDir; process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; delete process.env.AGENT_DEVICE_BUNDLETOOL_JAR; @@ -772,7 +773,7 @@ test('installAndroidApp .aab reports missing bundletool tooling', async () => { const previousPath = process.env.PATH; const previousBundletoolJar = process.env.AGENT_DEVICE_BUNDLETOOL_JAR; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.PATH = tmpDir; delete process.env.AGENT_DEVICE_BUNDLETOOL_JAR; const device: DeviceInfo = {