@apex-actions/cli 0.74.0 → 0.76.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.
@@ -0,0 +1,111 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ /**
3
+ * Complimentary access from the terminal (control-plane ADR-0056 as amended by
4
+ * ADR-0079, TASK-0199): the grant and installation shapes the admin API
5
+ * answers with, the checks `apex grant` makes before a round trip — the
6
+ * control plane makes them again — and how each reads as a line.
7
+ */
8
+ /** The admin API's limits on a grant (control-plane `domain/grant.ts`). */
9
+ export const MAX_GRANT_MINUTES = 120_000;
10
+ export const MAX_GRANT_CONCURRENCY = 50;
11
+ export const MAX_GRANT_REASON = 500;
12
+ const whole = (raw, min, max) => {
13
+ if (raw === undefined || !/^[0-9]+$/.test(raw.trim()))
14
+ return undefined;
15
+ const n = Number(raw.trim());
16
+ return Number.isSafeInteger(n) && n >= min && n <= max ? n : undefined;
17
+ };
18
+ /**
19
+ * `--until YYYY-MM-DD`: the grant ends at the end of that UTC day, as the Console's form has it — so the
20
+ * instant sent is the next day's midnight. A day that is not a real date, or not after `now`, is refused.
21
+ */
22
+ export const endOfDay = (day, now) => {
23
+ const value = day.trim();
24
+ const start = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00.000Z`) : new Date(Number.NaN);
25
+ if (Number.isNaN(start.getTime()) || start.toISOString().slice(0, 10) !== value)
26
+ return { ok: false, problem: `--until must be a date, YYYY-MM-DD, got ${JSON.stringify(day)}` };
27
+ const end = new Date(start.getTime() + 86_400_000);
28
+ if (end.getTime() <= now.getTime())
29
+ return { ok: false, problem: '--until must be today or later — the grant ends at the end of that UTC day' };
30
+ return { ok: true, value: end.toISOString() };
31
+ };
32
+ const minutesProblem = `--minutes must be a whole number from 1 to ${MAX_GRANT_MINUTES.toLocaleString('en-US')}`;
33
+ const concurrencyProblem = `--concurrency must be a whole number from 1 to ${MAX_GRANT_CONCURRENCY}`;
34
+ /** `apex grant create`'s arguments, checked. */
35
+ export const createGrantBody = (installation, flags, now) => {
36
+ const installationId = whole(installation, 1, Number.MAX_SAFE_INTEGER);
37
+ if (installationId === undefined)
38
+ return { ok: false, problem: `expected a positive installation id, got ${JSON.stringify(installation)}` };
39
+ const minutesPerMonth = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
40
+ if (minutesPerMonth === undefined)
41
+ return { ok: false, problem: minutesProblem };
42
+ const concurrency = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
43
+ if (concurrency === undefined)
44
+ return { ok: false, problem: concurrencyProblem };
45
+ const reason = flags.reason?.trim() ?? '';
46
+ if (reason === '' || reason.length > MAX_GRANT_REASON)
47
+ return { ok: false, problem: `--reason is required, up to ${MAX_GRANT_REASON} characters — who this is for, and why they run free` };
48
+ if (flags.until === undefined)
49
+ return { ok: true, value: { installationId, minutesPerMonth, concurrency, reason } };
50
+ const until = endOfDay(flags.until, now);
51
+ return until.ok ? { ok: true, value: { installationId, minutesPerMonth, concurrency, reason, expiresAt: until.value } } : until;
52
+ };
53
+ /** `apex grant update`'s flags, checked: at least one change, each well-formed, and not both an end date and none. */
54
+ export const updateGrantBody = (flags, now) => {
55
+ const body = {};
56
+ if (flags.minutes !== undefined) {
57
+ const n = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
58
+ if (n === undefined)
59
+ return { ok: false, problem: minutesProblem };
60
+ body.minutesPerMonth = n;
61
+ }
62
+ if (flags.concurrency !== undefined) {
63
+ const n = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
64
+ if (n === undefined)
65
+ return { ok: false, problem: concurrencyProblem };
66
+ body.concurrency = n;
67
+ }
68
+ if (flags.until !== undefined && flags.noEnd === true)
69
+ return { ok: false, problem: 'pass --until or --no-end, not both' };
70
+ if (flags.noEnd === true)
71
+ body.expiresAt = null;
72
+ if (flags.until !== undefined) {
73
+ const until = endOfDay(flags.until, now);
74
+ if (!until.ok)
75
+ return until;
76
+ body.expiresAt = until.value;
77
+ }
78
+ if (Object.keys(body).length === 0)
79
+ return { ok: false, problem: 'nothing to change: pass --minutes, --concurrency, --until or --no-end' };
80
+ return { ok: true, value: body };
81
+ };
82
+ const day = (iso) => iso?.slice(0, 10);
83
+ /** One grant as a line: whose, how it stands, the month so far, and who granted it and why. */
84
+ export const grantLine = (g) => {
85
+ const used = g.usedMinutes === undefined ? '' : `${Math.floor(g.usedMinutes).toLocaleString('en-US')} of `;
86
+ const state = g.state === 'ended' ? `ended ${day(g.endedAt ?? g.revokedAt) ?? ''}${g.revokedBy === undefined ? '' : ` by ${g.revokedBy}`}`.trim() : g.dormant ? `${g.state}, dormant — they pay for a plan` : g.state;
87
+ const until = g.expiresAt === undefined ? '' : `, until ${day(g.expiresAt) ?? ''}`;
88
+ return `${g.id} ${g.owner} (installation ${g.installationId}) — ${state}; ${used}${g.minutesPerMonth.toLocaleString('en-US')} minutes a month, ${g.concurrency} at once${until}; granted by ${g.grantedBy}: ${g.reason}`;
89
+ };
90
+ /** What a create means, in the Console's words: open, a replacement, or dormant behind a subscription. */
91
+ export const createdLine = (c) => c.dormant
92
+ ? `Granted to ${c.grant.owner}. They pay for a plan, so the grant is dormant until they stop.`
93
+ : `Granted to ${c.grant.owner}.${c.superseded === undefined ? '' : ' It replaces the grant they held.'}`;
94
+ export const revokedLine = (r) => `Revoked the grant for ${r.grant.owner}.${r.cancelledJobs > 0 ? ` ${r.cancelledJobs} queued job${r.cancelledJobs === 1 ? ' was' : 's were'} cancelled.` : ''}`;
95
+ /** One installation as a line: whose, and what Apex knows of it — or that it is new. */
96
+ export const installationLine = (i) => {
97
+ const known = [
98
+ i.accountId === undefined ? 'no Account' : `Account ${i.accountId}`,
99
+ ...(i.subscription === undefined ? [] : [`subscription ${i.subscription}`]),
100
+ ...(i.grant === undefined ? [] : [`grant ${i.grant}`]),
101
+ ...(i.recentUsage ? ['ran in the last 30 days'] : []),
102
+ ];
103
+ return `${i.installationId} ${i.account.login} (${i.account.type === 'User' ? 'personal account' : 'organization'})${i.listedAsTenant ? '' : ' — new'} · ${known.join(', ')}`;
104
+ };
105
+ /**
106
+ * The control plane refuses a grant write made with the machine token, and every operator route to an Account
107
+ * token. Either way the remedy is the operator token: say so.
108
+ */
109
+ export const withOperatorHint = (message) => /machine token|no token reaches|operator’s|only the grant routes|not signed in/.test(message)
110
+ ? `${message} — sign in as an operator with: apex login --console`
111
+ : message;
@@ -2,7 +2,8 @@
2
2
  import { writeFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
- import { NotConfiguredError, apiUrlFrom, resolveCredential } from './credentials.js';
5
+ import { withPermissionHint } from '../domain/credentials.js';
6
+ import { NotConfiguredError, apiUrlFrom, resolveCredential, resolveOperatorCredential } from './credentials.js';
6
7
  export { NotConfiguredError } from './credentials.js';
7
8
  /** The API refused, with its status, so a use case can tell a 403 from a 500. */
8
9
  export class HttpError extends Error {
@@ -16,12 +17,15 @@ export class HttpError extends Error {
16
17
  /**
17
18
  * Adapter: the control-plane REST API over the platform's `fetch`.
18
19
  *
19
- * The credential is resolved in control-plane ADR-0064's order (see
20
- * `credentials.ts`): a customer's `APEX_TOKEN`, then the token `apex login`
21
- * saved, then the operator's `APEX_ADMIN_TOKEN` — the machine principal, which
22
- * keeps scripts and boot paths working when GitHub is unreachable (ADR-0006).
23
- * A customer token reaches only the secrets and variables routes and `/me`;
24
- * every other command answers with the server's 403, which says so.
20
+ * The credential is resolved in control-plane ADR-0064's order as this repo's
21
+ * ADR-0005 amends it (see `credentials.ts`): a customer's `APEX_TOKEN`, then
22
+ * the operator's `APEX_ADMIN_TOKEN` — the machine principal, which keeps
23
+ * scripts and boot paths working when GitHub is unreachable (ADR-0006) — then
24
+ * the token `apex login` saved. The environment outranks the file.
25
+ * A customer token reaches the repository routes its permissions cover, in its
26
+ * Account (control-plane ADR-0071); a route it lacks the grant for answers 403
27
+ * `this token needs <permission>`, which this client extends with the edit
28
+ * that would add it.
25
29
  *
26
30
  * The token goes in the `authorization` header and nowhere else: not in a
27
31
  * URL, not in an error, not in anything this class returns.
@@ -32,6 +36,8 @@ export class HttpControlPlane {
32
36
  #fetch;
33
37
  /** Where the credential came from — for `apex whoami`, which never shows the credential. */
34
38
  credentialSource;
39
+ /** Credentials that were present and lost to `credentialSource` — sources only, never values. */
40
+ credentialsAlsoPresent;
35
41
  constructor(config = {}) {
36
42
  if (config.token !== undefined) {
37
43
  if (config.token === '')
@@ -39,12 +45,14 @@ export class HttpControlPlane {
39
45
  this.#token = config.token;
40
46
  this.#baseUrl = (config.baseUrl ?? apiUrlFrom()).replace(/\/$/, '');
41
47
  this.credentialSource = 'env';
48
+ this.credentialsAlsoPresent = [];
42
49
  }
43
50
  else {
44
- const resolved = resolveCredential();
51
+ const resolved = config.operator === true ? resolveOperatorCredential() : resolveCredential();
45
52
  this.#token = resolved.token;
46
53
  this.#baseUrl = (config.baseUrl ?? resolved.apiUrl).replace(/\/$/, '');
47
54
  this.credentialSource = resolved.source;
55
+ this.credentialsAlsoPresent = resolved.alsoPresent ?? [];
48
56
  }
49
57
  this.#fetch = config.fetchImpl ?? fetch;
50
58
  }
@@ -57,7 +65,10 @@ export class HttpControlPlane {
57
65
  }
58
66
  // ── the person's tokens: read and revoke, never mint (ADR-0064, amended 2026-09-27) ──
59
67
  async listTokens() {
60
- return (await this.#json('GET', '/api/v1/me/tokens')).tokens;
68
+ const body = await this.#json('GET', '/api/v1/me/tokens');
69
+ if (!Array.isArray(body.tokens))
70
+ throw new HttpError('the control plane answered the token list without a list', 502);
71
+ return body.tokens;
61
72
  }
62
73
  async revokeToken(id) {
63
74
  const response = await this.#request('DELETE', `/api/v1/me/tokens/${encodeURIComponent(id)}`);
@@ -70,6 +81,14 @@ export class HttpControlPlane {
70
81
  async revokeAllTokens() {
71
82
  return (await this.#json('DELETE', '/api/v1/me/tokens')).revoked;
72
83
  }
84
+ async editToken(id, change) {
85
+ const response = await this.#request('PATCH', `/api/v1/me/tokens/${encodeURIComponent(id)}`, change);
86
+ if (response.status === 404)
87
+ return { kind: 'not-found' };
88
+ if (!response.ok)
89
+ return { kind: 'refused', status: response.status, detail: await this.#errorMessage(response) };
90
+ return { kind: 'edited', token: (await this.#decodeOk(response)) };
91
+ }
73
92
  // ── secrets and variables (values are write-only) ──
74
93
  async listSecrets(target) {
75
94
  const body = await this.#json('GET', `${this.#scopePath(target)}/secrets`);
@@ -145,38 +164,43 @@ export class HttpControlPlane {
145
164
  const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(id)}`);
146
165
  if (response.status === 404)
147
166
  return undefined;
148
- return (await this.#decode(response));
167
+ const body = (await this.#decodeOk(response));
168
+ // A success that is not a run is not iterated as one: say so, rather than crash on `jobs`.
169
+ if (body === null || typeof body !== 'object' || typeof body.run !== 'object' || body.run === null || !Array.isArray(body.jobs)) {
170
+ throw new HttpError(`the control plane's answer for run ${id} is not a run`, response.status);
171
+ }
172
+ return body;
149
173
  }
