@apex-actions/cli 0.74.0 → 0.75.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.
@@ -51,9 +51,18 @@ export const parseSavedCredential = (raw) => {
51
51
  return { token: r['token'], login: r['login'], apiUrl: r['apiUrl'], expiresAt: r['expiresAt'] };
52
52
  };
53
53
  /**
54
- * The consent page `apex login` and `apex token create` open (RFC 8252
55
- * loopback, RFC 7636 S256). A token request adds `purpose=token` and what the
56
- * person is approving, so the page can show it and the code carries it.
54
+ * The consent page `apex login`, `apex token create` and a widening `apex token
55
+ * edit` open (RFC 8252 loopback, RFC 7636 S256). The query is the contract with
56
+ * the web app's `/cli/authorize` page:
57
+ *
58
+ * - `port`, `state`, `code_challenge`, `name` — always;
59
+ * - `purpose` — `token` or `edit` (absent for a login);
60
+ * - `days`, `repos` (comma-separated `owner/repo`) — a token's lifetime and scope; for an edit, `repos` absent
61
+ * keeps the token's own repositories;
62
+ * - `all_repos=1` — an edit that widens to every repository (never sent with `repos`);
63
+ * - `permission` — repeated, once per grant (`actions:read`, `secrets:write`, …);
64
+ * - `account` — the Account id, for a login or a token;
65
+ * - `token` — the token id an edit widens.
57
66
  */
