Skip to content

Commit 22fba87

Browse files
authored
feat: add shutdown command (#718)
* feat: add shutdown command * fix: address shutdown review feedback * fix: reject active session shutdown targets * fix: preserve shutdown failure details * fix: satisfy shutdown fallow audit * fix: simplify shutdown session handling
1 parent 1de7e73 commit 22fba87

33 files changed

Lines changed: 637 additions & 58 deletions

src/__tests__/cli-client-commands.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,7 @@ function createStubClient(params: {
994994
devices: {
995995
list: async () => [],
996996
boot: unexpectedCommandCall,
997+
shutdown: unexpectedCommandCall,
997998
},
998999
sessions: {
9991000
list: async () => [],

src/__tests__/runtime-admin-router.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ test('admin runtime commands call typed backend primitives', async () => {
3939
const boot = await device.admin.boot({ target: { id: 'SIM-1' } });
4040
assert.equal(boot.kind, 'deviceBooted');
4141

42+
const shutdown = await device.admin.shutdown({ target: { id: 'SIM-1' } });
43+
assert.equal(shutdown.kind, 'deviceShutdown');
44+
4245
const installed = await device.admin.install({
4346
app: 'com.example.app',
4447
source: { kind: 'path', path: '/tmp/Example.app' },
@@ -60,6 +63,7 @@ test('admin runtime commands call typed backend primitives', async () => {
6063
assert.deepEqual(calls, [
6164
'listDevices',
6265
'bootDevice',
66+
'shutdownDevice',
6367
'installApp',
6468
'reinstallApp',
6569
'installApp',
@@ -244,6 +248,9 @@ function createAdminBackend(
244248
bootDevice: async () => {
245249
calls.push('bootDevice');
246250
},
251+
shutdownDevice: async () => {
252+
calls.push('shutdownDevice');
253+
},
247254
installApp: async (_context, target) => {
248255
calls.push('installApp');
249256
onInstallSource?.(target.source);

src/backend.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,10 @@ export type AgentDeviceBackend = {
531531
context: BackendCommandContext,
532532
target?: BackendDeviceTarget,
533533
): Promise<BackendActionResult>;
534+
shutdownDevice?(
535+
context: BackendCommandContext,
536+
target?: BackendDeviceTarget,
537+
): Promise<BackendActionResult>;
534538
resolveInstallSource?(
535539
context: BackendCommandContext,
536540
source: BackendInstallSource,

src/client-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,8 @@ export type DeviceBootOptions = ClientCommandBaseOptions & {
561561
headless?: boolean;
562562
};
563563

564+
export type DeviceShutdownOptions = ClientCommandBaseOptions;
565+
564566
export type AppPushOptions = ClientCommandBaseOptions & {
565567
app: string;
566568
payload: string | Record<string, unknown>;
@@ -908,6 +910,7 @@ export type AgentDeviceClient = {
908910
options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions,
909911
) => Promise<AgentDeviceDevice[]>;
910912
boot: (options?: DeviceBootOptions) => Promise<CommandRequestResult>;
913+
shutdown: (options?: DeviceShutdownOptions) => Promise<CommandRequestResult>;
911914
};
912915
sessions: {
913916
list: (options?: AgentDeviceRequestOverrides) => Promise<AgentDeviceSession[]>;

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function createAgentDeviceClient(
105105
return devices.map(normalizeDevice);
106106
},
107107
boot: async (options = {}) => await executeCommand('boot', options),
108+
shutdown: async (options = {}) => await executeCommand('shutdown', options),
108109
},
109110
sessions: {
110111
list: async (options = {}) => await listSessions(options),

src/command-catalog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const PUBLIC_COMMANDS = {
3737
scroll: 'scroll',
3838
screenshot: 'screenshot',
3939
settings: 'settings',
40+
shutdown: 'shutdown',
4041
snapshot: 'snapshot',
4142
swipe: 'swipe',
4243
test: 'test',
@@ -82,6 +83,7 @@ export type ClientBackedCliCommandName =
8283
export const BATCH_COMMAND_NAMES = [
8384
PUBLIC_COMMANDS.devices,
8485
PUBLIC_COMMANDS.boot,
86+
PUBLIC_COMMANDS.shutdown,
8587
PUBLIC_COMMANDS.apps,
8688
PUBLIC_COMMANDS.open,
8789
PUBLIC_COMMANDS.close,

src/commands/admin.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ export type AdminBootCommandResult = {
3636
target?: BackendDeviceTarget;
3737
} & BackendResultEnvelope;
3838

39+
export type AdminShutdownCommandOptions = CommandContext & {
40+
target?: BackendDeviceTarget;
41+
};
42+
43+
export type AdminShutdownCommandResult = {
44+
kind: 'deviceShutdown';
45+
target?: BackendDeviceTarget;
46+
backendResult?: Record<string, unknown>;
47+
message?: string;
48+
};
49+
3950
export type AdminInstallCommandOptions = CommandContext & {
4051
app: string;
4152
source: BackendInstallSource;
@@ -95,6 +106,27 @@ export const bootCommand: RuntimeCommand<
95106
};
96107
};
97108

109+
export const shutdownCommand: RuntimeCommand<
110+
AdminShutdownCommandOptions | undefined,
111+
AdminShutdownCommandResult
112+
> = async (runtime, options = {}): Promise<AdminShutdownCommandResult> => {
113+
if (!runtime.backend.shutdownDevice) {
114+
throw new AppError('UNSUPPORTED_OPERATION', 'admin.shutdown is not supported by this backend');
115+
}
116+
const target = normalizeDeviceTarget(options.target);
117+
const backendResult = await runtime.backend.shutdownDevice(
118+
toBackendContext(runtime, options),
119+
target,
120+
);
121+
const formattedBackendResult = toBackendResult(backendResult);
122+
return {
123+
kind: 'deviceShutdown',
124+
...(target ? { target } : {}),
125+
...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}),
126+
...successText('Shutdown device'),
127+
};
128+
};
129+
98130
export const installCommand: RuntimeCommand<
99131
AdminInstallCommandOptions,
100132
AdminInstallCommandResult

src/commands/cli-grammar/apps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const appCliReaders = {
2929
...commonInputFromFlags(flags),
3030
headless: flags.headless,
3131
}),
32+
shutdown: (_positionals, flags) => commonInputFromFlags(flags),
3233
prepare: (positionals, flags) => ({
3334
...commonInputFromFlags(flags),
3435
action: requiredString(positionals[0], 'prepare requires subcommand'),
@@ -77,6 +78,7 @@ export const appCliReaders = {
7778
export const appDaemonWriters = {
7879
devices: direct(PUBLIC_COMMANDS.devices),
7980
boot: direct(PUBLIC_COMMANDS.boot),
81+
shutdown: direct(PUBLIC_COMMANDS.shutdown),
8082
prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [
8183
requiredDaemonString(input.action, 'prepare requires subcommand'),
8284
]),

src/commands/cli-output.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
openCliOutput,
2020
recordCliOutput,
2121
sessionCliOutput,
22+
shutdownCliOutput,
2223
snapshotCliOutput,
2324
tapCliOutput,
2425
} from './client-output.ts';
@@ -42,6 +43,7 @@ const messageOutput = resultOutput(messageCliOutput);
4243

4344
const cliOutputFormatters: Partial<Record<CommandName, CliOutputFormatter>> = {
4445
boot: resultOutput(bootCliOutput),
46+
shutdown: resultOutput(shutdownCliOutput),
4547
click: resultOutput(tapCliOutput),
4648
press: resultOutput(tapCliOutput),
4749
batch: resultOutput(batchCliOutput),

src/commands/client-command-contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & Metro
2222
export const clientCommandDefinitions = [
2323
defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)),
2424
defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)),
25+
defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)),
2526
defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)),
2627
defineExecutableCommand(metadata('session'), async (client, { action: _action, ...input }) => ({
2728
sessions: await client.sessions.list(input),

0 commit comments

Comments
 (0)