@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.
Files changed (81) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +84 -0
  3. package/THIRD-PARTY-NOTICES.md +1226 -0
  4. package/dist/cli/actions/main.js +193 -0
  5. package/dist/cli/approval/main.js +76 -0
  6. package/dist/cli/artifact/main.js +115 -0
  7. package/dist/cli/cache/main.js +104 -0
  8. package/dist/cli/deployment/main.js +73 -0
  9. package/dist/cli/dispatch/main.js +47 -0
  10. package/dist/cli/environment/main.js +139 -0
  11. package/dist/cli/login/main.js +73 -0
  12. package/dist/cli/logout/main.js +43 -0
  13. package/dist/cli/logs/main.js +125 -0
  14. package/dist/cli/lsp/main.js +46 -0
  15. package/dist/cli/main.js +88 -0
  16. package/dist/cli/migrate/main.js +275 -0
  17. package/dist/cli/notify/main.js +257 -0
  18. package/dist/cli/oidc/main.js +103 -0
  19. package/dist/cli/reproduce/main.js +73 -0
  20. package/dist/cli/rerun/main.js +100 -0
  21. package/dist/cli/run/main.js +50 -0
  22. package/dist/cli/runner/main.js +238 -0
  23. package/dist/cli/schedule/main.js +54 -0
  24. package/dist/cli/search/main.js +123 -0
  25. package/dist/cli/secret/main.js +102 -0
  26. package/dist/cli/secret/shared.js +104 -0
  27. package/dist/cli/spend/main.js +81 -0
  28. package/dist/cli/terms/main.js +85 -0
  29. package/dist/cli/tests/main.js +122 -0
  30. package/dist/cli/timing/main.js +90 -0
  31. package/dist/cli/validate/main.js +48 -0
  32. package/dist/cli/variable/main.js +83 -0
  33. package/dist/cli/whoami/main.js +63 -0
  34. package/dist/cli/why/main.js +106 -0
  35. package/dist/src/application/authenticate.js +56 -0
  36. package/dist/src/application/dispatch-workflow.js +23 -0
  37. package/dist/src/application/explain-runs.js +40 -0
  38. package/dist/src/application/index.js +2 -0
  39. package/dist/src/application/inspect-runs.js +29 -0
  40. package/dist/src/application/manage-actions.js +26 -0
  41. package/dist/src/application/manage-approvals.js +24 -0
  42. package/dist/src/application/manage-artifacts.js +79 -0
  43. package/dist/src/application/manage-caches.js +41 -0
  44. package/dist/src/application/manage-environments.js +136 -0
  45. package/dist/src/application/manage-notifications.js +20 -0
  46. package/dist/src/application/manage-runners.js +89 -0
  47. package/dist/src/application/manage-secrets.js +83 -0
  48. package/dist/src/application/manage-terms.js +34 -0
  49. package/dist/src/application/read-logs.js +40 -0
  50. package/dist/src/application/report-spend.js +15 -0
  51. package/dist/src/application/rerun-runs.js +34 -0
  52. package/dist/src/application/resolve-run.js +19 -0
  53. package/dist/src/application/run-workflow.js +17 -0
  54. package/dist/src/application/scan-migration.js +76 -0
  55. package/dist/src/application/search-logs.js +24 -0
  56. package/dist/src/application/serve-language-server.js +15 -0
  57. package/dist/src/application/validate-workflows.js +12 -0
  58. package/dist/src/domain/credentials.js +82 -0
  59. package/dist/src/domain/fleet-history.js +136 -0
  60. package/dist/src/domain/index.js +2 -0
  61. package/dist/src/domain/insight.js +42 -0
  62. package/dist/src/domain/logsearch.js +40 -0
  63. package/dist/src/domain/migration.js +122 -0
  64. package/dist/src/domain/notify.js +64 -0
  65. package/dist/src/domain/outcomediff.js +40 -0
  66. package/dist/src/domain/provenance.js +63 -0
  67. package/dist/src/domain/runs.js +90 -0
  68. package/dist/src/domain/secrets.js +39 -0
  69. package/dist/src/domain/spend.js +50 -0
  70. package/dist/src/domain/supplychain.js +48 -0
  71. package/dist/src/domain/terms.js +16 -0
  72. package/dist/src/domain/validation.js +4 -0
  73. package/dist/src/infrastructure/control-plane-http.js +458 -0
  74. package/dist/src/infrastructure/credentials.js +134 -0
  75. package/dist/src/infrastructure/engine-binary.js +164 -0
  76. package/dist/src/infrastructure/github-api.js +154 -0
  77. package/dist/src/infrastructure/index.js +2 -0
  78. package/dist/src/infrastructure/loopback-login.js +136 -0
  79. package/dist/src/infrastructure/runner-binary.js +74 -0
  80. package/dist/src/infrastructure/secret-input.js +52 -0
  81. package/package.json +54 -0
