@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,83 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { batchExitCode, listVariables, setEverywhere } from '../../src/application/manage-secrets.js';
4
+ import { readToEnd } from '../../src/infrastructure/secret-input.js';
5
+ import { api, collect, deleteAction, printResults, targetsFrom, usage, validName } from '../secret/shared.js';
6
+ /**
7
+ * `apex variable`: the same routes as `apex secret`, for values that are not
8
+ * secret. A variable is readable back — `list` prints it — so `--value` on the
9
+ * command line is allowed here, and is the ordinary way to set one.
10
+ */
11
+ const setCommand = new Command('set')
12
+ .description('Set a variable on one or more repositories, from --value or stdin')
13
+ .argument('<name>', 'variable name, as a workflow reads it: vars.NAME')
14
+ .option('--value <value>', 'the value (not secret, so a flag is allowed); otherwise read from stdin')
15
+ .option('--repo <owner/repo>', 'repository to set it on (repeatable)', collect, [])
16
+ .option('--org', 'the organisation scope (the operator organisation only)')
17
+ .action(async (name, opts) => {
18
+ if (!validName(name))
19
+ return;
20
+ const targets = targetsFrom(opts);
21
+ if (!targets)
22
+ return;
23
+ let value = opts.value;
24
+ if (value === undefined) {
25
+ if (process.stdin.isTTY === true) {
26
+ usage('pass --value, or pipe the value on stdin');
27
+ return;
28
+ }
29
+ value = await readToEnd(process.stdin);
30
+ }
31
+ if (value === '') {
32
+ usage('the value is empty; nothing was set');
33
+ return;
34
+ }
35
+ const client = api();
36
+ if (!client)
37
+ return;
38
+ const results = await setEverywhere(client, 'variable', targets, name, value);
39
+ printResults(name, results);
40
+ process.exitCode = batchExitCode(results);
41
+ });
42
+ const listCommand = new Command('list')
43
+ .description("A repository's variables, names and values")
44
+ .option('--repo <owner/repo>', 'repository', collect, [])
45
+ .option('--org', 'the organisation scope (the operator organisation only)')
46
+ .option('--json', 'machine-readable output')
47
+ .action(async (opts) => {
48
+ const targets = targetsFrom(opts, true);
49
+ if (!targets?.[0])
50
+ return;
51
+ const client = api();
52
+ if (!client)
53
+ return;
54
+ const outcome = await listVariables(client, targets[0]);
55
+ if (outcome.kind === 'refused') {
56
+ console.error(chalk.red(outcome.detail));
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ if (opts.json === true) {
61
+ console.log(JSON.stringify({ variables: outcome.variables }, null, 2));
62
+ return;
63
+ }
64
+ if (outcome.variables.length === 0) {
65
+ console.log(chalk.yellow('No variables are set here.'));
66
+ return;
67
+ }
68
+ console.log(chalk.bold(['NAME'.padEnd(32), 'VALUE'].join(' ')));
69
+ for (const v of outcome.variables)
70
+ console.log([v.name.padEnd(32), v.value].join(' '));
71
+ });
72
+ const deleteCommand = new Command('delete')
73
+ .description('Delete a variable from one or more repositories')
74
+ .argument('<name>', 'variable name')
75
+ .option('--repo <owner/repo>', 'repository (repeatable)', collect, [])
76
+ .option('--org', 'the organisation scope (the operator organisation only)')
77
+ .option('-y, --yes', 'do not ask; required when not on a terminal')
78
+ .action(deleteAction('variable'));
79
+ export default new Command('variable')
80
+ .description("Variables: set a repository's non-secret configuration from the terminal")
81
+ .addCommand(setCommand)
82
+ .addCommand(listCommand)
83
+ .addCommand(deleteCommand);
@@ -0,0 +1,63 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { whoami } from '../../src/application/authenticate.js';
4
+ import { describeSource } from '../../src/domain/credentials.js';
5
+ import { credentialsPath } from '../../src/infrastructure/credentials.js';
6
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
+ const credentialLine = (credential) => {
8
+ if (credential === undefined)
9
+ return 'unknown';
10
+ if (credential.kind !== 'token')
11
+ return credential.kind === 'machine' ? 'the machine token (operator)' : 'a signed-in session';
12
+ const source = credential.source === 'login' ? 'minted by apex login' : credential.source === 'dashboard' ? 'minted on the dashboard' : 'a token';
13
+ return `token, ${source}${credential.expiresAt === undefined ? '' : `, expires ${credential.expiresAt}`}`;
14
+ };
15
+ /**
16
+ * `apex whoami`: who the control plane thinks this CLI is, and which
17
+ * credential got it there. The answer is the server's (`GET /api/v1/me`), not
18
+ * a guess from the local file; the credential itself is never printed.
19
+ */
20
+ export default new Command('whoami')
21
+ .description('Who the CLI is acting as, with which credential, and where it found it')
22
+ .option('--json', 'machine-readable output')
23
+ .action(async (opts) => {
24
+ let client;
25
+ try {
26
+ client = new HttpControlPlane();
27
+ }
28
+ catch (err) {
29
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
30
+ process.exitCode = 2;
31
+ return;
32
+ }
33
+ const outcome = await whoami(client);
34
+ if (outcome.kind === 'refused') {
35
+ console.error(chalk.red(outcome.detail));
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ const { identity, source } = outcome;
40
+ const login = identity.actor.kind === 'user' ? identity.actor.login : undefined;
41
+ const foundIn = source === 'login' ? `${describeSource(source)} (${credentialsPath()})` : describeSource(source);
42
+ if (opts.json === true) {
43
+ console.log(JSON.stringify({
44
+ actor: identity.actor.kind,
45
+ ...(login === undefined ? {} : { login }),
46
+ // Rebuilt field by field: whatever else the server sends, only these three are repeated.
47
+ credential: identity.credential === undefined
48
+ ? null
49
+ : {
50
+ kind: identity.credential.kind,
51
+ ...(identity.credential.source === undefined ? {} : { source: identity.credential.source }),
52
+ ...(identity.credential.expiresAt === undefined ? {} : { expiresAt: identity.credential.expiresAt }),
53
+ },
54
+ foundIn: source,
55
+ apiUrl: client.baseUrl,
56
+ }, null, 2));
57
+ return;
58
+ }
59
+ console.log(`${'acting as'.padEnd(14)}${login ?? 'the machine principal'}`);
60
+ console.log(`${'credential'.padEnd(14)}${credentialLine(identity.credential)}`);
61
+ console.log(`${'found in'.padEnd(14)}${foundIn}`);
62
+ console.log(`${'control plane'.padEnd(14)}${client.baseUrl}`);
63
+ });
@@ -0,0 +1,106 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { explainWhy } from '../../src/application/explain-runs.js';
4
+ import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
+ import { jobMarker, retryNote, verdictLine, worthShowing } from '../../src/domain/provenance.js';
6
+ import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
7
+ const printRun = (e) => {
8
+ const { run, repository, trigger } = e;
9
+ console.log(chalk.bold(`Run #${run.runNumber} · ${run.workflowName}`) + chalk.dim(` · ${repository.owner}/${repository.name}`));
10
+ const by = trigger.actor === undefined ? '' : ` by ${trigger.actor}`;
11
+ console.log(` ${trigger.event} on ${trigger.ref} at ${trigger.sha.slice(0, 7)}${by}`);
12
+ if (e.verdict?.reason !== undefined)
13
+ console.log(` ${e.verdict.reason}`);
14
+ if (trigger.deliveryId !== undefined)
15
+ console.log(chalk.dim(` delivery ${trigger.deliveryId}`));
16
+ console.log(chalk.bold('\nJobs'));
17
+ for (const job of e.jobs) {
18
+ const where = job.instanceType === undefined ? '' : chalk.dim(` (${job.instanceType})`);
19
+ console.log(` ${jobMarker(job)} ${job.name.padEnd(24)} ${job.reason}${where}`);
20
+ // A job that was retried says so on its own line, including when it went on
21
+ // to succeed — that is the case worth seeing, not the failure.
22
+ const retry = retryNote(job);
23
+ if (retry !== undefined)
24
+ console.log(chalk.dim(` ↻ ${retry}`));
25
+ }
26
+ // What the same webhook decided about everything else. This is the half
27
+ // that answers "…and why didn't the other one run?", which is the question
28
+ // people actually arrive with.
29
+ const others = e.siblings.filter(worthShowing);
30
+ if (others.length > 0) {
31
+ console.log(chalk.bold('\nAlso decided by this delivery'));
32
+ for (const t of others)
33
+ console.log(` ${t.workflowPath}\n ${chalk.yellow(verdictLine(t))}`);
34
+ }
35
+ };
36
+ const printDelivery = (e) => {
37
+ const { delivery } = e;
38
+ const action = delivery.action === undefined ? '' : `.${delivery.action}`;
39
+ console.log(chalk.bold(`Delivery ${delivery.deliveryId}`) + chalk.dim(` · ${delivery.event}${action} · ${delivery.status}`));
40
+ console.log(` ${e.summary}`);
41
+ if (e.verdicts.length > 0) {
42
+ console.log(chalk.bold('\nWorkflows considered'));
43
+ for (const t of e.verdicts) {
44
+ const line = t.outcome === 'planned' ? chalk.green(verdictLine(t)) : chalk.yellow(verdictLine(t));
45
+ console.log(` ${t.workflowPath}\n ${line}`);
46
+ }
47
+ }
48
+ };
49
+ const handler = async (run, options) => {
50
+ const repository = options.repo === undefined ? undefined : parseRepository(options.repo);
51
+ if (options.repo !== undefined && repository === undefined) {
52
+ console.error(chalk.red(`--repo must be owner/repo, got ${options.repo}`));
53
+ process.exitCode = 2;
54
+ return;
55
+ }
56
+ let api;
57
+ try {
58
+ api = new HttpControlPlane();
59
+ }
60
+ catch (err) {
61
+ console.error(chalk.red(err instanceof NotConfiguredError ? err.message : String(err)));
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ let outcome;
66
+ try {
67
+ outcome = await explainWhy(api, { run: run ?? 'latest', repository, delivery: options.delivery });
68
+ }
69
+ catch (err) {
70
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
71
+ process.exitCode = 2;
72
+ return;
73
+ }
74
+ switch (outcome.kind) {
75
+ case 'no-runs':
76
+ console.error(chalk.yellow('no runs found'));
77
+ process.exitCode = 2;
78
+ return;
79
+ case 'unknown-run':
80
+ console.error(chalk.red(unknownRunMessage(outcome.selector)));
81
+ process.exitCode = 2;
82
+ return;
83
+ case 'unknown-delivery':
84
+ console.error(chalk.red(`no delivery ${outcome.selector} is stored`));
85
+ process.exitCode = 2;
86
+ return;
87
+ case 'run':
88
+ if (options.json === true)
89
+ console.log(JSON.stringify(outcome.explanation, null, 2));
90
+ else
91
+ printRun(outcome.explanation);
92
+ return;
93
+ case 'delivery':
94
+ if (options.json === true)
95
+ console.log(JSON.stringify(outcome.explanation, null, 2));
96
+ else
97
+ printDelivery(outcome.explanation);
98
+ }
99
+ };
100
+ export default new Command('why')
101
+ .description('Explain why a run happened — or, with --delivery, why a webhook produced no run at all')
102
+ .argument('[run]', 'run id, #number, or "latest"', 'latest')
103
+ .option('--repo <owner/repo>', 'limit the run search to one repository')
104
+ .option('--delivery <id>', 'explain a webhook delivery instead of a run')
105
+ .option('--json', 'print the explanation as JSON')
106
+ .action(handler);
@@ -0,0 +1,56 @@
1
+ import { authorizeUrl } from '../domain/credentials.js';
2
+ export const login = async (deps) => {
3
+ const pair = deps.pkce();
4
+ const listener = await deps.listen(pair.state);
5
+ try {
6
+ const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name });
7
+ deps.announce(url);
8
+ deps.openBrowser?.(url);
9
+ const callback = await listener.wait();
10
+ if (callback.kind !== 'code')
11
+ return callback;
12
+ let minted;
13
+ try {
14
+ minted = await deps.exchange.redeem(callback.code, pair.verifier);
15
+ }
16
+ catch (err) {
17
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
18
+ }
19
+ await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
20
+ return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
21
+ }
22
+ finally {
23
+ await listener.close();
24
+ }
25
+ };
26
+ /**
27
+ * Revoke the saved token at the control plane that minted it, then forget it
28
+ * (control-plane ADR-0064: a token may revoke itself and nothing else). The file
29
+ * goes either way — keeping a credential because the network was down would be
30
+ * the wrong failure — and a revocation that did not happen is reported as such,
31
+ * never implied.
32
+ */
33
+ export const logout = async (store, revoke) => {
34
+ const saved = store.read();
35
+ let revoked = { failed: 'no readable saved token' };
36
+ if (saved !== undefined) {
37
+ try {
38
+ await revoke(saved);
39
+ revoked = true;
40
+ }
41
+ catch (err) {
42
+ revoked = { failed: err instanceof Error ? err.message : String(err) };
43
+ }
44
+ }
45
+ if (!(await store.remove()))
46
+ return { kind: 'nothing', location: store.location };
47
+ return { kind: 'removed', location: store.location, revoked };
48
+ };
49
+ export const whoami = async (api) => {
50
+ try {
51
+ return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource };
52
+ }
53
+ catch (err) {
54
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
55
+ }
56
+ };
@@ -0,0 +1,23 @@
1
+ // application layer — see ../../../../docs/conventions.md §1
2
+ import { parseInputs, parseRepository } from '../domain/runs.js';
3
+ /**
4
+ * Trigger a `workflow_dispatch`. Everything the API can refuse — an unknown
5
+ * repository, a disabled workflow, a workflow that does not declare dispatch,
6
+ * a required input left out — is refused *by the API*, and reported here with
7
+ * the reason it gave. The CLI does not second-guess it.
8
+ */
9
+ export const dispatchWorkflow = async (api, request) => {
10
+ const repository = parseRepository(request.repository);
11
+ if (!repository)
12
+ return { kind: 'bad-repository', given: request.repository };
13
+ const { inputs, invalid } = parseInputs(request.inputs);
14
+ if (invalid.length > 0)
15
+ return { kind: 'bad-inputs', invalid };
16
+ try {
17
+ const { deliveryId } = await api.dispatch(repository.owner, repository.repo, request.workflow, request.ref, inputs);
18
+ return { kind: 'dispatched', deliveryId };
19
+ }
20
+ catch (err) {
21
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
22
+ }
23
+ };
@@ -0,0 +1,40 @@
1
+ import { resolveRunId } from './resolve-run.js';
2
+ /**
3
+ * Explain a run, or a delivery.
4
+ *
5
+ * A delivery is addressed directly rather than resolved through the run list,
6
+ * because the interesting deliveries are precisely the ones with no run to
7
+ * find them by.
8
+ */
9
+ export const explainWhy = async (api, request) => {
10
+ if (request.delivery !== undefined) {
11
+ const explanation = await api.explainDelivery(request.delivery);
12
+ return explanation === undefined ? { kind: 'unknown-delivery', selector: request.delivery } : { kind: 'delivery', explanation };
13
+ }
14
+ const resolved = await resolveRunId(api, request);
15
+ if (resolved.kind !== 'run')
16
+ return resolved;
17
+ const explanation = await api.explainRun(resolved.id);
18
+ return explanation === undefined ? { kind: 'unknown-run', selector: request.run } : { kind: 'run', explanation };
19
+ };
20
+ /**
21
+ * Build the local invocation for a past run, and put the event payload where
22
+ * that invocation expects to find it.
23
+ *
24
+ * Writing the file is part of the recipe rather than a separate step a reader
25
+ * has to infer: the command names `apex-event.json`, so a command that
26
+ * prints without writing it is a command that does not work.
27
+ */
28
+ export const reproduceRun = async (api, files, request) => {
29
+ const resolved = await resolveRunId(api, request);
30
+ if (resolved.kind !== 'run')
31
+ return resolved;
32
+ const recipe = await api.reproduction(resolved.id, request.job);
33
+ if (recipe === undefined)
34
+ return { kind: 'unknown-run', selector: request.run };
35
+ if (request.write && recipe.payload !== undefined) {
36
+ await files.write(recipe.payloadFile, `${JSON.stringify(recipe.payload, null, 2)}\n`);
37
+ return { kind: 'recipe', recipe, wrote: recipe.payloadFile };
38
+ }
39
+ return { kind: 'recipe', recipe };
40
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ // application layer — see ../../../../docs/conventions.md §1
@@ -0,0 +1,29 @@
1
+ import { resolveRunId } from './resolve-run.js';
2
+ export const inspectTiming = async (api, request) => {
3
+ const selected = await resolve(api, request);
4
+ if (selected.kind !== 'run')
5
+ return selected;
6
+ const timing = await api.runTiming(selected.id);
7
+ return timing === undefined ? { kind: 'unknown-run', selector: request.run } : { kind: 'timing', timing };
8
+ };
9
+ /**
10
+ * A run's tests, or — with `flaky` — a repository's unreliable ones.
11
+ *
12
+ * The flake list is a property of a repository rather than of a run, so it
13
+ * needs `--repo` and says so rather than guessing from the newest run: a list
14
+ * of the wrong repository's flaky tests is worse than a refusal.
15
+ */
16
+ export const inspectTests = async (api, request) => {
17
+ if (request.flaky) {
18
+ if (request.repository === undefined)
19
+ return { kind: 'needs-repo' };
20
+ const tests = await api.flakyTests(request.repository.owner, request.repository.repo);
21
+ return { kind: 'flaky', tests, repository: `${request.repository.owner}/${request.repository.repo}` };
22
+ }
23
+ const selected = await resolve(api, request);
24
+ if (selected.kind !== 'run')
25
+ return selected;
26
+ const tests = await api.runTests(selected.id);
27
+ return tests === undefined ? { kind: 'unknown-run', selector: request.run } : { kind: 'tests', tests };
28
+ };
29
+ const resolve = async (api, request) => resolveRunId(api, request);
@@ -0,0 +1,26 @@
1
+ const attempt = async (fn) => {
2
+ try {
3
+ return { kind: 'ok', value: await fn() };
4
+ }
5
+ catch (err) {
6
+ return { kind: 'refused', message: err instanceof Error ? err.message : String(err) };
7
+ }
8
+ };
9
+ /**
10
+ * Read what a repository pulls in.
11
+ *
12
+ * No classification and no judgement: `pinning` and the findings are the
13
+ * control plane's answers, and a second implementation here would be a second
14
+ * answer to a question that must have one.
15
+ */
16
+ export const listActions = async (api, repository) => await attempt(() => api.actionInventory(repository));
17
+ export const readPolicy = async (api, owner) => await attempt(() => api.actionPolicy(owner));
18
+ export const writePolicy = async (api, owner, policy) => await attempt(() => api.setActionPolicy(owner, policy));
19
+ /**
20
+ * Preview or open the pin proposal.
21
+ *
22
+ * `apply` is passed through rather than inferred: opening a pull request in
23
+ * somebody's repository is not a thing a CLI should decide on the operator's
24
+ * behalf from the shape of the output they asked for.
25
+ */
26
+ export const proposePins = async (api, repository, apply) => await attempt(() => api.pinActions(repository, apply));
@@ -0,0 +1,24 @@
1
+ // application layer — see ../../../../docs/conventions.md §1
2
+ const refused = (err) => ({ kind: 'refused', detail: err instanceof Error ? err.message : String(err) });
3
+ export const listWaitingRuns = async (api) => {
4
+ try {
5
+ const { runs } = await api.listWaitingRuns();
6
+ // Oldest first: the queue is a to-do list, and the run somebody has been
7
+ // waiting longest for is the one to look at.
8
+ return { kind: 'listed', runs: [...runs].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)) };
9
+ }
10
+ catch (err) {
11
+ return refused(err);
12
+ }
13
+ };
14
+ export const decideRun = async (api, runId, decision, comment) => {
15
+ try {
16
+ const decided = await api.decideRun(runId, decision, comment);
17
+ return decided === undefined ? { kind: 'not-found' } : { kind: 'decided', decision: decided };
18
+ }
19
+ catch (err) {
20
+ return refused(err);
21
+ }
22
+ };
23
+ /** One waiting run on one line: what it is, and where it came from. */
24
+ export const waitingSummary = (r) => `${r.repository} #${r.runNumber} ${r.workflowName} (${r.event} ${r.sha.slice(0, 7)})`;
@@ -0,0 +1,79 @@
1
+ // application layer — see ../../../../docs/conventions.md §1
2
+ /**
3
+ * `target` is either a run id or `owner/repo`. They are told apart by shape
4
+ * rather than by a flag: a run id is a uuid and a repository has a slash, and
5
+ * making the operator say which is which would be asking them to describe
6
+ * something they just pasted.
7
+ */
8
+ export const listArtifacts = async (api, target) => {
9
+ try {
10
+ const repository = splitRepository(target);
11
+ const body = repository ? await api.listRepoArtifacts(repository.owner, repository.repo) : await api.listRunArtifacts(target);
12
+ // Newest first: what a person is looking for is almost always what a run
13
+ // just produced, and the oldest is what expiry takes next.
14
+ const artifacts = [...body.artifacts].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
15
+ return { kind: 'listed', artifacts, totalSizeBytes: body.totalSizeBytes };
16
+ }
17
+ catch (err) {
18
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
19
+ }
20
+ };
21
+ /**
22
+ * Find one artifact of a run by name and hand back its bytes.
23
+ *
24
+ * A name is what a person has — it is what the workflow file says — and it is
25
+ * unique within a run, so no id is needed. With no name and exactly one
26
+ * artifact the answer is unambiguous; with several it is not, and guessing
27
+ * would silently download the wrong one.
28
+ */
29
+ export const downloadArtifact = async (api, runId, name) => {
30
+ try {
31
+ const { artifacts } = await api.listRunArtifacts(runId);
32
+ const matches = name === undefined ? artifacts : artifacts.filter((a) => a.name === name);
33
+ if (matches.length === 0)
34
+ return { kind: 'not-found', name: name ?? '(any)' };
35
+ if (matches.length > 1)
36
+ return { kind: 'ambiguous', names: matches.map((a) => a.name) };
37
+ const artifact = matches[0];
38
+ const body = await api.downloadArtifact(runId, artifact.id);
39
+ if (!body)
40
+ return { kind: 'not-found', name: artifact.name };
41
+ return { kind: 'downloaded', artifact, body };
42
+ }
43
+ catch (err) {
44
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
45
+ }
46
+ };
47
+ export const deleteArtifact = async (api, runId, id) => {
48
+ const numeric = Number(id);
49
+ if (!Number.isSafeInteger(numeric) || numeric <= 0)
50
+ return { kind: 'bad-id', given: id };
51
+ try {
52
+ return (await api.deleteArtifact(runId, numeric)) ? { kind: 'deleted' } : { kind: 'not-found', id: numeric };
53
+ }
54
+ catch (err) {
55
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
56
+ }
57
+ };
58
+ /** `owner/repo` → its halves; `undefined` for anything else, which is then a run id. */
59
+ export const splitRepository = (slug) => {
60
+ const [owner, repo, ...rest] = slug.split('/');
61
+ if (owner === undefined || repo === undefined || rest.length > 0 || owner === '' || repo === '')
62
+ return undefined;
63
+ return { owner, repo };
64
+ };
65
+ /**
66
+ * How long until `iso`, for an operator scanning a column. Days are the unit
67
+ * that matters for retention; anything under a day is "today", because an
68
+ * artifact that expires in seven hours and one that expires in nine are the
69
+ * same problem.
70
+ */
71
+ export const untilExpiry = (iso, now = Date.now()) => {
72
+ const ms = Date.parse(iso) - now;
73
+ if (Number.isNaN(ms))
74
+ return '?';
75
+ if (ms <= 0)
76
+ return 'expired';
77
+ const days = Math.floor(ms / 86_400_000);
78
+ return days === 0 ? 'today' : `${days}d`;
79
+ };
@@ -0,0 +1,41 @@
1
+ // application layer — see ../../../../docs/conventions.md §1
2
+ export const listCaches = async (api, owner, repo) => {
3
+ try {
4
+ const body = await api.listCaches(owner, repo);
5
+ // Least recently used last: the next thing eviction takes is the last line.
6
+ const caches = [...body.caches].sort((a, b) => Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt));
7
+ return { kind: 'listed', caches, totalSizeBytes: body.totalSizeBytes };
8
+ }
9
+ catch (err) {
10
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
11
+ }
12
+ };
13
+ export const deleteCache = async (api, owner, repo, id) => {
14
+ const numeric = Number(id);
15
+ if (!Number.isSafeInteger(numeric) || numeric <= 0)
16
+ return { kind: 'bad-id', given: id };
17
+ try {
18
+ return (await api.deleteCache(owner, repo, numeric)) ? { kind: 'deleted' } : { kind: 'not-found', id: numeric };
19
+ }
20
+ catch (err) {
21
+ return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
22
+ }
23
+ };
24
+ /** `owner/repo` → its halves. Anything else is a usage error, not a 404. */
25
+ export const splitRepository = (slug) => {
26
+ const [owner, repo, ...rest] = slug.split('/');
27
+ if (owner === undefined || repo === undefined || rest.length > 0 || owner === '' || repo === '')
28
+ return undefined;
29
+ return { owner, repo };
30
+ };
31
+ /** Bytes as an operator reads them. Whole units below a thousand; never scientific notation. */
32
+ export const humanBytes = (bytes) => {
33
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
34
+ let value = bytes;
35
+ let unit = 0;
36
+ while (value >= 1024 && unit < units.length - 1) {
37
+ value /= 1024;
38
+ unit += 1;
39
+ }
40
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
41
+ };