@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.
@@ -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;
@@ -0,0 +1,105 @@
1
+ import { colors } from './styling.js';
2
+ import { ui } from './ui.js';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function text(value) {
7
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
8
+ }
9
+ function toFlow(raw, file) {
10
+ const success = raw.success === true;
11
+ return {
12
+ file: text(raw.file) ?? file,
13
+ success,
14
+ // A failed flow's reason is in `error`; some reports only have `message`.
15
+ error: text(raw.error) ?? (success ? null : text(raw.message)),
16
+ };
17
+ }
18
+ /** The runner's JSON report in `body`, or null when it is not one. */
19
+ function parseRunnerJson(body) {
20
+ if (!body || !body.trimStart().startsWith('{'))
21
+ return null;
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(body);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ if (!isRecord(parsed))
30
+ return null;
31
+ if (Array.isArray(parsed.results)) {
32
+ return {
33
+ flows: parsed.results.filter((r) => isRecord(r)).map((r) => toFlow(r, null)),
34
+ message: text(parsed.message),
35
+ };
36
+ }
37
+ if (typeof parsed.success !== 'boolean')
38
+ return null;
39
+ // One flow: the report is that flow's result.
40
+ return { flows: [toFlow(parsed, null)], message: text(parsed.message) };
41
+ }
42
+ /**
43
+ * The flows a run reported, or null when the result carries no report the
44
+ * CLI can read (plain text): then the text is shown as it came.
45
+ */
46
+ export function parseRunReport(result) {
47
+ if (Array.isArray(result.flows)) {
48
+ return { flows: result.flows.map((f) => toFlow({ ...f }, null)), message: null };
49
+ }
50
+ return parseRunnerJson(result.error) ?? parseRunnerJson(result.output);
51
+ }
52
+ /** The failure message for a failed run: each failing flow and why. */
53
+ export function runFailureMessage(result, report) {
54
+ if (!report)
55
+ return result.error || 'The flow failed.';
56
+ const failed = report.flows.filter((f) => !f.success);
57
+ if (failed.length === 0)
58
+ return report.message ?? 'The flow failed.';
59
+ return failed
60
+ .map((f) => `${f.file ?? 'flow'}: ${f.error ?? 'failed'}`)
61
+ .join('; ');
62
+ }
63
+ /**
64
+ * Print a run's result for a person. `label` names a flow the report does not
65
+ * (a single flow's report has no file name).
66
+ */
67
+ export function printRunResult(log, passed, result, label) {
68
+ const report = parseRunReport(result);
69
+ if (!report || report.flows.length === 0) {
70
+ log(passed ? ui.success('Flow passed') : `${ui.statusSymbol('FAILED')} Flow failed`);
71
+ if (report) {
72
+ if (report.message)
73
+ log(ui.note(report.message));
74
+ return;
75
+ }
76
+ // Plain text, not a report the CLI can read: show it as it came.
77
+ if (result.output) {
78
+ log(ui.section('Output'));
79
+ log(result.output);
80
+ }
81
+ if (result.error) {
82
+ log(ui.section('Error'));
83
+ log(colors.error(result.error));
84
+ }
85
+ return;
86
+ }
87
+ const { flows } = report;
88
+ const failed = flows.filter((f) => !f.success).length;
89
+ const total = flows.length;
90
+ let summary;
91
+ if (total === 1)
92
+ summary = passed ? 'Flow passed' : 'Flow failed';
93
+ else if (failed === 0)
94
+ summary = `${total} flows passed`;
95
+ else
96
+ summary = `${failed} of ${total} flows failed`;
97
+ log(passed && failed === 0 ? ui.success(summary) : `${ui.statusSymbol('FAILED')} ${summary}`);
98
+ const rows = flows.map((f) => [
99
+ f.file ?? label,
100
+ f.success
101
+ ? ui.statusWord('PASSED')
102
+ : `${ui.statusWord('FAILED')}${f.error ? ` ${colors.error(f.error)}` : ''}`,
103
+ ]);
104
+ log(ui.branch(ui.fields(rows)));
105
+ }
@@ -4,9 +4,13 @@ declare class Action {
4
4
  private _lastPrinted;
5
5
  private interactive;
6
6
  start(title: string, initialStatus?: string, _opts?: unknown): void;
7
+ /** End the action as a success: `✓ message`. */
7
8
  stop(message?: string): void;
9
+ /** End the action as a failure: `✗ message`. */
10
+ fail(message?: string): void;
8
11
  set status(value: string);
9
12
  get status(): string;
13
+ private finish;
10
14
  private print;
11
15
  }
