@intentius/plywood 0.2.0 → 0.3.1

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/plywood.mjs CHANGED
@@ -260,13 +260,16 @@ function approvalOf(doc) {
260
260
  function gateOf(doc) {
261
261
  return typeof doc.gate === "string" && GATES.includes(doc.gate) ? doc.gate : DEFAULT_GATE;
262
262
  }
263
+ function previewOf(doc) {
264
+ return doc.preview === true;
265
+ }
263
266
  function quorumOf(rules) {
264
267
  return Object.entries(rules).map(([glob, r]) => {
265
268
  const roles = [...r.role !== void 0 ? [r.role] : [], ...r.roles ?? []];
266
269
  return { targets: [glob], count: r.quorum ?? 1, ...roles.length ? { roles } : {}, notAuthor: true };
267
270
  });
268
271
  }
269
- var GATES, DEFAULT_GATE, APPROVALS, DEFAULT_APPROVAL, APPROVAL_RULE_KEYS, approval;
272
+ var GATES, DEFAULT_GATE, APPROVALS, DEFAULT_APPROVAL, DEFAULT_PREVIEW, APPROVAL_RULE_KEYS, approval;
270
273
  var init_approval = __esm({
271
274
  "src/config/approval.ts"() {
272
275
  "use strict";
@@ -275,13 +278,19 @@ var init_approval = __esm({
275
278
  DEFAULT_GATE = "on-destructive";
276
279
  APPROVALS = ["ledger", "sealed", "pr-review"];
277
280
  DEFAULT_APPROVAL = "ledger";
281
+ DEFAULT_PREVIEW = false;
278
282
  APPROVAL_RULE_KEYS = ["role", "roles", "quorum"];
279
283
  approval = {
280
284
  name: "approval",
281
- keys: ["gate", "approval", "approval_rules"],
285
+ keys: ["gate", "approval", "approval_rules", "preview"],
282
286
  schema: {
283
287
  gate: { type: "string", enum: [...GATES], default: DEFAULT_GATE, description: "always, on-destructive or never." },
284
288
  approval: { type: "string", enum: [...APPROVALS], default: DEFAULT_APPROVAL, description: "ledger, sealed or pr-review." },
289
+ preview: {
290
+ type: "boolean",
291
+ default: DEFAULT_PREVIEW,
292
+ description: "true: plywood init sets approvals.preview in the waves spec, so a merged wave applies only the plans its pull request previewed, or exits 4. Known limits: plywood#55 (the second of two disjoint merges is refused) and plywood#56 (a pull request that creates an autonamed resource is refused)."
293
+ },
285
294
  approval_rules: {
286
295
  type: "object",
287
296
  description: "Role and quorum per stack glob. Read at base.",
@@ -298,6 +307,7 @@ var init_approval = __esm({
298
307
  },
299
308
  check(doc) {
300
309
  const out = [...oneOf(doc.gate, GATES, "gate"), ...oneOf(doc.approval, APPROVALS, "approval")];
310
+ if (doc.preview !== void 0 && typeof doc.preview !== "boolean") out.push(error("preview", "must be true or false"));
301
311
  const rules = doc.approval_rules;
302
312
  if (rules === void 0) return out;
303
313
  if (!isObject(rules)) return [...out, error("approval_rules", "must map a stack glob to { role, quorum }")];
@@ -422,7 +432,7 @@ var init_words = __esm({
422
432
  "src/words.ts"() {
423
433
  "use strict";
424
434
  PRODUCT = "plywood";
425
- TAGLINE = "CI Automation for Pulumi";
435
+ TAGLINE = "Sophisticated automation for Pulumi.";
426
436
  OP_NAME = "pulumi-up";
427
437
  PR_GATE = "up";
428
438
  VERBS = [
@@ -587,13 +597,13 @@ var init_control = __esm({
587
597
  var version, dependencies;
588
598
  var init_package = __esm({
589
599
  "package.json"() {
590
- version = "0.2.0";
600
+ version = "0.3.1";
591
601
  dependencies = {
592
- "@intentius/chant": "0.124.58",
593
- "@intentius/chant-lexicon-forgejo": "0.124.58",
594
- "@intentius/chant-lexicon-github": "0.124.58",
595
- "@intentius/chant-lexicon-gitlab": "0.124.58",
596
- "@intentius/chant-lexicon-pulumi": "0.124.58"
602
+ "@intentius/chant": "0.124.59",
603
+ "@intentius/chant-lexicon-forgejo": "0.124.59",
604
+ "@intentius/chant-lexicon-github": "0.124.59",
605
+ "@intentius/chant-lexicon-gitlab": "0.124.59",
606
+ "@intentius/chant-lexicon-pulumi": "0.124.59"
597
607
  };
598
608
  }
599
609
  });
@@ -603,8 +613,8 @@ var image_digests_default;
603
613
  var init_image_digests = __esm({
604
614
  "src/image-digests.json"() {
605
615
  image_digests_default = {
606
- "ghcr.io/intentius/plywood-pulumi:0.2.0-pulumi3.272.0": "sha256:ea268f1dd2fa15f80ddcedd5c3498d24b79cc2b8a590177c5499e2db3f686f18",
607
- "ghcr.io/intentius/plywood-plunge:0.2.0-plunge0.1.0": "sha256:6a862ad23e39081a32fe8b2331b095ed2de44e527f2719416201b0affa46a1eb"
616
+ "ghcr.io/intentius/plywood-pulumi:0.3.1-pulumi3.272.0": "sha256:acdadb5000ade3afea20ab2f555946312281066e1df288b1007bff096514ebfd",
617
+ "ghcr.io/intentius/plywood-plunge:0.3.1-plunge0.2.0": "sha256:adc42bc97564ecb2074b3ee4d97f84ddb986e0f30f6a26f35b8721106c177456"
608
618
  };
609
619
  }
610
620
  });
@@ -741,14 +751,14 @@ var init_images = __esm({
741
751
  TOOL_VERSIONS = {
742
752
  pulumi: "3.272.0",
743
753
  /** A plunge release (INTENTIUS/plunge, tag plunge-v<ver>), not the Pulumi version it is built from. */
744
- plunge: "0.1.0"
754
+ plunge: "0.2.0"
745
755
  };
746
756
  ARCHES = ["x64", "arm64"];
747
757
  PINNED_SHA256 = {
748
758
  "pulumi-v3.272.0-linux-x64.tar.gz": "58d545f2b98761051922008ebd381ecef95484d7f7a5ff7faa15d7a780ea84f6",
749
759
  "pulumi-v3.272.0-linux-arm64.tar.gz": "4f0741901b8982c4326b702dabf483c63d2cf77c88ac42dd752b72541284a534",
750
- "plunge-v0.1.0-linux-x64.tar.gz": "22b89da39ab7dc11268c7b625fec3d99c0793c9c47bf8d031fa94e6967ee8187",
751
- "plunge-v0.1.0-linux-arm64.tar.gz": "6855611b8cf8381d73f5ddf0b5c0a0d83bdc2a5bd8c50d415e52bb05a4574f83"
760
+ "plunge-v0.2.0-linux-x64.tar.gz": "c9c73d05cca9ad214b7f44230a6162cdcb6ad64bbfc725bc94688b997b191495",
761
+ "plunge-v0.2.0-linux-arm64.tar.gz": "c176a82f038164085aa19c8ad6530d6022e560e124050e535dbe364ae0473357"
752
762
  };
753
763
  ENGINE_ROOT = "/opt/plywood";
754
764
  JOB_ARCH = `"$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')"`;
@@ -2458,7 +2468,7 @@ var init_chant_product = __esm({
2458
2468
  "src/chant-product.ts"() {
2459
2469
  "use strict";
2460
2470
  REQUIRED2 = ["assertProductWords", "productPipelineJobs", "approveLine", "runChantInProcess"];
2461
- BUILT_CHANT = true ? "0.124.58" : "unknown";
2471
+ BUILT_CHANT = true ? "0.124.59" : "unknown";
2462
2472
  }
2463
2473
  });
2464
2474
 
@@ -2475,14 +2485,37 @@ function hasChantApprovalStatus(chant) {
2475
2485
  function hasChantPullRequestApprovalStatus(chant) {
2476
2486
  return typeof chant?.APPROVAL_STATUS_PULL_REQUEST_FLOOR === "string";
2477
2487
  }
2488
+ function hasChantWavePreview() {
2489
+ wavePreview ??= (async () => {
2490
+ try {
2491
+ const m = await import("@intentius/chant/op");
2492
+ return typeof m.OP_WAVES_PREVIEW_FLOOR === "string";
2493
+ } catch {
2494
+ return false;
2495
+ }
2496
+ })();
2497
+ return wavePreview;
2498
+ }
2499
+ function withWavePreview(spec, forge) {
2500
+ const approvals = spec.approvals && typeof spec.approvals === "object" && !Array.isArray(spec.approvals) ? spec.approvals : {};
2501
+ const next = approvals.preview !== void 0 ? spec : { ...spec, approvals: { ...approvals, preview: true } };
2502
+ if (forge === "gitlab" || next.approvals.preview !== true || !Array.isArray(next.waves)) return next;
2503
+ const waves = next.waves.map((w) => ({
2504
+ ...w,
2505
+ variables: { [WAVE_TOKEN_VARIABLE]: "${{ github.token }}", ...w.variables ?? {} }
2506
+ }));
2507
+ return { ...next, waves };
2508
+ }
2478
2509
  function chantAudit() {
2479
2510
  audit ??= import("@intentius/chant/ci-audit").then((m) => m);
2480
2511
  return audit;
2481
2512
  }
2482
- var audit;
2513
+ var wavePreview, WAVE_TOKEN_VARIABLE, GITLAB_READ_TOKEN, audit;
2483
2514
  var init_chant_next = __esm({
2484
2515
  "src/chant-next.ts"() {
2485
2516
  "use strict";
2517
+ WAVE_TOKEN_VARIABLE = "GITHUB_TOKEN";
2518
+ GITLAB_READ_TOKEN = "CHANT_FORGE_TOKEN";
2486
2519
  }
2487
2520
  });
2488
2521
 
@@ -2631,6 +2664,38 @@ var init_note_control = __esm({
2631
2664
  }
2632
2665
  });
2633
2666
 
2667
+ // src/init/run-group.ts
2668
+ function withRunGroup(text, forge, key) {
2669
+ if (forge !== "forgejo" || TOP_CONCURRENCY.test(text)) return text;
2670
+ const jobs2 = TOP_JOBS.exec(text);
2671
+ if (!jobs2) return text;
2672
+ const name = (TOP_NAME.exec(text)?.[1] ?? "plywood").replace(/^["']|["']$/g, "");
2673
+ const group = key === "commit" ? `${name}-\${{ github.event_name }}-\${{ github.sha }}` : `${name}-\${{ github.event_name == 'pull_request' && github.ref || github.run_id }}`;
2674
+ const cancel = key === "commit" ? "false" : "${{ github.event_name == 'pull_request' }}";
2675
+ const block = [
2676
+ "# Without a group Forgejo cancels the run in progress when a push starts another run of this workflow.",
2677
+ key === "commit" ? "# One group for each commit keeps a merge's wave jobs running to completion when another merge lands (plywood#51)." : "# A push to main and a comment get a group of their own, so the record job runs to completion (plywood#51).",
2678
+ "concurrency:",
2679
+ ` group: ${yamlScalar(group)}`,
2680
+ ` cancel-in-progress: ${yamlScalar(cancel)}`,
2681
+ "",
2682
+ ""
2683
+ ].join("\n");
2684
+ return text.slice(0, jobs2.index) + block + text.slice(jobs2.index);
2685
+ }
2686
+ function yamlScalar(value) {
2687
+ return value === "false" ? value : JSON.stringify(value);
2688
+ }
2689
+ var TOP_CONCURRENCY, TOP_JOBS, TOP_NAME;
2690
+ var init_run_group = __esm({
2691
+ "src/init/run-group.ts"() {
2692
+ "use strict";
2693
+ TOP_CONCURRENCY = /^concurrency:/m;
2694
+ TOP_JOBS = /^jobs:/m;
2695
+ TOP_NAME = /^name:\s*(.+?)\s*$/m;
2696
+ }
2697
+ });
2698
+
2634
2699
  // src/init/workflow.ts
2635
2700
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
2636
2701
  import { dirname as dirname2, isAbsolute, join as join2 } from "node:path";
@@ -2643,7 +2708,7 @@ async function writeWorkflow(chant, input) {
2643
2708
  if (previous !== void 0) writeFileSync(file, previous);
2644
2709
  return { code: code !== 0 ? code : 1, file, status: "failed", kept: [] };
2645
2710
  }
2646
- const generated = withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl);
2711
+ const generated = withRunGroup(withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl), input.forge, "pull-request");
2647
2712
  const { text, kept } = keepOwnJobs(previous, generated, input.forge);
2648
2713
  writeFileSync(file, text);
2649
2714
  return { code, file, status: text === previous ? "unchanged" : "written", kept };
@@ -2653,6 +2718,7 @@ var init_workflow = __esm({
2653
2718
  "use strict";
2654
2719
  init_own_jobs();
2655
2720
  init_note_control();
2721
+ init_run_group();
2656
2722
  }
2657
2723
  });
2658
2724
 
@@ -2794,11 +2860,9 @@ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}
2794
2860
  }
2795
2861
  const lines2 = hasChant4607(chant) ? void 0 : binLines(waves, words.bin);
2796
2862
  for (const file of files) {
2797
- if (lines2) {
2798
- const text = readFileSync3(file, "utf8");
2799
- const next = withBinLines(text, lines2);
2800
- if (next !== text) writeFileSync2(file, next);
2801
- }
2863
+ const text = readFileSync3(file, "utf8");
2864
+ const next = withRunGroup(lines2 ? withBinLines(text, lines2) : text, forge, "commit");
2865
+ if (next !== text) writeFileSync2(file, next);
2802
2866
  io.err(`${PRODUCT} ci: waves workflow at ${file.slice(io.cwd.length + 1)}`);
2803
2867
  }
2804
2868
  if (forge === "gitlab") {
@@ -2812,6 +2876,7 @@ var init_waves_workflow = __esm({
2812
2876
  "use strict";
2813
2877
  init_chant_product();
2814
2878
  init_images();
2879
+ init_run_group();
2815
2880
  init_words();
2816
2881
  WAVES_OUTPUT_DIR = {
2817
2882
  github: ".github/workflows",
@@ -3121,193 +3186,573 @@ var init_jobs2 = __esm({
3121
3186
  }
3122
3187
  });
3123
3188
 
3124
- // src/refusals.ts
3125
- function refusalOf(code, table) {
3126
- return REFUSALS.find((name) => table[name] === code);
3127
- }
3128
- function refusalLine(refusal, ctx = {}) {
3129
- const gate = ctx.wave !== void 0 ? `wave-${ctx.wave}` : PR_GATE;
3130
- const again = ctx.pr !== void 0 ? `/${PRODUCT} up on pull request ${ctx.pr}` : `${PRODUCT} up${ctx.wave !== void 0 ? ` --wave ${ctx.wave}` : ""}`;
3131
- switch (refusal) {
3132
- case "waiting":
3133
- return `${gate} waits for an approval. Approve the digest chant printed with '${PRODUCT} approve ${gate} --plan <digest>', then run ${again} again.`;
3134
- case "moved":
3135
- return `The preview moved after it was approved (on plunge, a record the saved plan read was written by another update), so nothing was updated. Preview again ('${PRODUCT} preview' or /${PRODUCT} preview), read the new change, and approve its digest with '${PRODUCT} approve ${PR_GATE} --plan <digest>'.`;
3136
- case "held":
3137
- return `Another update or pull request holds a lock on a resource this update touches, and chant named it above. A running update lets go when it ends; a pull request's lock goes with /${PRODUCT} unlock on that pull request. Then run ${again} again.`;
3138
- case "superseded":
3139
- return `A newer push stands in for this one, which stood down. Nothing needs doing: the newer run updates the stacks.`;
3140
- case "denied":
3141
- return `A policy denied the change. Fix it and preview again, or override it with '${PRODUCT} override <stack> --rule <id> --reason <text>' naming exactly the rules chant listed (${POLICY_RULE_FORMS}). An override is spent once its update runs.`;
3142
- case "declined":
3143
- return `An approver declined the change, so nothing was updated. Push a new change and preview it again.`;
3144
- case "misplaced":
3145
- return `The job ran outside the forge environment, branch or runner plywood.yml gives this stack, and chant's job guard named the difference above, so nothing ran. Run it from that branch on that runner, or change environments and runners in plywood.yml and run '${PRODUCT} ci' again.`;
3189
+ // src/init/detect.ts
3190
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync } from "node:fs";
3191
+ import { basename, dirname as dirname5, join as join5, relative, sep } from "node:path";
3192
+ import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
3193
+ function repoRoot(dir) {
3194
+ let cur = dir;
3195
+ for (let i = 0; i < 64; i++) {
3196
+ if (existsSync5(join5(cur, ".git"))) return cur;
3197
+ const parent = dirname5(cur);
3198
+ if (parent === cur) return dir;
3199
+ cur = parent;
3146
3200
  }
3201
+ return dir;
3147
3202
  }
3148
- function explainRefusal(code, table, err, ctx = {}) {
3149
- const refusal = refusalOf(code, table);
3150
- if (refusal) err(`${PRODUCT}: ${refusalLine(refusal, ctx)}`);
3151
- return code;
3152
- }
3153
- var REFUSALS;
3154
- var init_refusals = __esm({
3155
- "src/refusals.ts"() {
3156
- "use strict";
3157
- init_words();
3158
- REFUSALS = ["waiting", "moved", "held", "superseded", "denied", "declined", "misplaced"];
3159
- }
3160
- });
3161
-
3162
- // src/up.ts
3163
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
3164
- import { join as join5 } from "node:path";
3165
- function approveTarget(gate, opts) {
3166
- if (opts.op) return { op: opts.op, gate };
3167
- if (PR_OP.test(gate)) return { op: gate, gate: PR_GATE };
3168
- if (gate === REVIEW_GATE_WORD || gate === REVIEW_GATE) {
3169
- if (!opts.pr) return { error: `a review stack is a pull request's: name it with --pr <n> (or PR_NUMBER)` };
3170
- if (!/^\d+$/.test(opts.pr)) return { error: `--pr takes a pull request number, not '${opts.pr}'` };
3171
- return { op: reviewOp(opts.pr), gate: REVIEW_GATE };
3172
- }
3173
- if (gate === PR_GATE) {
3174
- if (!opts.pr) return { error: `the ${PR_GATE} gate is a pull request's: name it with --pr <n> (or PR_NUMBER), or approve pr-<n>` };
3175
- if (!/^\d+$/.test(opts.pr)) return { error: `--pr takes a pull request number, not '${opts.pr}'` };
3176
- return { op: opts.stack ? `pr-${opts.pr}-${opts.stack}` : `pr-${opts.pr}`, gate };
3177
- }
3178
- return { op: OP_NAME, gate };
3203
+ function posixRel(root, path) {
3204
+ const rel = relative(root, path);
3205
+ return rel === "" ? "." : rel.split(sep).join("/");
3179
3206
  }
3180
- function passFlags(args, valued, switches) {
3181
- const argv = [];
3182
- const rest = [];
3183
- for (let i = 0; i < args.length; i++) {
3184
- const a = args[i];
3185
- if (valued.includes(a)) {
3186
- const v = args[i + 1];
3187
- if (v === void 0 || v.startsWith("--")) return { argv, rest, missing: a };
3188
- argv.push(a, v);
3189
- i++;
3190
- } else if (switches.includes(a)) argv.push(a);
3191
- else rest.push(a);
3207
+ function readYamlFile(file) {
3208
+ try {
3209
+ const doc = parseYAML2(readFileSync5(file, "utf8"));
3210
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
3211
+ } catch {
3212
+ return void 0;
3192
3213
  }
3193
- return { argv, rest };
3194
3214
  }
3195
- async function approve(args, json, table, io) {
3196
- const pr = takeFlag(args, "--pr");
3197
- const stack = takeFlag(pr.rest, "--stack");
3198
- const op = takeFlag(stack.rest, "--op");
3199
- const forgeFlag = takeFlag(op.rest, "--forge");
3200
- const branchFlag = takeFlag(forgeFlag.rest, "--branch");
3201
- for (const [flag, f] of [["--pr", pr], ["--stack", stack], ["--op", op], ["--forge", forgeFlag], ["--branch", branchFlag]]) {
3202
- if (f.missing) return usage(`${flag} takes a value`, table, io);
3203
- }
3204
- if (forgeFlag.value !== void 0 && !FORGES.includes(forgeFlag.value)) return usage(`--forge ${forgeFlag.value} is not one of ${FORGES.join(", ")}`, table, io);
3205
- const passed = passFlags(branchFlag.rest, APPROVE_PASS, APPROVE_SWITCHES);
3206
- if (passed.missing) return usage(`${passed.missing} takes a value`, table, io);
3207
- const named = positionals(passed.rest);
3208
- const unknown = passed.rest.filter((a) => a.startsWith("-"));
3209
- if (unknown.length) return usage(`approve does not take ${unknown.join(" ")}`, table, io);
3210
- if (named.length !== 1) return usage(`approve takes one gate: '${PRODUCT} approve ${PR_GATE} --pr <n> --plan <digest>' or '${PRODUCT} approve wave-<k> --plan <digest>'`, table, io);
3211
- const prNumber = pr.value ?? io.env.PR_NUMBER;
3212
- const gate = named[0];
3213
- const plan = passed.argv[passed.argv.indexOf("--plan") + 1];
3214
- const byPlan = gate === PR_GATE && !prNumber && !op.value && passed.argv.includes("--plan");
3215
- const target = byPlan ? void 0 : approveTarget(gate, { pr: prNumber, stack: stack.value, op: op.value });
3216
- if (target && "error" in target) return usage(target.error, table, io);
3217
- if (!passed.argv.includes("--plan") && !passed.argv.includes("--expire")) {
3218
- return usage(`approve needs --plan <digest>, the digest the preview printed, so a change that moved since is never approved in its place`, table, io);
3219
- }
3220
- return withChant(`${PRODUCT} approve`, table, io, async (chant) => {
3221
- let to = target;
3222
- if (!to) {
3223
- const found = await upTargetByPlan(chant, plan, { stack: stack.value, cwd: io.cwd });
3224
- if ("error" in found) return usage(found.error, table, io);
3225
- if (!json) io.err(`${PRODUCT} approve: ${plan} waits at pull request ${found.pr}'s ${PR_GATE} gate (op ${found.op})`);
3226
- to = { op: found.op, gate: PR_GATE };
3215
+ function findPulumiProjects(root) {
3216
+ const out = [];
3217
+ const walk = (dir, depth) => {
3218
+ let entries;
3219
+ try {
3220
+ entries = readdirSync3(dir).sort();
3221
+ } catch {
3222
+ return;
3227
3223
  }
3228
- const argv = ["approve", to.op, to.gate, ...passed.argv, ...json ? ["--json"] : []];
3229
- const code = explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err);
3230
- if (code !== table.done || to.gate !== PR_GATE || !PR_OP.test(to.op) || !passed.argv.includes("--plan") || passed.argv.includes("--expire")) return code;
3231
- const spec = wavesSpecAt(io.cwd);
3232
- if (spec && wavesTakePullRequestApproval(spec)) {
3233
- const carried = await chant.runChantInProcess(carryArgv(passed.argv), PRODUCT_WORDS);
3234
- if (carried !== table.done) {
3235
- io.err(`${PRODUCT} approve: ${to.op} is approved, and recording the same approval under ${OP_NAME} failed (exit ${carried}), so the waves after the merge still wait at ${PR_GATE}. Run '${PRODUCT} approve ${PR_GATE} --op ${OP_NAME} --plan ${plan} --yes' to record it there.`);
3236
- return explainRefusal(carried, table, io.err);
3224
+ const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
3225
+ if (file) {
3226
+ const project = readProject(root, dir, join5(dir, file));
3227
+ if (project) out.push(project);
3228
+ }
3229
+ if (depth >= MAX_DEPTH) return;
3230
+ for (const name of entries) {
3231
+ if (SKIP.has(name) || name.startsWith(".")) continue;
3232
+ const path = join5(dir, name);
3233
+ try {
3234
+ if (statSync(path).isDirectory()) walk(path, depth + 1);
3235
+ } catch {
3237
3236
  }
3238
- if (!json) io.err(`${PRODUCT} approve: recorded the same approval under ${OP_NAME}, where the waves after the merge read the pull request's ${PR_GATE} gate`);
3239
3237
  }
3240
- if (spec) await refreshApprovalStatus(chant, spec, { forge: forgeFlag.value, branch: branchFlag.value, json }, io);
3241
- return code;
3242
- });
3238
+ };
3239
+ walk(root, 0);
3240
+ return out;
3243
3241
  }
3244
- function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3245
- const path = join5(cwd, file);
3246
- if (!existsSync5(path)) return void 0;
3242
+ function readProject(root, dir, file) {
3243
+ const doc = readYamlFile(file);
3244
+ if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
3245
+ const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
3246
+ const backend = doc.backend;
3247
+ const configDir = typeof doc.stackConfigDir === "string" ? join5(dir, doc.stackConfigDir) : dir;
3248
+ return {
3249
+ name: doc.name,
3250
+ dir: posixRel(root, dir),
3251
+ ...typeof runtime === "string" ? { runtime } : {},
3252
+ ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
3253
+ stacks: stackNames(configDir)
3254
+ };
3255
+ }
3256
+ function stackNames(configDir) {
3257
+ let entries;
3247
3258
  try {
3248
- const spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3249
- return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3259
+ entries = readdirSync3(configDir);
3250
3260
  } catch {
3251
- return void 0;
3261
+ return [];
3252
3262
  }
3263
+ const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
3264
+ return [...new Set(names)].sort(byEnvironment);
3253
3265
  }
3254
- function wavesTakePullRequestApproval(spec) {
3255
- const gate = spec.approvals?.pullRequest?.gate;
3256
- const waves = Array.isArray(spec.waves) ? spec.waves : [];
3257
- return gate === PR_GATE && waves.some((w) => w?.approval === "pull-request");
3266
+ function environmentOf(stack) {
3267
+ const last = stack.split("/").pop().toLowerCase();
3268
+ const head = last.split(/[-_.]/)[0];
3269
+ return ENV_ORDER.includes(head) ? head : last;
3258
3270
  }
3259
- function carryArgv(passed) {
3260
- const kept = passed.filter((a) => a !== "--resume");
3261
- return ["approve", OP_NAME, PR_GATE, ...kept, ...kept.includes("--yes") ? [] : ["--yes"]];
3271
+ function rank(stack) {
3272
+ const env = environmentOf(stack);
3273
+ const at = ENV_ORDER.indexOf(env);
3274
+ return at >= 0 ? at : PROD_RANK - 0.5;
3262
3275
  }
3263
- function approvalStatusTarget(opts, doc, env) {
3264
- const configured = doc?.forge?.kind;
3265
- const ci2 = env.GITLAB_CI === "true" ? "gitlab" : env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" ? "forgejo" : env.GITHUB_ACTIONS === "true" ? "github" : void 0;
3266
- const forge = opts.forge ?? configured ?? ci2;
3267
- const branch = opts.branch ?? (env.GITHUB_BASE_REF || env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || env.CI_DEFAULT_BRANCH || (env.GITHUB_ACTIONS === "true" ? env.GITHUB_REF_NAME : void 0) || void 0);
3268
- return { ...forge ? { forge } : {}, ...branch ? { branch } : {} };
3276
+ function byEnvironment(a, b) {
3277
+ return rank(a) - rank(b) || a.localeCompare(b);
3269
3278
  }
3270
- async function refreshApprovalStatus(chant, spec, opts, io) {
3271
- if (!hasChantPullRequestApprovalStatus(chant)) return;
3272
- const posts = chant.opWavesPostsApprovalStatus ? chant.opWavesPostsApprovalStatus(spec) : wavesTakePullRequestApproval(spec);
3273
- if (!posts) return;
3274
- const path = findConfig(io.cwd);
3275
- const doc = path ? loadConfig(path).doc : void 0;
3276
- const at = approvalStatusTarget(opts, doc, io.env);
3277
- const line = (branch, forge) => `${PRODUCT} run wave --spec ${WAVES_SPEC_FILE} --approval-status --open --branch ${branch} --forge ${forge}`;
3278
- if (!at.forge || !at.branch) {
3279
- io.err(`${PRODUCT} approve: ${PRODUCT}/approval moves at the next push or review; to post it now, run '${line(at.branch ?? "<target branch>", at.forge ?? "<forge>")}' with a forge token`);
3280
- return;
3279
+ function stacksOf2(projects) {
3280
+ const names = /* @__PURE__ */ new Map();
3281
+ for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
3282
+ const out = [];
3283
+ for (const p of projects) {
3284
+ const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
3285
+ for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
3281
3286
  }
3282
- const code = await chant.runChantInProcess(["run", "wave", "--spec", WAVES_SPEC_FILE, "--approval-status", "--open", "--branch", at.branch, "--forge", at.forge], PRODUCT_WORDS);
3283
- if (code !== 0) io.err(`${PRODUCT} approve: the approval is recorded, and ${PRODUCT}/approval was not posted again (exit ${code}); it moves at the next push or review, or run '${line(at.branch, at.forge)}'`);
3284
- else if (!opts.json) io.err(`${PRODUCT} approve: posted ${PRODUCT}/approval again on the open pull requests into ${at.branch}`);
3287
+ return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
3285
3288
  }
3286
- async function upTargetByPlan(chant, digest, opts = {}) {
3287
- const needsPr = `the ${PR_GATE} gate is a pull request's: name it with --pr <n> (or PR_NUMBER), or approve pr-<n>`;
3288
- if (!hasChant4607(chant)) return { error: `${needsPr}. The installed chant cannot find a pull request from a plan digest (chant#4607)` };
3289
- let gates;
3290
- try {
3291
- gates = await chant.pendingGatesByPlan(digest, { pullRequestsOnly: true, ...opts.cwd ? { cwd: opts.cwd } : {} });
3292
- } catch (e) {
3293
- return { error: `${needsPr}. Reading the pending gates for ${digest} failed: ${e.message}` };
3294
- }
3295
- let up2 = gates.filter((g) => g.gate === PR_GATE && g.pr !== void 0);
3296
- if (opts.stack) up2 = up2.filter((g) => g.member === opts.stack);
3297
- const ops = [...new Set(up2.map((g) => g.op))];
3298
- if (ops.length === 0) return { error: `no pull request's ${PR_GATE} gate waits on ${digest}${opts.stack ? ` for ${opts.stack}` : ""}. Check the digest the preview printed and fetch chant/lifecycle, or name the pull request with --pr <n>` };
3299
- const prs = [...new Set(up2.map((g) => g.pr))];
3300
- if (prs.length > 1) return { error: `${digest} waits at the ${PR_GATE} gate of pull requests ${prs.sort((a, b) => a - b).join(", ")}; name one with --pr <n>` };
3301
- const whole = up2.find((g) => g.member === void 0);
3302
- if (whole) return { op: whole.op, pr: whole.pr };
3303
- if (ops.length > 1) return { error: `${digest} waits at the ${PR_GATE} gate of ${ops.join(", ")}; name the stack with --stack <stack>` };
3304
- return { op: ops[0], pr: prs[0] };
3289
+ function slug(s) {
3290
+ const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3291
+ return out === "" ? "root" : out;
3305
3292
  }
3306
- async function override(args, json, table, io) {
3307
- const rules = [];
3308
- const rest = [];
3309
- for (let i = 0; i < args.length; i++) {
3310
- if (args[i] === "--rule") {
3293
+ function originUrl(root) {
3294
+ const config2 = gitFile(root, "config");
3295
+ if (!config2) return void 0;
3296
+ let inOrigin = false;
3297
+ for (const raw of config2.split(/\r?\n/)) {
3298
+ const line = raw.trim();
3299
+ if (line.startsWith("[")) {
3300
+ inOrigin = /^\[remote\s+"origin"\]$/.test(line);
3301
+ continue;
3302
+ }
3303
+ const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
3304
+ if (m) return m[1].trim();
3305
+ }
3306
+ return void 0;
3307
+ }
3308
+ function gitFile(root, name) {
3309
+ const dotGit = join5(root, ".git");
3310
+ let gitDir = dotGit;
3311
+ try {
3312
+ if (statSync(dotGit).isFile()) {
3313
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync5(dotGit, "utf8"));
3314
+ if (!m) return void 0;
3315
+ gitDir = join5(root, m[1].trim());
3316
+ const common = join5(gitDir, "commondir");
3317
+ if (existsSync5(common) && !existsSync5(join5(gitDir, name))) gitDir = join5(gitDir, readFileSync5(common, "utf8").trim());
3318
+ }
3319
+ const path = join5(gitDir, name);
3320
+ return existsSync5(path) ? readFileSync5(path, "utf8") : void 0;
3321
+ } catch {
3322
+ return void 0;
3323
+ }
3324
+ }
3325
+ function remoteHost(url) {
3326
+ const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
3327
+ if (scp) return scp[1].toLowerCase();
3328
+ try {
3329
+ return new URL(url).hostname.toLowerCase() || void 0;
3330
+ } catch {
3331
+ return void 0;
3332
+ }
3333
+ }
3334
+ function forgeOfRemote(url) {
3335
+ const host = remoteHost(url);
3336
+ if (!host) return void 0;
3337
+ if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
3338
+ if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
3339
+ if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
3340
+ if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
3341
+ if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
3342
+ return void 0;
3343
+ }
3344
+ function detectForge(root) {
3345
+ const url = originUrl(root);
3346
+ const byRemote = url ? forgeOfRemote(url) : void 0;
3347
+ if (byRemote) return byRemote;
3348
+ if (existsSync5(join5(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
3349
+ if (existsSync5(join5(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
3350
+ if (existsSync5(join5(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
3351
+ return void 0;
3352
+ }
3353
+ function defaultBranch(root) {
3354
+ const head = gitFile(root, "refs/remotes/origin/HEAD");
3355
+ const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
3356
+ if (remote) return remote.trim();
3357
+ const local = gitFile(root, "HEAD");
3358
+ const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
3359
+ return branch ? branch.trim() : "main";
3360
+ }
3361
+ function findPulumiActions(root) {
3362
+ const out = [];
3363
+ for (const d of WORKFLOW_DIRS) {
3364
+ let names;
3365
+ try {
3366
+ names = readdirSync3(join5(root, d)).sort();
3367
+ } catch {
3368
+ continue;
3369
+ }
3370
+ for (const n of names) {
3371
+ if (!/\.ya?ml$/.test(n)) continue;
3372
+ try {
3373
+ if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync5(join5(root, d, n), "utf8"))) out.push(`${d}/${n}`);
3374
+ } catch {
3375
+ }
3376
+ }
3377
+ }
3378
+ return out;
3379
+ }
3380
+ function detect(cwd) {
3381
+ const root = repoRoot(cwd);
3382
+ const projects = findPulumiProjects(root);
3383
+ const forge = detectForge(root);
3384
+ const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync5(join5(root, n)));
3385
+ return {
3386
+ root,
3387
+ projects,
3388
+ stacks: stacksOf2(projects),
3389
+ ...forge ? { forge } : {},
3390
+ defaultBranch: defaultBranch(root),
3391
+ pulumiActions: findPulumiActions(root),
3392
+ ...preCommit ? { preCommit } : {}
3393
+ };
3394
+ }
3395
+ function repoName(root) {
3396
+ return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
3397
+ }
3398
+ var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
3399
+ var init_detect = __esm({
3400
+ "src/init/detect.ts"() {
3401
+ "use strict";
3402
+ SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
3403
+ MAX_DEPTH = 8;
3404
+ ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
3405
+ PROD_RANK = ENV_ORDER.indexOf("prod");
3406
+ WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
3407
+ }
3408
+ });
3409
+
3410
+ // src/templates.ts
3411
+ import { readFileSync as readFileSync6 } from "node:fs";
3412
+ function template(name) {
3413
+ return readFileSync6(new URL(`../templates/${name}`, import.meta.url), "utf8");
3414
+ }
3415
+ function fill(text, values) {
3416
+ return text.replace(/\{\{(\w+)\}\}/g, (all, key) => key in values ? values[key] : all);
3417
+ }
3418
+ var init_templates = __esm({
3419
+ "src/templates.ts"() {
3420
+ "use strict";
3421
+ }
3422
+ });
3423
+
3424
+ // src/init/render.ts
3425
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
3426
+ function plywoodYml(keys) {
3427
+ const body = Object.entries(keys).filter(([, v]) => v !== void 0).map(([k, v]) => emitYAMLEntry(k, v)).join("\n");
3428
+ const text = fill(template("plywood.yml"), { keys: body });
3429
+ return text.endsWith("\n") ? text : text + "\n";
3430
+ }
3431
+ function chantConfigTs() {
3432
+ return template("chant.config.ts.tmpl");
3433
+ }
3434
+ function componentName(stack) {
3435
+ return slug(stack.key);
3436
+ }
3437
+ function exportName(stack, taken) {
3438
+ let base = stack.key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3439
+ if (base === "" || /^\d/.test(base)) base = `stack_${base}`;
3440
+ let name = base;
3441
+ for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
3442
+ taken.add(name);
3443
+ return name;
3444
+ }
3445
+ function stepInput(s) {
3446
+ return {
3447
+ dir: s.dir,
3448
+ stack: s.stack,
3449
+ member: s.project,
3450
+ ...s.engine ? { engine: s.engine } : {},
3451
+ ...s.backend ? { backend: s.backend } : {},
3452
+ ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
3453
+ ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
3454
+ ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
3455
+ };
3456
+ }
3457
+ function componentsFile(stacks2, after = {}, opts = {}) {
3458
+ const taken = /* @__PURE__ */ new Set();
3459
+ const lines2 = [
3460
+ "// Written by plywood init from the stacks it found; running it again rewrites",
3461
+ "// this file. Each stack is a chant component whose pulumi-up step previews it",
3462
+ "// on a pull request and updates it in its wave.",
3463
+ ""
3464
+ ];
3465
+ for (const s of stacks2) {
3466
+ const dependsOn = (after[s.key] ?? []).filter((u) => stacks2.some((x) => x.key === u)).map((u) => componentName({ key: u }));
3467
+ const step = { kind: "pulumi-up", ...stepInput(s), ...opts.reviewSuffix ? { reviewSuffix: opts.reviewSuffix } : {} };
3468
+ const component = { name: componentName(s), dependsOn, deploy: [{ phase: "up", steps: [step] }] };
3469
+ lines2.push(`export const ${exportName(s, taken)} = ${JSON.stringify(component)};`);
3470
+ }
3471
+ return lines2.join("\n") + "\n";
3472
+ }
3473
+ function wavesOf(stacks2, configured) {
3474
+ const byKey = new Map(stacks2.map((s) => [s.key, s]));
3475
+ if (configured && configured.length > 0) {
3476
+ return configured.map((keys, i) => ({ name: `wave-${i + 1}`, stacks: keys.map((k) => byKey.get(k)).filter((s) => s !== void 0) }));
3477
+ }
3478
+ const out = [];
3479
+ for (const s of stacks2) {
3480
+ const env = environmentOf(s.stack);
3481
+ let name = slug(env);
3482
+ if (!WAVE_NAME.test(name)) name = `wave-${out.length + 1}`;
3483
+ const wave = out.find((w) => w.name === name);
3484
+ if (wave) wave.stacks.push(s);
3485
+ else out.push({ name, stacks: [s] });
3486
+ }
3487
+ return out;
3488
+ }
3489
+ function waveRunner(runner) {
3490
+ if (runner === void 0) return void 0;
3491
+ if (typeof runner === "string" || Array.isArray(runner) || "group" in runner) return runner;
3492
+ const staged = runner;
3493
+ return staged.up ?? staged.default;
3494
+ }
3495
+ function wavesSpec(input) {
3496
+ const waves = (input.ordered ?? wavesOf(input.stacks, input.waves)).filter((w) => w.stacks.length > 0);
3497
+ return {
3498
+ name: OP_NAME,
3499
+ op: OP_NAME,
3500
+ gate: PR_GATE,
3501
+ branches: [input.branch],
3502
+ ...input.runner !== void 0 ? { runner: input.runner } : {},
3503
+ step: { kind: "pulumi-up" },
3504
+ ...input.quorum && input.quorum.length > 0 ? { approvals: { quorum: input.quorum } } : {},
3505
+ ...input.notify ? { notify: input.notify } : {},
3506
+ waves: waves.map((w) => {
3507
+ const gate = w.stacks.map((s) => s.gate ?? input.gate).reduce((a, b) => STRICTNESS[b] > STRICTNESS[a] ? b : a);
3508
+ return { name: w.name, gate, runs: w.stacks.map((s) => ({ target: s.key, input: stepInput(s) })) };
3509
+ })
3510
+ };
3511
+ }
3512
+ function jsonText(v) {
3513
+ return JSON.stringify(v, null, 2) + "\n";
3514
+ }
3515
+ function memberName(project, taken) {
3516
+ let base = slug(project.name).slice(0, 36).replace(/-+$/, "");
3517
+ if (!MEMBER_NAME.test(base)) base = "stack";
3518
+ let name = base;
3519
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
3520
+ taken.add(name);
3521
+ return name;
3522
+ }
3523
+ function workspaceDeclaration(existing, input) {
3524
+ const doc = existing ? structuredClone(existing) : { name: input.name, schema: 1 };
3525
+ const members = Array.isArray(doc.members) ? doc.members : [];
3526
+ const taken = new Set(members.map((m) => String(m.name)));
3527
+ for (const p of input.projects) {
3528
+ if (members.some((m) => m.dir === p.dir || p.dir === "." && m.dir === void 0)) continue;
3529
+ members.push({ name: memberName(p, taken), dir: p.dir, kind: "pulumi" });
3530
+ }
3531
+ doc.members = members;
3532
+ const pins = Array.isArray(doc.pins) ? doc.pins : [];
3533
+ if (!pins.some((p) => p.package === PULUMI_LEXICON)) pins.push({ package: PULUMI_LEXICON, version: input.lexiconVersion });
3534
+ doc.pins = pins;
3535
+ const ci2 = doc.ci && typeof doc.ci === "object" && !Array.isArray(doc.ci) ? doc.ci : {};
3536
+ for (const [k, v] of Object.entries(input.ci)) if (v !== void 0) ci2[k] = v;
3537
+ if (Object.keys(ci2).length > 0) doc.ci = ci2;
3538
+ return doc;
3539
+ }
3540
+ function memberOfDir(doc, dir) {
3541
+ const members = doc && Array.isArray(doc.members) ? doc.members : [];
3542
+ const m = members.find((x) => x.dir === dir || dir === "." && x.dir === void 0);
3543
+ return typeof m?.name === "string" ? m.name : void 0;
3544
+ }
3545
+ var WAVE_NAME, STRICTNESS, MEMBER_NAME, PULUMI_LEXICON;
3546
+ var init_render = __esm({
3547
+ "src/init/render.ts"() {
3548
+ "use strict";
3549
+ init_templates();
3550
+ init_words();
3551
+ init_detect();
3552
+ WAVE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
3553
+ STRICTNESS = { never: 0, "on-destructive": 1, always: 2 };
3554
+ MEMBER_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
3555
+ PULUMI_LEXICON = "@intentius/chant-lexicon-pulumi";
3556
+ }
3557
+ });
3558
+
3559
+ // src/refusals.ts
3560
+ function refusalOf(code, table) {
3561
+ return REFUSALS.find((name) => table[name] === code);
3562
+ }
3563
+ function refusalLine(refusal, ctx = {}) {
3564
+ const gate = ctx.wave !== void 0 ? `wave-${ctx.wave}` : PR_GATE;
3565
+ const again = ctx.pr !== void 0 ? `/${PRODUCT} up on pull request ${ctx.pr}` : `${PRODUCT} up${ctx.wave !== void 0 ? ` --wave ${ctx.wave}` : ""}`;
3566
+ switch (refusal) {
3567
+ case "waiting":
3568
+ return `${gate} waits for an approval. Approve the digest chant printed with '${PRODUCT} approve ${gate} --plan <digest>', then run ${again} again.`;
3569
+ case "moved":
3570
+ return `The preview moved after it was approved (on plunge, a record the saved plan read was written by another update), so nothing was updated. Preview again ('${PRODUCT} preview' or /${PRODUCT} preview), read the new change, and approve its digest with '${PRODUCT} approve ${PR_GATE} --plan <digest>'.`;
3571
+ case "held":
3572
+ return `Another update or pull request holds a lock on a resource this update touches, and chant named it above. A running update lets go when it ends; a pull request's lock goes with /${PRODUCT} unlock on that pull request. Then run ${again} again.`;
3573
+ case "superseded":
3574
+ return `A newer push stands in for this one, which stood down. Nothing needs doing: the newer run updates the stacks.`;
3575
+ case "denied":
3576
+ return `A policy denied the change. Fix it and preview again, or override it with '${PRODUCT} override <stack> --rule <id> --reason <text>' naming exactly the rules chant listed (${POLICY_RULE_FORMS}). An override is spent once its update runs.`;
3577
+ case "declined":
3578
+ return `An approver declined the change, so nothing was updated. Push a new change and preview it again.`;
3579
+ case "misplaced":
3580
+ return `The job ran outside the forge environment, branch or runner plywood.yml gives this stack, and chant's job guard named the difference above, so nothing ran. Run it from that branch on that runner, or change environments and runners in plywood.yml and run '${PRODUCT} ci' again.`;
3581
+ }
3582
+ }
3583
+ function explainRefusal(code, table, err, ctx = {}) {
3584
+ const refusal = refusalOf(code, table);
3585
+ if (refusal) err(`${PRODUCT}: ${refusalLine(refusal, ctx)}`);
3586
+ return code;
3587
+ }
3588
+ function approveLineIn(output) {
3589
+ const m = /Approve these plans to apply them:[ \t]*([^\r\n]+)/.exec(output);
3590
+ const line = m?.[1]?.trim();
3591
+ return line ? line : void 0;
3592
+ }
3593
+ function waveMovedLine(output) {
3594
+ const approve2 = approveLineIn(output);
3595
+ const how = approve2 ? `If a person has read the plans chant listed above and wants them applied, approve exactly them with: ${approve2}, then run the job again.` : `chant named no approval for these plans. Read the plans above, then approve them with '${PRODUCT} approve <wave gate> --plan <digest>' and run the job again.`;
3596
+ return `A merged wave stopped with exit 4 and nothing in it was applied: its plans are not the ones its pull request previewed (or no preview was recorded for it). ${how} To apply only what a pull request previews, open a new pull request, let '${PRODUCT} run wave --record-plans' record its plans, and merge that.`;
3597
+ }
3598
+ var REFUSALS;
3599
+ var init_refusals = __esm({
3600
+ "src/refusals.ts"() {
3601
+ "use strict";
3602
+ init_words();
3603
+ REFUSALS = ["waiting", "moved", "held", "superseded", "denied", "declined", "misplaced"];
3604
+ }
3605
+ });
3606
+
3607
+ // src/up.ts
3608
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
3609
+ import { join as join6 } from "node:path";
3610
+ function approveTarget(gate, opts) {
3611
+ if (opts.op) return { op: opts.op, gate };
3612
+ if (PR_OP.test(gate)) return { op: gate, gate: PR_GATE };
3613
+ if (gate === REVIEW_GATE_WORD || gate === REVIEW_GATE) {
3614
+ if (!opts.pr) return { error: `a review stack is a pull request's: name it with --pr <n> (or PR_NUMBER)` };
3615
+ if (!/^\d+$/.test(opts.pr)) return { error: `--pr takes a pull request number, not '${opts.pr}'` };
3616
+ return { op: reviewOp(opts.pr), gate: REVIEW_GATE };
3617
+ }
3618
+ if (gate === PR_GATE) {
3619
+ if (!opts.pr) return { error: `the ${PR_GATE} gate is a pull request's: name it with --pr <n> (or PR_NUMBER), or approve pr-<n>` };
3620
+ if (!/^\d+$/.test(opts.pr)) return { error: `--pr takes a pull request number, not '${opts.pr}'` };
3621
+ return { op: opts.stack ? `pr-${opts.pr}-${opts.stack}` : `pr-${opts.pr}`, gate };
3622
+ }
3623
+ return { op: OP_NAME, gate };
3624
+ }
3625
+ function passFlags(args, valued, switches) {
3626
+ const argv = [];
3627
+ const rest = [];
3628
+ for (let i = 0; i < args.length; i++) {
3629
+ const a = args[i];
3630
+ if (valued.includes(a)) {
3631
+ const v = args[i + 1];
3632
+ if (v === void 0 || v.startsWith("--")) return { argv, rest, missing: a };
3633
+ argv.push(a, v);
3634
+ i++;
3635
+ } else if (switches.includes(a)) argv.push(a);
3636
+ else rest.push(a);
3637
+ }
3638
+ return { argv, rest };
3639
+ }
3640
+ async function approve(args, json, table, io) {
3641
+ const pr = takeFlag(args, "--pr");
3642
+ const stack = takeFlag(pr.rest, "--stack");
3643
+ const op = takeFlag(stack.rest, "--op");
3644
+ const forgeFlag = takeFlag(op.rest, "--forge");
3645
+ const branchFlag = takeFlag(forgeFlag.rest, "--branch");
3646
+ for (const [flag, f] of [["--pr", pr], ["--stack", stack], ["--op", op], ["--forge", forgeFlag], ["--branch", branchFlag]]) {
3647
+ if (f.missing) return usage(`${flag} takes a value`, table, io);
3648
+ }
3649
+ if (forgeFlag.value !== void 0 && !FORGES.includes(forgeFlag.value)) return usage(`--forge ${forgeFlag.value} is not one of ${FORGES.join(", ")}`, table, io);
3650
+ const passed = passFlags(branchFlag.rest, APPROVE_PASS, APPROVE_SWITCHES);
3651
+ if (passed.missing) return usage(`${passed.missing} takes a value`, table, io);
3652
+ const named = positionals(passed.rest);
3653
+ const unknown = passed.rest.filter((a) => a.startsWith("-"));
3654
+ if (unknown.length) return usage(`approve does not take ${unknown.join(" ")}`, table, io);
3655
+ if (named.length !== 1) return usage(`approve takes one gate: '${PRODUCT} approve ${PR_GATE} --pr <n> --plan <digest>' or '${PRODUCT} approve wave-<k> --plan <digest>'`, table, io);
3656
+ const prNumber = pr.value ?? io.env.PR_NUMBER;
3657
+ const gate = named[0];
3658
+ const plan = passed.argv[passed.argv.indexOf("--plan") + 1];
3659
+ const byPlan = gate === PR_GATE && !prNumber && !op.value && passed.argv.includes("--plan");
3660
+ const target = byPlan ? void 0 : approveTarget(gate, { pr: prNumber, stack: stack.value, op: op.value });
3661
+ if (target && "error" in target) return usage(target.error, table, io);
3662
+ if (!passed.argv.includes("--plan") && !passed.argv.includes("--expire")) {
3663
+ return usage(`approve needs --plan <digest>, the digest the preview printed, so a change that moved since is never approved in its place`, table, io);
3664
+ }
3665
+ return withChant(`${PRODUCT} approve`, table, io, async (chant) => {
3666
+ let to = target;
3667
+ if (!to) {
3668
+ const found = await upTargetByPlan(chant, plan, { stack: stack.value, cwd: io.cwd });
3669
+ if ("error" in found) return usage(found.error, table, io);
3670
+ if (!json) io.err(`${PRODUCT} approve: ${plan} waits at pull request ${found.pr}'s ${PR_GATE} gate (op ${found.op})`);
3671
+ to = { op: found.op, gate: PR_GATE };
3672
+ }
3673
+ const argv = ["approve", to.op, to.gate, ...passed.argv, ...json ? ["--json"] : []];
3674
+ const code = explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err);
3675
+ if (code !== table.done || to.gate !== PR_GATE || !PR_OP.test(to.op) || !passed.argv.includes("--plan") || passed.argv.includes("--expire")) return code;
3676
+ const spec = wavesSpecAt(io.cwd);
3677
+ if (spec && wavesTakePullRequestApproval(spec)) {
3678
+ const carried = await chant.runChantInProcess(carryArgv(passed.argv), PRODUCT_WORDS);
3679
+ if (carried !== table.done) {
3680
+ io.err(`${PRODUCT} approve: ${to.op} is approved, and recording the same approval under ${OP_NAME} failed (exit ${carried}), so the waves after the merge still wait at ${PR_GATE}. Run '${PRODUCT} approve ${PR_GATE} --op ${OP_NAME} --plan ${plan} --yes' to record it there.`);
3681
+ return explainRefusal(carried, table, io.err);
3682
+ }
3683
+ if (!json) io.err(`${PRODUCT} approve: recorded the same approval under ${OP_NAME}, where the waves after the merge read the pull request's ${PR_GATE} gate`);
3684
+ }
3685
+ if (spec) await refreshApprovalStatus(chant, spec, { forge: forgeFlag.value, branch: branchFlag.value, json }, io);
3686
+ return code;
3687
+ });
3688
+ }
3689
+ function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3690
+ const path = join6(cwd, file);
3691
+ if (!existsSync6(path)) return void 0;
3692
+ try {
3693
+ const spec = unwrapWavesSpec(JSON.parse(readFileSync7(path, "utf8")));
3694
+ return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3695
+ } catch {
3696
+ return void 0;
3697
+ }
3698
+ }
3699
+ function wavesTakePullRequestApproval(spec) {
3700
+ const gate = spec.approvals?.pullRequest?.gate;
3701
+ const waves = Array.isArray(spec.waves) ? spec.waves : [];
3702
+ return gate === PR_GATE && waves.some((w) => w?.approval === "pull-request");
3703
+ }
3704
+ function carryArgv(passed) {
3705
+ const kept = passed.filter((a) => a !== "--resume");
3706
+ return ["approve", OP_NAME, PR_GATE, ...kept, ...kept.includes("--yes") ? [] : ["--yes"]];
3707
+ }
3708
+ function approvalStatusTarget(opts, doc, env) {
3709
+ const configured = doc?.forge?.kind;
3710
+ const ci2 = env.GITLAB_CI === "true" ? "gitlab" : env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" ? "forgejo" : env.GITHUB_ACTIONS === "true" ? "github" : void 0;
3711
+ const forge = opts.forge ?? configured ?? ci2;
3712
+ const branch = opts.branch ?? (env.GITHUB_BASE_REF || env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || env.CI_DEFAULT_BRANCH || (env.GITHUB_ACTIONS === "true" ? env.GITHUB_REF_NAME : void 0) || void 0);
3713
+ return { ...forge ? { forge } : {}, ...branch ? { branch } : {} };
3714
+ }
3715
+ async function refreshApprovalStatus(chant, spec, opts, io) {
3716
+ if (!hasChantPullRequestApprovalStatus(chant)) return;
3717
+ const posts = chant.opWavesPostsApprovalStatus ? chant.opWavesPostsApprovalStatus(spec) : wavesTakePullRequestApproval(spec);
3718
+ if (!posts) return;
3719
+ const path = findConfig(io.cwd);
3720
+ const doc = path ? loadConfig(path).doc : void 0;
3721
+ const at = approvalStatusTarget(opts, doc, io.env);
3722
+ const line = (branch, forge) => `${PRODUCT} run wave --spec ${WAVES_SPEC_FILE} --approval-status --open --branch ${branch} --forge ${forge}`;
3723
+ if (!at.forge || !at.branch) {
3724
+ io.err(`${PRODUCT} approve: ${PRODUCT}/approval moves at the next push or review; to post it now, run '${line(at.branch ?? "<target branch>", at.forge ?? "<forge>")}' with a forge token`);
3725
+ return;
3726
+ }
3727
+ const code = await chant.runChantInProcess(["run", "wave", "--spec", WAVES_SPEC_FILE, "--approval-status", "--open", "--branch", at.branch, "--forge", at.forge], PRODUCT_WORDS);
3728
+ if (code !== 0) io.err(`${PRODUCT} approve: the approval is recorded, and ${PRODUCT}/approval was not posted again (exit ${code}); it moves at the next push or review, or run '${line(at.branch, at.forge)}'`);
3729
+ else if (!opts.json) io.err(`${PRODUCT} approve: posted ${PRODUCT}/approval again on the open pull requests into ${at.branch}`);
3730
+ }
3731
+ async function upTargetByPlan(chant, digest, opts = {}) {
3732
+ const needsPr = `the ${PR_GATE} gate is a pull request's: name it with --pr <n> (or PR_NUMBER), or approve pr-<n>`;
3733
+ if (!hasChant4607(chant)) return { error: `${needsPr}. The installed chant cannot find a pull request from a plan digest (chant#4607)` };
3734
+ let gates;
3735
+ try {
3736
+ gates = await chant.pendingGatesByPlan(digest, { pullRequestsOnly: true, ...opts.cwd ? { cwd: opts.cwd } : {} });
3737
+ } catch (e) {
3738
+ return { error: `${needsPr}. Reading the pending gates for ${digest} failed: ${e.message}` };
3739
+ }
3740
+ let up2 = gates.filter((g) => g.gate === PR_GATE && g.pr !== void 0);
3741
+ if (opts.stack) up2 = up2.filter((g) => g.member === opts.stack);
3742
+ const ops = [...new Set(up2.map((g) => g.op))];
3743
+ if (ops.length === 0) return { error: `no pull request's ${PR_GATE} gate waits on ${digest}${opts.stack ? ` for ${opts.stack}` : ""}. Check the digest the preview printed and fetch chant/lifecycle, or name the pull request with --pr <n>` };
3744
+ const prs = [...new Set(up2.map((g) => g.pr))];
3745
+ if (prs.length > 1) return { error: `${digest} waits at the ${PR_GATE} gate of pull requests ${prs.sort((a, b) => a - b).join(", ")}; name one with --pr <n>` };
3746
+ const whole = up2.find((g) => g.member === void 0);
3747
+ if (whole) return { op: whole.op, pr: whole.pr };
3748
+ if (ops.length > 1) return { error: `${digest} waits at the ${PR_GATE} gate of ${ops.join(", ")}; name the stack with --stack <stack>` };
3749
+ return { op: ops[0], pr: prs[0] };
3750
+ }
3751
+ async function override(args, json, table, io) {
3752
+ const rules = [];
3753
+ const rest = [];
3754
+ for (let i = 0; i < args.length; i++) {
3755
+ if (args[i] === "--rule") {
3311
3756
  const v = args[i + 1];
3312
3757
  if (v === void 0 || v.startsWith("--")) return usage("--rule takes a rule id", table, io);
3313
3758
  rules.push(v);
@@ -3329,11 +3774,11 @@ async function override(args, json, table, io) {
3329
3774
  return withChant(`${PRODUCT} override`, table, io, async (chant) => explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err));
3330
3775
  }
3331
3776
  function readUpSpec(cwd, file = WAVES_SPEC_FILE) {
3332
- const path = join5(cwd, file);
3333
- if (!existsSync5(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3777
+ const path = join6(cwd, file);
3778
+ if (!existsSync6(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3334
3779
  let spec;
3335
3780
  try {
3336
- spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3781
+ spec = unwrapWavesSpec(JSON.parse(readFileSync7(path, "utf8")));
3337
3782
  } catch (e) {
3338
3783
  return { error: `${file}: ${e.message}` };
3339
3784
  }
@@ -3488,9 +3933,9 @@ var init_up = __esm({
3488
3933
  // src/drift.ts
3489
3934
  import { createHash as createHash2 } from "node:crypto";
3490
3935
  import { resolveTargetCloudIdentity } from "@intentius/chant/ci-identity";
3491
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3936
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync4, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3492
3937
  import { tmpdir } from "node:os";
3493
- import { dirname as dirname5, join as join6 } from "node:path";
3938
+ import { dirname as dirname6, join as join7 } from "node:path";
3494
3939
  function driftRows(doc) {
3495
3940
  return (doc.records ?? []).filter((r) => DRIFT_STATUSES.includes(r.status)).map((r) => ({ urn: r.address, status: r.status, attributes: (r.attributes ?? []).map((a) => a.path).sort() })).sort((a, b) => a.urn < b.urn ? -1 : a.urn > b.urn ? 1 : 0);
3496
3941
  }
@@ -3549,11 +3994,12 @@ async function drift2(args, json, table, io) {
3549
3994
  const respond = respondFlag.value ?? driftResponseOf(doc);
3550
3995
  const forge = forgeFlag.value;
3551
3996
  return withChant(`${PRODUCT} drift`, table, io, async (chant) => {
3552
- const dir = mkdtempSync(join6(tmpdir(), "plywood-drift-"));
3997
+ const dir = mkdtempSync(join7(tmpdir(), "plywood-drift-"));
3553
3998
  const results = [];
3554
3999
  try {
3555
4000
  const attribute = await attributionFor(doc, io);
3556
- for (const stack of stacks2) results.push(await readStack(chant, stack, dir, { resource: resource.value, env: envFlag.value, ...attribute ? { attribute } : {} }));
4001
+ const root = dirname6(path);
4002
+ for (const stack of stacks2) results.push(await readStack(chant, stack, driftMember(root, doc, stack), dir, { resource: resource.value, env: envFlag.value, ...attribute ? { attribute } : {} }));
3557
4003
  } finally {
3558
4004
  rmSync(dir, { recursive: true, force: true });
3559
4005
  }
@@ -3628,13 +4074,31 @@ async function driftNotice(chant, doc, results, environment, io) {
3628
4074
  ...notifyFetch ? { fetch: notifyFetch } : {}
3629
4075
  });
3630
4076
  }
3631
- async function readStack(chant, stack, dir, opts) {
3632
- const file = join6(dir, `${driftOpName(stack)}.json`);
3633
- const argv = ["workspace", "drift", "--member", stack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
4077
+ function driftMember(root, doc, stack) {
4078
+ const project = stack.includes("/") ? stack.slice(0, stack.indexOf("/")) : stack;
4079
+ let declaration;
4080
+ try {
4081
+ declaration = JSON.parse(readFileSync8(join7(root, "chant.workspace.json"), "utf8"));
4082
+ } catch {
4083
+ return project;
4084
+ }
4085
+ const dir = stacksOf(doc)[stack]?.dir;
4086
+ const byDir = dir !== void 0 ? memberOfDir(declaration, dir) : void 0;
4087
+ if (byDir) return byDir;
4088
+ const names = (Array.isArray(declaration.members) ? declaration.members : []).flatMap((m) => typeof m.name === "string" ? [m.name] : []);
4089
+ if (names.includes(project)) return project;
4090
+ const slugged = names.find((n) => n === slug(project));
4091
+ if (slugged) return slugged;
4092
+ return names.length === 1 ? names[0] : project;
4093
+ }
4094
+ async function readStack(chant, stack, member, dir, opts) {
4095
+ const file = join7(dir, `${driftOpName(stack)}.json`);
4096
+ const pulumiStack = stack.slice(stack.indexOf("/") + 1);
4097
+ const argv = ["workspace", "drift", "--member", member, "--stack", pulumiStack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
3634
4098
  const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
3635
4099
  let doc;
3636
4100
  try {
3637
- if (existsSync6(file)) doc = JSON.parse(readFileSync6(file, "utf8"));
4101
+ if (existsSync7(file)) doc = JSON.parse(readFileSync8(file, "utf8"));
3638
4102
  } catch (e) {
3639
4103
  return { stack, ok: false, drifted: 0, rows: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3640
4104
  }
@@ -3667,7 +4131,7 @@ async function attributeStack(a, stack, doc) {
3667
4131
  }
3668
4132
  function specHasUpdate(cwd, op) {
3669
4133
  try {
3670
- const spec = JSON.parse(readFileSync6(join6(cwd, DRIFT_SPEC_FILE), "utf8"));
4134
+ const spec = JSON.parse(readFileSync8(join7(cwd, DRIFT_SPEC_FILE), "utf8"));
3671
4135
  return (spec.ops ?? []).some((o) => o.name === op && o.update !== void 0);
3672
4136
  } catch {
3673
4137
  return false;
@@ -3743,9 +4207,9 @@ function withNotifyVariables(fields, notify2) {
3743
4207
  return { ...fields, variables: { ...notify2, ...fields.variables ?? {} } };
3744
4208
  }
3745
4209
  function writeIfChanged2(cwd, file, text) {
3746
- const path = join6(cwd, file);
3747
- if (existsSync6(path) && readFileSync6(path, "utf8") === text) return false;
3748
- mkdirSync4(dirname5(path), { recursive: true });
4210
+ const path = join7(cwd, file);
4211
+ if (existsSync7(path) && readFileSync8(path, "utf8") === text) return false;
4212
+ mkdirSync4(dirname6(path), { recursive: true });
3749
4213
  writeFileSync4(path, text);
3750
4214
  return true;
3751
4215
  }
@@ -3761,9 +4225,9 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3761
4225
  const outDir = OP_OUTPUT_DIR2[forge];
3762
4226
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", DRIFT_SPEC_FILE, "--output", outDir], words);
3763
4227
  if (code !== table.done) return code;
3764
- const dir = join6(io.cwd, outDir);
3765
- const files = existsSync6(dir) ? readdirSync3(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync6(join6(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
3766
- for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join6(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
4228
+ const dir = join7(io.cwd, outDir);
4229
+ const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync8(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
4230
+ for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join7(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
3767
4231
  if (files.length && forge === "gitlab") io.err(`${PRODUCT} ci: GitLab runs the drift job from a pipeline schedule; include the file from .gitlab-ci.yml and add a schedule`);
3768
4232
  return table.done;
3769
4233
  }
@@ -3781,6 +4245,8 @@ var init_drift2 = __esm({
3781
4245
  init_drift();
3782
4246
  init_config();
3783
4247
  init_stacks();
4248
+ init_detect();
4249
+ init_render();
3784
4250
  init_routing();
3785
4251
  init_images();
3786
4252
  init_stages();
@@ -3837,8 +4303,8 @@ var init_tips = __esm({
3837
4303
  });
3838
4304
 
3839
4305
  // src/agents.ts
3840
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
3841
- import { dirname as dirname6, join as join7 } from "node:path";
4306
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
4307
+ import { dirname as dirname7, join as join8 } from "node:path";
3842
4308
  function plywoodMcpTools(ctx) {
3843
4309
  const none = { type: "object", properties: {} };
3844
4310
  const doc = () => {
@@ -3948,304 +4414,83 @@ function reviewOpFile(doc) {
3948
4414
  "",
3949
4415
  "export default Op({",
3950
4416
  ` name: ${JSON.stringify(REVIEW_OP)},`,
3951
- ` overview: "A model reviews the pull request against its stacks' previews",`,
3952
- " phases: [",
3953
- ` phase("Preview", [shell(${JSON.stringify(REVIEW_PREVIEW_LINE)})]),`,
3954
- ` phase("Review", [reviewAgent({ ${fields} })]),`,
3955
- " ],",
3956
- "});",
3957
- ""
3958
- ].join("\n");
3959
- }
3960
- function reviewOpSpec(doc) {
3961
- const settings = toChantSettings({ doc });
3962
- const p = settings.pipeline;
3963
- const engine2 = chantPipelineOptions({ ...p.image ? { image: p.image } : {}, ...p.engineSetup ? { engineSetup: p.engineSetup } : {} }, false);
3964
- return {
3965
- ops: [
3966
- {
3967
- name: REVIEW_OP,
3968
- trigger: { kind: "pull_request" },
3969
- reviewAgent: true,
3970
- // The preview in the review job takes the preview identity; id-token is the one write the review job may hold.
3971
- ...settings.ci.identity ? { permissions: { "id-token": "write" } } : {}
3972
- }
3973
- ],
3974
- options: {
3975
- ...engine2.beforeScript ? { beforeScript: engine2.beforeScript } : {},
3976
- ...engine2.image ? { image: engine2.image } : {}
3977
- }
3978
- };
3979
- }
3980
- function writeIfChanged3(cwd, file, text) {
3981
- const path = join7(cwd, file);
3982
- if (existsSync7(path) && readFileSync7(path, "utf8") === text) return false;
3983
- mkdirSync5(dirname6(path), { recursive: true });
3984
- writeFileSync5(path, text);
3985
- return true;
3986
- }
3987
- async function writeReviewJobs(chant, forge, doc, words, table, io) {
3988
- if (!reviewOf(doc)) return table.done;
3989
- if (writeIfChanged3(io.cwd, REVIEW_OP_FILE, reviewOpFile(doc))) io.err(`${PRODUCT} ci: wrote ${REVIEW_OP_FILE}; commit it with the workflow`);
3990
- if (writeIfChanged3(io.cwd, REVIEW_SPEC_FILE, JSON.stringify(reviewOpSpec(doc), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${REVIEW_SPEC_FILE}`);
3991
- const outDir = OP_OUTPUT_DIR3[forge];
3992
- const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
3993
- if (code !== table.done) return code;
3994
- const dir = join7(io.cwd, outDir);
3995
- const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync7(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
3996
- for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join7(outDir, f).replace(/^\.\//, "")}`);
3997
- const r = reviewOf(doc);
3998
- io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
3999
- if (forge === "gitlab") io.err(`${PRODUCT} ci: on GitLab the note job runs on the default branch with CHANT_REVIEW_NOTES=1 from a pipeline schedule, and posts with a protected CHANT_FORGE_TOKEN`);
4000
- return table.done;
4001
- }
4002
- var PERSON_VERBS, READ_INSTRUCTIONS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4003
- var init_agents2 = __esm({
4004
- "src/agents.ts"() {
4005
- "use strict";
4006
- init_chant_config();
4007
- init_config_check();
4008
- init_agents();
4009
- init_config();
4010
- init_stacks();
4011
- init_images();
4012
- init_stages();
4013
- init_tips();
4014
- init_words();
4015
- PERSON_VERBS = ["up", "approve", "override"];
4016
- READ_INSTRUCTIONS = `To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`;
4017
- REVIEW_OP = "plywood-review";
4018
- REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4019
- REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
4020
- REVIEW_CHANGE_SET = ".chant/pr/change-set.json";
4021
- REVIEW_PREVIEW_LINE = `${BIN} components pr-plan --base "origin/\${GITHUB_BASE_REF:-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" --pr "\${CI_MERGE_REQUEST_IID:-$(node -p 'require(process.env.GITHUB_EVENT_PATH).pull_request.number')}" --gate ${PR_GATE} --no-status`;
4022
- OP_OUTPUT_DIR3 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
4023
- }
4024
- });
4025
-
4026
- // src/init/detect.ts
4027
- import { existsSync as existsSync8, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync } from "node:fs";
4028
- import { basename, dirname as dirname7, join as join8, relative, sep } from "node:path";
4029
- import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
4030
- function repoRoot(dir) {
4031
- let cur = dir;
4032
- for (let i = 0; i < 64; i++) {
4033
- if (existsSync8(join8(cur, ".git"))) return cur;
4034
- const parent = dirname7(cur);
4035
- if (parent === cur) return dir;
4036
- cur = parent;
4037
- }
4038
- return dir;
4039
- }
4040
- function posixRel(root, path) {
4041
- const rel = relative(root, path);
4042
- return rel === "" ? "." : rel.split(sep).join("/");
4043
- }
4044
- function readYamlFile(file) {
4045
- try {
4046
- const doc = parseYAML2(readFileSync8(file, "utf8"));
4047
- return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
4048
- } catch {
4049
- return void 0;
4050
- }
4051
- }
4052
- function findPulumiProjects(root) {
4053
- const out = [];
4054
- const walk = (dir, depth) => {
4055
- let entries;
4056
- try {
4057
- entries = readdirSync5(dir).sort();
4058
- } catch {
4059
- return;
4060
- }
4061
- const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
4062
- if (file) {
4063
- const project = readProject(root, dir, join8(dir, file));
4064
- if (project) out.push(project);
4065
- }
4066
- if (depth >= MAX_DEPTH) return;
4067
- for (const name of entries) {
4068
- if (SKIP.has(name) || name.startsWith(".")) continue;
4069
- const path = join8(dir, name);
4070
- try {
4071
- if (statSync(path).isDirectory()) walk(path, depth + 1);
4072
- } catch {
4073
- }
4074
- }
4075
- };
4076
- walk(root, 0);
4077
- return out;
4078
- }
4079
- function readProject(root, dir, file) {
4080
- const doc = readYamlFile(file);
4081
- if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
4082
- const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
4083
- const backend = doc.backend;
4084
- const configDir = typeof doc.stackConfigDir === "string" ? join8(dir, doc.stackConfigDir) : dir;
4085
- return {
4086
- name: doc.name,
4087
- dir: posixRel(root, dir),
4088
- ...typeof runtime === "string" ? { runtime } : {},
4089
- ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
4090
- stacks: stackNames(configDir)
4091
- };
4092
- }
4093
- function stackNames(configDir) {
4094
- let entries;
4095
- try {
4096
- entries = readdirSync5(configDir);
4097
- } catch {
4098
- return [];
4099
- }
4100
- const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
4101
- return [...new Set(names)].sort(byEnvironment);
4102
- }
4103
- function environmentOf(stack) {
4104
- const last = stack.split("/").pop().toLowerCase();
4105
- const head = last.split(/[-_.]/)[0];
4106
- return ENV_ORDER.includes(head) ? head : last;
4107
- }
4108
- function rank(stack) {
4109
- const env = environmentOf(stack);
4110
- const at = ENV_ORDER.indexOf(env);
4111
- return at >= 0 ? at : PROD_RANK - 0.5;
4112
- }
4113
- function byEnvironment(a, b) {
4114
- return rank(a) - rank(b) || a.localeCompare(b);
4115
- }
4116
- function stacksOf2(projects) {
4117
- const names = /* @__PURE__ */ new Map();
4118
- for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
4119
- const out = [];
4120
- for (const p of projects) {
4121
- const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
4122
- for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
4123
- }
4124
- return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
4125
- }
4126
- function slug(s) {
4127
- const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4128
- return out === "" ? "root" : out;
4129
- }
4130
- function originUrl(root) {
4131
- const config2 = gitFile(root, "config");
4132
- if (!config2) return void 0;
4133
- let inOrigin = false;
4134
- for (const raw of config2.split(/\r?\n/)) {
4135
- const line = raw.trim();
4136
- if (line.startsWith("[")) {
4137
- inOrigin = /^\[remote\s+"origin"\]$/.test(line);
4138
- continue;
4139
- }
4140
- const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
4141
- if (m) return m[1].trim();
4142
- }
4143
- return void 0;
4144
- }
4145
- function gitFile(root, name) {
4146
- const dotGit = join8(root, ".git");
4147
- let gitDir = dotGit;
4148
- try {
4149
- if (statSync(dotGit).isFile()) {
4150
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync8(dotGit, "utf8"));
4151
- if (!m) return void 0;
4152
- gitDir = join8(root, m[1].trim());
4153
- const common = join8(gitDir, "commondir");
4154
- if (existsSync8(common) && !existsSync8(join8(gitDir, name))) gitDir = join8(gitDir, readFileSync8(common, "utf8").trim());
4155
- }
4156
- const path = join8(gitDir, name);
4157
- return existsSync8(path) ? readFileSync8(path, "utf8") : void 0;
4158
- } catch {
4159
- return void 0;
4160
- }
4161
- }
4162
- function remoteHost(url) {
4163
- const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
4164
- if (scp) return scp[1].toLowerCase();
4165
- try {
4166
- return new URL(url).hostname.toLowerCase() || void 0;
4167
- } catch {
4168
- return void 0;
4169
- }
4170
- }
4171
- function forgeOfRemote(url) {
4172
- const host = remoteHost(url);
4173
- if (!host) return void 0;
4174
- if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
4175
- if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
4176
- if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
4177
- if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
4178
- if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
4179
- return void 0;
4180
- }
4181
- function detectForge(root) {
4182
- const url = originUrl(root);
4183
- const byRemote = url ? forgeOfRemote(url) : void 0;
4184
- if (byRemote) return byRemote;
4185
- if (existsSync8(join8(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
4186
- if (existsSync8(join8(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
4187
- if (existsSync8(join8(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
4188
- return void 0;
4189
- }
4190
- function defaultBranch(root) {
4191
- const head = gitFile(root, "refs/remotes/origin/HEAD");
4192
- const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
4193
- if (remote) return remote.trim();
4194
- const local = gitFile(root, "HEAD");
4195
- const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
4196
- return branch ? branch.trim() : "main";
4417
+ ` overview: "A model reviews the pull request against its stacks' previews",`,
4418
+ " phases: [",
4419
+ ` phase("Preview", [shell(${JSON.stringify(REVIEW_PREVIEW_LINE)})]),`,
4420
+ ` phase("Review", [reviewAgent({ ${fields} })]),`,
4421
+ " ],",
4422
+ "});",
4423
+ ""
4424
+ ].join("\n");
4197
4425
  }
4198
- function findPulumiActions(root) {
4199
- const out = [];
4200
- for (const d of WORKFLOW_DIRS) {
4201
- let names;
4202
- try {
4203
- names = readdirSync5(join8(root, d)).sort();
4204
- } catch {
4205
- continue;
4206
- }
4207
- for (const n of names) {
4208
- if (!/\.ya?ml$/.test(n)) continue;
4209
- try {
4210
- if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync8(join8(root, d, n), "utf8"))) out.push(`${d}/${n}`);
4211
- } catch {
4426
+ function reviewOpSpec(doc) {
4427
+ const settings = toChantSettings({ doc });
4428
+ const p = settings.pipeline;
4429
+ const engine2 = chantPipelineOptions({ ...p.image ? { image: p.image } : {}, ...p.engineSetup ? { engineSetup: p.engineSetup } : {} }, false);
4430
+ return {
4431
+ ops: [
4432
+ {
4433
+ name: REVIEW_OP,
4434
+ trigger: { kind: "pull_request" },
4435
+ reviewAgent: true,
4436
+ // The preview in the review job takes the preview identity; id-token is the one write the review job may hold.
4437
+ ...settings.ci.identity ? { permissions: { "id-token": "write" } } : {}
4212
4438
  }
4439
+ ],
4440
+ options: {
4441
+ ...engine2.beforeScript ? { beforeScript: engine2.beforeScript } : {},
4442
+ ...engine2.image ? { image: engine2.image } : {}
4213
4443
  }
4214
- }
4215
- return out;
4216
- }
4217
- function detect(cwd) {
4218
- const root = repoRoot(cwd);
4219
- const projects = findPulumiProjects(root);
4220
- const forge = detectForge(root);
4221
- const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync8(join8(root, n)));
4222
- return {
4223
- root,
4224
- projects,
4225
- stacks: stacksOf2(projects),
4226
- ...forge ? { forge } : {},
4227
- defaultBranch: defaultBranch(root),
4228
- pulumiActions: findPulumiActions(root),
4229
- ...preCommit ? { preCommit } : {}
4230
4444
  };
4231
4445
  }
4232
- function repoName(root) {
4233
- return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
4446
+ function writeIfChanged3(cwd, file, text) {
4447
+ const path = join8(cwd, file);
4448
+ if (existsSync8(path) && readFileSync9(path, "utf8") === text) return false;
4449
+ mkdirSync5(dirname7(path), { recursive: true });
4450
+ writeFileSync5(path, text);
4451
+ return true;
4234
4452
  }
4235
- var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
4236
- var init_detect = __esm({
4237
- "src/init/detect.ts"() {
4453
+ async function writeReviewJobs(chant, forge, doc, words, table, io) {
4454
+ if (!reviewOf(doc)) return table.done;
4455
+ if (writeIfChanged3(io.cwd, REVIEW_OP_FILE, reviewOpFile(doc))) io.err(`${PRODUCT} ci: wrote ${REVIEW_OP_FILE}; commit it with the workflow`);
4456
+ if (writeIfChanged3(io.cwd, REVIEW_SPEC_FILE, JSON.stringify(reviewOpSpec(doc), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${REVIEW_SPEC_FILE}`);
4457
+ const outDir = OP_OUTPUT_DIR3[forge];
4458
+ const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
4459
+ if (code !== table.done) return code;
4460
+ const dir = join8(io.cwd, outDir);
4461
+ const files = existsSync8(dir) ? readdirSync5(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync9(join8(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
4462
+ for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join8(outDir, f).replace(/^\.\//, "")}`);
4463
+ const r = reviewOf(doc);
4464
+ io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
4465
+ if (forge === "gitlab") io.err(`${PRODUCT} ci: on GitLab the note job runs on the default branch with CHANT_REVIEW_NOTES=1 from a pipeline schedule, and posts with a protected CHANT_FORGE_TOKEN`);
4466
+ return table.done;
4467
+ }
4468
+ var PERSON_VERBS, READ_INSTRUCTIONS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4469
+ var init_agents2 = __esm({
4470
+ "src/agents.ts"() {
4238
4471
  "use strict";
4239
- SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
4240
- MAX_DEPTH = 8;
4241
- ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
4242
- PROD_RANK = ENV_ORDER.indexOf("prod");
4243
- WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
4472
+ init_chant_config();
4473
+ init_config_check();
4474
+ init_agents();
4475
+ init_config();
4476
+ init_stacks();
4477
+ init_images();
4478
+ init_stages();
4479
+ init_tips();
4480
+ init_words();
4481
+ PERSON_VERBS = ["up", "approve", "override"];
4482
+ READ_INSTRUCTIONS = `To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`;
4483
+ REVIEW_OP = "plywood-review";
4484
+ REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4485
+ REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
4486
+ REVIEW_CHANGE_SET = ".chant/pr/change-set.json";
4487
+ REVIEW_PREVIEW_LINE = `${BIN} components pr-plan --base "origin/\${GITHUB_BASE_REF:-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" --pr "\${CI_MERGE_REQUEST_IID:-$(node -p 'require(process.env.GITHUB_EVENT_PATH).pull_request.number')}" --gate ${PR_GATE} --no-status`;
4488
+ OP_OUTPUT_DIR3 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
4244
4489
  }
4245
4490
  });
4246
4491
 
4247
4492
  // src/policy-presets.ts
4248
- import { readFileSync as readFileSync9 } from "node:fs";
4493
+ import { readFileSync as readFileSync10 } from "node:fs";
4249
4494
  function providersRego(allowed, namespace = "main") {
4250
4495
  const set = allowed.length ? `{${[...new Set(allowed)].sort().map((p) => JSON.stringify(p)).join(", ")}}` : "set()";
4251
4496
  return preset("plywood_providers.rego.tmpl").replace("{{namespace}}", namespace).replace("{{allowed}}", set);
@@ -4264,7 +4509,7 @@ var init_policy_presets = __esm({
4264
4509
  PROVIDERS_PACK_CONFIG = "policy/plywood-providers.json";
4265
4510
  PROVIDERS_REGO_FILE = "plywood_providers.rego";
4266
4511
  PROVIDERS_POLICY = "provider-allowlist";
4267
- preset = (rel) => readFileSync9(new URL(`../presets/policy/${rel}`, import.meta.url), "utf8");
4512
+ preset = (rel) => readFileSync10(new URL(`../presets/policy/${rel}`, import.meta.url), "utf8");
4268
4513
  }
4269
4514
  });
4270
4515
 
@@ -4617,155 +4862,6 @@ var init_presets = __esm({
4617
4862
  }
4618
4863
  });
4619
4864
 
4620
- // src/templates.ts
4621
- import { readFileSync as readFileSync10 } from "node:fs";
4622
- function template(name) {
4623
- return readFileSync10(new URL(`../templates/${name}`, import.meta.url), "utf8");
4624
- }
4625
- function fill(text, values) {
4626
- return text.replace(/\{\{(\w+)\}\}/g, (all, key) => key in values ? values[key] : all);
4627
- }
4628
- var init_templates = __esm({
4629
- "src/templates.ts"() {
4630
- "use strict";
4631
- }
4632
- });
4633
-
4634
- // src/init/render.ts
4635
- import { emitYAMLEntry } from "@intentius/chant/yaml";
4636
- function plywoodYml(keys) {
4637
- const body = Object.entries(keys).filter(([, v]) => v !== void 0).map(([k, v]) => emitYAMLEntry(k, v)).join("\n");
4638
- const text = fill(template("plywood.yml"), { keys: body });
4639
- return text.endsWith("\n") ? text : text + "\n";
4640
- }
4641
- function chantConfigTs() {
4642
- return template("chant.config.ts.tmpl");
4643
- }
4644
- function componentName(stack) {
4645
- return slug(stack.key);
4646
- }
4647
- function exportName(stack, taken) {
4648
- let base = stack.key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
4649
- if (base === "" || /^\d/.test(base)) base = `stack_${base}`;
4650
- let name = base;
4651
- for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
4652
- taken.add(name);
4653
- return name;
4654
- }
4655
- function stepInput(s) {
4656
- return {
4657
- dir: s.dir,
4658
- stack: s.stack,
4659
- member: s.project,
4660
- ...s.engine ? { engine: s.engine } : {},
4661
- ...s.backend ? { backend: s.backend } : {},
4662
- ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
4663
- ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
4664
- ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
4665
- };
4666
- }
4667
- function componentsFile(stacks2, after = {}, opts = {}) {
4668
- const taken = /* @__PURE__ */ new Set();
4669
- const lines2 = [
4670
- "// Written by plywood init from the stacks it found; running it again rewrites",
4671
- "// this file. Each stack is a chant component whose pulumi-up step previews it",
4672
- "// on a pull request and updates it in its wave.",
4673
- ""
4674
- ];
4675
- for (const s of stacks2) {
4676
- const dependsOn = (after[s.key] ?? []).filter((u) => stacks2.some((x) => x.key === u)).map((u) => componentName({ key: u }));
4677
- const step = { kind: "pulumi-up", ...stepInput(s), ...opts.reviewSuffix ? { reviewSuffix: opts.reviewSuffix } : {} };
4678
- const component = { name: componentName(s), dependsOn, deploy: [{ phase: "up", steps: [step] }] };
4679
- lines2.push(`export const ${exportName(s, taken)} = ${JSON.stringify(component)};`);
4680
- }
4681
- return lines2.join("\n") + "\n";
4682
- }
4683
- function wavesOf(stacks2, configured) {
4684
- const byKey = new Map(stacks2.map((s) => [s.key, s]));
4685
- if (configured && configured.length > 0) {
4686
- return configured.map((keys, i) => ({ name: `wave-${i + 1}`, stacks: keys.map((k) => byKey.get(k)).filter((s) => s !== void 0) }));
4687
- }
4688
- const out = [];
4689
- for (const s of stacks2) {
4690
- const env = environmentOf(s.stack);
4691
- let name = slug(env);
4692
- if (!WAVE_NAME.test(name)) name = `wave-${out.length + 1}`;
4693
- const wave = out.find((w) => w.name === name);
4694
- if (wave) wave.stacks.push(s);
4695
- else out.push({ name, stacks: [s] });
4696
- }
4697
- return out;
4698
- }
4699
- function waveRunner(runner) {
4700
- if (runner === void 0) return void 0;
4701
- if (typeof runner === "string" || Array.isArray(runner) || "group" in runner) return runner;
4702
- const staged = runner;
4703
- return staged.up ?? staged.default;
4704
- }
4705
- function wavesSpec(input) {
4706
- const waves = (input.ordered ?? wavesOf(input.stacks, input.waves)).filter((w) => w.stacks.length > 0);
4707
- return {
4708
- name: OP_NAME,
4709
- op: OP_NAME,
4710
- gate: PR_GATE,
4711
- branches: [input.branch],
4712
- ...input.runner !== void 0 ? { runner: input.runner } : {},
4713
- step: { kind: "pulumi-up" },
4714
- ...input.quorum && input.quorum.length > 0 ? { approvals: { quorum: input.quorum } } : {},
4715
- ...input.notify ? { notify: input.notify } : {},
4716
- waves: waves.map((w) => {
4717
- const gate = w.stacks.map((s) => s.gate ?? input.gate).reduce((a, b) => STRICTNESS[b] > STRICTNESS[a] ? b : a);
4718
- return { name: w.name, gate, runs: w.stacks.map((s) => ({ target: s.key, input: stepInput(s) })) };
4719
- })
4720
- };
4721
- }
4722
- function jsonText(v) {
4723
- return JSON.stringify(v, null, 2) + "\n";
4724
- }
4725
- function memberName(project, taken) {
4726
- let base = slug(project.name).slice(0, 36).replace(/-+$/, "");
4727
- if (!MEMBER_NAME.test(base)) base = "stack";
4728
- let name = base;
4729
- for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
4730
- taken.add(name);
4731
- return name;
4732
- }
4733
- function workspaceDeclaration(existing, input) {
4734
- const doc = existing ? structuredClone(existing) : { name: input.name, schema: 1 };
4735
- const members = Array.isArray(doc.members) ? doc.members : [];
4736
- const taken = new Set(members.map((m) => String(m.name)));
4737
- for (const p of input.projects) {
4738
- if (members.some((m) => m.dir === p.dir || p.dir === "." && m.dir === void 0)) continue;
4739
- members.push({ name: memberName(p, taken), dir: p.dir, kind: "pulumi" });
4740
- }
4741
- doc.members = members;
4742
- const pins = Array.isArray(doc.pins) ? doc.pins : [];
4743
- if (!pins.some((p) => p.package === PULUMI_LEXICON)) pins.push({ package: PULUMI_LEXICON, version: input.lexiconVersion });
4744
- doc.pins = pins;
4745
- const ci2 = doc.ci && typeof doc.ci === "object" && !Array.isArray(doc.ci) ? doc.ci : {};
4746
- for (const [k, v] of Object.entries(input.ci)) if (v !== void 0) ci2[k] = v;
4747
- if (Object.keys(ci2).length > 0) doc.ci = ci2;
4748
- return doc;
4749
- }
4750
- function memberOfDir(doc, dir) {
4751
- const members = doc && Array.isArray(doc.members) ? doc.members : [];
4752
- const m = members.find((x) => x.dir === dir || dir === "." && x.dir === void 0);
4753
- return typeof m?.name === "string" ? m.name : void 0;
4754
- }
4755
- var WAVE_NAME, STRICTNESS, MEMBER_NAME, PULUMI_LEXICON;
4756
- var init_render = __esm({
4757
- "src/init/render.ts"() {
4758
- "use strict";
4759
- init_templates();
4760
- init_words();
4761
- init_detect();
4762
- WAVE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
4763
- STRICTNESS = { never: 0, "on-destructive": 1, always: 2 };
4764
- MEMBER_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
4765
- PULUMI_LEXICON = "@intentius/chant-lexicon-pulumi";
4766
- }
4767
- });
4768
-
4769
4865
  // src/stack-order.ts
4770
4866
  import { join as join9 } from "node:path";
4771
4867
  function chantReferenceReader() {
@@ -5400,6 +5496,11 @@ __export(init_exports, {
5400
5496
  import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync6 } from "node:fs";
5401
5497
  import { emitYAMLEntry as emitYAMLEntry2 } from "@intentius/chant/yaml";
5402
5498
  import { dirname as dirname9, join as join13, posix as posix3 } from "node:path";
5499
+ function chantPin() {
5500
+ const pin = dependencies["@intentius/chant"];
5501
+ if (!pin) throw new Error("packages/plywood/package.json has no @intentius/chant dependency");
5502
+ return pin;
5503
+ }
5403
5504
  function parseInitArgs(args) {
5404
5505
  if (args.includes("--print-prompt")) return { printPrompt: true };
5405
5506
  let rest = args;
@@ -5620,7 +5721,7 @@ async function runInit(opts, table, io) {
5620
5721
  const message = needsChantMessage("Writing the waves spec and the workflow");
5621
5722
  files.push({ file: WAVES_SPEC_FILE2, status: "skipped", note: message }, { file: WORKFLOW_FILE[forge.kind], status: "skipped", note: "needs the same chant" });
5622
5723
  } else {
5623
- let spec = wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves);
5724
+ let spec = await wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves, forge.kind);
5624
5725
  if (settings.pipeline.jobs) {
5625
5726
  try {
5626
5727
  spec = withJobSettings(spec, settings.pipeline.jobs, forge.kind, { parallelism: await hasChant4625() });
@@ -5638,6 +5739,9 @@ async function runInit(opts, table, io) {
5638
5739
  if (opts.bucketCheck) notes.push(...await bucketStep(detected2, doc, io.env, buckets));
5639
5740
  adopt.push(...await adoptStep(root, detected2, doc, io.env, declaration.doc, opts.dryRun, files, notes, todo));
5640
5741
  todo.push(...todoLines(detected2, doc, forge.kind, io.env));
5742
+ if (forge.kind === "gitlab" && chant && stacks2.length > 0 && previewOf(doc) && await hasChantWavePreview()) {
5743
+ todo.push(`Add a ${GITLAB_READ_TOKEN} CI/CD variable (a project access token with the read_api scope, masked and protected) so a wave can read the merge request it applies; without it the wave refuses`);
5744
+ }
5641
5745
  const failed = files.find((f) => f.status === "failed");
5642
5746
  return {
5643
5747
  ok: !failed,
@@ -5702,7 +5806,7 @@ async function preCommitStep(root, config2, dryRun) {
5702
5806
  const after = readFileSync12(join13(root, DECLARATION_FILE), "utf8");
5703
5807
  return { file: DECLARATION_FILE, status: after === before ? "unchanged" : "written", note: `${config2} runs as the check hook pre-commit; the check job's image needs pre-commit` };
5704
5808
  }
5705
- function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered) {
5809
+ async function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered, forge) {
5706
5810
  const runner = waveRunner(chantPipelineOptions(settings.pipeline, false).runner);
5707
5811
  const base = wavesSpec({
5708
5812
  stacks: stacks2,
@@ -5714,7 +5818,8 @@ function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered) {
5714
5818
  // notify.relay only on a chant with the relay (chant#4611); an older chant refuses the setting.
5715
5819
  ...settings.pipeline.notify ? { notify: { ...notifyForChant(settings.pipeline.notify, chant) } } : {}
5716
5820
  });
5717
- return chant.productWavesSpec ? chant.productWavesSpec(base, PRODUCT_WORDS) : base;
5821
+ const named = chant.productWavesSpec ? chant.productWavesSpec(base, PRODUCT_WORDS) : base;
5822
+ return previewOf(doc) && await hasChantWavePreview() ? withWavePreview(named, forge) : named;
5718
5823
  }
5719
5824
  async function workflowStep(chant, root, doc, settings, forge, opts, spec, table, io) {
5720
5825
  const output = WORKFLOW_FILE[forge];
@@ -5872,6 +5977,7 @@ var init_init = __esm({
5872
5977
  init_stacks();
5873
5978
  init_types();
5874
5979
  init_images();
5980
+ init_chant_next();
5875
5981
  init_jobs2();
5876
5982
  init_backend();
5877
5983
  init_adopt();
@@ -5891,7 +5997,7 @@ var init_init = __esm({
5891
5997
  CHANT_CONFIG_FILE = "chant.config.ts";
5892
5998
  COMPONENTS_FILE = "plywood.component.ts";
5893
5999
  WAVES_SPEC_FILE2 = `.chant/op-waves/${OP_NAME}.spec.json`;
5894
- CHANT_PIN = dependencies["@intentius/chant"] ?? "0.124.54";
6000
+ CHANT_PIN = chantPin();
5895
6001
  }
5896
6002
  });
5897
6003
 
@@ -6244,8 +6350,25 @@ async function withChant(what, table, io, run) {
6244
6350
  return run(chant);
6245
6351
  }
6246
6352
  function passthrough(argv, table, io) {
6353
+ if (argv[0] === "run" && argv[1] === "wave") return runWave(argv, table, io);
6247
6354
  return withChant(`${PRODUCT} ${argv[0]}`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
6248
6355
  }
6356
+ async function runWave(argv, table, io) {
6357
+ const seen = [];
6358
+ const write = process.stderr.write;
6359
+ process.stderr.write = ((chunk, ...rest) => {
6360
+ seen.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
6361
+ return write.call(process.stderr, chunk, ...rest);
6362
+ });
6363
+ let code;
6364
+ try {
6365
+ code = await withChant(`${PRODUCT} run wave`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
6366
+ } finally {
6367
+ process.stderr.write = write;
6368
+ }
6369
+ if (code === table.moved) io.err(`${PRODUCT}: ${waveMovedLine(seen.join(""))}`);
6370
+ return code;
6371
+ }
6249
6372
  async function check(args, json, table, io) {
6250
6373
  const file = takeFlag(args, "--file");
6251
6374
  if (file.missing) return usage("--file takes a path", table, io);
@@ -6544,6 +6667,7 @@ var init_stages = __esm({
6544
6667
  init_review_stacks2();
6545
6668
  init_waves_workflow();
6546
6669
  init_drift2();
6670
+ init_refusals();
6547
6671
  init_agents2();
6548
6672
  init_agent_jobs();
6549
6673
  init_words();
@@ -7804,7 +7928,7 @@ var execChild = (bin, args, opts) => new Promise((done) => {
7804
7928
 
7805
7929
  // src/cli.ts
7806
7930
  init_words();
7807
- var VERSION3 = true ? "0.2.0" : "0.0.0-dev";
7931
+ var VERSION3 = true ? "0.3.1" : "0.0.0-dev";
7808
7932
  var stdio = {
7809
7933
  out: (s) => process.stdout.write(s + "\n"),
7810
7934
  err: (s) => process.stderr.write(s + "\n")