@devicecloud.dev/dcd 5.11.0-beta.1 → 5.11.0-beta.3
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/README.md +50 -3
- package/dist/commands/device.d.ts +36 -0
- package/dist/commands/device.js +1123 -0
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +9 -0
- package/dist/commands/login.js +2 -17
- 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 +16 -0
- package/dist/utils/browser.js +48 -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,211 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { DeviceGateway } from '../gateways/device-gateway.js';
|
|
7
|
+
import { ENDED_STATES, } from '../types/domain/device-session.types.js';
|
|
8
|
+
const EXTENSIONS = {
|
|
9
|
+
'application/json': 'json',
|
|
10
|
+
'image/jpeg': 'jpg',
|
|
11
|
+
'image/png': 'png',
|
|
12
|
+
'text/plain': 'log',
|
|
13
|
+
'video/mp4': 'mp4',
|
|
14
|
+
};
|
|
15
|
+
function extensionFor(link, fallback) {
|
|
16
|
+
return EXTENSIONS[link.content_type.split(';')[0].trim()] ?? fallback;
|
|
17
|
+
}
|
|
18
|
+
/** Every page of a session's artifacts. */
|
|
19
|
+
export async function fetchAllArtifacts(apiUrl, auth, id) {
|
|
20
|
+
const pages = [];
|
|
21
|
+
let before;
|
|
22
|
+
do {
|
|
23
|
+
const page = await DeviceGateway.artifacts(apiUrl, auth, id, before);
|
|
24
|
+
pages.push(page);
|
|
25
|
+
before = page.next_before ?? undefined;
|
|
26
|
+
} while (before);
|
|
27
|
+
return { pages, session: pages.at(-1).session };
|
|
28
|
+
}
|
|
29
|
+
/** How long after a session ends its video and log may still be uploading. */
|
|
30
|
+
const PENDING_WINDOW_MS = 3 * 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* What a session's runner is still uploading: the video (`recording`) and
|
|
33
|
+
* the device log (`logs`) land up to a minute or so after the session ends.
|
|
34
|
+
* The API says so in `pending`. An API older than that field gets a guess:
|
|
35
|
+
* an ended session missing either file whose last activity (its newest
|
|
36
|
+
* action, else its start) was under three minutes ago. The session summary
|
|
37
|
+
* carries no end time, so the last activity stands in for it.
|
|
38
|
+
*/
|
|
39
|
+
export function pendingArtifacts(pages, now = Date.now()) {
|
|
40
|
+
const first = pages[0];
|
|
41
|
+
if (!first)
|
|
42
|
+
return [];
|
|
43
|
+
if (Array.isArray(first.pending))
|
|
44
|
+
return first.pending;
|
|
45
|
+
if (!ENDED_STATES.includes(first.session.state))
|
|
46
|
+
return [];
|
|
47
|
+
const lastActivity = Date.parse(first.actions[0]?.created_at ?? first.session.created_at);
|
|
48
|
+
if (!Number.isFinite(lastActivity) || now - lastActivity > PENDING_WINDOW_MS)
|
|
49
|
+
return [];
|
|
50
|
+
const pending = [];
|
|
51
|
+
if (!first.recording)
|
|
52
|
+
pending.push('recording');
|
|
53
|
+
if (!first.logs)
|
|
54
|
+
pending.push('logs');
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
/** The warning for {@link pendingArtifacts}, or null when nothing is pending. */
|
|
58
|
+
export function pendingArtifactsWarning(pending) {
|
|
59
|
+
if (pending.length === 0)
|
|
60
|
+
return null;
|
|
61
|
+
const what = pending.length === 2
|
|
62
|
+
? 'The video and device log are'
|
|
63
|
+
: pending[0] === 'recording'
|
|
64
|
+
? 'The video is'
|
|
65
|
+
: 'The device log is';
|
|
66
|
+
return `${what} still uploading. Run this again in a minute.`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lay the artifacts out as files: `recording.mp4`, `device.log`, and per
|
|
70
|
+
* action `actions/001-screenshot.jpg` and `actions/001-screen.json`,
|
|
71
|
+
* numbered oldest first so the folder reads in order.
|
|
72
|
+
*/
|
|
73
|
+
export function planArtifactFiles(pages) {
|
|
74
|
+
const files = [];
|
|
75
|
+
const first = pages[0];
|
|
76
|
+
if (first?.recording) {
|
|
77
|
+
files.push({
|
|
78
|
+
kind: 'recording',
|
|
79
|
+
file: `recording.${extensionFor(first.recording, 'mp4')}`,
|
|
80
|
+
url: first.recording.url,
|
|
81
|
+
bytes: first.recording.bytes,
|
|
82
|
+
created_at: first.recording.created_at,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (first?.logs) {
|
|
86
|
+
files.push({
|
|
87
|
+
kind: 'logs',
|
|
88
|
+
file: `device.${extensionFor(first.logs, 'log')}`,
|
|
89
|
+
url: first.logs.url,
|
|
90
|
+
bytes: first.logs.bytes,
|
|
91
|
+
created_at: first.logs.created_at,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const actions = pages.flatMap((p) => p.actions).reverse();
|
|
95
|
+
const width = Math.max(3, String(actions.length).length);
|
|
96
|
+
for (const [i, action] of actions.entries()) {
|
|
97
|
+
const n = String(i + 1).padStart(width, '0');
|
|
98
|
+
if (action.screenshot) {
|
|
99
|
+
files.push({
|
|
100
|
+
kind: 'screenshot',
|
|
101
|
+
file: path.join('actions', `${n}-screenshot.${extensionFor(action.screenshot, 'jpg')}`),
|
|
102
|
+
url: action.screenshot.url,
|
|
103
|
+
bytes: action.screenshot.bytes,
|
|
104
|
+
command_id: action.command_id,
|
|
105
|
+
created_at: action.created_at,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (action.hierarchy) {
|
|
109
|
+
files.push({
|
|
110
|
+
kind: 'hierarchy',
|
|
111
|
+
file: path.join('actions', `${n}-screen.${extensionFor(action.hierarchy, 'json')}`),
|
|
112
|
+
url: action.hierarchy.url,
|
|
113
|
+
bytes: action.hierarchy.bytes,
|
|
114
|
+
command_id: action.command_id,
|
|
115
|
+
created_at: action.created_at,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
const CONCURRENCY = 4;
|
|
122
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
|
|
123
|
+
const DEFAULT_TOTAL_TIMEOUT_MS = 10 * 60_000;
|
|
124
|
+
const DEFAULT_ATTEMPTS = 2;
|
|
125
|
+
function seconds(ms) {
|
|
126
|
+
return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Fetch one URL to `target`. Aborts when no data arrives for `idleMs`
|
|
130
|
+
* (including while waiting for the response headers) or when the whole
|
|
131
|
+
* download takes longer than `totalMs`. A CDN stream can stall without
|
|
132
|
+
* closing, and without these the command would wait for it forever.
|
|
133
|
+
*/
|
|
134
|
+
async function downloadOnce(url, target, idleMs, totalMs) {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const abort = (reason) => controller.abort(new Error(reason));
|
|
137
|
+
let idle = setTimeout(() => abort(`timed out: no data for ${seconds(idleMs)}`), idleMs);
|
|
138
|
+
const total = setTimeout(() => abort(`timed out: took longer than ${seconds(totalMs)}`), totalMs);
|
|
139
|
+
const resetIdle = () => {
|
|
140
|
+
clearTimeout(idle);
|
|
141
|
+
idle = setTimeout(() => abort(`timed out: no data for ${seconds(idleMs)}`), idleMs);
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
145
|
+
if (!res.ok || !res.body)
|
|
146
|
+
throw new Error(`HTTP ${res.status}`);
|
|
147
|
+
resetIdle();
|
|
148
|
+
const body = Readable.fromWeb(res.body);
|
|
149
|
+
await pipeline(body, async function* (source) {
|
|
150
|
+
for await (const chunk of source) {
|
|
151
|
+
resetIdle();
|
|
152
|
+
yield chunk;
|
|
153
|
+
}
|
|
154
|
+
}, createWriteStream(target), { signal: controller.signal });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Report the timeout, not the AbortError it surfaces as.
|
|
158
|
+
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
|
159
|
+
throw controller.signal.reason;
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(idle);
|
|
165
|
+
clearTimeout(total);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Download every file with a link into `dir`, four at a time. Each file is
|
|
170
|
+
* tried twice (a stalled CDN response usually succeeds on a second
|
|
171
|
+
* request). Returns the files that could not be fetched, with why, rather
|
|
172
|
+
* than failing the rest.
|
|
173
|
+
*/
|
|
174
|
+
export async function downloadArtifactFiles(dir, files, onFile = () => { }, options = {}) {
|
|
175
|
+
const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
176
|
+
const totalMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
|
177
|
+
const attempts = Math.max(1, options.attempts ?? DEFAULT_ATTEMPTS);
|
|
178
|
+
const failed = [];
|
|
179
|
+
const queue = [...files];
|
|
180
|
+
async function worker() {
|
|
181
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
182
|
+
if (!next.url) {
|
|
183
|
+
failed.push({ file: next.file, reason: 'no download link (it may have been cleaned up)' });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const target = path.join(dir, next.file);
|
|
187
|
+
let reason = '';
|
|
188
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
189
|
+
try {
|
|
190
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
191
|
+
await downloadOnce(next.url, target, idleMs, totalMs);
|
|
192
|
+
reason = '';
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (reason) {
|
|
200
|
+
// Don't leave a half-written file that looks like a real one.
|
|
201
|
+
await rm(target, { force: true }).catch(() => { });
|
|
202
|
+
failed.push({ file: next.file, reason: attempts > 1 ? `${reason} (after ${attempts} tries)` : reason });
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
onFile(next);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
210
|
+
return failed;
|
|
211
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuthContext } from '../types/domain/auth.types.js';
|
|
2
|
+
import type { DeviceFlowBundle } from '../types/domain/device-session.types.js';
|
|
3
|
+
/** The API's limit on a bundle (dcd plan 2.5b). */
|
|
4
|
+
export declare const MAX_BUNDLE_BYTES: number;
|
|
5
|
+
/** Every file under `dir`, relative to it, in a stable order. */
|
|
6
|
+
export declare function bundleFiles(dir: string): string[];
|
|
7
|
+
export interface UploadFlowBundleOptions {
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
auth: AuthContext;
|
|
10
|
+
dir: string;
|
|
11
|
+
log: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Zip a directory of flows and upload it as a flow bundle: ask the API for
|
|
15
|
+
* upload URLs, PUT the zip to both copies, then register it with its
|
|
16
|
+
* SHA-256. Encrypted like `dcd cloud`'s flow zips when the org's default
|
|
17
|
+
* (or DCD_ENCRYPT) says so. Entries are relative to `dir`, so a run's
|
|
18
|
+
* `flow` is a path inside it, e.g. `login.yaml`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function uploadFlowBundle(opts: UploadFlowBundleOptions): Promise<DeviceFlowBundle & {
|
|
21
|
+
files: number;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { DeviceGateway } from '../gateways/device-gateway.js';
|
|
5
|
+
import { compressFilesFromRelativePath } from '../methods.js';
|
|
6
|
+
import { detectCiContext } from './ci.js';
|
|
7
|
+
import { CliError, getCliVersion } from './cli.js';
|
|
8
|
+
import { fetchCompatibilityData } from './compatibility.js';
|
|
9
|
+
import { checkKekForEncryption, encryptFlowBuffer, resolveEncryption, resolveKekPublicKey, } from './envelope.js';
|
|
10
|
+
/** The API's limit on a bundle (dcd plan 2.5b). */
|
|
11
|
+
export const MAX_BUNDLE_BYTES = 50 * 1024 * 1024;
|
|
12
|
+
/** Never bundled: version control, dependencies, and other dot-folders. */
|
|
13
|
+
function skipped(relativePath) {
|
|
14
|
+
return relativePath
|
|
15
|
+
.split(path.sep)
|
|
16
|
+
.some((part) => part === 'node_modules' || (part.startsWith('.') && part !== '.'));
|
|
17
|
+
}
|
|
18
|
+
/** Every file under `dir`, relative to it, in a stable order. */
|
|
19
|
+
export function bundleFiles(dir) {
|
|
20
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
21
|
+
.filter((entry) => entry.isFile())
|
|
22
|
+
.map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)))
|
|
23
|
+
.filter((file) => !skipped(file))
|
|
24
|
+
.sort();
|
|
25
|
+
}
|
|
26
|
+
/** PUT the zip to one signed URL. The URL is the credential. */
|
|
27
|
+
async function put(url, body) {
|
|
28
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
headers: { 'content-type': 'application/zip' },
|
|
33
|
+
body: new Uint8Array(body),
|
|
34
|
+
});
|
|
35
|
+
await res.body?.cancel();
|
|
36
|
+
if (res.ok)
|
|
37
|
+
return true;
|
|
38
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A network error: try once more.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Zip a directory of flows and upload it as a flow bundle: ask the API for
|
|
50
|
+
* upload URLs, PUT the zip to both copies, then register it with its
|
|
51
|
+
* SHA-256. Encrypted like `dcd cloud`'s flow zips when the org's default
|
|
52
|
+
* (or DCD_ENCRYPT) says so. Entries are relative to `dir`, so a run's
|
|
53
|
+
* `flow` is a path inside it, e.g. `login.yaml`.
|
|
54
|
+
*/
|
|
55
|
+
export async function uploadFlowBundle(opts) {
|
|
56
|
+
const { apiUrl, auth, dir, log } = opts;
|
|
57
|
+
const files = bundleFiles(dir);
|
|
58
|
+
if (!files.some((f) => /\.ya?ml$/i.test(f))) {
|
|
59
|
+
throw new CliError(`No flow files (.yaml) in ${dir}.`);
|
|
60
|
+
}
|
|
61
|
+
const zip = await compressFilesFromRelativePath(dir, files, '');
|
|
62
|
+
let serverDefault;
|
|
63
|
+
try {
|
|
64
|
+
const ci = detectCiContext();
|
|
65
|
+
const compatibility = await fetchCompatibilityData(apiUrl, auth, {
|
|
66
|
+
cliVersion: getCliVersion(),
|
|
67
|
+
ciProvider: ci.provider,
|
|
68
|
+
ciWrapperVersion: ci.wrapperVersion,
|
|
69
|
+
});
|
|
70
|
+
serverDefault = compatibility.encryption?.defaultOn;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
serverDefault = undefined;
|
|
74
|
+
}
|
|
75
|
+
const encryption = await checkKekForEncryption(resolveEncryption({ serverDefault }), apiUrl);
|
|
76
|
+
if (encryption.notice)
|
|
77
|
+
log(encryption.notice);
|
|
78
|
+
const kek = encryption.enabled ? await resolveKekPublicKey(apiUrl) : null;
|
|
79
|
+
const sealed = kek ? encryptFlowBuffer(zip, kek) : null;
|
|
80
|
+
const body = sealed ? sealed.ciphertext : zip;
|
|
81
|
+
if (body.length > MAX_BUNDLE_BYTES) {
|
|
82
|
+
throw new CliError(`${dir} zips to ${(body.length / 1024 / 1024).toFixed(1)} MB; a bundle can be at most ${MAX_BUNDLE_BYTES / 1024 / 1024} MB.`);
|
|
83
|
+
}
|
|
84
|
+
const sha = createHash('sha256').update(body).digest('hex');
|
|
85
|
+
const urls = await DeviceGateway.bundleUploadUrls(apiUrl, auth, body.length);
|
|
86
|
+
const [supabase, backblaze] = await Promise.all([
|
|
87
|
+
urls.upload ? put(urls.upload.signedUrl, body) : Promise.resolve(false),
|
|
88
|
+
urls.mirror ? put(urls.mirror.signedUrl, body) : Promise.resolve(false),
|
|
89
|
+
]);
|
|
90
|
+
if (!supabase && !backblaze) {
|
|
91
|
+
throw new Error('The flow bundle could not be uploaded. Please retry.');
|
|
92
|
+
}
|
|
93
|
+
const { bundle } = await DeviceGateway.registerBundle(apiUrl, auth, {
|
|
94
|
+
id: urls.id,
|
|
95
|
+
sha,
|
|
96
|
+
bytes: body.length,
|
|
97
|
+
supabase,
|
|
98
|
+
backblaze,
|
|
99
|
+
...(sealed && { enc: sealed.enc }),
|
|
100
|
+
});
|
|
101
|
+
return { ...bundle, files: files.length };
|
|
102
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AuthContext } from '../types/domain/auth.types.js';
|
|
2
|
+
import type { DeviceHumanStatus, DeviceSession } from '../types/domain/device-session.types.js';
|
|
3
|
+
import { type Field } from './ui.js';
|
|
4
|
+
/**
|
|
5
|
+
* Exit codes for `dcd device` (dcd docs/device-v1-plan.md, section 4.2).
|
|
6
|
+
* Scripts and agents branch on these, so they are a contract.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEVICE_EXIT: {
|
|
9
|
+
readonly ok: 0;
|
|
10
|
+
/** Called wrongly: a bad flag, a bad value, or no session to pick. */
|
|
11
|
+
readonly usage: 1;
|
|
12
|
+
/** It ran and failed: a flow or action failed, or the API could not do it. */
|
|
13
|
+
readonly failed: 2;
|
|
14
|
+
/** A limit: concurrency, spend cap or rate limit. */
|
|
15
|
+
readonly limit: 3;
|
|
16
|
+
/** The session has ended, or there is no such session. */
|
|
17
|
+
readonly gone: 4;
|
|
18
|
+
};
|
|
19
|
+
export declare function deviceExitCode(error: unknown): number;
|
|
20
|
+
/**
|
|
21
|
+
* The one object `--json` prints, on success and on failure alike. `session`
|
|
22
|
+
* is left out by the commands that are not about one session (`list`,
|
|
23
|
+
* `models`, `stop --all`), so it never reads as "no session".
|
|
24
|
+
*/
|
|
25
|
+
export interface DeviceEnvelope<T> {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
session?: DeviceSession | null;
|
|
28
|
+
data: T | null;
|
|
29
|
+
error: {
|
|
30
|
+
code: null | string;
|
|
31
|
+
message: string;
|
|
32
|
+
} | null;
|
|
33
|
+
}
|
|
34
|
+
export interface DeviceOutcome<T> {
|
|
35
|
+
/** The session the command was about; undefined when it is not about one. */
|
|
36
|
+
session: DeviceSession | null | undefined;
|
|
37
|
+
data: T;
|
|
38
|
+
/** Prints the result for a person. Not called under --json. */
|
|
39
|
+
print(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Set when the command ran but did not succeed (a failed launch, a failed
|
|
42
|
+
* flow). The CLI then exits 2, still printing `data`.
|
|
43
|
+
*/
|
|
44
|
+
failure?: {
|
|
45
|
+
code: null | string;
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Runs one `dcd device` command and prints its result: one envelope under
|
|
51
|
+
* --json, readable text otherwise. Errors leave with the matching exit code.
|
|
52
|
+
* Under --json the error is printed here, as an envelope, so the top-level
|
|
53
|
+
* handler's `{ status, error }` never appears.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runDeviceCommand<T>(json: boolean, fn: () => Promise<DeviceOutcome<T>>): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Progress for a person, on stderr, so stdout carries only the result. Off
|
|
58
|
+
* under --json. These are plain lines, never a spinner, so they read the same
|
|
59
|
+
* in a terminal, a CI log or an agent's tool output.
|
|
60
|
+
*/
|
|
61
|
+
export declare function deviceProgress(json: boolean): (message: string) => void;
|
|
62
|
+
/**
|
|
63
|
+
* The default-session rule: `<id>` may be left out when the org has exactly
|
|
64
|
+
* one open session. With none, or several, the command is a usage error and
|
|
65
|
+
* lists what there is to choose from.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolveSessionId(apiUrl: string, auth: AuthContext, given: string | undefined): Promise<string>;
|
|
68
|
+
export declare function formatSeconds(seconds: number): string;
|
|
69
|
+
/** The rows that describe a session, for {@link ui.fields}. */
|
|
70
|
+
export declare function sessionFields(session: DeviceSession): Field[];
|
|
71
|
+
/** A session as a branch group, with its warnings after it. */
|
|
72
|
+
export declare function printSession(session: DeviceSession): void;
|
|
73
|
+
/**
|
|
74
|
+
* An API timestamp for a person: how far off it is and the local time, e.g.
|
|
75
|
+
* "in 12m (14:32)" or "3m ago (14:17)". The text as given when unreadable.
|
|
76
|
+
*/
|
|
77
|
+
export declare function formatWhen(iso: string, now?: number): string;
|
|
78
|
+
/** Where the hand-over stands, in a few words. */
|
|
79
|
+
export declare function humanState(human: DeviceHumanStatus): string;
|
|
80
|
+
/** The rows that describe a request for a person, for {@link ui.fields}. */
|
|
81
|
+
export declare function humanFields(human: DeviceHumanStatus, now?: number): Field[];
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js';
|
|
2
|
+
import { CliError, logger } from './cli.js';
|
|
3
|
+
import { colors, formatUrl } from './styling.js';
|
|
4
|
+
import { ui } from './ui.js';
|
|
5
|
+
/**
|
|
6
|
+
* Exit codes for `dcd device` (dcd docs/device-v1-plan.md, section 4.2).
|
|
7
|
+
* Scripts and agents branch on these, so they are a contract.
|
|
8
|
+
*/
|
|
9
|
+
export const DEVICE_EXIT = {
|
|
10
|
+
ok: 0,
|
|
11
|
+
/** Called wrongly: a bad flag, a bad value, or no session to pick. */
|
|
12
|
+
usage: 1,
|
|
13
|
+
/** It ran and failed: a flow or action failed, or the API could not do it. */
|
|
14
|
+
failed: 2,
|
|
15
|
+
/** A limit: concurrency, spend cap or rate limit. */
|
|
16
|
+
limit: 3,
|
|
17
|
+
/** The session has ended, or there is no such session. */
|
|
18
|
+
gone: 4,
|
|
19
|
+
};
|
|
20
|
+
const LIMIT_CODES = new Set(['LIVE_CONCURRENCY_LIMIT', 'LIVE_RATE_LIMITED', 'LIVE_SPEND_CAP']);
|
|
21
|
+
const GONE_CODES = new Set(['LIVE_NOT_FOUND', 'LIVE_SESSION_ENDED']);
|
|
22
|
+
const USAGE_CODES = new Set([
|
|
23
|
+
'LIVE_INVALID_ACTION',
|
|
24
|
+
'LIVE_INVALID_APP',
|
|
25
|
+
'LIVE_SESSION_REQUIRED',
|
|
26
|
+
]);
|
|
27
|
+
export function deviceExitCode(error) {
|
|
28
|
+
if (error instanceof DeviceApiError) {
|
|
29
|
+
if (error.code && GONE_CODES.has(error.code))
|
|
30
|
+
return DEVICE_EXIT.gone;
|
|
31
|
+
if (error.code && LIMIT_CODES.has(error.code))
|
|
32
|
+
return DEVICE_EXIT.limit;
|
|
33
|
+
if (error.code && USAGE_CODES.has(error.code))
|
|
34
|
+
return DEVICE_EXIT.usage;
|
|
35
|
+
if (error.status === 404)
|
|
36
|
+
return DEVICE_EXIT.gone;
|
|
37
|
+
if (error.status === 429)
|
|
38
|
+
return DEVICE_EXIT.limit;
|
|
39
|
+
if ([400, 401, 403].includes(error.status))
|
|
40
|
+
return DEVICE_EXIT.usage;
|
|
41
|
+
return DEVICE_EXIT.failed;
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof CliError)
|
|
44
|
+
return error.exitCode;
|
|
45
|
+
return DEVICE_EXIT.failed;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Runs one `dcd device` command and prints its result: one envelope under
|
|
49
|
+
* --json, readable text otherwise. Errors leave with the matching exit code.
|
|
50
|
+
* Under --json the error is printed here, as an envelope, so the top-level
|
|
51
|
+
* handler's `{ status, error }` never appears.
|
|
52
|
+
*/
|
|
53
|
+
export async function runDeviceCommand(json, fn) {
|
|
54
|
+
let outcome;
|
|
55
|
+
try {
|
|
56
|
+
outcome = await fn();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const exitCode = deviceExitCode(error);
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
if (!json)
|
|
62
|
+
throw new CliError(message, exitCode);
|
|
63
|
+
const envelope = {
|
|
64
|
+
ok: false,
|
|
65
|
+
session: error instanceof DeviceApiError ? error.session : null,
|
|
66
|
+
data: null,
|
|
67
|
+
error: { code: error instanceof DeviceApiError ? error.code : null, message },
|
|
68
|
+
};
|
|
69
|
+
logger.json(envelope);
|
|
70
|
+
logger.fail(message, exitCode);
|
|
71
|
+
}
|
|
72
|
+
if (json) {
|
|
73
|
+
const envelope = {
|
|
74
|
+
ok: !outcome.failure,
|
|
75
|
+
...(outcome.session !== undefined && { session: outcome.session }),
|
|
76
|
+
data: outcome.data,
|
|
77
|
+
error: outcome.failure ?? null,
|
|
78
|
+
};
|
|
79
|
+
logger.json(envelope);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
outcome.print();
|
|
83
|
+
}
|
|
84
|
+
if (outcome.failure)
|
|
85
|
+
logger.fail(outcome.failure.message, DEVICE_EXIT.failed);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Progress for a person, on stderr, so stdout carries only the result. Off
|
|
89
|
+
* under --json. These are plain lines, never a spinner, so they read the same
|
|
90
|
+
* in a terminal, a CI log or an agent's tool output.
|
|
91
|
+
*/
|
|
92
|
+
export function deviceProgress(json) {
|
|
93
|
+
return (message) => {
|
|
94
|
+
if (!json)
|
|
95
|
+
process.stderr.write(`${message}\n`);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The default-session rule: `<id>` may be left out when the org has exactly
|
|
100
|
+
* one open session. With none, or several, the command is a usage error and
|
|
101
|
+
* lists what there is to choose from.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveSessionId(apiUrl, auth, given) {
|
|
104
|
+
const id = given?.trim();
|
|
105
|
+
if (id)
|
|
106
|
+
return id;
|
|
107
|
+
const { sessions } = await DeviceGateway.list(apiUrl, auth);
|
|
108
|
+
if (sessions.length === 1)
|
|
109
|
+
return sessions[0].id;
|
|
110
|
+
if (sessions.length === 0) {
|
|
111
|
+
throw new CliError('No open device session. Start one with `dcd device start --platform ios|android`.', DEVICE_EXIT.usage);
|
|
112
|
+
}
|
|
113
|
+
const candidates = sessions
|
|
114
|
+
.map((s) => ` ${s.id} ${s.platform} ${s.state}${s.client_label ? ` ${s.client_label}` : ''}`)
|
|
115
|
+
.join('\n');
|
|
116
|
+
throw new CliError(`${sessions.length} device sessions are open. Name one:\n${candidates}`, DEVICE_EXIT.usage);
|
|
117
|
+
}
|
|
118
|
+
export function formatSeconds(seconds) {
|
|
119
|
+
if (seconds < 60)
|
|
120
|
+
return `${seconds}s`;
|
|
121
|
+
const minutes = Math.floor(seconds / 60);
|
|
122
|
+
const rest = seconds % 60;
|
|
123
|
+
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
|
|
124
|
+
}
|
|
125
|
+
/** The rows that describe a session, for {@link ui.fields}. */
|
|
126
|
+
export function sessionFields(session) {
|
|
127
|
+
const fields = [
|
|
128
|
+
['session', colors.highlight(session.id)],
|
|
129
|
+
['state', session.release_reason ? `${session.state} (${session.release_reason})` : session.state],
|
|
130
|
+
['platform', session.platform],
|
|
131
|
+
];
|
|
132
|
+
if (session.model)
|
|
133
|
+
fields.push(['model', session.model]);
|
|
134
|
+
if (session.client_label)
|
|
135
|
+
fields.push(['client', session.client_label]);
|
|
136
|
+
if (session.queue) {
|
|
137
|
+
const position = session.queue.position === null ? '' : `#${session.queue.position}`;
|
|
138
|
+
const eta = session.queue.eta_s === null ? '' : `, about ${formatSeconds(session.queue.eta_s)}`;
|
|
139
|
+
fields.push(['queue', `${position || 'waiting'}${eta} (${session.queue.reason.replaceAll('_', ' ')})`]);
|
|
140
|
+
}
|
|
141
|
+
if (session.time_remaining_s !== null) {
|
|
142
|
+
fields.push(['time left', formatSeconds(session.time_remaining_s)]);
|
|
143
|
+
}
|
|
144
|
+
if (session.idle_remaining_s !== null) {
|
|
145
|
+
fields.push(['idle timeout', `in ${formatSeconds(session.idle_remaining_s)}`]);
|
|
146
|
+
}
|
|
147
|
+
if (session.cost_so_far_usd !== null) {
|
|
148
|
+
fields.push(['cost so far', `$${session.cost_so_far_usd.toFixed(2)}`]);
|
|
149
|
+
}
|
|
150
|
+
if (session.last_crash) {
|
|
151
|
+
fields.push(['last crash', colors.error(`${session.last_crash.summary} (${session.last_crash.at})`)]);
|
|
152
|
+
}
|
|
153
|
+
fields.push(['monitor', formatUrl(session.monitor_url)]);
|
|
154
|
+
return fields;
|
|
155
|
+
}
|
|
156
|
+
/** A session as a branch group, with its warnings after it. */
|
|
157
|
+
export function printSession(session) {
|
|
158
|
+
logger.log(ui.branch(ui.fields(sessionFields(session))));
|
|
159
|
+
for (const warning of session.warnings)
|
|
160
|
+
logger.log(ui.warn(warning));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* An API timestamp for a person: how far off it is and the local time, e.g.
|
|
164
|
+
* "in 12m (14:32)" or "3m ago (14:17)". The text as given when unreadable.
|
|
165
|
+
*/
|
|
166
|
+
export function formatWhen(iso, now = Date.now()) {
|
|
167
|
+
const at = Date.parse(iso);
|
|
168
|
+
if (!Number.isFinite(at))
|
|
169
|
+
return iso;
|
|
170
|
+
const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
171
|
+
const seconds = Math.round((at - now) / 1000);
|
|
172
|
+
if (Math.abs(seconds) < 5)
|
|
173
|
+
return `now (${clock})`;
|
|
174
|
+
return seconds > 0
|
|
175
|
+
? `in ${formatSeconds(seconds)} (${clock})`
|
|
176
|
+
: `${formatSeconds(-seconds)} ago (${clock})`;
|
|
177
|
+
}
|
|
178
|
+
/** Where the hand-over stands, in a few words. */
|
|
179
|
+
export function humanState(human) {
|
|
180
|
+
if (human.human_in_control)
|
|
181
|
+
return 'a person has control';
|
|
182
|
+
if (human.waiting)
|
|
183
|
+
return 'waiting for a person';
|
|
184
|
+
if (human.handed_back)
|
|
185
|
+
return 'handed back';
|
|
186
|
+
return 'no help asked for';
|
|
187
|
+
}
|
|
188
|
+
/** The rows that describe a request for a person, for {@link ui.fields}. */
|
|
189
|
+
export function humanFields(human, now = Date.now()) {
|
|
190
|
+
const fields = [['state', humanState(human)]];
|
|
191
|
+
if (human.reason)
|
|
192
|
+
fields.push(['reason', human.reason]);
|
|
193
|
+
if (human.since)
|
|
194
|
+
fields.push(['asked', formatWhen(human.since, now)]);
|
|
195
|
+
if (human.waiting && human.expires_at) {
|
|
196
|
+
fields.push(['ends', `${formatWhen(human.expires_at, now)} unless they hand it back`]);
|
|
197
|
+
}
|
|
198
|
+
if (human.handed_back && human.handed_back_at) {
|
|
199
|
+
fields.push(['handed back', formatWhen(human.handed_back_at, now)]);
|
|
200
|
+
}
|
|
201
|
+
if (human.handed_back)
|
|
202
|
+
fields.push(['note', human.note ?? colors.dim('none')]);
|
|
203
|
+
return fields;
|
|
204
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DeviceCommandResult, DeviceFlowResult } from '../types/domain/device-session.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* What `dcd device run` shows a person: one row per flow and a short summary.
|
|
4
|
+
*
|
|
5
|
+
* The API passes on the runner's report as text in `result.output` (a pass)
|
|
6
|
+
* or `result.error` (a failure). For a directory that report is Maestro's
|
|
7
|
+
* JSON: `{ success, device_id, total_flows, total_commands_executed,
|
|
8
|
+
* results: [{ file, success, error, message }], env_vars, message }`. For one
|
|
9
|
+
* flow it is the same without `results`. Newer APIs also send
|
|
10
|
+
* `result.flows`, which is used when present. None of the runner's own
|
|
11
|
+
* fields (device_id, command counts, env_vars) are shown.
|
|
12
|
+
*/
|
|
13
|
+
export interface RunReport {
|
|
14
|
+
flows: DeviceFlowResult[];
|
|
15
|
+
/** The runner's one-line summary, e.g. "Some flows failed to execute". */
|
|
16
|
+
message: null | string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The flows a run reported, or null when the result carries no report the
|
|
20
|
+
* CLI can read (plain text): then the text is shown as it came.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseRunReport(result: DeviceCommandResult): null | RunReport;
|
|
23
|
+
/** The failure message for a failed run: each failing flow and why. */
|
|
24
|
+
export declare function runFailureMessage(result: DeviceCommandResult, report: null | RunReport): string;
|
|
25
|
+
/**
|
|
26
|
+
* Print a run's result for a person. `label` names a flow the report does not
|
|
27
|
+
* (a single flow's report has no file name).
|
|
28
|
+
*/
|
|
29
|
+
export declare function printRunResult(log: (line: string) => void, passed: boolean, result: DeviceCommandResult, label: string): void;
|