150
174
  async explainRun(runId) {
151
175
  const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/explain`);
152
176
  if (response.status === 404)
153
177
  return undefined;
154
- return (await this.#decode(response));
178
+ return (await this.#decodeOk(response));
155
179
  }
156
180
  async explainDelivery(deliveryId) {
157
181
  const response = await this.#request('GET', `/api/v1/deliveries/${encodeURIComponent(deliveryId)}/explain`);
158
182
  if (response.status === 404)
159
183
  return undefined;
160
- return (await this.#decode(response));
184
+ return (await this.#decodeOk(response));
161
185
  }
162
186
  async reproduction(runId, job) {
163
187
  const query = job === undefined ? '' : `?job=${encodeURIComponent(job)}`;
164
188
  const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/reproduction${query}`);
165
189
  if (response.status === 404)
166
190
  return undefined;
167
- return (await this.#decode(response));
191
+ return (await this.#decodeOk(response));
168
192
  }
169
193
  async runTiming(runId) {
170
194
  const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/timing`);
171
195
  if (response.status === 404)
172
196
  return undefined;
173
- return (await this.#decode(response));
197
+ return (await this.#decodeOk(response));
174
198
  }
175
199
  async runTests(runId) {
176
200
  const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/tests`);
177
201
  if (response.status === 404)
178
202
  return undefined;
179
- return (await this.#decode(response));
203
+ return (await this.#decodeOk(response));
180
204
  }
181
205
  async flakyTests(owner, repo) {
182
206
  const body = await this.#json('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/flaky-tests`);
@@ -223,6 +247,22 @@ export class HttpControlPlane {
223
247
  async accountsBackfill(input, apply) {
224
248
  return await this.#json('POST', `/api/v1/admin/accounts/backfill${apply ? '?apply=true' : ''}`, input);
225
249
  }
250
+ // ── complimentary access (control-plane ADR-0056, ADR-0079): the admin API's shapes, unchanged ──
251
+ async listGrants() {
252
+ return (await this.#json('GET', '/api/v1/admin/grants')).grants;
253
+ }
254
+ async createGrant(body) {
255
+ return await this.#json('POST', '/api/v1/admin/grants', body);
256
+ }
257
+ async updateGrant(id, body) {
258
+ return (await this.#json('PATCH', `/api/v1/admin/grants/${encodeURIComponent(id)}`, body)).grant;
259
+ }
260
+ async revokeGrant(id) {
261
+ return await this.#json('DELETE', `/api/v1/admin/grants/${encodeURIComponent(id)}`);
262
+ }
263
+ async listInstallations() {
264
+ return (await this.#json('GET', '/api/v1/admin/installations')).installations;
265
+ }
226
266
  async accountsUnconnected() {
227
267
  return (await this.#json('GET', '/api/v1/admin/accounts/unconnected')).installations;
228
268
  }
@@ -471,18 +511,51 @@ export class HttpControlPlane {
471
511
  async #decode(response) {
472
512
  return await response.json();
473
513
  }
474
- /** The API's own message, so a refusal explains itself. */
514
+ /**
515
+ * Decode a response that must be a success. Every read that answers
516
+ * `undefined` for a 404 used to decode whatever else came back — so a 403
517
+ * scope refusal became the value, and `apex logs <run-id>` crashed on
518
+ * `jobs is not iterable` instead of saying why (BUG-0189). The refusal is
519
+ * now thrown in the server's words, like every `#json` call.
520
+ */
521
+ async #decodeOk(response) {
522
+ if (!response.ok)
523
+ throw new HttpError(await this.#errorMessage(response), response.status);
524
+ try {
525
+ return await response.json();
526
+ }
527
+ catch {
528
+ throw new HttpError(`the control plane answered ${response.status} with a body that is not JSON`, response.status);
529
+ }
530
+ }
531
+ /** The API's own message, so a refusal explains itself — and a missing grant, how to add it. */
475
532
  async #errorMessage(response) {
476
533
  try {
477
534
  const problem = (await response.json());
478
- if (typeof problem.error === 'string')
479
- return problem.error;
535
+ if (typeof problem.error === 'string') {
536
+ // Only a missing grant costs another call; every other refusal is answered as it came.
537
+ return response.status === 403 && withPermissionHint(problem.error) !== problem.error ? await this.#hinted(problem.error) : problem.error;
538
+ }
480
539
  }
481
540
  catch {
482
541
  // a non-JSON body leaves the status
483
542
  }
484
543
  return `request failed with ${response.status}`;
485
544
  }
545
+ /**
546
+ * `this token needs <permission>`, with the `apex token edit` that adds it. Which token is asked of the server
547
+ * (`current` on the list), because the CLI holds the token and not its id; failing that, the hint says `<id>`.
548
+ */
549
+ async #hinted(message) {
550
+ const hinted = withPermissionHint(message);
551
+ try {
552
+ const current = (await this.listTokens()).find((t) => t.current === true)?.id;
553
+ return withPermissionHint(message, current);
554
+ }
555
+ catch {
556
+ return hinted;
557
+ }
558
+ }
486
559
  }
487
560
  /** Adapter: real time, injected so following a run is testable without it. */
488
561
  export class RealSleeper {
@@ -9,6 +9,7 @@ export const URL_ENV = 'APEX_API_URL';
9
9
  export const WEB_URL_ENV = 'APEX_WEB_URL';
10
10
  export const TOKEN_ENV = 'APEX_TOKEN';
11
11
  export const ADMIN_TOKEN_ENV = 'APEX_ADMIN_TOKEN';
12
+ export const CONSOLE_URL_ENV = 'APEX_CONSOLE_URL';
12
13
  const set = (value) => value !== undefined && value !== '';
13
14
  /** The control plane: `APEX_API_URL`, or the hosted service. */
14
15
  export const apiUrlFrom = (env = process.env) => normaliseUrl(set(env[URL_ENV]) ? env[URL_ENV] : DEFAULT_API_URL);
@@ -35,6 +36,11 @@ export class InsecureCredentialsError extends NotConfiguredError {
35
36
  }
36
37
  /** `$XDG_CONFIG_HOME/apex/credentials`, defaulting to `~/.config`. */
37
38
  export const credentialsPath = (env = process.env) => join(set(env['XDG_CONFIG_HOME']) ? env['XDG_CONFIG_HOME'] : join(homedir(), '.config'), 'apex', 'credentials');
39
+ /**
40
+ * `$XDG_CONFIG_HOME/apex/console-credentials`: where `apex login --console` keeps the operator token
41
+ * (control-plane ADR-0079) — beside the Account login, never in place of it.
42
+ */
43
+ export const consoleCredentialsPath = (env = process.env) => join(dirname(credentialsPath(env)), 'console-credentials');
38
44
  /**
39
45
  * Adapter: the credentials file `apex login` writes.
40
46
  *
@@ -99,30 +105,52 @@ export class FileCredentialStore {
99
105
  }
100
106
  }
101
107
  /**
102
- * Which credential the CLI uses, in ADR-0064's order:
108
+ * Which credential the CLI uses (control-plane ADR-0064's order, amended by
109
+ * this repo's ADR-0005 — the environment outranks the file):
103
110
  *
104
111
  * 1. `APEX_TOKEN` — a dashboard token, for CI and scripts;
105
- * 2. the token `apex login` saved — refused outright if the file is readable
112
+ * 2. `APEX_ADMIN_TOKEN` — the operator's. An explicitly exported variable is
113
+ * the more deliberate choice than a file somebody saved once, as `GH_TOKEN`
114
+ * outranks `gh`'s stored login; the old order let an operator shell act as
115
+ * whichever customer last ran `apex login` on the machine (BUG-0189);
116
+ * 3. the token `apex login` saved — refused outright if the file is readable
106
117
  * by others, skipped if it has expired or was minted by a different
107
118
  * control plane than the one `APEX_API_URL` names (a token is only ever
108
119
  * sent to the server that issued it);
109
- * 3. `APEX_ADMIN_TOKEN` — the operator's, unchanged;
110
120
  * 4. nothing: `NotConfiguredError`, naming `APEX_TOKEN` and `apex login`.
111
121
  */
112
122
  export const resolveCredential = (env = process.env, store = new FileCredentialStore(credentialsPath(env)), now = new Date()) => {
113
123
  const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
114
124
  const token = env[TOKEN_ENV];
115
- if (set(token))
116
- return { token, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'env' };
125
+ const admin = env[ADMIN_TOKEN_ENV];
126
+ /** A saved login this control plane would accept, read only to report it — an unreadable file is simply not one. */
127
+ const usableSaved = () => {
128
+ try {
129
+ const s = store.read();
130
+ return s !== undefined && !hasExpired(s, now) && (explicitUrl === undefined || explicitUrl === normaliseUrl(s.apiUrl));
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ };
136
+ const withOthers = (resolved, others) => others.length === 0 ? resolved : { ...resolved, alsoPresent: others };
137
+ if (set(token)) {
138
+ const others = [];
139
+ if (set(admin))
140
+ others.push('operator');
141
+ if (usableSaved())
142
+ others.push('login');
143
+ return withOthers({ token, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'env' }, others);
144
+ }
145
+ if (set(admin)) {
146
+ return withOthers({ token: admin, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'operator' }, usableSaved() ? ['login'] : []);
147
+ }
117
148
  const saved = store.read();
118
149
  if (saved !== undefined && !hasExpired(saved, now)) {
119
150
  const mintedBy = normaliseUrl(saved.apiUrl);
120
151
  if (explicitUrl === undefined || explicitUrl === mintedBy)
121
152
  return { token: saved.token, apiUrl: mintedBy, source: 'login' };
122
153
  }
123
- const admin = env[ADMIN_TOKEN_ENV];
124
- if (set(admin))
125
- return { token: admin, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'operator' };
126
154
  if (saved !== undefined && hasExpired(saved, now)) {
127
155
  throw new NotConfiguredError(`the token \`apex login\` saved expired at ${saved.expiresAt}: run \`apex login\` again, or set ${TOKEN_ENV}`);
128
156
  }
@@ -132,3 +160,20 @@ export const resolveCredential = (env = process.env, store = new FileCredentialS
132
160
  }
133
161
  throw new NotConfiguredError();
134
162
  };
163
+ /**
164
+ * Which credential an operator command — `apex grant`, `apex tenant installations` — uses (control-plane
165
+ * ADR-0079): the operator token `apex login --console` saved, when it is private, unexpired and was minted by
166
+ * the control plane `APEX_API_URL` names; otherwise the ordinary order ({@link resolveCredential}), in which
167
+ * the machine token can read grants and is refused every write. An expired operator token is skipped, and
168
+ * the refusal that follows says to sign in again.
169
+ */
170
+ export const resolveOperatorCredential = (env = process.env, consoleStore = new FileCredentialStore(consoleCredentialsPath(env)), fallback = () => resolveCredential(env), now = new Date()) => {
171
+ const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
172
+ const saved = consoleStore.read();
173
+ if (saved !== undefined && !hasExpired(saved, now)) {
174
+ const mintedBy = normaliseUrl(saved.apiUrl);
175
+ if (explicitUrl === undefined || explicitUrl === mintedBy)
176
+ return { token: saved.token, apiUrl: mintedBy, source: 'console' };
177
+ }
178
+ return fallback();
179
+ };
@@ -0,0 +1,82 @@
1
+ // infrastructure layer — see ../../../../docs/conventions.md §1
2
+ import { existsSync } from 'node:fs';
3
+ import { hostname, platform, release, type } from 'node:os';
4
+ import { headlessReason, isDevicePollError, normaliseUrl } from '../domain/credentials.js';
5
+ import { redeemedFrom } from './loopback-login.js';
6
+ /**
7
+ * The device flow's adapters (control-plane ADR-0075): the two calls, what this machine says about
8
+ * itself, and whether a browser can open here at all.
9
+ */
10
+ /** Why no browser can open here, read from this process's environment; `undefined` when one can. */
11
+ export const detectHeadless = () => headlessReason({ env: process.env, platform: process.platform, dockerenv: existsSync('/.dockerenv') });
12
+ /** What the approval page shows as "reported by the terminal": this machine's name and system. */
13
+ export const machineReport = () => ({
14
+ hostname: hostname().slice(0, 100),
15
+ os: `${type()} ${release()} (${platform()})`.slice(0, 100),
16
+ });
17
+ export const sleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms));
18
+ /** Adapter: `POST /api/v1/cli/device` and the `{ deviceCode }` poll of `POST /api/v1/cli/token`. No credential either way. */
19
+ export class HttpDeviceExchange {
20
+ #baseUrl;
21
+ #fetch;
22
+ constructor(baseUrl, fetchImpl = fetch) {
23
+ this.#baseUrl = normaliseUrl(baseUrl);
24
+ this.#fetch = fetchImpl;
25
+ }
26
+ async start(request) {
27
+ const body = await this.#post('/api/v1/cli/device', {
28
+ name: request.name,
29
+ purpose: request.purpose,
30
+ ...(request.days === undefined ? {} : { expiresInDays: request.days }),
31
+ ...(request.repos === undefined ? {} : { repos: request.repos }),
32
+ ...(request.permissions === undefined ? {} : { permissions: request.permissions }),
33
+ ...(request.accountId === undefined ? {} : { accountId: request.accountId }),
34
+ ...(request.tokenId === undefined ? {} : { tokenId: request.tokenId }),
35
+ ...(request.allRepositories === true ? { allRepositories: true } : {}),
36
+ ...(request.os === undefined ? {} : { os: request.os }),
37
+ ...(request.hostname === undefined ? {} : { hostname: request.hostname }),
38
+ });
39
+ if (!body.ok) {
40
+ if (body.status === 429)
41
+ throw new Error('too many device logins from this address this hour — wait, or approve in a browser on this machine with --browser');
42
+ throw new Error(typeof body.json['error'] === 'string' ? body.json['error'] : `starting the device login failed with ${body.status}`);
43
+ }
44
+ const j = body.json;
45
+ if (typeof j['deviceCode'] !== 'string' || typeof j['userCode'] !== 'string' || typeof j['verificationUri'] !== 'string') {
46
+ throw new Error('the control plane answered the device login without a code — is it older than this CLI?');
47
+ }
48
+ return {
49
+ deviceCode: j['deviceCode'],
50
+ userCode: j['userCode'],
51
+ verificationUri: j['verificationUri'],
52
+ expiresIn: typeof j['expiresIn'] === 'number' ? j['expiresIn'] : 600,
53
+ interval: typeof j['interval'] === 'number' ? j['interval'] : 5,
54
+ };
55
+ }
56
+ async poll(deviceCode) {
57
+ const body = await this.#post('/api/v1/cli/token', { deviceCode });
58
+ if (body.ok)
59
+ return { kind: 'minted', minted: redeemedFrom(body.json) };
60
+ const error = body.json['error'];
61
+ const interval = body.json['interval'];
62
+ if (body.status === 400 && isDevicePollError(error))
63
+ return { kind: error, ...(typeof interval === 'number' ? { interval } : {}) };
64
+ throw new Error(typeof error === 'string' ? error : `polling the device login failed with ${body.status}`);
65
+ }
66
+ async #post(path, payload) {
67
+ let response;
68
+ try {
69
+ response = await this.#fetch(`${this.#baseUrl}${path}`, {
70
+ method: 'POST',
71
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
72
+ body: JSON.stringify(payload),
73
+ signal: AbortSignal.timeout(30_000),
74
+ });
75
+ }
76
+ catch (err) {
77
+ throw new Error(`${this.#baseUrl} is unreachable: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
78
+ }
79
+ const json = (await response.json().catch(() => ({})));
80
+ return { ok: response.ok, status: response.status, json };
81
+ }
82
+ }
@@ -41,6 +41,8 @@ export const listenForCallback = async (expectedState, timeoutMs = LOGIN_TIMEOUT
41
41
  settle = resolve;
42
42
  });
43
43
  let done = false;
44
+ /** The browser's response for a `code` or `denied` callback, held until `conclude` says where it goes. */
45
+ let held;
44
46
  const finish = (result) => {
45
47
  if (done)
46
48
  return;
@@ -56,10 +58,9 @@ export const listenForCallback = async (expectedState, timeoutMs = LOGIN_TIMEOUT
56
58
  const verdict = judgeCallback(url.searchParams, expectedState);
57
59
  switch (verdict.kind) {
58
60
  case 'code':
59
- answer(res, 200, page('Approved', 'The <code>apex</code> CLI has what you approved. You can close this tab.'));
60
- break;
61
61
  case 'denied':
62
- answer(res, 200, page('Declined', 'The request was declined. You can close this tab.'));
62
+ // Answered by `conclude` once the code is redeemed — a 303 to the web app's bare done page.
63
+ held = res;
63
64
  break;
64
65
  case 'state-mismatch':
65
66
  answer(res, 400, page('Not signed in', 'This callback does not belong to the login waiting in your terminal, so it was refused.'));
@@ -79,8 +80,24 @@ export const listenForCallback = async (expectedState, timeoutMs = LOGIN_TIMEOUT
79
80
  return {
80
81
  port: server.address().port,
81
82
  wait: async () => await settled,
83
+ conclude: async (location) => {
84
+ const res = held;
85
+ held = undefined;
86
+ if (res === undefined)
87
+ return;
88
+ await new Promise((resolve) => {
89
+ res.once('close', () => resolve());
90
+ res.writeHead(303, { location, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', connection: 'close' });
91
+ res.end(() => resolve());
92
+ });
93
+ },
82
94
  close: async () => {
83
95
  clearTimeout(timer);
96
+ // A held response nobody concluded (a caller that threw) still gets an answer rather than a hung tab.
97
+ if (held !== undefined) {
98
+ answer(held, 200, page('Finished', 'Return to your terminal to see how the <code>apex</code> command ended. You can close this tab.'));
99
+ held = undefined;
100
+ }
84
101
  finish({ kind: 'timeout' });
85
102
  server.closeAllConnections();
86
103
  await new Promise((resolve) => server.close(() => resolve()));
@@ -127,17 +144,39 @@ export class HttpLoginExchange {
127
144
  }
128
145
  const body = (await response.json().catch(() => ({})));
129
146
  if (!response.ok)
130
- throw new Error(typeof body.error === 'string' ? body.error : `the code exchange failed with ${response.status}`);
131
- if (typeof body.token !== 'string' || typeof body.login !== 'string' || typeof body.expiresAt !== 'string') {
132
- throw new Error('the control plane answered the code exchange without a token');
133
- }
134
- return {
135
- token: body.token,
136
- login: body.login,
137
- expiresAt: body.expiresAt,
138
- ...(typeof body.id === 'string' ? { id: body.id } : {}),
139
- ...(typeof body.name === 'string' ? { name: body.name } : {}),
140
- ...(Array.isArray(body.repos) ? { repos: body.repos.filter((r) => typeof r === 'string') } : {}),
141
- };
147
+ throw new Error(typeof body['error'] === 'string' ? body['error'] : `the code exchange failed with ${response.status}`);
148
+ return redeemedFrom(body);
142
149
  }
143
150
  }
151
+ /**
152
+ * What `POST /api/v1/cli/token` minted, from its answer — shared by the loopback's redemption and the
153
+ * device flow's poll, which end in the same token. Throws when the answer names nobody.
154
+ */
155
+ export const redeemedFrom = (body) => {
156
+ if (typeof body['login'] !== 'string' || typeof body['expiresAt'] !== 'string') {
157
+ throw new Error('the control plane answered the code exchange without a token');
158
+ }
159
+ const str = (k) => {
160
+ const v = body[k];
161
+ return typeof v === 'string' ? { [k]: v } : {};
162
+ };
163
+ const list = (k) => {
164
+ const v = body[k];
165
+ return Array.isArray(v) ? { [k]: v.filter((r) => typeof r === 'string') } : {};
166
+ };
167
+ const source = body['source'];
168
+ return {
169
+ login: body['login'],
170
+ expiresAt: body['expiresAt'],
171
+ // An edit's answer carries no token: it changed one and minted nothing. The caller that needs one says so.
172
+ ...str('token'),
173
+ ...str('id'),
174
+ ...str('name'),
175
+ ...str('accountId'),
176
+ ...str('last4'),
177
+ ...str('createdAt'),
178
+ ...list('repos'),
179
+ ...list('permissions'),
180
+ ...(source === 'dashboard' || source === 'login' || source === 'cli' ? { source } : {}),
181
+ };
182
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-actions/cli",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "//files": "Without this npm ships src, test and the CI config alongside the binary a user installs.",
6
6
  "files": [