@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,164 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
const BIN_ENV = 'APEX_ENGINE_BIN';
|
|
6
|
+
const DEFAULT_BIN = 'apex-engine';
|
|
7
|
+
const missingDetail = (bin) => `engine binary ${bin} not found — install apex-engine on your PATH or set ${BIN_ENV}`;
|
|
8
|
+
/** Adapter: shells out to `apex-engine validate --json` (engine ADR-0004). */
|
|
9
|
+
export class EngineBinaryValidator {
|
|
10
|
+
#bin;
|
|
11
|
+
constructor(bin = process.env[BIN_ENV] ?? DEFAULT_BIN) {
|
|
12
|
+
this.#bin = bin;
|
|
13
|
+
}
|
|
14
|
+
async validate(files) {
|
|
15
|
+
const bin = this.#bin;
|
|
16
|
+
return await new Promise((resolve, reject) => {
|
|
17
|
+
execFile(bin, ['validate', '--json', ...files], { maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
18
|
+
const code = typeof error?.code === 'number' ? error.code : error ? -1 : 0;
|
|
19
|
+
if (error && 'code' in error && error.code === 'ENOENT') {
|
|
20
|
+
resolve({ kind: 'engine-missing', detail: missingDetail(bin) });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (code !== 0 && code !== 1) {
|
|
24
|
+
reject(new Error(`${bin} validate failed: ${stderr.toString().trim() || `exit ${code}`}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
resolve({ kind: 'report', report: JSON.parse(stdout.toString()) });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
reject(new Error(`${bin} validate produced unparseable output`));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Adapter: shells out to `apex-engine compat --manifest --json` (engine ADR-0013).
|
|
39
|
+
*
|
|
40
|
+
* The manifest goes down stdin rather than into a temp directory. These are
|
|
41
|
+
* somebody else's workflow files, fetched to answer a question that needs them
|
|
42
|
+
* only in memory, and writing them to our disk to pass a path would also
|
|
43
|
+
* replace the repository-relative paths the report has to name with temp paths
|
|
44
|
+
* nobody can act on.
|
|
45
|
+
*
|
|
46
|
+
* Exit 1 means the engine found blockers or unreadable files — a report, not a
|
|
47
|
+
* failure — so it is read the same way as exit 0.
|
|
48
|
+
*/
|
|
49
|
+
export class EngineBinaryCompat {
|
|
50
|
+
#bin;
|
|
51
|
+
constructor(bin = process.env[BIN_ENV] ?? DEFAULT_BIN) {
|
|
52
|
+
this.#bin = bin;
|
|
53
|
+
}
|
|
54
|
+
async assess(org, documents) {
|
|
55
|
+
const bin = this.#bin;
|
|
56
|
+
const manifest = JSON.stringify({ organization: org, documents });
|
|
57
|
+
return await new Promise((resolve, reject) => {
|
|
58
|
+
const child = execFile(bin, ['compat', '--manifest', '--json'], { maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
59
|
+
const code = typeof error?.code === 'number' ? error.code : error ? -1 : 0;
|
|
60
|
+
if (error && 'code' in error && error.code === 'ENOENT') {
|
|
61
|
+
resolve({ kind: 'engine-missing', detail: missingDetail(bin) });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (code !== 0 && code !== 1) {
|
|
65
|
+
reject(new Error(`${bin} compat failed: ${stderr.toString().trim() || `exit ${code}`}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
resolve({ kind: 'report', report: JSON.parse(stdout.toString()) });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
reject(new Error(`${bin} compat produced unparseable output`));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
child.on('error', () => undefined);
|
|
76
|
+
child.stdin?.end(manifest);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Adapter: a checkout on this machine, scanned as one repository.
|
|
82
|
+
*
|
|
83
|
+
* It exists because the first thing somebody evaluating a migration wants is
|
|
84
|
+
* an answer about a repository they already have, without handing anybody a
|
|
85
|
+
* token — and because it is the path the tests can drive end to end.
|
|
86
|
+
*/
|
|
87
|
+
export class LocalWorkflowSource {
|
|
88
|
+
#root;
|
|
89
|
+
#slug;
|
|
90
|
+
constructor(root) {
|
|
91
|
+
this.#root = root;
|
|
92
|
+
this.#slug = basename(resolve(root)) || root;
|
|
93
|
+
}
|
|
94
|
+
async repositories() {
|
|
95
|
+
return [{ slug: this.#slug, archived: false }];
|
|
96
|
+
}
|
|
97
|
+
async workflows() {
|
|
98
|
+
const dir = join(this.#root, '.github', 'workflows');
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = await readdir(dir);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const documents = [];
|
|
107
|
+
for (const name of entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')).sort()) {
|
|
108
|
+
documents.push({
|
|
109
|
+
path: join('.github', 'workflows', name),
|
|
110
|
+
repo: this.#slug,
|
|
111
|
+
source: await readFile(join(dir, name), 'utf8'),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return documents;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Adapter: runs `apex-engine lsp` with this process's stdio inherited.
|
|
119
|
+
*
|
|
120
|
+
* `inherit` rather than pipes, and that is the whole adapter: an editor talks
|
|
121
|
+
* to its language server down the child's own stdin and stdout, and copying
|
|
122
|
+
* every byte through this process would add a buffer, a backpressure question
|
|
123
|
+
* and a place for the framing to be corrupted, in exchange for nothing.
|
|
124
|
+
*/
|
|
125
|
+
export class EngineBinaryLanguageServer {
|
|
126
|
+
#bin;
|
|
127
|
+
constructor(bin = process.env[BIN_ENV] ?? DEFAULT_BIN) {
|
|
128
|
+
this.#bin = bin;
|
|
129
|
+
}
|
|
130
|
+
async serve() {
|
|
131
|
+
const bin = this.#bin;
|
|
132
|
+
return await new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn(bin, ['lsp'], { stdio: 'inherit' });
|
|
134
|
+
child.on('error', (error) => {
|
|
135
|
+
if (error.code === 'ENOENT') {
|
|
136
|
+
resolve({ code: 2, missing: missingDetail(bin) });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
reject(error);
|
|
140
|
+
});
|
|
141
|
+
child.on('exit', (code) => resolve({ code: code ?? 0 }));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Adapter: finds `.github/workflows/*.yml|yaml` under the working directory. */
|
|
146
|
+
export class GitHubWorkflowLocator {
|
|
147
|
+
#root;
|
|
148
|
+
constructor(root = process.cwd()) {
|
|
149
|
+
this.#root = root;
|
|
150
|
+
}
|
|
151
|
+
async defaultWorkflows() {
|
|
152
|
+
const dir = join(this.#root, '.github', 'workflows');
|
|
153
|
+
try {
|
|
154
|
+
const entries = await readdir(dir);
|
|
155
|
+
return entries
|
|
156
|
+
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
157
|
+
.sort()
|
|
158
|
+
.map((f) => join(dir, f));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const TOKEN_ENVS = ['GITHUB_TOKEN', 'GH_TOKEN'];
|
|
2
|
+
const API_ENV = 'GITHUB_API_URL';
|
|
3
|
+
const DEFAULT_API = 'https://api.github.com';
|
|
4
|
+
const WORKFLOW_DIR = '.github/workflows';
|
|
5
|
+
export class NoTokenError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super(`set ${TOKEN_ENVS.join(' or ')} to a token that can read the organization's repositories`);
|
|
8
|
+
this.name = 'NoTokenError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Adapter: the GitHub REST API, for reading somebody else's workflows before
|
|
13
|
+
* they have moved anything.
|
|
14
|
+
*
|
|
15
|
+
* Read-only, and deliberately narrow. It fetches workflow documents and the
|
|
16
|
+
* **names** of secrets and variables, and there is no method here that could
|
|
17
|
+
* return a value. That is not an accident of what GitHub exposes:
|
|
18
|
+
* `/actions/secrets` genuinely has no value field, because a secret can only
|
|
19
|
+
* be written — but `/actions/variables` **does** carry `value`, and this
|
|
20
|
+
* adapter drops it. Importing values was never the deliverable, a variable is
|
|
21
|
+
* not reliably non-sensitive, and §11's write-only rule is easiest to keep
|
|
22
|
+
* when the value never enters the process at all.
|
|
23
|
+
*/
|
|
24
|
+
export class GitHubApi {
|
|
25
|
+
#token;
|
|
26
|
+
#baseUrl;
|
|
27
|
+
#fetch;
|
|
28
|
+
constructor(config = {}) {
|
|
29
|
+
const token = config.token ?? TOKEN_ENVS.map((e) => process.env[e]).find((v) => v !== undefined && v !== '');
|
|
30
|
+
if (token === undefined || token === '')
|
|
31
|
+
throw new NoTokenError();
|
|
32
|
+
this.#token = token;
|
|
33
|
+
this.#baseUrl = (config.baseUrl ?? process.env[API_ENV] ?? DEFAULT_API).replace(/\/+$/, '');
|
|
34
|
+
this.#fetch = config.fetchImpl ?? fetch;
|
|
35
|
+
}
|
|
36
|
+
async repositories(org) {
|
|
37
|
+
const repos = await this.#paged(`/orgs/${encodeURIComponent(org)}/repos?per_page=100&type=all`);
|
|
38
|
+
return repos.map((r) => ({ slug: r.full_name, archived: r.archived === true }));
|
|
39
|
+
}
|
|
40
|
+
async workflows(repo) {
|
|
41
|
+
const listing = await this.#json(`/repos/${repo}/contents/${WORKFLOW_DIR}`, { allow404: true });
|
|
42
|
+
if (listing === undefined)
|
|
43
|
+
return [];
|
|
44
|
+
const files = listing.filter((e) => e.type === 'file' && (e.name.endsWith('.yml') || e.name.endsWith('.yaml')));
|
|
45
|
+
const documents = [];
|
|
46
|
+
for (const file of files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
47
|
+
const source = await this.#raw(`/repos/${repo}/contents/${file.path}`);
|
|
48
|
+
if (source !== undefined)
|
|
49
|
+
documents.push({ path: file.path, repo, source });
|
|
50
|
+
}
|
|
51
|
+
return documents;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The names configured on GitHub: organization-wide first, then each
|
|
55
|
+
* repository's own.
|
|
56
|
+
*
|
|
57
|
+
* A repository whose secrets the token may not read is skipped rather than
|
|
58
|
+
* fatal. A checklist missing one repository's names is still worth having;
|
|
59
|
+
* an error instead of a report is not.
|
|
60
|
+
*/
|
|
61
|
+
async configuredNames(org, repos) {
|
|
62
|
+
const names = [];
|
|
63
|
+
names.push(...(await this.#secretNames(`/orgs/${encodeURIComponent(org)}/actions/secrets`, 'organization')));
|
|
64
|
+
names.push(...(await this.#variableNames(`/orgs/${encodeURIComponent(org)}/actions/variables`, 'organization')));
|
|
65
|
+
for (const repo of repos) {
|
|
66
|
+
names.push(...(await this.#secretNames(`/repos/${repo}/actions/secrets`, 'repository', repo)));
|
|
67
|
+
names.push(...(await this.#variableNames(`/repos/${repo}/actions/variables`, 'repository', repo)));
|
|
68
|
+
}
|
|
69
|
+
return names;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every property is named. A spread of the response would copy whatever
|
|
73
|
+
* GitHub sends, which for a variable is its value.
|
|
74
|
+
*/
|
|
75
|
+
async #secretNames(path, scope, repo) {
|
|
76
|
+
const body = await this.#json(`${path}?per_page=100`, {
|
|
77
|
+
allow404: true,
|
|
78
|
+
allowForbidden: true,
|
|
79
|
+
});
|
|
80
|
+
return (body?.secrets ?? []).map((s) => ({
|
|
81
|
+
name: s.name,
|
|
82
|
+
kind: 'secret',
|
|
83
|
+
scope,
|
|
84
|
+
...(repo === undefined ? {} : { repo }),
|
|
85
|
+
...(s.updated_at === undefined ? {} : { updatedAt: s.updated_at }),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
async #variableNames(path, scope, repo) {
|
|
89
|
+
const body = await this.#json(`${path}?per_page=100`, {
|
|
90
|
+
allow404: true,
|
|
91
|
+
allowForbidden: true,
|
|
92
|
+
});
|
|
93
|
+
// `value` is present on this response and is deliberately not read.
|
|
94
|
+
return (body?.variables ?? []).map((v) => ({
|
|
95
|
+
name: v.name,
|
|
96
|
+
kind: 'variable',
|
|
97
|
+
scope,
|
|
98
|
+
...(repo === undefined ? {} : { repo }),
|
|
99
|
+
...(v.updated_at === undefined ? {} : { updatedAt: v.updated_at }),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
async #paged(path) {
|
|
103
|
+
const out = [];
|
|
104
|
+
let next = `${this.#baseUrl}${path}`;
|
|
105
|
+
while (next !== undefined) {
|
|
106
|
+
const response = await this.#fetch(next, { headers: this.#headers() });
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw new Error(`GET ${next} → ${response.status}`);
|
|
109
|
+
out.push(...(await response.json()));
|
|
110
|
+
next = nextLink(response.headers.get('link'));
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
async #json(path, options = {}) {
|
|
115
|
+
const url = `${this.#baseUrl}${path}`;
|
|
116
|
+
const response = await this.#fetch(url, { headers: this.#headers() });
|
|
117
|
+
if (response.status === 404 && options.allow404 === true)
|
|
118
|
+
return undefined;
|
|
119
|
+
if ((response.status === 403 || response.status === 401) && options.allowForbidden === true)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
throw new Error(`GET ${url} → ${response.status}`);
|
|
123
|
+
return (await response.json());
|
|
124
|
+
}
|
|
125
|
+
async #raw(path) {
|
|
126
|
+
const url = `${this.#baseUrl}${path}`;
|
|
127
|
+
const response = await this.#fetch(url, {
|
|
128
|
+
headers: { ...this.#headers(), Accept: 'application/vnd.github.raw' },
|
|
129
|
+
});
|
|
130
|
+
if (response.status === 404)
|
|
131
|
+
return undefined;
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw new Error(`GET ${url} → ${response.status}`);
|
|
134
|
+
return await response.text();
|
|
135
|
+
}
|
|
136
|
+
#headers() {
|
|
137
|
+
return {
|
|
138
|
+
Authorization: `Bearer ${this.#token}`,
|
|
139
|
+
Accept: 'application/vnd.github+json',
|
|
140
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** `<https://…?page=2>; rel="next", <…>; rel="last"` → the next URL. */
|
|
145
|
+
export const nextLink = (header) => {
|
|
146
|
+
if (header === null)
|
|
147
|
+
return undefined;
|
|
148
|
+
for (const part of header.split(',')) {
|
|
149
|
+
const match = /^\s*<([^>]+)>\s*;\s*rel="next"\s*$/.exec(part);
|
|
150
|
+
if (match?.[1] !== undefined)
|
|
151
|
+
return match[1];
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { judgeCallback, normaliseUrl } from '../domain/credentials.js';
|
|
6
|
+
/**
|
|
7
|
+
* Adapters for `apex login`: the PKCE pair, the loopback listener, the
|
|
8
|
+
* browser, and the code exchange (RFC 8252, RFC 7636; control-plane ADR-0064).
|
|
9
|
+
*/
|
|
10
|
+
/** base64url(sha256(verifier)) — RFC 7636's S256. */
|
|
11
|
+
export const s256 = (verifier) => createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
12
|
+
/**
|
|
13
|
+
* A fresh pair. 32 random bytes base64url-encode to 43 characters, all of
|
|
14
|
+
* them in RFC 7636's unreserved set, which is the verifier's minimum length
|
|
15
|
+
* and the whole of its alphabet.
|
|
16
|
+
*/
|
|
17
|
+
export const newPkcePair = () => {
|
|
18
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
19
|
+
return { verifier, challenge: s256(verifier), state: randomBytes(16).toString('base64url') };
|
|
20
|
+
};
|
|
21
|
+
/** How long a person has to finish in the browser. */
|
|
22
|
+
export const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
23
|
+
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>` +
|
|
24
|
+
`<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5}</style>` +
|
|
25
|
+
`</head><body><h1>${title}</h1><p>${body}</p></body></html>`;
|
|
26
|
+
const answer = (res, status, html) => {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' });
|
|
28
|
+
res.end(html);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Listen on `127.0.0.1` on a port the kernel picks, for exactly one callback.
|
|
32
|
+
*
|
|
33
|
+
* Only the loopback address: nothing off this machine can reach it. The first
|
|
34
|
+
* request to `/callback` settles the login either way — a `state` that does
|
|
35
|
+
* not match ends it rather than waiting on, because a second tab racing the
|
|
36
|
+
* real one is not something to paper over.
|
|
37
|
+
*/
|
|
38
|
+
export const listenForCallback = async (expectedState, timeoutMs = LOGIN_TIMEOUT_MS) => {
|
|
39
|
+
let settle = () => undefined;
|
|
40
|
+
const settled = new Promise((resolve) => {
|
|
41
|
+
settle = resolve;
|
|
42
|
+
});
|
|
43
|
+
let done = false;
|
|
44
|
+
const finish = (result) => {
|
|
45
|
+
if (done)
|
|
46
|
+
return;
|
|
47
|
+
done = true;
|
|
48
|
+
settle(result);
|
|
49
|
+
};
|
|
50
|
+
const server = createServer((req, res) => {
|
|
51
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
52
|
+
if (req.method !== 'GET' || url.pathname !== '/callback' || done) {
|
|
53
|
+
answer(res, 404, page('Not found', 'This address only answers the <code>apex login</code> callback.'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const verdict = judgeCallback(url.searchParams, expectedState);
|
|
57
|
+
switch (verdict.kind) {
|
|
58
|
+
case 'code':
|
|
59
|
+
answer(res, 200, page('Signed in', 'The <code>apex</code> CLI is signed in. You can close this tab.'));
|
|
60
|
+
break;
|
|
61
|
+
case 'denied':
|
|
62
|
+
answer(res, 200, page('Not signed in', 'The request was declined. You can close this tab.'));
|
|
63
|
+
break;
|
|
64
|
+
case 'state-mismatch':
|
|
65
|
+
answer(res, 400, page('Not signed in', 'This callback does not belong to the login waiting in your terminal, so it was refused.'));
|
|
66
|
+
break;
|
|
67
|
+
case 'malformed':
|
|
68
|
+
answer(res, 400, page('Not signed in', 'The callback carried no code. Run <code>apex login</code> again.'));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
finish(verdict);
|
|
72
|
+
});
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
server.once('error', reject);
|
|
75
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
76
|
+
});
|
|
77
|
+
const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
|
|
78
|
+
timer.unref();
|
|
79
|
+
return {
|
|
80
|
+
port: server.address().port,
|
|
81
|
+
wait: async () => await settled,
|
|
82
|
+
close: async () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
finish({ kind: 'timeout' });
|
|
85
|
+
server.closeAllConnections();
|
|
86
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The platform's opener, spawned with the URL as one argument — never through
|
|
92
|
+
* a shell, so nothing in the URL is ever interpreted. Windows' `start` is a
|
|
93
|
+
* `cmd` builtin that would re-parse the `&`s, so it gets the URL handler
|
|
94
|
+
* directly. A failure is silent: the URL is already on the terminal.
|
|
95
|
+
*/
|
|
96
|
+
export const openInBrowser = (url, platform = process.platform) => {
|
|
97
|
+
const [command, args] = platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
|
|
98
|
+
try {
|
|
99
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
100
|
+
child.on('error', () => undefined);
|
|
101
|
+
child.unref();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// no opener on this machine; the printed URL is the fallback
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/** Adapter: `POST /api/v1/cli/token` — the one call made with no credential but the code and verifier. */
|
|
108
|
+
export class HttpLoginExchange {
|
|
109
|
+
#baseUrl;
|
|
110
|
+
#fetch;
|
|
111
|
+
constructor(baseUrl, fetchImpl = fetch) {
|
|
112
|
+
this.#baseUrl = normaliseUrl(baseUrl);
|
|
113
|
+
this.#fetch = fetchImpl;
|
|
114
|
+
}
|
|
115
|
+
async redeem(code, verifier) {
|
|
116
|
+
let response;
|
|
117
|
+
try {
|
|
118
|
+
response = await this.#fetch(`${this.#baseUrl}/api/v1/cli/token`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ code, codeVerifier: verifier }),
|
|
122
|
+
signal: AbortSignal.timeout(30_000),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new Error(`${this.#baseUrl} is unreachable: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
127
|
+
}
|
|
128
|
+
const body = (await response.json().catch(() => ({})));
|
|
129
|
+
if (!response.ok)
|
|
130
|
+
throw new Error(typeof body.error === 'string' ? body.error : `the code exchange failed with ${response.status}`);
|
|
131
|
+
if (typeof body.token !== 'string' || typeof body.login !== 'string' || typeof body.expiresAt !== 'string') {
|
|
132
|
+
throw new Error('the control plane answered the code exchange without a token');
|
|
133
|
+
}
|
|
134
|
+
return { token: body.token, login: body.login, expiresAt: body.expiresAt };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
const BIN_ENV = 'APEX_RUNNER_BIN';
|
|
4
|
+
const DEFAULT_BIN = 'apex-runner';
|
|
5
|
+
const missingDetail = (bin) => `runner binary ${bin} not found — install apex-runner on your PATH or set ${BIN_ENV}`;
|
|
6
|
+
/** Builds the `apex-runner --local` argv for a request (pure, tested). */
|
|
7
|
+
export const runnerArgs = (request) => {
|
|
8
|
+
const args = ['--local', '--workflow', request.workflow];
|
|
9
|
+
if (request.job !== undefined)
|
|
10
|
+
args.push('--job', request.job);
|
|
11
|
+
if (request.event !== undefined)
|
|
12
|
+
args.push('--event', request.event);
|
|
13
|
+
if (request.payload !== undefined)
|
|
14
|
+
args.push('--payload', request.payload);
|
|
15
|
+
if (request.image !== undefined)
|
|
16
|
+
args.push('--image', request.image);
|
|
17
|
+
for (const s of request.secrets)
|
|
18
|
+
args.push('--secret', s);
|
|
19
|
+
for (const v of request.vars)
|
|
20
|
+
args.push('--var', v);
|
|
21
|
+
for (const i of request.inputs)
|
|
22
|
+
args.push('--input', i);
|
|
23
|
+
for (const c of request.changedFiles)
|
|
24
|
+
args.push('--changed-file', c);
|
|
25
|
+
if (request.json)
|
|
26
|
+
args.push('--json');
|
|
27
|
+
return args;
|
|
28
|
+
};
|
|
29
|
+
/** Builds the remote-mode argv for `apex runner register` (pure, tested). */
|
|
30
|
+
export const registerArgs = (request) => {
|
|
31
|
+
const args = ['--server', request.server, '--registration-token', request.token, '--labels', request.labels.join(',')];
|
|
32
|
+
if (request.name !== undefined)
|
|
33
|
+
args.push('--name', request.name);
|
|
34
|
+
if (request.workDir !== undefined)
|
|
35
|
+
args.push('--work-dir', request.workDir);
|
|
36
|
+
if (request.idleTimeout !== undefined)
|
|
37
|
+
args.push('--idle-timeout', request.idleTimeout);
|
|
38
|
+
if (request.ephemeral)
|
|
39
|
+
args.push('--ephemeral');
|
|
40
|
+
return args;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Adapter: spawns `apex-runner` with inherited stdio so logs stream
|
|
44
|
+
* straight to the terminal (runner ADR-0003). `--local` runs one workflow here;
|
|
45
|
+
* remote mode registers this machine and leases work until stopped.
|
|
46
|
+
*/
|
|
47
|
+
export class RunnerBinaryExecutor {
|
|
48
|
+
#bin;
|
|
49
|
+
constructor(bin = process.env[BIN_ENV] ?? DEFAULT_BIN) {
|
|
50
|
+
this.#bin = bin;
|
|
51
|
+
}
|
|
52
|
+
async run(request) {
|
|
53
|
+
return await this.#spawn(runnerArgs(request));
|
|
54
|
+
}
|
|
55
|
+
async register(request) {
|
|
56
|
+
return await this.#spawn(registerArgs(request));
|
|
57
|
+
}
|
|
58
|
+
async #spawn(args) {
|
|
59
|
+
const bin = this.#bin;
|
|
60
|
+
return await new Promise((resolve, reject) => {
|
|
61
|
+
const child = spawn(bin, args, { stdio: 'inherit' });
|
|
62
|
+
child.on('error', (error) => {
|
|
63
|
+
if (error.code === 'ENOENT') {
|
|
64
|
+
resolve({ kind: 'runner-missing', detail: missingDetail(bin) });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
reject(error);
|
|
68
|
+
});
|
|
69
|
+
child.on('exit', (code, signal) => {
|
|
70
|
+
resolve({ kind: 'exit', code: code ?? (signal === null ? 0 : 1) });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { trimOneNewline } from '../domain/secrets.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where a secret's value comes from — anywhere but argv (BUG-0157: argv is
|
|
7
|
+
* readable in `ps` by every user on the machine, and lands in shell history).
|
|
8
|
+
*
|
|
9
|
+
* Every error here names the source and never the content: a file that turns
|
|
10
|
+
* out not to be the one somebody meant may still hold something private.
|
|
11
|
+
*/
|
|
12
|
+
export class ValueInputError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'ValueInputError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Everything on a stream, to EOF, as UTF-8, with one trailing newline off. */
|
|
19
|
+
export const readToEnd = async (stream) => {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for await (const chunk of stream)
|
|
22
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk);
|
|
23
|
+
return trimOneNewline(Buffer.concat(chunks).toString('utf8'));
|
|
24
|
+
};
|
|
25
|
+
/** A file's bytes as UTF-8, one trailing newline off. */
|
|
26
|
+
export const readValueFile = async (path) => {
|
|
27
|
+
try {
|
|
28
|
+
return trimOneNewline(await readFile(path, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const code = err.code;
|
|
32
|
+
throw new ValueInputError(`cannot read ${path}${typeof code === 'string' ? ` (${code})` : ''}`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A no-echo prompt, asked twice: a typo in a value nobody can read back is
|
|
37
|
+
* otherwise discovered by the job that fails with it.
|
|
38
|
+
*/
|
|
39
|
+
export const promptHidden = async (name) => {
|
|
40
|
+
const answers = await inquirer.prompt([
|
|
41
|
+
{ type: 'password', name: 'value', message: `value for ${name} (not echoed)`, mask: false },
|
|
42
|
+
{ type: 'password', name: 'again', message: 'again, to confirm', mask: false },
|
|
43
|
+
]);
|
|
44
|
+
if (answers.value !== answers.again)
|
|
45
|
+
throw new ValueInputError('the two entries differ; nothing was set');
|
|
46
|
+
return answers.value;
|
|
47
|
+
};
|
|
48
|
+
/** A yes/no on the terminal, defaulting to no. */
|
|
49
|
+
export const confirm = async (message) => {
|
|
50
|
+
const answers = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: false }]);
|
|
51
|
+
return answers.ok;
|
|
52
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@apex-actions/cli",
|
|
3
|
+
"version": "0.67.0",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"//files": "Without this npm ships src, test and the CI config alongside the binary a user installs.",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"THIRD-PARTY-NOTICES.md"
|
|
9
|
+
],
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org",
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Apex-Actions/cli.git"
|
|
17
|
+
},
|
|
18
|
+
"description": "apex — developer and operator CLI for Apex Actions",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"apex": "dist/cli/main.js"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"chalk": "^5.4.1",
|
|
28
|
+
"commander": "^14.0.0",
|
|
29
|
+
"inquirer": "^12.6.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@eslint/js": "^10.0.1",
|
|
33
|
+
"@types/node": "^24.0.0",
|
|
34
|
+
"dependency-cruiser": "^16.10.0",
|
|
35
|
+
"eslint": "^9.28.0",
|
|
36
|
+
"husky": "^9.1.7",
|
|
37
|
+
"lint-staged": "^16.1.0",
|
|
38
|
+
"tsx": "^4.19.4",
|
|
39
|
+
"typescript": "^5.8.3",
|
|
40
|
+
"typescript-eslint": "^8.67.0",
|
|
41
|
+
"vitest": "^3.2.0"
|
|
42
|
+
},
|
|
43
|
+
"lint-staged": {
|
|
44
|
+
"*.ts": "eslint --max-warnings=0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"lint": "eslint . --max-warnings=0",
|
|
51
|
+
"lint:arch": "depcruise src cli --config .dependency-cruiser.cjs",
|
|
52
|
+
"apex": "tsx cli/main.ts"
|
|
53
|
+
}
|
|
54
|
+
}
|