@apex-actions/cli 0.73.1 → 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.
- package/README.md +29 -11
- package/dist/cli/environment/main.js +2 -2
- package/dist/cli/login/main.js +18 -6
- package/dist/cli/logout/main.js +5 -3
- package/dist/cli/reference.js +197 -38
- package/dist/cli/spend/main.js +1 -1
- package/dist/cli/tenant/main.js +60 -4
- package/dist/cli/token/main.js +132 -29
- package/dist/cli/whoami/main.js +44 -2
- package/dist/src/application/authenticate.js +24 -8
- package/dist/src/application/manage-accounts.js +22 -0
- package/dist/src/application/manage-environments.js +7 -4
- package/dist/src/application/manage-tokens.js +83 -3
- package/dist/src/domain/accounts.js +11 -0
- package/dist/src/domain/credentials.js +115 -16
- package/dist/src/infrastructure/control-plane-http.js +79 -16
- package/dist/src/infrastructure/credentials.js +30 -8
- package/dist/src/infrastructure/loopback-login.js +43 -11
- package/package.json +1 -1
|
@@ -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
|
|
55
|
-
* loopback, RFC 7636 S256).
|
|
56
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
20
|
-
* `credentials.ts`): a customer's `APEX_TOKEN`, then
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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,12 @@ 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
|
+
async accountsUnconnected() {
|
|
251
|
+
return (await this.#json('GET', '/api/v1/admin/accounts/unconnected')).installations;
|
|
252
|
+
}
|
|
253
|
+
async accountsConnect(input) {
|
|
254
|
+
return await this.#json('POST', '/api/v1/admin/accounts/connect', input);
|
|
255
|
+
}
|
|
226
256
|
async actionPolicy(owner) {
|
|
227
257
|
return await this.#json('GET', `/api/v1/orgs/${encodeURIComponent(owner)}/action-policy`);
|
|
228
258
|
}
|
|
@@ -465,18 +495,51 @@ export class HttpControlPlane {
|
|
|
465
495
|
async #decode(response) {
|
|
466
496
|
return await response.json();
|
|
467
497
|
}
|
|
468
|
-
/**
|
|
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. */
|
|
469
516
|
async #errorMessage(response) {
|
|
470
517
|
try {
|
|
471
518
|
const problem = (await response.json());
|
|
472
|
-
if (typeof problem.error === 'string')
|
|
473
|
-
|
|
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
|
+
}
|
|
474
523
|
}
|
|
475
524
|
catch {
|
|
476
525
|
// a non-JSON body leaves the status
|
|
477
526
|
}
|
|
478
527
|
return `request failed with ${response.status}`;
|
|
479
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
|
+
}
|
|
480
543
|
}
|
|
481
544
|
/** Adapter: real time, injected so following a run is testable without it. */
|
|
482
545
|
export class RealSleeper {
|
|
@@ -99,30 +99,52 @@ export class FileCredentialStore {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Which credential the CLI uses
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
if (typeof body
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
...(
|
|
139
|
-
...(
|
|
140
|
-
...(
|
|
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
|
}
|