@intentius/chant 0.124.48 → 0.124.49
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/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -12
- package/dist/cli/handlers/graph-reader.d.ts +26 -0
- package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
- package/dist/cli/handlers/graph-reader.js +41 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +40 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +22 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +11 -0
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +3 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/gate-decision.d.ts +9 -0
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +22 -7
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-approval-base.d.ts +25 -0
- package/dist/op/gate-approval-base.d.ts.map +1 -0
- package/dist/op/gate-approval-base.js +71 -0
- package/dist/op/gate-approval.d.ts +7 -4
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +2 -25
- package/dist/op/gate-name.d.ts +13 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +14 -1
- package/dist/op/gate.d.ts +4 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +9 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +24 -5
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +4 -0
- package/dist/pr-migrations.d.ts +62 -0
- package/dist/pr-migrations.d.ts.map +1 -0
- package/dist/pr-migrations.js +50 -0
- package/dist/pr-post.js +1 -1
- package/dist/wave-runtime.mjs +219 -218
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/gate-approval.d.ts +58 -0
- package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
- package/dist/workspace/checks/gate-approval.js +103 -0
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -0
- package/dist/workspace/declaration.d.ts +7 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +13 -1
- package/dist/workspace/declaration.schema.json +33 -0
- package/dist/workspace/exit-cli.d.ts +44 -0
- package/dist/workspace/exit-cli.d.ts.map +1 -0
- package/dist/workspace/exit-cli.js +198 -0
- package/dist/workspace/exit.d.ts +159 -0
- package/dist/workspace/exit.d.ts.map +1 -0
- package/dist/workspace/exit.js +480 -0
- package/dist/workspace/exit.schema.json +257 -0
- package/dist/workspace/graph-cache.d.ts +3 -1
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +12 -1
- package/dist/workspace/import-fetch.d.ts +38 -0
- package/dist/workspace/import-fetch.d.ts.map +1 -0
- package/dist/workspace/import-fetch.js +99 -0
- package/dist/workspace/import-init.d.ts +26 -0
- package/dist/workspace/import-init.d.ts.map +1 -0
- package/dist/workspace/import-init.js +130 -0
- package/dist/workspace/import-source.d.ts +147 -0
- package/dist/workspace/import-source.d.ts.map +1 -0
- package/dist/workspace/import-source.js +370 -0
- package/dist/workspace/init.d.ts +4 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/init.js +70 -3
- package/dist/workspace/kind-readers.d.ts +6 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +18 -0
- package/dist/workspace/kinds.d.ts +24 -1
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +64 -4
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +13 -4
- package/dist/workspace/new-migrations.d.ts +47 -0
- package/dist/workspace/new-migrations.d.ts.map +1 -0
- package/dist/workspace/new-migrations.js +91 -0
- package/dist/workspace/pr-report.schema.json +46 -0
- package/dist/workspace/promote.d.ts +96 -0
- package/dist/workspace/promote.d.ts.map +1 -0
- package/dist/workspace/promote.js +263 -0
- package/dist/workspace/reason-codes.d.ts +13 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +14 -0
- package/dist/workspace/search-contract.d.ts +1 -1
- package/dist/workspace/search-contract.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +50 -7
- package/package.json +1 -1
- package/src/cli/handlers/approve.ts +10 -2
- package/src/cli/handlers/graph-reader.test.ts +31 -0
- package/src/cli/handlers/graph-reader.ts +51 -0
- package/src/cli/handlers/graph.ts +14 -0
- package/src/cli/handlers/pr.ts +38 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +22 -0
- package/src/cli/parse-args.ts +8 -0
- package/src/cli/registry.ts +8 -0
- package/src/components/pr-env-run-bundle.test.ts +1 -0
- package/src/components/pr-run.ts +4 -0
- package/src/config.ts +16 -0
- package/src/gate-decision-bundle.test.ts +4 -2
- package/src/gate-decision-identity.test.ts +11 -0
- package/src/gate-decision.ts +26 -6
- package/src/lexicon.ts +24 -0
- package/src/op/gate-approval-at-base.test.ts +97 -0
- package/src/op/gate-approval-base.ts +72 -0
- package/src/op/gate-approval.ts +10 -21
- package/src/op/gate-name.ts +16 -1
- package/src/op/gate.ts +4 -3
- package/src/op/local-executor.ts +25 -5
- package/src/pr-loop-bundle.test.ts +3 -2
- package/src/pr-loop.test.ts +52 -0
- package/src/pr-loop.ts +12 -0
- package/src/pr-migrations.ts +97 -0
- package/src/pr-post.ts +1 -1
- package/src/workspace/__fixtures__/import/spacelift.json +9 -0
- package/src/workspace/__fixtures__/import/tfc.json +13 -0
- package/src/workspace/behold-kinds.test.ts +1 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/gate-approval.test.ts +77 -0
- package/src/workspace/checks/gate-approval.ts +121 -0
- package/src/workspace/checks.ts +4 -0
- package/src/workspace/declaration.schema.json +33 -0
- package/src/workspace/declaration.ts +19 -2
- package/src/workspace/exit-cli.ts +219 -0
- package/src/workspace/exit.schema.json +257 -0
- package/src/workspace/exit.test.ts +185 -0
- package/src/workspace/exit.ts +560 -0
- package/src/workspace/graph-cache.ts +12 -2
- package/src/workspace/identity.test.ts +18 -3
- package/src/workspace/import-fetch.ts +151 -0
- package/src/workspace/import-init.ts +147 -0
- package/src/workspace/import-source.test.ts +183 -0
- package/src/workspace/import-source.ts +462 -0
- package/src/workspace/init.test.ts +55 -1
- package/src/workspace/init.ts +66 -4
- package/src/workspace/kind-readers.test.ts +36 -0
- package/src/workspace/kind-readers.ts +18 -1
- package/src/workspace/kinds.test.ts +23 -0
- package/src/workspace/kinds.ts +81 -5
- package/src/workspace/lineage-upgrade.test.ts +20 -0
- package/src/workspace/lineage-upgrade.ts +16 -7
- package/src/workspace/new-migrations.test.ts +98 -0
- package/src/workspace/new-migrations.ts +109 -0
- package/src/workspace/pr-report.schema.json +46 -0
- package/src/workspace/promote.test.ts +114 -0
- package/src/workspace/promote.ts +302 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +14 -0
- package/src/workspace/workspace-kinds.schema.json +50 -7
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace exit` (#3177): leave a member, or the workspace, in one
|
|
3
|
+
* step, and prove on every commit that leaving still works.
|
|
4
|
+
*
|
|
5
|
+
* Per member kind:
|
|
6
|
+
*
|
|
7
|
+
* - `chant`: build once per lexicon and commit the native output under
|
|
8
|
+
* `native/`, rewrite the member's CI files to call the platform's own CLI,
|
|
9
|
+
* drop the chant dependencies and `chant.config.*`, and set the member to
|
|
10
|
+
* `other` with its outputs listed. Where an Op's `op.json` sits in the
|
|
11
|
+
* member and the Op is linear (shell steps only), a plain CI job is written
|
|
12
|
+
* beside it. The `op.json` stays.
|
|
13
|
+
* - `choudoufu`: the roundtrip in choudoufu's `live/MIGRATE.md`. It needs
|
|
14
|
+
* stock OpenTofu and the estate's state, so the exit lists its steps and
|
|
15
|
+
* changes no file.
|
|
16
|
+
* - every other kind (`other`, records, ledgers, `workspace`, `repo`): kept
|
|
17
|
+
* as files.
|
|
18
|
+
*
|
|
19
|
+
* The functions here work on a plain directory, so the same code runs in the
|
|
20
|
+
* staging worktree (`exit-cli.ts`) and in the drill's scratch copy.
|
|
21
|
+
* {@link runExitDrill} runs the exit on a scratch copy of a workspace and
|
|
22
|
+
* checks the result statically: it proves each member holds what a stock
|
|
23
|
+
* tool needs and nothing that needs chant. It never installs chant or calls
|
|
24
|
+
* a cloud, so it is a unit-level check CI can run on every commit.
|
|
25
|
+
*/
|
|
26
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
29
|
+
import { readDeclaration } from "./declaration.js";
|
|
30
|
+
import { setProperty } from "./json-edit.js";
|
|
31
|
+
import { workingTree } from "./tree.js";
|
|
32
|
+
/** The lexicons `workspace exit` knows a native target for. */
|
|
33
|
+
export const NATIVE_TARGETS = {
|
|
34
|
+
aws: {
|
|
35
|
+
describes: "CloudFormation template",
|
|
36
|
+
file: "template.json",
|
|
37
|
+
deploy: (f, m) => `aws cloudformation deploy --template-file ${f} --stack-name ${m} --capabilities CAPABILITY_NAMED_IAM`,
|
|
38
|
+
},
|
|
39
|
+
azure: {
|
|
40
|
+
describes: "ARM template",
|
|
41
|
+
file: "azuredeploy.json",
|
|
42
|
+
deploy: (f, m) => `az deployment group create --resource-group ${m} --template-file ${f}`,
|
|
43
|
+
},
|
|
44
|
+
gcp: { describes: "Config Connector objects", file: "config-connector.yaml", deploy: (f) => `kubectl apply -f ${f}` },
|
|
45
|
+
k8s: { describes: "Kubernetes YAML", file: "k8s.yaml", deploy: (f) => `kubectl apply -f ${f}` },
|
|
46
|
+
docker: { describes: "Compose file", file: "docker-compose.yml", deploy: (f) => `docker compose -f ${f} up -d` },
|
|
47
|
+
otel: { describes: "OpenTelemetry collector config", file: "otel-collector.yaml", deploy: null },
|
|
48
|
+
github: { describes: "GitHub Actions workflow", file: ".github/workflows/chant-exit.yml", inMember: true, deploy: null },
|
|
49
|
+
gitlab: { describes: "GitLab CI file", file: ".gitlab-ci.yml", inMember: true, deploy: null },
|
|
50
|
+
};
|
|
51
|
+
/** The lexicons a member's `chant.config.*` or `package.json` names, in order. */
|
|
52
|
+
export function memberLexicons(dir) {
|
|
53
|
+
for (const name of ["chant.config.ts", "chant.config.js", "chant.config.mjs"]) {
|
|
54
|
+
const file = join(dir, name);
|
|
55
|
+
if (!existsSync(file))
|
|
56
|
+
continue;
|
|
57
|
+
const m = /lexicons\s*:\s*\[([^\]]*)\]/.exec(readFileSync(file, "utf-8"));
|
|
58
|
+
if (m)
|
|
59
|
+
return [...m[1].matchAll(/["'`]([\w-]+)["'`]/g)].map((x) => x[1]);
|
|
60
|
+
}
|
|
61
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
64
|
+
for (const dep of Object.keys(pkg?.[section] ?? {})) {
|
|
65
|
+
const m = /^@intentius\/chant-lexicon-([\w-]+)$/.exec(dep);
|
|
66
|
+
if (m && !found.includes(m[1]))
|
|
67
|
+
found.push(m[1]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
/** Why a chant member cannot be exited. Closed; each is in `reason-codes.ts`. */
|
|
73
|
+
export const EXIT_REFUSAL_CODES = ["missing-dir", "no-lexicon", "lexicon-unsupported"];
|
|
74
|
+
export class ExitError extends Error {
|
|
75
|
+
}
|
|
76
|
+
/** What exiting `names` (every exitable member when none are named) does, read from the declaration. */
|
|
77
|
+
export function planExit(root, names = []) {
|
|
78
|
+
const dec = readDeclaration(workingTree(root));
|
|
79
|
+
const real = dec.members.filter((m) => m.type === "member");
|
|
80
|
+
for (const n of names) {
|
|
81
|
+
if (!real.some((m) => m.name === n))
|
|
82
|
+
throw new ExitError(`no member named "${n}" (members: ${real.map((m) => m.name).join(", ") || "none"})`);
|
|
83
|
+
}
|
|
84
|
+
const chosen = names.length > 0 ? real.filter((m) => names.includes(m.name)) : real;
|
|
85
|
+
return { root, members: chosen.map((m) => planMember(root, m)) };
|
|
86
|
+
}
|
|
87
|
+
function planMember(root, m) {
|
|
88
|
+
const base = { name: m.name, dir: m.dir, kind: m.kind };
|
|
89
|
+
if (m.kind === "choudoufu")
|
|
90
|
+
return { ...base, action: "choudoufu-roundtrip", lexicons: [] };
|
|
91
|
+
if (m.kind !== "chant")
|
|
92
|
+
return { ...base, action: "keep", lexicons: [] };
|
|
93
|
+
const abs = resolve(root, m.dir);
|
|
94
|
+
if (!existsSync(abs))
|
|
95
|
+
return { ...base, action: "native-build", lexicons: [], refused: { code: "missing-dir", message: `${m.dir} does not exist` } };
|
|
96
|
+
const lexicons = memberLexicons(abs);
|
|
97
|
+
if (lexicons.length === 0) {
|
|
98
|
+
return { ...base, action: "native-build", lexicons, refused: { code: "no-lexicon", message: "no lexicon is named in chant.config.* or package.json, so there is nothing to build" } };
|
|
99
|
+
}
|
|
100
|
+
const unknown = lexicons.filter((l) => !NATIVE_TARGETS[l]);
|
|
101
|
+
if (unknown.length > 0) {
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
action: "native-build",
|
|
105
|
+
lexicons,
|
|
106
|
+
refused: { code: "lexicon-unsupported", message: `no native target for lexicon ${unknown.join(", ")} (supported: ${Object.keys(NATIVE_TARGETS).join(", ")})` },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ...base, action: "native-build", lexicons };
|
|
110
|
+
}
|
|
111
|
+
const CHANT_FILES = ["chant.config.ts", "chant.config.js", "chant.config.mjs", "chant.config.json"];
|
|
112
|
+
const CI_GLOBS = [".github/workflows", "ci", ".circleci"];
|
|
113
|
+
const CI_ROOT_FILES = [".gitlab-ci.yml", ".gitlab-ci.yaml"];
|
|
114
|
+
/** The choudoufu roundtrip, in order (`live/MIGRATE.md`). Run with stock OpenTofu. */
|
|
115
|
+
export function choudoufuSteps(member, dir) {
|
|
116
|
+
return [
|
|
117
|
+
`cd ${dir}`,
|
|
118
|
+
"copy the cache to terraform.tfstate",
|
|
119
|
+
"remove the live block from the estate",
|
|
120
|
+
`CHOUDOUFU_UNMIGRATE=${member} tofu plan # stock's first plan removes the two marker tags`,
|
|
121
|
+
"tofu apply",
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Exit the planned members in the tree at `plan.root`, editing it in place.
|
|
126
|
+
* Builds through `runBuild`, so a test can supply output without chant.
|
|
127
|
+
*/
|
|
128
|
+
export async function exitInDir(plan, runBuild) {
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const m of plan.members) {
|
|
131
|
+
const result = { name: m.name, dir: m.dir, kind: m.kind, status: "kept", native: [], deploy: [], ci: [], ops: [], steps: [] };
|
|
132
|
+
results.push(result);
|
|
133
|
+
if (m.refused) {
|
|
134
|
+
result.status = "refused";
|
|
135
|
+
result.code = m.refused.code;
|
|
136
|
+
result.detail = `${m.refused.code}: ${m.refused.message}`;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (m.action === "choudoufu-roundtrip") {
|
|
140
|
+
result.status = "manual";
|
|
141
|
+
result.steps = choudoufuSteps(m.name, m.dir);
|
|
142
|
+
result.detail = "needs stock OpenTofu and the estate's state; the exit changes no file";
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (m.action === "keep")
|
|
146
|
+
continue;
|
|
147
|
+
const abs = resolve(plan.root, m.dir);
|
|
148
|
+
const outputs = [];
|
|
149
|
+
for (const lexicon of m.lexicons) {
|
|
150
|
+
const target = NATIVE_TARGETS[lexicon];
|
|
151
|
+
const rel = target.inMember ? target.file : `native/${target.file}`;
|
|
152
|
+
const out = join(abs, rel);
|
|
153
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
154
|
+
const run = await runBuild("build", abs, [".", "--lexicon", lexicon, "--output", out]);
|
|
155
|
+
if (run.exitCode !== 0 || !existsSync(out) || statSync(out).size === 0) {
|
|
156
|
+
result.status = "failed";
|
|
157
|
+
result.detail = `chant build --lexicon ${lexicon} failed${run.output ? `: ${run.output.trim().split("\n").slice(-5).join("\n")}` : ""}`;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
result.native.push(relPath(plan.root, out));
|
|
161
|
+
if (target.deploy)
|
|
162
|
+
result.deploy.push(target.deploy(rel, m.name));
|
|
163
|
+
outputs.push(rel);
|
|
164
|
+
}
|
|
165
|
+
if (result.status === "failed")
|
|
166
|
+
continue;
|
|
167
|
+
result.ci = rewriteCi(plan.root, abs, result.deploy);
|
|
168
|
+
result.ops = collectOps(plan.root, abs);
|
|
169
|
+
for (const op of linearOpJobs(plan.root, abs))
|
|
170
|
+
result.ci.push(op);
|
|
171
|
+
dropChant(abs);
|
|
172
|
+
result.status = "exited";
|
|
173
|
+
}
|
|
174
|
+
setKindOther(plan.root, results.filter((r) => r.status === "exited"));
|
|
175
|
+
return { members: results };
|
|
176
|
+
}
|
|
177
|
+
function relPath(root, p) {
|
|
178
|
+
return relative(root, p).split("\\").join("/");
|
|
179
|
+
}
|
|
180
|
+
function readJson(file) {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function walk(dir, visit) {
|
|
189
|
+
if (!existsSync(dir))
|
|
190
|
+
return;
|
|
191
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
192
|
+
if (e.name === "node_modules" || e.name === ".git" || e.name === "native")
|
|
193
|
+
continue;
|
|
194
|
+
const p = join(dir, e.name);
|
|
195
|
+
if (e.isDirectory())
|
|
196
|
+
walk(p, visit);
|
|
197
|
+
else
|
|
198
|
+
visit(p);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function ciFiles(abs) {
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const d of CI_GLOBS)
|
|
204
|
+
walk(join(abs, d), (f) => /\.ya?ml$/.test(f) && out.push(f));
|
|
205
|
+
for (const f of CI_ROOT_FILES)
|
|
206
|
+
if (existsSync(join(abs, f)))
|
|
207
|
+
out.push(join(abs, f));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const CHANT_CALL = /^(\s*(?:-\s+)?(?:run:\s*)?)(?:npx\s+|pnpm\s+exec\s+|npm\s+exec\s+(?:--\s+)?)?chant\s+(?:build|deploy|apply)\b.*$/;
|
|
211
|
+
/**
|
|
212
|
+
* Replace each `chant build|deploy|apply` line of the member's CI files with
|
|
213
|
+
* the stock commands that deploy its native output. A line with no stock
|
|
214
|
+
* command to replace it (CI output, a collector config) is removed.
|
|
215
|
+
*/
|
|
216
|
+
function rewriteCi(root, abs, deploy) {
|
|
217
|
+
const changed = [];
|
|
218
|
+
for (const file of ciFiles(abs)) {
|
|
219
|
+
const lines = readFileSync(file, "utf-8").split("\n");
|
|
220
|
+
let touched = false;
|
|
221
|
+
const next = [];
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
const m = CHANT_CALL.exec(line);
|
|
224
|
+
if (!m) {
|
|
225
|
+
next.push(line);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
touched = true;
|
|
229
|
+
const lead = m[1];
|
|
230
|
+
if (deploy.length === 0)
|
|
231
|
+
continue;
|
|
232
|
+
const inline = /run:/.test(lead);
|
|
233
|
+
if (inline) {
|
|
234
|
+
next.push(`${lead}${deploy.join(" && ")}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const indent = lead.replace(/-\s+$/, "").replace(/\S.*$/, "");
|
|
238
|
+
deploy.forEach((d, i) => next.push(i === 0 ? `${lead}${d}` : `${indent}${d}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (touched) {
|
|
242
|
+
writeFileSync(file, next.join("\n"));
|
|
243
|
+
changed.push(relPath(root, file));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return changed;
|
|
247
|
+
}
|
|
248
|
+
/** `op.json` files under the member's `dist/ops/<name>/` or anywhere beside generated Ops. They stay. */
|
|
249
|
+
function collectOps(root, abs) {
|
|
250
|
+
const out = [];
|
|
251
|
+
walk(abs, (f) => f.endsWith(`${"/"}op.json`) && out.push(relPath(root, f)));
|
|
252
|
+
return out.sort();
|
|
253
|
+
}
|
|
254
|
+
/** The plain commands of a linear Op, or undefined when the Op is not linear. */
|
|
255
|
+
export function linearCommands(op) {
|
|
256
|
+
const cmds = [];
|
|
257
|
+
for (const phase of op.phases ?? []) {
|
|
258
|
+
if (phase.parallel)
|
|
259
|
+
return undefined;
|
|
260
|
+
for (const step of phase.steps ?? []) {
|
|
261
|
+
if (step.kind !== "activity" || step.fn !== "shellCmd" || typeof step.args?.cmd !== "string")
|
|
262
|
+
return undefined;
|
|
263
|
+
cmds.push(step.args.cmd);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if ((op.onFailure ?? []).length > 0)
|
|
267
|
+
return undefined;
|
|
268
|
+
return cmds.length > 0 ? cmds : undefined;
|
|
269
|
+
}
|
|
270
|
+
/** For each linear Op, a plain GitHub Actions job beside its `op.json`. */
|
|
271
|
+
function linearOpJobs(root, abs) {
|
|
272
|
+
const written = [];
|
|
273
|
+
walk(abs, (f) => {
|
|
274
|
+
if (!f.endsWith("/op.json"))
|
|
275
|
+
return;
|
|
276
|
+
const op = readJson(f);
|
|
277
|
+
const cmds = op && linearCommands(op);
|
|
278
|
+
if (!op || !cmds)
|
|
279
|
+
return;
|
|
280
|
+
const out = join(dirname(f), "job.yml");
|
|
281
|
+
const name = op.name ?? "op";
|
|
282
|
+
writeFileSync(out, [
|
|
283
|
+
`# Written by chant workspace exit from ${name}'s op.json. The op is linear, so it is one job.`,
|
|
284
|
+
`name: ${name}`,
|
|
285
|
+
"on: workflow_dispatch",
|
|
286
|
+
"jobs:",
|
|
287
|
+
` ${name.replace(/[^\w-]/g, "-")}:`,
|
|
288
|
+
" runs-on: ubuntu-latest",
|
|
289
|
+
" steps:",
|
|
290
|
+
" - uses: actions/checkout@v4",
|
|
291
|
+
...cmds.map((c, i) => ` - name: step ${i + 1}\n run: ${JSON.stringify(c)}`),
|
|
292
|
+
"",
|
|
293
|
+
].join("\n"));
|
|
294
|
+
written.push(relPath(root, out));
|
|
295
|
+
});
|
|
296
|
+
return written;
|
|
297
|
+
}
|
|
298
|
+
/** Remove chant.config.* and the chant dependencies and scripts from the member's package.json. */
|
|
299
|
+
function dropChant(abs) {
|
|
300
|
+
for (const f of CHANT_FILES)
|
|
301
|
+
rmSync(join(abs, f), { force: true });
|
|
302
|
+
const file = join(abs, "package.json");
|
|
303
|
+
const pkg = readJson(file);
|
|
304
|
+
if (!pkg)
|
|
305
|
+
return;
|
|
306
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
307
|
+
const deps = pkg[section];
|
|
308
|
+
if (!deps)
|
|
309
|
+
continue;
|
|
310
|
+
for (const d of Object.keys(deps))
|
|
311
|
+
if (d === "@intentius/chant" || d.startsWith("@intentius/chant-"))
|
|
312
|
+
delete deps[d];
|
|
313
|
+
if (Object.keys(deps).length === 0)
|
|
314
|
+
delete pkg[section];
|
|
315
|
+
}
|
|
316
|
+
const scripts = pkg.scripts;
|
|
317
|
+
if (scripts) {
|
|
318
|
+
for (const [k, v] of Object.entries(scripts))
|
|
319
|
+
if (/\bchant\b/.test(v))
|
|
320
|
+
delete scripts[k];
|
|
321
|
+
if (Object.keys(scripts).length === 0)
|
|
322
|
+
delete pkg.scripts;
|
|
323
|
+
}
|
|
324
|
+
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
325
|
+
}
|
|
326
|
+
/** Set each exited member to kind `other` in the declaration, with `because` and its outputs. */
|
|
327
|
+
function setKindOther(root, exited) {
|
|
328
|
+
if (exited.length === 0)
|
|
329
|
+
return;
|
|
330
|
+
const dec = readDeclaration(workingTree(root));
|
|
331
|
+
const file = ["chant.workspace.json", "chant.workspace.jsonc"].find((f) => existsSync(join(root, f)));
|
|
332
|
+
const jsonc = file.endsWith("c");
|
|
333
|
+
let text = readFileSync(join(root, file), "utf-8");
|
|
334
|
+
const raw = JSON.parse(jsonc ? text.replace(/^\s*\/\/.*$/gm, "") : text).members;
|
|
335
|
+
for (const r of exited) {
|
|
336
|
+
const i = raw.findIndex((e) => e.name === r.name);
|
|
337
|
+
if (i < 0)
|
|
338
|
+
continue;
|
|
339
|
+
const ptr = `/members/${i}`;
|
|
340
|
+
const member = dec.members.find((m) => m.type === "member" && m.name === r.name);
|
|
341
|
+
text = setProperty(text, jsonc, ptr, "kind", "other");
|
|
342
|
+
text = setProperty(text, jsonc, ptr, "because", `left chant with chant workspace exit; its native output is committed under ${r.native.map((n) => relPath(join(root, r.dir), join(root, n))).join(", ")}`);
|
|
343
|
+
const outputs = member?.outputs ?? [];
|
|
344
|
+
if (outputs.length > 0)
|
|
345
|
+
text = setProperty(text, jsonc, ptr, "outputs", outputs);
|
|
346
|
+
}
|
|
347
|
+
writeFileSync(join(root, file), text);
|
|
348
|
+
}
|
|
349
|
+
// ── The drill ────────────────────────────────────────────────────────────────
|
|
350
|
+
/** What the exit drill finds wrong with an exited member. Closed; each is in `reason-codes.ts`. */
|
|
351
|
+
export const DRILL_CODES = [
|
|
352
|
+
"native-missing",
|
|
353
|
+
"native-unreadable",
|
|
354
|
+
"chant-dependency",
|
|
355
|
+
"chant-config-left",
|
|
356
|
+
"chant-in-ci",
|
|
357
|
+
"no-deploy-command",
|
|
358
|
+
"declaration-not-other",
|
|
359
|
+
"exit-failed",
|
|
360
|
+
"choudoufu-steps-missing",
|
|
361
|
+
];
|
|
362
|
+
/** Every code `workspace exit` prints: why a member was refused, and what the drill found. */
|
|
363
|
+
export const EXIT_CODES = [...EXIT_REFUSAL_CODES, ...DRILL_CODES];
|
|
364
|
+
/** Copy a workspace, leaving out `node_modules`, `.git` and chant's own scratch. */
|
|
365
|
+
export function copyWorkspace(from, to) {
|
|
366
|
+
const skip = new Set(["node_modules", ".git", ".chant"]);
|
|
367
|
+
const copy = (src, dst) => {
|
|
368
|
+
mkdirSync(dst, { recursive: true });
|
|
369
|
+
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
370
|
+
if (skip.has(e.name))
|
|
371
|
+
continue;
|
|
372
|
+
const s = join(src, e.name);
|
|
373
|
+
const d = join(dst, e.name);
|
|
374
|
+
if (e.isDirectory())
|
|
375
|
+
copy(s, d);
|
|
376
|
+
else if (e.isFile())
|
|
377
|
+
copyFileSync(s, d);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
copy(from, to);
|
|
381
|
+
}
|
|
382
|
+
const CHANT_IN_CI = /\bchant\s+(?:build|lint|deploy|apply|run|workspace|ci)\b|@intentius\/chant\b/;
|
|
383
|
+
/**
|
|
384
|
+
* Run the exit on a scratch copy and check each member for what a stock tool
|
|
385
|
+
* needs. Nothing here runs chant or a cloud; the build is the one injected
|
|
386
|
+
* step, and its output is read as text.
|
|
387
|
+
*/
|
|
388
|
+
export async function runExitDrill(opts) {
|
|
389
|
+
const scratch = mkdtempSync(join(tmpdir(), "chant-exit-drill-"));
|
|
390
|
+
try {
|
|
391
|
+
copyWorkspace(opts.root, scratch);
|
|
392
|
+
const plan = planExit(scratch, opts.members ?? []);
|
|
393
|
+
const result = await exitInDir(plan, opts.runBuild);
|
|
394
|
+
return verifyExit(scratch, result);
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Check an exited tree at `root` against what {@link exitInDir} reported. */
|
|
401
|
+
export function verifyExit(root, result) {
|
|
402
|
+
const declared = new Map(readDeclaration(workingTree(root)).members.filter((m) => m.type === "member").map((m) => [m.name, m]));
|
|
403
|
+
const members = result.members.map((r) => checkMember(root, r, declared.get(r.name)));
|
|
404
|
+
return { ok: members.every((m) => m.status !== "failed"), members };
|
|
405
|
+
}
|
|
406
|
+
function checkMember(root, r, declared) {
|
|
407
|
+
const base = { name: r.name, kind: r.kind, native: r.native, deploy: r.deploy };
|
|
408
|
+
const findings = [];
|
|
409
|
+
if (r.status === "kept")
|
|
410
|
+
return { ...base, status: "kept", note: `kind ${r.kind} stays as files`, findings };
|
|
411
|
+
if (r.status === "manual") {
|
|
412
|
+
if (r.steps.length === 0)
|
|
413
|
+
findings.push({ code: "choudoufu-steps-missing", message: "the roundtrip steps were not listed" });
|
|
414
|
+
return findings.length > 0
|
|
415
|
+
? { ...base, status: "failed", findings }
|
|
416
|
+
: { ...base, status: "not-run", note: "the stock OpenTofu roundtrip (copy the cache to terraform.tfstate, remove the live block, plan) is not run, needs the maintainer", findings };
|
|
417
|
+
}
|
|
418
|
+
if (r.status !== "exited") {
|
|
419
|
+
return { ...base, status: "failed", findings: [{ code: "exit-failed", message: r.detail ?? `exit ${r.status}` }] };
|
|
420
|
+
}
|
|
421
|
+
const abs = resolve(root, r.dir);
|
|
422
|
+
for (const n of r.native) {
|
|
423
|
+
const p = join(root, n);
|
|
424
|
+
if (!existsSync(p) || statSync(p).size === 0) {
|
|
425
|
+
findings.push({ code: "native-missing", message: `${n} is missing or empty` });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const text = readFileSync(p, "utf-8");
|
|
429
|
+
if (p.endsWith(".json")) {
|
|
430
|
+
try {
|
|
431
|
+
JSON.parse(text);
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
findings.push({ code: "native-unreadable", message: `${n} is not valid JSON: ${e.message}` });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (/^\s*$/.test(text) || /^\s*[{[]?\s*[}\]]?\s*$/.test(text)) {
|
|
438
|
+
findings.push({ code: "native-unreadable", message: `${n} has no content` });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const deployable = r.native.filter((n) => !/\.github\/|\.gitlab-ci|otel-collector/.test(n));
|
|
442
|
+
if (deployable.length > 0 && r.deploy.length === 0)
|
|
443
|
+
findings.push({ code: "no-deploy-command", message: "a native file has no stock deploy command" });
|
|
444
|
+
for (const f of CHANT_FILES)
|
|
445
|
+
if (existsSync(join(abs, f)))
|
|
446
|
+
findings.push({ code: "chant-config-left", message: `${join(r.dir, f)} is still there` });
|
|
447
|
+
const pkg = readJson(join(abs, "package.json"));
|
|
448
|
+
if (pkg) {
|
|
449
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
450
|
+
for (const d of Object.keys(pkg[section] ?? {})) {
|
|
451
|
+
if (d === "@intentius/chant" || d.startsWith("@intentius/chant-"))
|
|
452
|
+
findings.push({ code: "chant-dependency", message: `package.json ${section} still has ${d}` });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const [k, v] of Object.entries(pkg.scripts ?? {})) {
|
|
456
|
+
if (/\bchant\b/.test(v))
|
|
457
|
+
findings.push({ code: "chant-dependency", message: `package.json script "${k}" still calls chant` });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const f of ciFiles(abs)) {
|
|
461
|
+
const text = readFileSync(f, "utf-8");
|
|
462
|
+
const bad = text.split("\n").find((l) => CHANT_IN_CI.test(l) && !l.trim().startsWith("#"));
|
|
463
|
+
if (bad)
|
|
464
|
+
findings.push({ code: "chant-in-ci", message: `${relPath(root, f)} still calls chant: ${bad.trim()}` });
|
|
465
|
+
}
|
|
466
|
+
if (declared && declared.kind !== "other")
|
|
467
|
+
findings.push({ code: "declaration-not-other", message: `the declaration still has kind ${declared.kind}` });
|
|
468
|
+
return { ...base, status: findings.length > 0 ? "failed" : "passed", findings };
|
|
469
|
+
}
|
|
470
|
+
/** The drill report as lines for a terminal. */
|
|
471
|
+
export function describeDrill(report) {
|
|
472
|
+
const lines = [];
|
|
473
|
+
for (const m of report.members) {
|
|
474
|
+
lines.push(`${m.name} ${m.kind} ${m.status}${m.note ? ` (${m.note})` : ""}`);
|
|
475
|
+
for (const f of m.findings)
|
|
476
|
+
lines.push(` ${f.code}: ${f.message}`);
|
|
477
|
+
}
|
|
478
|
+
lines.push(report.ok ? "exit drill passed" : "exit drill failed");
|
|
479
|
+
return lines;
|
|
480
|
+
}
|