@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.
@@ -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,16 @@
1
+ /**
2
+ * Open `url` in the default browser. False if it isn't an http(s) URL or no
3
+ * opener could be started (none installed, as on a headless box).
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): Promise<boolean>;
11
+ /**
12
+ * Start `bin` detached. False if it couldn't be started. A missing program is
13
+ * reported as an 'error' event, not thrown, and with no listener it would
14
+ * crash the process, so this waits for 'spawn' or 'error' to know.
15
+ */
16
+ export declare function startOpener(bin: string, args: string[]): Promise<boolean>;
@@ -0,0 +1,48 @@
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 (none installed, as on a headless box).
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 async 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
+ return startOpener(cmd.bin, cmd.args);
28
+ }
29
+ /**
30
+ * Start `bin` detached. False if it couldn't be started. A missing program is
31
+ * reported as an 'error' event, not thrown, and with no listener it would
32
+ * crash the process, so this waits for 'spawn' or 'error' to know.
33
+ */
34
+ export function startOpener(bin, args) {
35
+ return new Promise((resolve) => {
36
+ try {
37
+ const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
38
+ child.once('spawn', () => {
39
+ child.unref();
40
+ resolve(true);
41
+ });
42
+ child.once('error', () => resolve(false));
43
+ }
44
+ catch {
45
+ resolve(false);
46
+ }
47
+ });
48
+ }
@@ -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
+ }[]>;