@devicecloud.dev/dcd 5.10.0 → 5.11.0-beta.2
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.
- package/dist/commands/device.d.ts +30 -0
- package/dist/commands/device.js +1106 -0
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +9 -0
- package/dist/commands/login.js +1 -16
- package/dist/commands/mcp.d.ts +12 -0
- package/dist/commands/mcp.js +29 -0
- package/dist/gateways/device-gateway.d.ts +88 -0
- package/dist/gateways/device-gateway.js +140 -0
- package/dist/index.js +4 -0
- package/dist/mcp/bridge.d.ts +27 -0
- package/dist/mcp/bridge.js +209 -0
- package/dist/methods.js +3 -3
- package/dist/services/moropo.service.js +1 -1
- package/dist/services/results-polling.service.js +1 -1
- package/dist/types/domain/device-session.types.d.ts +197 -0
- package/dist/types/domain/device-session.types.js +5 -0
- package/dist/utils/app-upload.d.ts +19 -0
- package/dist/utils/app-upload.js +69 -0
- package/dist/utils/auth.d.ts +0 -8
- package/dist/utils/auth.js +8 -24
- package/dist/utils/browser.d.ts +10 -0
- package/dist/utils/browser.js +35 -0
- package/dist/utils/device-artifacts.d.ts +55 -0
- package/dist/utils/device-artifacts.js +211 -0
- package/dist/utils/device-bundle.d.ts +22 -0
- package/dist/utils/device-bundle.js +102 -0
- package/dist/utils/device-output.d.ts +81 -0
- package/dist/utils/device-output.js +204 -0
- package/dist/utils/device-run.d.ts +29 -0
- package/dist/utils/device-run.js +105 -0
- package/dist/utils/progress.d.ts +4 -0
- package/dist/utils/progress.js +45 -21
- package/package.json +2 -2
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { apiFlags } from '../config/flags/api.flags.js';
|
|
6
|
+
import { jsonFlag } from '../config/flags/output.flags.js';
|
|
7
|
+
import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js';
|
|
8
|
+
import { ENDED_STATES, } from '../types/domain/device-session.types.js';
|
|
9
|
+
import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js';
|
|
10
|
+
import { refreshAuth, resolveAuth } from '../utils/auth.js';
|
|
11
|
+
import { openBrowser } from '../utils/browser.js';
|
|
12
|
+
import { uploadFlowBundle } from '../utils/device-bundle.js';
|
|
13
|
+
import { downloadArtifactFiles, fetchAllArtifacts, pendingArtifacts, pendingArtifactsWarning, planArtifactFiles, } from '../utils/device-artifacts.js';
|
|
14
|
+
import { CliError, logger, validateEnum } from '../utils/cli.js';
|
|
15
|
+
import { resolveApiUrl } from '../utils/config-store.js';
|
|
16
|
+
import { deviceProgress, humanFields, printSession, resolveSessionId, runDeviceCommand, } from '../utils/device-output.js';
|
|
17
|
+
import { parseRunReport, printRunResult, runFailureMessage } from '../utils/device-run.js';
|
|
18
|
+
import { isUrl } from '../utils/expo.js';
|
|
19
|
+
import { colors, formatUrl } from '../utils/styling.js';
|
|
20
|
+
import { ui } from '../utils/ui.js';
|
|
21
|
+
const PLATFORMS = ['android', 'ios'];
|
|
22
|
+
/** What `--name` defaults to: shown in the console and the session timeline. */
|
|
23
|
+
const DEFAULT_CLIENT_LABEL = 'dcd-cli';
|
|
24
|
+
const WAIT_POLL_MS = 2000;
|
|
25
|
+
/** Longer than the API's own queue timeout, so that one is what ends a wait. */
|
|
26
|
+
const WAIT_TIMEOUT_MS = 20 * 60 * 1000;
|
|
27
|
+
/** States in which a session takes commands. */
|
|
28
|
+
const USABLE_STATES = new Set(['active', 'needs_human', 'ready']);
|
|
29
|
+
/** Every `dcd device` subcommand takes these. */
|
|
30
|
+
const commonArgs = { ...apiFlags, json: jsonFlag };
|
|
31
|
+
const idArg = {
|
|
32
|
+
type: 'positional',
|
|
33
|
+
required: false,
|
|
34
|
+
description: 'Session id, e.g. brave-teal-otter. Optional when exactly one session is open.',
|
|
35
|
+
};
|
|
36
|
+
async function context(args) {
|
|
37
|
+
const json = Boolean(args.json);
|
|
38
|
+
return {
|
|
39
|
+
apiUrl: resolveApiUrl(args['api-url']),
|
|
40
|
+
auth: await resolveAuth({ apiKeyFlag: args['api-key'] }),
|
|
41
|
+
json,
|
|
42
|
+
progress: deviceProgress(json),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A spinner only for a person at a terminal (plan 4.2: none when not a TTY). */
|
|
46
|
+
function interactive(json) {
|
|
47
|
+
return !json && process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Polling loops can outlast a `dcd login` access token (about an hour), so
|
|
51
|
+
* they renew it before each call. An API key is returned unchanged.
|
|
52
|
+
*/
|
|
53
|
+
async function freshAuth(ctx) {
|
|
54
|
+
ctx.auth = await refreshAuth(ctx.auth);
|
|
55
|
+
return ctx.auth;
|
|
56
|
+
}
|
|
57
|
+
function sleep(ms) {
|
|
58
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Poll until the session takes commands. Reports each change of state (and
|
|
62
|
+
* queue position) as progress. A session that ends while waiting is exit 4.
|
|
63
|
+
*/
|
|
64
|
+
async function waitUntilUsable(ctx, id) {
|
|
65
|
+
const deadline = Date.now() + WAIT_TIMEOUT_MS;
|
|
66
|
+
let last = '';
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id);
|
|
69
|
+
if (USABLE_STATES.has(session.state))
|
|
70
|
+
return session;
|
|
71
|
+
if (ENDED_STATES.includes(session.state)) {
|
|
72
|
+
throw new DeviceApiError(`Device session ${id} ended while starting (${session.release_reason ?? session.state}).`, 410, 'LIVE_SESSION_ENDED', session);
|
|
73
|
+
}
|
|
74
|
+
const position = session.queue?.position;
|
|
75
|
+
const line = position ? `${session.state} (position ${position} in the queue)` : session.state;
|
|
76
|
+
if (line !== last) {
|
|
77
|
+
last = line;
|
|
78
|
+
ctx.progress(` ${colors.dim(line)}`);
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() >= deadline) {
|
|
81
|
+
throw new DeviceApiError(`Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for ${id} (state: ${session.state}). It is still open: check it with \`dcd device status ${id}\`.`, 408, null, session);
|
|
82
|
+
}
|
|
83
|
+
await sleep(WAIT_POLL_MS);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Poll until the device has finished installing `binaryId`: the session
|
|
88
|
+
* block's `install.status` leaves `installing`. The session is usable
|
|
89
|
+
* throughout (it stays ready while a new build installs), so waiting on the
|
|
90
|
+
* state alone returned too early. An API without `install` returns at once.
|
|
91
|
+
*/
|
|
92
|
+
async function waitForInstall(ctx, id, binaryId) {
|
|
93
|
+
const deadline = Date.now() + WAIT_TIMEOUT_MS;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id);
|
|
96
|
+
const install = session.install;
|
|
97
|
+
if (ENDED_STATES.includes(session.state)) {
|
|
98
|
+
throw new DeviceApiError(`Device session ${id} ended while installing (${session.release_reason ?? session.state}).`, 410, 'LIVE_SESSION_ENDED', session);
|
|
99
|
+
}
|
|
100
|
+
// Done, failed, superseded by another install, or an API too old to say.
|
|
101
|
+
if (!install || install.binary_upload_id !== binaryId || install.status !== 'installing') {
|
|
102
|
+
return session;
|
|
103
|
+
}
|
|
104
|
+
if (Date.now() >= deadline) {
|
|
105
|
+
throw new DeviceApiError(`Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for the install on ${id}. It may still finish: check it with \`dcd device status ${id}\`.`, 408, null, session);
|
|
106
|
+
}
|
|
107
|
+
await sleep(WAIT_POLL_MS);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** A failed install as a command failure (exit 2); the session stays open. */
|
|
111
|
+
function installFailure(session) {
|
|
112
|
+
if (session.install?.status !== 'failed')
|
|
113
|
+
return undefined;
|
|
114
|
+
return {
|
|
115
|
+
code: null,
|
|
116
|
+
message: `The app could not be installed: ${session.install.error ?? 'unknown error'} Session ${session.id} is still open.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Turn `<path|url>` into what /install takes: a URL goes to the API's
|
|
121
|
+
* install-from-URL, a local file is uploaded first.
|
|
122
|
+
*/
|
|
123
|
+
async function resolveApp(ctx, app) {
|
|
124
|
+
if (isUrl(app))
|
|
125
|
+
return { url: app };
|
|
126
|
+
if (!existsSync(app))
|
|
127
|
+
throw new CliError(`No such file: ${app}`);
|
|
128
|
+
ctx.progress(ui.running(`Uploading ${colors.highlight(app)}…`));
|
|
129
|
+
const binaryUploadId = await uploadLocalApp({
|
|
130
|
+
apiUrl: ctx.apiUrl,
|
|
131
|
+
auth: ctx.auth,
|
|
132
|
+
filePath: app,
|
|
133
|
+
log: ctx.progress,
|
|
134
|
+
showProgress: interactive(ctx.json),
|
|
135
|
+
});
|
|
136
|
+
return { binaryUploadId };
|
|
137
|
+
}
|
|
138
|
+
function parsePositiveInt(value, flag) {
|
|
139
|
+
if (value === undefined || value === '')
|
|
140
|
+
return undefined;
|
|
141
|
+
const n = Number(value);
|
|
142
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
143
|
+
throw new CliError(`--${flag} must be a whole number of minutes, got "${String(value)}".`);
|
|
144
|
+
}
|
|
145
|
+
return n;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The platform a `--model` belongs to, from GET /device/models. Matches the
|
|
149
|
+
* model id (iphone-16) or its name (iPhone 16), ignoring case. Unknown, or on
|
|
150
|
+
* both platforms: a usage error that says to pass --platform.
|
|
151
|
+
*/
|
|
152
|
+
export function platformOfModel(catalogue, model) {
|
|
153
|
+
const wanted = model.trim().toLowerCase();
|
|
154
|
+
const found = PLATFORMS.filter((p) => (catalogue[p] ?? []).some((m) => m.model.toLowerCase() === wanted || m.name.toLowerCase() === wanted));
|
|
155
|
+
if (found.length === 1)
|
|
156
|
+
return found[0];
|
|
157
|
+
if (found.length === 0) {
|
|
158
|
+
throw new CliError(`Unknown model "${model}". See \`dcd device models\` for the list, or pass --platform.`);
|
|
159
|
+
}
|
|
160
|
+
throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`);
|
|
161
|
+
}
|
|
162
|
+
const startSub = defineCommand({
|
|
163
|
+
meta: { name: 'start', description: 'Start a device session' },
|
|
164
|
+
args: {
|
|
165
|
+
...commonArgs,
|
|
166
|
+
platform: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
description: 'ios or android. Optional with --model or --app, which imply it',
|
|
169
|
+
},
|
|
170
|
+
model: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description: 'Device model, e.g. iphone-16 or pixel-10 (see `dcd device models`)',
|
|
173
|
+
},
|
|
174
|
+
os: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
description: 'iOS major version or Android API level, e.g. 18 or 35',
|
|
177
|
+
},
|
|
178
|
+
'google-play': {
|
|
179
|
+
type: 'boolean',
|
|
180
|
+
description: '[Android] An image with Google Play services',
|
|
181
|
+
},
|
|
182
|
+
app: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'A build to install: a local .apk/.app/.zip/.tar.gz (uploaded first) or an https URL',
|
|
185
|
+
},
|
|
186
|
+
'app-binary-id': {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: 'An already uploaded build to install',
|
|
189
|
+
},
|
|
190
|
+
locale: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
description: 'Device locale, e.g. de_DE',
|
|
193
|
+
},
|
|
194
|
+
name: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
description: `Who is driving the session, shown in the console (default: ${DEFAULT_CLIENT_LABEL})`,
|
|
197
|
+
},
|
|
198
|
+
ttl: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
description: "Longest the session may run, in minutes (default: your org's limit)",
|
|
201
|
+
},
|
|
202
|
+
record: {
|
|
203
|
+
type: 'boolean',
|
|
204
|
+
default: true,
|
|
205
|
+
description: 'Record a video of the session (--no-record to turn off)',
|
|
206
|
+
},
|
|
207
|
+
wait: {
|
|
208
|
+
type: 'boolean',
|
|
209
|
+
default: false,
|
|
210
|
+
description: 'Wait until the device is ready for commands',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
async run({ args }) {
|
|
214
|
+
const json = Boolean(args.json);
|
|
215
|
+
await runDeviceCommand(json, async () => {
|
|
216
|
+
const app = args.app;
|
|
217
|
+
const binaryId = args['app-binary-id'];
|
|
218
|
+
if (app && binaryId)
|
|
219
|
+
throw new CliError('Pass --app or --app-binary-id, not both.');
|
|
220
|
+
const model = args.model;
|
|
221
|
+
const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined;
|
|
222
|
+
let platform = validateEnum(args.platform, PLATFORMS, 'platform') ?? appPlatform;
|
|
223
|
+
if (!platform && !model) {
|
|
224
|
+
throw new CliError('Pass --platform ios or android, or a --model or --app that says which.');
|
|
225
|
+
}
|
|
226
|
+
const ctx = await context(args);
|
|
227
|
+
if (!platform) {
|
|
228
|
+
platform = platformOfModel(await DeviceGateway.models(ctx.apiUrl, ctx.auth), model);
|
|
229
|
+
}
|
|
230
|
+
if (appPlatform && appPlatform !== platform) {
|
|
231
|
+
throw new CliError(`${app} is a build for ${appPlatform}, not ${platform}.`);
|
|
232
|
+
}
|
|
233
|
+
if (args['google-play'] && platform !== 'android') {
|
|
234
|
+
throw new CliError('--google-play is only for Android.');
|
|
235
|
+
}
|
|
236
|
+
const resolved = app ? await resolveApp(ctx, app) : undefined;
|
|
237
|
+
const uploadedId = binaryId ?? (resolved && 'binaryUploadId' in resolved ? resolved.binaryUploadId : undefined);
|
|
238
|
+
const body = {
|
|
239
|
+
platform,
|
|
240
|
+
clientLabel: args.name || DEFAULT_CLIENT_LABEL,
|
|
241
|
+
...(uploadedId && { binaryUploadId: uploadedId }),
|
|
242
|
+
...(model ? { model } : {}),
|
|
243
|
+
...(args.os ? { os: String(args.os) } : {}),
|
|
244
|
+
...(args['google-play'] ? { googlePlay: true } : {}),
|
|
245
|
+
...(args.locale ? { deviceLocale: args.locale } : {}),
|
|
246
|
+
...(args.record === false ? { record: false } : {}),
|
|
247
|
+
};
|
|
248
|
+
const ttl = parsePositiveInt(args.ttl, 'ttl');
|
|
249
|
+
if (ttl)
|
|
250
|
+
body.maxDurationMin = ttl;
|
|
251
|
+
ctx.progress(ui.running(`Starting ${platform} device session…`));
|
|
252
|
+
let { session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body);
|
|
253
|
+
ctx.progress(ui.success(`Session ${session.id} started`));
|
|
254
|
+
// The session exists and bills from here on: every later failure must
|
|
255
|
+
// still name it, so the caller can stop it.
|
|
256
|
+
let installedId;
|
|
257
|
+
try {
|
|
258
|
+
if (resolved && 'url' in resolved) {
|
|
259
|
+
ctx.progress(ui.running('Installing the app from its URL…'));
|
|
260
|
+
const fromUrl = await DeviceGateway.install(ctx.apiUrl, ctx.auth, session.id, resolved);
|
|
261
|
+
session = fromUrl.session;
|
|
262
|
+
installedId = fromUrl.app.binary_upload_id;
|
|
263
|
+
}
|
|
264
|
+
if (args.wait) {
|
|
265
|
+
ctx.progress(ui.running('Waiting for the device…'));
|
|
266
|
+
session = await waitUntilUsable(ctx, session.id);
|
|
267
|
+
const expected = installedId ?? uploadedId;
|
|
268
|
+
if (expected) {
|
|
269
|
+
ctx.progress(ui.running('Waiting for the app to install…'));
|
|
270
|
+
session = await waitForInstall(ctx, session.id, expected);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED')
|
|
276
|
+
throw error;
|
|
277
|
+
const message = `${error instanceof Error ? error.message : String(error)} Session ${session.id} is still open: stop it with \`dcd device stop ${session.id}\`.`;
|
|
278
|
+
if (error instanceof DeviceApiError) {
|
|
279
|
+
throw new DeviceApiError(message, error.status, error.code, error.session ?? session);
|
|
280
|
+
}
|
|
281
|
+
throw new DeviceApiError(message, 500, null, session);
|
|
282
|
+
}
|
|
283
|
+
const startFailure = args.wait ? installFailure(session) : undefined;
|
|
284
|
+
return {
|
|
285
|
+
session,
|
|
286
|
+
data: { binary_upload_id: installedId ?? uploadedId ?? null },
|
|
287
|
+
...(startFailure && { failure: startFailure }),
|
|
288
|
+
print() {
|
|
289
|
+
logger.log(ui.section('Device session'));
|
|
290
|
+
printSession(session);
|
|
291
|
+
logger.log(ui.section('Next steps'));
|
|
292
|
+
logger.log(ui.branch(ui.fields([
|
|
293
|
+
['inspect the screen', colors.highlight(`dcd device inspect ${session.id}`)],
|
|
294
|
+
['run a flow', colors.highlight(`dcd device run ${session.id} flow.yaml`)],
|
|
295
|
+
['stop', colors.highlight(`dcd device stop ${session.id}`)],
|
|
296
|
+
])));
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
const listSub = defineCommand({
|
|
303
|
+
meta: { name: 'list', description: "List your org's open device sessions" },
|
|
304
|
+
args: {
|
|
305
|
+
...commonArgs,
|
|
306
|
+
all: {
|
|
307
|
+
type: 'boolean',
|
|
308
|
+
default: false,
|
|
309
|
+
description: 'Include sessions that ended in the last 24 hours',
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
async run({ args }) {
|
|
313
|
+
const json = Boolean(args.json);
|
|
314
|
+
await runDeviceCommand(json, async () => {
|
|
315
|
+
const ctx = await context(args);
|
|
316
|
+
const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth, Boolean(args.all));
|
|
317
|
+
return {
|
|
318
|
+
session: undefined,
|
|
319
|
+
data: { sessions },
|
|
320
|
+
print() {
|
|
321
|
+
if (sessions.length === 0) {
|
|
322
|
+
logger.log(ui.info(args.all ? 'No device sessions in the last 24 hours.' : 'No open device sessions.'));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
logger.log(ui.section(`Device sessions (${sessions.length})`));
|
|
326
|
+
logger.log(ui.branch(ui.fields(sessions.map((s) => [
|
|
327
|
+
colors.highlight(s.id),
|
|
328
|
+
[s.state, s.platform, s.model, s.client_label].filter(Boolean).join(' '),
|
|
329
|
+
]))));
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const statusSub = defineCommand({
|
|
336
|
+
meta: { name: 'status', description: "A session's state, time left and cost" },
|
|
337
|
+
args: { ...commonArgs, id: idArg },
|
|
338
|
+
async run({ args }) {
|
|
339
|
+
const json = Boolean(args.json);
|
|
340
|
+
await runDeviceCommand(json, async () => {
|
|
341
|
+
const ctx = await context(args);
|
|
342
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
343
|
+
// The summary does not say whether a person handed the session back,
|
|
344
|
+
// so ask. An API without the route (or a failure) just leaves it out.
|
|
345
|
+
const [{ session }, human] = await Promise.all([
|
|
346
|
+
DeviceGateway.status(ctx.apiUrl, ctx.auth, id),
|
|
347
|
+
DeviceGateway.human(ctx.apiUrl, ctx.auth, id).then((r) => r.human ?? null, () => null),
|
|
348
|
+
]);
|
|
349
|
+
return {
|
|
350
|
+
session,
|
|
351
|
+
data: human ? { human } : null,
|
|
352
|
+
print() {
|
|
353
|
+
logger.log(ui.section('Device session'));
|
|
354
|
+
printSession(session);
|
|
355
|
+
if (human?.handed_back) {
|
|
356
|
+
logger.log(ui.info(`A person handed it back${human.note ? `: ${human.note}` : ' (no note).'}`));
|
|
357
|
+
}
|
|
358
|
+
// The API's own warning already says a person has control and how
|
|
359
|
+
// to wait; only add ours when an older API leaves it out.
|
|
360
|
+
const apiSaysWaiting = (session.warnings ?? []).some((w) => w.startsWith('Waiting for a human'));
|
|
361
|
+
if (human?.human_in_control && !apiSaysWaiting) {
|
|
362
|
+
logger.log(ui.warn(`A person has control. Wait with \`dcd device human ${session.id} --wait\`.`));
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const stopSub = defineCommand({
|
|
370
|
+
meta: { name: 'stop', description: 'Stop a session, or every open one with --all' },
|
|
371
|
+
args: {
|
|
372
|
+
...commonArgs,
|
|
373
|
+
id: idArg,
|
|
374
|
+
all: { type: 'boolean', default: false, description: "Stop every open session in your org" },
|
|
375
|
+
},
|
|
376
|
+
async run({ args }) {
|
|
377
|
+
const json = Boolean(args.json);
|
|
378
|
+
await runDeviceCommand(json, async () => {
|
|
379
|
+
const ctx = await context(args);
|
|
380
|
+
if (args.all) {
|
|
381
|
+
if (args.id)
|
|
382
|
+
throw new CliError('Pass a session id or --all, not both.');
|
|
383
|
+
const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth);
|
|
384
|
+
const stopped = [];
|
|
385
|
+
for (const s of sessions) {
|
|
386
|
+
stopped.push((await DeviceGateway.stop(ctx.apiUrl, ctx.auth, s.id)).session);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
session: undefined,
|
|
390
|
+
data: { stopped },
|
|
391
|
+
print() {
|
|
392
|
+
logger.log(stopped.length === 0
|
|
393
|
+
? ui.info('No open device sessions to stop.')
|
|
394
|
+
: ui.success(`Stopped ${stopped.map((s) => s.id).join(', ')}`));
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
399
|
+
const { session } = await DeviceGateway.stop(ctx.apiUrl, ctx.auth, id);
|
|
400
|
+
return {
|
|
401
|
+
session,
|
|
402
|
+
data: null,
|
|
403
|
+
print() {
|
|
404
|
+
logger.log(ui.success(`Stopped ${session.id}`));
|
|
405
|
+
printSession(session);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
/**
|
|
412
|
+
* `install [id] <path|url>`: with one positional it is the app when it looks
|
|
413
|
+
* like one (a URL, a file that exists, or a build's extension).
|
|
414
|
+
*/
|
|
415
|
+
function looksLikeApp(value) {
|
|
416
|
+
return isUrl(value) || existsSync(value) || platformOfAppFile(value) !== undefined;
|
|
417
|
+
}
|
|
418
|
+
const installSub = defineCommand({
|
|
419
|
+
meta: { name: 'install', description: 'Install a build: a local file (uploaded first) or an https URL' },
|
|
420
|
+
args: {
|
|
421
|
+
...commonArgs,
|
|
422
|
+
id: idArg,
|
|
423
|
+
app: {
|
|
424
|
+
type: 'positional',
|
|
425
|
+
required: false,
|
|
426
|
+
description: 'A local .apk/.app/.zip/.tar.gz, or an https URL to an .apk or a zipped .app',
|
|
427
|
+
},
|
|
428
|
+
'app-binary-id': { type: 'string', description: 'An already uploaded build' },
|
|
429
|
+
wait: {
|
|
430
|
+
type: 'boolean',
|
|
431
|
+
default: false,
|
|
432
|
+
description: 'Wait until the device is ready again after installing',
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
async run({ args }) {
|
|
436
|
+
const json = Boolean(args.json);
|
|
437
|
+
await runDeviceCommand(json, async () => {
|
|
438
|
+
let id = args.id;
|
|
439
|
+
let app = args.app;
|
|
440
|
+
if (id && !app && looksLikeApp(id))
|
|
441
|
+
[id, app] = [undefined, id];
|
|
442
|
+
const binaryId = args['app-binary-id'];
|
|
443
|
+
if (Boolean(app) === Boolean(binaryId)) {
|
|
444
|
+
throw new CliError('Give the build: a file path, an https URL, or --app-binary-id.');
|
|
445
|
+
}
|
|
446
|
+
const ctx = await context(args);
|
|
447
|
+
id = await resolveSessionId(ctx.apiUrl, ctx.auth, id);
|
|
448
|
+
const body = binaryId ? { binaryUploadId: binaryId } : await resolveApp(ctx, app);
|
|
449
|
+
ctx.progress(ui.running(`Installing on ${colors.highlight(id)}…`));
|
|
450
|
+
const installed = await DeviceGateway.install(ctx.apiUrl, ctx.auth, id, body);
|
|
451
|
+
let { session } = installed;
|
|
452
|
+
if (args.wait) {
|
|
453
|
+
ctx.progress(ui.running('Waiting for the device…'));
|
|
454
|
+
session = await waitUntilUsable(ctx, id);
|
|
455
|
+
ctx.progress(ui.running('Waiting for the app to install…'));
|
|
456
|
+
session = await waitForInstall(ctx, id, installed.app.binary_upload_id);
|
|
457
|
+
}
|
|
458
|
+
const appInfo = installed.app;
|
|
459
|
+
const failure = args.wait ? installFailure(session) : undefined;
|
|
460
|
+
return {
|
|
461
|
+
session,
|
|
462
|
+
data: { app: appInfo },
|
|
463
|
+
...(failure && { failure }),
|
|
464
|
+
print() {
|
|
465
|
+
if (failure) {
|
|
466
|
+
logger.log(`${ui.statusSymbol('FAILED')} ${failure.message}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
logger.log(ui.success(`${args.wait ? 'Installed' : 'Installing'} ${appInfo.app_id ?? appInfo.binary_upload_id}`));
|
|
470
|
+
logger.log(ui.branch(ui.fields([
|
|
471
|
+
['binary id', appInfo.binary_upload_id],
|
|
472
|
+
...(appInfo.app_id ? [['app id', appInfo.app_id]] : []),
|
|
473
|
+
['session', colors.highlight(session.id)],
|
|
474
|
+
])));
|
|
475
|
+
if (!args.wait) {
|
|
476
|
+
logger.log(ui.note('The device installs it next. Commands answer "not ready" until it is done.'));
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
/** Bundle ids and package names have dots; session ids never do. */
|
|
484
|
+
function looksLikeAppId(value) {
|
|
485
|
+
return value.includes('.');
|
|
486
|
+
}
|
|
487
|
+
const launchSub = defineCommand({
|
|
488
|
+
meta: { name: 'launch', description: 'Launch an app: the installed one, or a bundle id / package name' },
|
|
489
|
+
args: {
|
|
490
|
+
...commonArgs,
|
|
491
|
+
id: idArg,
|
|
492
|
+
appId: {
|
|
493
|
+
type: 'positional',
|
|
494
|
+
required: false,
|
|
495
|
+
description: 'Bundle id or package name (default: the installed app)',
|
|
496
|
+
},
|
|
497
|
+
'clear-state': {
|
|
498
|
+
type: 'boolean',
|
|
499
|
+
default: false,
|
|
500
|
+
description: "Clear the app's data before launching",
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
async run({ args }) {
|
|
504
|
+
const json = Boolean(args.json);
|
|
505
|
+
await runDeviceCommand(json, async () => {
|
|
506
|
+
let id = args.id;
|
|
507
|
+
let appId = args.appId;
|
|
508
|
+
if (id && !appId && looksLikeAppId(id))
|
|
509
|
+
[id, appId] = [undefined, id];
|
|
510
|
+
const ctx = await context(args);
|
|
511
|
+
id = await resolveSessionId(ctx.apiUrl, ctx.auth, id);
|
|
512
|
+
const { result, session } = await DeviceGateway.launch(ctx.apiUrl, ctx.auth, id, {
|
|
513
|
+
...(appId && { appId }),
|
|
514
|
+
...(args['clear-state'] ? { clearState: true } : {}),
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
session,
|
|
518
|
+
data: { result },
|
|
519
|
+
...(!result.success && {
|
|
520
|
+
failure: { code: null, message: result.error || 'The app did not launch.' },
|
|
521
|
+
}),
|
|
522
|
+
print() {
|
|
523
|
+
logger.log(result.success
|
|
524
|
+
? ui.success(`Launched ${appId ?? 'the app'}`)
|
|
525
|
+
: `${ui.statusSymbol('FAILED')} Launch failed`);
|
|
526
|
+
if (result.error)
|
|
527
|
+
logger.log(colors.error(result.error));
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
const modelsSub = defineCommand({
|
|
534
|
+
meta: { name: 'models', description: 'The devices a session can ask for' },
|
|
535
|
+
args: {
|
|
536
|
+
...commonArgs,
|
|
537
|
+
platform: { type: 'string', description: 'Only this platform: ios or android' },
|
|
538
|
+
},
|
|
539
|
+
async run({ args }) {
|
|
540
|
+
const json = Boolean(args.json);
|
|
541
|
+
await runDeviceCommand(json, async () => {
|
|
542
|
+
const platform = validateEnum(args.platform, PLATFORMS, 'platform');
|
|
543
|
+
const ctx = await context(args);
|
|
544
|
+
const catalogue = await DeviceGateway.models(ctx.apiUrl, ctx.auth);
|
|
545
|
+
const data = platform ? { [platform]: catalogue[platform] } : catalogue;
|
|
546
|
+
return {
|
|
547
|
+
session: undefined,
|
|
548
|
+
data,
|
|
549
|
+
print() {
|
|
550
|
+
for (const p of PLATFORMS) {
|
|
551
|
+
const models = data[p];
|
|
552
|
+
if (!models)
|
|
553
|
+
continue;
|
|
554
|
+
logger.log(ui.section(p === 'ios' ? 'iOS' : 'Android'));
|
|
555
|
+
logger.log(ui.branch(ui.fields(models.map((m) => [
|
|
556
|
+
m.default ? colors.bold(m.model) : m.model,
|
|
557
|
+
`${m.name} ${colors.dim(`os ${m.os.join(', ')} (default ${m.default_os})`)}` +
|
|
558
|
+
(m.google_play ? colors.dim(' google play') : '') +
|
|
559
|
+
(m.default ? colors.dim(' default') : ''),
|
|
560
|
+
]))));
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
});
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
const inspectSub = defineCommand({
|
|
568
|
+
meta: { name: 'inspect', description: 'The screen as compact JSON: each element with text, an id or a label' },
|
|
569
|
+
args: { ...commonArgs, id: idArg },
|
|
570
|
+
async run({ args }) {
|
|
571
|
+
const json = Boolean(args.json);
|
|
572
|
+
await runDeviceCommand(json, async () => {
|
|
573
|
+
const ctx = await context(args);
|
|
574
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
575
|
+
const { screen, session } = await DeviceGateway.inspect(ctx.apiUrl, ctx.auth, id);
|
|
576
|
+
return {
|
|
577
|
+
session,
|
|
578
|
+
data: { screen },
|
|
579
|
+
print() {
|
|
580
|
+
logger.log(ui.section(`Screen ${screen.width}x${screen.height}, ${screen.elements.length} elements` +
|
|
581
|
+
(screen.fresh ? '' : colors.dim(' (no new frame; this is the latest)'))));
|
|
582
|
+
logger.log(ui.branch(screen.elements.map((el) => formatElement(el))));
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
/** One element on one line: what to target it by, then its bounds. */
|
|
589
|
+
export function formatElement(el) {
|
|
590
|
+
const parts = [];
|
|
591
|
+
if (el.txt)
|
|
592
|
+
parts.push(`"${el.txt}"`);
|
|
593
|
+
if (el.a11y && el.a11y !== el.txt)
|
|
594
|
+
parts.push(`a11y:"${el.a11y}"`);
|
|
595
|
+
if (el.rid)
|
|
596
|
+
parts.push(`id:${el.rid}`);
|
|
597
|
+
if (el.hint)
|
|
598
|
+
parts.push(`hint:"${el.hint}"`);
|
|
599
|
+
return `${parts.join(' ')} ${colors.dim(el.b)}`;
|
|
600
|
+
}
|
|
601
|
+
/** `data:image/jpeg;base64,...` to bytes and a file extension. */
|
|
602
|
+
export function decodeDataUrl(dataUrl) {
|
|
603
|
+
const match = /^data:image\/([a-z]+);base64,(.*)$/s.exec(dataUrl);
|
|
604
|
+
if (!match)
|
|
605
|
+
throw new Error('The API returned a screenshot in an unexpected format.');
|
|
606
|
+
return { bytes: Buffer.from(match[2], 'base64'), ext: match[1] === 'jpeg' ? 'jpg' : match[1] };
|
|
607
|
+
}
|
|
608
|
+
const screenshotSub = defineCommand({
|
|
609
|
+
meta: { name: 'screenshot', description: 'Save a screenshot of the device' },
|
|
610
|
+
args: {
|
|
611
|
+
...commonArgs,
|
|
612
|
+
id: idArg,
|
|
613
|
+
output: {
|
|
614
|
+
type: 'string',
|
|
615
|
+
alias: ['o'],
|
|
616
|
+
description: 'Where to save it (default: ./<session>-<time>.jpg)',
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
async run({ args }) {
|
|
620
|
+
const json = Boolean(args.json);
|
|
621
|
+
await runDeviceCommand(json, async () => {
|
|
622
|
+
const ctx = await context(args);
|
|
623
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
624
|
+
const { screenshot, session } = await DeviceGateway.screenshot(ctx.apiUrl, ctx.auth, id);
|
|
625
|
+
const { bytes, ext } = decodeDataUrl(screenshot.data_url);
|
|
626
|
+
const file = args.output ||
|
|
627
|
+
`${session.id}-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.${ext}`;
|
|
628
|
+
writeFileSync(file, bytes);
|
|
629
|
+
const filePath = resolvePath(file);
|
|
630
|
+
return {
|
|
631
|
+
session,
|
|
632
|
+
data: { path: filePath, captured_at: screenshot.captured_at, fresh: screenshot.fresh },
|
|
633
|
+
print() {
|
|
634
|
+
logger.log(ui.success(`Saved ${filePath}`));
|
|
635
|
+
if (!screenshot.fresh)
|
|
636
|
+
logger.log(ui.note('No new frame came in time; this is the latest.'));
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
function isDirectory(p) {
|
|
643
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
644
|
+
}
|
|
645
|
+
/** `--env KEY=VALUE`, given once or several times. */
|
|
646
|
+
export function parseEnvFlags(value) {
|
|
647
|
+
if (value === undefined || value === false)
|
|
648
|
+
return undefined;
|
|
649
|
+
const items = (Array.isArray(value) ? value : [value]).map(String);
|
|
650
|
+
const env = {};
|
|
651
|
+
for (const item of items) {
|
|
652
|
+
const eq = item.indexOf('=');
|
|
653
|
+
if (eq <= 0)
|
|
654
|
+
throw new CliError(`--env takes KEY=VALUE, got "${item}".`);
|
|
655
|
+
env[item.slice(0, eq)] = item.slice(eq + 1);
|
|
656
|
+
}
|
|
657
|
+
return env;
|
|
658
|
+
}
|
|
659
|
+
/** Wait for a flow that outlasted the API's own wait. */
|
|
660
|
+
async function waitForCommand(ctx, id, commandId) {
|
|
661
|
+
let last = '';
|
|
662
|
+
for (;;) {
|
|
663
|
+
await sleep(WAIT_POLL_MS);
|
|
664
|
+
const response = await DeviceGateway.command(ctx.apiUrl, await freshAuth(ctx), id, commandId);
|
|
665
|
+
if (response.status !== 'running')
|
|
666
|
+
return response;
|
|
667
|
+
const line = `still running (${response.session.state})`;
|
|
668
|
+
if (line !== last) {
|
|
669
|
+
last = line;
|
|
670
|
+
ctx.progress(` ${colors.dim(line)}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const runSub = defineCommand({
|
|
675
|
+
meta: { name: 'run', description: 'Run a Maestro flow (or a directory of flows) on the device, and wait for the result' },
|
|
676
|
+
args: {
|
|
677
|
+
...commonArgs,
|
|
678
|
+
id: idArg,
|
|
679
|
+
flow: {
|
|
680
|
+
type: 'positional',
|
|
681
|
+
required: false,
|
|
682
|
+
description: 'A flow file (.yaml), or a directory of flows (uploaded as a bundle)',
|
|
683
|
+
},
|
|
684
|
+
yaml: {
|
|
685
|
+
type: 'string',
|
|
686
|
+
description: 'The flow inline, instead of a file',
|
|
687
|
+
},
|
|
688
|
+
entry: {
|
|
689
|
+
type: 'string',
|
|
690
|
+
description: 'With a directory: the one flow to run, as a path inside it (default: all of them)',
|
|
691
|
+
},
|
|
692
|
+
env: {
|
|
693
|
+
type: 'string',
|
|
694
|
+
description: 'A variable for the flow, KEY=VALUE, used as ${KEY}. Repeatable. Sent sealed, never stored in plain text',
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
async run({ args, rawArgs }) {
|
|
698
|
+
const json = Boolean(args.json);
|
|
699
|
+
await runDeviceCommand(json, async () => {
|
|
700
|
+
let id = args.id;
|
|
701
|
+
let flow = args.flow;
|
|
702
|
+
if (id && !flow && (/\.ya?ml$/i.test(id) || isDirectory(id)))
|
|
703
|
+
[id, flow] = [undefined, id];
|
|
704
|
+
const inline = recoverFlagValue('--yaml', args.yaml, rawArgs);
|
|
705
|
+
if (Boolean(flow) === Boolean(inline)) {
|
|
706
|
+
throw new CliError('Give a flow file, a directory of flows, or --yaml: one of the three.');
|
|
707
|
+
}
|
|
708
|
+
const dir = flow && isDirectory(flow) ? flow : undefined;
|
|
709
|
+
const entry = args.entry;
|
|
710
|
+
if (entry && !dir)
|
|
711
|
+
throw new CliError('--entry picks a flow inside a directory; give a directory.');
|
|
712
|
+
if (entry && (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..'))) {
|
|
713
|
+
throw new CliError('--entry is a path inside the directory, e.g. flows/login.yaml.');
|
|
714
|
+
}
|
|
715
|
+
let yaml = inline;
|
|
716
|
+
if (flow && !dir) {
|
|
717
|
+
try {
|
|
718
|
+
yaml = readFileSync(flow, 'utf8');
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
throw new CliError(`Could not read ${flow}: ${error.message}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const repeated = collectFlagValues('--env', rawArgs);
|
|
725
|
+
const env = parseEnvFlags(repeated.length > 0 ? repeated : args.env);
|
|
726
|
+
const ctx = await context(args);
|
|
727
|
+
id = await resolveSessionId(ctx.apiUrl, ctx.auth, id);
|
|
728
|
+
let body;
|
|
729
|
+
if (dir) {
|
|
730
|
+
ctx.progress(ui.running(`Uploading the flows in ${colors.highlight(dir)}…`));
|
|
731
|
+
const bundle = await uploadFlowBundle({ apiUrl: ctx.apiUrl, auth: ctx.auth, dir, log: ctx.progress });
|
|
732
|
+
ctx.progress(ui.success(`Uploaded ${bundle.files} files`));
|
|
733
|
+
body = {
|
|
734
|
+
flowUploadId: bundle.id,
|
|
735
|
+
...(entry && { flow: entry.split(path.sep).join('/') }),
|
|
736
|
+
...(env && { env }),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
body = { yaml: yaml, ...(env && { env }) };
|
|
741
|
+
}
|
|
742
|
+
ctx.progress(ui.running(`Running ${entry ?? flow ?? 'the flow'} on ${colors.highlight(id)}…`));
|
|
743
|
+
let response = await DeviceGateway.run(ctx.apiUrl, ctx.auth, id, body);
|
|
744
|
+
if (response.status === 'running')
|
|
745
|
+
response = await waitForCommand(ctx, id, response.command_id);
|
|
746
|
+
const result = response.result ?? { success: false };
|
|
747
|
+
const passed = response.status === 'passed';
|
|
748
|
+
return {
|
|
749
|
+
session: response.session,
|
|
750
|
+
data: { command_id: response.command_id, status: response.status, result },
|
|
751
|
+
...(!passed && {
|
|
752
|
+
failure: { code: null, message: runFailureMessage(result, parseRunReport(result)) },
|
|
753
|
+
}),
|
|
754
|
+
print() {
|
|
755
|
+
printRunResult(logger.log, passed, result, entry ?? (flow ? path.basename(flow) : 'flow'));
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
});
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
/** Every value of a repeatable flag; citty keeps only the last one. */
|
|
762
|
+
export function collectFlagValues(flag, rawArgs) {
|
|
763
|
+
const values = [];
|
|
764
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
765
|
+
if (rawArgs[i] === flag && i + 1 < rawArgs.length)
|
|
766
|
+
values.push(rawArgs[++i]);
|
|
767
|
+
else if (rawArgs[i].startsWith(`${flag}=`))
|
|
768
|
+
values.push(rawArgs[i].slice(flag.length + 1));
|
|
769
|
+
}
|
|
770
|
+
return values;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* citty drops a string flag's value when it starts with `-`, as a flow's
|
|
774
|
+
* first command does (`--yaml "- launchApp"`). Read it from argv instead.
|
|
775
|
+
*/
|
|
776
|
+
export function recoverFlagValue(flag, parsed, rawArgs) {
|
|
777
|
+
if (parsed && parsed.trim())
|
|
778
|
+
return parsed;
|
|
779
|
+
const eq = rawArgs.find((a) => a.startsWith(`${flag}=`));
|
|
780
|
+
if (eq)
|
|
781
|
+
return eq.slice(flag.length + 1);
|
|
782
|
+
const idx = rawArgs.indexOf(flag);
|
|
783
|
+
if (idx !== -1 && idx + 1 < rawArgs.length)
|
|
784
|
+
return rawArgs[idx + 1];
|
|
785
|
+
return parsed;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The lines of `next` that `prev` has not shown. Each fetch is the log's
|
|
789
|
+
* last N lines, so the overlap is the longest end of `prev` that `next`
|
|
790
|
+
* starts with. No overlap: everything in `next` is new.
|
|
791
|
+
*/
|
|
792
|
+
export function newLogLines(prev, next) {
|
|
793
|
+
for (let k = Math.min(prev.length, next.length); k > 0; k--) {
|
|
794
|
+
let same = true;
|
|
795
|
+
for (let i = 0; i < k; i++) {
|
|
796
|
+
if (prev[prev.length - k + i] !== next[i]) {
|
|
797
|
+
same = false;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (same)
|
|
802
|
+
return next.slice(k);
|
|
803
|
+
}
|
|
804
|
+
return next;
|
|
805
|
+
}
|
|
806
|
+
const LOGS_FOLLOW_MS = 3000;
|
|
807
|
+
const logsSub = defineCommand({
|
|
808
|
+
meta: { name: 'logs', description: "The installed app's recent device log lines" },
|
|
809
|
+
args: {
|
|
810
|
+
...commonArgs,
|
|
811
|
+
id: idArg,
|
|
812
|
+
lines: { type: 'string', default: '200', description: 'How many lines, up to 2000' },
|
|
813
|
+
follow: {
|
|
814
|
+
type: 'boolean',
|
|
815
|
+
alias: ['f'],
|
|
816
|
+
default: false,
|
|
817
|
+
description: 'Keep printing new lines until the session ends (not with --json)',
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
async run({ args }) {
|
|
821
|
+
const json = Boolean(args.json);
|
|
822
|
+
if (args.follow && json) {
|
|
823
|
+
await runDeviceCommand(json, async () => {
|
|
824
|
+
throw new CliError('--follow prints a stream of lines, so it cannot be used with --json.');
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const lines = Number.parseInt(String(args.lines), 10);
|
|
829
|
+
await runDeviceCommand(json, async () => {
|
|
830
|
+
if (!Number.isInteger(lines) || lines < 1)
|
|
831
|
+
throw new CliError('--lines must be a whole number.');
|
|
832
|
+
const ctx = await context(args);
|
|
833
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
834
|
+
const first = await DeviceGateway.logs(ctx.apiUrl, ctx.auth, id, lines);
|
|
835
|
+
if (!args.follow) {
|
|
836
|
+
return {
|
|
837
|
+
session: first.session,
|
|
838
|
+
data: { logs: first.logs },
|
|
839
|
+
print() {
|
|
840
|
+
if (first.logs.lines.length === 0) {
|
|
841
|
+
logger.log(ui.info(first.logs.source === 'none' ? 'No app installed yet, so no app logs.' : 'No log lines yet.'));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
for (const line of first.logs.lines)
|
|
845
|
+
logger.log(line);
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
// --follow: print as lines arrive. Ends when the session does.
|
|
850
|
+
for (const line of first.logs.lines)
|
|
851
|
+
logger.log(line);
|
|
852
|
+
let shown = first.logs.lines;
|
|
853
|
+
let { session } = first;
|
|
854
|
+
for (;;) {
|
|
855
|
+
await sleep(LOGS_FOLLOW_MS);
|
|
856
|
+
try {
|
|
857
|
+
const next = await DeviceGateway.logs(ctx.apiUrl, await freshAuth(ctx), id, lines);
|
|
858
|
+
for (const line of newLogLines(shown, next.logs.lines))
|
|
859
|
+
logger.log(line);
|
|
860
|
+
shown = next.logs.lines;
|
|
861
|
+
session = next.session;
|
|
862
|
+
}
|
|
863
|
+
catch (error) {
|
|
864
|
+
if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED') {
|
|
865
|
+
return {
|
|
866
|
+
session: error.session ?? session,
|
|
867
|
+
data: null,
|
|
868
|
+
print() {
|
|
869
|
+
logger.log(ui.info(`Session ${id} has ended.`));
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
const openSub = defineCommand({
|
|
880
|
+
meta: { name: 'open', description: "Open the session's live monitor in the browser" },
|
|
881
|
+
args: { ...commonArgs, id: idArg },
|
|
882
|
+
async run({ args }) {
|
|
883
|
+
const json = Boolean(args.json);
|
|
884
|
+
await runDeviceCommand(json, async () => {
|
|
885
|
+
const ctx = await context(args);
|
|
886
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
887
|
+
const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id);
|
|
888
|
+
// Only a person at a terminal gets a browser; a script gets the URL.
|
|
889
|
+
const opened = interactive(json) && openBrowser(session.monitor_url);
|
|
890
|
+
return {
|
|
891
|
+
session,
|
|
892
|
+
data: { url: session.monitor_url, opened },
|
|
893
|
+
print() {
|
|
894
|
+
logger.log(opened ? ui.success('Opened the monitor') : ui.info('The monitor:'));
|
|
895
|
+
logger.log(ui.branch([formatUrl(session.monitor_url)]));
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
const HUMAN_POLL_MS = 5000;
|
|
902
|
+
/** The session is with a person: they were asked for help, or took control. */
|
|
903
|
+
function withPerson(human) {
|
|
904
|
+
return human.waiting || human.human_in_control;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Poll GET .../human until the person hands the session back (or gives up
|
|
908
|
+
* control). A session that ends meanwhile is exit 4. Ctrl-C stops waiting
|
|
909
|
+
* and leaves the session as it is.
|
|
910
|
+
*/
|
|
911
|
+
async function waitForHandBack(ctx, id, first) {
|
|
912
|
+
let current = first;
|
|
913
|
+
if (!withPerson(current.human))
|
|
914
|
+
return current;
|
|
915
|
+
ctx.progress(ui.running(`Waiting for a person to hand ${id} back. Ctrl-C stops waiting; the session stays open.`));
|
|
916
|
+
for (;;) {
|
|
917
|
+
if (ENDED_STATES.includes(current.session.state)) {
|
|
918
|
+
throw new DeviceApiError(`Device session ${id} ended while waiting for a person (${current.session.release_reason ?? current.session.state}).`, 410, 'LIVE_SESSION_ENDED', current.session);
|
|
919
|
+
}
|
|
920
|
+
if (!withPerson(current.human))
|
|
921
|
+
return current;
|
|
922
|
+
await sleep(HUMAN_POLL_MS);
|
|
923
|
+
current = await DeviceGateway.human(ctx.apiUrl, await freshAuth(ctx), id);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/** A person's part in a session, for `human` and `request-human`. */
|
|
927
|
+
function printHuman(id, human, waited) {
|
|
928
|
+
if (waited && human.handed_back)
|
|
929
|
+
logger.log(ui.success(`${id} was handed back`));
|
|
930
|
+
logger.log(ui.section('Human help'));
|
|
931
|
+
logger.log(ui.branch(ui.fields(humanFields(human))));
|
|
932
|
+
if (!waited && withPerson(human)) {
|
|
933
|
+
logger.log(ui.note(`Wait for the hand-back with \`dcd device human ${id} --wait\`.`));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const waitForPersonArg = {
|
|
937
|
+
type: 'boolean',
|
|
938
|
+
default: false,
|
|
939
|
+
description: `Wait until the person hands the session back (checks every ${HUMAN_POLL_MS / 1000}s; Ctrl-C stops waiting)`,
|
|
940
|
+
};
|
|
941
|
+
const requestHumanSub = defineCommand({
|
|
942
|
+
meta: {
|
|
943
|
+
name: 'request-human',
|
|
944
|
+
description: 'Ask a person for help (a 2FA code, a CAPTCHA): the session waits for them to hand it back',
|
|
945
|
+
},
|
|
946
|
+
args: {
|
|
947
|
+
...commonArgs,
|
|
948
|
+
reason: {
|
|
949
|
+
type: 'positional',
|
|
950
|
+
// Checked in run, so a missing reason still fails with the --json envelope.
|
|
951
|
+
required: false,
|
|
952
|
+
description: 'What the person should do, e.g. "Enter the 2FA code sent to the test phone"',
|
|
953
|
+
},
|
|
954
|
+
id: idArg,
|
|
955
|
+
wait: waitForPersonArg,
|
|
956
|
+
},
|
|
957
|
+
async run({ args }) {
|
|
958
|
+
const json = Boolean(args.json);
|
|
959
|
+
await runDeviceCommand(json, async () => {
|
|
960
|
+
const reason = String(args.reason ?? '').trim();
|
|
961
|
+
if (!reason)
|
|
962
|
+
throw new CliError('Say what the person should do, e.g. dcd device request-human "Enter the 2FA code".');
|
|
963
|
+
if (reason.length > 500)
|
|
964
|
+
throw new CliError('The reason is too long: keep it to 500 characters.');
|
|
965
|
+
const ctx = await context(args);
|
|
966
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
967
|
+
let response = await DeviceGateway.requestHuman(ctx.apiUrl, ctx.auth, id, reason);
|
|
968
|
+
ctx.progress(ui.success(`Asked for a person on ${id}`));
|
|
969
|
+
if (args.wait)
|
|
970
|
+
response = await waitForHandBack(ctx, id, response);
|
|
971
|
+
const { human, session } = response;
|
|
972
|
+
return {
|
|
973
|
+
session,
|
|
974
|
+
data: { human },
|
|
975
|
+
print() {
|
|
976
|
+
printHuman(session.id, human, Boolean(args.wait));
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
});
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
const humanSub = defineCommand({
|
|
983
|
+
meta: { name: 'human', description: 'Whether a session waits for a person, and their note when they hand it back' },
|
|
984
|
+
args: { ...commonArgs, id: idArg, wait: waitForPersonArg },
|
|
985
|
+
async run({ args }) {
|
|
986
|
+
const json = Boolean(args.json);
|
|
987
|
+
await runDeviceCommand(json, async () => {
|
|
988
|
+
const ctx = await context(args);
|
|
989
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
990
|
+
let response = await DeviceGateway.human(ctx.apiUrl, ctx.auth, id);
|
|
991
|
+
if (args.wait)
|
|
992
|
+
response = await waitForHandBack(ctx, id, response);
|
|
993
|
+
const { human, session } = response;
|
|
994
|
+
return {
|
|
995
|
+
session,
|
|
996
|
+
data: { human },
|
|
997
|
+
print() {
|
|
998
|
+
printHuman(session.id, human, Boolean(args.wait));
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
const artifactsSub = defineCommand({
|
|
1005
|
+
meta: {
|
|
1006
|
+
name: 'artifacts',
|
|
1007
|
+
description: 'Download what a session left behind: its video, device log, and the screen after each action',
|
|
1008
|
+
},
|
|
1009
|
+
args: {
|
|
1010
|
+
...commonArgs,
|
|
1011
|
+
id: idArg,
|
|
1012
|
+
output: {
|
|
1013
|
+
type: 'string',
|
|
1014
|
+
alias: ['o'],
|
|
1015
|
+
description: 'Directory to save them in (default: ./<session>-artifacts)',
|
|
1016
|
+
},
|
|
1017
|
+
links: {
|
|
1018
|
+
type: 'boolean',
|
|
1019
|
+
default: false,
|
|
1020
|
+
description: 'List the download links instead of downloading (they expire after a while)',
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
async run({ args }) {
|
|
1024
|
+
const json = Boolean(args.json);
|
|
1025
|
+
await runDeviceCommand(json, async () => {
|
|
1026
|
+
const ctx = await context(args);
|
|
1027
|
+
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
1028
|
+
const { pages, session } = await fetchAllArtifacts(ctx.apiUrl, ctx.auth, id);
|
|
1029
|
+
const files = planArtifactFiles(pages);
|
|
1030
|
+
const pending = pendingArtifacts(pages);
|
|
1031
|
+
const warning = pendingArtifactsWarning(pending);
|
|
1032
|
+
const printWarning = () => {
|
|
1033
|
+
if (warning)
|
|
1034
|
+
logger.log(ui.warn(warning));
|
|
1035
|
+
};
|
|
1036
|
+
if (args.links) {
|
|
1037
|
+
return {
|
|
1038
|
+
session,
|
|
1039
|
+
data: { dir: null, files, failed: [], pending, warning },
|
|
1040
|
+
print() {
|
|
1041
|
+
if (files.length === 0) {
|
|
1042
|
+
logger.log(ui.info('This session has no artifacts yet.'));
|
|
1043
|
+
printWarning();
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
logger.log(ui.section(`Artifacts (${files.length})`));
|
|
1047
|
+
logger.log(ui.branch(ui.fields(files.map((f) => [f.file, f.url ? formatUrl(f.url) : colors.dim('no link')]))));
|
|
1048
|
+
printWarning();
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const dir = resolvePath(args.output || `${session.id}-artifacts`);
|
|
1053
|
+
if (files.length > 0)
|
|
1054
|
+
ctx.progress(ui.running(`Downloading ${files.length} files to ${dir}…`));
|
|
1055
|
+
const failed = await downloadArtifactFiles(dir, files);
|
|
1056
|
+
const saved = files.length - failed.length;
|
|
1057
|
+
return {
|
|
1058
|
+
session,
|
|
1059
|
+
data: { dir, files, failed, pending, warning },
|
|
1060
|
+
...(failed.length > 0 && {
|
|
1061
|
+
failure: {
|
|
1062
|
+
code: null,
|
|
1063
|
+
message: `${failed.length} of ${files.length} files could not be downloaded: ${failed.map((f) => `${f.file} (${f.reason})`).join(', ')}`,
|
|
1064
|
+
},
|
|
1065
|
+
}),
|
|
1066
|
+
print() {
|
|
1067
|
+
if (files.length === 0) {
|
|
1068
|
+
logger.log(ui.info('This session has no artifacts yet.'));
|
|
1069
|
+
printWarning();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
logger.log(ui.success(`Saved ${saved} of ${files.length} files to ${dir}`));
|
|
1073
|
+
for (const f of failed)
|
|
1074
|
+
logger.log(ui.warn(`${f.file}: ${f.reason}`));
|
|
1075
|
+
printWarning();
|
|
1076
|
+
if (!ENDED_STATES.includes(session.state)) {
|
|
1077
|
+
logger.log(ui.note('The session is still open: its video and log are saved when it ends.'));
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
export const deviceCommand = defineCommand({
|
|
1085
|
+
meta: {
|
|
1086
|
+
name: 'device',
|
|
1087
|
+
description: 'Cloud devices you (or an AI agent) can lease, drive and release [beta]',
|
|
1088
|
+
},
|
|
1089
|
+
subCommands: {
|
|
1090
|
+
start: startSub,
|
|
1091
|
+
list: listSub,
|
|
1092
|
+
status: statusSub,
|
|
1093
|
+
stop: stopSub,
|
|
1094
|
+
install: installSub,
|
|
1095
|
+
launch: launchSub,
|
|
1096
|
+
models: modelsSub,
|
|
1097
|
+
inspect: inspectSub,
|
|
1098
|
+
screenshot: screenshotSub,
|
|
1099
|
+
run: runSub,
|
|
1100
|
+
logs: logsSub,
|
|
1101
|
+
open: openSub,
|
|
1102
|
+
artifacts: artifactsSub,
|
|
1103
|
+
'request-human': requestHumanSub,
|
|
1104
|
+
human: humanSub,
|
|
1105
|
+
},
|
|
1106
|
+
});
|