@devicecloud.dev/dcd 5.8.0 → 5.9.0

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.
Files changed (39) hide show
  1. package/dist/commands/artifacts.d.ts +4 -0
  2. package/dist/commands/artifacts.js +21 -6
  3. package/dist/commands/cloud.js +2 -4
  4. package/dist/commands/list.js +1 -2
  5. package/dist/commands/live.js +159 -56
  6. package/dist/commands/login.d.ts +8 -0
  7. package/dist/commands/login.js +74 -14
  8. package/dist/commands/logout.d.ts +6 -1
  9. package/dist/commands/logout.js +17 -5
  10. package/dist/commands/status.js +5 -8
  11. package/dist/commands/switch-org.d.ts +4 -0
  12. package/dist/commands/switch-org.js +26 -23
  13. package/dist/commands/upgrade.d.ts +6 -1
  14. package/dist/commands/upgrade.js +24 -3
  15. package/dist/commands/upload.js +1 -2
  16. package/dist/commands/whoami.d.ts +4 -0
  17. package/dist/commands/whoami.js +17 -0
  18. package/dist/config/environments.d.ts +6 -9
  19. package/dist/config/environments.js +4 -8
  20. package/dist/config/flags/output.flags.d.ts +8 -0
  21. package/dist/config/flags/output.flags.js +8 -0
  22. package/dist/config/supabase-key.d.ts +27 -0
  23. package/dist/config/supabase-key.js +54 -0
  24. package/dist/gateways/cli-auth-gateway.d.ts +3 -2
  25. package/dist/gateways/cli-auth-gateway.js +16 -6
  26. package/dist/gateways/realtime-gateway.js +48 -34
  27. package/dist/gateways/supabase-gateway.d.ts +1 -1
  28. package/dist/gateways/supabase-gateway.js +8 -6
  29. package/dist/index.js +6 -2
  30. package/dist/services/report-download.service.d.ts +12 -5
  31. package/dist/services/report-download.service.js +15 -10
  32. package/dist/utils/auth.js +1 -3
  33. package/dist/utils/ci.d.ts +9 -0
  34. package/dist/utils/ci.js +9 -0
  35. package/dist/utils/cli.d.ts +7 -0
  36. package/dist/utils/cli.js +23 -6
  37. package/dist/utils/orgs.d.ts +38 -1
  38. package/dist/utils/orgs.js +59 -3
  39. package/package.json +3 -3
@@ -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
@@ -32,10 +35,13 @@ import * as p from '@clack/prompts';
32
35
  import { defineCommand } from 'citty';
33
36
  import { spawn } from 'node:child_process';
34
37
  import { createHash, randomBytes } from 'node:crypto';
35
- import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js';
38
+ import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl, } from '../config/environments.js';
39
+ import { getSupabaseKey } from '../config/supabase-key.js';
40
+ import { jsonFlag } from '../config/flags/output.flags.js';
41
+ import { canPrompt } from '../utils/ci.js';
36
42
  import { CliError, logger } from '../utils/cli.js';
37
43
  import { readConfig, writeConfig } from '../utils/config-store.js';
38
- import { fetchOrgs, pickOrg } from '../utils/orgs.js';
44
+ import { fetchOrgs, selectOrg } from '../utils/orgs.js';
39
45
  import { colors } from '../utils/styling.js';
40
46
  import { ui } from '../utils/ui.js';
41
47
  const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
@@ -64,11 +70,35 @@ export const loginCommand = defineCommand({
64
70
  default: true,
65
71
  description: 'Open the login URL in a browser (pass --no-browser to just print it)',
66
72
  },
73
+ org: {
74
+ type: 'string',
75
+ description: 'Organization to use, by name or id (skips the picker)',
76
+ },
77
+ json: jsonFlag,
67
78
  },
