@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.
- package/README.md +48 -12
- package/dist/cli/grant/main.js +141 -0
- package/dist/cli/login/device.js +43 -0
- package/dist/cli/login/main.js +110 -25
- package/dist/cli/logout/main.js +16 -8
- package/dist/cli/program.js +2 -0
- package/dist/cli/reference.js +211 -39
- package/dist/cli/spend/main.js +1 -1
- package/dist/cli/tenant/main.js +38 -2
- package/dist/cli/token/main.js +156 -32
- package/dist/cli/whoami/main.js +44 -2
- package/dist/src/application/authenticate.js +104 -7
- package/dist/src/application/manage-grants.js +21 -0
- package/dist/src/application/manage-tokens.js +84 -4
- package/dist/src/domain/credentials.js +185 -17
- package/dist/src/domain/grants.js +111 -0
- package/dist/src/infrastructure/control-plane-http.js +91 -18
- package/dist/src/infrastructure/credentials.js +53 -8
- package/dist/src/infrastructure/device-login.js +82 -0
- package/dist/src/infrastructure/loopback-login.js +54 -15
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
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
|
-
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
|
-
|
|
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,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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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
|
+
};
|