@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,275 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { scanOrganization } from '../../src/application/scan-migration.js';
|
|
5
|
+
import { EngineBinaryCompat, LocalWorkflowSource } from '../../src/infrastructure/engine-binary.js';
|
|
6
|
+
import { GitHubApi, NoTokenError } from '../../src/infrastructure/github-api.js';
|
|
7
|
+
import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
|
|
8
|
+
import { parseRepository } from '../../src/domain/runs.js';
|
|
9
|
+
import { setterCommand } from '../../src/domain/secrets.js';
|
|
10
|
+
import { comparisonLine, diffHeadline } from '../../src/domain/outcomediff.js';
|
|
11
|
+
import { plural, proofLine, useLocation, verdictHeadline, } from '../../src/domain/migration.js';
|
|
12
|
+
const collect = (value, previous) => [...previous, value];
|
|
13
|
+
const statusColour = (verdict) => {
|
|
14
|
+
switch (verdict.status) {
|
|
15
|
+
case 'unsupported':
|
|
16
|
+
return chalk.red(verdict.status);
|
|
17
|
+
case 'partial':
|
|
18
|
+
case 'unknown':
|
|
19
|
+
return chalk.yellow(verdict.status);
|
|
20
|
+
default:
|
|
21
|
+
return chalk.green(verdict.status);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Print one group of verdicts.
|
|
26
|
+
*
|
|
27
|
+
* Every line carries what proves it. A verdict with no fixture says so in the
|
|
28
|
+
* same breath as the verdict — that is the whole point of reading the answers
|
|
29
|
+
* out of the conformance corpus rather than writing them here.
|
|
30
|
+
*/
|
|
31
|
+
const printVerdicts = (title, verdicts, limit) => {
|
|
32
|
+
if (verdicts.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
console.log(chalk.bold(`\n${title}`));
|
|
35
|
+
for (const v of verdicts) {
|
|
36
|
+
console.log(` ${statusColour(v)} ${v.feature} ${chalk.dim(`(${plural(v.uses.length, 'use')})`)}`);
|
|
37
|
+
console.log(chalk.dim(` ${proofLine(v)}`));
|
|
38
|
+
// An unknown verdict's note only restates the line above it.
|
|
39
|
+
if (v.status !== 'unknown' && v.notes !== undefined && v.notes !== '')
|
|
40
|
+
console.log(chalk.dim(` ${v.notes}`));
|
|
41
|
+
for (const use of v.uses.slice(0, limit))
|
|
42
|
+
console.log(chalk.dim(` · ${useLocation(use)}`));
|
|
43
|
+
if (v.uses.length > limit)
|
|
44
|
+
console.log(chalk.dim(` · … and ${v.uses.length - limit} more`));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const checklistLine = (entry) => {
|
|
48
|
+
const where = entry.repos === undefined ? (entry.scope ?? '') : entry.repos.join(', ');
|
|
49
|
+
return ` ${entry.name.padEnd(32)} ${entry.kind.padEnd(9)} ${chalk.dim(where)}`;
|
|
50
|
+
};
|
|
51
|
+
const printChecklist = (scan) => {
|
|
52
|
+
const { entries, configuredNamesRead } = scan.checklist;
|
|
53
|
+
if (entries.length === 0) {
|
|
54
|
+
console.log(chalk.dim('\nNo secrets or variables are referenced or configured.'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
console.log(chalk.bold('\nRe-entry checklist'));
|
|
58
|
+
console.log(chalk.dim('Names only. No value leaves GitHub — not into this report, not into a log, not onto disk.'));
|
|
59
|
+
const group = (action, heading, withCommand = false) => {
|
|
60
|
+
const of = entries.filter((e) => e.action === action);
|
|
61
|
+
if (of.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
console.log(`\n ${heading}`);
|
|
64
|
+
for (const entry of of) {
|
|
65
|
+
console.log(checklistLine(entry));
|
|
66
|
+
// The second half of the checklist: not just what to set, but the command
|
|
67
|
+
// that sets it (TASK-0160). A secret's command reads the value from stdin,
|
|
68
|
+
// so nothing here ever suggests putting one on a command line.
|
|
69
|
+
if (withCommand)
|
|
70
|
+
console.log(chalk.dim(` $ ${setterCommand(entry.kind, entry.name, entry.repos ?? [])}`));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
group('re-enter', chalk.bold('Set these in Apex before the first run:'), true);
|
|
74
|
+
group('automatic', chalk.bold('Provided by the platform per job — nothing to re-enter:'));
|
|
75
|
+
group('unaccounted', chalk.bold('Read by a workflow, and not configured on GitHub — find out where these come from:'));
|
|
76
|
+
group('not-needed', chalk.bold('Configured on GitHub and read by none of these workflows:'));
|
|
77
|
+
if (!configuredNamesRead) {
|
|
78
|
+
console.log(chalk.yellow('\n The names configured on GitHub were not read, so this list is what the workflows reference and nothing else.'));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const printScan = (scan, limit) => {
|
|
82
|
+
const { report } = scan;
|
|
83
|
+
const headline = verdictHeadline(scan.verdict);
|
|
84
|
+
console.log(chalk.bold(`${scan.organization} — ${headline}`));
|
|
85
|
+
console.log(chalk.dim(`${plural(report.summary.files, 'workflow file')} across ${plural(scan.repositories.length, 'repository', 'repositories')}` +
|
|
86
|
+
`, ${report.summary.unreadable} unreadable · ${report.corpus.title}`));
|
|
87
|
+
console.log(chalk.dim(`${report.summary.supported} supported · ${report.summary.partial} partial · ` +
|
|
88
|
+
`${report.summary.unsupported} unsupported · ${report.summary.unknown} unknown · ${report.summary.unproven} unproven`));
|
|
89
|
+
for (const file of report.files.filter((f) => !f.assessed)) {
|
|
90
|
+
console.log(chalk.red(`\n${file.path} could not be read, so nothing here covers it`));
|
|
91
|
+
for (const d of file.diagnostics ?? []) {
|
|
92
|
+
console.log(chalk.dim(` ${d.line === undefined ? '' : `${d.line}: `}${d.severity}: ${d.message}`));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
printVerdicts('Will not run — unsupported, and used here', report.blockers, limit);
|
|
96
|
+
printVerdicts('Runs, with a limit', report.caveats, limit);
|
|
97
|
+
printVerdicts('Cannot be proved either way — no row of the matrix claims it', report.unknown, limit);
|
|
98
|
+
printVerdicts('Claimed, with no fixture behind the claim', report.unproven, limit);
|
|
99
|
+
printVerdicts('Runs as-is', report.supported, limit);
|
|
100
|
+
if (report.not_used.length > 0) {
|
|
101
|
+
console.log(chalk.bold('\nLimits these workflows never reach'));
|
|
102
|
+
console.log(chalk.dim('Listed so the silence is legible: a caveat nobody hits is not a migration blocker.'));
|
|
103
|
+
for (const row of report.not_used)
|
|
104
|
+
console.log(chalk.dim(` ${row.status.padEnd(12)} ${row.feature}`));
|
|
105
|
+
}
|
|
106
|
+
printChecklist(scan);
|
|
107
|
+
if (scan.archived.length > 0) {
|
|
108
|
+
console.log(chalk.dim(`\nSkipped ${plural(scan.archived.length, 'archived repository', 'archived repositories')}: ${scan.archived.join(', ')}`));
|
|
109
|
+
}
|
|
110
|
+
if (scan.withoutWorkflows.length > 0) {
|
|
111
|
+
console.log(chalk.dim(`${plural(scan.withoutWorkflows.length, 'repository', 'repositories')} with no workflow files: ${scan.withoutWorkflows.join(', ')}`));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Build the ports.
|
|
116
|
+
*
|
|
117
|
+
* `--path` scans a checkout on this machine and needs no token, which is the
|
|
118
|
+
* first thing somebody evaluating a migration wants: an answer about a
|
|
119
|
+
* repository they already have, without handing anybody credentials.
|
|
120
|
+
*/
|
|
121
|
+
const deps = (options) => {
|
|
122
|
+
const checker = new EngineBinaryCompat();
|
|
123
|
+
if (options.path !== undefined)
|
|
124
|
+
return { workflows: new LocalWorkflowSource(options.path), checker };
|
|
125
|
+
try {
|
|
126
|
+
const api = new GitHubApi();
|
|
127
|
+
return { workflows: api, checker, names: api };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error(chalk.red(err instanceof NoTokenError ? err.message : String(err)));
|
|
131
|
+
process.exitCode = 2;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const handler = async (org, options) => {
|
|
136
|
+
if (org === undefined && options.path === undefined) {
|
|
137
|
+
console.error(chalk.red('name an organization, or pass --path to scan a checkout on this machine'));
|
|
138
|
+
process.exitCode = 2;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const built = deps(options);
|
|
142
|
+
if (built === undefined)
|
|
143
|
+
return;
|
|
144
|
+
// A local scan has no organization. The directory's name is what the person
|
|
145
|
+
// running it calls the thing, so it is what the report is labelled with.
|
|
146
|
+
const label = org ?? basename(resolve(options.path ?? '.'));
|
|
147
|
+
const outcome = await scanOrganization(built, label, {
|
|
148
|
+
repos: options.repo.length > 0 ? options.repo : undefined,
|
|
149
|
+
includeNames: options.secrets,
|
|
150
|
+
});
|
|
151
|
+
switch (outcome.kind) {
|
|
152
|
+
case 'no-repositories':
|
|
153
|
+
console.error(chalk.yellow(`no repositories to scan for ${outcome.organization}`));
|
|
154
|
+
process.exitCode = 2;
|
|
155
|
+
return;
|
|
156
|
+
case 'no-workflows':
|
|
157
|
+
console.error(chalk.yellow(`no .github/workflows/*.yml in ${plural(outcome.repositories.length, 'repository', 'repositories')}`));
|
|
158
|
+
process.exitCode = 2;
|
|
159
|
+
return;
|
|
160
|
+
case 'engine-missing':
|
|
161
|
+
console.error(chalk.red(outcome.detail));
|
|
162
|
+
process.exitCode = 2;
|
|
163
|
+
return;
|
|
164
|
+
case 'refused':
|
|
165
|
+
console.error(chalk.red(outcome.message));
|
|
166
|
+
process.exitCode = 2;
|
|
167
|
+
return;
|
|
168
|
+
case 'scan':
|
|
169
|
+
if (options.json === true) {
|
|
170
|
+
console.log(JSON.stringify(outcome.scan, null, 2));
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
printScan(outcome.scan, options.uses);
|
|
174
|
+
}
|
|
175
|
+
process.exitCode = outcome.ok ? 0 : 1;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const ScanCommand = new Command('scan')
|
|
179
|
+
.description("Report what an organization's workflows do on Apex Actions, and what each verdict is proved by")
|
|
180
|
+
.argument('[org]', 'GitHub organization to scan')
|
|
181
|
+
.option('--repo <owner/repo>', 'limit to this repository (repeatable)', collect, [])
|
|
182
|
+
.option('--path <dir>', 'scan a checkout on this machine instead of the GitHub API')
|
|
183
|
+
.option('--no-secrets', 'skip the secret and variable name import')
|
|
184
|
+
.option('--uses <n>', 'how many places to list per feature', (v) => Number.parseInt(v, 10), 3)
|
|
185
|
+
.option('--json', 'print the scan as JSON')
|
|
186
|
+
.action(handler);
|
|
187
|
+
/**
|
|
188
|
+
* Coexistence: run both platforms on one repository, one workflow at a time.
|
|
189
|
+
*
|
|
190
|
+
* The order is the safe one and the command enforces nothing else: turn
|
|
191
|
+
* coexistence **on** first — from that moment Apex plans nothing here — then add
|
|
192
|
+
* workflows as you gain confidence in them. Doing it the other way round leaves
|
|
193
|
+
* a window in which everything runs on both, which is how a publishing workflow
|
|
194
|
+
* nearly pushed the same images twice (control-plane ADR-0046).
|
|
195
|
+
*/
|
|
196
|
+
const CoexistCommand = new Command('coexist')
|
|
197
|
+
.description('Run Apex beside GitHub Actions on one repository, opting workflows in one at a time')
|
|
198
|
+
.argument('<repository>', 'owner/repo')
|
|
199
|
+
.option('--on', 'turn coexistence on: Apex plans only opted-in workflows here')
|
|
200
|
+
.option('--off', 'turn it off: Apex is this repository’s CI and runs everything not disabled')
|
|
201
|
+
.option('--add <workflow...>', 'opt a workflow in, by path or file name')
|
|
202
|
+
.option('--remove <workflow...>', 'opt a workflow back out')
|
|
203
|
+
.option('--diff <sha>', 'compare both platforms’ outcomes for one commit')
|
|
204
|
+
.action(async (repository, opts) => {
|
|
205
|
+
const parsed = parseRepository(repository);
|
|
206
|
+
if (parsed === undefined) {
|
|
207
|
+
console.error(chalk.red(`repository must be owner/repo, got ${repository}`));
|
|
208
|
+
process.exitCode = 2;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (opts.on === true && opts.off === true) {
|
|
212
|
+
console.error(chalk.red('--on and --off contradict each other'));
|
|
213
|
+
process.exitCode = 2;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const nothing = opts.on !== true &&
|
|
217
|
+
opts.off !== true &&
|
|
218
|
+
opts.diff === undefined &&
|
|
219
|
+
(opts.add ?? []).length === 0 &&
|
|
220
|
+
(opts.remove ?? []).length === 0;
|
|
221
|
+
if (nothing) {
|
|
222
|
+
console.error(chalk.red('nothing to do: pass --on, --off, --add or --remove'));
|
|
223
|
+
process.exitCode = 2;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let api;
|
|
227
|
+
try {
|
|
228
|
+
api = new HttpControlPlane();
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
|
|
232
|
+
process.exitCode = 2;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
// On before adding, off after removing: neither order can open a window
|
|
237
|
+
// in which a workflow runs on both platforms unintentionally.
|
|
238
|
+
if (opts.on === true) {
|
|
239
|
+
await api.setCoexistence(parsed.owner, parsed.repo, true);
|
|
240
|
+
console.log(`${chalk.green('✔')} ${repository} is coexisting — Apex plans only what is opted in`);
|
|
241
|
+
}
|
|
242
|
+
for (const w of opts.add ?? []) {
|
|
243
|
+
await api.setWorkflowOptIn(parsed.owner, parsed.repo, w, true);
|
|
244
|
+
console.log(`${chalk.green('✔')} opted in ${w}`);
|
|
245
|
+
}
|
|
246
|
+
for (const w of opts.remove ?? []) {
|
|
247
|
+
await api.setWorkflowOptIn(parsed.owner, parsed.repo, w, false);
|
|
248
|
+
console.log(`${chalk.green('✔')} opted out ${w}`);
|
|
249
|
+
}
|
|
250
|
+
if (opts.off === true) {
|
|
251
|
+
await api.setCoexistence(parsed.owner, parsed.repo, false);
|
|
252
|
+
console.log(`${chalk.green('✔')} ${repository} is no longer coexisting — Apex runs everything not disabled`);
|
|
253
|
+
}
|
|
254
|
+
if (opts.diff !== undefined) {
|
|
255
|
+
const d = await api.coexistenceDiff(parsed.owner, parsed.repo, opts.diff);
|
|
256
|
+
console.log(chalk.bold(`\n${repository} @ ${d.sha.slice(0, 12)}`));
|
|
257
|
+
console.log(diffHeadline(d.summary));
|
|
258
|
+
for (const c of d.comparisons) {
|
|
259
|
+
// The two that mean something get a marker; the rest are dim,
|
|
260
|
+
// because "we could not compare this" must not read as a result.
|
|
261
|
+
const mark = c.verdict === 'agree' ? chalk.green('✔') : c.verdict === 'differ' ? chalk.yellow('≠') : chalk.dim('·');
|
|
262
|
+
const line = ` ${mark} ${c.path} ${comparisonLine(c)}`;
|
|
263
|
+
console.log(c.verdict === 'agree' || c.verdict === 'differ' ? line : chalk.dim(line));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
export default new Command('migrate')
|
|
273
|
+
.description('Plan a migration from GitHub Actions: what runs, what does not, and what has to be re-entered')
|
|
274
|
+
.addCommand(ScanCommand)
|
|
275
|
+
.addCommand(CoexistCommand);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { listRules, proveRule, readDeliveries, removeRule, saveRule } from '../../src/application/manage-notifications.js';
|
|
5
|
+
import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
|
|
6
|
+
import { DESTINATION_KINDS, destinationLine, filterLine, invalidTriggers, lastAttemptLine, listOption, sendOutcomeLine, subscriptionLine, } from '../../src/domain/notify.js';
|
|
7
|
+
import { parseRepository } from '../../src/domain/runs.js';
|
|
8
|
+
const api = () => {
|
|
9
|
+
try {
|
|
10
|
+
return new HttpControlPlane();
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
|
|
14
|
+
process.exitCode = 2;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const repository = (slug) => {
|
|
19
|
+
const parsed = parseRepository(slug);
|
|
20
|
+
if (parsed)
|
|
21
|
+
return parsed;
|
|
22
|
+
console.error(chalk.red(`expected owner/repo, got ${JSON.stringify(slug)}`));
|
|
23
|
+
process.exitCode = 2;
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
const STATUS_COLOUR = {
|
|
27
|
+
sent: chalk.green,
|
|
28
|
+
failed: chalk.red,
|
|
29
|
+
refused: chalk.yellow,
|
|
30
|
+
};
|
|
31
|
+
const printRule = (rule) => {
|
|
32
|
+
const state = rule.enabled ? chalk.green('enabled') : chalk.dim('disabled');
|
|
33
|
+
console.log(` ${chalk.bold(rule.name.padEnd(18))} ${chalk.dim(destinationLine(rule).padEnd(46))} ${state}`);
|
|
34
|
+
console.log(chalk.dim(` on ${subscriptionLine(rule)}`));
|
|
35
|
+
console.log(chalk.dim(` workflows ${filterLine(rule.workflows)}`));
|
|
36
|
+
console.log(chalk.dim(` branches ${filterLine(rule.branches)}`));
|
|
37
|
+
const colour = rule.lastStatus === undefined ? chalk.dim : STATUS_COLOUR[rule.lastStatus];
|
|
38
|
+
console.log(` ${chalk.dim('last ')} ${colour(lastAttemptLine(rule))}`);
|
|
39
|
+
};
|
|
40
|
+
const printDelivery = (d) => {
|
|
41
|
+
const colour = STATUS_COLOUR[d.status];
|
|
42
|
+
const status = `${d.status}${d.httpStatus === undefined ? '' : ` ${String(d.httpStatus)}`}`;
|
|
43
|
+
console.log(` ${chalk.dim(d.createdAt)} ${d.ruleName.padEnd(18)} ${d.trigger.padEnd(10)} ${colour(status.padEnd(12))} ${d.attempts > 1 ? chalk.dim(`${String(d.attempts)} attempts`) : ''}`);
|
|
44
|
+
// Somebody else's server's words, so they are printed as data and never
|
|
45
|
+
// interpreted (control-plane ADR-0029).
|
|
46
|
+
if (d.detail !== undefined && d.detail !== '')
|
|
47
|
+
console.log(chalk.dim(` ${d.detail}`));
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Read the destination without putting it in the shell's history.
|
|
51
|
+
*
|
|
52
|
+
* A webhook URL is a credential, and `--to https://hooks.slack.com/…` on a
|
|
53
|
+
* command line ends up in `.zsh_history` and in every `ps` on the box.
|
|
54
|
+
*/
|
|
55
|
+
const promptForUrl = async () => {
|
|
56
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
57
|
+
try {
|
|
58
|
+
const answer = await rl.question('destination URL (not echoed to your shell history): ');
|
|
59
|
+
return answer.trim();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rl.close();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const listCommand = new Command('list')
|
|
66
|
+
.description('The rules for one repository, and how each one last fared')
|
|
67
|
+
.argument('<repository>', 'owner/repo')
|
|
68
|
+
.option('--json', 'machine-readable output')
|
|
69
|
+
.action(async (slug, opts) => {
|
|
70
|
+
const target = repository(slug);
|
|
71
|
+
if (!target)
|
|
72
|
+
return;
|
|
73
|
+
const client = api();
|
|
74
|
+
if (!client)
|
|
75
|
+
return;
|
|
76
|
+
const outcome = await listRules(client, target);
|
|
77
|
+
if (outcome.kind === 'refused') {
|
|
78
|
+
console.error(chalk.red(outcome.message));
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (opts.json === true) {
|
|
83
|
+
console.log(JSON.stringify(outcome.value, null, 2));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
console.log(chalk.bold(`${target.owner}/${target.repo} · ${String(outcome.value.length)} ${outcome.value.length === 1 ? 'rule' : 'rules'}`));
|
|
87
|
+
if (outcome.value.length === 0) {
|
|
88
|
+
console.log(chalk.yellow('\n Nothing is notified about this repository.'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log('');
|
|
92
|
+
for (const rule of outcome.value) {
|
|
93
|
+
printRule(rule);
|
|
94
|
+
console.log('');
|
|
95
|
+
}
|
|
96
|
+
console.log(chalk.dim(' A destination URL is a credential and is never shown; the #fingerprint identifies it.'));
|
|
97
|
+
});
|
|
98
|
+
const addCommand = new Command('add')
|
|
99
|
+
.description('Create or replace one rule')
|
|
100
|
+
.argument('<repository>', 'owner/repo')
|
|
101
|
+
.argument('<name>', 'the rule name, e.g. ci-alerts')
|
|
102
|
+
.option('--kind <kind>', `destination type: ${DESTINATION_KINDS.join(', ')}`, 'slack')
|
|
103
|
+
// Optional so the URL can be prompted for instead of landing in shell
|
|
104
|
+
// history — and, on an update, so it can be left exactly as it is.
|
|
105
|
+
.option('--to <url>', 'the destination URL; omit to be prompted, or to keep the one already stored')
|
|
106
|
+
.option('--on <triggers>', 'comma-separated: broke, recovered, failed, succeeded (default: broke,recovered)', (v, acc) => [...acc, v], [])
|
|
107
|
+
.option('--workflow <glob>', 'only these workflow paths (repeatable); omit for all', (v, acc) => [...acc, v], [])
|
|
108
|
+
.option('--branch <glob>', 'only these branches (repeatable); omit for all', (v, acc) => [...acc, v], [])
|
|
109
|
+
.option('--disable', 'store the rule but send nothing through it')
|
|
110
|
+
.option('--json', 'machine-readable output')
|
|
111
|
+
.action(async (slug, name, opts) => {
|
|
112
|
+
const target = repository(slug);
|
|
113
|
+
if (!target)
|
|
114
|
+
return;
|
|
115
|
+
const triggers = listOption(opts.on);
|
|
116
|
+
const bad = invalidTriggers(triggers);
|
|
117
|
+
if (bad.length > 0) {
|
|
118
|
+
console.error(chalk.red(`--on accepts broke, recovered, failed or succeeded; got ${bad.join(', ')}`));
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const client = api();
|
|
123
|
+
if (!client)
|
|
124
|
+
return;
|
|
125
|
+
// An existing rule may keep its destination; a new one must be given
|
|
126
|
+
// something, and being asked beats a confusing 422 from the API.
|
|
127
|
+
const existing = await listRules(client, target);
|
|
128
|
+
const known = existing.kind === 'ok' && existing.value.some((r) => r.name === name);
|
|
129
|
+
let url = opts.to;
|
|
130
|
+
if (url === undefined && !known)
|
|
131
|
+
url = await promptForUrl();
|
|
132
|
+
const outcome = await saveRule(client, target, name, {
|
|
133
|
+
kind: opts.kind,
|
|
134
|
+
...(url === undefined || url === '' ? {} : { url }),
|
|
135
|
+
...(opts.disable === true ? { enabled: false } : {}),
|
|
136
|
+
...(triggers.length > 0 ? { on: triggers } : {}),
|
|
137
|
+
...(listOption(opts.workflow).length > 0 ? { workflows: listOption(opts.workflow) } : {}),
|
|
138
|
+
...(listOption(opts.branch).length > 0 ? { branches: listOption(opts.branch) } : {}),
|
|
139
|
+
});
|
|
140
|
+
if (outcome.kind === 'refused') {
|
|
141
|
+
console.error(chalk.red(outcome.message));
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (opts.json === true) {
|
|
146
|
+
console.log(JSON.stringify(outcome.value, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
console.log(`${chalk.green('✔')} ${target.owner}/${target.repo} · ${outcome.value.name}`);
|
|
150
|
+
printRule(outcome.value);
|
|
151
|
+
console.log(chalk.dim(`\n Prove it with: apex notify test ${target.owner}/${target.repo} ${outcome.value.name}`));
|
|
152
|
+
});
|
|
153
|
+
const removeCommand = new Command('rm')
|
|
154
|
+
.description('Remove one rule, and the record of its deliveries')
|
|
155
|
+
.argument('<repository>', 'owner/repo')
|
|
156
|
+
.argument('<name>', 'the rule name')
|
|
157
|
+
.action(async (slug, name) => {
|
|
158
|
+
const target = repository(slug);
|
|
159
|
+
if (!target)
|
|
160
|
+
return;
|
|
161
|
+
const client = api();
|
|
162
|
+
if (!client)
|
|
163
|
+
return;
|
|
164
|
+
const outcome = await removeRule(client, target, name);
|
|
165
|
+
if (outcome.kind === 'refused') {
|
|
166
|
+
console.error(chalk.red(outcome.message));
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (outcome.value)
|
|
171
|
+
console.log(`${chalk.green('✔')} removed ${name}`);
|
|
172
|
+
else {
|
|
173
|
+
console.error(chalk.yellow(`no rule named ${name}`));
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const testCommand = new Command('test')
|
|
178
|
+
.description('Send a message to one destination now, and report what it said')
|
|
179
|
+
.argument('<repository>', 'owner/repo')
|
|
180
|
+
.argument('<name>', 'the rule name')
|
|
181
|
+
.option('--json', 'machine-readable output')
|
|
182
|
+
.action(async (slug, name, opts) => {
|
|
183
|
+
const target = repository(slug);
|
|
184
|
+
if (!target)
|
|
185
|
+
return;
|
|
186
|
+
const client = api();
|
|
187
|
+
if (!client)
|
|
188
|
+
return;
|
|
189
|
+
const outcome = await proveRule(client, target, name);
|
|
190
|
+
if (outcome.kind === 'refused') {
|
|
191
|
+
console.error(chalk.red(outcome.message));
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (opts.json === true) {
|
|
196
|
+
console.log(JSON.stringify(outcome.value, null, 2));
|
|
197
|
+
if (!outcome.value.ok)
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const result = outcome.value;
|
|
202
|
+
if (result.ok) {
|
|
203
|
+
console.log(`${chalk.green('✔')} ${name} accepted the message${result.status === undefined ? '' : ` (HTTP ${String(result.status)})`}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// A refusal is our decision, a rejection is theirs, and no answer at all
|
|
207
|
+
// is neither; the difference is what somebody does next, so it is said
|
|
208
|
+
// out loud.
|
|
209
|
+
console.error(`${chalk.red('✘')} ${name}: ${sendOutcomeLine(result)}`);
|
|
210
|
+
if (result.detail !== undefined && result.detail !== '')
|
|
211
|
+
console.error(chalk.dim(` ${result.detail}`));
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
});
|
|
214
|
+
const logCommand = new Command('log')
|
|
215
|
+
.description('The last attempts to reach this repository’s destinations')
|
|
216
|
+
.argument('<repository>', 'owner/repo')
|
|
217
|
+
.option('--limit <n>', 'how many attempts to show (default 20)', '20')
|
|
218
|
+
.option('--json', 'machine-readable output')
|
|
219
|
+
.action(async (slug, opts) => {
|
|
220
|
+
const target = repository(slug);
|
|
221
|
+
if (!target)
|
|
222
|
+
return;
|
|
223
|
+
const limit = Number(opts.limit);
|
|
224
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
225
|
+
console.error(chalk.red(`--limit must be a positive number, got ${opts.limit}`));
|
|
226
|
+
process.exitCode = 2;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const client = api();
|
|
230
|
+
if (!client)
|
|
231
|
+
return;
|
|
232
|
+
const outcome = await readDeliveries(client, target, Math.min(limit, 100));
|
|
233
|
+
if (outcome.kind === 'refused') {
|
|
234
|
+
console.error(chalk.red(outcome.message));
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (opts.json === true) {
|
|
239
|
+
console.log(JSON.stringify(outcome.value, null, 2));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.log(chalk.bold(`${target.owner}/${target.repo} · last ${String(outcome.value.length)} attempts`));
|
|
243
|
+
if (outcome.value.length === 0) {
|
|
244
|
+
console.log(chalk.yellow('\n Nothing has been sent yet.'));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
console.log('');
|
|
248
|
+
for (const d of outcome.value)
|
|
249
|
+
printDelivery(d);
|
|
250
|
+
});
|
|
251
|
+
export default new Command('notify')
|
|
252
|
+
.description('Notification routing: where a repository’s outcome changes are sent')
|
|
253
|
+
.addCommand(listCommand)
|
|
254
|
+
.addCommand(addCommand)
|
|
255
|
+
.addCommand(removeCommand)
|
|
256
|
+
.addCommand(testCommand)
|
|
257
|
+
.addCommand(logCommand);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { oidcDiscovery, oidcSubject, setOidcSubject } from '../../src/application/manage-environments.js';
|
|
4
|
+
import { splitRepository } from '../../src/application/manage-caches.js';
|
|
5
|
+
import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
|
|
6
|
+
const api = () => {
|
|
7
|
+
try {
|
|
8
|
+
return new HttpControlPlane();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
|
|
12
|
+
process.exitCode = 2;
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const repository = (slug) => {
|
|
17
|
+
const parsed = splitRepository(slug);
|
|
18
|
+
if (parsed)
|
|
19
|
+
return parsed;
|
|
20
|
+
console.error(chalk.red(`expected owner/repo, got ${JSON.stringify(slug)}`));
|
|
21
|
+
process.exitCode = 2;
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
const discoveryCommand = new Command('discovery')
|
|
25
|
+
.description('The OIDC discovery document, as a cloud provider fetches it')
|
|
26
|
+
.option('--json', 'machine-readable output')
|
|
27
|
+
.action(async (opts) => {
|
|
28
|
+
const client = api();
|
|
29
|
+
if (!client)
|
|
30
|
+
return;
|
|
31
|
+
const outcome = await oidcDiscovery(client);
|
|
32
|
+
switch (outcome.kind) {
|
|
33
|
+
case 'refused':
|
|
34
|
+
console.error(chalk.red(outcome.detail));
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
case 'not-an-issuer':
|
|
38
|
+
console.error(chalk.red('this deployment is not an OIDC issuer'));
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
case 'found':
|
|
42
|
+
if (opts.json === true) {
|
|
43
|
+
console.log(JSON.stringify(outcome.document, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// The two lines a trust policy is written against. `issuer` must match
|
|
47
|
+
// it exactly, trailing slash and all.
|
|
48
|
+
console.log(`${chalk.bold('issuer')} ${String(outcome.document['issuer'] ?? '')}`);
|
|
49
|
+
console.log(`${chalk.bold('jwks_uri')} ${String(outcome.document['jwks_uri'] ?? '')}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const printSubject = (keys) => {
|
|
53
|
+
if (keys.length === 0) {
|
|
54
|
+
console.log("GitHub's default subject:");
|
|
55
|
+
console.log(' a job with an environment repo:<owner>/<name>:environment:<env>');
|
|
56
|
+
console.log(' a pull request repo:<owner>/<name>:pull_request');
|
|
57
|
+
console.log(' anything else repo:<owner>/<name>:ref:<ref>');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
console.log(`custom subject: ${keys.map((k) => `${k}:<${k}>`).join(':')}`);
|
|
61
|
+
};
|
|
62
|
+
const subjectCommand = new Command('subject')
|
|
63
|
+
.description("A repository's OIDC subject claim (repository admin)")
|
|
64
|
+
.argument('<repository>', 'owner/repo')
|
|
65
|
+
.action(async (slug) => {
|
|
66
|
+
const target = repository(slug);
|
|
67
|
+
if (!target)
|
|
68
|
+
return;
|
|
69
|
+
const client = api();
|
|
70
|
+
if (!client)
|
|
71
|
+
return;
|
|
72
|
+
const outcome = await oidcSubject(client, target.owner, target.repo);
|
|
73
|
+
if (outcome.kind === 'refused') {
|
|
74
|
+
console.error(chalk.red(outcome.detail));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
printSubject(outcome.includeClaimKeys);
|
|
79
|
+
});
|
|
80
|
+
subjectCommand.addCommand(new Command('set')
|
|
81
|
+
.description('Fold claims into the subject, or reset to the default with no --claim')
|
|
82
|
+
.argument('<repository>', 'owner/repo')
|
|
83
|
+
.option('--claim <key...>', 'claim keys, in the order they appear in the subject')
|
|
84
|
+
.action(async (slug, opts) => {
|
|
85
|
+
const target = repository(slug);
|
|
86
|
+
if (!target)
|
|
87
|
+
return;
|
|
88
|
+
const client = api();
|
|
89
|
+
if (!client)
|
|
90
|
+
return;
|
|
91
|
+
const outcome = await setOidcSubject(client, target.owner, target.repo, opts.claim ?? []);
|
|
92
|
+
if (outcome.kind === 'refused') {
|
|
93
|
+
console.error(chalk.red(outcome.detail));
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log(`${chalk.green('✔')} subject updated`);
|
|
98
|
+
printSubject(outcome.includeClaimKeys);
|
|
99
|
+
// A trust policy matches on `sub`. Changing it here breaks every policy
|
|
100
|
+
// written against the old one, and it will break at deploy time.
|
|
101
|
+
console.log(chalk.dim('Update any cloud trust policy that matches on sub before the next deployment.'));
|
|
102
|
+
}));
|
|
103
|
+
export default new Command('oidc').description('OIDC: what this issuer publishes, and what a token says it is').addCommand(discoveryCommand).addCommand(subjectCommand);
|