68
79
  async run({ args }) {
69
80
  const apiUrl = args['api-url'].replace(/\/$/, '');
70
81
  const frontendOverride = args['frontend-url'];
71
82
  const noBrowser = args.browser === false;
83
+ const json = Boolean(args.json);
84
+ // citty gives '' for a bare `--org`. Refuse it, and an empty `--org "$ORG"`,
85
+ // rather than fall back to an org the caller didn't ask for.
86
+ const requestedOrg = args.org?.trim();
87
+ if (requestedOrg === '') {
88
+ throw new CliError('--org needs an organization name or id.');
89
+ }
90
+ // Login is interactive, so under --json the human rows and prompts move to
91
+ // stderr (the user still needs the URL) and stdout gets only the result.
92
+ const say = (m) => {
93
+ if (json)
94
+ process.stderr.write(`${m}\n`);
95
+ else
96
+ logger.log(m);
97
+ };
98
+ const promptOutput = json ? process.stderr : undefined;
99
+ // Without a TTY (a script, CI, a shell an agent runs) nobody can answer a
100
+ // prompt, so each one below falls back to the answer Enter would give.
101
+ const interactive = canPrompt(promptOutput);
72
102
  // If there's an existing stored session, make the user confirm before we
73
103
  // overwrite it. Silent clobber is fine for power users but surprising if
74
104
  // someone runs `dcd login` by mistake while already authenticated. An
@@ -79,7 +109,12 @@ export const loginCommand = defineCommand({
79
109
  if (existing?.session) {
80
110
  const now = Math.floor(Date.now() / 1000);
81
111
  if (existing.session.expires_at <= now) {
82
- logger.log(ui.info(`Your session for ${existing.session.user_email} has expired — signing in again.`));
112
+ say(ui.info(`Your session for ${existing.session.user_email} has expired — signing in again.`));
113
+ }
114
+ else if (!interactive) {
115
+ // The confirm below defaults to yes. The old session stays until the
116
+ // new login succeeds, so this replaces nothing if it never completes.
117
+ say(ui.info(`Already logged in as ${existing.session.user_email} — signing in again.`));
83
118
  }
84
119
  else {
85
120
  const currentOrg = existing.current_org_name ?? existing.current_org_id;
@@ -88,9 +123,15 @@ export const loginCommand = defineCommand({
88
123
  (currentOrg ? ` (org ${currentOrg})` : '') +
89
124
  `. Sign out and log in again?`,
90
125
  initialValue: true,
126
+ output: promptOutput,
91
127
  });
92
128
  if (p.isCancel(ok) || !ok) {
93
- logger.log(ui.info('Keeping existing session.'));
129
+ if (json) {
130
+ logger.json({ status: 'UNCHANGED', user: existing.session.user_email });
131
+ }
132
+ else {
133
+ logger.log(ui.info('Keeping existing session.'));
134
+ }
94
135
  return;
95
136
  }
96
137
  }
@@ -102,7 +143,7 @@ export const loginCommand = defineCommand({
102
143
  const codeVerifier = base64url(randomBytes(32));
103
144
  const codeChallenge = base64url(createHash('sha256').update(codeVerifier, 'ascii').digest());
104
145
  const loginUrl = `${frontendUrl}/cli-login?state=${state}&code_challenge=${codeChallenge}`;
105
- logger.log(ui.section('Signing in to devicecloud.dev'));
146
+ say(ui.section('Signing in to devicecloud.dev'));
106
147
  const rows = [];
107
148
  if (noBrowser) {
108
149
  rows.push(ui.note('Open this URL in a browser to finish login:'), colors.highlight(loginUrl));
@@ -117,7 +158,7 @@ export const loginCommand = defineCommand({
117
158
  }
118
159
  }
119
160
  rows.push(ui.note('Waiting for login to complete…'));
120
- logger.log(ui.branch(rows));
161
+ say(ui.branch(rows));
121
162
  try {
122
163
  const payload = await pollForClaim(apiUrl, state, codeVerifier);
123
164
  if (!payload.access_token || typeof payload.access_token !== 'string') {
@@ -139,14 +180,24 @@ export const loginCommand = defineCommand({
139
180
  // rejects it, the token itself is bad; if Supabase accepts, the
140
181
  // api's JwtService is misconfigured.
141
182
  const iss = decodeJwtIssuer(payload.access_token);
142
- const supabaseUser = await probeSupabaseUser(iss, ENVIRONMENTS[env].supabase.anonKey, payload.access_token);
183
+ const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token);
143
184
  const parts = [
144
185
  iss ? `token iss: ${iss}` : null,
145
186
  `supabase /auth/v1/user: ${supabaseUser}`,
146
187
  ].filter(Boolean);
147
188
  throw new CliError(`${err.message} [${parts.join(' | ')}]`);
148
189
  }
149
- const chosen = await pickOrg(orgs);
190
+ const { org: chosen, fromLastLogin } = await selectOrg(orgs, {
191
+ requested: requestedOrg,
192
+ // Org ids are per environment (dev org 1 isn't prod org 1), so the
193
+ // last login's org only counts if that login was to this same API.
194
+ lastOrgId: existing?.api_url === apiUrl ? existing.current_org_id : undefined,
195
+ interactive,
196
+ output: promptOutput,
197
+ });
198
+ if (fromLastLogin) {
199
+ say(ui.info(`No terminal to pick an organization in, so using ${chosen.name} from your last login.`));
200
+ }
150
201
  writeConfig({
151
202
  version: 1,
152
203
  env,
@@ -162,6 +213,15 @@ export const loginCommand = defineCommand({
162
213
  current_org_id: chosen.id,
163
214
  current_org_name: chosen.name,
164
215
  });
216
+ if (json) {
217
+ logger.json({
218
+ status: 'SUCCESS',
219
+ user: payload.user_email,
220
+ org: { id: chosen.id, name: chosen.name },
221
+ env,
222
+ });
223
+ return;
224
+ }
165
225
  logger.log(ui.success(`Logged in as ${colors.highlight(payload.user_email)}`));
166
226
  logger.log(ui.branch([
167
227
  ...ui.fields([['organization', colors.highlight(chosen.name)]]),
@@ -171,7 +231,7 @@ export const loginCommand = defineCommand({
171
231
  ]));
172
232
  }
173
233
  catch (error) {
174
- logger.error(error, { exit: 1 });
234
+ logger.error(error, { exit: 1, json });
175
235
  }
176
236
  },
177
237
  });
@@ -228,14 +288,14 @@ async function pollForClaim(apiUrl, state, codeVerifier) {
228
288
  function base64url(buf) {
229
289
  return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
230
290
  }
231
- async function probeSupabaseUser(iss, anonKey, token) {
291
+ async function probeSupabaseUser(iss, env, token) {
232
292
  if (!iss)
233
293
  return 'skipped (no iss)';
234
294
  try {
235
295
  const res = await fetch(`${iss}/user`, {
236
296
  headers: {
237
297
  authorization: `Bearer ${token}`,
238
- apikey: anonKey,
298
+ apikey: await getSupabaseKey(env),
239
299
  },
240
300
  });
241
301
  const bodyText = await res.text().catch(() => '');
@@ -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;
@@ -3,7 +3,7 @@
3
3
  * it on Supabase. Leaves DEVICE_CLOUD_API_KEY (env/flag) untouched.
4
4
  */
5
5
  import { defineCommand } from 'citty';
6
- import { ENVIRONMENTS } from '../config/environments.js';
6
+ import { jsonFlag } from '../config/flags/output.flags.js';
7
7
  import { CliAuthGateway } from '../gateways/cli-auth-gateway.js';
8
8
  import { logger } from '../utils/cli.js';
9
9
  import { clearConfig, getConfigPath, readConfig } from '../utils/config-store.js';
@@ -14,16 +14,28 @@ export const logoutCommand = defineCommand({
14
14
  name: 'logout',
15
15
  description: 'Clear the stored devicecloud.dev session',
16
16
  },
17
- async run() {
17
+ args: {
18
+ json: jsonFlag,
19
+ },
20
+ async run({ args }) {
21
+ const json = Boolean(args.json);
18
22
  const config = readConfig();
19
23
  if (!config?.session) {
20
- logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`));
21
24
  clearConfig();
25
+ if (json) {
26
+ logger.json({ status: 'SUCCESS', loggedOut: false });
27
+ }
28
+ else {
29
+ logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`));
30
+ }
22
31
  return;
23
32
  }
24
- const { anonKey } = ENVIRONMENTS[config.env].supabase;
25
- await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session);
33
+ await CliAuthGateway.signOut(config.supabase_url, config.env, config.session);
26
34
  clearConfig();
35
+ if (json) {
36
+ logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email });
37
+ return;
38
+ }
27
39
  logger.log(ui.success(`Logged out ${colors.dim(`(${config.session.user_email})`)}`));
28
40
  },
29
41
  });
@@ -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,12 +1,10 @@
1
1
  /**
2
2
  * Per-env devicecloud.dev coordinates — single source of truth for the URLs
3
- * and Supabase credentials the CLI uses.
3
+ * the CLI uses.
4
4
  *
5
- * **These `anonKey` values are public.** Supabase anon keys are JWTs designed
6
- * to be embedded in client code (web apps, mobile apps, CLIs). They only
7
- * grant whatever the project's RLS policies expose. Don't worry about seeing
8
- * them here — every Supabase-based product ships its anon key the same way.
9
- * https://supabase.com/docs/guides/api/api-keys
5
+ * There is deliberately no Supabase key here. A compiled-in key can never be
6
+ * rotated without breaking every pinned CLI version, so it is fetched from the
7
+ * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112).
10
8
  */
11
9
  export type DcdEnvName = 'dev' | 'prod';
12
10
  export interface DcdEnvironment {
@@ -15,13 +13,12 @@ export interface DcdEnvironment {
15
13
  supabase: {
16
14
  url: string;
17
15
  projectRef: string;
18
- anonKey: string;
19
16
  };
20
17
  /**
21
18
  * Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
22
19
  * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
23
- * KEK the platform API unwraps with. **Public — safe to embed** (like the anon
24
- * key above): it can only *encrypt*; the private half lives solely on the API.
20
+ * KEK the platform API unwraps with. **Public — safe to embed**: it can only
21
+ * *encrypt*; the private half lives solely on the API.
25
22
  *
26
23
  * Left `null` until the KEK is generated and provisioned. Generate a keypair
27
24
  * with, e.g.:
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * Per-env devicecloud.dev coordinates — single source of truth for the URLs
3
- * and Supabase credentials the CLI uses.
3
+ * the CLI uses.
4
4
  *
5
- * **These `anonKey` values are public.** Supabase anon keys are JWTs designed
6
- * to be embedded in client code (web apps, mobile apps, CLIs). They only
7
- * grant whatever the project's RLS policies expose. Don't worry about seeing
8
- * them here — every Supabase-based product ships its anon key the same way.
9
- * https://supabase.com/docs/guides/api/api-keys
5
+ * There is deliberately no Supabase key here. A compiled-in key can never be
6
+ * rotated without breaking every pinned CLI version, so it is fetched from the
7
+ * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112).
10
8
  */
11
9
  export const ENVIRONMENTS = {
12
10
  prod: {
@@ -15,7 +13,6 @@ export const ENVIRONMENTS = {
15
13
  supabase: {
16
14
  url: 'https://cloud.devicecloud.dev',
17
15
  projectRef: 'pgydnphbimetinsgfkbo',
18
- anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
19
16
  },
20
17
  kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
21
18
  },
@@ -25,7 +22,6 @@ export const ENVIRONMENTS = {
25
22
  supabase: {
26
23
  url: 'https://lbmsowehtjwnqlurpemb.supabase.co',
27
24
  projectRef: 'lbmsowehtjwnqlurpemb',
28
- anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
29
25
  },
30
26
  kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
31
27
  },
@@ -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
  */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112).
3
+ *
4
+ * Releases up to this one compiled the anon key in, so the key could never be
5
+ * rotated without breaking every CLI version pinned in customer CI. The API now
6
+ * serves it from the public `GET /cli-config`, and rotating it is an env change
7
+ * on the API.
8
+ *
9
+ * Fetched once per process and kept in memory only. It is deliberately not
10
+ * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which
11
+ * `resolveAuth` treats as a dead session and deletes the user's login.
12
+ *
13
+ * Only the key comes from the API. The Supabase URLs stay pinned in
14
+ * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain,
15
+ * which customer firewalls may allow-list, not the API's raw `supabase.co` URL.
16
+ */
17
+ import { type DcdEnvName } from './environments.js';
18
+ /** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
19
+ export declare function getSupabaseKey(env: DcdEnvName): Promise<string>;
20
+ /**
21
+ * Headers that authenticate a raw Storage/Auth request with `key` alone. A
22
+ * legacy anon key is a JWT and also goes in `Authorization`; an
23
+ * `sb_publishable_` key is not a JWT and must only be sent as `apikey`.
24
+ */
25
+ export declare function supabaseKeyHeaders(key: string): Record<string, string>;
26
+ /** Test seam: forget fetched keys. */
27
+ export declare function resetSupabaseKeyCache(): void;