@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.
@@ -18,9 +18,12 @@
18
18
  * 5. Meanwhile, the CLI polls POST /cli-login/claim with {state, code_verifier}.
19
19
  * Once the api has the row, it verifies sha256(verifier) === challenge,
20
20
  * deletes the row, and returns the session.
21
- * 6. CLI fetches /me/orgs with the fresh Bearer token and prompts the user to
22
- * pick an org. Org selection lives here, not in the web UI — that way the
23
- * CLI login and `dcd switch-org` share one picker.
21
+ * 6. CLI fetches /me/orgs with the fresh Bearer token and picks the org:
22
+ * `--org` if given, else the only one, else a picker that starts on the
23
+ * last login's org. Without a TTY nobody can answer the picker, so the
24
+ * last login's org is used, or the command fails pointing at `--org`.
25
+ * Org selection lives here, not in the web UI — that way the CLI login
26
+ * and `dcd switch-org` share one picker.
24
27
  *
25
28
  * Why PKCE + server rendezvous (instead of a localhost loopback): Safari
26
29
  * blocks HTTPS → HTTP fetches to 127.0.0.1 as mixed content, and a loopback
@@ -33,9 +36,11 @@ import { defineCommand } from 'citty';
33
36
  import { spawn } from 'node:child_process';
34
37
  import { createHash, randomBytes } from 'node:crypto';
35
38
  import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js';
39
+ import { jsonFlag } from '../config/flags/output.flags.js';
40
+ import { canPrompt } from '../utils/ci.js';
36
41
  import { CliError, logger } from '../utils/cli.js';
37
42
  import { readConfig, writeConfig } from '../utils/config-store.js';
38
- import { fetchOrgs, pickOrg } from '../utils/orgs.js';
43
+ import { fetchOrgs, selectOrg } from '../utils/orgs.js';
39
44
  import { colors } from '../utils/styling.js';
40
45
  import { ui } from '../utils/ui.js';
41
46
  const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
@@ -64,11 +69,35 @@ export const loginCommand = defineCommand({
64
69
  default: true,
65
70
  description: 'Open the login URL in a browser (pass --no-browser to just print it)',
66
71
  },
72
+ org: {
73
+ type: 'string',
74
+ description: 'Organization to use, by name or id (skips the picker)',
75
+ },
76
+ json: jsonFlag,
67
77
  },
