@apex-actions/cli 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +84 -0
- package/THIRD-PARTY-NOTICES.md +1226 -0
- package/dist/cli/actions/main.js +193 -0
- package/dist/cli/approval/main.js +76 -0
- package/dist/cli/artifact/main.js +115 -0
- package/dist/cli/cache/main.js +104 -0
- package/dist/cli/deployment/main.js +73 -0
- package/dist/cli/dispatch/main.js +47 -0
- package/dist/cli/environment/main.js +139 -0
- package/dist/cli/login/main.js +73 -0
- package/dist/cli/logout/main.js +43 -0
- package/dist/cli/logs/main.js +125 -0
- package/dist/cli/lsp/main.js +46 -0
- package/dist/cli/main.js +88 -0
- package/dist/cli/migrate/main.js +275 -0
- package/dist/cli/notify/main.js +257 -0
- package/dist/cli/oidc/main.js +103 -0
- package/dist/cli/reproduce/main.js +73 -0
- package/dist/cli/rerun/main.js +100 -0
- package/dist/cli/run/main.js +50 -0
- package/dist/cli/runner/main.js +238 -0
- package/dist/cli/schedule/main.js +54 -0
- package/dist/cli/search/main.js +123 -0
- package/dist/cli/secret/main.js +102 -0
- package/dist/cli/secret/shared.js +104 -0
- package/dist/cli/spend/main.js +81 -0
- package/dist/cli/terms/main.js +85 -0
- package/dist/cli/tests/main.js +122 -0
- package/dist/cli/timing/main.js +90 -0
- package/dist/cli/validate/main.js +48 -0
- package/dist/cli/variable/main.js +83 -0
- package/dist/cli/whoami/main.js +63 -0
- package/dist/cli/why/main.js +106 -0
- package/dist/src/application/authenticate.js +56 -0
- package/dist/src/application/dispatch-workflow.js +23 -0
- package/dist/src/application/explain-runs.js +40 -0
- package/dist/src/application/index.js +2 -0
- package/dist/src/application/inspect-runs.js +29 -0
- package/dist/src/application/manage-actions.js +26 -0
- package/dist/src/application/manage-approvals.js +24 -0
- package/dist/src/application/manage-artifacts.js +79 -0
- package/dist/src/application/manage-caches.js +41 -0
- package/dist/src/application/manage-environments.js +136 -0
- package/dist/src/application/manage-notifications.js +20 -0
- package/dist/src/application/manage-runners.js +89 -0
- package/dist/src/application/manage-secrets.js +83 -0
- package/dist/src/application/manage-terms.js +34 -0
- package/dist/src/application/read-logs.js +40 -0
- package/dist/src/application/report-spend.js +15 -0
- package/dist/src/application/rerun-runs.js +34 -0
- package/dist/src/application/resolve-run.js +19 -0
- package/dist/src/application/run-workflow.js +17 -0
- package/dist/src/application/scan-migration.js +76 -0
- package/dist/src/application/search-logs.js +24 -0
- package/dist/src/application/serve-language-server.js +15 -0
- package/dist/src/application/validate-workflows.js +12 -0
- package/dist/src/domain/credentials.js +82 -0
- package/dist/src/domain/fleet-history.js +136 -0
- package/dist/src/domain/index.js +2 -0
- package/dist/src/domain/insight.js +42 -0
- package/dist/src/domain/logsearch.js +40 -0
- package/dist/src/domain/migration.js +122 -0
- package/dist/src/domain/notify.js +64 -0
- package/dist/src/domain/outcomediff.js +40 -0
- package/dist/src/domain/provenance.js +63 -0
- package/dist/src/domain/runs.js +90 -0
- package/dist/src/domain/secrets.js +39 -0
- package/dist/src/domain/spend.js +50 -0
- package/dist/src/domain/supplychain.js +48 -0
- package/dist/src/domain/terms.js +16 -0
- package/dist/src/domain/validation.js +4 -0
- package/dist/src/infrastructure/control-plane-http.js +458 -0
- package/dist/src/infrastructure/credentials.js +134 -0
- package/dist/src/infrastructure/engine-binary.js +164 -0
- package/dist/src/infrastructure/github-api.js +154 -0
- package/dist/src/infrastructure/index.js +2 -0
- package/dist/src/infrastructure/loopback-login.js +136 -0
- package/dist/src/infrastructure/runner-binary.js +74 -0
- package/dist/src/infrastructure/secret-input.js +52 -0
- package/package.json +54 -0
|
@@ -0,0 +1,136 @@
|
|
|
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 listEnvironments = async (api, owner, repo) => {
|
|
4
|
+
try {
|
|
5
|
+
const { environments } = await api.listEnvironments(owner, repo);
|
|
6
|
+
return { kind: 'listed', environments: [...environments].sort((a, b) => (a.name < b.name ? -1 : 1)) };
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
return refused(err);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export const saveEnvironment = async (api, owner, repo, name, input) => {
|
|
13
|
+
try {
|
|
14
|
+
return { kind: 'saved', environment: await api.putEnvironment(owner, repo, name, input) };
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return refused(err);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const deleteEnvironment = async (api, owner, repo, name) => {
|
|
21
|
+
try {
|
|
22
|
+
return (await api.deleteEnvironment(owner, repo, name)) ? { kind: 'deleted' } : { kind: 'not-found' };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return refused(err);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `user:ana`, `team:acme/platform`, or a bare name (a user, because that is
|
|
30
|
+
* what people mean when they type one).
|
|
31
|
+
*/
|
|
32
|
+
export const parseReviewer = (raw) => {
|
|
33
|
+
const [head, ...rest] = raw.split(':');
|
|
34
|
+
if (head === undefined || head === '')
|
|
35
|
+
return undefined;
|
|
36
|
+
if (rest.length === 0)
|
|
37
|
+
return { type: 'user', name: head };
|
|
38
|
+
const name = rest.join(':');
|
|
39
|
+
if (name === '')
|
|
40
|
+
return undefined;
|
|
41
|
+
if (head === 'user')
|
|
42
|
+
return { type: 'user', name };
|
|
43
|
+
if (head === 'team')
|
|
44
|
+
return name.includes('/') ? { type: 'team', name } : undefined;
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
export const listDeployments = async (api, runId) => {
|
|
48
|
+
try {
|
|
49
|
+
const { deployments } = runId === undefined ? await api.listWaitingDeployments() : await api.listRunDeployments(runId);
|
|
50
|
+
return { kind: 'listed', deployments };
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return refused(err);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export const decideDeployment = async (api, id, decision, comment) => {
|
|
57
|
+
try {
|
|
58
|
+
const deployment = await api.decideDeployment(id, decision, comment);
|
|
59
|
+
return deployment === undefined ? { kind: 'not-found' } : { kind: 'decided', deployment };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return refused(err);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
export const listSchedules = async (api, owner, repo) => {
|
|
66
|
+
try {
|
|
67
|
+
const { schedules } = await api.listSchedules(owner, repo);
|
|
68
|
+
return { kind: 'listed', schedules: [...schedules].sort((a, b) => Date.parse(a.nextRunAt) - Date.parse(b.nextRunAt)) };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return refused(err);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
export const oidcDiscovery = async (api) => {
|
|
75
|
+
try {
|
|
76
|
+
const document = await api.oidcDiscovery();
|
|
77
|
+
return document === undefined ? { kind: 'not-an-issuer' } : { kind: 'found', document };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return refused(err);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
export const oidcSubject = async (api, owner, repo) => {
|
|
84
|
+
try {
|
|
85
|
+
const { includeClaimKeys } = await api.getOidcSubject(owner, repo);
|
|
86
|
+
return { kind: 'read', includeClaimKeys };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return refused(err);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export const setOidcSubject = async (api, owner, repo, keys) => {
|
|
93
|
+
try {
|
|
94
|
+
const { includeClaimKeys } = await api.putOidcSubject(owner, repo, keys);
|
|
95
|
+
return { kind: 'read', includeClaimKeys };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return refused(err);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* How a deployment reads on one line: what it is waiting for, or how it was
|
|
103
|
+
* decided. The API records a reason for every state that has one; this only
|
|
104
|
+
* has to cover the states where it does not.
|
|
105
|
+
*/
|
|
106
|
+
export const deploymentSummary = (d) => {
|
|
107
|
+
if (d.reason !== undefined && d.reason !== '')
|
|
108
|
+
return d.reason;
|
|
109
|
+
switch (d.status) {
|
|
110
|
+
case 'success':
|
|
111
|
+
return `deployed to ${d.environment}`;
|
|
112
|
+
case 'failure':
|
|
113
|
+
return `the deployment to ${d.environment} failed`;
|
|
114
|
+
case 'cancelled':
|
|
115
|
+
return `the deployment to ${d.environment} was cancelled`;
|
|
116
|
+
case 'rejected':
|
|
117
|
+
return `the deployment to ${d.environment} was rejected`;
|
|
118
|
+
default:
|
|
119
|
+
return `deploying to ${d.environment}`;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/** An environment's rules on one line, in the order they are applied. */
|
|
123
|
+
export const environmentSummary = (e) => {
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (e.branchPatterns.length > 0)
|
|
126
|
+
parts.push(`branches ${e.branchPatterns.join(', ')}`);
|
|
127
|
+
if (e.waitTimerMinutes > 0)
|
|
128
|
+
parts.push(`wait ${e.waitTimerMinutes}m`);
|
|
129
|
+
if (e.reviewers.length > 0)
|
|
130
|
+
parts.push(`reviewers ${e.reviewers.map((r) => `${r.type}:${r.name}`).join(', ')}`);
|
|
131
|
+
if (e.preventSelfReview)
|
|
132
|
+
parts.push('no self-review');
|
|
133
|
+
// An environment with no rules is not a gate at all, and saying so beats an
|
|
134
|
+
// empty line somebody has to interpret.
|
|
135
|
+
return parts.length > 0 ? parts.join(' · ') : 'no protection rules';
|
|
136
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export const listRules = async (api, repository) => await attempt(() => api.listNotificationRules(repository));
|
|
10
|
+
export const saveRule = async (api, repository, name, input) => await attempt(() => api.putNotificationRule(repository, name, input));
|
|
11
|
+
export const removeRule = async (api, repository, name) => await attempt(() => api.deleteNotificationRule(repository, name));
|
|
12
|
+
/**
|
|
13
|
+
* Prove a destination now.
|
|
14
|
+
*
|
|
15
|
+
* A refusal by the destination is an `ok` outcome carrying `ok: false`: the
|
|
16
|
+
* call succeeded and its answer *is* the news. Only a failure to reach the
|
|
17
|
+
* control plane is `refused` here.
|
|
18
|
+
*/
|
|
19
|
+
export const proveRule = async (api, repository, name) => await attempt(() => api.testNotificationRule(repository, name));
|
|
20
|
+
export const readDeliveries = async (api, repository, limit) => await attempt(() => api.listNotificationDeliveries(repository, limit));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { reconstructFleet } from '../domain/fleet-history.js';
|
|
3
|
+
/**
|
|
4
|
+
* List runners *and* what is waiting for them.
|
|
5
|
+
*
|
|
6
|
+
* The two together, never separately: "six runners" is not an answer to "why is
|
|
7
|
+
* my job queued", and the pair is — six runners, none of whose labels satisfy
|
|
8
|
+
* what you asked for.
|
|
9
|
+
*/
|
|
10
|
+
export const listRunners = async (api) => {
|
|
11
|
+
try {
|
|
12
|
+
const [runners, fleet] = await Promise.all([api.listRunners(), api.fleetDemand()]);
|
|
13
|
+
return { kind: 'listed', runners, demand: fleet.demand };
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* How big the fleet was, over a window that has already happened.
|
|
21
|
+
*
|
|
22
|
+
* The control plane serves the lifetimes; the arithmetic is `reconstructFleet`,
|
|
23
|
+
* in the domain, so the two can be wrong separately and are tested separately.
|
|
24
|
+
*/
|
|
25
|
+
export const fleetHistory = async (api, window) => {
|
|
26
|
+
try {
|
|
27
|
+
const served = await api.fleetHistory({ since: window.since.toISOString(), until: window.until.toISOString() });
|
|
28
|
+
// The window the server actually served, not the one we asked for: it
|
|
29
|
+
// clamps, and a reconstruction laid over the wrong window would invent a
|
|
30
|
+
// zero stretch at the front of it.
|
|
31
|
+
return {
|
|
32
|
+
kind: 'reconstructed',
|
|
33
|
+
history: reconstructFleet(served.runners, { since: served.since, until: served.until }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** How many registrations one token may ever grant; the API clamps to the same. */
|
|
41
|
+
export const MAX_TOKEN_USES = 64;
|
|
42
|
+
export const mintRegistrationToken = async (api, uses) => {
|
|
43
|
+
if (!Number.isInteger(uses) || uses < 1 || uses > MAX_TOKEN_USES)
|
|
44
|
+
return { kind: 'bad-uses', given: uses };
|
|
45
|
+
try {
|
|
46
|
+
return { kind: 'minted', token: await api.mintRegistrationToken({ uses }) };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Attach this machine to the control plane as a self-hosted runner: mint a
|
|
54
|
+
* one-time token, then hand off to `apex-runner`, which is the process that
|
|
55
|
+
* actually leases and executes work.
|
|
56
|
+
*
|
|
57
|
+
* The token is minted here rather than asked for on the command line because a
|
|
58
|
+
* registration token is single-use and short-lived — pasting one is a step that
|
|
59
|
+
* exists only to be got wrong, and a token on a command line ends up in shell
|
|
60
|
+
* history.
|
|
61
|
+
*/
|
|
62
|
+
export const registerRunner = async (api, agent, request) => {
|
|
63
|
+
const labels = request.labels.map((l) => l.trim()).filter((l) => l !== '');
|
|
64
|
+
// A runner with no labels satisfies `runs-on: []` and nothing else — it would
|
|
65
|
+
// register, look healthy, and never take a job.
|
|
66
|
+
if (labels.length === 0)
|
|
67
|
+
return { kind: 'no-labels' };
|
|
68
|
+
let token;
|
|
69
|
+
try {
|
|
70
|
+
token = (await api.mintRegistrationToken({ uses: 1 })).token;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
74
|
+
}
|
|
75
|
+
return await agent.register({ ...request, labels, token });
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Deregister a runner. A runner id, not a name: names are not unique and
|
|
79
|
+
* revoking the wrong runner's credential is not a mistake worth making
|
|
80
|
+
* convenient. `apex runner list` prints the ids.
|
|
81
|
+
*/
|
|
82
|
+
export const removeRunner = async (api, id) => {
|
|
83
|
+
try {
|
|
84
|
+
return (await api.removeRunner(id)) ? { kind: 'removed' } : { kind: 'not-found', id };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { targetLabel } from '../domain/secrets.js';
|
|
3
|
+
/**
|
|
4
|
+
* Why the organisation scope refused: it answers only for the operator
|
|
5
|
+
* organisation (BUG-0112), and a customer token has no scope there at all.
|
|
6
|
+
* A bare 403 would read as a permissions problem somebody could fix.
|
|
7
|
+
*/
|
|
8
|
+
export const ORG_SCOPE_REFUSAL = 'organisation secrets are not available to customer organisations yet — set it on each repository with --repo';
|
|
9
|
+
const statusOf = (err) => {
|
|
10
|
+
const status = err?.status;
|
|
11
|
+
return typeof status === 'number' ? status : undefined;
|
|
12
|
+
};
|
|
13
|
+
const refused = (target, err) => {
|
|
14
|
+
if (target.kind === 'org' && statusOf(err) === 403)
|
|
15
|
+
return { kind: 'refused', detail: ORG_SCOPE_REFUSAL };
|
|
16
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* One value, written to every target in order.
|
|
20
|
+
*
|
|
21
|
+
* A refusal on one repository does not stop the others: the etsuite case is
|
|
22
|
+
* one token onto several repositories, and stopping half-way would leave the
|
|
23
|
+
* person to work out which half. The summary says, per target.
|
|
24
|
+
*/
|
|
25
|
+
export const setEverywhere = async (api, kind, targets, name, value) => {
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const target of targets) {
|
|
28
|
+
try {
|
|
29
|
+
await (kind === 'secret' ? api.setSecret(target, name, value) : api.setVariable(target, name, value));
|
|
30
|
+
results.push({ target: targetLabel(target), kind: 'set' });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
results.push({ target: targetLabel(target), ...refused(target, err) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
};
|
|
38
|
+
export const deleteEverywhere = async (api, kind, targets, name) => {
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const target of targets) {
|
|
41
|
+
try {
|
|
42
|
+
const existed = await (kind === 'secret' ? api.deleteSecret(target, name) : api.deleteVariable(target, name));
|
|
43
|
+
results.push({ target: targetLabel(target), kind: existed ? 'deleted' : 'not-found' });
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
results.push({ target: targetLabel(target), ...refused(target, err) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
};
|
|
51
|
+
/** §7 exit code for a batch: 0 all done, 3 something was not there to delete, 1 anything refused. */
|
|
52
|
+
export const batchExitCode = (results) => {
|
|
53
|
+
if (results.some((r) => r.kind === 'refused'))
|
|
54
|
+
return 1;
|
|
55
|
+
if (results.some((r) => r.kind === 'not-found'))
|
|
56
|
+
return 3;
|
|
57
|
+
return 0;
|
|
58
|
+
};
|
|
59
|
+
export const listSecrets = async (api, target) => {
|
|
60
|
+
try {
|
|
61
|
+
const secrets = await api.listSecrets(target);
|
|
62
|
+
// Names and dates only, rebuilt field by field: a response that grew a
|
|
63
|
+
// value would still not carry it past here.
|
|
64
|
+
return {
|
|
65
|
+
kind: 'listed',
|
|
66
|
+
secrets: secrets
|
|
67
|
+
.map((s) => ({ name: s.name, ...(s.updatedAt === undefined ? {} : { updatedAt: s.updatedAt }) }))
|
|
68
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return refused(target, err);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
export const listVariables = async (api, target) => {
|
|
76
|
+
try {
|
|
77
|
+
const variables = await api.listVariables(target);
|
|
78
|
+
return { kind: 'listed', variables: [...variables].sort((a, b) => (a.name < b.name ? -1 : 1)) };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return refused(target, err);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
* Who owes an acceptance of the terms in force.
|
|
11
|
+
*
|
|
12
|
+
* The API answers what *this caller* may act for; the CLI authenticates as the
|
|
13
|
+
* machine principal, which administers every account, so an operator sees the
|
|
14
|
+
* whole list. Nothing is classified here — outstanding is the control plane's
|
|
15
|
+
* judgement (control-plane ADR-0039 amendment 1) and a second one would be a
|
|
16
|
+
* second answer to a question that must have one.
|
|
17
|
+
*/
|
|
18
|
+
export const readTermsStanding = async (api) => await attempt(() => api.termsStanding());
|
|
19
|
+
/**
|
|
20
|
+
* Accept for one account, **as the machine token**.
|
|
21
|
+
*
|
|
22
|
+
* This is the escalation path and nothing else: the customer accepts for
|
|
23
|
+
* themselves, in the app, at their next sign-in. An operator does it only on a
|
|
24
|
+
* customer's written instruction — for an account whose only owner has left —
|
|
25
|
+
* and the row then says the machine token accepted, which is exactly as much
|
|
26
|
+
* as it should claim.
|
|
27
|
+
*
|
|
28
|
+
* The version is read immediately before it is sent, never assumed: the same
|
|
29
|
+
* rule the screen follows, for the same reason.
|
|
30
|
+
*/
|
|
31
|
+
export const acceptTermsFor = async (api, installationId) => await attempt(async () => {
|
|
32
|
+
const standing = await api.termsStanding();
|
|
33
|
+
return await api.acceptTerms(installationId, standing.version);
|
|
34
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { isLive, selectJobs } from '../domain/runs.js';
|
|
3
|
+
import { resolveRunId } from './resolve-run.js';
|
|
4
|
+
/**
|
|
5
|
+
* Fetch (and optionally follow) a run's logs.
|
|
6
|
+
*
|
|
7
|
+
* Following re-reads rather than streaming: a job's log is compacted when it
|
|
8
|
+
* finishes, so the whole text is always the truth, and re-reading it cannot
|
|
9
|
+
* produce the interleaving bugs that stitching chunks can.
|
|
10
|
+
*/
|
|
11
|
+
export const readLogs = async (api, sleeper, request) => {
|
|
12
|
+
const resolved = await resolveRunId(api, request);
|
|
13
|
+
if (resolved.kind !== 'run')
|
|
14
|
+
return resolved;
|
|
15
|
+
let detail = await api.getRun(resolved.id);
|
|
16
|
+
if (!detail)
|
|
17
|
+
return { kind: 'unknown-run', selector: request.run };
|
|
18
|
+
const wanted = selectJobs(detail.jobs, request.job);
|
|
19
|
+
if (wanted.length === 0) {
|
|
20
|
+
return { kind: 'unknown-job', selector: request.job ?? '', available: detail.jobs.map((j) => j.name) };
|
|
21
|
+
}
|
|
22
|
+
while (request.follow === true && isLive(detail.run.status)) {
|
|
23
|
+
await sleeper.sleep(request.pollMs ?? 3000);
|
|
24
|
+
const fresh = await api.getRun(resolved.id);
|
|
25
|
+
if (!fresh)
|
|
26
|
+
break;
|
|
27
|
+
detail = fresh;
|
|
28
|
+
}
|
|
29
|
+
const jobs = selectJobs(detail.jobs, request.job);
|
|
30
|
+
const sections = [];
|
|
31
|
+
for (const job of jobs)
|
|
32
|
+
sections.push({ job, text: await api.jobLogs(job.id) });
|
|
33
|
+
return {
|
|
34
|
+
kind: 'logs',
|
|
35
|
+
run: detail.run,
|
|
36
|
+
sections,
|
|
37
|
+
// A live run has not failed yet; only a finished one sets a failing code.
|
|
38
|
+
exitCode: isLive(detail.run.status) ? 0 : detail.run.conclusion === 'success' ? 0 : 1,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a spend report.
|
|
3
|
+
*
|
|
4
|
+
* No selection logic and no arithmetic: the comparison's rules live in the
|
|
5
|
+
* control plane's domain, and a second implementation here would be a second
|
|
6
|
+
* answer to a question that must have one.
|
|
7
|
+
*/
|
|
8
|
+
export const reportSpend = async (api, request) => {
|
|
9
|
+
try {
|
|
10
|
+
return { kind: 'report', report: await api.spend(request.repository, request.days) };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return { kind: 'refused', message: err instanceof Error ? err.message : String(err) };
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveRunId } from './resolve-run.js';
|
|
2
|
+
/**
|
|
3
|
+
* Re-run a completed run, optionally resuming each job from the step it failed
|
|
4
|
+
* at.
|
|
5
|
+
*
|
|
6
|
+
* The snapshots are read **before** the re-run rather than reported after it,
|
|
7
|
+
* so the caller can be told what will actually be skipped. A `--resume` that
|
|
8
|
+
* silently found nothing to resume from would look identical to one that
|
|
9
|
+
* worked, and the difference is several minutes of somebody's afternoon.
|
|
10
|
+
*/
|
|
11
|
+
export const rerunRun = async (api, request) => {
|
|
12
|
+
if (request.mode === 'job' && (request.jobId === undefined || request.jobId === '')) {
|
|
13
|
+
return { kind: 'needs-job' };
|
|
14
|
+
}
|
|
15
|
+
const resolved = await resolveRunId(api, request);
|
|
16
|
+
if (resolved.kind !== 'run')
|
|
17
|
+
return resolved;
|
|
18
|
+
const resuming = request.resume ? await api.runSnapshots(resolved.id) : [];
|
|
19
|
+
try {
|
|
20
|
+
const { runId } = await api.rerun(resolved.id, {
|
|
21
|
+
mode: request.mode,
|
|
22
|
+
...(request.jobId !== undefined ? { jobId: request.jobId } : {}),
|
|
23
|
+
debug: request.debug,
|
|
24
|
+
resume: request.resume,
|
|
25
|
+
});
|
|
26
|
+
return { kind: 'started', runId, resuming };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
// Everything the API can refuse — a run still in progress, a job that is
|
|
30
|
+
// not part of it, a run with no plan — comes back in its words rather than
|
|
31
|
+
// being re-guessed here.
|
|
32
|
+
return { kind: 'refused', message: err instanceof Error ? err.message : String(err) };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { isRunId, selectRun } from '../domain/runs.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a selector to a run id.
|
|
5
|
+
*
|
|
6
|
+
* An id is returned **without listing anything**. Whether it exists is then
|
|
7
|
+
* settled by the call the caller was going to make anyway, which 404s on a run
|
|
8
|
+
* that is really absent — so nothing is lost by not checking twice, and the
|
|
9
|
+
* horizon is gone.
|
|
10
|
+
*/
|
|
11
|
+
export const resolveRunId = async (api, request) => {
|
|
12
|
+
if (isRunId(request.run))
|
|
13
|
+
return { kind: 'run', id: request.run.trim() };
|
|
14
|
+
const runs = await api.listRuns(request.repository);
|
|
15
|
+
if (runs.length === 0)
|
|
16
|
+
return { kind: 'no-runs' };
|
|
17
|
+
const selected = selectRun(runs, request.run);
|
|
18
|
+
return selected === undefined ? { kind: 'unknown-run', selector: request.run } : { kind: 'run', id: selected.id };
|
|
19
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the given workflow, or the repository's only workflow when none is
|
|
3
|
+
* named. More than one candidate is an error — the runner executes exactly
|
|
4
|
+
* one file per invocation.
|
|
5
|
+
*/
|
|
6
|
+
export const runWorkflow = async (runner, locator, workflow, options) => {
|
|
7
|
+
let target = workflow;
|
|
8
|
+
if (target === undefined) {
|
|
9
|
+
const candidates = await locator.defaultWorkflows();
|
|
10
|
+
if (candidates.length === 0)
|
|
11
|
+
return { kind: 'no-workflows' };
|
|
12
|
+
if (candidates.length > 1)
|
|
13
|
+
return { kind: 'ambiguous', candidates };
|
|
14
|
+
target = candidates[0];
|
|
15
|
+
}
|
|
16
|
+
return await runner.run({ ...options, workflow: target });
|
|
17
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { buildChecklist, migrationVerdict, } from '../domain/migration.js';
|
|
3
|
+
/**
|
|
4
|
+
* Scan an organization: find its workflows, ask the engine what runs, and turn
|
|
5
|
+
* the secret and variable names into a checklist.
|
|
6
|
+
*
|
|
7
|
+
* The three steps are separate ports because they fail differently and a
|
|
8
|
+
* migration report is most useful when a partial answer still arrives. No
|
|
9
|
+
* repositories, no workflows and no engine are each their own outcome rather
|
|
10
|
+
* than an exception, and the checklist degrades to "could not be read" rather
|
|
11
|
+
* than to an empty list that looks complete.
|
|
12
|
+
*/
|
|
13
|
+
export const scanOrganization = async (deps, org, options = {}) => {
|
|
14
|
+
let summaries;
|
|
15
|
+
try {
|
|
16
|
+
summaries =
|
|
17
|
+
options.repos !== undefined && options.repos.length > 0
|
|
18
|
+
? options.repos.map((slug) => ({ slug, archived: false }))
|
|
19
|
+
: await deps.workflows.repositories(org);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return { kind: 'refused', message: message(err) };
|
|
23
|
+
}
|
|
24
|
+
if (summaries.length === 0)
|
|
25
|
+
return { kind: 'no-repositories', organization: org };
|
|
26
|
+
const archived = summaries.filter((r) => r.archived).map((r) => r.slug);
|
|
27
|
+
const repositories = summaries.filter((r) => !r.archived).map((r) => r.slug);
|
|
28
|
+
if (repositories.length === 0)
|
|
29
|
+
return { kind: 'no-repositories', organization: org };
|
|
30
|
+
const documents = [];
|
|
31
|
+
const withoutWorkflows = [];
|
|
32
|
+
for (const repo of repositories) {
|
|
33
|
+
let found;
|
|
34
|
+
try {
|
|
35
|
+
found = await deps.workflows.workflows(repo);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { kind: 'refused', message: message(err) };
|
|
39
|
+
}
|
|
40
|
+
if (found.length === 0)
|
|
41
|
+
withoutWorkflows.push(repo);
|
|
42
|
+
documents.push(...found);
|
|
43
|
+
}
|
|
44
|
+
if (documents.length === 0)
|
|
45
|
+
return { kind: 'no-workflows', organization: org, repositories };
|
|
46
|
+
const result = await deps.checker.assess(org, documents);
|
|
47
|
+
if (result.kind === 'engine-missing')
|
|
48
|
+
return result;
|
|
49
|
+
const report = result.report;
|
|
50
|
+
// undefined, not [], when nobody could read them: an empty checklist that
|
|
51
|
+
// looks complete is the failure this distinction exists to prevent.
|
|
52
|
+
let configured;
|
|
53
|
+
if (options.includeNames === true && deps.names !== undefined) {
|
|
54
|
+
try {
|
|
55
|
+
configured = await deps.names.configuredNames(org, repositories);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
configured = undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const verdict = migrationVerdict(report);
|
|
62
|
+
return {
|
|
63
|
+
kind: 'scan',
|
|
64
|
+
ok: verdict !== 'blocked' && verdict !== 'incomplete',
|
|
65
|
+
scan: {
|
|
66
|
+
organization: org,
|
|
67
|
+
repositories,
|
|
68
|
+
report,
|
|
69
|
+
checklist: buildChecklist(report.references, configured),
|
|
70
|
+
verdict,
|
|
71
|
+
withoutWorkflows,
|
|
72
|
+
archived,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search a repository's logs.
|
|
3
|
+
*
|
|
4
|
+
* No filtering and no matching here: the scan's rules and its bounds live in
|
|
5
|
+
* the control plane, which is the only place that can enforce them, and a
|
|
6
|
+
* second implementation would be a second answer.
|
|
7
|
+
*/
|
|
8
|
+
export const searchLogs = async (api, repository, query) => {
|
|
9
|
+
try {
|
|
10
|
+
return { kind: 'report', report: await api.searchLogs(repository, query) };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return { kind: 'refused', message: err instanceof Error ? err.message : String(err) };
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/** The structured reading of one job's log, for `apex logs --summary`. */
|
|
17
|
+
export const readLogView = async (api, jobId) => {
|
|
18
|
+
try {
|
|
19
|
+
return { kind: 'view', view: await api.jobLogView(jobId) };
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return { kind: 'refused', message: err instanceof Error ? err.message : String(err) };
|
|
23
|
+
}
|
|
24
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
/**
|
|
3
|
+
* Run the language server until the editor closes the connection.
|
|
4
|
+
*
|
|
5
|
+
* The one decision here is that a missing engine binary is reported as itself
|
|
6
|
+
* rather than as a crash: an editor that starts a language server and gets a
|
|
7
|
+
* non-zero exit shows "the server crashed", which sends somebody looking in the
|
|
8
|
+
* wrong place. The message names the binary and the variable that overrides it.
|
|
9
|
+
*/
|
|
10
|
+
export const serveLanguageServer = async (host) => {
|
|
11
|
+
const { code, missing } = await host.serve();
|
|
12
|
+
if (missing !== undefined)
|
|
13
|
+
return { kind: 'engine-missing', message: missing };
|
|
14
|
+
return { kind: 'exited', code };
|
|
15
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { hasErrors } from '../domain/validation.js';
|
|
3
|
+
/** Validate the given files, or every workflow the locator finds. */
|
|
4
|
+
export const validateWorkflows = async (validator, locator, files) => {
|
|
5
|
+
const targets = files.length > 0 ? files : await locator.defaultWorkflows();
|
|
6
|
+
if (targets.length === 0)
|
|
7
|
+
return { kind: 'no-workflows' };
|
|
8
|
+
const result = await validator.validate(targets);
|
|
9
|
+
if (result.kind === 'engine-missing')
|
|
10
|
+
return result;
|
|
11
|
+
return { kind: 'report', report: result.report, ok: !hasErrors(result.report) };
|
|
12
|
+
};
|