@apex-actions/cli 0.67.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/LICENSE +20 -0
- package/README.md +84 -0
- package/THIRD-PARTY-NOTICES.md +1226 -0
- package/dist/cli/actions/main.js +193 -0
- package/dist/cli/approval/main.js +76 -0
- package/dist/cli/artifact/main.js +115 -0
- package/dist/cli/cache/main.js +104 -0
- package/dist/cli/deployment/main.js +73 -0
- package/dist/cli/dispatch/main.js +47 -0
- package/dist/cli/environment/main.js +139 -0
- package/dist/cli/login/main.js +73 -0
- package/dist/cli/logout/main.js +43 -0
- package/dist/cli/logs/main.js +125 -0
- package/dist/cli/lsp/main.js +46 -0
- package/dist/cli/main.js +88 -0
- package/dist/cli/migrate/main.js +275 -0
- package/dist/cli/notify/main.js +257 -0
- package/dist/cli/oidc/main.js +103 -0
- package/dist/cli/reproduce/main.js +73 -0
- package/dist/cli/rerun/main.js +100 -0
- package/dist/cli/run/main.js +50 -0
- package/dist/cli/runner/main.js +238 -0
- package/dist/cli/schedule/main.js +54 -0
- package/dist/cli/search/main.js +123 -0
- package/dist/cli/secret/main.js +102 -0
- package/dist/cli/secret/shared.js +104 -0
- package/dist/cli/spend/main.js +81 -0
- package/dist/cli/terms/main.js +85 -0
- package/dist/cli/tests/main.js +122 -0
- package/dist/cli/timing/main.js +90 -0
- package/dist/cli/validate/main.js +48 -0
- package/dist/cli/variable/main.js +83 -0
- package/dist/cli/whoami/main.js +63 -0
- package/dist/cli/why/main.js +106 -0
- package/dist/src/application/authenticate.js +56 -0
- package/dist/src/application/dispatch-workflow.js +23 -0
- package/dist/src/application/explain-runs.js +40 -0
- package/dist/src/application/index.js +2 -0
- package/dist/src/application/inspect-runs.js +29 -0
- package/dist/src/application/manage-actions.js +26 -0
- package/dist/src/application/manage-approvals.js +24 -0
- package/dist/src/application/manage-artifacts.js +79 -0
- package/dist/src/application/manage-caches.js +41 -0
- package/dist/src/application/manage-environments.js +136 -0
- package/dist/src/application/manage-notifications.js +20 -0
- package/dist/src/application/manage-runners.js +89 -0
- package/dist/src/application/manage-secrets.js +83 -0
- package/dist/src/application/manage-terms.js +34 -0
- package/dist/src/application/read-logs.js +40 -0
- package/dist/src/application/report-spend.js +15 -0
- package/dist/src/application/rerun-runs.js +34 -0
- package/dist/src/application/resolve-run.js +19 -0
- package/dist/src/application/run-workflow.js +17 -0
- package/dist/src/application/scan-migration.js +76 -0
- package/dist/src/application/search-logs.js +24 -0
- package/dist/src/application/serve-language-server.js +15 -0
- package/dist/src/application/validate-workflows.js +12 -0
- package/dist/src/domain/credentials.js +82 -0
- package/dist/src/domain/fleet-history.js +136 -0
- package/dist/src/domain/index.js +2 -0
- package/dist/src/domain/insight.js +42 -0
- package/dist/src/domain/logsearch.js +40 -0
- package/dist/src/domain/migration.js +122 -0
- package/dist/src/domain/notify.js +64 -0
- package/dist/src/domain/outcomediff.js +40 -0
- package/dist/src/domain/provenance.js +63 -0
- package/dist/src/domain/runs.js +90 -0
- package/dist/src/domain/secrets.js +39 -0
- package/dist/src/domain/spend.js +50 -0
- package/dist/src/domain/supplychain.js +48 -0
- package/dist/src/domain/terms.js +16 -0
- package/dist/src/domain/validation.js +4 -0
- package/dist/src/infrastructure/control-plane-http.js +458 -0
- package/dist/src/infrastructure/credentials.js +134 -0
- package/dist/src/infrastructure/engine-binary.js +164 -0
- package/dist/src/infrastructure/github-api.js +154 -0
- package/dist/src/infrastructure/index.js +2 -0
- package/dist/src/infrastructure/loopback-login.js +136 -0
- package/dist/src/infrastructure/runner-binary.js +74 -0
- package/dist/src/infrastructure/secret-input.js +52 -0
- package/package.json +54 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { NotConfiguredError, apiUrlFrom, resolveCredential } from './credentials.js';
|
|
6
|
+
export { NotConfiguredError } from './credentials.js';
|
|
7
|
+
/** The API refused, with its status, so a use case can tell a 403 from a 500. */
|
|
8
|
+
export class HttpError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'HttpError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Adapter: the control-plane REST API over the platform's `fetch`.
|
|
18
|
+
*
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* The token goes in the `authorization` header and nowhere else: not in a
|
|
27
|
+
* URL, not in an error, not in anything this class returns.
|
|
28
|
+
*/
|
|
29
|
+
export class HttpControlPlane {
|
|
30
|
+
#baseUrl;
|
|
31
|
+
#token;
|
|
32
|
+
#fetch;
|
|
33
|
+
/** Where the credential came from — for `apex whoami`, which never shows the credential. */
|
|
34
|
+
credentialSource;
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
if (config.token !== undefined) {
|
|
37
|
+
if (config.token === '')
|
|
38
|
+
throw new NotConfiguredError();
|
|
39
|
+
this.#token = config.token;
|
|
40
|
+
this.#baseUrl = (config.baseUrl ?? apiUrlFrom()).replace(/\/$/, '');
|
|
41
|
+
this.credentialSource = 'env';
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const resolved = resolveCredential();
|
|
45
|
+
this.#token = resolved.token;
|
|
46
|
+
this.#baseUrl = (config.baseUrl ?? resolved.apiUrl).replace(/\/$/, '');
|
|
47
|
+
this.credentialSource = resolved.source;
|
|
48
|
+
}
|
|
49
|
+
this.#fetch = config.fetchImpl ?? fetch;
|
|
50
|
+
}
|
|
51
|
+
/** The control plane this client talks to. */
|
|
52
|
+
get baseUrl() {
|
|
53
|
+
return this.#baseUrl;
|
|
54
|
+
}
|
|
55
|
+
async whoami() {
|
|
56
|
+
return await this.#json('GET', '/api/v1/me');
|
|
57
|
+
}
|
|
58
|
+
// ── secrets and variables (values are write-only) ──
|
|
59
|
+
async listSecrets(target) {
|
|
60
|
+
const body = await this.#json('GET', `${this.#scopePath(target)}/secrets`);
|
|
61
|
+
return body.secrets;
|
|
62
|
+
}
|
|
63
|
+
async setSecret(target, name, value) {
|
|
64
|
+
await this.#noContent('PUT', `${this.#scopePath(target)}/secrets/${encodeURIComponent(name)}`, { value });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `apex logout`: revoke the token this client authenticates with. A 401 means
|
|
68
|
+
* it no longer counts anyway — expired or already revoked — which is the
|
|
69
|
+
* outcome logout wants, so it is not an error.
|
|
70
|
+
*/
|
|
71
|
+
async revokeCurrentToken() {
|
|
72
|
+
const response = await this.#request('DELETE', '/api/v1/me/token');
|
|
73
|
+
if (response.ok || response.status === 401)
|
|
74
|
+
return;
|
|
75
|
+
throw new HttpError(await this.#errorMessage(response), response.status);
|
|
76
|
+
}
|
|
77
|
+
async deleteSecret(target, name) {
|
|
78
|
+
return await this.#deleted(`${this.#scopePath(target)}/secrets/${encodeURIComponent(name)}`);
|
|
79
|
+
}
|
|
80
|
+
async listVariables(target) {
|
|
81
|
+
const body = await this.#json('GET', `${this.#scopePath(target)}/variables`);
|
|
82
|
+
return body.variables;
|
|
83
|
+
}
|
|
84
|
+
async setVariable(target, name, value) {
|
|
85
|
+
await this.#noContent('PUT', `${this.#scopePath(target)}/variables/${encodeURIComponent(name)}`, { value });
|
|
86
|
+
}
|
|
87
|
+
async deleteVariable(target, name) {
|
|
88
|
+
return await this.#deleted(`${this.#scopePath(target)}/variables/${encodeURIComponent(name)}`);
|
|
89
|
+
}
|
|
90
|
+
#scopePath(target) {
|
|
91
|
+
return target.kind === 'org' ? '/api/v1/org' : this.#repoPath(target.owner, target.repo);
|
|
92
|
+
}
|
|
93
|
+
async #noContent(method, path, body) {
|
|
94
|
+
const response = await this.#request(method, path, body);
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
throw new HttpError(await this.#errorMessage(response), response.status);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A bare 404 is "no such name"; a 404 that explains itself ("repository not
|
|
100
|
+
* found") is a refusal, and reporting it as a missing secret would send
|
|
101
|
+
* somebody looking for the wrong thing.
|
|
102
|
+
*/
|
|
103
|
+
async #deleted(path) {
|
|
104
|
+
const response = await this.#request('DELETE', path);
|
|
105
|
+
if (response.status === 404) {
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
let problem = {};
|
|
108
|
+
try {
|
|
109
|
+
problem = JSON.parse(text) ?? {};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// no body: the name was not there
|
|
113
|
+
}
|
|
114
|
+
if (typeof problem.error === 'string')
|
|
115
|
+
throw new HttpError(problem.error, 404);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (!response.ok)
|
|
119
|
+
throw new HttpError(await this.#errorMessage(response), response.status);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
async listRuns(repository) {
|
|
123
|
+
const path = repository === undefined
|
|
124
|
+
? '/api/v1/runs?limit=100'
|
|
125
|
+
: `/api/v1/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/runs`;
|
|
126
|
+
const body = await this.#json('GET', path);
|
|
127
|
+
return body.runs;
|
|
128
|
+
}
|
|
129
|
+
async getRun(id) {
|
|
130
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(id)}`);
|
|
131
|
+
if (response.status === 404)
|
|
132
|
+
return undefined;
|
|
133
|
+
return (await this.#decode(response));
|
|
134
|
+
}
|
|
135
|
+
async explainRun(runId) {
|
|
136
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/explain`);
|
|
137
|
+
if (response.status === 404)
|
|
138
|
+
return undefined;
|
|
139
|
+
return (await this.#decode(response));
|
|
140
|
+
}
|
|
141
|
+
async explainDelivery(deliveryId) {
|
|
142
|
+
const response = await this.#request('GET', `/api/v1/deliveries/${encodeURIComponent(deliveryId)}/explain`);
|
|
143
|
+
if (response.status === 404)
|
|
144
|
+
return undefined;
|
|
145
|
+
return (await this.#decode(response));
|
|
146
|
+
}
|
|
147
|
+
async reproduction(runId, job) {
|
|
148
|
+
const query = job === undefined ? '' : `?job=${encodeURIComponent(job)}`;
|
|
149
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/reproduction${query}`);
|
|
150
|
+
if (response.status === 404)
|
|
151
|
+
return undefined;
|
|
152
|
+
return (await this.#decode(response));
|
|
153
|
+
}
|
|
154
|
+
async runTiming(runId) {
|
|
155
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/timing`);
|
|
156
|
+
if (response.status === 404)
|
|
157
|
+
return undefined;
|
|
158
|
+
return (await this.#decode(response));
|
|
159
|
+
}
|
|
160
|
+
async runTests(runId) {
|
|
161
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/tests`);
|
|
162
|
+
if (response.status === 404)
|
|
163
|
+
return undefined;
|
|
164
|
+
return (await this.#decode(response));
|
|
165
|
+
}
|
|
166
|
+
async flakyTests(owner, repo) {
|
|
167
|
+
const body = await this.#json('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/flaky-tests`);
|
|
168
|
+
return body.tests;
|
|
169
|
+
}
|
|
170
|
+
async runSnapshots(runId) {
|
|
171
|
+
const body = await this.#json('GET', `/api/v1/runs/${encodeURIComponent(runId)}/snapshots`);
|
|
172
|
+
return body.snapshots;
|
|
173
|
+
}
|
|
174
|
+
async rerun(runId, request) {
|
|
175
|
+
return await this.#json('POST', `/api/v1/runs/${encodeURIComponent(runId)}/rerun`, request);
|
|
176
|
+
}
|
|
177
|
+
async spend(repository, days) {
|
|
178
|
+
const query = days === undefined ? '' : `?days=${encodeURIComponent(String(days))}`;
|
|
179
|
+
const path = repository === undefined
|
|
180
|
+
? `/api/v1/spend${query}`
|
|
181
|
+
: `/api/v1/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/spend${query}`;
|
|
182
|
+
return await this.#json('GET', path);
|
|
183
|
+
}
|
|
184
|
+
async actionInventory(repository) {
|
|
185
|
+
return await this.#json('GET', `/api/v1/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/actions`);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The terms in force and who owes an acceptance, read from `/me`.
|
|
189
|
+
*
|
|
190
|
+
* It rides on `/me` in the API because the remedy is at somebody's next
|
|
191
|
+
* sign-in and that is the call every screen makes (control-plane ADR-0039
|
|
192
|
+
* amendment 1); here it means one request answers both halves.
|
|
193
|
+
*/
|
|
194
|
+
async termsStanding() {
|
|
195
|
+
const body = await this.#json('GET', '/api/v1/me');
|
|
196
|
+
return { version: body.termsVersion ?? '', pending: body.termsPending ?? [] };
|
|
197
|
+
}
|
|
198
|
+
async acceptTerms(installationId, version) {
|
|
199
|
+
const body = await this.#json('POST', '/api/v1/terms/acceptance', { installationId, termsVersion: version });
|
|
200
|
+
return body.accepted;
|
|
201
|
+
}
|
|
202
|
+
async actionPolicy(owner) {
|
|
203
|
+
return await this.#json('GET', `/api/v1/orgs/${encodeURIComponent(owner)}/action-policy`);
|
|
204
|
+
}
|
|
205
|
+
async setActionPolicy(owner, policy) {
|
|
206
|
+
return await this.#json('PUT', `/api/v1/orgs/${encodeURIComponent(owner)}/action-policy`, policy);
|
|
207
|
+
}
|
|
208
|
+
async pinActions(repository, apply) {
|
|
209
|
+
return await this.#json('POST', `/api/v1/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/actions/pin`, { apply });
|
|
210
|
+
}
|
|
211
|
+
async listNotificationRules(repository) {
|
|
212
|
+
const body = await this.#json('GET', `${this.#repoPath(repository.owner, repository.repo)}/notifications`);
|
|
213
|
+
return body.rules;
|
|
214
|
+
}
|
|
215
|
+
async putNotificationRule(repository, name, input) {
|
|
216
|
+
return await this.#json('PUT', `${this.#repoPath(repository.owner, repository.repo)}/notifications/${encodeURIComponent(name)}`, input);
|
|
217
|
+
}
|
|
218
|
+
async deleteNotificationRule(repository, name) {
|
|
219
|
+
const response = await this.#request('DELETE', `${this.#repoPath(repository.owner, repository.repo)}/notifications/${encodeURIComponent(name)}`);
|
|
220
|
+
if (response.status === 404)
|
|
221
|
+
return false;
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw new Error(await this.#errorMessage(response));
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
async testNotificationRule(repository, name) {
|
|
227
|
+
return await this.#json('POST', `${this.#repoPath(repository.owner, repository.repo)}/notifications/${encodeURIComponent(name)}/test`);
|
|
228
|
+
}
|
|
229
|
+
async listNotificationDeliveries(repository, limit) {
|
|
230
|
+
const body = await this.#json('GET', `${this.#repoPath(repository.owner, repository.repo)}/notification-deliveries?limit=${String(limit)}`);
|
|
231
|
+
return body.deliveries;
|
|
232
|
+
}
|
|
233
|
+
async searchLogs(repository, request) {
|
|
234
|
+
const params = new URLSearchParams({ q: request.query });
|
|
235
|
+
if (request.workflow !== undefined)
|
|
236
|
+
params.set('workflow', request.workflow);
|
|
237
|
+
if (request.branch !== undefined)
|
|
238
|
+
params.set('branch', request.branch);
|
|
239
|
+
if (request.conclusion !== undefined)
|
|
240
|
+
params.set('conclusion', request.conclusion);
|
|
241
|
+
if (request.job !== undefined)
|
|
242
|
+
params.set('job', request.job);
|
|
243
|
+
if (request.caseSensitive === true)
|
|
244
|
+
params.set('case', 'true');
|
|
245
|
+
if (request.days !== undefined)
|
|
246
|
+
params.set('days', String(request.days));
|
|
247
|
+
if (request.limit !== undefined)
|
|
248
|
+
params.set('limit', String(request.limit));
|
|
249
|
+
const path = `/api/v1/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/logs/search?${params.toString()}`;
|
|
250
|
+
return await this.#json('GET', path);
|
|
251
|
+
}
|
|
252
|
+
async jobLogView(jobId) {
|
|
253
|
+
return await this.#json('GET', `/api/v1/jobs/${encodeURIComponent(jobId)}/log-view`);
|
|
254
|
+
}
|
|
255
|
+
async jobLogs(jobId) {
|
|
256
|
+
const response = await this.#request('GET', `/api/v1/jobs/${encodeURIComponent(jobId)}/logs`, undefined, 'text/plain');
|
|
257
|
+
if (!response.ok)
|
|
258
|
+
throw new Error(await this.#errorMessage(response));
|
|
259
|
+
return await response.text();
|
|
260
|
+
}
|
|
261
|
+
async dispatch(owner, repo, workflow, ref, inputs) {
|
|
262
|
+
return await this.#json('POST', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/workflows/${encodeURIComponent(workflow)}/dispatches`, { ref, inputs });
|
|
263
|
+
}
|
|
264
|
+
async setCoexistence(owner, repo, enabled) {
|
|
265
|
+
await this.#json('PUT', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/coexistence`, { enabled });
|
|
266
|
+
}
|
|
267
|
+
async setWorkflowOptIn(owner, repo, workflow, optedIn) {
|
|
268
|
+
await this.#json('PUT', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/workflows/${encodeURIComponent(workflow)}/opt-in`, { optedIn });
|
|
269
|
+
}
|
|
270
|
+
async coexistenceDiff(owner, repo, sha) {
|
|
271
|
+
const q = new URLSearchParams({ sha });
|
|
272
|
+
return await this.#json('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/coexistence/diff?${q.toString()}`);
|
|
273
|
+
}
|
|
274
|
+
async listRunners() {
|
|
275
|
+
const body = await this.#json('GET', '/api/v1/runners');
|
|
276
|
+
return body.runners;
|
|
277
|
+
}
|
|
278
|
+
async mintRegistrationToken(options) {
|
|
279
|
+
return await this.#json('POST', '/api/v1/runners/registration-tokens', { uses: options.uses ?? 1 });
|
|
280
|
+
}
|
|
281
|
+
async removeRunner(id) {
|
|
282
|
+
const response = await this.#request('DELETE', `/api/v1/runners/${encodeURIComponent(id)}`);
|
|
283
|
+
if (response.status === 404)
|
|
284
|
+
return false;
|
|
285
|
+
if (!response.ok)
|
|
286
|
+
throw new Error(await this.#errorMessage(response));
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
async fleetDemand() {
|
|
290
|
+
return await this.#json('GET', '/api/v1/fleet/demand');
|
|
291
|
+
}
|
|
292
|
+
async fleetHistory(window) {
|
|
293
|
+
const query = new URLSearchParams({ since: window.since, until: window.until });
|
|
294
|
+
return await this.#json('GET', `/api/v1/fleet/history?${query.toString()}`);
|
|
295
|
+
}
|
|
296
|
+
async listCaches(owner, repo) {
|
|
297
|
+
return await this.#json('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/caches`);
|
|
298
|
+
}
|
|
299
|
+
async deleteCache(owner, repo, id) {
|
|
300
|
+
const response = await this.#request('DELETE', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/caches/${id}`);
|
|
301
|
+
if (response.status === 404)
|
|
302
|
+
return false;
|
|
303
|
+
if (!response.ok)
|
|
304
|
+
throw new Error(await this.#errorMessage(response));
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
async listRunArtifacts(runId) {
|
|
308
|
+
return await this.#json('GET', `/api/v1/runs/${encodeURIComponent(runId)}/artifacts`);
|
|
309
|
+
}
|
|
310
|
+
async listRepoArtifacts(owner, repo) {
|
|
311
|
+
return await this.#json('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/artifacts`);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The API answers with a 302 to a presigned object URL. `fetch` follows it
|
|
315
|
+
* by default, and following it is the point: the bytes come from the bucket
|
|
316
|
+
* rather than through the control plane, and this process never holds a
|
|
317
|
+
* credential for that bucket.
|
|
318
|
+
*/
|
|
319
|
+
async downloadArtifact(runId, id) {
|
|
320
|
+
const response = await this.#request('GET', `/api/v1/runs/${encodeURIComponent(runId)}/artifacts/${id}/download`, undefined, '*/*');
|
|
321
|
+
if (response.status === 404)
|
|
322
|
+
return undefined;
|
|
323
|
+
if (!response.ok)
|
|
324
|
+
throw new Error(await this.#errorMessage(response));
|
|
325
|
+
// Node's `fetch` body is async-iterable; the DOM type it is declared as is
|
|
326
|
+
// not, which is a gap in the declarations rather than in the runtime.
|
|
327
|
+
return response.body ?? undefined;
|
|
328
|
+
}
|
|
329
|
+
async deleteArtifact(runId, id) {
|
|
330
|
+
const response = await this.#request('DELETE', `/api/v1/runs/${encodeURIComponent(runId)}/artifacts/${id}`);
|
|
331
|
+
if (response.status === 404)
|
|
332
|
+
return false;
|
|
333
|
+
if (!response.ok)
|
|
334
|
+
throw new Error(await this.#errorMessage(response));
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
// ── environments, deployments, schedules, OIDC ──
|
|
338
|
+
async listEnvironments(owner, repo) {
|
|
339
|
+
return await this.#json('GET', `${this.#repoPath(owner, repo)}/environments`);
|
|
340
|
+
}
|
|
341
|
+
async putEnvironment(owner, repo, name, input) {
|
|
342
|
+
return await this.#json('PUT', `${this.#repoPath(owner, repo)}/environments/${encodeURIComponent(name)}`, input);
|
|
343
|
+
}
|
|
344
|
+
async deleteEnvironment(owner, repo, name) {
|
|
345
|
+
const response = await this.#request('DELETE', `${this.#repoPath(owner, repo)}/environments/${encodeURIComponent(name)}`);
|
|
346
|
+
if (response.status === 404)
|
|
347
|
+
return false;
|
|
348
|
+
if (!response.ok)
|
|
349
|
+
throw new Error(await this.#errorMessage(response));
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
async listWaitingDeployments() {
|
|
353
|
+
return await this.#json('GET', '/api/v1/deployments');
|
|
354
|
+
}
|
|
355
|
+
async listRunDeployments(runId) {
|
|
356
|
+
return await this.#json('GET', `/api/v1/runs/${encodeURIComponent(runId)}/deployments`);
|
|
357
|
+
}
|
|
358
|
+
async decideDeployment(id, decision, comment) {
|
|
359
|
+
const response = await this.#request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/decision`, {
|
|
360
|
+
decision,
|
|
361
|
+
...(comment !== undefined && comment !== '' ? { comment } : {}),
|
|
362
|
+
});
|
|
363
|
+
if (response.status === 404)
|
|
364
|
+
return undefined;
|
|
365
|
+
if (!response.ok)
|
|
366
|
+
throw new Error(await this.#errorMessage(response));
|
|
367
|
+
return (await this.#decode(response));
|
|
368
|
+
}
|
|
369
|
+
async listWaitingRuns() {
|
|
370
|
+
return await this.#json('GET', '/api/v1/approvals');
|
|
371
|
+
}
|
|
372
|
+
async decideRun(runId, decision, comment) {
|
|
373
|
+
const response = await this.#request('POST', `/api/v1/runs/${encodeURIComponent(runId)}/approval`, {
|
|
374
|
+
decision,
|
|
375
|
+
...(comment !== undefined && comment !== '' ? { comment } : {}),
|
|
376
|
+
});
|
|
377
|
+
if (response.status === 404)
|
|
378
|
+
return undefined;
|
|
379
|
+
if (!response.ok)
|
|
380
|
+
throw new Error(await this.#errorMessage(response));
|
|
381
|
+
return (await this.#decode(response));
|
|
382
|
+
}
|
|
383
|
+
async listSchedules(owner, repo) {
|
|
384
|
+
return await this.#json('GET', `${this.#repoPath(owner, repo)}/schedules`);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The discovery document, unauthenticated — the same request a cloud
|
|
388
|
+
* provider makes, which is the point of reading it from here: if this
|
|
389
|
+
* answers, federation's first step works.
|
|
390
|
+
*/
|
|
391
|
+
async oidcDiscovery() {
|
|
392
|
+
const response = await this.#request('GET', '/.well-known/openid-configuration');
|
|
393
|
+
if (response.status === 404)
|
|
394
|
+
return undefined;
|
|
395
|
+
if (!response.ok)
|
|
396
|
+
throw new Error(await this.#errorMessage(response));
|
|
397
|
+
return (await this.#decode(response));
|
|
398
|
+
}
|
|
399
|
+
async getOidcSubject(owner, repo) {
|
|
400
|
+
return await this.#json('GET', `${this.#repoPath(owner, repo)}/oidc/subject`);
|
|
401
|
+
}
|
|
402
|
+
async putOidcSubject(owner, repo, keys) {
|
|
403
|
+
return await this.#json('PUT', `${this.#repoPath(owner, repo)}/oidc/subject`, { includeClaimKeys: keys });
|
|
404
|
+
}
|
|
405
|
+
#repoPath(owner, repo) {
|
|
406
|
+
return `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
407
|
+
}
|
|
408
|
+
async #json(method, path, body) {
|
|
409
|
+
const response = await this.#request(method, path, body);
|
|
410
|
+
if (!response.ok)
|
|
411
|
+
throw new HttpError(await this.#errorMessage(response), response.status);
|
|
412
|
+
return (await this.#decode(response));
|
|
413
|
+
}
|
|
414
|
+
async #request(method, path, body, accept = 'application/json') {
|
|
415
|
+
try {
|
|
416
|
+
return await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
417
|
+
method,
|
|
418
|
+
headers: {
|
|
419
|
+
accept,
|
|
420
|
+
authorization: `Bearer ${this.#token}`,
|
|
421
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
422
|
+
},
|
|
423
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
424
|
+
signal: AbortSignal.timeout(30_000),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
throw new Error(`${this.#baseUrl} is unreachable: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async #decode(response) {
|
|
432
|
+
return await response.json();
|
|
433
|
+
}
|
|
434
|
+
/** The API's own message, so a refusal explains itself. */
|
|
435
|
+
async #errorMessage(response) {
|
|
436
|
+
try {
|
|
437
|
+
const problem = (await response.json());
|
|
438
|
+
if (typeof problem.error === 'string')
|
|
439
|
+
return problem.error;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// a non-JSON body leaves the status
|
|
443
|
+
}
|
|
444
|
+
return `request failed with ${response.status}`;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Adapter: real time, injected so following a run is testable without it. */
|
|
448
|
+
export class RealSleeper {
|
|
449
|
+
async sleep(ms) {
|
|
450
|
+
await delay(ms);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Writes a file relative to the working directory the command was run from. */
|
|
454
|
+
export class WorkingDirectoryWriter {
|
|
455
|
+
async write(path, contents) {
|
|
456
|
+
await writeFile(resolve(process.cwd(), path), contents, 'utf8');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { closeSync, openSync, readFileSync, statSync, writeSync } from 'node:fs';
|
|
3
|
+
import { chmod, mkdir, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { DEFAULT_API_URL, TOKENS_PAGE, hasExpired, isPrivateMode, normaliseUrl, parseSavedCredential, } from '../domain/credentials.js';
|
|
8
|
+
export const URL_ENV = 'APEX_API_URL';
|
|
9
|
+
export const WEB_URL_ENV = 'APEX_WEB_URL';
|
|
10
|
+
export const TOKEN_ENV = 'APEX_TOKEN';
|
|
11
|
+
export const ADMIN_TOKEN_ENV = 'APEX_ADMIN_TOKEN';
|
|
12
|
+
const set = (value) => value !== undefined && value !== '';
|
|
13
|
+
/** The control plane: `APEX_API_URL`, or the hosted service. */
|
|
14
|
+
export const apiUrlFrom = (env = process.env) => normaliseUrl(set(env[URL_ENV]) ? env[URL_ENV] : DEFAULT_API_URL);
|
|
15
|
+
/**
|
|
16
|
+
* No credential at all, or one the CLI will not use. Exit 2 (§7): it is
|
|
17
|
+
* configuration, not a refusal by the server. The message names what to set
|
|
18
|
+
* and never a value.
|
|
19
|
+
*/
|
|
20
|
+
export class NotConfiguredError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message ??
|
|
23
|
+
`no credential for the control plane: set ${TOKEN_ENV} to a token from the dashboard (${TOKENS_PAGE}), ` +
|
|
24
|
+
`or run \`apex login\`; operators set ${ADMIN_TOKEN_ENV}. ${URL_ENV} selects the control plane (default ${DEFAULT_API_URL})`);
|
|
25
|
+
this.name = 'NotConfiguredError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** `ssh`'s refusal: a credentials file others can read is not used. */
|
|
29
|
+
export class InsecureCredentialsError extends NotConfiguredError {
|
|
30
|
+
constructor(path, mode) {
|
|
31
|
+
super(`${path} is readable by others (mode ${(mode & 0o777).toString(8).padStart(3, '0')}), so it is not used: ` +
|
|
32
|
+
`run \`chmod 600 ${path}\`, or \`apex logout\` and \`apex login\` again`);
|
|
33
|
+
this.name = 'InsecureCredentialsError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** `$XDG_CONFIG_HOME/apex/credentials`, defaulting to `~/.config`. */
|
|
37
|
+
export const credentialsPath = (env = process.env) => join(set(env['XDG_CONFIG_HOME']) ? env['XDG_CONFIG_HOME'] : join(homedir(), '.config'), 'apex', 'credentials');
|
|
38
|
+
/**
|
|
39
|
+
* Adapter: the credentials file `apex login` writes.
|
|
40
|
+
*
|
|
41
|
+
* The file is `0600` in a `0700` directory, and it is written to a temporary
|
|
42
|
+
* name and renamed, so a crash half-way leaves the old file or none — never a
|
|
43
|
+
* truncated one, and never a moment where the token sits in a file with the
|
|
44
|
+
* process's default mode.
|
|
45
|
+
*/
|
|
46
|
+
export class FileCredentialStore {
|
|
47
|
+
location;
|
|
48
|
+
constructor(path = credentialsPath()) {
|
|
49
|
+
this.location = path;
|
|
50
|
+
}
|
|
51
|
+
/** The saved credential, or undefined when there is none. Throws when the file is not private. */
|
|
52
|
+
read() {
|
|
53
|
+
let mode;
|
|
54
|
+
try {
|
|
55
|
+
mode = statSync(this.location).mode;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!isPrivateMode(mode))
|
|
61
|
+
throw new InsecureCredentialsError(this.location, mode);
|
|
62
|
+
try {
|
|
63
|
+
return parseSavedCredential(JSON.parse(readFileSync(this.location, 'utf8')));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async save(credential) {
|
|
70
|
+
const dir = dirname(this.location);
|
|
71
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
await chmod(dir, 0o700);
|
|
73
|
+
const temp = `${this.location}.${randomBytes(6).toString('hex')}.tmp`;
|
|
74
|
+
// `wx` and the mode at creation: the token is never in a file anybody else could open.
|
|
75
|
+
const fd = openSync(temp, 'wx', 0o600);
|
|
76
|
+
try {
|
|
77
|
+
writeSync(fd, `${JSON.stringify(credential, null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await rename(temp, this.location);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
await rm(temp, { force: true });
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async remove() {
|
|
91
|
+
try {
|
|
92
|
+
statSync(this.location);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
await rm(this.location, { force: true });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Which credential the CLI uses, in ADR-0064's order:
|
|
103
|
+
*
|
|
104
|
+
* 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
|
+
* by others, skipped if it has expired or was minted by a different
|
|
107
|
+
* control plane than the one `APEX_API_URL` names (a token is only ever
|
|
108
|
+
* sent to the server that issued it);
|
|
109
|
+
* 3. `APEX_ADMIN_TOKEN` — the operator's, unchanged;
|
|
110
|
+
* 4. nothing: `NotConfiguredError`, naming `APEX_TOKEN` and `apex login`.
|
|
111
|
+
*/
|
|
112
|
+
export const resolveCredential = (env = process.env, store = new FileCredentialStore(credentialsPath(env)), now = new Date()) => {
|
|
113
|
+
const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
|
|
114
|
+
const token = env[TOKEN_ENV];
|
|
115
|
+
if (set(token))
|
|
116
|
+
return { token, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'env' };
|
|
117
|
+
const saved = store.read();
|
|
118
|
+
if (saved !== undefined && !hasExpired(saved, now)) {
|
|
119
|
+
const mintedBy = normaliseUrl(saved.apiUrl);
|
|
120
|
+
if (explicitUrl === undefined || explicitUrl === mintedBy)
|
|
121
|
+
return { token: saved.token, apiUrl: mintedBy, source: 'login' };
|
|
122
|
+
}
|
|
123
|
+
const admin = env[ADMIN_TOKEN_ENV];
|
|
124
|
+
if (set(admin))
|
|
125
|
+
return { token: admin, apiUrl: explicitUrl ?? DEFAULT_API_URL, source: 'operator' };
|
|
126
|
+
if (saved !== undefined && hasExpired(saved, now)) {
|
|
127
|
+
throw new NotConfiguredError(`the token \`apex login\` saved expired at ${saved.expiresAt}: run \`apex login\` again, or set ${TOKEN_ENV}`);
|
|
128
|
+
}
|
|
129
|
+
if (saved !== undefined) {
|
|
130
|
+
throw new NotConfiguredError(`the token \`apex login\` saved was issued by ${normaliseUrl(saved.apiUrl)}, and ${URL_ENV} names ${explicitUrl ?? ''}: ` +
|
|
131
|
+
`it is not sent anywhere else — run \`apex login\` against this control plane, or set ${TOKEN_ENV}`);
|
|
132
|
+
}
|
|
133
|
+
throw new NotConfiguredError();
|
|
134
|
+
};
|