68
78
  async run({ args }) {
69
79
  const apiUrl = args['api-url'].replace(/\/$/, '');
70
80
  const frontendOverride = args['frontend-url'];
71
81
  const noBrowser = args.browser === false;
82
+ const json = Boolean(args.json);
83
+ // citty gives '' for a bare `--org`. Refuse it, and an empty `--org "$ORG"`,
84
+ // rather than fall back to an org the caller didn't ask for.
85
+ const requestedOrg = args.org?.trim();
86
+ if (requestedOrg === '') {
87
+ throw new CliError('--org needs an organization name or id.');
88
+ }
89
+ // Login is interactive, so under --json the human rows and prompts move to
90
+ // stderr (the user still needs the URL) and stdout gets only the result.
91
+ const say = (m) => {
92
+ if (json)
93
+ process.stderr.write(`${m}\n`);
94
+ else
95
+ logger.log(m);
96
+ };
97
+ const promptOutput = json ? process.stderr : undefined;
98
+ // Without a TTY (a script, CI, a shell an agent runs) nobody can answer a
99
+ // prompt, so each one below falls back to the answer Enter would give.
100
+ const interactive = canPrompt(promptOutput);
72
101
  // If there's an existing stored session, make the user confirm before we
73
102
  // overwrite it. Silent clobber is fine for power users but surprising if
74
103
  // someone runs `dcd login` by mistake while already authenticated. An
@@ -79,7 +108,12 @@ export const loginCommand = defineCommand({
79
108
  if (existing?.session) {
80
109
  const now = Math.floor(Date.now() / 1000);
81
110
  if (existing.session.expires_at <= now) {
82
- logger.log(ui.info(`Your session for ${existing.session.user_email} has expired — signing in again.`));
111
+ say(ui.info(`Your session for ${existing.session.user_email} has expired — signing in again.`));
112
+ }
113
+ else if (!interactive) {
114
+ // The confirm below defaults to yes. The old session stays until the
115
+ // new login succeeds, so this replaces nothing if it never completes.
116
+ say(ui.info(`Already logged in as ${existing.session.user_email} — signing in again.`));
83
117
  }
84
118
  else {
85
119
  const currentOrg = existing.current_org_name ?? existing.current_org_id;
@@ -88,9 +122,15 @@ export const loginCommand = defineCommand({
88
122
  (currentOrg ? ` (org ${currentOrg})` : '') +
89
123
  `. Sign out and log in again?`,
90
124
  initialValue: true,
125
+ output: promptOutput,
91
126
  });
92
127
  if (p.isCancel(ok) || !ok) {
93
- logger.log(ui.info('Keeping existing session.'));
128
+ if (json) {
129
+ logger.json({ status: 'UNCHANGED', user: existing.session.user_email });
130
+ }
131
+ else {
132
+ logger.log(ui.info('Keeping existing session.'));
133
+ }
94
134
  return;
95
135
  }
96
136
  }
@@ -102,7 +142,7 @@ export const loginCommand = defineCommand({
102
142
  const codeVerifier = base64url(randomBytes(32));
103
143
  const codeChallenge = base64url(createHash('sha256').update(codeVerifier, 'ascii').digest());
104
144
  const loginUrl = `${frontendUrl}/cli-login?state=${state}&code_challenge=${codeChallenge}`;
105
- logger.log(ui.section('Signing in to devicecloud.dev'));
145
+ say(ui.section('Signing in to devicecloud.dev'));
106
146
  const rows = [];
107
147
  if (noBrowser) {
108
148
  rows.push(ui.note('Open this URL in a browser to finish login:'), colors.highlight(loginUrl));
@@ -117,7 +157,7 @@ export const loginCommand = defineCommand({
117
157
  }
118
158
  }
119
159
  rows.push(ui.note('Waiting for login to complete…'));
120
- logger.log(ui.branch(rows));
160
+ say(ui.branch(rows));
121
161
  try {
122
162
  const payload = await pollForClaim(apiUrl, state, codeVerifier);
123
163
  if (!payload.access_token || typeof payload.access_token !== 'string') {
@@ -146,7 +186,17 @@ export const loginCommand = defineCommand({
146
186
  ].filter(Boolean);
147
187
  throw new CliError(`${err.message} [${parts.join(' | ')}]`);
148
188
  }
149
- const chosen = await pickOrg(orgs);
189
+ const { org: chosen, fromLastLogin } = await selectOrg(orgs, {
190
+ requested: requestedOrg,
191
+ // Org ids are per environment (dev org 1 isn't prod org 1), so the
192
+ // last login's org only counts if that login was to this same API.
193
+ lastOrgId: existing?.api_url === apiUrl ? existing.current_org_id : undefined,
194
+ interactive,
195
+ output: promptOutput,
196
+ });
197
+ if (fromLastLogin) {
198
+ say(ui.info(`No terminal to pick an organization in, so using ${chosen.name} from your last login.`));
199
+ }
150
200
  writeConfig({
151
201
  version: 1,
152
202
  env,
@@ -162,6 +212,15 @@ export const loginCommand = defineCommand({
162
212
  current_org_id: chosen.id,
163
213
  current_org_name: chosen.name,
164
214
  });
215
+ if (json) {
216
+ logger.json({
217
+ status: 'SUCCESS',
218
+ user: payload.user_email,
219
+ org: { id: chosen.id, name: chosen.name },
220
+ env,
221
+ });
222
+ return;
223
+ }
165
224
  logger.log(ui.success(`Logged in as ${colors.highlight(payload.user_email)}`));
166
225
  logger.log(ui.branch([
167
226
  ...ui.fields([['organization', colors.highlight(chosen.name)]]),
@@ -171,7 +230,7 @@ export const loginCommand = defineCommand({
171
230
  ]));
172
231
  }
173
232
  catch (error) {
174
- logger.error(error, { exit: 1 });
233
+ logger.error(error, { exit: 1, json });
175
234
  }
176
235
  },
177
236
  });
@@ -1,2 +1,7 @@
1
- export declare const logoutCommand: import("citty").CommandDef<import("citty").ArgsDef>;
1
+ export declare const logoutCommand: import("citty").CommandDef<{
2
+ readonly json: {
3
+ readonly type: "boolean";
4
+ readonly description: "Output the result as a single JSON object on stdout";
5
+ };
6
+ }>;
2
7
  export default logoutCommand;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { defineCommand } from 'citty';
6
6
  import { ENVIRONMENTS } from '../config/environments.js';
7
+ import { jsonFlag } from '../config/flags/output.flags.js';
7
8
  import { CliAuthGateway } from '../gateways/cli-auth-gateway.js';
8
9
  import { logger } from '../utils/cli.js';
9
10
  import { clearConfig, getConfigPath, readConfig } from '../utils/config-store.js';
@@ -14,16 +15,29 @@ export const logoutCommand = defineCommand({
14
15
  name: 'logout',
15
16
  description: 'Clear the stored devicecloud.dev session',
16
17
  },
17
- async run() {
18
+ args: {
19
+ json: jsonFlag,
20
+ },
21
+ async run({ args }) {
22
+ const json = Boolean(args.json);
18
23
  const config = readConfig();
19
24
  if (!config?.session) {
20
- logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`));
21
25
  clearConfig();
26
+ if (json) {
27
+ logger.json({ status: 'SUCCESS', loggedOut: false });
28
+ }
29
+ else {
30
+ logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`));
31
+ }
22
32
  return;
23
33
  }
24
34
  const { anonKey } = ENVIRONMENTS[config.env].supabase;
25
35
  await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session);
26
36
  clearConfig();
37
+ if (json) {
38
+ logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email });
39
+ return;
40
+ }
27
41
  logger.log(ui.success(`Logged out ${colors.dim(`(${config.session.user_email})`)}`));
28
42
  },
29
43
  });
@@ -105,13 +105,12 @@ async function statusMain({ apiKeyFlag, apiUrl, json, name, uploadId, }) {
105
105
  if (isClientApiError(lastError)) {
106
106
  const errorMessage = lastError?.message || 'Unknown error';
107
107
  if (json) {
108
- // eslint-disable-next-line no-console
109
- console.log(JSON.stringify({
108
+ logger.json({
110
109
  status: 'FAILED',
111
110
  error: errorMessage,
112
111
  attempts: attemptsMade,
113
112
  tests: [],
114
- }, null, 2));
113
+ });
115
114
  return;
116
115
  }
117
116
  throw new CliError(errorMessage);
@@ -128,8 +127,7 @@ async function statusMain({ apiKeyFlag, apiUrl, json, name, uploadId, }) {
128
127
  errorMessage = `Failed to get status after ${attemptsMade} attempt${attemptsMade > 1 ? 's' : ''}.\n\nInternet connectivity check failed - all test endpoints unreachable:\n${endpointDetails}\n\nPlease verify your network connection and DNS resolution.\nLast API error: ${lastError?.message || 'Unknown error'}`;
129
128
  }
130
129
  if (json) {
131
- // eslint-disable-next-line no-console
132
- console.log(JSON.stringify({
130
+ logger.json({
133
131
  status: 'FAILED',
134
132
  error: errorMessage,
135
133
  attempts: attemptsMade,
@@ -139,7 +137,7 @@ async function statusMain({ apiKeyFlag, apiUrl, json, name, uploadId, }) {
139
137
  message: connectivityCheck.message,
140
138
  },
141
139
  tests: [],
142
- }, null, 2));
140
+ });
143
141
  return;
144
142
  }
145
143
  throw new CliError(errorMessage);
@@ -147,8 +145,7 @@ async function statusMain({ apiKeyFlag, apiUrl, json, name, uploadId, }) {
147
145
  try {
148
146
  if (json) {
149
147
  const { tests, ...rest } = status;
150
- // eslint-disable-next-line no-console
151
- console.log(JSON.stringify({ ...rest, tests }, null, 2));
148
+ logger.json({ ...rest, tests });
152
149
  return;
153
150
  }
154
151
  const fields = [];
@@ -8,5 +8,9 @@ export declare const switchOrgCommand: import("citty").CommandDef<{
8
8
  readonly required: false;
9
9
  readonly description: "Org name to switch to (omit for an interactive picker)";
10
10
  };
11
+ readonly json: {
12
+ readonly type: "boolean";
13
+ readonly description: "Output the result as a single JSON object on stdout";
14
+ };
11
15
  }>;
12
16
  export default switchOrgCommand;
@@ -3,13 +3,16 @@
3
3
  *
4
4
  * With an argument (`dcd switch-org <name>`): update config immediately after
5
5
  * confirming the user is a member via GET /me/orgs.
6
- * Without an argument: fetch orgs and prompt the user to pick one.
6
+ * Without an argument: fetch orgs and prompt the user to pick one (an error
7
+ * instead when there's no TTY to answer the prompt).
7
8
  */
8
9
  import { defineCommand } from 'citty';
10
+ import { jsonFlag } from '../config/flags/output.flags.js';
9
11
  import { apiKeyOverride, resolveAuth } from '../utils/auth.js';
12
+ import { canPrompt } from '../utils/ci.js';
10
13
  import { CliError, logger } from '../utils/cli.js';
11
14
  import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js';
12
- import { fetchOrgs, pickOrg } from '../utils/orgs.js';
15
+ import { fetchOrgs, matchOrg, pickOrg } from '../utils/orgs.js';
13
16
  import { colors } from '../utils/styling.js';
14
17
  import { ui } from '../utils/ui.js';
15
18
  export const switchOrgCommand = defineCommand({
@@ -27,6 +30,7 @@ export const switchOrgCommand = defineCommand({
27
30
  required: false,
28
31
  description: 'Org name to switch to (omit for an interactive picker)',
29
32
  },
33
+ json: jsonFlag,
30
34
  },
31
35
  async run({ args }) {
32
36
  const config = readConfig();
@@ -37,44 +41,43 @@ export const switchOrgCommand = defineCommand({
37
41
  // a dev Bearer token to the prod API.
38
42
  const apiUrl = resolveApiUrl(args['api-url']);
39
43
  const target = args.org;
44
+ const json = Boolean(args.json);
40
45
  // sessionOnly: an exported DEVICE_CLOUD_API_KEY must not shadow the
41
46
  // browser session this command requires.
42
47
  const auth = await resolveAuth({ apiKeyFlag: undefined, sessionOnly: true });
43
48
  const orgs = await fetchOrgs(apiUrl, auth.headers);
44
49
  let chosen;
45
50
  if (target) {
46
- chosen = matchOrg(orgs, target);
51
+ chosen = matchOrg(orgs, target, '`dcd switch-org` without arguments');
47
52
  }
48
53
  else {
49
- chosen = await pickOrg(orgs);
54
+ // Under --json the picker draws on stderr so stdout stays pure JSON.
55
+ const output = json ? process.stderr : undefined;
56
+ // Without a TTY the picker would wait forever for a keypress.
57
+ if (orgs.length > 1 && !canPrompt(output)) {
58
+ throw new CliError(`There's no terminal to pick an organization in. Run \`dcd switch-org <name>\` with one of: ${orgs.map((o) => o.name).join(', ')}`);
59
+ }
60
+ chosen = await pickOrg(orgs, { output });
50
61
  }
51
62
  writeConfig({
52
63
  ...config,
53
64
  current_org_id: chosen.id,
54
65
  current_org_name: chosen.name,
55
66
  });
56
- logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`));
57
67
  // The switch only changes the stored session; an exported key still wins.
58
- if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') {
68
+ const keyOverride = apiKeyOverride() === 'DEVICE_CLOUD_API_KEY';
69
+ if (json) {
70
+ logger.json({
71
+ status: 'SUCCESS',
72
+ org: { id: chosen.id, name: chosen.name, slug: chosen.slug ?? null },
73
+ apiKeyOverride: keyOverride,
74
+ });
75
+ return;
76
+ }
77
+ logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`));
78
+ if (keyOverride) {
59
79
  logger.log(ui.warn(`${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`));
60
80
  }
61
81
  },
62
82
  });
63
- /**
64
- * Match a user-supplied string to an org. Prefers case-insensitive name match.
65
- * Falls back to slug / id so existing scripts keep working, but error output
66
- * only surfaces names.
67
- */
68
- function matchOrg(orgs, target) {
69
- const needle = target.toLowerCase();
70
- const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle);
71
- if (nameMatches.length > 1) {
72
- throw new CliError(`Multiple orgs named "${target}". Run \`dcd switch-org\` without arguments to pick interactively.`);
73
- }
74
- const chosen = nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target);
75
- if (!chosen) {
76
- throw new CliError(`No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`);
77
- }
78
- return chosen;
79
- }
80
83
  export default switchOrgCommand;
@@ -1,2 +1,7 @@
1
- export declare const upgradeCommand: import("citty").CommandDef<import("citty").ArgsDef>;
1
+ export declare const upgradeCommand: import("citty").CommandDef<{
2
+ readonly json: {
3
+ readonly type: "boolean";
4
+ readonly description: "Output the result as a single JSON object on stdout";
5
+ };
6
+ }>;
2
7
  export default upgradeCommand;
@@ -12,6 +12,7 @@ import { chmodSync, createReadStream, createWriteStream, renameSync, unlinkSync,
12
12
  import { Readable } from 'node:stream';
13
13
  import { pipeline } from 'node:stream/promises';
14
14
  import { defineCommand } from 'citty';
15
+ import { jsonFlag } from '../config/flags/output.flags.js';
15
16
  import { VersionService } from '../services/version.service.js';
16
17
  import { CliError, getCliVersion, getInstallMethod, logger, } from '../utils/cli.js';
17
18
  import { colors } from '../utils/styling.js';
@@ -31,7 +32,15 @@ export const upgradeCommand = defineCommand({
31
32
  name: 'upgrade',
32
33
  description: 'Upgrade dcd in place to the latest released version',
33
34
  },
34
- async run() {
35
+ args: {
36
+ json: jsonFlag,
37
+ },
38
+ async run({ args }) {
39
+ const json = Boolean(args.json);
40
+ const out = (m) => {
41
+ if (!json)
42
+ logger.log(m);
43
+ };
35
44
  if (getInstallMethod() !== 'binary') {
36
45
  throw new CliError('`dcd upgrade` only applies to the standalone binary install. ' +
37
46
  'Run: npm install -g @devicecloud.dev/dcd@latest');
@@ -45,11 +54,19 @@ export const upgradeCommand = defineCommand({
45
54
  // Reachable, but nothing published on this channel yet (e.g. a stable
46
55
  // install while only betas exist). Not an error — just nothing to do.
47
56
  if (result.version === null) {
57
+ if (json) {
58
+ logger.json({ status: 'NO_RELEASE', current, channel: result.channel });
59
+ return;
60
+ }
48
61
  logger.log(ui.info(`No newer release available on the ${result.channel} channel.`));
49
62
  return;
50
63
  }
51
64
  const latest = result.version;
52
65
  if (!versionService.isOutdated(current, latest)) {
66
+ if (json) {
67
+ logger.json({ status: 'UP_TO_DATE', current, latest, channel: result.channel });
68
+ return;
69
+ }
53
70
  logger.log(ui.success(`Already on the latest version (${colors.highlight(current)})`));
54
71
  return;
55
72
  }
@@ -69,8 +86,8 @@ export const upgradeCommand = defineCommand({
69
86
  const base = process.env.DCD_DOWNLOAD_BASE ?? DEFAULT_DOWNLOAD_BASE;
70
87
  const binaryUrl = `${base}/download/${latest}/${asset}`;
71
88
  const sumsUrl = `${base}/download/${latest}/SHA256SUMS`;
72
- logger.log(ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`));
73
- logger.log(ui.note(` ${binaryUrl}`));
89
+ out(ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`));
90
+ out(ui.note(` ${binaryUrl}`));
74
91
  const execPath = process.execPath;
75
92
  const tmpPath = `${execPath}.new`;
76
93
  try {
@@ -89,6 +106,10 @@ export const upgradeCommand = defineCommand({
89
106
  safeUnlink(tmpPath);
90
107
  throw new CliError(`Upgrade failed: ${e.message}. The existing binary at ${execPath} was not modified.`);
91
108
  }
109
+ if (json) {
110
+ logger.json({ status: 'UPGRADED', current, latest, channel: result.channel });
111
+ return;
112
+ }
92
113
  logger.log(ui.success(`Upgraded to ${colors.highlight(latest)}`));
93
114
  },
94
115
  });
@@ -116,8 +116,7 @@ export const uploadCommand = defineCommand({
116
116
  });
117
117
  telemetry.recordEncryption({ binary: encryption.enabled });
118
118
  if (json) {
119
- // eslint-disable-next-line no-console
120
- console.log(JSON.stringify({ appBinaryId }, null, 2));
119
+ logger.json({ appBinaryId });
121
120
  return;
122
121
  }
123
122
  logger.log(`\n${ui.success('Upload complete')}`);
@@ -4,5 +4,9 @@ export declare const whoamiCommand: import("citty").CommandDef<{
4
4
  readonly type: "string";
5
5
  readonly alias: ["apiKey"];
6
6
  };
7
+ readonly json: {
8
+ readonly type: "boolean";
9
+ readonly description: "Output the result as a single JSON object on stdout";
10
+ };
7
11
  }>;
8
12
  export default whoamiCommand;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { defineCommand } from 'citty';
9
9
  import { apiFlags } from '../config/flags/api.flags.js';
10
+ import { jsonFlag } from '../config/flags/output.flags.js';
10
11
  import { apiKeyOverride } from '../utils/auth.js';
11
12
  import { logger } from '../utils/cli.js';
12
13
  import { readConfig } from '../utils/config-store.js';
@@ -22,10 +23,26 @@ export const whoamiCommand = defineCommand({
22
23
  ...apiFlags['api-key'],
23
24
  description: 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session',
24
25
  },
26
+ json: jsonFlag,
25
27
  },
26
28
  run({ args }) {
27
29
  const config = readConfig();
28
30
  const keySource = apiKeyOverride(args['api-key']);
31
+ if (args.json) {
32
+ logger.json({
33
+ status: 'SUCCESS',
34
+ loggedIn: Boolean(config?.session),
35
+ ...(config?.session && {
36
+ user: config.session.user_email,
37
+ org: config.current_org_id
38
+ ? { id: config.current_org_id, name: config.current_org_name ?? null }
39
+ : null,
40
+ env: config.env,
41
+ }),
42
+ apiKeySource: keySource ?? null,
43
+ });
44
+ return;
45
+ }
29
46
  const keyName = colors.highlight(keySource ?? '');
30
47
  if (!config?.session) {
31
48
  logger.log(ui.info(keySource === 'DEVICE_CLOUD_API_KEY'
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Generic `--json` flag for commands outside `cloud`. With it, stdout carries
3
+ * exactly one JSON object; failures print `{ status: 'FAILED', error }`.
4
+ */
5
+ export declare const jsonFlag: {
6
+ readonly type: "boolean";
7
+ readonly description: "Output the result as a single JSON object on stdout";
8
+ };
1
9
  /**
2
10
  * Output format and artifact download flags
3
11
  */
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Generic `--json` flag for commands outside `cloud`. With it, stdout carries
3
+ * exactly one JSON object; failures print `{ status: 'FAILED', error }`.
4
+ */
5
+ export const jsonFlag = {
6
+ type: 'boolean',
7
+ description: 'Output the result as a single JSON object on stdout',
8
+ };
1
9
  /**
2
10
  * Output format and artifact download flags
3
11
  */
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { upgradeCommand } from './commands/upgrade.js';
13
13
  import { uploadCommand } from './commands/upload.js';
14
14
  import { whoamiCommand } from './commands/whoami.js';
15
15
  import { telemetry } from './services/telemetry.service.js';
16
- import { CliError, getCliVersion, logger } from './utils/cli.js';
16
+ import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js';
17
17
  import { withoutHiddenArgs } from './utils/help.js';
18
18
  // @clack/prompts ships the US spelling ("Canceled") for its built-in
19
19
  // spinner/prompt cancellation message; align it with the British spelling
@@ -88,9 +88,12 @@ async function run() {
88
88
  await telemetry.flush();
89
89
  }
90
90
  catch (error) {
91
+ // Under --json, stdout must stay a single JSON object: no help text, and
92
+ // the error itself is printed as `{ status: 'FAILED', error }`.
93
+ const json = wantsJson(rawArgs);
91
94
  // citty throws CLIError (by name — the class isn't exported) for usage
92
95
  // problems like unknown commands or missing required args.
93
- if (error instanceof Error && error.name === 'CLIError') {
96
+ if (!json && error instanceof Error && error.name === 'CLIError') {
94
97
  await showHelp(rawArgs);
95
98
  }
96
99
  const exitCode = error instanceof CliError ? error.exitCode : 1;
@@ -98,6 +101,7 @@ async function run() {
98
101
  // synchronously, and exits with the given code.
99
102
  logger.error(error instanceof Error ? error : String(error), {
100
103
  exit: exitCode,
104
+ json,
101
105
  });
102
106
  }
103
107
  }
@@ -17,6 +17,13 @@ export interface ReportDownloadOptions extends DownloadOptions {
17
17
  junitPath?: string;
18
18
  reportType: 'allure' | 'html' | 'html-detailed' | 'junit';
19
19
  }
20
+ /** What one download attempt did; failures are warned about, not thrown. */
21
+ export interface DownloadResult {
22
+ error?: string;
23
+ ok: boolean;
24
+ path: string;
25
+ type: 'allure' | 'artifacts' | 'html' | 'junit';
26
+ }
20
27
  /**
21
28
  * Service for downloading test artifacts and reports
22
29
  */
@@ -24,21 +31,21 @@ export declare class ReportDownloadService {
24
31
  /**
25
32
  * Download test artifacts as a zip file
26
33
  * @param options Download configuration
27
- * @returns Promise that resolves when download is complete
34
+ * @returns What the download did
28
35
  */
29
- downloadArtifacts(options: ArtifactsDownloadOptions): Promise<void>;
36
+ downloadArtifacts(options: ArtifactsDownloadOptions): Promise<DownloadResult>;
30
37
  /**
31
38
  * Handle downloading reports based on the report type specified
32
39
  * @param options Report download configuration
33
- * @returns Promise that resolves when download is complete
40
+ * @returns What the download did, or undefined for an unknown report type
34
41
  */
35
- downloadReports(options: ReportDownloadOptions): Promise<void>;
42
+ downloadReports(options: ReportDownloadOptions): Promise<DownloadResult | undefined>;
36
43
  /**
37
44
  * Download a specific report type
38
45
  * @param type Report type to download
39
46
  * @param filePath Path where report should be saved
40
47
  * @param options Download configuration
41
- * @returns Promise that resolves when download is complete
48
+ * @returns What the download did
42
49
  */
43
50
  private downloadReport;
44
51
  /**