@devicecloud.dev/dcd 5.11.0-beta.1 → 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.
@@ -0,0 +1,197 @@
1
+ export type DeviceSessionState = 'active' | 'expired' | 'failed' | 'needs_human' | 'provisioning' | 'queued' | 'ready' | 'released';
2
+ /** States a session can no longer leave. */
3
+ export declare const ENDED_STATES: readonly DeviceSessionState[];
4
+ /**
5
+ * The compact `session` block every /device/sessions response carries
6
+ * (ILiveSessionSummary in the API). `id` is the session name, which every
7
+ * `dcd device` command takes.
8
+ */
9
+ export interface DeviceSession {
10
+ id: string;
11
+ live_id: number;
12
+ state: DeviceSessionState;
13
+ release_reason: null | string;
14
+ platform: string;
15
+ model: null | string;
16
+ client_label: null | string;
17
+ created_at: string;
18
+ queue: {
19
+ eta_s: null | number;
20
+ position: null | number;
21
+ reason: 'fleet_busy' | 'waiting_for_device';
22
+ } | null;
23
+ time_remaining_s: null | number;
24
+ idle_remaining_s: null | number;
25
+ cost_so_far_usd: null | number;
26
+ last_crash: {
27
+ at: string;
28
+ summary: string;
29
+ } | null;
30
+ /**
31
+ * The build the session asked for and where its install is. Null when no
32
+ * build was asked for; absent from APIs older than this field.
33
+ */
34
+ install?: {
35
+ binary_upload_id: string;
36
+ error: null | string;
37
+ status: 'failed' | 'installed' | 'installing';
38
+ } | null;
39
+ warnings: string[];
40
+ monitor_url: string;
41
+ }
42
+ /**
43
+ * GET .../human and POST .../request-human (ILiveHumanStatus in the API):
44
+ * whether the session waits for a person, and what they said on handing back.
45
+ */
46
+ export interface DeviceHumanStatus {
47
+ /** The session is in needs_human: wait, polling this. */
48
+ waiting: boolean;
49
+ /** Why help was asked for, while waiting. */
50
+ reason: null | string;
51
+ /** When help was asked for, while waiting. */
52
+ since: null | string;
53
+ /** When the session ends unless a person hands it back, while waiting. */
54
+ expires_at: null | string;
55
+ /** A person handed the session back since help was last asked for. */
56
+ handed_back: boolean;
57
+ /** Their note, if they left one. */
58
+ note: null | string;
59
+ handed_back_at: null | string;
60
+ /** A person has taken control in the console; commands answer 409 meanwhile. */
61
+ human_in_control: boolean;
62
+ controller_user_id: null | string;
63
+ }
64
+ /** POST /device/sessions. */
65
+ export interface StartDeviceSessionRequest {
66
+ platform: 'android' | 'ios';
67
+ binaryUploadId?: string;
68
+ model?: string;
69
+ os?: string;
70
+ googlePlay?: boolean;
71
+ deviceLocale?: string;
72
+ clientLabel?: string;
73
+ maxDurationMin?: number;
74
+ record?: boolean;
75
+ }
76
+ /** One entry of GET /device/models. */
77
+ export interface DeviceModel {
78
+ model: string;
79
+ name: string;
80
+ os: string[];
81
+ default_os: string;
82
+ google_play: boolean;
83
+ default: boolean;
84
+ }
85
+ export interface DeviceCatalogue {
86
+ android: DeviceModel[];
87
+ ios: DeviceModel[];
88
+ }
89
+ /** What POST .../install answers with beside the session. */
90
+ export interface DeviceInstalledApp {
91
+ app_id: null | string;
92
+ binary_upload_id: string;
93
+ reused: boolean;
94
+ }
95
+ /** One flow of a run, as newer APIs report it in `result.flows`. */
96
+ export interface DeviceFlowResult {
97
+ error: null | string;
98
+ /** The flow's path inside the bundle; null for a single flow. */
99
+ file: null | string;
100
+ success: boolean;
101
+ }
102
+ /** The result of a typed action, a launch or a run. */
103
+ export interface DeviceCommandResult {
104
+ error?: string;
105
+ /** Each flow of a run. Absent from APIs older than this field. */
106
+ flows?: DeviceFlowResult[];
107
+ output?: string;
108
+ success: boolean;
109
+ }
110
+ /** One element of the compact screen (Maestro's key names). */
111
+ export interface DeviceScreenElement {
112
+ /** Bounds, "[left,top][right,bottom]". */
113
+ b: string;
114
+ a11y?: string;
115
+ hint?: string;
116
+ rid?: string;
117
+ txt?: string;
118
+ }
119
+ /** GET .../inspect: the elements worth targeting, and what each key means. */
120
+ export interface DeviceScreen {
121
+ elements: DeviceScreenElement[];
122
+ /** False when no new frame came in time; this is then the latest. */
123
+ fresh: boolean;
124
+ height: number;
125
+ keys: Record<string, string>;
126
+ width: number;
127
+ }
128
+ /** GET .../screenshot. */
129
+ export interface DeviceScreenshot {
130
+ captured_at: null | string;
131
+ /** A JPEG data URL. */
132
+ data_url: string;
133
+ fresh: boolean;
134
+ }
135
+ /** POST .../run and GET .../commands/:id. */
136
+ export interface DeviceRunResponse {
137
+ command_id: string;
138
+ result: DeviceCommandResult | null;
139
+ session: DeviceSession;
140
+ /** `running` when the flow outlasted the API's wait: poll the command. */
141
+ status: 'failed' | 'passed' | 'running';
142
+ }
143
+ /** GET .../logs. */
144
+ export interface DeviceLogs {
145
+ app_id: null | string;
146
+ lines: string[];
147
+ /** logcat, ios-unified-log, or none before an app is installed. */
148
+ source: string;
149
+ }
150
+ /** A short-lived download link for one stored artifact. */
151
+ export interface DeviceArtifactLink {
152
+ bytes: null | number;
153
+ content_type: string;
154
+ created_at: string;
155
+ /** Null when the file could not be signed (e.g. already cleaned up). */
156
+ url: null | string;
157
+ }
158
+ /** GET .../artifacts: one page, newest actions first. */
159
+ export interface DeviceArtifactsPage {
160
+ actions: {
161
+ command_id: null | string;
162
+ created_at: string;
163
+ hierarchy: DeviceArtifactLink | null;
164
+ screenshot: DeviceArtifactLink | null;
165
+ }[];
166
+ logs: DeviceArtifactLink | null;
167
+ /** Pass as `before` for the next (older) page; null on the last page. */
168
+ next_before: null | string;
169
+ /**
170
+ * Files the runner is still uploading after the session ended: the video
171
+ * and the device log land up to a minute or so later. Absent from APIs
172
+ * older than this field.
173
+ */
174
+ pending?: ('logs' | 'recording')[];
175
+ recording: DeviceArtifactLink | null;
176
+ session: DeviceSession;
177
+ }
178
+ /** POST /device/bundles/upload-url: where to PUT the zip (both, when both are given). */
179
+ export interface DeviceBundleUploadUrls {
180
+ id: string;
181
+ mirror: {
182
+ path: string;
183
+ signedUrl: string;
184
+ } | null;
185
+ upload: {
186
+ path: string;
187
+ signedUrl: string;
188
+ } | null;
189
+ }
190
+ /** A registered flow bundle. `run` takes its id as `flowUploadId`. */
191
+ export interface DeviceFlowBundle {
192
+ bytes: number;
193
+ created_at: string;
194
+ encrypted: boolean;
195
+ id: string;
196
+ sha: string;
197
+ }
@@ -0,0 +1,5 @@
1
+ // Hand-defined: the /device/sessions routes are not in the swagger spec the
2
+ // CLI generates schema.types.ts from. They mirror the API's own types in
3
+ // dcd/api/src/apps/live/live.types.ts and device-sessions.controller.ts.
4
+ /** States a session can no longer leave. */
5
+ export const ENDED_STATES = ['released', 'expired', 'failed'];
@@ -0,0 +1,19 @@
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
+ /** The platform a local build is for, from its file name; undefined if unknown. */
3
+ export declare function platformOfAppFile(filePath: string): 'android' | 'ios' | undefined;
4
+ export interface UploadLocalAppOptions {
5
+ apiUrl: string;
6
+ auth: AuthContext;
7
+ filePath: string;
8
+ /** Show the upload's progress spinner. Only on a TTY, never under --json. */
9
+ showProgress: boolean;
10
+ /** One line of progress for a person, e.g. the encryption notice. */
11
+ log: (message: string) => void;
12
+ }
13
+ /**
14
+ * Upload a local build the way `dcd upload` does, and return its binary id:
15
+ * an Expo `.tar.gz` is unpacked to its `.app`, a `.zip` is checked, and the
16
+ * org's encryption default applies. URLs are not handled here: `dcd device`
17
+ * hands those to the API's install-from-URL instead.
18
+ */
19
+ export declare function uploadLocalApp(opts: UploadLocalAppOptions): Promise<string>;
@@ -0,0 +1,69 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { uploadBinary, verifyAppZip } from '../methods.js';
3
+ import { detectCiContext } from './ci.js';
4
+ import { CliError, getCliVersion } from './cli.js';
5
+ import { fetchCompatibilityData } from './compatibility.js';
6
+ import { checkKekForEncryption, resolveEncryption } from './envelope.js';
7
+ import { extractTarGz, findAppBundle } from './expo.js';
8
+ const APP_EXTENSIONS = ['.apk', '.app', '.zip', '.tar.gz'];
9
+ /** The platform a local build is for, from its file name; undefined if unknown. */
10
+ export function platformOfAppFile(filePath) {
11
+ const clean = filePath.replace(/\/+$/, '');
12
+ if (clean.endsWith('.apk'))
13
+ return 'android';
14
+ if (['.app', '.zip', '.tar.gz'].some((ext) => clean.endsWith(ext)))
15
+ return 'ios';
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Upload a local build the way `dcd upload` does, and return its binary id:
20
+ * an Expo `.tar.gz` is unpacked to its `.app`, a `.zip` is checked, and the
21
+ * org's encryption default applies. URLs are not handled here: `dcd device`
22
+ * hands those to the API's install-from-URL instead.
23
+ */
24
+ export async function uploadLocalApp(opts) {
25
+ const { apiUrl, auth, log } = opts;
26
+ let filePath = opts.filePath.replace(/\/+$/, '');
27
+ if (!APP_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
28
+ throw new CliError('The app must be a .apk for Android, or a .app, .zip or .tar.gz (Expo) for iOS.');
29
+ }
30
+ // As in `dcd upload`: a failed compatibility fetch leaves the default off
31
+ // rather than failing the upload.
32
+ let serverDefault;
33
+ try {
34
+ const ci = detectCiContext();
35
+ const compatibility = await fetchCompatibilityData(apiUrl, auth, {
36
+ cliVersion: getCliVersion(),
37
+ ciProvider: ci.provider,
38
+ ciWrapperVersion: ci.wrapperVersion,
39
+ });
40
+ serverDefault = compatibility.encryption?.defaultOn;
41
+ }
42
+ catch {
43
+ serverDefault = undefined;
44
+ }
45
+ const encryption = await checkKekForEncryption(resolveEncryption({ serverDefault }), apiUrl);
46
+ if (encryption.notice)
47
+ log(encryption.notice);
48
+ const tempDirs = [];
49
+ try {
50
+ if (filePath.endsWith('.tar.gz')) {
51
+ const dir = await extractTarGz(filePath, false);
52
+ tempDirs.push(dir);
53
+ filePath = await findAppBundle(dir);
54
+ }
55
+ if (filePath.endsWith('.zip'))
56
+ await verifyAppZip(filePath);
57
+ return await uploadBinary({
58
+ apiUrl,
59
+ auth,
60
+ encrypt: encryption.enabled,
61
+ filePath,
62
+ log: opts.showProgress,
63
+ });
64
+ }
65
+ finally {
66
+ for (const dir of tempDirs)
67
+ await rm(dir, { force: true, recursive: true }).catch(() => { });
68
+ }
69
+ }
@@ -44,11 +44,3 @@ export declare function refreshAuth(auth: AuthContext): Promise<AuthContext>;
44
44
  * next attempt may get past.
45
45
  */
46
46
  export declare function isDefinitiveAuthFailure(error: unknown): boolean;
47
- /**
48
- * Remove only the (definitively dead) session from the stored config, keeping
49
- * env/api_url/org so a re-login lands back in the same environment. With the
50
- * dead session gone, subsequent commands report "Not authenticated" instead
51
- * of failing the same refresh, and `dcd login` skips its "already logged in"
52
- * confirm. Exported for tests.
53
- */
54
- export declare function dropStoredSession(): void;
@@ -143,16 +143,14 @@ async function refreshSessionWithLock(initial) {
143
143
  if (session.expires_at > now + REFRESH_SKEW_SECONDS) {
144
144
  return { config: current, session };
145
145
  }
146
- let refreshed;
147
- try {
148
- refreshed = await CliAuthGateway.refresh(current.supabase_url, current.env, session);
149
- }
150
- catch (error) {
151
- if (error instanceof SessionRefreshError && error.definitive) {
152
- dropStoredSession();
153
- }
154
- throw error;
155
- }
146
+ // A failed refresh never deletes the stored session, even one Supabase
147
+ // refused outright. A refusal is not always the session's fault: an old
148
+ // CLI build sending a retired Supabase key gets a 401 ("Legacy API keys
149
+ // are disabled") for a session that is still good, and wiping it then
150
+ // forced a fresh login when an upgrade alone would have fixed it. The
151
+ // error tells the user to log in again; only `dcd logout` (or the next
152
+ // `dcd login`) replaces the stored session.
153
+ const refreshed = await CliAuthGateway.refresh(current.supabase_url, current.env, session);
156
154
  // Re-read again and merge only `session` so a concurrent `switch-org`
157
155
  // write (org fields) isn't reverted by our pre-refresh snapshot.
158
156
  const merged = { ...(readConfig() ?? current), session: refreshed };
@@ -166,20 +164,6 @@ async function refreshSessionWithLock(initial) {
166
164
  catch { /* best effort */ }
167
165
  }
168
166
  }
169
- /**
170
- * Remove only the (definitively dead) session from the stored config, keeping
171
- * env/api_url/org so a re-login lands back in the same environment. With the
172
- * dead session gone, subsequent commands report "Not authenticated" instead
173
- * of failing the same refresh, and `dcd login` skips its "already logged in"
174
- * confirm. Exported for tests.
175
- */
176
- export function dropStoredSession() {
177
- const config = readConfig();
178
- if (!config?.session)
179
- return;
180
- delete config.session;
181
- writeConfig(config);
182
- }
183
167
  async function acquireRefreshLock(lockPath) {
184
168
  const deadline = Date.now() + LOCK_WAIT_MS;
185
169
  for (;;) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Open `url` in the default browser. False if it isn't an http(s) URL or no
3
+ * opener could be started.
4
+ *
5
+ * Only http(s) is opened, so a URL can't name a local file or start with `-`
6
+ * and be read as an option. No opener goes through a shell: Windows uses
7
+ * rundll32, because `cmd /c start` parses the URL again and treats its `&`
8
+ * as a command separator.
9
+ */
10
+ export declare function openBrowser(url: string): boolean;
@@ -0,0 +1,35 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Open `url` in the default browser. False if it isn't an http(s) URL or no
4
+ * opener could be started.
5
+ *
6
+ * Only http(s) is opened, so a URL can't name a local file or start with `-`
7
+ * and be read as an option. No opener goes through a shell: Windows uses
8
+ * rundll32, because `cmd /c start` parses the URL again and treats its `&`
9
+ * as a command separator.
10
+ */
11
+ export function openBrowser(url) {
12
+ let href;
13
+ try {
14
+ const parsed = new URL(url);
15
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
16
+ return false;
17
+ href = parsed.href;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ const cmd = process.platform === 'darwin'
23
+ ? { bin: 'open', args: [href] }
24
+ : process.platform === 'win32'
25
+ ? { bin: 'rundll32', args: ['url.dll,FileProtocolHandler', href] }
26
+ : { bin: 'xdg-open', args: [href] };
27
+ try {
28
+ const child = spawn(cmd.bin, cmd.args, { detached: true, stdio: 'ignore' });
29
+ child.unref();
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
@@ -0,0 +1,55 @@
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
+ import { type DeviceArtifactsPage, type DeviceSession } from '../types/domain/device-session.types.js';
3
+ /** One file a session left behind, and where it goes on disk. */
4
+ export interface DeviceArtifactFile {
5
+ /** recording, logs, screenshot or hierarchy. */
6
+ kind: 'hierarchy' | 'logs' | 'recording' | 'screenshot';
7
+ /** Relative to the output directory. */
8
+ file: string;
9
+ url: null | string;
10
+ bytes: null | number;
11
+ /** The action a screenshot or screen dump was taken after. */
12
+ command_id?: null | string;
13
+ created_at: string;
14
+ }
15
+ /** Every page of a session's artifacts. */
16
+ export declare function fetchAllArtifacts(apiUrl: string, auth: AuthContext, id: string): Promise<{
17
+ pages: DeviceArtifactsPage[];
18
+ session: DeviceSession;
19
+ }>;
20
+ /**
21
+ * What a session's runner is still uploading: the video (`recording`) and
22
+ * the device log (`logs`) land up to a minute or so after the session ends.
23
+ * The API says so in `pending`. An API older than that field gets a guess:
24
+ * an ended session missing either file whose last activity (its newest
25
+ * action, else its start) was under three minutes ago. The session summary
26
+ * carries no end time, so the last activity stands in for it.
27
+ */
28
+ export declare function pendingArtifacts(pages: DeviceArtifactsPage[], now?: number): ('logs' | 'recording')[];
29
+ /** The warning for {@link pendingArtifacts}, or null when nothing is pending. */
30
+ export declare function pendingArtifactsWarning(pending: ('logs' | 'recording')[]): null | string;
31
+ /**
32
+ * Lay the artifacts out as files: `recording.mp4`, `device.log`, and per
33
+ * action `actions/001-screenshot.jpg` and `actions/001-screen.json`,
34
+ * numbered oldest first so the folder reads in order.
35
+ */
36
+ export declare function planArtifactFiles(pages: DeviceArtifactsPage[]): DeviceArtifactFile[];
37
+ /** Timeouts and retries for one artifact download. */
38
+ export interface DownloadArtifactOptions {
39
+ /** Give up on an attempt that receives no data for this long. */
40
+ idleTimeoutMs?: number;
41
+ /** Give up on an attempt that takes longer than this in total. */
42
+ totalTimeoutMs?: number;
43
+ /** How many times to try each file before reporting it failed. */
44
+ attempts?: number;
45
+ }
46
+ /**
47
+ * Download every file with a link into `dir`, four at a time. Each file is
48
+ * tried twice (a stalled CDN response usually succeeds on a second
49
+ * request). Returns the files that could not be fetched, with why, rather
50
+ * than failing the rest.
51
+ */
52
+ export declare function downloadArtifactFiles(dir: string, files: DeviceArtifactFile[], onFile?: (file: DeviceArtifactFile) => void, options?: DownloadArtifactOptions): Promise<{
53
+ file: string;
54
+ reason: string;
55
+ }[]>;
@@ -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
+ }