@devicecloud.dev/dcd 5.8.0 → 5.9.0-beta.1

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.
@@ -8,7 +8,7 @@ export class ReportDownloadService {
8
8
  /**
9
9
  * Download test artifacts as a zip file
10
10
  * @param options Download configuration
11
- * @returns Promise that resolves when download is complete
11
+ * @returns What the download did
12
12
  */
13
13
  async downloadArtifacts(options) {
14
14
  const { apiUrl, auth, uploadId, downloadType, artifactsPath = './artifacts.zip', debug = false, logger, warnLogger, } = options;
@@ -23,53 +23,53 @@ export class ReportDownloadService {
23
23
  logger('\n');
24
24
  logger(`Test artifacts have been downloaded to ${artifactsPath}`);
25
25
  }
26
+ return { ok: true, path: artifactsPath, type: 'artifacts' };
26
27
  }
27
28
  catch (error) {
28
29
  if (debug && logger) {
29
30
  logger(`[DEBUG] Error downloading artifacts: ${error}`);
30
31
  }
31
32
  this.warnDownloadFailure(warnLogger, 'artifacts', 'No artifacts found for this upload. Make sure your tests generated results.', error);
33
+ return { error: errorText(error), ok: false, path: artifactsPath, type: 'artifacts' };
32
34
  }
33
35
  }
34
36
  /**
35
37
  * Handle downloading reports based on the report type specified
36
38
  * @param options Report download configuration
37
- * @returns Promise that resolves when download is complete
39
+ * @returns What the download did, or undefined for an unknown report type
38
40
  */
