@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,73 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { reproduceRun } from '../../src/application/explain-runs.js';
4
+ import { HttpControlPlane, NotConfiguredError, WorkingDirectoryWriter } from '../../src/infrastructure/control-plane-http.js';
5
+ import { commandLine } from '../../src/domain/provenance.js';
6
+ import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
7
+ const handler = async (run, options) => {
8
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
9
+ if (options.repo !== undefined && repository === undefined) {
10
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
11
+ process.exitCode = 2;
12
+ return;
13
+ }
14
+ let api;
15
+ try {
16
+ api = new HttpControlPlane();
17
+ }
18
+ catch (err) {
19
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
20
+ process.exitCode = 2;
21
+ return;
22
+ }
23
+ let outcome;
24
+ try {
25
+ outcome = await reproduceRun(api, new WorkingDirectoryWriter(), {
26
+ run: run ?? 'latest',
27
+ repository,
28
+ job: options.job,
29
+ write: options.write,
30
+ });
31
+ }
32
+ catch (err) {
33
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
34
+ process.exitCode = 2;
35
+ return;
36
+ }
37
+ switch (outcome.kind) {
38
+ case 'no-runs':
39
+ console.error(chalk.yellow('no runs found'));
40
+ process.exitCode = 2;
41
+ return;
42
+ case 'unknown-run':
43
+ console.error(chalk.red(unknownRunMessage(outcome.selector)));
44
+ process.exitCode = 2;
45
+ return;
46
+ case 'recipe': {
47
+ const { recipe } = outcome;
48
+ if (options.json === true) {
49
+ console.log(JSON.stringify({ ...recipe, wrote: outcome.wrote }, null, 2));
50
+ return;
51
+ }
52
+ const leg = recipe.job === undefined ? '' : ` · ${recipe.job.name}`;
53
+ console.log(chalk.bold(`${recipe.repository} · ${recipe.workflowPath} @ ${recipe.sha.slice(0, 7)}${leg}`));
54
+ if (outcome.wrote !== undefined)
55
+ console.log(chalk.dim(`wrote ${outcome.wrote}`));
56
+ if (recipe.payloadUnavailable !== undefined)
57
+ console.log(chalk.yellow(recipe.payloadUnavailable));
58
+ // The command goes to stdout on its own line so it survives being piped
59
+ // into a shell; everything else here is commentary.
60
+ console.log(`\n${commandLine(recipe.command)}\n`);
61
+ for (const note of recipe.notes)
62
+ console.error(chalk.yellow(`note: ${note}`));
63
+ }
64
+ }
65
+ };
66
+ export default new Command('reproduce')
67
+ .description("Print the `apex run` invocation that reproduces a past run locally (secrets by name only)")
68
+ .argument('[run]', 'run id, #number, or "latest"', 'latest')
69
+ .option('--repo <owner/repo>', 'limit the run search to one repository')
70
+ .option('--job <key>', 'reproduce one matrix leg by its key, rather than the whole workflow')
71
+ .option('--no-write', 'print the command without writing the event payload file it refers to')
72
+ .option('--json', 'print the recipe as JSON')
73
+ .action(handler);
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { rerunRun } from '../../src/application/rerun-runs.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
6
+ const modeFor = (options) => (options.job !== undefined ? 'job' : options.failed === true ? 'failed' : 'all');
7
+ const handler = async (run, options) => {
8
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
9
+ if (options.repo !== undefined && repository === undefined) {
10
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
11
+ process.exitCode = 2;
12
+ return;
13
+ }
14
+ let api;
15
+ try {
16
+ api = new HttpControlPlane();
17
+ }
18
+ catch (err) {
19
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
20
+ process.exitCode = 2;
21
+ return;
22
+ }
23
+ let outcome;
24
+ try {
25
+ outcome = await rerunRun(api, {
26
+ run: run ?? 'latest',
27
+ repository,
28
+ mode: modeFor(options),
29
+ jobId: options.job,
30
+ debug: options.debug === true,
31
+ resume: options.resume === true,
32
+ });
33
+ }
34
+ catch (err) {
35
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
36
+ process.exitCode = 2;
37
+ return;
38
+ }
39
+ switch (outcome.kind) {
40
+ case 'no-runs':
41
+ console.error(chalk.yellow('no runs found'));
42
+ process.exitCode = 2;
43
+ return;
44
+ case 'unknown-run':
45
+ console.error(chalk.red(unknownRunMessage(outcome.selector)));
46
+ process.exitCode = 2;
47
+ return;
48
+ case 'needs-job':
49
+ console.error(chalk.red('--job needs an engine job id'));
50
+ process.exitCode = 2;
51
+ return;
52
+ case 'refused':
53
+ console.error(chalk.red(outcome.message));
54
+ process.exitCode = 2;
55
+ return;
56
+ case 'started': {
57
+ if (options.json === true) {
58
+ console.log(JSON.stringify({ runId: outcome.runId, resuming: outcome.resuming }, null, 2));
59
+ return;
60
+ }
61
+ console.log(chalk.green(`re-run started — ${outcome.runId}`));
62
+ if (options.resume === true) {
63
+ if (outcome.resuming.length === 0) {
64
+ // A --resume that silently found nothing looks identical to one that
65
+ // worked, and the difference is several minutes of somebody's
66
+ // afternoon.
67
+ console.error(chalk.yellow('nothing to resume from: no snapshot of this run survives, so every job starts from the top'));
68
+ }
69
+ else {
70
+ for (const snap of outcome.resuming) {
71
+ const step = snap.resumeFromName ?? snap.resumeFrom;
72
+ console.log(` ${snap.jobName} resumes at ${chalk.bold(step)} ${chalk.dim(`(${formatSize(snap.sizeBytes)} workspace)`)}`);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ };
79
+ const formatSize = (bytes) => {
80
+ if (bytes < 1024)
81
+ return `${bytes} B`;
82
+ const units = ['KB', 'MB', 'GB'];
83
+ let value = bytes / 1024;
84
+ let unit = 0;
85
+ while (value >= 1024 && unit < units.length - 1) {
86
+ value /= 1024;
87
+ unit++;
88
+ }
89
+ return `${value.toFixed(1)} ${units[unit]}`;
90
+ };
91
+ export default new Command('rerun')
92
+ .description('Re-run a completed run — all of it, only what failed, or one job — optionally resuming from the step that failed')
93
+ .argument('[run]', 'run id, #number, or "latest"', 'latest')
94
+ .option('--repo <owner/repo>', 'limit the run search to one repository')
95
+ .option('--failed', 'only the jobs that did not succeed, plus everything downstream of one')
96
+ .option('--job <id>', 'one engine job id and its dependents (matrix legs re-run as a unit, like GitHub)')
97
+ .option('--resume', 'start each job at the step it failed at, where a snapshot of that failure survives')
98
+ .option('--debug', 'enable step debug logging for the new attempt')
99
+ .option('--json', 'print the result as JSON')
100
+ .action(handler);
@@ -0,0 +1,50 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { runWorkflow } from '../../src/application/run-workflow.js';
4
+ import { RunnerBinaryExecutor } from '../../src/infrastructure/runner-binary.js';
5
+ import { GitHubWorkflowLocator } from '../../src/infrastructure/engine-binary.js';
6
+ const collect = (value, previous) => [...previous, value];
7
+ const handler = async (workflow, options) => {
8
+ const outcome = await runWorkflow(new RunnerBinaryExecutor(), new GitHubWorkflowLocator(), workflow, {
9
+ ...(options.job !== undefined ? { job: options.job } : {}),
10
+ ...(options.event !== undefined ? { event: options.event } : {}),
11
+ ...(options.payload !== undefined ? { payload: options.payload } : {}),
12
+ ...(options.image !== undefined ? { image: options.image } : {}),
13
+ secrets: options.secret,
14
+ vars: options.var,
15
+ inputs: options.input,
16
+ changedFiles: options.changedFile,
17
+ json: options.json === true,
18
+ });
19
+ switch (outcome.kind) {
20
+ case 'no-workflows':
21
+ console.error(chalk.yellow('no workflow files found (.github/workflows/*.yml) — pass one explicitly'));
22
+ process.exitCode = 2;
23
+ return;
24
+ case 'ambiguous':
25
+ console.error(chalk.yellow('multiple workflows found — pass one explicitly:'));
26
+ for (const c of outcome.candidates)
27
+ console.error(` ${c}`);
28
+ process.exitCode = 2;
29
+ return;
30
+ case 'runner-missing':
31
+ console.error(chalk.red(outcome.detail));
32
+ process.exitCode = 2;
33
+ return;
34
+ case 'exit':
35
+ process.exitCode = outcome.code;
36
+ }
37
+ };
38
+ export default new Command('run')
39
+ .description('Run a workflow locally with the same engine and executor as the service (wraps apex-runner --local)')
40
+ .argument('[workflow]', 'workflow file to run (default: the only file in .github/workflows/)')
41
+ .option('--job <id>', 'run only this job id (its needs are assumed satisfied)')
42
+ .option('--event <name>', 'event to plan against (default: push)')
43
+ .option('--payload <file>', 'JSON file with the event payload')
44
+ .option('--secret <KEY[=value]>', 'secret (repeatable): KEY alone reads the value from your environment, keeping it out of argv; GITHUB_TOKEN reaches the github context', collect, [])
45
+ .option('--var <KEY=value>', 'repository variable (repeatable)', collect, [])
46
+ .option('--input <key=value>', 'workflow_dispatch input (repeatable)', collect, [])
47
+ .option('--changed-file <path>', 'changed file for paths filters (repeatable)', collect, [])
48
+ .option('--image <image>', 'default job container image')
49
+ .option('--json', 'print the run result as JSON on stdout')
50
+ .action(handler);
@@ -0,0 +1,238 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { fleetHistory, listRunners, mintRegistrationToken, registerRunner, removeRunner, MAX_TOKEN_USES, } from '../../src/application/manage-runners.js';
4
+ import { humanSeconds, parseDuration } from '../../src/domain/fleet-history.js';
5
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
6
+ import { RunnerBinaryExecutor } from '../../src/infrastructure/runner-binary.js';
7
+ /** Build the API client, or explain what is missing and stop. */
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 age = (iso) => {
19
+ if (iso === undefined)
20
+ return '—';
21
+ const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
22
+ if (seconds < 90)
23
+ return `${seconds}s`;
24
+ if (seconds < 5400)
25
+ return `${Math.round(seconds / 60)}m`;
26
+ return `${Math.round(seconds / 3600)}h`;
27
+ };
28
+ const machineOf = (r) => {
29
+ if (r.instanceType === undefined)
30
+ return `${r.os}/${r.arch}`;
31
+ return `${r.instanceType}${r.instanceLifecycle === 'spot' ? ' (spot)' : ''}`;
32
+ };
33
+ const printRunners = (runners, demand) => {
34
+ if (runners.length === 0) {
35
+ console.log(chalk.yellow('No runners are registered.'));
36
+ }
37
+ else {
38
+ console.log(chalk.bold(['ID', 'NAME', 'POOL', 'MACHINE', 'LABELS', 'SEEN'].join(' ')));
39
+ for (const r of runners) {
40
+ console.log([r.id, r.name, r.pool ?? '—', machineOf(r), r.labels.join(','), age(r.lastSeenAt ?? r.registeredAt)].join(' '));
41
+ }
42
+ }
43
+ // The half that answers "why is my job still queued".
44
+ const waiting = demand.filter((d) => d.queued > 0);
45
+ if (waiting.length === 0) {
46
+ console.log(chalk.green('\nNothing is queued.'));
47
+ return;
48
+ }
49
+ console.log(chalk.bold('\nQUEUED WAITING IDLE BUSY RUNS-ON'));
50
+ for (const d of waiting) {
51
+ const line = [String(d.queued), `${d.oldestWaitSeconds}s`, String(d.idleRunners), String(d.busyRunners), d.labels.join(',')].join(' ');
52
+ // Nothing can take it: the single most useful thing to shout about.
53
+ console.log(d.idleRunners + d.busyRunners === 0 ? chalk.red(line) : line);
54
+ }
55
+ if (waiting.some((d) => d.idleRunners + d.busyRunners === 0)) {
56
+ console.log(chalk.red('\nRed rows have no runner whose labels satisfy them. Nothing will pick them up.'));
57
+ }
58
+ };
59
+ const listCommand = new Command('list')
60
+ .description('Registered runners, and the work waiting for them')
61
+ .option('--json', 'machine-readable output')
62
+ .action(async (opts) => {
63
+ const client = api();
64
+ if (!client)
65
+ return;
66
+ const outcome = await listRunners(client);
67
+ if (outcome.kind === 'refused') {
68
+ console.error(chalk.red(outcome.detail));
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ if (opts.json === true) {
73
+ console.log(JSON.stringify({ runners: outcome.runners, demand: outcome.demand }, null, 2));
74
+ return;
75
+ }
76
+ printRunners(outcome.runners, outcome.demand);
77
+ });
78
+ const tokenCommand = new Command('token')
79
+ .description('Mint a runner registration token (single-use by default)')
80
+ .option('--uses <n>', `registrations this token may grant, 1–${MAX_TOKEN_USES}`, '1')
81
+ .action(async (opts) => {
82
+ const client = api();
83
+ if (!client)
84
+ return;
85
+ const outcome = await mintRegistrationToken(client, Number(opts.uses));
86
+ switch (outcome.kind) {
87
+ case 'bad-uses':
88
+ console.error(chalk.red(`--uses must be a whole number between 1 and ${MAX_TOKEN_USES}, got ${opts.uses}`));
89
+ process.exitCode = 2;
90
+ return;
91
+ case 'refused':
92
+ console.error(chalk.red(outcome.detail));
93
+ process.exitCode = 1;
94
+ return;
95
+ case 'minted':
96
+ // The token itself on stdout and nothing else, so `$(...)` works; the
97
+ // commentary goes to stderr where it cannot end up in a script's
98
+ // variable — or in a log of one.
99
+ console.log(outcome.token.token);
100
+ console.error(chalk.dim(`${outcome.token.uses} use(s), expires ${outcome.token.expiresAt}. Register with:\n` +
101
+ ` apex-runner --server <host:50052> --registration-token <token>`));
102
+ }
103
+ });
104
+ const removeCommand = new Command('remove')
105
+ .description('Deregister a runner by id, revoking its credential')
106
+ .argument('<id>', 'runner id (apex runner list prints them)')
107
+ .action(async (id) => {
108
+ const client = api();
109
+ if (!client)
110
+ return;
111
+ const outcome = await removeRunner(client, id);
112
+ switch (outcome.kind) {
113
+ case 'not-found':
114
+ console.error(chalk.red(`no runner ${outcome.id}`));
115
+ process.exitCode = 1;
116
+ return;
117
+ case 'refused':
118
+ console.error(chalk.red(outcome.detail));
119
+ process.exitCode = 1;
120
+ return;
121
+ case 'removed':
122
+ console.log(`${chalk.green('✔')} runner ${id} deregistered`);
123
+ }
124
+ });
125
+ const registerCommand = new Command('register')
126
+ .description('Attach this machine as a self-hosted runner and start leasing work')
127
+ .requiredOption('--labels <labels>', 'comma-separated labels, e.g. self-hosted,linux,gpu')
128
+ .option('--server <host:port>', 'runner endpoint (APEX_RUNNER_SERVER)', process.env['APEX_RUNNER_SERVER'])
129
+ .option('--name <name>', 'display name (default: this hostname)')
130
+ .option('--work-dir <path>', 'scratch root for job workspaces')
131
+ .option('--idle-timeout <duration>', 'stop after this long with no job, e.g. 30m (default: never)')
132
+ .option('--ephemeral', 'take one job, then deregister', false)
133
+ .action(async (opts) => {
134
+ if (opts.server === undefined || opts.server === '') {
135
+ console.error(chalk.red('--server <host:port> is required (or set APEX_RUNNER_SERVER) — this is the runner.v1 endpoint, not the web URL'));
136
+ process.exitCode = 2;
137
+ return;
138
+ }
139
+ const client = api();
140
+ if (!client)
141
+ return;
142
+ // This process becomes the runner: it runs until stopped, and executes
143
+ // other people's code on this machine. Say so before it starts.
144
+ console.error(chalk.dim(`registering ${opts.name ?? 'this machine'} with ${opts.server} as [${opts.labels}]`));
145
+ const outcome = await registerRunner(client, new RunnerBinaryExecutor(), {
146
+ server: opts.server,
147
+ labels: opts.labels.split(','),
148
+ name: opts.name,
149
+ workDir: opts.workDir,
150
+ idleTimeout: opts.idleTimeout,
151
+ ephemeral: opts.ephemeral,
152
+ });
153
+ switch (outcome.kind) {
154
+ case 'no-labels':
155
+ console.error(chalk.red('--labels cannot be empty: a runner with no labels would register, look healthy, and never take a job'));
156
+ process.exitCode = 2;
157
+ return;
158
+ case 'runner-missing':
159
+ case 'refused':
160
+ console.error(chalk.red(outcome.detail));
161
+ process.exitCode = 1;
162
+ return;
163
+ case 'exit':
164
+ process.exitCode = outcome.code;
165
+ }
166
+ });
167
+ const printHistory = (h) => {
168
+ const span = Math.round((Date.parse(h.until) - Date.parse(h.since)) / 1000);
169
+ console.log(chalk.bold(`${h.since} → ${h.until}`) + chalk.dim(` (${humanSeconds(span)})`));
170
+ console.log(chalk.dim(`${h.counted} runner${h.counted === 1 ? '' : 's'} existed in this window`));
171
+ if (h.emptyThroughout) {
172
+ console.log(chalk.green('\nThe fleet held no instances at any point in this window.'));
173
+ }
174
+ else {
175
+ console.log(chalk.bold('\nPeak'));
176
+ console.log(` ${chalk.bold(String(h.peak.count))} instances at once, at ${h.peak.at}`);
177
+ for (const p of h.peakByPool)
178
+ console.log(chalk.dim(` ${String(p.count).padStart(3)} ${p.pool} (${p.at})`));
179
+ }
180
+ console.log(chalk.bold('\nAt zero'));
181
+ if (h.secondsAtZero === 0) {
182
+ console.log(chalk.yellow(' never — the fleet held at least one instance throughout'));
183
+ }
184
+ else {
185
+ const share = Math.round((h.secondsAtZero / Math.max(1, span)) * 100);
186
+ console.log(` ${humanSeconds(h.secondsAtZero)} of ${humanSeconds(span)} (${share}%), in ${h.zeroWindows.length} stretch${h.zeroWindows.length === 1 ? '' : 'es'}`);
187
+ if (h.longestZero)
188
+ console.log(` longest ${chalk.bold(humanSeconds(h.longestZero.seconds))}: ${h.longestZero.from} → ${h.longestZero.to}`);
189
+ }
190
+ if (h.excluded.length > 0) {
191
+ // Named, never silently dropped: a capacity number that quietly ignores
192
+ // machines is the kind that gets quoted back later.
193
+ console.log(chalk.bold('\nNot counted'));
194
+ for (const e of h.excluded)
195
+ console.log(chalk.dim(` ${e.name} — ${e.reason}`));
196
+ }
197
+ console.log(chalk.dim(`\nReconstructed from runner lifetimes; an instance's end is accurate to ~${h.resolutionSeconds}s (one long-poll window).`));
198
+ };
199
+ const historyCommand = new Command('history')
200
+ .description('How big the fleet was, and when it was at zero, over a window that has passed')
201
+ .option('--since <duration>', 'how far back to look: 24h, 90m, 2d', '24h')
202
+ .option('--until <iso>', 'end of the window (default: now)')
203
+ .option('--json', 'machine-readable output')
204
+ .action(async (opts) => {
205
+ const seconds = parseDuration(opts.since);
206
+ if (seconds === undefined) {
207
+ console.error(chalk.red(`--since must be a duration like 24h, 90m or 2d, got ${opts.since}`));
208
+ process.exitCode = 2;
209
+ return;
210
+ }
211
+ const until = opts.until === undefined ? new Date() : new Date(opts.until);
212
+ if (Number.isNaN(until.getTime())) {
213
+ console.error(chalk.red(`--until must be an ISO 8601 timestamp, got ${opts.until ?? ''}`));
214
+ process.exitCode = 2;
215
+ return;
216
+ }
217
+ const client = api();
218
+ if (!client)
219
+ return;
220
+ const outcome = await fleetHistory(client, { since: new Date(until.getTime() - seconds * 1000), until });
221
+ if (outcome.kind === 'refused') {
222
+ console.error(chalk.red(outcome.detail));
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ if (opts.json === true) {
227
+ console.log(JSON.stringify(outcome.history, null, 2));
228
+ return;
229
+ }
230
+ printHistory(outcome.history);
231
+ });
232
+ export default new Command('runner')
233
+ .description('Runners: attach this machine, list what is registered, mint tokens, deregister, read the fleet’s past')
234
+ .addCommand(listCommand)
235
+ .addCommand(historyCommand)
236
+ .addCommand(tokenCommand)
237
+ .addCommand(registerCommand)
238
+ .addCommand(removeCommand);
@@ -0,0 +1,54 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { listSchedules } 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 printSchedules = (schedules) => {
17
+ if (schedules.length === 0) {
18
+ console.log(chalk.yellow('Nothing is scheduled.'));
19
+ // The commonest cause by a distance, and the one nobody guesses.
20
+ console.log(chalk.dim('A schedule is read from the default branch. A `schedule:` block on any other branch never runs — on GitHub either.'));
21
+ return;
22
+ }
23
+ console.log(chalk.bold(['NEXT (UTC)', 'CRON', 'WORKFLOW'].join(' ')));
24
+ for (const s of schedules)
25
+ console.log([s.nextRunAt, s.cron, s.workflowPath].join(' '));
26
+ console.log(chalk.dim('\nEverything is UTC, as on GitHub. Schedules are derived from the default branch and rebuilt whenever it is read.'));
27
+ };
28
+ const listCommand = new Command('list')
29
+ .description('What a repository has scheduled, and when each fires next')
30
+ .argument('<repository>', 'owner/repo')
31
+ .option('--json', 'machine-readable output')
32
+ .action(async (slug, opts) => {
33
+ const target = splitRepository(slug);
34
+ if (!target) {
35
+ console.error(chalk.red(`expected owner/repo, got ${JSON.stringify(slug)}`));
36
+ process.exitCode = 2;
37
+ return;
38
+ }
39
+ const client = api();
40
+ if (!client)
41
+ return;
42
+ const outcome = await listSchedules(client, target.owner, target.repo);
43
+ if (outcome.kind === 'refused') {
44
+ console.error(chalk.red(outcome.detail));
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ if (opts.json === true) {
49
+ console.log(JSON.stringify({ schedules: outcome.schedules }, null, 2));
50
+ return;
51
+ }
52
+ printSchedules(outcome.schedules);
53
+ });
54
+ export default new Command('schedule').description('Schedules: what `on: schedule` will fire, and when').addCommand(listCommand);
@@ -0,0 +1,123 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { searchLogs } from '../../src/application/search-logs.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { resultRef, resultTitle } from '../../src/domain/logsearch.js';
6
+ import { parseRepository } from '../../src/domain/runs.js';
7
+ /** The matched terms in bold, the rest as written. */
8
+ const highlight = (hit) => {
9
+ let out = '';
10
+ let cursor = 0;
11
+ for (const span of hit.spans) {
12
+ if (span.start < cursor)
13
+ continue; // overlapping terms: the first one won
14
+ out += hit.text.slice(cursor, span.start) + chalk.bold.yellow(hit.text.slice(span.start, span.end));
15
+ cursor = span.end;
16
+ }
17
+ return out + hit.text.slice(cursor) + (hit.clipped === true ? chalk.dim(' …') : '');
18
+ };
19
+ const print = (report) => {
20
+ console.log(chalk.bold(`${report.repository} · ${JSON.stringify(report.query)} · last ${report.windowDays} day${report.windowDays === 1 ? '' : 's'}`));
21
+ console.log('');
22
+ for (const result of report.results) {
23
+ const state = result.conclusion ?? result.status;
24
+ const colour = state === 'success' ? chalk.green : state === 'failure' ? chalk.red : chalk.yellow;
25
+ console.log(` ${chalk.bold(resultTitle(result))} ${colour(state)}`);
26
+ console.log(chalk.dim(` ${resultRef(result)} · ${result.jobId}`));
27
+ for (const hit of result.hits) {
28
+ const where = hit.step === undefined ? '' : chalk.dim(` ${hit.step}`);
29
+ console.log(` ${chalk.dim(String(hit.line).padStart(6))}${where} ${highlight(hit)}`);
30
+ }
31
+ if (result.moreHits !== undefined)
32
+ console.log(chalk.dim(` … ${result.moreHits} more matching lines in this job`));
33
+ console.log('');
34
+ }
35
+ if (report.results.length === 0)
36
+ console.log(chalk.yellow(' no matches\n'));
37
+ console.log(chalk.dim(` ${report.matchedLines} matching line${report.matchedLines === 1 ? '' : 's'} in ${report.matchedJobs} of ${report.scanned} jobs read`));
38
+ // Never behind a flag: what a search did not cover reads as an absence of
39
+ // matches unless it is said out loud (ADR-0026).
40
+ for (const note of report.notes)
41
+ console.log(chalk.dim(` note: ${note}`));
42
+ };
43
+ const handler = async (positional, options) => {
44
+ // `-flaky error` is a valid query and commander reads any argv token starting
45
+ // with `-` as an option, so the documented exclusion syntax is unusable as
46
+ // the *first* term of a positional argument. `-e/--query` is the way past it,
47
+ // the same way `grep -e` is; `--` works too. Found on dev, v0.24.0.
48
+ const query = options.query ?? positional;
49
+ if (query === undefined || query.trim() === '') {
50
+ console.error(chalk.red('a query is required — pass it as an argument, or with -e when it starts with "-"'));
51
+ console.error(chalk.dim(' apex search "OOMKilled" --repo owner/repo'));
52
+ console.error(chalk.dim(' apex search -e "-flaky error" --repo owner/repo'));
53
+ process.exitCode = 2;
54
+ return;
55
+ }
56
+ if (options.repo === undefined) {
57
+ console.error(chalk.red('--repo owner/repo is required — a log search is scoped to one repository'));
58
+ process.exitCode = 2;
59
+ return;
60
+ }
61
+ const repository = parseRepository(options.repo);
62
+ if (repository === undefined) {
63
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
64
+ process.exitCode = 2;
65
+ return;
66
+ }
67
+ const days = options.days === undefined ? undefined : Number(options.days);
68
+ if (days !== undefined && (!Number.isInteger(days) || days < 1)) {
69
+ console.error(chalk.red(`--days must be a positive integer, got ${options.days ?? ''}`));
70
+ process.exitCode = 2;
71
+ return;
72
+ }
73
+ const limit = options.limit === undefined ? undefined : Number(options.limit);
74
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
75
+ console.error(chalk.red(`--limit must be a positive integer, got ${options.limit ?? ''}`));
76
+ process.exitCode = 2;
77
+ return;
78
+ }
79
+ let api;
80
+ try {
81
+ api = new HttpControlPlane();
82
+ }
83
+ catch (err) {
84
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
85
+ process.exitCode = 2;
86
+ return;
87
+ }
88
+ const outcome = await searchLogs(api, repository, {
89
+ query,
90
+ workflow: options.workflow,
91
+ branch: options.branch,
92
+ conclusion: options.conclusion,
93
+ job: options.job,
94
+ caseSensitive: options.case,
95
+ days,
96
+ limit,
97
+ });
98
+ if (outcome.kind === 'refused') {
99
+ console.error(chalk.red(outcome.message));
100
+ process.exitCode = 2;
101
+ return;
102
+ }
103
+ if (options.json === true)
104
+ console.log(JSON.stringify(outcome.report, null, 2));
105
+ else
106
+ print(outcome.report);
107
+ // Nothing found is not a failure — a search that answers "no" answered.
108
+ process.exitCode = 0;
109
+ };
110
+ export default new Command('search')
111
+ .description('Search a repository’s log text across runs')
112
+ .argument('[query]', 'terms, "a phrase" to keep words together, -term to exclude; all required terms must be on the same line')
113
+ .option('-e, --query <query>', 'the query, for one that starts with "-" — an argument beginning with a dash is read as an option')
114
+ .option('--repo <owner/repo>', 'the repository to search (required)')
115
+ .option('--workflow <path>', 'one workflow file')
116
+ .option('--branch <name>', 'one branch, written as a name or a full ref')
117
+ .option('--conclusion <state>', 'jobs that ended this way, e.g. failure')
118
+ .option('--job <name>', 'one job by name or matrix key')
119
+ .option('--case', 'match case (default: fold it)')
120
+ .option('--days <n>', 'window in days, clamped to 1–365 (default: 30)')
121
+ .option('--limit <n>', 'jobs whose logs are read, newest first (default: 200)')
122
+ .option('--json', 'print the report as JSON')
123
+ .action(handler);