@devicecloud.dev/dcd 4.4.9 → 5.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +75 -2
  2. package/dist/commands/artifacts.d.ts +47 -18
  3. package/dist/commands/artifacts.js +69 -64
  4. package/dist/commands/cloud.d.ts +228 -88
  5. package/dist/commands/cloud.js +430 -342
  6. package/dist/commands/list.d.ts +39 -38
  7. package/dist/commands/list.js +124 -131
  8. package/dist/commands/live.d.ts +2 -0
  9. package/dist/commands/live.js +520 -0
  10. package/dist/commands/login.d.ts +17 -0
  11. package/dist/commands/login.js +252 -0
  12. package/dist/commands/logout.d.ts +2 -0
  13. package/dist/commands/logout.js +30 -0
  14. package/dist/commands/status.d.ts +23 -42
  15. package/dist/commands/status.js +170 -179
  16. package/dist/commands/switch-org.d.ts +12 -0
  17. package/dist/commands/switch-org.js +76 -0
  18. package/dist/commands/upgrade.d.ts +2 -0
  19. package/dist/commands/upgrade.js +120 -0
  20. package/dist/commands/upload.d.ts +33 -18
  21. package/dist/commands/upload.js +72 -78
  22. package/dist/commands/whoami.d.ts +2 -0
  23. package/dist/commands/whoami.js +31 -0
  24. package/dist/config/environments.d.ts +31 -0
  25. package/dist/config/environments.js +52 -0
  26. package/dist/config/flags/api.flags.d.ts +10 -2
  27. package/dist/config/flags/api.flags.js +13 -14
  28. package/dist/config/flags/binary.flags.d.ts +17 -4
  29. package/dist/config/flags/binary.flags.js +14 -18
  30. package/dist/config/flags/device.flags.d.ts +49 -11
  31. package/dist/config/flags/device.flags.js +43 -38
  32. package/dist/config/flags/environment.flags.d.ts +27 -6
  33. package/dist/config/flags/environment.flags.js +24 -29
  34. package/dist/config/flags/execution.flags.d.ts +35 -8
  35. package/dist/config/flags/execution.flags.js +31 -41
  36. package/dist/config/flags/github.flags.d.ts +23 -5
  37. package/dist/config/flags/github.flags.js +19 -15
  38. package/dist/config/flags/output.flags.d.ts +57 -13
  39. package/dist/config/flags/output.flags.js +48 -47
  40. package/dist/constants.d.ts +218 -51
  41. package/dist/constants.js +17 -20
  42. package/dist/gateways/api-gateway.d.ts +72 -16
  43. package/dist/gateways/api-gateway.js +298 -104
  44. package/dist/gateways/cli-auth-gateway.d.ts +13 -0
  45. package/dist/gateways/cli-auth-gateway.js +54 -0
  46. package/dist/gateways/realtime-gateway.d.ts +32 -0
  47. package/dist/gateways/realtime-gateway.js +103 -0
  48. package/dist/gateways/supabase-gateway.d.ts +11 -11
  49. package/dist/gateways/supabase-gateway.js +20 -48
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.js +98 -4
  52. package/dist/mcp/context.d.ts +33 -0
  53. package/dist/mcp/context.js +33 -0
  54. package/dist/mcp/helpers.d.ts +16 -0
  55. package/dist/mcp/helpers.js +34 -0
  56. package/dist/mcp/index.d.ts +2 -0
  57. package/dist/mcp/index.js +24 -0
  58. package/dist/mcp/server.d.ts +7 -0
  59. package/dist/mcp/server.js +27 -0
  60. package/dist/mcp/tools/download-artifacts.d.ts +11 -0
  61. package/dist/mcp/tools/download-artifacts.js +84 -0
  62. package/dist/mcp/tools/get-status.d.ts +7 -0
  63. package/dist/mcp/tools/get-status.js +39 -0
  64. package/dist/mcp/tools/list-devices.d.ts +7 -0
  65. package/dist/mcp/tools/list-devices.js +27 -0
  66. package/dist/mcp/tools/list-runs.d.ts +3 -0
  67. package/dist/mcp/tools/list-runs.js +60 -0
  68. package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
  69. package/dist/mcp/tools/run-cloud-test.js +233 -0
  70. package/dist/methods.d.ts +34 -5
  71. package/dist/methods.js +266 -215
  72. package/dist/services/device-validation.service.d.ts +9 -1
  73. package/dist/services/device-validation.service.js +56 -40
  74. package/dist/services/execution-plan.service.js +40 -31
  75. package/dist/services/execution-plan.utils.d.ts +3 -0
  76. package/dist/services/execution-plan.utils.js +25 -55
  77. package/dist/services/flow-paths.d.ts +17 -0
  78. package/dist/services/flow-paths.js +52 -0
  79. package/dist/services/metadata-extractor.service.d.ts +0 -2
  80. package/dist/services/metadata-extractor.service.js +75 -78
  81. package/dist/services/moropo.service.js +33 -34
  82. package/dist/services/report-download.service.d.ts +12 -1
  83. package/dist/services/report-download.service.js +34 -27
  84. package/dist/services/results-polling.service.d.ts +23 -9
  85. package/dist/services/results-polling.service.js +257 -123
  86. package/dist/services/telemetry.service.d.ts +49 -0
  87. package/dist/services/telemetry.service.js +252 -0
  88. package/dist/services/test-submission.service.d.ts +21 -4
  89. package/dist/services/test-submission.service.js +51 -33
  90. package/dist/services/version.service.d.ts +4 -3
  91. package/dist/services/version.service.js +28 -16
  92. package/dist/types/domain/auth.types.d.ts +20 -0
  93. package/dist/types/domain/auth.types.js +1 -0
  94. package/dist/types/domain/device.types.js +8 -11
  95. package/dist/types/domain/live.types.d.ts +76 -0
  96. package/dist/types/domain/live.types.js +3 -0
  97. package/dist/types/generated/schema.types.js +1 -2
  98. package/dist/types/index.d.ts +2 -2
  99. package/dist/types/index.js +2 -18
  100. package/dist/types.js +1 -2
  101. package/dist/utils/auth.d.ts +13 -0
  102. package/dist/utils/auth.js +141 -0
  103. package/dist/utils/ci.d.ts +12 -0
  104. package/dist/utils/ci.js +39 -0
  105. package/dist/utils/cli.d.ts +35 -0
  106. package/dist/utils/cli.js +118 -0
  107. package/dist/utils/compatibility.d.ts +2 -1
  108. package/dist/utils/compatibility.js +6 -8
  109. package/dist/utils/config-store.d.ts +35 -0
  110. package/dist/utils/config-store.js +115 -0
  111. package/dist/utils/connectivity.js +8 -7
  112. package/dist/utils/expo.js +29 -24
  113. package/dist/utils/orgs.d.ts +11 -0
  114. package/dist/utils/orgs.js +36 -0
  115. package/dist/utils/paths.d.ts +11 -0
  116. package/dist/utils/paths.js +21 -0
  117. package/dist/utils/progress.d.ts +13 -0
  118. package/dist/utils/progress.js +47 -0
  119. package/dist/utils/styling.d.ts +42 -36
  120. package/dist/utils/styling.js +78 -82
  121. package/dist/utils/ui.d.ts +41 -0
  122. package/dist/utils/ui.js +95 -0
  123. package/package.json +36 -45
  124. package/bin/dev.cmd +0 -3
  125. package/bin/dev.js +0 -6
  126. package/bin/run.cmd +0 -3
  127. package/bin/run.js +0 -7
  128. package/dist/types/schema.types.d.ts +0 -2702
  129. package/dist/types/schema.types.js +0 -3
  130. package/oclif.manifest.json +0 -884