12
16
  export declare const ux: {
@@ -10,9 +10,20 @@
10
10
  * non-interactive environments we skip the spinner entirely and instead print a
11
11
  * plain line once per *distinct* status, so CI logs show real progress without
12
12
  * the flood.
13
+ *
14
+ * Styling: the spinner uses the CLI's own palette (cyan braille frames) rather
15
+ * than clack's magenta default, and no `│` guide bar, since the rest of the CLI
16
+ * is a `⏺`/`⎿` tree rather than a clack session. The final line is written by
17
+ * us, not clack, so it carries the same `✓` / `✗` symbols as every other
18
+ * command (see STYLE_GUIDE.md).
13
19
  */
14
20
  import * as p from '@clack/prompts';
15
21
  import { isCI } from './ci.js';
22
+ import { colors, symbols } from './styling.js';
23
+ // Braille dots where the terminal can draw them, plain ASCII otherwise.
24
+ const FRAMES = p.unicode
25
+ ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
26
+ : ['-', '\\', '|', '/'];
16
27
  class Action {
17
28
  current = null;
18
29
  _status = '';
@@ -23,37 +34,30 @@ class Action {
23
34
  }
24
35
  start(title, initialStatus, _opts) {
25
36
  this._status = initialStatus ?? '';
26
- const line = initialStatus ? `${title} — ${initialStatus}` : title;
37
+ const line = initialStatus ? `${title} ${colors.dim('·')} ${initialStatus}` : title;
27
38
  if (!this.interactive()) {
28
39
  this.current = null;
29
40
  this.print(line);
30
41
  return;
31
42
  }
32
43
  if (this.current) {
33
- this.current.stop();
44
+ this.current.clear();
34
45
  }
35
- this.current = p.spinner();
46
+ this.current = p.spinner({
47
+ delay: 80,
48
+ frames: FRAMES,
49
+ styleFrame: colors.highlight,
50
+ withGuide: false,
51
+ });
36
52
  this.current.start(line);
37
53
  }
54
+ /** End the action as a success: `✓ message`. */
38
55
  stop(message) {
39
- if (!this.interactive()) {
40
- if (message)
41
- this.print(message);
42
- this.current = null;
43
- this._status = '';
44
- this._lastPrinted = '';
45
- return;
46
- }
47
- if (!this.current) {
48
- if (message) {
49
- // eslint-disable-next-line no-console
50
- console.log(message);
51
- }
52
- return;
53
- }
54
- this.current.stop(message ?? '');
55
- this.current = null;
56
- this._status = '';
56
+ this.finish(symbols.success, message);
57
+ }
58
+ /** End the action as a failure: `✗ message`. */
59
+ fail(message) {
60
+ this.finish(symbols.error, message);
57
61
  }
58
62
  set status(value) {
59
63
  this._status = value;
@@ -70,6 +74,26 @@ class Action {
70
74
  get status() {
71
75
  return this._status;
72
76
  }
77
+ // Erase the spinner (if one is live) and print the final line ourselves, so
78
+ // the symbol matches the rest of the CLI instead of clack's `◇`.
79
+ finish(symbol, message) {
80
+ const line = message ? `${symbol} ${message}` : '';
81
+ if (!this.interactive()) {
82
+ if (line)
83
+ this.print(line);
84
+ this.current = null;
85
+ this._status = '';
86
+ this._lastPrinted = '';
87
+ return;
88
+ }
89
+ this.current?.clear();
90
+ this.current = null;
91
+ this._status = '';
92
+ if (line) {
93
+ // eslint-disable-next-line no-console
94
+ console.log(line);
95
+ }
96
+ }
73
97
  // Emit a line only when it differs from the previous one, so repeated polls
74
98
  // with no state change stay quiet.
75
99
  print(line) {
package/package.json CHANGED
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.10.0",
71
+ "version": "5.11.0-beta.2",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },
@@ -109,7 +109,7 @@
109
109
  "ws@>=8.0.0 <8.21.0": "8.21.0",
110
110
  "esbuild@<0.28.1": ">=0.28.1",
111
111
  "qs@<6.16.0": "6.16.0",
112
- "fast-uri@>=3.0.0 <3.1.8": "3.1.8",
112
+ "fast-uri@>=3.0.0 <3.1.9": "3.1.9",
113
113
  "hono@>=4.0.0 <4.13.11": "4.13.11",
114
114
  "source-map-js@<1.2.2": "1.2.2",
115
115
  "micromatch>picomatch": "^2.3.2",