@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,82 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ /**
3
+ * A customer's terminal credential, and the browser login that mints one
4
+ * (control-plane ADR-0064).
5
+ *
6
+ * Pure decisions only: where the consent page is, whether a loopback callback
7
+ * is the one this login asked for, whether a file mode is private enough to
8
+ * hold a token. The randomness, the socket and the file live in
9
+ * infrastructure.
10
+ */
11
+ /** Where the CLI goes when nobody says otherwise: the hosted control plane. */
12
+ export const DEFAULT_API_URL = 'https://app.apexactions.com';
13
+ /** The dashboard page that lists, mints and revokes terminal tokens. */
14
+ export const TOKENS_PAGE = '/settings/tokens';
15
+ export const describeSource = (source) => {
16
+ switch (source) {
17
+ case 'env':
18
+ return 'APEX_TOKEN in the environment';
19
+ case 'login':
20
+ return 'the token `apex login` saved';
21
+ case 'operator':
22
+ return 'APEX_ADMIN_TOKEN, the operator token';
23
+ }
24
+ };
25
+ /** A URL without its trailing slash, so two spellings of one origin compare equal. */
26
+ export const normaliseUrl = (url) => url.replace(/\/+$/, '');
27
+ /**
28
+ * The web app's base URL. The consent page lives there, not in the API; on the
29
+ * hosted service one origin serves both, so the API's is the right default.
30
+ */
31
+ export const webBaseFor = (apiUrl, webUrl) => normaliseUrl(webUrl !== undefined && webUrl !== '' ? webUrl : apiUrl);
32
+ /**
33
+ * `ssh`'s rule: a file holding a credential that anybody but its owner can
34
+ * read is refused, not used. The group and other bits must all be clear.
35
+ */
36
+ export const isPrivateMode = (mode) => (mode & 0o077) === 0;
37
+ /** Whether a saved credential has run out, so it is not offered to the server. */
38
+ export const hasExpired = (credential, now) => {
39
+ const at = Date.parse(credential.expiresAt);
40
+ return Number.isFinite(at) && at <= now.getTime();
41
+ };
42
+ /** A parsed credentials file, or undefined when it is not one — never a partial. */
43
+ export const parseSavedCredential = (raw) => {
44
+ if (raw === null || typeof raw !== 'object')
45
+ return undefined;
46
+ const r = raw;
47
+ const fields = ['token', 'login', 'apiUrl', 'expiresAt'];
48
+ for (const f of fields)
49
+ if (typeof r[f] !== 'string' || r[f] === '')
50
+ return undefined;
51
+ return { token: r['token'], login: r['login'], apiUrl: r['apiUrl'], expiresAt: r['expiresAt'] };
52
+ };
53
+ /** The consent page `apex login` opens (RFC 8252 loopback, RFC 7636 S256). */
54
+ export const authorizeUrl = (webBase, request) => {
55
+ const query = new URLSearchParams({
56
+ port: String(request.port),
57
+ state: request.state,
58
+ code_challenge: request.codeChallenge,
59
+ name: request.name,
60
+ });
61
+ return `${normaliseUrl(webBase)}/cli/authorize?${query.toString()}`;
62
+ };
63
+ /**
64
+ * Judge what arrived at the loopback callback.
65
+ *
66
+ * The `state` is checked before anything else, the denial included: a request
67
+ * this login did not cause — another page on this machine guessing the port —
68
+ * must not be able to end it with a message of its choosing either.
69
+ */
70
+ export const judgeCallback = (params, expectedState) => {
71
+ if (params.get('state') !== expectedState)
72
+ return { kind: 'state-mismatch' };
73
+ const error = params.get('error');
74
+ if (error !== null && error !== '') {
75
+ const description = params.get('error_description');
76
+ return { kind: 'denied', detail: description !== null && description !== '' ? `${error}: ${description}` : error };
77
+ }
78
+ const code = params.get('code');
79
+ if (code === null || code === '')
80
+ return { kind: 'malformed' };
81
+ return { kind: 'code', code };
82
+ };
@@ -0,0 +1,136 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ //
3
+ // How big was the fleet, and when was it nothing?
4
+ //
5
+ // The control plane serves runner *lifetimes* — `registeredAt` to `lastSeenAt`,
6
+ // departed runners included (control-plane ADR-0041). Everything below is the
7
+ // pure arithmetic that turns a pile of intervals into the two answers a capacity
8
+ // question actually wants: the peak, and the time at zero. It is here rather
9
+ // than in the control plane so that it can be tested without a server, and so
10
+ // the endpoint stays a statement of fact rather than of judgement.
11
+ //
12
+ // **What this can and cannot say.** A runner touches its row on every long-poll,
13
+ // at most `POLL_WINDOW_SECONDS` apart, so each interval's end is right to within
14
+ // that. It is therefore honest about an idle night — the question is whether the
15
+ // count was zero for hours — and dishonest about anything measured in seconds.
16
+ /** The control plane's cap on the acquire long-poll: the resolution of an end. */
17
+ export const POLL_WINDOW_SECONDS = 30;
18
+ /**
19
+ * The end of a runner's life.
20
+ *
21
+ * A runner that registered and never polled ends where it began — the same
22
+ * COALESCE the control plane reaps by. A runner still alive has no end inside
23
+ * the window, so it is clipped to `until` rather than treated as departed:
24
+ * `lastSeenAt` on a live runner is "a moment ago", not "gone since".
25
+ */
26
+ const endOf = (r, until) => {
27
+ const last = r.lastSeenAt === undefined ? Date.parse(r.registeredAt) : Date.parse(r.lastSeenAt);
28
+ return r.deleted ? last : Math.max(last, until);
29
+ };
30
+ /**
31
+ * Reconstruct the fleet's size across the window.
32
+ *
33
+ * **Only pooled runners count.** A runner without a pool is not fleet capacity —
34
+ * on this deployment it is the dogfooding runner on the control-plane host, which
35
+ * never goes away and would make "the fleet was at zero" impossible to ever
36
+ * observe. They are reported as excluded rather than dropped in silence, because
37
+ * a count that quietly ignores machines is the kind of number that gets believed.
38
+ */
39
+ export const reconstructFleet = (runners, window) => {
40
+ const since = Date.parse(window.since);
41
+ const until = Date.parse(window.until);
42
+ const counted = [];
43
+ const excluded = [];
44
+ for (const r of runners) {
45
+ if (r.pool === undefined || r.pool === '') {
46
+ excluded.push({ name: r.name, reason: 'no pool — not fleet capacity' });
47
+ continue;
48
+ }
49
+ counted.push(r);
50
+ }
51
+ const edges = [];
52
+ for (const r of counted) {
53
+ const from = Math.max(Date.parse(r.registeredAt), since);
54
+ const to = Math.min(endOf(r, until), until);
55
+ if (to <= from)
56
+ continue; // lived entirely outside the window after clipping
57
+ edges.push({ at: from, delta: +1, pool: r.pool });
58
+ edges.push({ at: to, delta: -1, pool: r.pool });
59
+ }
60
+ // Departures before arrivals at the same instant: an instance replaced at a
61
+ // tick boundary must not be counted as two.
62
+ edges.sort((a, b) => a.at - b.at || a.delta - b.delta);
63
+ let count = 0;
64
+ let peak = { count: 0, at: window.since };
65
+ const perPool = new Map();
66
+ const poolPeak = new Map();
67
+ const zeroWindows = [];
68
+ let zeroFrom = since;
69
+ for (const edge of edges) {
70
+ if (count === 0 && edge.delta > 0 && zeroFrom !== undefined && edge.at > zeroFrom) {
71
+ zeroWindows.push({
72
+ from: new Date(zeroFrom).toISOString(),
73
+ to: new Date(edge.at).toISOString(),
74
+ seconds: Math.round((edge.at - zeroFrom) / 1000),
75
+ });
76
+ zeroFrom = undefined;
77
+ }
78
+ count += edge.delta;
79
+ if (count > peak.count)
80
+ peak = { count, at: new Date(edge.at).toISOString() };
81
+ if (edge.pool !== undefined) {
82
+ const next = (perPool.get(edge.pool) ?? 0) + edge.delta;
83
+ perPool.set(edge.pool, next);
84
+ const best = poolPeak.get(edge.pool);
85
+ if (best === undefined || next > best.count)
86
+ poolPeak.set(edge.pool, { count: next, at: new Date(edge.at).toISOString() });
87
+ }
88
+ if (count === 0)
89
+ zeroFrom = edge.at;
90
+ }
91
+ if (zeroFrom !== undefined && until > zeroFrom) {
92
+ zeroWindows.push({
93
+ from: new Date(zeroFrom).toISOString(),
94
+ to: new Date(until).toISOString(),
95
+ seconds: Math.round((until - zeroFrom) / 1000),
96
+ });
97
+ }
98
+ const secondsAtZero = zeroWindows.reduce((sum, w) => sum + w.seconds, 0);
99
+ const longestZero = [...zeroWindows].sort((a, b) => b.seconds - a.seconds)[0];
100
+ return {
101
+ since: window.since,
102
+ until: window.until,
103
+ counted: counted.length,
104
+ excluded,
105
+ peak,
106
+ peakByPool: [...poolPeak.entries()]
107
+ .map(([pool, p]) => ({ pool, count: p.count, at: p.at }))
108
+ .sort((a, b) => b.count - a.count || a.pool.localeCompare(b.pool)),
109
+ zeroWindows,
110
+ secondsAtZero,
111
+ longestZero,
112
+ emptyThroughout: peak.count === 0,
113
+ resolutionSeconds: POLL_WINDOW_SECONDS,
114
+ };
115
+ };
116
+ /** `24h`, `90m`, `2d`, `3600` (seconds) — what an operator types for a window. */
117
+ export const parseDuration = (input) => {
118
+ const m = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(input.trim());
119
+ if (!m)
120
+ return undefined;
121
+ const value = Number(m[1]);
122
+ if (!Number.isFinite(value) || value <= 0)
123
+ return undefined;
124
+ const unit = m[2] === '' ? 's' : m[2];
125
+ const scale = { s: 1, m: 60, h: 3600, d: 86_400 }[unit];
126
+ return Math.round(value * scale);
127
+ };
128
+ export const humanSeconds = (seconds) => {
129
+ if (seconds < 90)
130
+ return `${seconds}s`;
131
+ if (seconds < 5400)
132
+ return `${Math.round(seconds / 60)}m`;
133
+ if (seconds < 172_800)
134
+ return `${(seconds / 3600).toFixed(1)}h`;
135
+ return `${(seconds / 86_400).toFixed(1)}d`;
136
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ // domain layer — see ../../../../docs/conventions.md §1
@@ -0,0 +1,42 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ // The REST shapes for "what got slower" and "what is flaky", mirroring
3
+ // control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
4
+ // about presenting them.
5
+ /**
6
+ * A duration a person can compare at a glance.
7
+ *
8
+ * Sub-second work is rounded to milliseconds and everything above a minute
9
+ * loses its milliseconds entirely: nobody reading "which step got slower"
10
+ * needs three decimal places on four minutes, and the extra digits make two
11
+ * numbers harder to compare rather than easier.
12
+ */
13
+ export const humanDuration = (ms) => {
14
+ if (!Number.isFinite(ms) || ms < 0)
15
+ return '—';
16
+ if (ms < 1000)
17
+ return `${Math.round(ms)}ms`;
18
+ const seconds = ms / 1000;
19
+ if (seconds < 60)
20
+ return `${seconds.toFixed(1)}s`;
21
+ const whole = Math.round(seconds);
22
+ const minutes = Math.floor(whole / 60);
23
+ if (minutes < 60)
24
+ return `${minutes}m ${whole % 60}s`;
25
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
26
+ };
27
+ /** `+2.4× (was 10.0s)`, or nothing when there is no baseline to compare to. */
28
+ export const trendNote = (t) => {
29
+ if (t.baselineMs === undefined || t.ratio === undefined)
30
+ return undefined;
31
+ return `${t.ratio.toFixed(1)}× (was ${humanDuration(t.baselineMs)})`;
32
+ };
33
+ /**
34
+ * A run's steps, slowest first, for a listing that has room for only a few.
35
+ *
36
+ * Sorted by duration rather than by document order because the question this
37
+ * answers is "where did the time go" — and a step that took four minutes is
38
+ * the answer whether it ran first or last.
39
+ */
40
+ export const slowestFirst = (steps, limit) => [...steps].sort((a, b) => b.durationMs - a.durationMs).slice(0, limit);
41
+ /** A one-word marker per outcome, so a scan down the column reads. */
42
+ export const testMarker = (outcome) => outcome === 'passed' ? '✓' : outcome === 'skipped' ? '−' : '✗';
@@ -0,0 +1,40 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ // The REST shapes for a log search and a structured log view, mirroring
3
+ // control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
4
+ // about presenting them.
5
+ /**
6
+ * `ci.yml #12 · build` — how a result names the job it found.
7
+ *
8
+ * The workflow's file name rather than its display name: the reader's next act
9
+ * is to open that file, and two workflows may share a `name:`.
10
+ */
11
+ export const resultTitle = (result) => {
12
+ const file = result.workflowPath.split('/').pop() ?? result.workflowPath;
13
+ const attempt = result.attempt > 1 ? ` (attempt ${result.attempt})` : '';
14
+ return `${file} #${result.runNumber}${attempt} · ${result.jobName}`;
15
+ };
16
+ /** `main · a1b2c3d` — the branch as people write it, and the commit. */
17
+ export const resultRef = (result) => `${result.ref.replace(/^refs\/heads\//, '')} · ${result.sha.slice(0, 7)}`;
18
+ /** Only what the tool itself stated. A missing count is missing, never zero. */
19
+ export const totalsPhrase = (view) => {
20
+ const parts = [];
21
+ if (view.totals.failed !== undefined)
22
+ parts.push(`${view.totals.failed} failed`);
23
+ if (view.totals.passed !== undefined)
24
+ parts.push(`${view.totals.passed} passed`);
25
+ if (view.totals.skipped !== undefined && view.totals.skipped > 0)
26
+ parts.push(`${view.totals.skipped} skipped`);
27
+ if (view.totals.total !== undefined)
28
+ parts.push(`${view.totals.total} total`);
29
+ return parts.length === 0 ? 'no counts reported' : parts.join(', ');
30
+ };
31
+ /**
32
+ * What the reader should trust, in a sentence.
33
+ *
34
+ * `report` is the only case that needs saying: the numbers on screen came from
35
+ * reading the runner's prose, and a machine-readable statement of the same run
36
+ * exists and outranks them.
37
+ */
38
+ export const authorityNote = (view) => view.authority === 'report'
39
+ ? 'this job also left a test report, which is the runner’s own statement — `apex tests` reads that; the counts here are read from the console output and the line numbers are what they add'
40
+ : undefined;
@@ -0,0 +1,122 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ // The migration report as the engine produces it (engine ADR-0013,
3
+ // engine/docs/compat-format.md), plus the re-entry checklist built on top.
4
+ //
5
+ // Nothing here decides what is supported. Every verdict is a row of the
6
+ // engine's conformance corpus, and a second opinion in this file would be a
7
+ // second answer to a question that must have one — it would agree on the day
8
+ // it was written and drift, silently, the first time a fixture changed.
9
+ /**
10
+ * Secrets nobody configures, because the platform mints them per job.
11
+ *
12
+ * `secrets.GITHUB_TOKEN` is in every second workflow on GitHub and is in no
13
+ * organization's secret list, so a checklist that did not know this would open
14
+ * every real scan by telling somebody to go and find a secret that does not
15
+ * exist — which is how a list stops being read. Found by running the scan
16
+ * against a real organization; every path had been tested against a fake.
17
+ */
18
+ export const AUTOMATIC_SECRETS = ['GITHUB_TOKEN'];
19
+ const dedupe = (values) => [...new Set(values)].sort();
20
+ /**
21
+ * Build the re-entry checklist: what must be set in Apex before a first run,
22
+ * what the workflows read that GitHub cannot account for, and what can be left
23
+ * behind.
24
+ *
25
+ * `configured` may be empty because there was nothing, or because nobody could
26
+ * read it. Those are different states and the second one is recorded, because
27
+ * an empty checklist that looks complete is exactly the failure this list
28
+ * exists to prevent.
29
+ */
30
+ export const buildChecklist = (referenced, configured) => {
31
+ const entries = [];
32
+ const byKind = (kind, names) => {
33
+ const configuredOfKind = (configured ?? []).filter((c) => c.kind === kind);
34
+ const referencedNames = dedupe(names);
35
+ for (const name of referencedNames) {
36
+ const matches = configuredOfKind.filter((c) => c.name === name);
37
+ const action = kind === 'secret' && AUTOMATIC_SECRETS.includes(name)
38
+ ? 'automatic'
39
+ : configured === undefined || matches.length > 0
40
+ ? 're-enter'
41
+ : 'unaccounted';
42
+ entries.push({
43
+ name,
44
+ kind,
45
+ action,
46
+ ...(matches[0]?.scope === undefined ? {} : { scope: matches[0].scope }),
47
+ ...(matches.some((m) => m.repo !== undefined)
48
+ ? { repos: dedupe(matches.flatMap((m) => (m.repo === undefined ? [] : [m.repo]))) }
49
+ : {}),
50
+ });
51
+ }
52
+ for (const name of dedupe(configuredOfKind.map((c) => c.name))) {
53
+ if (referencedNames.includes(name))
54
+ continue;
55
+ const matches = configuredOfKind.filter((c) => c.name === name);
56
+ entries.push({
57
+ name,
58
+ kind,
59
+ action: 'not-needed',
60
+ ...(matches[0]?.scope === undefined ? {} : { scope: matches[0].scope }),
61
+ ...(matches.some((m) => m.repo !== undefined)
62
+ ? { repos: dedupe(matches.flatMap((m) => (m.repo === undefined ? [] : [m.repo]))) }
63
+ : {}),
64
+ });
65
+ }
66
+ };
67
+ byKind('secret', referenced.secrets ?? []);
68
+ byKind('variable', referenced.vars ?? []);
69
+ return { entries, configuredNamesRead: configured !== undefined };
70
+ };
71
+ /**
72
+ * Where this organization stands.
73
+ *
74
+ * `incomplete` wins over everything: a file we could not read is not a file
75
+ * that runs, and a report that called an organization ready while three of its
76
+ * workflows failed to parse would be worse than no report.
77
+ */
78
+ export const migrationVerdict = (report) => {
79
+ if (report.summary.unreadable > 0)
80
+ return 'incomplete';
81
+ if (report.summary.blockers > 0)
82
+ return 'blocked';
83
+ if (report.summary.partial > 0 || report.summary.unknown > 0 || report.caveats.length > 0)
84
+ return 'ready-with-caveats';
85
+ return 'ready';
86
+ };
87
+ export const verdictHeadline = (verdict) => {
88
+ switch (verdict) {
89
+ case 'ready':
90
+ return 'Every feature these workflows use runs on Apex Actions, and a fixture proves each one.';
91
+ case 'ready-with-caveats':
92
+ return 'These workflows run, with limits worth reading before you move them.';
93
+ case 'blocked':
94
+ return 'Something these workflows use does not run on Apex Actions.';
95
+ case 'incomplete':
96
+ return 'Some workflows could not be read, so this report does not cover them.';
97
+ }
98
+ };
99
+ /** `n thing` / `n things`, because a report that says "1 files" is not read carefully. */
100
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
101
+ /**
102
+ * How a verdict's proof reads in the report.
103
+ *
104
+ * A verdict with no fixture never says "proved by" anything. "Supported" and
105
+ * "proved supported" are different sentences, and the whole point of the
106
+ * corpus is that only the second one is the product's claim.
107
+ */
108
+ export const proofLine = (verdict) => {
109
+ if (verdict.status === 'unknown')
110
+ return 'no row of the compatibility matrix claims this';
111
+ if (verdict.proven && verdict.fixtures !== undefined && verdict.fixtures.length > 0) {
112
+ return `proved by ${verdict.fixtures.join(', ')}`;
113
+ }
114
+ return verdict.status === 'supported'
115
+ ? 'claimed, with no conformance fixture behind the claim'
116
+ : 'recorded in the matrix; no fixture demonstrates the limit';
117
+ };
118
+ /** Where a use is, as a reader would go and look at it. */
119
+ export const useLocation = (use) => {
120
+ const at = use.path === undefined ? use.where : `${use.path}${use.line === undefined ? '' : `:${use.line}`}`;
121
+ return use.detail === undefined || use.detail === '' ? at : `${at} — ${use.detail}`;
122
+ };
@@ -0,0 +1,64 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ // The REST shapes for notification routing, mirroring
3
+ // control-plane/contracts/openapi.yaml, plus the pure presentation decisions
4
+ // the CLI makes about them.
5
+ //
6
+ // There is no destination URL in any of these types, and that is not an
7
+ // oversight: the control plane never returns one (control-plane ADR-0029), so
8
+ // a field for it here would be a field that is always empty and would invite
9
+ // somebody to try to fill it.
10
+ export const TRIGGERS = ['broke', 'recovered', 'failed', 'succeeded'];
11
+ export const DESTINATION_KINDS = ['slack', 'teams', 'webhook'];
12
+ /** `slack · hooks.slack.com · #a41f9c0d1e2f` — how a destination is recognised without being readable. */
13
+ export const destinationLine = (rule) => `${rule.kind} ${rule.urlOrigin.replace(/^https:\/\//, '')} #${rule.urlFingerprint}`;
14
+ /** Empty filters mean everything, and saying so beats printing a blank. */
15
+ export const filterLine = (patterns) => (patterns.length === 0 ? '(all)' : patterns.join(', '));
16
+ /** What a comma-or-repeated option means. Empty entries are dropped, not preserved as blanks. */
17
+ export const listOption = (raw) => (raw ?? []).flatMap((v) => v.split(',')).map((v) => v.trim()).filter((v) => v !== '');
18
+ export const invalidTriggers = (values) => values.filter((v) => !TRIGGERS.includes(v));
19
+ /**
20
+ * One line summarising the last attempt.
21
+ *
22
+ * A rule that has never been used says so rather than reading as healthy: an
23
+ * empty status is the state a rule is in before anybody has proved it, and
24
+ * that is exactly what somebody debugging a silent channel needs to see.
25
+ */
26
+ export const lastAttemptLine = (rule) => {
27
+ if (rule.lastStatus === undefined)
28
+ return 'never used — try `apex notify test`';
29
+ const when = rule.lastAttemptAt === undefined ? '' : ` ${rule.lastAttemptAt}`;
30
+ const detail = rule.lastDetail === undefined || rule.lastDetail === '' ? '' : ` · ${rule.lastDetail}`;
31
+ return `${rule.lastStatus}${when}${detail}`;
32
+ };
33
+ /**
34
+ * How one attempt went, in the words somebody acts on.
35
+ *
36
+ * Three outcomes, not two. A refusal is ours and a rejection is theirs, and
37
+ * that distinction was already drawn — but a send that never got an answer at
38
+ * all is neither, and calling it a rejection points the reader at the
39
+ * destination when the fault is on this side of the socket. It read as Slack
40
+ * turning us away while the real cause was a `TypeError` in our own resolver
41
+ * (control-plane ADR-0036), which is a day of somebody's time in the wrong
42
+ * logs.
43
+ */
44
+ export const sendOutcomeLine = (outcome) => {
45
+ if (outcome.refused === true)
46
+ return 'refused before connecting';
47
+ if (outcome.status === undefined)
48
+ return 'never reached the destination';
49
+ return `rejected by the destination (HTTP ${String(outcome.status)})`;
50
+ };
51
+ /**
52
+ * What a rule will actually do, in one sentence.
53
+ *
54
+ * Written out because the default subscription is the thing people misread:
55
+ * "broke, recovered" looks like it might mean *every* failure, and it does not
56
+ * (control-plane ADR-0028).
57
+ */
58
+ export const subscriptionLine = (rule) => {
59
+ const transitions = rule.on.filter((t) => t === 'broke' || t === 'recovered');
60
+ const everyTime = rule.on.filter((t) => t === 'failed' || t === 'succeeded');
61
+ if (everyTime.length > 0)
62
+ return `${rule.on.join(', ')} — ${everyTime.join(' and ')} fire on every run of that kind`;
63
+ return `${transitions.join(', ')} — only when the outcome changes`;
64
+ };
@@ -0,0 +1,40 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ //
3
+ // How the coexistence outcome diff reads to a person. The comparison itself is
4
+ // the control plane's (ADR-0047); this is the sentence it deserves.
5
+ /** One line per workflow, saying what happened and — when it matters — why not. */
6
+ export const comparisonLine = (c) => {
7
+ const both = `${c.ours ?? '—'} / ${c.theirs ?? '—'}`;
8
+ switch (c.verdict) {
9
+ case 'agree':
10
+ return `both ${c.ours ?? '—'}`;
11
+ case 'differ':
12
+ return `apex ${c.ours ?? '—'}, github ${c.theirs ?? '—'}`;
13
+ case 'not-run-here':
14
+ return 'not run here — opt it in to compare it';
15
+ case 'not-run-there':
16
+ return 'GitHub Actions has no run for this commit';
17
+ case 'in-flight':
18
+ return `still running (${both})`;
19
+ case 'incomparable-secrets':
20
+ return `not comparable: this workflow reads ${c.missingSecrets.join(', ')}, which we do not hold`;
21
+ }
22
+ };
23
+ /**
24
+ * The headline, and the only honest denominator.
25
+ *
26
+ * `comparable` is `agree + differ`. A repository whose workflows all need
27
+ * secrets we lack has **nothing** to report, and saying "100%" of an empty set
28
+ * is the failure this whole feature is built to avoid — so that case gets a
29
+ * sentence rather than a number.
30
+ */
31
+ export const diffHeadline = (s) => {
32
+ if (s.comparable === 0) {
33
+ return s.incomparable === 0
34
+ ? 'nothing ran on either platform for this commit'
35
+ : `nothing could be compared for this commit (${s.incomparable} not comparable)`;
36
+ }
37
+ const pct = Math.round((s.agree / s.comparable) * 100);
38
+ const tail = s.incomparable === 0 ? '' : `, ${s.incomparable} not comparable`;
39
+ return `${s.agree} of ${s.comparable} comparable workflows agree (${pct}%)${tail}`;
40
+ };
@@ -0,0 +1,63 @@
1
+ // domain layer — see ../../../../docs/conventions.md §1
2
+ // The REST shapes for "why did this run" and "how do I run it here", mirroring
3
+ // control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
4
+ // about presenting them.
5
+ /**
6
+ * The retry note for one job, or `undefined` when it ran first time.
7
+ *
8
+ * Shown even on a job that succeeded, because that is the interesting case: a
9
+ * Spot reclaim that cost a delay rather than a run is the thing GA gate G3 asks
10
+ * somebody to witness, and by then the job's own `reason` is about its
11
+ * conclusion.
12
+ */
13
+ export const retryNote = (job) => {
14
+ const attempt = job.attempt ?? 1;
15
+ if (attempt < 2 && job.requeueReason === undefined)
16
+ return undefined;
17
+ const why = job.requeueReason === 'spot-interruption'
18
+ ? 'instance reclaimed by Spot'
19
+ : job.requeueReason === 'lease-expired'
20
+ ? 'runner stopped reporting'
21
+ : job.requeueReason === 'shutdown'
22
+ ? 'runner shutting down'
23
+ : job.requeueReason;
24
+ return why === undefined ? `attempt ${attempt}` : `attempt ${attempt} — ${why}`;
25
+ };
26
+ /**
27
+ * A verdict worth printing under "and here is what else this delivery
28
+ * decided". `planned` is the run the reader is already looking at, and
29
+ * `already-planned` is bookkeeping about a reclaimed claim — neither answers
30
+ * the question that brought them here.
31
+ */
32
+ export const worthShowing = (t) => t.outcome !== 'planned' && t.outcome !== 'already-planned';
33
+ /**
34
+ * One line describing a verdict, ending in the filter list when there is one.
35
+ *
36
+ * The filter is printed as the workflow wrote it, because the next thing the
37
+ * reader does is open that file and change that line — quoting our paraphrase
38
+ * of it would send them looking for words that are not there.
39
+ */
40
+ export const verdictLine = (t) => {
41
+ const head = t.reason ?? t.summary ?? t.outcome;
42
+ const filter = t.filter ?? [];
43
+ return filter.length === 0 ? head : `${head} — the trigger allows ${filter.join(', ')}`;
44
+ };
45
+ /** A one-word status marker for a job, chosen so a scan down the column reads. */
46
+ export const jobMarker = (job) => {
47
+ if (job.status !== 'completed')
48
+ return job.status === 'in_progress' ? '›' : '·';
49
+ switch (job.conclusion) {
50
+ case 'success':
51
+ return '✓';
52
+ case 'skipped':
53
+ return '−';
54
+ case 'cancelled':
55
+ return '⨯';
56
+ default:
57
+ return '✗';
58
+ }
59
+ };
60
+ /** Shell-quote one argument of a generated command, so it can be pasted as-is. */
61
+ export const shellQuote = (arg) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
62
+ /** The generated invocation as one pasteable line. */
63
+ export const commandLine = (command) => command.map(shellQuote).join(' ');