39
41
  async downloadReports(options) {
40
42
  const { reportType, junitPath, allurePath, htmlPath, warnLogger, ...downloadOptions } = options;
41
43
  switch (reportType) {
42
44
  case 'junit': {
43
45
  const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml');
44
- await this.downloadReport('junit', reportPath, {
46
+ return this.downloadReport('junit', reportPath, {
45
47
  ...downloadOptions,
46
48
  warnLogger,
47
49
  });
48
- break;
49
50
  }
50
51
  case 'allure': {
51
52
  const reportPath = path.resolve(process.cwd(), allurePath || 'report.html');
52
- await this.downloadReport('allure', reportPath, {
53
+ return this.downloadReport('allure', reportPath, {
53
54
  ...downloadOptions,
54
55
  warnLogger,
55
56
  });
56
- break;
57
57
  }
58
58
  case 'html':
59
59
  case 'html-detailed': {
60
60
  // The HTML report is a ZIP (report.html plus its screenshots and
61
61
  // assets), so the default name says so. An explicit path is kept as-is.
62
62
  const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip');
63
- await this.downloadReport('html', htmlReportPath, {
63
+ return this.downloadReport('html', htmlReportPath, {
64
64
  ...downloadOptions,
65
65
  warnLogger,
66
66
  });
67
- break;
68
67
  }
69
68
  default: {
70
69
  if (warnLogger) {
71
70
  warnLogger(`Unknown report type: ${reportType}`);
72
71
  }
72
+ return undefined;
73
73
  }
74
74
  }
75
75
  }
@@ -78,7 +78,7 @@ export class ReportDownloadService {
78
78
  * @param type Report type to download
79
79
  * @param filePath Path where report should be saved
80
80
  * @param options Download configuration
81
- * @returns Promise that resolves when download is complete
81
+ * @returns What the download did
82
82
  */
83
83
  async downloadReport(type, filePath, options) {
84
84
  const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options;
@@ -91,12 +91,14 @@ export class ReportDownloadService {
91
91
  if (logger) {
92
92
  logger(`${type.toUpperCase()} test report has been downloaded to ${filePath}`);
93
93
  }
94
+ return { ok: true, path: filePath, type };
94
95
  }
95
96
  catch (error) {
96
97
  if (debug && logger) {
97
98
  logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`);
98
99
  }
99
100
  this.warnDownloadFailure(warnLogger, `${type.toUpperCase()} report`, `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error);
101
+ return { error: errorText(error), ok: false, path: filePath, type };
100
102
  }
101
103
  }
102
104
  /**
@@ -112,7 +114,7 @@ export class ReportDownloadService {
112
114
  if (!warnLogger) {
113
115
  return;
114
116
  }
115
- const errorMessage = error instanceof Error ? error.message : String(error);
117
+ const errorMessage = errorText(error);
116
118
  warnLogger(`Failed to download ${subject}: ${errorMessage}`);
117
119
  if (errorMessage.includes('404')) {
118
120
  warnLogger(notFoundHint);
@@ -125,3 +127,6 @@ export class ReportDownloadService {
125
127
  }
126
128
  }
127
129
  }
130
+ function errorText(error) {
131
+ return error instanceof Error ? error.message : String(error);
132
+ }
@@ -10,6 +10,15 @@
10
10
  * counts — providers set `CI=true`, but a bare presence check is the safe net.
11
11
  */
12
12
  export declare function isCI(): boolean;
13
+ /**
14
+ * Whether a prompt drawn on `output` can be answered: stdin and `output` must
15
+ * both be terminals. Without that (a script, CI, a shell an agent runs) a clack
16
+ * prompt waits for a keypress that never comes. Unlike isCI(), CI env vars
17
+ * don't count: a terminal with `CI=true` exported can still answer a prompt.
18
+ */
19
+ export declare function canPrompt(output?: {
20
+ isTTY?: boolean;
21
+ }): boolean;
13
22
  /** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */
14
23
  export interface CiContext {
15
24
  /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */
package/dist/utils/ci.js CHANGED
@@ -37,6 +37,15 @@ export function isCI() {
37
37
  }
38
38
  return !process.stdout.isTTY;
39
39
  }
40
+ /**
41
+ * Whether a prompt drawn on `output` can be answered: stdin and `output` must
42
+ * both be terminals. Without that (a script, CI, a shell an agent runs) a clack
43
+ * prompt waits for a keypress that never comes. Unlike isCI(), CI env vars
44
+ * don't count: a terminal with `CI=true` exported can still answer a prompt.
45
+ */
46
+ export function canPrompt(output = process.stdout) {
47
+ return process.stdin.isTTY === true && output.isTTY === true;
48
+ }
40
49
  /** Infer the CI provider from the env vars each platform sets natively. */
41
50
  function inferProvider() {
42
51
  const env = process.env;
@@ -9,13 +9,20 @@ export declare class CliError extends Error {
9
9
  export interface Logger {
10
10
  log(message: string): void;
11
11
  warn(message: string): void;
12
+ json(value: unknown): void;
12
13
  error(message: Error | string, opts?: {
13
14
  exit?: number;
14
15
  json?: boolean;
15
16
  }): never;
16
17
  exit(code?: number): never;
18
+ fail(message: Error | string, exitCode: number): never;
17
19
  }
18
20
  export declare const logger: Logger;
21
+ /**
22
+ * Whether the invocation asked for JSON output. Read from raw argv so the
23
+ * top-level error handler can honour it even when citty never parsed args.
24
+ */
25
+ export declare function wantsJson(rawArgs: string[]): boolean;
19
26
  /**
20
27
  * Validate that a string flag value is one of the allowed options.
21
28
  * Returns the value untouched on success; throws CliError otherwise.
package/dist/utils/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * sites ported from oclif keep working.
8
8
  */
9
9
  import { readFileSync } from 'node:fs';
10
+ import { stripVTControlCharacters } from 'node:util';
10
11
  import { telemetry } from '../services/telemetry.service.js';
11
12
  import { symbols } from './styling.js';
12
13
  // Resolve version at runtime. The bun-compiled binary can't read package.json
@@ -57,13 +58,18 @@ export const logger = {
57
58
  // eslint-disable-next-line no-console
58
59
  console.warn(symbols.warning + ' ' + message);
59
60
  },
61
+ json(value) {
62
+ // The one stdout write under --json; everything else is gated off.
63
+ // eslint-disable-next-line no-console
64
+ console.log(JSON.stringify(value, null, 2));
65
+ },
60
66
  error(message, opts = {}) {
61
67
  const text = message instanceof Error ? message.message : message;
62
68
  if (opts.json) {
63
69
  // When --json is active, emit failures as JSON on stdout so that
64
70
  // scripts consuming the output never need to parse stderr too.
65
- // eslint-disable-next-line no-console
66
- console.log(JSON.stringify({ status: 'FAILED', error: text }, null, 2));
71
+ // citty colours some of its messages; JSON consumers want plain text.
72
+ logger.json({ status: 'FAILED', error: stripVTControlCharacters(text) });
67
73
  }
68
74
  else {
69
75
  // The literal "Error:" prefix is important for tests and for grep-friendly
@@ -71,18 +77,29 @@ export const logger = {
71
77
  // eslint-disable-next-line no-console
72
78
  console.error(symbols.error + ' Error: ' + text);
73
79
  }
80
+ return logger.fail(message, opts.exit ?? 1);
81
+ },
82
+ exit(code = 0) {
83
+ process.exit(code);
84
+ },
85
+ // Exit as a failure without printing anything — for --json paths that have
86
+ // already written their result object and must not print a second one.
87
+ fail(message, exitCode) {
74
88
  // process.exit bypasses beforeExit, so async fetch in telemetry.flush()
75
89
  // would be killed mid-flight. flushSync uses curl to ship synchronously
76
90
  // before we exit; it's a no-op if telemetry never reached configure().
77
- const exitCode = opts.exit ?? 1;
78
91
  telemetry.recordCommandFailure({ error: message, exitCode });
79
92
  telemetry.flushSync();
80
93
  process.exit(exitCode);
81
94
  },
82
- exit(code = 0) {
83
- process.exit(code);
84
- },
85
95
  };
96
+ /**
97
+ * Whether the invocation asked for JSON output. Read from raw argv so the
98
+ * top-level error handler can honour it even when citty never parsed args.
99
+ */
100
+ export function wantsJson(rawArgs) {
101
+ return rawArgs.some((a) => a === '--json' || a === '--json=true');
102
+ }
86
103
  /**
87
104
  * Validate that a string flag value is one of the allowed options.
88
105
  * Returns the value untouched on success; throws CliError otherwise.
@@ -1,11 +1,48 @@
1
+ import type { Readable, Writable } from 'node:stream';
1
2
  export interface OrgListItem {
2
3
  id: string;
3
4
  name: string;
4
5
  slug?: string;
5
6
  }
6
7
  export declare function fetchOrgs(apiUrl: string, headers: Record<string, string>): Promise<OrgListItem[]>;
8
+ /**
9
+ * Match a user-supplied string to an org. Prefers case-insensitive name match.
10
+ * Falls back to slug / id so existing scripts keep working, but error output
11
+ * only surfaces names. `pickCommand` is how to pick interactively instead,
12
+ * for when two orgs share a name.
13
+ */
14
+ export declare function matchOrg(orgs: OrgListItem[], target: string, pickCommand: string): OrgListItem;
15
+ export interface PickOrgOptions {
16
+ message?: string;
17
+ /** Org to start the picker on, so Enter confirms it. Ignored if not listed. */
18
+ initialId?: string;
19
+ input?: Readable;
20
+ output?: Writable;
21
+ }
7
22
  /**
8
23
  * Interactive org picker. Auto-selects when there's only one org (returns it
9
24
  * without prompting). Throws on zero orgs or user cancellation.
10
25
  */
11
- export declare function pickOrg(orgs: OrgListItem[], message?: string): Promise<OrgListItem>;
26
+ export declare function pickOrg(orgs: OrgListItem[], { message, initialId, input, output, }?: PickOrgOptions): Promise<OrgListItem>;
27
+ export interface SelectOrgOptions {
28
+ /** `dcd login --org`: a name, slug or id. Skips the picker. */
29
+ requested?: string;
30
+ /** The org the last login chose. */
31
+ lastOrgId?: string;
32
+ /** Whether anyone can answer the picker (see canPrompt). */
33
+ interactive: boolean;
34
+ input?: Readable;
35
+ output?: Writable;
36
+ }
37
+ /**
38
+ * Choose the org for `dcd login`: `--org` if given, else the only org, else
39
+ * the picker starting on the last login's org. Without a TTY nobody can
40
+ * answer the picker, so the last login's org is used without asking, and
41
+ * when there isn't one the error names `--org` instead of hanging.
42
+ * `fromLastLogin` is set when that fallback chose the org, so the caller
43
+ * can say so.
44
+ */
45
+ export declare function selectOrg(orgs: OrgListItem[], { requested, lastOrgId, interactive, input, output }: SelectOrgOptions): Promise<{
46
+ org: OrgListItem;
47
+ fromLastLogin: boolean;
48
+ }>;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared helpers for fetching and picking from /me/orgs. Used by both
3
- * `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`.
2
+ * Shared helpers for fetching, matching and picking from /me/orgs. Used by
3
+ * both `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`.
4
4
  */
5
5
  import * as p from '@clack/prompts';
6
6
  import { CliError } from './cli.js';
@@ -13,11 +13,29 @@ export async function fetchOrgs(apiUrl, headers) {
13
13
  const body = (await res.json());
14
14
  return body.orgs ?? [];
15
15
  }
16
+ /**
17
+ * Match a user-supplied string to an org. Prefers case-insensitive name match.
18
+ * Falls back to slug / id so existing scripts keep working, but error output
19
+ * only surfaces names. `pickCommand` is how to pick interactively instead,
20
+ * for when two orgs share a name.
21
+ */
22
+ export function matchOrg(orgs, target, pickCommand) {
23
+ const needle = target.toLowerCase();
24
+ const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle);
25
+ if (nameMatches.length > 1) {
26
+ throw new CliError(`Multiple orgs named "${target}". Run ${pickCommand} to pick interactively.`);
27
+ }
28
+ const chosen = nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target);
29
+ if (!chosen) {
30
+ throw new CliError(`No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`);
31
+ }
32
+ return chosen;
33
+ }
16
34
  /**
17
35
  * Interactive org picker. Auto-selects when there's only one org (returns it
18
36
  * without prompting). Throws on zero orgs or user cancellation.
19
37
  */
20
- export async function pickOrg(orgs, message = 'Pick an organization') {
38
+ export async function pickOrg(orgs, { message = 'Pick an organization', initialId, input, output, } = {}) {
21
39
  if (orgs.length === 0) {
22
40
  throw new CliError('No organizations found for this user.');
23
41
  }
@@ -25,7 +43,11 @@ export async function pickOrg(orgs, message = 'Pick an organization') {
25
43
  return orgs[0];
26
44
  const picked = await p.select({
27
45
  message,
46
+ input,
47
+ output,
28
48
  options: orgs.map((o) => ({ value: o.id, label: o.name })),
49
+ // clack starts on the first option when this isn't one of them.
50
+ initialValue: initialId,
29
51
  });
30
52
  if (p.isCancel(picked))
31
53
  throw new CliError('Cancelled.');
@@ -34,3 +56,37 @@ export async function pickOrg(orgs, message = 'Pick an organization') {
34
56
  throw new CliError('No organization selected.');
35
57
  return chosen;
36
58
  }
59
+ /**
60
+ * Choose the org for `dcd login`: `--org` if given, else the only org, else
61
+ * the picker starting on the last login's org. Without a TTY nobody can
62
+ * answer the picker, so the last login's org is used without asking, and
63
+ * when there isn't one the error names `--org` instead of hanging.
64
+ * `fromLastLogin` is set when that fallback chose the org, so the caller
65
+ * can say so.
66
+ */
67
+ export async function selectOrg(orgs, { requested, lastOrgId, interactive, input, output }) {
68
+ if (orgs.length === 0) {
69
+ throw new CliError('No organizations found for this user.');
70
+ }
71
+ if (requested !== undefined) {
72
+ return {
73
+ org: matchOrg(orgs, requested, '`dcd login` without --org'),
74
+ fromLastLogin: false,
75
+ };
76
+ }
77
+ if (orgs.length === 1)
78
+ return { org: orgs[0], fromLastLogin: false };
79
+ if (!interactive) {
80
+ const last = orgs.find((o) => o.id === lastOrgId);
81
+ if (last)
82
+ return { org: last, fromLastLogin: true };
83
+ const reason = lastOrgId
84
+ ? "The organization from your last login is no longer available, and there's no terminal to pick another in."
85
+ : "You belong to more than one organization, and there's no terminal to pick one in.";
86
+ throw new CliError(`${reason} Run \`dcd login --org <name>\` with one of: ${orgs.map((o) => o.name).join(', ')}`);
87
+ }
88
+ return {
89
+ org: await pickOrg(orgs, { initialId: lastOrgId, input, output }),
90
+ fromLastLogin: false,
91
+ };
92
+ }
package/package.json CHANGED
@@ -30,7 +30,7 @@
30
30
  "chai": "^6.2.2",
31
31
  "eslint": "^10.5.0",
32
32
  "eslint-config-prettier": "^10.1.8",
33
- "eslint-plugin-unicorn": "^77.0.0",
33
+ "eslint-plugin-unicorn": "^78.0.0",
34
34
  "husky": "^9.1.7",
35
35
  "mocha": "^12.0.0",
36
36
  "prettier": "^3.8.4",
@@ -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.8.0",
71
+ "version": "5.9.0-beta.1",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },
@@ -110,7 +110,7 @@
110
110
  "esbuild@<0.28.1": ">=0.28.1",
111
111
  "qs@<6.16.0": "6.16.0",
112
112
  "fast-uri@>=3.0.0 <3.1.8": "3.1.8",
113
- "hono@>=4.0.0 <4.13.7": "4.13.7",
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",
116
116
  "tinyglobby>picomatch": "^4.0.4"