58
67
  export const authorizeUrl = (webBase, request) => {
59
68
  const query = new URLSearchParams({
@@ -62,27 +71,55 @@ export const authorizeUrl = (webBase, request) => {
62
71
  code_challenge: request.codeChallenge,
63
72
  name: request.name,
64
73
  });
65
- if (request.token !== undefined) {
66
- query.set('purpose', 'token');
67
- if (request.token.days !== undefined)
68
- query.set('days', String(request.token.days));
69
- if (request.token.repos !== undefined && request.token.repos.length > 0)
70
- query.set('repos', request.token.repos.join(','));
74
+ const t = request.token;
75
+ if (t !== undefined) {
76
+ query.set('purpose', t.purpose ?? 'token');
77
+ if (t.days !== undefined)
78
+ query.set('days', String(t.days));
79
+ if (t.allRepos === true)
80
+ query.set('all_repos', '1');
81
+ else if (t.repos !== undefined && t.repos.length > 0)
82
+ query.set('repos', t.repos.join(','));
83
+ for (const p of t.permissions ?? [])
84
+ query.append('permission', p);
85
+ if (t.accountId !== undefined)
86
+ query.set('account', t.accountId);
87
+ if (t.tokenId !== undefined)
88
+ query.set('token', t.tokenId);
89
+ }
90
+ else if (request.accountId !== undefined) {
91
+ query.set('account', request.accountId);
71
92
  }
72
93
  return `${normaliseUrl(webBase)}/cli/authorize?${query.toString()}`;
73
94
  };
95
+ /**
96
+ * Where the loopback callback sends the browser once the code is redeemed (control-plane ADR-0075): the
97
+ * web app's bare `/cli/done`, so the tab ends on our domain rather than on this process's `127.0.0.1`.
98
+ * The page is copy only and confirms nothing server-side, so it carries only the outcome and the purpose
99
+ * — never the code, the state or anything else the callback held.
100
+ */
101
+ export const doneUrl = (webBase, outcome, purpose) => `${normaliseUrl(webBase)}/cli/done?${new URLSearchParams({ outcome, purpose }).toString()}`;
74
102
  /** A token's lifetime bounds (control-plane ADR-0064): every token expires, 90 days at most. */
75
103
  export const MAX_TOKEN_DAYS = 90;
76
104
  /** Repositories one token may be held to. */
77
105
  export const MAX_TOKEN_REPOS = 100;
78
106
  const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
79
- /** `apex token create`'s flags, checked before a browser opens — the control plane checks them again. */
80
- export const tokenRequestProblem = (name, days, repos) => {
81
- if (name.trim() === '' || name.trim().length > 100)
82
- return 'a token needs a name of 1 to 100 characters';
83
- if (days !== undefined && !(Number.isInteger(days) && days >= 1 && days <= MAX_TOKEN_DAYS)) {
84
- return `--expires-in-days must be a whole number from 1 to ${MAX_TOKEN_DAYS}`;
85
- }
107
+ /**
108
+ * What a token may be granted (context ADR-0038): `resource:read|write`, write implying read. The control plane's
109
+ * `RepoPermission`, in the order a person reads them.
110
+ */
111
+ export const TOKEN_RESOURCES = ['actions', 'approvals', 'deployments', 'secrets', 'variables', 'environments', 'notifications', 'administration'];
112
+ export const TOKEN_PERMISSIONS = TOKEN_RESOURCES.flatMap((r) => [`${r}:read`, `${r}:write`]);
113
+ /** `--permission`'s values: at least one when `required`, each one a known grant. */
114
+ export const permissionsProblem = (permissions, required) => {
115
+ if (required && permissions.length === 0)
116
+ return 'a token needs at least one --permission, e.g. --permission actions:read';
117
+ const bad = permissions.filter((p) => !TOKEN_PERMISSIONS.includes(p));
118
+ if (bad.length > 0)
119
+ return `not a permission: ${bad.join(', ')} — each is resource:read or resource:write, resource one of ${TOKEN_RESOURCES.join(', ')}`;
120
+ return undefined;
121
+ };
122
+ const reposProblem = (repos) => {
86
123
  if (repos.length > MAX_TOKEN_REPOS)
87
124
  return `a token may name at most ${MAX_TOKEN_REPOS} repositories`;
88
125
  const bad = repos.filter((r) => !REPO.test(r));
@@ -90,6 +127,68 @@ export const tokenRequestProblem = (name, days, repos) => {
90
127
  return `not owner/repo: ${bad.join(', ')}`;
91
128
  return undefined;
92
129
  };
130
+ /**
131
+ * `apex token create`'s flags, checked before a browser opens — the control plane checks them again. `permissions`,
132
+ * when given, must name at least one grant.
133
+ */
134
+ export const tokenRequestProblem = (name, days, repos, permissions) => {
135
+ if (name.trim() === '' || name.trim().length > 100)
136
+ return 'a token needs a name of 1 to 100 characters';
137
+ if (days !== undefined && !(Number.isInteger(days) && days >= 1 && days <= MAX_TOKEN_DAYS)) {
138
+ return `--expires-in-days must be a whole number from 1 to ${MAX_TOKEN_DAYS}`;
139
+ }
140
+ return reposProblem(repos) ?? (permissions === undefined ? undefined : permissionsProblem(permissions, true));
141
+ };
142
+ /** `apex token edit`'s flags: something to change, and each change well-formed. */
143
+ export const tokenEditProblem = (edit) => {
144
+ if (edit.permissions === undefined && edit.add.length === 0 && edit.remove.length === 0 && edit.repos === undefined) {
145
+ return 'nothing to change: pass --permission, --add-permission, --remove-permission, --repo or --all-repos';
146
+ }
147
+ if (edit.permissions !== undefined && (edit.add.length > 0 || edit.remove.length > 0))
148
+ return 'pass --permission for the whole set, or --add-permission/--remove-permission, not both';
149
+ return ((edit.permissions === undefined ? undefined : permissionsProblem(edit.permissions, true)) ??
150
+ permissionsProblem([...edit.add, ...edit.remove], false) ??
151
+ (edit.repos === undefined || edit.repos === 'all' ? undefined : reposProblem(edit.repos)));
152
+ };
153
+ /** Whether `current` already grants `asked` — itself, or its write when a read is asked (write implies read). */
154
+ export const covers = (current, asked) => {
155
+ if (current.includes(asked))
156
+ return true;
157
+ const [resource, level] = asked.split(':');
158
+ return level === 'read' && current.includes(`${resource ?? ''}:write`);
159
+ };
160
+ /**
161
+ * The grants an edit asks for: the whole new set, or the current set with additions (skipping any already covered)
162
+ * and removals. Removing a write keeps nothing of it — ask for the read to keep reading.
163
+ */
164
+ export const editedPermissions = (current, edit) => {
165
+ if (edit.permissions !== undefined)
166
+ return [...new Set(edit.permissions)];
167
+ const out = current.filter((p) => !edit.remove.includes(p));
168
+ for (const p of edit.add)
169
+ if (!covers(out, p))
170
+ out.push(p);
171
+ return out;
172
+ };
173
+ /** A grant set as one short phrase: `every permission` for all of them, otherwise the grants as given. */
174
+ export const describeGrants = (permissions) => {
175
+ if (permissions === undefined || permissions.length === 0)
176
+ return 'none';
177
+ if (TOKEN_RESOURCES.every((r) => permissions.includes(`${r}:write`)))
178
+ return 'every permission';
179
+ return [...permissions].sort((a, b) => TOKEN_PERMISSIONS.indexOf(a) - TOKEN_PERMISSIONS.indexOf(b)).join(', ');
180
+ };
181
+ const NEEDS = /^this token needs ([a-z]+:(?:read|write))$/;
182
+ /**
183
+ * The control plane refuses a token a route its grants do not cover with `this token needs <permission>`. Say how to
184
+ * get it — an edit that adds the grant, which a browser approves — naming the token when the CLI knows which it is.
185
+ */
186
+ export const withPermissionHint = (message, tokenId) => {
187
+ const m = NEEDS.exec(message);
188
+ if (m === null)
189
+ return message;
190
+ return `${message} — add it with: apex token edit ${tokenId ?? '<id>'} --add-permission ${m[1] ?? ''}${tokenId === undefined ? ' (apex token list shows the id)' : ''}`;
191
+ };
93
192
  /**
94
193
  * Judge what arrived at the loopback callback.
95
194
  *
@@ -2,6 +2,7 @@
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 { withPermissionHint } from '../domain/credentials.js';
5
6
  import { NotConfiguredError, apiUrlFrom, resolveCredential } 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. */
@@ -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
51
  const resolved = 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`);
@@ -471,18 +495,51 @@ export class HttpControlPlane {
471
495
  async #decode(response) {
472
496
  return await response.json();
473
497
  }
474
- /** The API's own message, so a refusal explains itself. */
498
+ /**
499
+ * Decode a response that must be a success. Every read that answers
500
+ * `undefined` for a 404 used to decode whatever else came back — so a 403
501
+ * scope refusal became the value, and `apex logs <run-id>` crashed on
502
+ * `jobs is not iterable` instead of saying why (BUG-0189). The refusal is
503
+ * now thrown in the server's words, like every `#json` call.
504
+ */
505
+ async #decodeOk(response) {
506
+ if (!response.ok)
507
+ throw new HttpError(await this.#errorMessage(response), response.status);
508
+ try {
509
+ return await response.json();
510
+ }
511
+ catch {
512
+ throw new HttpError(`the control plane answered ${response.status} with a body that is not JSON`, response.status);
513
+ }
514
+ }
515
+ /** The API's own message, so a refusal explains itself — and a missing grant, how to add it. */
475
516
  async #errorMessage(response) {
476
517
  try {
477
518
  const problem = (await response.json());
478
- if (typeof problem.error === 'string')
479
- return problem.error;
519
+ if (typeof problem.error === 'string') {
520
+ // Only a missing grant costs another call; every other refusal is answered as it came.
521
+ return response.status === 403 && withPermissionHint(problem.error) !== problem.error ? await this.#hinted(problem.error) : problem.error;
522
+ }
480
523
  }
481
524
  catch {
482
525
  // a non-JSON body leaves the status
483
526
  }
484
527
  return `request failed with ${response.status}`;
485
528
  }
529
+ /**
530
+ * `this token needs <permission>`, with the `apex token edit` that adds it. Which token is asked of the server
531
+ * (`current` on the list), because the CLI holds the token and not its id; failing that, the hint says `<id>`.
532
+ */
533
+ async #hinted(message) {
534
+ const hinted = withPermissionHint(message);
535
+ try {
536
+ const current = (await this.listTokens()).find((t) => t.current === true)?.id;
537
+ return withPermissionHint(message, current);
538
+ }
539
+ catch {
540
+ return hinted;
541
+ }
542
+ }
486
543
  }
487
544
  /** Adapter: real time, injected so following a run is testable without it. */
488
545
  export class RealSleeper {
@@ -99,30 +99,52 @@ export class FileCredentialStore {
99
99
  }
100
100
  }
101
101
  /**
102
- * Which credential the CLI uses, in ADR-0064's order:
102
+ * Which credential the CLI uses (control-plane ADR-0064's order, amended by
103
+ * this repo's ADR-0005 — the environment outranks the file):
103
104
  *
104
105
  * 1. `APEX_TOKEN` — a dashboard token, for CI and scripts;
105
- * 2. the token `apex login` saved — refused outright if the file is readable
106
+ * 2. `APEX_ADMIN_TOKEN` — the operator's. An explicitly exported variable is
107
+ * the more deliberate choice than a file somebody saved once, as `GH_TOKEN`
108
+ * outranks `gh`'s stored login; the old order let an operator shell act as
109
+ * whichever customer last ran `apex login` on the machine (BUG-0189);
110
+ * 3. the token `apex login` saved — refused outright if the file is readable
106
111
  * by others, skipped if it has expired or was minted by a different
107
112
  * control plane than the one `APEX_API_URL` names (a token is only ever
108
113
  * sent to the server that issued it);
109
- * 3. `APEX_ADMIN_TOKEN` — the operator's, unchanged;
110
114
  * 4. nothing: `NotConfiguredError`, naming `APEX_TOKEN` and `apex login`.
111
115
  */
112
116
  export const resolveCredential = (env = process.env, store = new FileCredentialStore(credentialsPath(env)), now = new Date()) => {
113
117
  const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
114
118
  const token = env[TOKEN_ENV];
115
- if (set(token))
116
- return { token, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'env' };
119
+ const admin = env[ADMIN_TOKEN_ENV];
120
+ /** A saved login this control plane would accept, read only to report it — an unreadable file is simply not one. */
121
+ const usableSaved = () => {
122
+ try {
123
+ const s = store.read();
124
+ return s !== undefined && !hasExpired(s, now) && (explicitUrl === undefined || explicitUrl === normaliseUrl(s.apiUrl));
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ };
130
+ const withOthers = (resolved, others) => others.length === 0 ? resolved : { ...resolved, alsoPresent: others };
131
+ if (set(token)) {
132
+ const others = [];
133
+ if (set(admin))
134
+ others.push('operator');
135
+ if (usableSaved())
136
+ others.push('login');
137
+ return withOthers({ token, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'env' }, others);
138
+ }
139
+ if (set(admin)) {
140
+ return withOthers({ token: admin, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'operator' }, usableSaved() ? ['login'] : []);
141
+ }
117
142
  const saved = store.read();
118
143
  if (saved !== undefined && !hasExpired(saved, now)) {
119
144
  const mintedBy = normaliseUrl(saved.apiUrl);
120
145
  if (explicitUrl === undefined || explicitUrl === mintedBy)
121
146
  return { token: saved.token, apiUrl: mintedBy, source: 'login' };
122
147
  }
123
- const admin = env[ADMIN_TOKEN_ENV];
124
- if (set(admin))
125
- return { token: admin, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'operator' };
126
148
  if (saved !== undefined && hasExpired(saved, now)) {
127
149
  throw new NotConfiguredError(`the token \`apex login\` saved expired at ${saved.expiresAt}: run \`apex login\` again, or set ${TOKEN_ENV}`);
128
150
  }
@@ -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,32 @@ 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') {
147
+ throw new Error(typeof body['error'] === 'string' ? body['error'] : `the code exchange failed with ${response.status}`);
148
+ if (typeof body['login'] !== 'string' || typeof body['expiresAt'] !== 'string') {
132
149
  throw new Error('the control plane answered the code exchange without a token');
133
150
  }
151
+ const str = (k) => {
152
+ const v = body[k];
153
+ return typeof v === 'string' ? { [k]: v } : {};
154
+ };
155
+ const list = (k) => {
156
+ const v = body[k];
157
+ return Array.isArray(v) ? { [k]: v.filter((r) => typeof r === 'string') } : {};
158
+ };
159
+ const source = body['source'];
134
160
  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') } : {}),
161
+ login: body['login'],
162
+ expiresAt: body['expiresAt'],
163
+ // An edit's answer carries no token: it changed one and minted nothing. The caller that needs one says so.
164
+ ...str('token'),
165
+ ...str('id'),
166
+ ...str('name'),
167
+ ...str('accountId'),
168
+ ...str('last4'),
169
+ ...str('createdAt'),
170
+ ...list('repos'),
171
+ ...list('permissions'),
172
+ ...(source === 'dashboard' || source === 'login' || source === 'cli' ? { source } : {}),
141
173
  };
142
174
  }
143
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-actions/cli",
3
- "version": "0.74.0",
3
+ "version": "0.75.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": [