@@ -0,0 +1,520 @@
1
+ import { defineCommand } from 'citty';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { resolveFrontendUrl } from '../config/environments.js';
4
+ import { apiFlags } from '../config/flags/api.flags.js';
5
+ import { ApiGateway } from '../gateways/api-gateway.js';
6
+ import { resolveAuth } from '../utils/auth.js';
7
+ import { CliError, logger, validateEnum } from '../utils/cli.js';
8
+ import { resolveApiUrl } from '../utils/config-store.js';
9
+ import { colors, formatUrl } from '../utils/styling.js';
10
+ import { ui } from '../utils/ui.js';
11
+ const PLATFORM_OPTIONS = ['android', 'ios'];
12
+ const READY_TIMEOUT_MS = 180_000;
13
+ const READY_POLL_MS = 2_000;
14
+ async function requireAuth(keyFlag) {
15
+ return resolveAuth({ apiKeyFlag: keyFlag });
16
+ }
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+ /**
21
+ * Poll the session until it reports `ready` (RUNNING + device streaming),
22
+ * surfacing each new device phase so the user sees "Cloning device…",
23
+ * "Installing app…", etc. Removes the need to hand-roll retry loops around the
24
+ * 425/409 "not ready" window that `exec` returns right after start/install.
25
+ */
26
+ async function waitForReady(apiUrl, auth, sessionName, timeoutMs = READY_TIMEOUT_MS) {
27
+ const deadline = Date.now() + timeoutMs;
28
+ let lastPhase = '';
29
+ for (;;) {
30
+ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName);
31
+ if (session.ready)
32
+ return session;
33
+ if (['CANCELLED', 'FAILED', 'STOPPED'].includes(session.status?.toUpperCase?.() ?? '')) {
34
+ throw new CliError(`Session ${sessionName} is ${session.status}; cannot become ready.`);
35
+ }
36
+ const phase = session.device_state?.phase_label ?? session.device_state?.phase ?? session.status;
37
+ if (phase && phase !== lastPhase) {
38
+ lastPhase = phase;
39
+ logger.log(` ${colors.dim(phase)}`);
40
+ }
41
+ if (Date.now() >= deadline) {
42
+ throw new CliError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the device to be ready` +
43
+ (phase ? ` (last phase: ${phase})` : '') +
44
+ '.');
45
+ }
46
+ await sleep(READY_POLL_MS);
47
+ }
48
+ }
49
+ /**
50
+ * Strip a Maestro flow header (`appId: ...` config block up to and including the
51
+ * first `---`) so a whole flow file can be fed to the exec endpoint, which wants
52
+ * just the command list. Returns the input unchanged if there's no `---`.
53
+ */
54
+ function extractFlowCommands(text) {
55
+ const lines = text.split(/\r?\n/);
56
+ const idx = lines.findIndex((l) => l.trim() === '---');
57
+ if (idx === -1)
58
+ return text.trim();
59
+ return lines.slice(idx + 1).join('\n').trim();
60
+ }
61
+ /**
62
+ * citty/mri silently drops the value after `--flag` when that value starts with
63
+ * `-` (e.g. `--yaml "- launchApp"`) for non-required string args. Recover it
64
+ * from the raw argv so the ergonomic inline form keeps working.
65
+ */
66
+ function recoverFlagValue(flag, parsed, rawArgs) {
67
+ if (parsed && parsed.trim())
68
+ return parsed;
69
+ const eq = rawArgs.find((a) => a.startsWith(`${flag}=`));
70
+ if (eq)
71
+ return eq.slice(flag.length + 1);
72
+ const idx = rawArgs.indexOf(flag);
73
+ if (idx !== -1 && idx + 1 < rawArgs.length)
74
+ return rawArgs[idx + 1];
75
+ return parsed;
76
+ }
77
+ function readFlowFile(filePath) {
78
+ try {
79
+ return readFileSync(filePath, 'utf8');
80
+ }
81
+ catch (err) {
82
+ throw new CliError(`Could not read flow file '${filePath}': ${err.message}`);
83
+ }
84
+ }
85
+ function printExecResult(result) {
86
+ logger.log(result.success
87
+ ? ui.success('Command executed successfully')
88
+ : `${ui.statusSymbol('FAILED')} Command failed`);
89
+ if (result.output) {
90
+ logger.log(ui.section('Output'));
91
+ logger.log(result.output);
92
+ }
93
+ if (result.error) {
94
+ logger.log(ui.section('Error'));
95
+ logger.log(colors.error(result.error));
96
+ }
97
+ }
98
+ const startSub = defineCommand({
99
+ meta: { name: 'start', description: 'Start a new live device session' },
100
+ args: {
101
+ ...apiFlags,
102
+ platform: {
103
+ type: 'string',
104
+ default: 'android',
105
+ description: 'Device platform (options: android, ios)',
106
+ },
107
+ 'app-binary-id': {
108
+ type: 'string',
109
+ description: 'Binary upload ID to install on the device',
110
+ },
111
+ 'device-locale': {
112
+ type: 'string',
113
+ description: 'Device locale, ISO-639-1 + ISO-3166-1, e.g. "de_DE"',
114
+ },
115
+ 'android-device': {
116
+ type: 'string',
117
+ description: '[Android] Device profile to match a cloud run (options: pixel-6, pixel-6-pro, pixel-7, pixel-7-pro)',
118
+ },
119
+ 'android-api-level': {
120
+ type: 'string',
121
+ description: '[Android] API level for the device, e.g. 34 (requires --android-device)',
122
+ },
123
+ wait: {
124
+ type: 'boolean',
125
+ default: false,
126
+ description: 'Block until the device is ready to accept commands',
127
+ },
128
+ },
129
+ async run({ args }) {
130
+ const auth = await requireAuth(args['api-key']);
131
+ const apiUrl = resolveApiUrl(args['api-url']);
132
+ const platform = validateEnum(args.platform, PLATFORM_OPTIONS, 'platform');
133
+ const binaryId = args['app-binary-id'];
134
+ const deviceLocale = args['device-locale'];
135
+ const androidDevice = args['android-device'];
136
+ const androidApiLevel = args['android-api-level'];
137
+ if ((androidDevice || androidApiLevel) && platform !== 'android') {
138
+ throw new CliError('--android-device/--android-api-level are only valid with --platform android.');
139
+ }
140
+ if (Boolean(androidDevice) !== Boolean(androidApiLevel)) {
141
+ throw new CliError('--android-device and --android-api-level must be provided together.');
142
+ }
143
+ logger.log(ui.running(`Starting ${platform} live session…`));
144
+ const session = await ApiGateway.startLiveSession(apiUrl, auth, {
145
+ binaryUploadId: binaryId,
146
+ deviceLocale,
147
+ platform,
148
+ androidDevice,
149
+ androidApiLevel,
150
+ });
151
+ const frontendUrl = resolveFrontendUrl(apiUrl);
152
+ logger.log(ui.success('Live session started'));
153
+ logger.log(ui.branch(ui.fields([
154
+ ['session', colors.highlight(session.session_name)],
155
+ ['platform', session.platform],
156
+ ['status', session.status],
157
+ ['console', formatUrl(`${frontendUrl}/live?session=${session.session_name}`)],
158
+ ])));
159
+ if (args.wait) {
160
+ logger.log(ui.running('Waiting for the device to be ready…'));
161
+ const ready = await waitForReady(apiUrl, auth, session.session_name);
162
+ logger.log(ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`));
163
+ }
164
+ logger.log(ui.section('Next steps'));
165
+ logger.log(ui.branch(ui.fields([
166
+ ['install a binary', colors.highlight(`dcd live install --session ${session.session_name} --app-binary-id <id>`)],
167
+ ['run a flow', colors.highlight(`dcd live run --session ${session.session_name} path/to/flow.yaml`)],
168
+ ['inspect screen', colors.highlight(`dcd live hierarchy --session ${session.session_name}`)],
169
+ ['stop session', colors.highlight(`dcd live stop --session ${session.session_name}`)],
170
+ ])));
171
+ },
172
+ });
173
+ const installSub = defineCommand({
174
+ meta: { name: 'install', description: 'Install a binary on the device' },
175
+ args: {
176
+ ...apiFlags,
177
+ session: { type: 'string', required: true, description: 'Live session name' },
178
+ 'app-binary-id': {
179
+ type: 'string',
180
+ required: true,
181
+ description: 'Binary upload ID to install on the device',
182
+ },
183
+ wait: {
184
+ type: 'boolean',
185
+ default: false,
186
+ description: 'Block until the device is ready again after installing',
187
+ },
188
+ },
189
+ async run({ args }) {
190
+ const auth = await requireAuth(args['api-key']);
191
+ const apiUrl = resolveApiUrl(args['api-url']);
192
+ const sessionName = args.session;
193
+ const binaryId = args['app-binary-id'];
194
+ logger.log(ui.running(`Installing binary ${colors.highlight(binaryId)} on session ${colors.highlight(sessionName)}…`));
195
+ await ApiGateway.installLiveBinary(apiUrl, auth, sessionName, binaryId);
196
+ logger.log(ui.success('Binary installed successfully'));
197
+ if (args.wait) {
198
+ logger.log(ui.running('Waiting for the device to be ready…'));
199
+ await waitForReady(apiUrl, auth, sessionName);
200
+ logger.log(ui.success('Device ready'));
201
+ }
202
+ },
203
+ });
204
+ const execSub = defineCommand({
205
+ meta: { name: 'exec', description: 'Execute Maestro YAML commands' },
206
+ args: {
207
+ ...apiFlags,
208
+ session: { type: 'string', required: true, description: 'Live session name' },
209
+ yaml: { type: 'string', description: 'Maestro YAML commands to execute' },
210
+ file: {
211
+ type: 'string',
212
+ description: 'Path to a file containing the Maestro YAML to execute',
213
+ },
214
+ wait: {
215
+ type: 'boolean',
216
+ default: false,
217
+ description: 'Wait for the device to be ready before executing',
218
+ },
219
+ },
220
+ async run({ args, rawArgs }) {
221
+ const auth = await requireAuth(args['api-key']);
222
+ const apiUrl = resolveApiUrl(args['api-url']);
223
+ const sessionName = args.session;
224
+ const inlineYaml = recoverFlagValue('--yaml', args.yaml, rawArgs);
225
+ const file = args.file;
226
+ if (inlineYaml && file) {
227
+ throw new CliError('Pass either --yaml or --file, not both.');
228
+ }
229
+ const yaml = file ? readFlowFile(file) : inlineYaml;
230
+ if (!yaml || !yaml.trim()) {
231
+ throw new CliError('Provide commands to execute via --yaml or --file.');
232
+ }
233
+ if (args.wait) {
234
+ await waitForReady(apiUrl, auth, sessionName);
235
+ }
236
+ logger.log(ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`));
237
+ const result = await ApiGateway.execLiveYaml(apiUrl, auth, sessionName, yaml);
238
+ printExecResult(result);
239
+ },
240
+ });
241
+ const runSub = defineCommand({
242
+ meta: {
243
+ name: 'run',
244
+ description: 'Run a whole Maestro flow file against the live session',
245
+ },
246
+ args: {
247
+ ...apiFlags,
248
+ session: { type: 'string', required: true, description: 'Live session name' },
249
+ wait: {
250
+ type: 'boolean',
251
+ default: false,
252
+ description: 'Wait for the device to be ready before running',
253
+ },
254
+ timeout: {
255
+ type: 'string',
256
+ default: '600',
257
+ description: 'Max seconds to wait for the flow to finish (default 600)',
258
+ },
259
+ flowFile: {
260
+ type: 'positional',
261
+ required: true,
262
+ description: 'Path to the Maestro flow file to run (e.g. flow.yaml)',
263
+ },
264
+ },
265
+ async run({ args }) {
266
+ const auth = await requireAuth(args['api-key']);
267
+ const apiUrl = resolveApiUrl(args['api-url']);
268
+ const sessionName = args.session;
269
+ const flowFile = args.flowFile;
270
+ const timeoutMs = Math.max(1, Number(args.timeout) || 600) * 1000;
271
+ const commands = extractFlowCommands(readFlowFile(flowFile));
272
+ if (!commands) {
273
+ throw new CliError(`Flow file '${flowFile}' has no commands to run.`);
274
+ }
275
+ if (args.wait) {
276
+ await waitForReady(apiUrl, auth, sessionName);
277
+ }
278
+ logger.log(ui.running(`Running ${colors.highlight(flowFile)} on session ${colors.highlight(sessionName)}…`));
279
+ // Submit asynchronously and poll, so a long flow isn't capped by the
280
+ // server's 120s synchronous exec limit. Older servers ignore `async` and
281
+ // return the full result inline — handle that by falling through.
282
+ const submitted = await ApiGateway.execLiveYaml(apiUrl, auth, sessionName, commands, {
283
+ async: true,
284
+ });
285
+ if (!submitted.commandId) {
286
+ printExecResult(submitted);
287
+ if (!submitted.success)
288
+ throw new CliError(submitted.error || 'Flow failed.', 2);
289
+ return;
290
+ }
291
+ const commandId = submitted.commandId;
292
+ const deadline = Date.now() + timeoutMs;
293
+ let lastKeepalive = Date.now();
294
+ for (;;) {
295
+ const status = await ApiGateway.getLiveCommand(apiUrl, auth, sessionName, commandId);
296
+ if (status.done) {
297
+ printExecResult(status);
298
+ if (!status.success)
299
+ throw new CliError(status.error || 'Flow failed.', 2);
300
+ return;
301
+ }
302
+ if (Date.now() >= deadline) {
303
+ throw new CliError(`Flow still running after ${Math.round(timeoutMs / 1000)}s (command ${commandId}). ` +
304
+ 'Raise --timeout if the flow legitimately runs longer.', 2);
305
+ }
306
+ // Keep the session alive during long polls so the inactivity sweep
307
+ // doesn't cancel it mid-flow.
308
+ if (Date.now() - lastKeepalive > 60_000) {
309
+ await ApiGateway.keepaliveLiveSession(apiUrl, auth, sessionName);
310
+ lastKeepalive = Date.now();
311
+ }
312
+ await sleep(2_000);
313
+ }
314
+ },
315
+ });
316
+ const screenshotSub = defineCommand({
317
+ meta: {
318
+ name: 'screenshot',
319
+ description: 'Save the current device screen to an image file (PNG/JPEG)',
320
+ },
321
+ args: {
322
+ ...apiFlags,
323
+ session: { type: 'string', required: true, description: 'Live session name' },
324
+ output: {
325
+ type: 'string',
326
+ alias: ['o'],
327
+ description: 'File path to write the screenshot to (default: live-screenshot.<ext> matching the device format)',
328
+ },
329
+ },
330
+ async run({ args }) {
331
+ const auth = await requireAuth(args['api-key']);
332
+ const apiUrl = resolveApiUrl(args['api-url']);
333
+ const sessionName = args.session;
334
+ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName);
335
+ const dataUrl = session.screenshot_data_url ?? session.hierarchy?.screenshot;
336
+ if (!dataUrl) {
337
+ throw new CliError(`No screenshot available yet for ${sessionName}. The device may not be streaming — ` +
338
+ 'try again, or start/install with --wait.');
339
+ }
340
+ const match = /^data:image\/(\w+);base64,(.+)$/s.exec(dataUrl);
341
+ const ext = match ? match[1].replace('jpeg', 'jpg') : 'png';
342
+ const base64 = match ? match[2] : dataUrl;
343
+ const output = args.output ?? `live-screenshot.${ext}`;
344
+ writeFileSync(output, Buffer.from(base64, 'base64'));
345
+ logger.log(ui.success(`Screenshot saved to ${colors.highlight(output)}`));
346
+ if (session.hierarchy) {
347
+ logger.log(ui.branch(ui.fields([
348
+ ['resolution', `${session.hierarchy.width}x${session.hierarchy.height}`],
349
+ ])));
350
+ }
351
+ },
352
+ });
353
+ const hierarchySub = defineCommand({
354
+ meta: {
355
+ name: 'hierarchy',
356
+ description: 'Dump the current view hierarchy (the selectors you can tap/assert on)',
357
+ },
358
+ args: {
359
+ ...apiFlags,
360
+ session: { type: 'string', required: true, description: 'Live session name' },
361
+ json: { type: 'boolean', description: 'Output the raw hierarchy as JSON' },
362
+ output: {
363
+ type: 'string',
364
+ alias: ['o'],
365
+ description: 'Write the output to a file instead of stdout',
366
+ },
367
+ },
368
+ async run({ args }) {
369
+ const auth = await requireAuth(args['api-key']);
370
+ const apiUrl = resolveApiUrl(args['api-url']);
371
+ const sessionName = args.session;
372
+ const json = Boolean(args.json);
373
+ const output = args.output;
374
+ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName);
375
+ const hierarchy = session.hierarchy;
376
+ if (!hierarchy) {
377
+ throw new CliError(`No hierarchy available yet for ${sessionName}. The device may not be streaming — ` +
378
+ 'try again, or start/install with --wait.');
379
+ }
380
+ if (json) {
381
+ const out = JSON.stringify(hierarchy, null, 2);
382
+ if (output) {
383
+ writeFileSync(output, out);
384
+ logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`));
385
+ }
386
+ else {
387
+ // eslint-disable-next-line no-console
388
+ console.log(out);
389
+ }
390
+ return;
391
+ }
392
+ // Human-readable: only the nodes worth selecting on (have text / a11y / id).
393
+ const lines = [];
394
+ lines.push(`Hierarchy — ${hierarchy.width}x${hierarchy.height}, ${hierarchy.elements.length} elements`);
395
+ for (const el of hierarchy.elements) {
396
+ const parts = [];
397
+ if (el.text)
398
+ parts.push(`"${el.text}"`);
399
+ if (el.accessibilityText)
400
+ parts.push(`a11y:"${el.accessibilityText}"`);
401
+ if (el.resourceId)
402
+ parts.push(`id:${el.resourceId}`);
403
+ if (parts.length === 0)
404
+ continue;
405
+ const bounds = el.bounds
406
+ ? ` ${colors.dim(`(${el.bounds.x},${el.bounds.y} ${el.bounds.width}x${el.bounds.height})`)}`
407
+ : '';
408
+ lines.push(` ${parts.join(' ')}${bounds}`);
409
+ }
410
+ const out = lines.join('\n');
411
+ if (output) {
412
+ writeFileSync(output, `${out}\n`);
413
+ logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`));
414
+ }
415
+ else {
416
+ logger.log(out);
417
+ }
418
+ },
419
+ });
420
+ const stopSub = defineCommand({
421
+ meta: { name: 'stop', description: 'Stop a live session' },
422
+ args: {
423
+ ...apiFlags,
424
+ session: { type: 'string', required: true, description: 'Live session name' },
425
+ },
426
+ async run({ args }) {
427
+ const auth = await requireAuth(args['api-key']);
428
+ const apiUrl = resolveApiUrl(args['api-url']);
429
+ const sessionName = args.session;
430
+ logger.log(ui.running(`Stopping session ${colors.highlight(sessionName)}…`));
431
+ await ApiGateway.stopLiveSession(apiUrl, auth, sessionName);
432
+ logger.log(ui.success('Session stopped'));
433
+ },
434
+ });
435
+ const statusSub = defineCommand({
436
+ meta: { name: 'status', description: 'Get session status' },
437
+ args: {
438
+ ...apiFlags,
439
+ session: { type: 'string', required: true, description: 'Live session name' },
440
+ },
441
+ async run({ args }) {
442
+ const auth = await requireAuth(args['api-key']);
443
+ const apiUrl = resolveApiUrl(args['api-url']);
444
+ const sessionName = args.session;
445
+ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName);
446
+ const fields = [
447
+ ['session', colors.highlight(session.session_name)],
448
+ ['platform', session.platform],
449
+ ['status', session.status],
450
+ ['ready', session.ready ? colors.success('yes') : colors.warning('no')],
451
+ ];
452
+ const phase = session.device_state?.phase_label ?? session.device_state?.phase;
453
+ if (phase) {
454
+ fields.push(['phase', phase]);
455
+ }
456
+ if (session.device_model) {
457
+ fields.push(['device', session.device_model]);
458
+ }
459
+ if (session.device_locale) {
460
+ fields.push(['locale', session.device_locale]);
461
+ }
462
+ if (session.binary_upload_id) {
463
+ fields.push(['binary', session.binary_upload_id]);
464
+ }
465
+ if (typeof session.seconds_until_auto_cancel === 'number') {
466
+ fields.push(['auto-cancel in', `${session.seconds_until_auto_cancel}s`]);
467
+ }
468
+ fields.push(['created', new Date(session.created_at).toLocaleString()]);
469
+ logger.log(ui.section('Live Session'));
470
+ logger.log(ui.branch(ui.fields(fields)));
471
+ },
472
+ });
473
+ export const liveCommand = defineCommand({
474
+ meta: {
475
+ name: 'live',
476
+ description: 'Start and interact with a live device session',
477
+ },
478
+ subCommands: {
479
+ start: startSub,
480
+ install: installSub,
481
+ exec: execSub,
482
+ run: runSub,
483
+ screenshot: screenshotSub,
484
+ hierarchy: hierarchySub,
485
+ stop: stopSub,
486
+ status: statusSub,
487
+ },
488
+ // citty's runCommand does not early-return after dispatching to a
489
+ // subcommand — it still invokes the parent `run` afterwards. So when a
490
+ // subcommand was matched, bail out here; otherwise the menu would print
491
+ // after every successful subcommand.
492
+ run({ rawArgs }) {
493
+ const subNames = new Set([
494
+ 'start',
495
+ 'install',
496
+ 'exec',
497
+ 'run',
498
+ 'screenshot',
499
+ 'hierarchy',
500
+ 'stop',
501
+ 'status',
502
+ ]);
503
+ const firstPositional = rawArgs.find((arg) => !arg.startsWith('-'));
504
+ if (firstPositional && subNames.has(firstPositional))
505
+ return;
506
+ logger.log(ui.section('Live Session Commands'));
507
+ logger.log(ui.branch(ui.fields([
508
+ [colors.bold('start'), 'Start a new live device session'],
509
+ [colors.bold('install'), 'Install a binary on the device'],
510
+ [colors.bold('exec'), 'Execute Maestro YAML commands'],
511
+ [colors.bold('run'), 'Run a whole Maestro flow file'],
512
+ [colors.bold('screenshot'), 'Save the current device screen to an image file'],
513
+ [colors.bold('hierarchy'), 'Dump the current view hierarchy (selectors)'],
514
+ [colors.bold('stop'), 'Stop a live session'],
515
+ [colors.bold('status'), 'Get session status'],
516
+ ])));
517
+ logger.log(ui.note(`\nRun ${colors.highlight('dcd live <command> --help')} for details`));
518
+ },
519
+ });
520
+ export default liveCommand;
@@ -0,0 +1,17 @@
1
+ export declare const loginCommand: import("citty").CommandDef<{
2
+ readonly 'api-url': {
3
+ readonly type: "string";
4
+ readonly default: "https://api.devicecloud.dev";
5
+ readonly description: "API base URL";
6
+ };
7
+ readonly 'frontend-url': {
8
+ readonly type: "string";
9
+ readonly description: "Override the frontend URL used to complete login (defaults per env)";
10
+ };
11
+ readonly browser: {
12
+ readonly type: "boolean";
13
+ readonly default: true;
14
+ readonly description: "Open the login URL in a browser (pass --no-browser to just print it)";
15
+ };
16
+ }>;
17
+ export default loginCommand;