@@ -0,0 +1,102 @@
1
+ import { Command, Option } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { batchExitCode, listSecrets, setEverywhere } from '../../src/application/manage-secrets.js';
4
+ import { ValueInputError, promptHidden, readToEnd, readValueFile } from '../../src/infrastructure/secret-input.js';
5
+ import { api, collect, deleteAction, printResults, targetsFrom, usage, validName } from './shared.js';
6
+ /**
7
+ * The refusal, in full, because the person who typed `--value` deserves the
8
+ * reason and the alternative in one breath (BUG-0157).
9
+ */
10
+ const ARGV_REFUSAL = 'a secret is never taken from the command line: argv is visible to every user in `ps` and is saved in shell history. ' +
11
+ 'Pipe it (`printf %s "$VALUE" | apex secret set NAME --repo owner/repo`), pass --from-file, or run it on a terminal to be prompted';
12
+ /**
13
+ * The value, from anywhere but argv: a file, a pipe read to EOF, or a no-echo
14
+ * prompt when stdin is a terminal. Never echoed, never in an error.
15
+ */
16
+ const readSecretValue = async (name, fromFile) => {
17
+ try {
18
+ if (fromFile !== undefined)
19
+ return await readValueFile(fromFile);
20
+ if (process.stdin.isTTY !== true)
21
+ return await readToEnd(process.stdin);
22
+ return await promptHidden(name);
23
+ }
24
+ catch (err) {
25
+ usage(err instanceof ValueInputError ? err.message : `could not read the value: ${err instanceof Error ? err.message : String(err)}`);
26
+ return undefined;
27
+ }
28
+ };
29
+ const setCommand = new Command('set')
30
+ .description('Set a secret on one or more repositories; the value is read from stdin, a file or a no-echo prompt')
31
+ .argument('<name>', 'secret name, as a workflow reads it: secrets.NAME')
32
+ .option('--repo <owner/repo>', 'repository to set it on (repeatable: one value, many repositories)', collect, [])
33
+ .option('--org', 'the organisation scope (the operator organisation only)')
34
+ .option('--from-file <path>', 'read the value from this file (one trailing newline is dropped)')
35
+ // Declared so the refusal is ours, with the reason, and not commander's "unknown option".
36
+ .addOption(new Option('--value [value]', 'refused: a secret never goes on the command line').hideHelp())
37
+ .action(async (name, opts) => {
38
+ if (opts.value !== undefined) {
39
+ usage(ARGV_REFUSAL);
40
+ return;
41
+ }
42
+ if (!validName(name))
43
+ return;
44
+ const targets = targetsFrom(opts);
45
+ if (!targets)
46
+ return;
47
+ const client = api();
48
+ if (!client)
49
+ return;
50
+ const value = await readSecretValue(name, opts.fromFile);
51
+ if (value === undefined)
52
+ return;
53
+ if (value === '') {
54
+ usage('the value is empty; nothing was set');
55
+ return;
56
+ }
57
+ const results = await setEverywhere(client, 'secret', targets, name, value);
58
+ printResults(name, results);
59
+ process.exitCode = batchExitCode(results);
60
+ });
61
+ const listCommand = new Command('list')
62
+ .description("A repository's secret names — never values: the API has none to give")
63
+ .option('--repo <owner/repo>', 'repository', collect, [])
64
+ .option('--org', 'the organisation scope (the operator organisation only)')
65
+ .option('--json', 'machine-readable output')
66
+ .action(async (opts) => {
67
+ const targets = targetsFrom(opts, true);
68
+ if (!targets?.[0])
69
+ return;
70
+ const client = api();
71
+ if (!client)
72
+ return;
73
+ const outcome = await listSecrets(client, targets[0]);
74
+ if (outcome.kind === 'refused') {
75
+ console.error(chalk.red(outcome.detail));
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ if (opts.json === true) {
80
+ console.log(JSON.stringify({ secrets: outcome.secrets }, null, 2));
81
+ return;
82
+ }
83
+ if (outcome.secrets.length === 0) {
84
+ console.log(chalk.yellow('No secrets are set here.'));
85
+ return;
86
+ }
87
+ console.log(chalk.bold(['NAME'.padEnd(32), 'UPDATED'].join(' ')));
88
+ for (const s of outcome.secrets)
89
+ console.log([s.name.padEnd(32), s.updatedAt ?? ''].join(' '));
90
+ });
91
+ const deleteCommand = new Command('delete')
92
+ .description('Delete a secret from one or more repositories')
93
+ .argument('<name>', 'secret name')
94
+ .option('--repo <owner/repo>', 'repository (repeatable)', collect, [])
95
+ .option('--org', 'the organisation scope (the operator organisation only)')
96
+ .option('-y, --yes', 'do not ask; required when not on a terminal')
97
+ .action(deleteAction('secret'));
98
+ export default new Command('secret')
99
+ .description("Secrets: set a repository's secrets from the terminal, values write-only")
100
+ .addCommand(setCommand)
101
+ .addCommand(listCommand)
102
+ .addCommand(deleteCommand);
@@ -0,0 +1,104 @@
1
+ import chalk from 'chalk';
2
+ import { splitRepository } from '../../src/application/manage-caches.js';
3
+ import { batchExitCode, deleteEverywhere } from '../../src/application/manage-secrets.js';
4
+ import { confirm } from '../../src/infrastructure/secret-input.js';
5
+ import { nameProblem } from '../../src/domain/secrets.js';
6
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
+ export const collect = (value, previous) => [...previous, value];
8
+ export const usage = (message) => {
9
+ console.error(chalk.red(message));
10
+ process.exitCode = 2;
11
+ };
12
+ /** Build the API client, or explain what is missing and stop. */
13
+ export const api = () => {
14
+ try {
15
+ return new HttpControlPlane();
16
+ }
17
+ catch (err) {
18
+ usage(err instanceof NotConfiguredError ? err.message : String(err));
19
+ return undefined;
20
+ }
21
+ };
22
+ export const validName = (name) => {
23
+ const problem = nameProblem(name);
24
+ if (problem === undefined)
25
+ return true;
26
+ usage(problem);
27
+ return false;
28
+ };
29
+ /**
30
+ * `--repo` (repeatable) or `--org`, never both and never neither. `single`
31
+ * is for `list`, which answers about one place at a time.
32
+ */
33
+ export const targetsFrom = (opts, single = false) => {
34
+ if (opts.org === true && opts.repo.length > 0) {
35
+ usage('pass --repo or --org, not both');
36
+ return undefined;
37
+ }
38
+ if (opts.org === true)
39
+ return [{ kind: 'org' }];
40
+ if (opts.repo.length === 0) {
41
+ usage('name a repository with --repo owner/repo');
42
+ return undefined;
43
+ }
44
+ if (single && opts.repo.length > 1) {
45
+ usage('list answers for one repository at a time: pass one --repo');
46
+ return undefined;
47
+ }
48
+ const targets = [];
49
+ for (const slug of opts.repo) {
50
+ const parsed = splitRepository(slug);
51
+ if (parsed === undefined) {
52
+ usage(`expected owner/repo, got ${JSON.stringify(slug)}`);
53
+ return undefined;
54
+ }
55
+ targets.push({ kind: 'repo', ...parsed });
56
+ }
57
+ return targets;
58
+ };
59
+ /** One line per target, then nothing else: the summary is the whole output. */
60
+ export const printResults = (name, results) => {
61
+ for (const r of results) {
62
+ switch (r.kind) {
63
+ case 'set':
64
+ console.log(`${chalk.green('✔')} ${name} set on ${r.target}`);
65
+ break;
66
+ case 'deleted':
67
+ console.log(`${chalk.green('✔')} ${name} deleted from ${r.target}`);
68
+ break;
69
+ case 'not-found':
70
+ console.error(`${chalk.yellow('·')} ${r.target} has no ${name}`);
71
+ break;
72
+ case 'refused':
73
+ console.error(`${chalk.red('✖')} ${r.target}: ${r.detail}`);
74
+ break;
75
+ }
76
+ }
77
+ };
78
+ /**
79
+ * Delete from each target. Off a terminal it needs `--yes` (§7: nothing
80
+ * destructive without it); on one it asks.
81
+ */
82
+ export const deleteAction = (kind) => async (name, opts) => {
83
+ if (!validName(name))
84
+ return;
85
+ const targets = targetsFrom(opts);
86
+ if (!targets)
87
+ return;
88
+ if (opts.yes !== true) {
89
+ if (process.stdin.isTTY !== true) {
90
+ usage(`deleting a ${kind} needs --yes when not run on a terminal`);
91
+ return;
92
+ }
93
+ if (!(await confirm(`delete ${kind} ${name} from ${targets.length === 1 ? 'this target' : `${targets.length} targets`}?`))) {
94
+ usage('nothing was deleted');
95
+ return;
96
+ }
97
+ }
98
+ const client = api();
99
+ if (!client)
100
+ return;
101
+ const results = await deleteEverywhere(client, kind, targets, name);
102
+ printResults(name, results);
103
+ process.exitCode = batchExitCode(results);
104
+ };
@@ -0,0 +1,81 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { reportSpend } from '../../src/application/report-spend.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { formatMicros, formatSeconds, savingPhrase, topWorkflows } from '../../src/domain/spend.js';
6
+ import { parseRepository } from '../../src/domain/runs.js';
7
+ const DEFAULT_ROWS = 10;
8
+ const print = (report, rows) => {
9
+ const scope = report.repository ?? 'every repository';
10
+ console.log(chalk.bold(`${scope} · last ${report.windowDays} day${report.windowDays === 1 ? '' : 's'}`));
11
+ console.log('');
12
+ console.log(` jobs ${report.total.jobs}`);
13
+ console.log(` runner time ${formatSeconds(report.total.seconds)}`);
14
+ console.log(` our estimate ${chalk.bold(formatMicros(report.total.ours))}`);
15
+ console.log(` GitHub would ${formatMicros(report.total.github)}`);
16
+ const saving = savingPhrase(report.total.savedMicros, report.totalSavedPercent);
17
+ console.log(` difference ${report.total.savedMicros >= 0 ? chalk.green(saving) : chalk.yellow(saving)}`);
18
+ const shown = topWorkflows(report.workflows, rows);
19
+ if (shown.length > 0) {
20
+ // Ordered by what each cost *us*: the question is where the money goes,
21
+ // and a workflow that saves 99% of very little is not the one to look at.
22
+ console.log(chalk.bold('\n where the time goes'));
23
+ for (const w of shown) {
24
+ console.log(` ${w.name.padEnd(38)} ${formatSeconds(w.seconds).padStart(9)} ${formatMicros(w.ours).padStart(11)} ${chalk.dim(formatMicros(w.github).padStart(11))}`);
25
+ }
26
+ if (report.workflows.length > shown.length) {
27
+ console.log(chalk.dim(` … ${report.workflows.length - shown.length} more`));
28
+ }
29
+ }
30
+ // Never behind a flag: the caveats arrive with the numbers and are printed
31
+ // with them (ADR-0024).
32
+ console.log('');
33
+ for (const caveat of report.caveats)
34
+ console.log(chalk.dim(` note: ${caveat}`));
35
+ };
36
+ const handler = async (options) => {
37
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
38
+ if (options.repo !== undefined && repository === undefined) {
39
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
40
+ process.exitCode = 2;
41
+ return;
42
+ }
43
+ const days = options.days === undefined ? undefined : Number(options.days);
44
+ if (days !== undefined && (!Number.isInteger(days) || days < 1)) {
45
+ console.error(chalk.red(`--days must be a positive integer, got ${options.days ?? ''}`));
46
+ process.exitCode = 2;
47
+ return;
48
+ }
49
+ const rows = options.workflows === undefined ? DEFAULT_ROWS : Number(options.workflows);
50
+ if (!Number.isInteger(rows) || rows < 1) {
51
+ console.error(chalk.red(`--workflows must be a positive integer, got ${options.workflows ?? ''}`));
52
+ process.exitCode = 2;
53
+ return;
54
+ }
55
+ let api;
56
+ try {
57
+ api = new HttpControlPlane();
58
+ }
59
+ catch (err) {
60
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
61
+ process.exitCode = 2;
62
+ return;
63
+ }
64
+ const outcome = await reportSpend(api, { repository, days });
65
+ if (outcome.kind === 'refused') {
66
+ console.error(chalk.red(outcome.message));
67
+ process.exitCode = 2;
68
+ return;
69
+ }
70
+ if (options.json === true)
71
+ console.log(JSON.stringify(outcome.report, null, 2));
72
+ else
73
+ print(outcome.report, rows);
74
+ };
75
+ export default new Command('spend')
76
+ .description('Show what the fleet cost, next to what the same work would have cost on GitHub')
77
+ .option('--repo <owner/repo>', 'one repository (default: every repository)')
78
+ .option('--days <n>', 'window in days, clamped to 1–365 (default: 30)')
79
+ .option('--workflows <n>', `workflows to list (default: ${DEFAULT_ROWS})`)
80
+ .option('--json', 'print the report as JSON')
81
+ .action(handler);
@@ -0,0 +1,85 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { acceptTermsFor, readTermsStanding } from '../../src/application/manage-terms.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { pendingLine, standingSummary } from '../../src/domain/terms.js';
6
+ /** Build the API client, or explain what is missing and stop. */
7
+ const api = () => {
8
+ try {
9
+ return new HttpControlPlane();
10
+ }
11
+ catch (err) {
12
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
13
+ process.exitCode = 2;
14
+ return undefined;
15
+ }
16
+ };
17
+ const pendingCommand = new Command('pending')
18
+ .description('Accounts that owe an acceptance of the customer terms in force')
19
+ .option('--json', 'machine-readable output')
20
+ .action(async (opts) => {
21
+ const client = api();
22
+ if (!client)
23
+ return;
24
+ const outcome = await readTermsStanding(client);
25
+ if (outcome.kind === 'refused') {
26
+ console.error(chalk.red(outcome.message));
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ if (opts.json === true) {
31
+ console.log(JSON.stringify(outcome.value, null, 2));
32
+ return;
33
+ }
34
+ console.log(chalk.bold(`terms in force: ${outcome.value.version}`));
35
+ for (const row of outcome.value.pending)
36
+ console.log(` ${pendingLine(row)}`);
37
+ console.log('');
38
+ console.log(outcome.value.pending.length === 0 ? chalk.green(standingSummary(outcome.value)) : chalk.yellow(standingSummary(outcome.value)));
39
+ // Never behind a flag: an operator reading this list is about to write to
40
+ // the people on it, and the product asks them anyway.
41
+ if (outcome.value.pending.length > 0) {
42
+ console.log(chalk.dim(' they are asked at their next sign-in; the notice by e-mail is yours — docs/run-book/customer-agreement.md'));
43
+ }
44
+ });
45
+ const acceptCommand = new Command('accept')
46
+ .description('Accept for one account as the machine token — the escalation path, not the normal one')
47
+ .argument('<installationId>', 'the installation the account is on')
48
+ .option('--confirm', "record the acceptance; without it nothing is written")
49
+ .option('--json', 'machine-readable output')
50
+ .action(async (raw, opts) => {
51
+ const installationId = Number(raw);
52
+ if (!Number.isSafeInteger(installationId) || installationId <= 0) {
53
+ console.error(chalk.red(`expected a positive installation id, got ${JSON.stringify(raw)}`));
54
+ process.exitCode = 2;
55
+ return;
56
+ }
57
+ // `--confirm` rather than a prompt, and refusing rather than defaulting:
58
+ // the customer accepts for themselves in the app, and an operator doing it
59
+ // for them is only right when a customer has asked in writing — for an
60
+ // account whose only owner has left. It must not be reachable by leaving a
61
+ // flag off.
62
+ if (opts.confirm !== true) {
63
+ console.error(chalk.yellow(`nothing written. The customer accepts for themselves at their next sign-in.\n` +
64
+ `Re-run with --confirm only on a customer's written instruction; the row will say the machine token accepted.`));
65
+ process.exitCode = 2;
66
+ return;
67
+ }
68
+ const client = api();
69
+ if (!client)
70
+ return;
71
+ const outcome = await acceptTermsFor(client, installationId);
72
+ if (outcome.kind === 'refused') {
73
+ console.error(chalk.red(outcome.message));
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ if (opts.json === true)
78
+ console.log(JSON.stringify(outcome.value, null, 2));
79
+ else
80
+ console.log(`${chalk.green('✔')} ${outcome.value.owner} accepted ${outcome.value.version} at ${outcome.value.at}`);
81
+ });
82
+ export default new Command('terms')
83
+ .description('The customer agreement: who owes an acceptance of the terms in force, and the escalation path')
84
+ .addCommand(pendingCommand)
85
+ .addCommand(acceptCommand);
@@ -0,0 +1,122 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { inspectTests } from '../../src/application/inspect-runs.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { humanDuration, testMarker } from '../../src/domain/insight.js';
6
+ import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
7
+ const printRun = (tests, all) => {
8
+ const { summary } = tests;
9
+ if (summary.total === 0) {
10
+ // Absence of reports is not absence of failures, and saying "0 tests"
11
+ // would let a job that wrote nothing read as a job that passed everything.
12
+ console.log(chalk.dim('no test reports were found in this run’s workspaces'));
13
+ return;
14
+ }
15
+ const parts = [chalk.green(`${summary.passed} passed`)];
16
+ if (summary.failed > 0)
17
+ parts.push(chalk.red(`${summary.failed} failed`));
18
+ if (summary.skipped > 0)
19
+ parts.push(chalk.dim(`${summary.skipped} skipped`));
20
+ console.log(parts.join(' · '));
21
+ const flaky = new Set(tests.flaky);
22
+ // A green suite is a number; only the failures are worth a line each,
23
+ // unless the reader asks for everything.
24
+ const shown = all ? tests.cases : tests.cases.filter((c) => c.outcome === 'failed');
25
+ if (shown.length > 0)
26
+ console.log('');
27
+ for (const c of shown) {
28
+ const suite = c.suite === undefined || c.suite === '' ? '' : chalk.dim(`${c.suite} · `);
29
+ const took = c.durationMs === undefined ? '' : chalk.dim(` (${humanDuration(c.durationMs)})`);
30
+ const mark = c.outcome === 'failed' ? chalk.red(testMarker(c.outcome)) : c.outcome === 'passed' ? chalk.green('✓') : chalk.dim('−');
31
+ const note = flaky.has(c.name) ? chalk.yellow(' [flaky]') : '';
32
+ console.log(` ${mark} ${suite}${c.name}${took}${note}`);
33
+ if (c.outcome === 'failed' && c.message !== undefined && c.message !== '') {
34
+ console.log(chalk.dim(` ${c.message.split('\n')[0]}`));
35
+ }
36
+ }
37
+ if (!all && summary.total > shown.length) {
38
+ console.log(chalk.dim(`\n${summary.total - shown.length} more — pass --all to list every test`));
39
+ }
40
+ if (flaky.size > 0) {
41
+ console.log(chalk.yellow(`\n${flaky.size} of these failures recently passed and failed at the same commit.`));
42
+ }
43
+ };
44
+ const printFlaky = (tests, repository) => {
45
+ if (tests.length === 0) {
46
+ console.log(chalk.dim(`no test in ${repository} has passed and failed at the same commit recently`));
47
+ return;
48
+ }
49
+ console.log(chalk.bold(`${tests.length} unreliable test${tests.length === 1 ? '' : 's'} in ${repository}`));
50
+ for (const t of tests) {
51
+ const suite = t.suite === '' ? '' : chalk.dim(`${t.suite} · `);
52
+ // The number of *commits* it disagreed at, which is the ranking — forty
53
+ // failures at one commit is one bad afternoon.
54
+ const commits = `${t.disagreements.length} commit${t.disagreements.length === 1 ? '' : 's'}`;
55
+ console.log(` ${chalk.yellow(commits.padStart(10))} ${suite}${t.name} ${chalk.dim(`in ${t.jobKey}`)}`);
56
+ }
57
+ };
58
+ const handler = async (run, options) => {
59
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
60
+ if (options.repo !== undefined && repository === undefined) {
61
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ let api;
66
+ try {
67
+ api = new HttpControlPlane();
68
+ }
69
+ catch (err) {
70
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
71
+ process.exitCode = 2;
72
+ return;
73
+ }
74
+ let outcome;
75
+ try {
76
+ outcome = await inspectTests(api, { run: run ?? 'latest', repository, flaky: options.flaky === true });
77
+ }
78
+ catch (err) {
79
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
80
+ process.exitCode = 2;
81
+ return;
82
+ }
83
+ switch (outcome.kind) {
84
+ case 'needs-repo':
85
+ // A flake list belongs to a repository, not to a run. Guessing one from
86
+ // the newest run would answer about the wrong repository.
87
+ console.error(chalk.red('--flaky needs --repo owner/repo: unreliable tests are a property of a repository, not of one run'));
88
+ process.exitCode = 2;
89
+ return;
90
+ case 'no-runs':
91
+ console.error(chalk.yellow('no runs found'));
92
+ process.exitCode = 2;
93
+ return;
94
+ case 'unknown-run':
95
+ console.error(chalk.red(unknownRunMessage(outcome.selector)));
96
+ process.exitCode = 2;
97
+ return;
98
+ case 'flaky':
99
+ if (options.json === true)
100
+ console.log(JSON.stringify(outcome.tests, null, 2));
101
+ else
102
+ printFlaky(outcome.tests, outcome.repository);
103
+ return;
104
+ case 'tests':
105
+ if (options.json === true)
106
+ console.log(JSON.stringify(outcome.tests, null, 2));
107
+ else
108
+ printRun(outcome.tests, options.all === true);
109
+ // A run whose tests failed exits non-zero, so this composes in a script
110
+ // the same way the run's own conclusion does.
111
+ if (outcome.tests.summary.failed > 0)
112
+ process.exitCode = 1;
113
+ }
114
+ };
115
+ export default new Command('tests')
116
+ .description('Show a run’s test results, or a repository’s unreliable tests with --flaky')
117
+ .argument('[run]', 'run id, #number, or "latest"', 'latest')
118
+ .option('--repo <owner/repo>', 'limit the run search to one repository; required with --flaky')
119
+ .option('--flaky', 'list the repository’s tests that passed and failed at the same commit')
120
+ .option('--all', 'list every test, not only the failures')
121
+ .option('--json', 'print the results as JSON')
122
+ .action(handler);
@@ -0,0 +1,90 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { inspectTiming } from '../../src/application/inspect-runs.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { humanDuration, slowestFirst, trendNote } from '../../src/domain/insight.js';
6
+ import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
7
+ const DEFAULT_STEPS = 5;
8
+ const print = (timing, perJob) => {
9
+ console.log(chalk.bold(timing.workflowPath));
10
+ // What got slower goes first, and on its own, because it is the only part of
11
+ // this output that is news. The per-job breakdown below is reference.
12
+ if (timing.slower.length > 0) {
13
+ console.log(chalk.bold('\nSlower than usual'));
14
+ for (const step of timing.slower) {
15
+ const where = step.jobKey === undefined ? '' : chalk.dim(` in ${step.jobKey}`);
16
+ console.log(` ${chalk.yellow(step.name)}${where} ${humanDuration(step.durationMs)} ${chalk.yellow(trendNote(step) ?? '')}`);
17
+ }
18
+ }
19
+ for (const job of timing.jobs) {
20
+ const total = job.durationMs === undefined ? '' : chalk.dim(` · ${humanDuration(job.durationMs)}`);
21
+ console.log(chalk.bold(`\n${job.name}`) + total);
22
+ if (job.steps.length === 0) {
23
+ // A job whose runner predates step timings has none, and printing an
24
+ // empty list would read as "this job had no steps".
25
+ console.log(chalk.dim(' no step timings recorded'));
26
+ continue;
27
+ }
28
+ for (const step of slowestFirst(job.steps, perJob)) {
29
+ const note = trendNote(step);
30
+ const suffix = note === undefined ? chalk.dim(` (${step.samples} previous)`) : chalk.dim(` ${note}`);
31
+ console.log(` ${humanDuration(step.durationMs).padStart(8)} ${step.slower ? chalk.yellow(step.name) : step.name}${suffix}`);
32
+ }
33
+ if (job.steps.length > perJob)
34
+ console.log(chalk.dim(` … ${job.steps.length - perJob} faster steps not shown`));
35
+ }
36
+ };
37
+ const handler = async (run, options) => {
38
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
39
+ if (options.repo !== undefined && repository === undefined) {
40
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
41
+ process.exitCode = 2;
42
+ return;
43
+ }
44
+ const perJob = Number(options.steps ?? DEFAULT_STEPS);
45
+ if (!Number.isInteger(perJob) || perJob < 1) {
46
+ console.error(chalk.red(`--steps must be a positive integer, got ${String(options.steps)}`));
47
+ process.exitCode = 2;
48
+ return;
49
+ }
50
+ let api;
51
+ try {
52
+ api = new HttpControlPlane();
53
+ }
54
+ catch (err) {
55
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
56
+ process.exitCode = 2;
57
+ return;
58
+ }
59
+ let outcome;
60
+ try {
61
+ outcome = await inspectTiming(api, { run: run ?? 'latest', repository });
62
+ }
63
+ catch (err) {
64
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
65
+ process.exitCode = 2;
66
+ return;
67
+ }
68
+ switch (outcome.kind) {
69
+ case 'no-runs':
70
+ console.error(chalk.yellow('no runs found'));
71
+ process.exitCode = 2;
72
+ return;
73
+ case 'unknown-run':
74
+ console.error(chalk.red(unknownRunMessage(outcome.selector)));
75
+ process.exitCode = 2;
76
+ return;
77
+ case 'timing':
78
+ if (options.json === true)
79
+ console.log(JSON.stringify(outcome.timing, null, 2));
80
+ else
81
+ print(outcome.timing, perJob);
82
+ }
83
+ };
84
+ export default new Command('timing')
85
+ .description('Show where a run spent its time, and which steps got slower than usual')
86
+ .argument('[run]', 'run id, #number, or "latest"', 'latest')
87
+ .option('--repo <owner/repo>', 'limit the run search to one repository')
88
+ .option('--steps <n>', `slowest steps to show per job (default: ${DEFAULT_STEPS})`)
89
+ .option('--json', 'print the timings as JSON')
90
+ .action(handler);
@@ -0,0 +1,48 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { validateWorkflows } from '../../src/application/validate-workflows.js';
4
+ import { EngineBinaryValidator, GitHubWorkflowLocator } from '../../src/infrastructure/engine-binary.js';
5
+ import { diagnosticCount } from '../../src/domain/validation.js';
6
+ const printHuman = (report) => {
7
+ for (const file of report.files) {
8
+ if (file.diagnostics.length === 0) {
9
+ console.log(`${chalk.green('✔')} ${file.path}`);
10
+ continue;
11
+ }
12
+ for (const d of file.diagnostics) {
13
+ const where = d.line ? `:${d.line}:${d.column ?? 1}` : '';
14
+ const sev = d.severity === 'error' ? chalk.red(d.severity) : chalk.yellow(d.severity);
15
+ const at = d.path ? chalk.dim(` (${d.path})`) : '';
16
+ console.log(`${file.path}${where} ${sev} ${d.message}${at}`);
17
+ }
18
+ }
19
+ const n = diagnosticCount(report);
20
+ if (n > 0)
21
+ console.log(chalk.dim(`${n} diagnostic${n === 1 ? '' : 's'}`));
22
+ };
23
+ const handler = async (files, options) => {
24
+ const outcome = await validateWorkflows(new EngineBinaryValidator(), new GitHubWorkflowLocator(), files);
25
+ switch (outcome.kind) {
26
+ case 'no-workflows':
27
+ console.error(chalk.yellow('no workflow files found (.github/workflows/*.yml) — pass files explicitly'));
28
+ process.exitCode = 2;
29
+ return;
30
+ case 'engine-missing':
31
+ console.error(chalk.red(outcome.detail));
32
+ process.exitCode = 2;
33
+ return;
34
+ case 'report':
35
+ if (options.json) {
36
+ console.log(JSON.stringify(outcome.report, null, 2));
37
+ }
38
+ else {
39
+ printHuman(outcome.report);
40
+ }
41
+ process.exitCode = outcome.ok ? 0 : 1;
42
+ }
43
+ };
44
+ export default new Command('validate')
45
+ .description('Validate workflow files against the engine (defaults to .github/workflows/*.yml)')
46
+ .argument('[files...]', 'workflow files to validate ("-" reads stdin)')
47
+ .option('--json', 'print diagnostics as JSON')
48
+ .action(handler);