@intentius/plywood 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plywood.mjs CHANGED
@@ -578,10 +578,13 @@ var init_control = __esm({
578
578
  var version, dependencies;
579
579
  var init_package = __esm({
580
580
  "package.json"() {
581
- version = "0.0.0";
581
+ version = "0.1.0";
582
582
  dependencies = {
583
- "@intentius/chant": "0.124.55",
584
- "@intentius/chant-lexicon-pulumi": "0.124.55"
583
+ "@intentius/chant": "0.124.57",
584
+ "@intentius/chant-lexicon-forgejo": "0.124.57",
585
+ "@intentius/chant-lexicon-github": "0.124.57",
586
+ "@intentius/chant-lexicon-gitlab": "0.124.57",
587
+ "@intentius/chant-lexicon-pulumi": "0.124.57"
585
588
  };
586
589
  }
587
590
  });
@@ -590,7 +593,10 @@ var init_package = __esm({
590
593
  var image_digests_default;
591
594
  var init_image_digests = __esm({
592
595
  "src/image-digests.json"() {
593
- image_digests_default = {};
596
+ image_digests_default = {
597
+ "ghcr.io/intentius/plywood-pulumi:0.1.0-pulumi3.272.0": "sha256:730237bed59e0054e6839898658069a4bcbdab0ffb8f893487c864bc2f3436c8",
598
+ "ghcr.io/intentius/plywood-plunge:0.1.0-plunge0.1.0": "sha256:76a50f55cc2fd96c19e4ecb625a19863783374c9dc89d1606cc70a36635cc48d"
599
+ };
594
600
  }
595
601
  });
596
602
 
@@ -2600,44 +2606,11 @@ var init_chant_product = __esm({
2600
2606
  "src/chant-product.ts"() {
2601
2607
  "use strict";
2602
2608
  REQUIRED2 = ["assertProductWords", "productPipelineJobs", "approveLine", "runChantInProcess"];
2603
- BUILT_CHANT = true ? "0.124.55" : "unknown";
2609
+ BUILT_CHANT = true ? "0.124.57" : "unknown";
2604
2610
  }
2605
2611
  });
2606
2612
 
2607
2613
  // src/chant-next.ts
2608
- async function acceptsContentsWrite(load) {
2609
- try {
2610
- const m = await load();
2611
- if (typeof m.assertScheduledOpCloud !== "function") return false;
2612
- m.assertScheduledOpCloud(structuredClone(CONTENTS_WRITE_PROBE));
2613
- return true;
2614
- } catch {
2615
- return false;
2616
- }
2617
- }
2618
- async function has(load, name, type = "function") {
2619
- try {
2620
- const m = await load();
2621
- return typeof m[name] === type;
2622
- } catch {
2623
- return false;
2624
- }
2625
- }
2626
- function chantNext() {
2627
- detected ??= (async () => ({
2628
- // @ts-ignore: the subpath is missing from chant 0.124.55 (chant#4624).
2629
- checkJob: await has(() => import("@intentius/chant/ci-job-guard"), "checkJob"),
2630
- planFlags: await has(() => import("@intentius/chant/components/capability"), "PLAN_FLAG_ARGS", "object"),
2631
- staleNotes: await has(() => import("@intentius/chant/pr-post"), "markStaleNotes"),
2632
- // @ts-ignore: the subpath is missing from chant 0.124.55 (chant#4631).
2633
- scheduledOpCloud: await has(() => import("@intentius/chant/scheduled-op-cloud"), "assertScheduledOpCloud"),
2634
- // @ts-ignore: the subpath is missing from chant 0.124.55 (chant#4631).
2635
- updateContentsWrite: await acceptsContentsWrite(() => import("@intentius/chant/scheduled-op-cloud")),
2636
- // @ts-ignore: the subpath is missing from chant 0.124.55 (chant#4636).
2637
- applyMerge: await has(() => import("@intentius/chant/pr-merge"), "mergeAfterApply")
2638
- }))();
2639
- return detected;
2640
- }
2641
2614
  function hasChantRespond(chant) {
2642
2615
  return typeof chant?.CI_RESPOND_FLOOR === "string";
2643
2616
  }
@@ -2651,26 +2624,13 @@ function hasChantPullRequestApprovalStatus(chant) {
2651
2624
  return typeof chant?.APPROVAL_STATUS_PULL_REQUEST_FLOOR === "string";
2652
2625
  }
2653
2626
  function chantAudit() {
2654
- audit ??= (async () => {
2655
- try {
2656
- const m = await import("@intentius/chant/ci-audit");
2657
- return typeof m.attributeDrift === "function" && typeof m.routeAttributions === "function" && typeof m.parseAuditLogSetting === "function" ? m : void 0;
2658
- } catch {
2659
- return void 0;
2660
- }
2661
- })();
2627
+ audit ??= import("@intentius/chant/ci-audit").then((m) => m);
2662
2628
  return audit;
2663
2629
  }
2664
- var CONTENTS_WRITE_PROBE, detected, audit;
2630
+ var audit;
2665
2631
  var init_chant_next = __esm({
2666
2632
  "src/chant-next.ts"() {
2667
2633
  "use strict";
2668
- CONTENTS_WRITE_PROBE = {
2669
- name: "plywood-probe",
2670
- trigger: { kind: "cron", schedule: "0 6 * * *" },
2671
- findingMode: "issue",
2672
- update: { command: "true", permissions: { contents: "write" } }
2673
- };
2674
2634
  }
2675
2635
  });
2676
2636
 
@@ -2875,6 +2835,74 @@ var init_review_stacks2 = __esm({
2875
2835
  // src/waves-workflow.ts
2876
2836
  import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
2877
2837
  import { dirname as dirname4, join as join4 } from "node:path";
2838
+ function wavesEngine(pipeline) {
2839
+ const o = chantPipelineOptions({ ...pipeline.image ? { image: pipeline.image } : {}, ...pipeline.engineSetup ? { engineSetup: pipeline.engineSetup } : {} }, false);
2840
+ return { ...o.image ? { image: o.image } : {}, ...o.beforeScript?.length ? { beforeScript: o.beforeScript } : {} };
2841
+ }
2842
+ function wavesSpecText(spec, engine2 = {}) {
2843
+ const options = { ...engine2.image ? { image: engine2.image } : {}, ...engine2.beforeScript?.length ? { beforeScript: engine2.beforeScript } : {} };
2844
+ return JSON.stringify(Object.keys(options).length ? { waves: spec, options } : spec, null, 2) + "\n";
2845
+ }
2846
+ function unwrapWavesSpec(doc) {
2847
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
2848
+ const inner = doc.waves;
2849
+ if (Array.isArray(inner)) return doc;
2850
+ return inner && typeof inner === "object" && Array.isArray(inner.waves) ? inner : doc;
2851
+ }
2852
+ function stepId(wave, target) {
2853
+ return `${wave}-${target}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2854
+ }
2855
+ function upOpFile(spec) {
2856
+ const s = spec ?? {};
2857
+ const op = typeof s.op === "string" && s.op !== "" ? s.op : OP_NAME;
2858
+ const taken = /* @__PURE__ */ new Set();
2859
+ const phases = [];
2860
+ for (const [i, w] of (s.waves ?? []).entries()) {
2861
+ const name = typeof w.name === "string" && w.name !== "" ? w.name : `wave-${i + 1}`;
2862
+ const steps = [];
2863
+ for (const run of w.runs ?? []) {
2864
+ const input = run.input ?? {};
2865
+ if (typeof input.member !== "string" || typeof input.dir !== "string") continue;
2866
+ let id = stepId(name, run.target);
2867
+ for (let n = 2; taken.has(id); n++) id = `${stepId(name, run.target)}-${n}`;
2868
+ taken.add(id);
2869
+ const opts = [
2870
+ `dir: ${JSON.stringify(input.dir)}`,
2871
+ ...typeof input.stack === "string" ? [`stack: ${JSON.stringify(input.stack)}`] : [],
2872
+ ...input.engine === "pulumi" || input.engine === "plunge" ? [`engine: ${JSON.stringify(input.engine)}`] : [],
2873
+ `id: ${JSON.stringify(id)}`
2874
+ ];
2875
+ steps.push(`pulumiPreviewStep(${JSON.stringify(input.member)}, { ${opts.join(", ")} })`);
2876
+ }
2877
+ if (steps.length > 0) phases.push(` phase(${JSON.stringify(name)}, [${steps.join(", ")}]),`);
2878
+ }
2879
+ return [
2880
+ `// Written by '${PRODUCT} init' and '${PRODUCT} ci' from .chant/op-waves/${OP_NAME}.spec.json, and written again on each run: edit plywood.yml, not this file.`,
2881
+ `// The waves workflow runs under this Op's name: each wave job previews and updates its stacks through the spec's`,
2882
+ `// pulumi-up step and records the runs under ${op}. chant's generator needs the Op to exist. Run by hand`,
2883
+ `// (npx chant run ${op}), it previews each stack in wave order and changes nothing.`,
2884
+ `import { Op, phase } from "@intentius/chant/op";`,
2885
+ `import { pulumiPreviewStep } from "@intentius/chant-lexicon-pulumi";`,
2886
+ "",
2887
+ "export default Op({",
2888
+ ` name: ${JSON.stringify(op)},`,
2889
+ ` overview: "The stacks ${PRODUCT}'s waves update on merge, previewed in wave order",`,
2890
+ ` labels: { Surface: "${PRODUCT}-up" },`,
2891
+ " phases: [",
2892
+ ...phases,
2893
+ " ],",
2894
+ "});",
2895
+ ""
2896
+ ].join("\n");
2897
+ }
2898
+ function writeUpOp(cwd, spec) {
2899
+ const path = join4(cwd, UP_OP_FILE);
2900
+ const text = upOpFile(spec);
2901
+ if (existsSync4(path) && readFileSync4(path, "utf8") === text) return false;
2902
+ mkdirSync2(dirname4(path), { recursive: true });
2903
+ writeFileSync2(path, text);
2904
+ return true;
2905
+ }
2878
2906
  function binLines(waves, bin) {
2879
2907
  const binArgs = bin.split(/\s+/).filter(Boolean);
2880
2908
  return waves.jobs.map((job) => {
@@ -2894,15 +2922,16 @@ function generatedFor(dir, specFile) {
2894
2922
  if (!existsSync4(dir)) return [];
2895
2923
  return readdirSync2(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => join4(dir, f)).filter((p) => (readFileSync4(p, "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${specFile}`));
2896
2924
  }
2897
- async function writeWaveJobs(chant, forge, waves, words, table, io) {
2925
+ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}) {
2898
2926
  const specPath = join4(io.cwd, waves.specFile);
2899
- const specText = JSON.stringify(waves.spec, null, 2) + "\n";
2927
+ const specText = wavesSpecText(waves.spec, engine2);
2900
2928
  const before = existsSync4(specPath) ? readFileSync4(specPath, "utf8") : void 0;
2901
2929
  if (before !== specText) {
2902
2930
  mkdirSync2(dirname4(specPath), { recursive: true });
2903
2931
  writeFileSync2(specPath, specText);
2904
2932
  io.err(`${PRODUCT} ci: wrote ${waves.specFile} with plywood's op and gates; commit it with the workflow`);
2905
2933
  }
2934
+ if (writeUpOp(io.cwd, waves.spec)) io.err(`${PRODUCT} ci: wrote ${UP_OP_FILE}, the Op the waves spec names; commit it with the workflow`);
2906
2935
  const outDir = WAVES_OUTPUT_DIR[forge];
2907
2936
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", waves.specFile, "--output", outDir], words);
2908
2937
  if (code !== table.done) return code;
@@ -2925,17 +2954,19 @@ async function writeWaveJobs(chant, forge, waves, words, table, io) {
2925
2954
  }
2926
2955
  return table.done;
2927
2956
  }
2928
- var WAVES_OUTPUT_DIR;
2957
+ var WAVES_OUTPUT_DIR, UP_OP_FILE;
2929
2958
  var init_waves_workflow = __esm({
2930
2959
  "src/waves-workflow.ts"() {
2931
2960
  "use strict";
2932
2961
  init_chant_product();
2962
+ init_images();
2933
2963
  init_words();
2934
2964
  WAVES_OUTPUT_DIR = {
2935
2965
  github: ".github/workflows",
2936
2966
  forgejo: ".forgejo/workflows",
2937
2967
  gitlab: "."
2938
2968
  };
2969
+ UP_OP_FILE = "ops/pulumi-up.op.ts";
2939
2970
  }
2940
2971
  });
2941
2972
 
@@ -3116,7 +3147,7 @@ __export(jobs_exports, {
3116
3147
  withJobSettings: () => withJobSettings
3117
3148
  });
3118
3149
  function hasChant4625() {
3119
- detected2 ??= (async () => {
3150
+ detected ??= (async () => {
3120
3151
  try {
3121
3152
  const m = await import("@intentius/chant/parallelism");
3122
3153
  return typeof m.mapLimited === "function" && typeof m.parallelismProblem === "function";
@@ -3124,10 +3155,10 @@ function hasChant4625() {
3124
3155
  return false;
3125
3156
  }
3126
3157
  })();
3127
- return detected2;
3158
+ return detected;
3128
3159
  }
3129
- function setChant4625ForTests(has2) {
3130
- detected2 = has2 === "reset" ? void 0 : Promise.resolve(has2);
3160
+ function setChant4625ForTests(has) {
3161
+ detected = has === "reset" ? void 0 : Promise.resolve(has);
3131
3162
  }
3132
3163
  function secretReference(forge, secret) {
3133
3164
  return forge === "gitlab" ? `$${secret}` : `\${{ secrets.${secret} }}`;
@@ -3228,7 +3259,7 @@ async function prJobsFor(pipeline) {
3228
3259
  const { flags, notes } = prLoopJobFlags(pipeline.jobs, pipeline.runner);
3229
3260
  return { options: prLoopJobOptions(pipeline.jobs, pipeline.runner), flags, notes };
3230
3261
  }
3231
- var detected2, JOBS_NEED_CHANT, GROUP_NAME;
3262
+ var detected, JOBS_NEED_CHANT, GROUP_NAME;
3232
3263
  var init_jobs2 = __esm({
3233
3264
  "src/jobs.ts"() {
3234
3265
  "use strict";
@@ -3362,7 +3393,7 @@ function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3362
3393
  const path = join6(cwd, file);
3363
3394
  if (!existsSync6(path)) return void 0;
3364
3395
  try {
3365
- const spec = JSON.parse(readFileSync6(path, "utf8"));
3396
+ const spec = unwrapWavesSpec(JSON.parse(readFileSync6(path, "utf8")));
3366
3397
  return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3367
3398
  } catch {
3368
3399
  return void 0;
@@ -3450,7 +3481,7 @@ function readUpSpec(cwd, file = WAVES_SPEC_FILE) {
3450
3481
  if (!existsSync6(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3451
3482
  let spec;
3452
3483
  try {
3453
- spec = JSON.parse(readFileSync6(path, "utf8"));
3484
+ spec = unwrapWavesSpec(JSON.parse(readFileSync6(path, "utf8")));
3454
3485
  } catch (e) {
3455
3486
  return { error: `${file}: ${e.message}` };
3456
3487
  }
@@ -3549,20 +3580,15 @@ async function upBeforeMerge(number, args, json, table, io) {
3549
3580
  "--forge",
3550
3581
  forge,
3551
3582
  ...requiresArgv(chant, config2.doc),
3552
- ...await mergeArgv(config2.doc, io),
3583
+ ...mergeArgv(config2.doc),
3553
3584
  ...json ? ["--json"] : []
3554
3585
  ];
3555
3586
  return explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err, { pr: number });
3556
3587
  });
3557
3588
  }
3558
- async function mergeArgv(doc, io) {
3589
+ function mergeArgv(doc) {
3559
3590
  const merge = applyMergeOf(doc);
3560
- if (!merge) return [];
3561
- if (!(await chantNext()).applyMerge) {
3562
- io.err(`${PRODUCT}: ${APPLY_MERGE_NEEDS_CHANT}`);
3563
- return [];
3564
- }
3565
- return applyMergeArgv(merge);
3591
+ return merge ? applyMergeArgv(merge) : [];
3566
3592
  }
3567
3593
  async function resume(args, json, table, io) {
3568
3594
  const op = takeFlag(args, "--op");
@@ -3597,6 +3623,7 @@ var init_up = __esm({
3597
3623
  init_review_stacks2();
3598
3624
  init_stages();
3599
3625
  init_words();
3626
+ init_waves_workflow();
3600
3627
  OVERRIDE_OP = "policy-override";
3601
3628
  WAVES_SPEC_FILE = `.chant/op-waves/${OP_NAME}.spec.json`;
3602
3629
  UP_STEP_KIND = "pulumi-up";
@@ -3769,12 +3796,7 @@ async function readStack(chant, stack, dir, opts) {
3769
3796
  async function attributionFor(doc, io) {
3770
3797
  const logs = driftSettingsOf(doc).auditLogs;
3771
3798
  if (!logs) return void 0;
3772
- const audit3 = await chantAudit();
3773
- if (!audit3) {
3774
- io.err(`${PRODUCT} drift: ${ATTRIBUTION_NEEDS_CHANT}`);
3775
- return void 0;
3776
- }
3777
- return { audit: audit3, logs };
3799
+ return { audit: await chantAudit(), logs };
3778
3800
  }
3779
3801
  function auditLogFor(logs, stack) {
3780
3802
  return Object.entries(logs).find(([glob]) => glob === "*" || glob === "**" || globMatches(glob, stack))?.[1];
@@ -3882,9 +3904,8 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3882
3904
  io.err(`${PRODUCT} ci: drift.schedule is set and plywood.yml lists no stacks, so no drift job was written`);
3883
3905
  return table.done;
3884
3906
  }
3885
- const next = await chantNext();
3886
3907
  if (writeIfChanged2(io.cwd, DRIFT_OP_FILE, driftOpFile(doc, settings.stacks, settings.schedule))) io.err(`${PRODUCT} ci: wrote ${DRIFT_OP_FILE}; commit it with the workflow`);
3887
- if (writeIfChanged2(io.cwd, DRIFT_SPEC_FILE, JSON.stringify(driftOpSpec(doc, forge, settings.stacks, settings.schedule, chant, { cloud: next.scheduledOpCloud, contentsWrite: next.updateContentsWrite, env }), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${DRIFT_SPEC_FILE}`);
3908
+ if (writeIfChanged2(io.cwd, DRIFT_SPEC_FILE, JSON.stringify(driftOpSpec(doc, forge, settings.stacks, settings.schedule, chant, { cloud: true, contentsWrite: true, env }), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${DRIFT_SPEC_FILE}`);
3888
3909
  const outDir = OP_OUTPUT_DIR2[forge];
3889
3910
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", DRIFT_SPEC_FILE, "--output", outDir], words);
3890
3911
  if (code !== table.done) return code;
@@ -3894,7 +3915,7 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3894
3915
  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`);
3895
3916
  return table.done;
3896
3917
  }
3897
- var DRIFT_OP_FILE, DRIFT_SPEC_FILE, SNAPSHOTS_REF, SNAPSHOTS_FETCH, DRIFT_STATUSES, forgeOverride, notifyFetch, ATTRIBUTION_NEEDS_CHANT, OP_OUTPUT_DIR2;
3918
+ var DRIFT_OP_FILE, DRIFT_SPEC_FILE, SNAPSHOTS_REF, SNAPSHOTS_FETCH, DRIFT_STATUSES, forgeOverride, notifyFetch, OP_OUTPUT_DIR2;
3898
3919
  var init_drift2 = __esm({
3899
3920
  "src/drift.ts"() {
3900
3921
  "use strict";
@@ -3918,7 +3939,6 @@ var init_drift2 = __esm({
3918
3939
  SNAPSHOTS_REF = "refs/chant/snapshots";
3919
3940
  SNAPSHOTS_FETCH = `git fetch --no-tags origin '+${SNAPSHOTS_REF}:${SNAPSHOTS_REF}' || true`;
3920
3941
  DRIFT_STATUSES = ["drifted", "missing"];
3921
- ATTRIBUTION_NEEDS_CHANT = "drift.audit_log is set, and the installed chant has no audit-log reads (chant#4632), so no drifted attribute was attributed";
3922
3942
  OP_OUTPUT_DIR2 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
3923
3943
  }
3924
3944
  });
@@ -4325,7 +4345,7 @@ function adoptLines(previews, chant) {
4325
4345
  }
4326
4346
  const count = `${v.resources.length} resource${v.resources.length === 1 ? "" : "s"}`;
4327
4347
  if (v.status === "refused") {
4328
- out.push(` ${p.key}: plunge refuses to adopt it:`);
4348
+ out.push(` ${p.key}: plunge refuses to adopt it, so the adopt Op leaves it out:`);
4329
4349
  for (const r of v.refusals) out.push(` ${r.code}: ${r.reason}${r.urn ? ` (${r.urn})` : ""}`);
4330
4350
  } else if (v.status === "already-adopted") {
4331
4351
  out.push(` ${p.key}: already on plunge's records (${count})`);
@@ -4338,7 +4358,10 @@ function adoptLines(previews, chant) {
4338
4358
  return out;
4339
4359
  }
4340
4360
  function stacksToAdopt(stacks2, previews) {
4341
- return stacks2.filter((s) => previews.find((p) => p.key === s.key)?.view?.status !== "already-adopted");
4361
+ return stacks2.filter((s) => {
4362
+ const status = previews.find((p) => p.key === s.key)?.view?.status;
4363
+ return status !== "already-adopted" && status !== "refused";
4364
+ });
4342
4365
  }
4343
4366
  function adoptGate(member, stack, single) {
4344
4367
  return single ? `approve-${ADOPT_OP}` : `approve-${ADOPT_OP}-${member}-${stack}`;
@@ -4401,9 +4424,9 @@ function plungeUrlOf(url) {
4401
4424
  const scheme = url.split("://")[0];
4402
4425
  return { pointer: `is on ${url}, and plunge has no plunge+ form for ${scheme}. Move it to a file, s3, azblob or gs backend first: ${MOVE_A_STACK_GUIDE}` };
4403
4426
  }
4404
- function plungeStacks(detected3, env) {
4405
- return detected3.stacks.map((s) => {
4406
- const project = detected3.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
4427
+ function plungeStacks(detected2, env) {
4428
+ return detected2.stacks.map((s) => {
4429
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
4407
4430
  const b = backendOf(project, {}, env);
4408
4431
  const to = plungeUrlOf(b.url);
4409
4432
  return {
@@ -4534,6 +4557,7 @@ function stepInput(s) {
4534
4557
  stack: s.stack,
4535
4558
  member: s.project,
4536
4559
  ...s.engine ? { engine: s.engine } : {},
4560
+ ...s.backend ? { backend: s.backend } : {},
4537
4561
  ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
4538
4562
  ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
4539
4563
  ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
@@ -4774,9 +4798,9 @@ function repoJoin(...parts) {
4774
4798
  const out = posix2.normalize(parts.filter((p) => p !== "" && p !== ".").join("/") || ".").replace(/^\.\//, "").replace(/\/+$/, "");
4775
4799
  return out === "" ? "." : out;
4776
4800
  }
4777
- function projectAt(detected3, dir) {
4801
+ function projectAt(detected2, dir) {
4778
4802
  const want = repoJoin(dir);
4779
- return detected3.projects.find((p) => repoJoin(p.dir) === want);
4803
+ return detected2.projects.find((p) => repoJoin(p.dir) === want);
4780
4804
  }
4781
4805
  function stackEntry(keys, key) {
4782
4806
  const stacks2 = isObject(keys.stacks) ? keys.stacks : keys.stacks = {};
@@ -4819,10 +4843,10 @@ var init_types2 = __esm({
4819
4843
  // src/import/deployments.ts
4820
4844
  import { readdirSync as readdirSync6 } from "node:fs";
4821
4845
  import { basename as basename2, dirname as dirname8, join as join11 } from "node:path";
4822
- function deploymentFiles(root, detected3, named) {
4846
+ function deploymentFiles(root, detected2, named) {
4823
4847
  if (named.length) return [...named];
4824
4848
  const out = [];
4825
- for (const p of detected3.projects) {
4849
+ for (const p of detected2.projects) {
4826
4850
  let names;
4827
4851
  try {
4828
4852
  names = readdirSync6(join11(root, p.dir)).sort();
@@ -4852,7 +4876,7 @@ var init_deployments = __esm({
4852
4876
  "gitHub.deployPullRequest": "set apply.when: pull-request and update with /plywood up",
4853
4877
  "gitHub.repository": "plywood runs in the repository it is set up in"
4854
4878
  };
4855
- importDeployments = (root, detected3, named) => {
4879
+ importDeployments = (root, detected2, named) => {
4856
4880
  let keys = {};
4857
4881
  const unsupported = [];
4858
4882
  const notes = [];
@@ -4860,7 +4884,7 @@ var init_deployments = __esm({
4860
4884
  const not = (file, setting, why) => {
4861
4885
  unsupported.push({ file, setting, why: why ?? NOT_CARRIED[setting] ?? "plywood has no setting for it" });
4862
4886
  };
4863
- for (const file of deploymentFiles(root, detected3, named)) {
4887
+ for (const file of deploymentFiles(root, detected2, named)) {
4864
4888
  const m = SETTINGS_FILE.exec(basename2(file));
4865
4889
  if (!m) {
4866
4890
  not(file, "(file)", "names no stack: call it Pulumi.<stack>.deploy.yaml (or .json) beside the project's Pulumi.yaml");
@@ -4879,7 +4903,7 @@ var init_deployments = __esm({
4879
4903
  const fileDir = repoJoin(posixRel(root, dirname8(join11(root, file))));
4880
4904
  const source = isObject(settings.sourceContext) && isObject(settings.sourceContext.git) ? settings.sourceContext.git : {};
4881
4905
  const dir = typeof source.repoDir === "string" ? repoJoin(source.repoDir) : fileDir;
4882
- const project = projectAt(detected3, dir)?.name ?? projectAt(detected3, fileDir)?.name;
4906
+ const project = projectAt(detected2, dir)?.name ?? projectAt(detected2, fileDir)?.name;
4883
4907
  if (!project) {
4884
4908
  not(file, "sourceContext.git.repoDir", `${dir} holds no Pulumi.yaml`);
4885
4909
  continue;
@@ -4934,9 +4958,9 @@ var init_deployments = __esm({
4934
4958
  });
4935
4959
 
4936
4960
  // src/import/env0.ts
4937
- function stackFor(detected3, names) {
4961
+ function stackFor(detected2, names) {
4938
4962
  for (const n of names) {
4939
- const hits = detected3.stacks.filter((s) => s.key === n || s.stack === n || detected3.stacks.filter((x) => x.project === s.project).length === 1 && s.project === n);
4963
+ const hits = detected2.stacks.filter((s) => s.key === n || s.stack === n || detected2.stacks.filter((x) => x.project === s.project).length === 1 && s.project === n);
4940
4964
  if (hits.length === 1) return hits[0].key;
4941
4965
  }
4942
4966
  return void 0;
@@ -4949,7 +4973,7 @@ var init_env0 = __esm({
4949
4973
  init_types2();
4950
4974
  ENV0_WORKFLOW_FILES = ["env0.workflow.yml", "env0.workflow.yaml"];
4951
4975
  ENV0_FLOW_FILES = ["env0.yml", "env0.yaml"];
4952
- importEnv0 = (root, detected3, named) => {
4976
+ importEnv0 = (root, detected2, named) => {
4953
4977
  const keys = {};
4954
4978
  const unsupported = [];
4955
4979
  const notes = [];
@@ -4967,7 +4991,7 @@ var init_env0 = __esm({
4967
4991
  for (const [id, raw] of Object.entries(envs)) {
4968
4992
  const e = isObject(raw) ? raw : {};
4969
4993
  const names = [id, ...typeof e.name === "string" ? [e.name] : [], ...typeof e.templateName === "string" ? [e.templateName] : []];
4970
- const key = stackFor(detected3, names);
4994
+ const key = stackFor(detected2, names);
4971
4995
  if (key) stackOf.set(id, key);
4972
4996
  else unsupported.push({ file: workflow, setting: `environments.${id}`, why: `names no stack of this repository (${names.join(", ")}); list it under stacks and waves.after by hand` });
4973
4997
  for (const k of Object.keys(e).filter((x) => !["name", "templateName", "needs"].includes(x))) {
@@ -5061,7 +5085,7 @@ var init_pulumi_actions = __esm({
5061
5085
  plan: "plywood's update applies the preview its approval names, through chant"
5062
5086
  };
5063
5087
  PULUMI_CLOUD = /^https:\/\/(?:app|api)\.pulumi\.com\/?$/;
5064
- importPulumiActions = (root, detected3, named) => {
5088
+ importPulumiActions = (root, detected2, named) => {
5065
5089
  let keys = {};
5066
5090
  const unsupported = [];
5067
5091
  const notes = [];
@@ -5097,7 +5121,7 @@ var init_pulumi_actions = __esm({
5097
5121
  const dir = repoJoin(typeof w["work-dir"] === "string" ? w["work-dir"] : ".");
5098
5122
  const parts = stackName.split("/");
5099
5123
  const stack = parts[parts.length - 1];
5100
- const project = projectAt(detected3, dir)?.name ?? (parts.length === 3 ? parts[1] : void 0);
5124
+ const project = projectAt(detected2, dir)?.name ?? (parts.length === 3 ? parts[1] : void 0);
5101
5125
  if (!project) {
5102
5126
  unsupported.push({ file, setting: `${where}.with.stack-name`, why: `${dir} holds no Pulumi.yaml, so ${stackName} names no project` });
5103
5127
  return;
@@ -5154,7 +5178,7 @@ var init_spacelift = __esm({
5154
5178
  init_types2();
5155
5179
  SPACELIFT_FILES = [".spacelift/config.yml", ".spacelift/config.yaml"];
5156
5180
  CARRIED2 = ["project_root", "runner_image"];
5157
- importSpacelift = (root, detected3, named) => {
5181
+ importSpacelift = (root, detected2, named) => {
5158
5182
  const keys = {};
5159
5183
  const unsupported = [];
5160
5184
  const notes = [];
@@ -5181,7 +5205,7 @@ var init_spacelift = __esm({
5181
5205
  const own = isObject(raw) ? raw : {};
5182
5206
  const s = { ...defaults, ...own };
5183
5207
  const dir = repoJoin(typeof s.project_root === "string" ? s.project_root : ".");
5184
- const project = projectAt(detected3, dir);
5208
+ const project = projectAt(detected2, dir);
5185
5209
  if (!project) {
5186
5210
  unsupported.push({ file, setting: `stacks.${id}.project_root`, why: `${dir} holds no Pulumi.yaml` });
5187
5211
  continue;
@@ -5230,9 +5254,9 @@ function parseFrom(value) {
5230
5254
  if (!source) return { error: `--from ${value} is not one of ${IMPORT_SOURCES.join(", ")}, and its path names none; give <source>=${value}` };
5231
5255
  return { source, files: [file] };
5232
5256
  }
5233
- function runImport(root, detected3, from) {
5257
+ function runImport(root, detected2, from) {
5234
5258
  try {
5235
- return IMPORTERS[from.source](root, detected3, from.files);
5259
+ return IMPORTERS[from.source](root, detected2, from.files);
5236
5260
  } catch (e) {
5237
5261
  const file = from.files[0] ?? "(its files)";
5238
5262
  return { source: from.source, files: [], keys: {}, unsupported: [{ file, setting: "(file)", why: `could not be read: ${e.message}` }], notes: [] };
@@ -5327,7 +5351,7 @@ function put(root, rel, text, dryRun, note) {
5327
5351
  function existingConfig(root) {
5328
5352
  return CONFIG_NAMES.find((n) => existsSync10(join13(root, n)));
5329
5353
  }
5330
- async function newConfigKeys(detected3, opts, io) {
5354
+ async function newConfigKeys(detected2, opts, io) {
5331
5355
  const notes = [];
5332
5356
  let presetName = opts.preset;
5333
5357
  if (!presetName && io.ask && !opts.yes) {
@@ -5337,31 +5361,31 @@ async function newConfigKeys(detected3, opts, io) {
5337
5361
  const preset2 = presetByName(presetName ?? DEFAULT_PRESET);
5338
5362
  const keys = structuredClone(preset2.keys);
5339
5363
  if (opts.gate) keys.gate = opts.gate;
5340
- const engine2 = engineStep({ engine: opts.engine, detected: detected3, env: io.env });
5364
+ const engine2 = engineStep({ engine: opts.engine, detected: detected2, env: io.env });
5341
5365
  Object.assign(keys, engine2.keys);
5342
5366
  notes.push(...engine2.notes);
5343
5367
  const engineBackends = engine2.keys.backend !== void 0 || engine2.keys.stacks !== void 0;
5344
5368
  let forge;
5345
5369
  if (opts.forge) {
5346
5370
  forge = { kind: opts.forge, because: "--forge" };
5347
- if (detected3.forge?.kind !== opts.forge) keys.forge = { kind: opts.forge };
5348
- else if (detected3.forge.url) keys.forge = { kind: opts.forge, url: detected3.forge.url };
5349
- } else if (detected3.forge) {
5350
- forge = { kind: detected3.forge.kind, because: detected3.forge.because };
5351
- if (detected3.forge.url) keys.forge = { kind: detected3.forge.kind, url: detected3.forge.url };
5371
+ if (detected2.forge?.kind !== opts.forge) keys.forge = { kind: opts.forge };
5372
+ else if (detected2.forge.url) keys.forge = { kind: opts.forge, url: detected2.forge.url };
5373
+ } else if (detected2.forge) {
5374
+ forge = { kind: detected2.forge.kind, because: detected2.forge.because };
5375
+ if (detected2.forge.url) keys.forge = { kind: detected2.forge.kind, url: detected2.forge.url };
5352
5376
  } else {
5353
5377
  const kind = io.ask && !opts.yes ? await io.ask("Forge", FORGES, "github") : "github";
5354
5378
  forge = { kind, because: io.ask && !opts.yes ? "you chose it" : "origin names no forge init knows, so github; change forge.kind in plywood.yml" };
5355
5379
  keys.forge = { kind };
5356
5380
  }
5357
5381
  const fromEnv = io.env.PULUMI_BACKEND_URL?.trim();
5358
- if (fromEnv && !engineBackends && detected3.projects.some((p) => !p.backendUrl)) {
5382
+ if (fromEnv && !engineBackends && detected2.projects.some((p) => !p.backendUrl)) {
5359
5383
  keys.backend = fromEnv;
5360
5384
  notes.push(`backend ${fromEnv} came from PULUMI_BACKEND_URL; it is in plywood.yml so the jobs use it too`);
5361
5385
  }
5362
5386
  return { keys, notes, forge };
5363
5387
  }
5364
- function initStacks(detected3, doc, members = []) {
5388
+ function initStacks(detected2, doc, members = []) {
5365
5389
  const listed = stacksOf(doc);
5366
5390
  const pinned = new Map(members.filter((m) => m.engineBin && m.engineVersion).map((m) => [m.name, m]));
5367
5391
  const out = [];
@@ -5370,6 +5394,7 @@ function initStacks(detected3, doc, members = []) {
5370
5394
  const own = listed[key] ?? {};
5371
5395
  const backend = own.backend ?? (typeof doc.backend === "string" ? doc.backend : void 0);
5372
5396
  const engine2 = engineOf(doc, backend)?.engine;
5397
+ const grain = lockGrainFor(doc, engine2);
5373
5398
  const stackDir = own.dir ?? dir;
5374
5399
  const packs = packsForStack(stepPolicyPacksOf(doc), stackDir);
5375
5400
  const pin = pinned.get(key);
@@ -5379,13 +5404,18 @@ function initStacks(detected3, doc, members = []) {
5379
5404
  stack,
5380
5405
  dir: stackDir,
5381
5406
  ...engine2 ? { engine: engine2 } : {},
5407
+ // plywood#22: the step's backend, so a preview and an update read this stack's store, not the job's PULUMI_BACKEND_URL.
5408
+ ...backend ? { backend } : {},
5382
5409
  ...pin ? { engineVersion: pin.engineVersion, ...pin.engineRoot ? { engineRoot: pin.engineRoot } : {} } : {},
5383
5410
  ...own.gate ? { gate: own.gate } : {},
5384
- ...packs.length ? { policyPacks: packs } : {}
5411
+ ...packs.length ? { policyPacks: packs } : {},
5412
+ // chant's ledger lock (ruling for ci-products 2026-10-28): each stack's grain on its step, so a plunge stack on the
5413
+ // resource grain leases its resource rows and never waits, and a stock stack (or locks.grain: stack) waits for its stack row.
5414
+ ...grain !== "per-engine" ? { lockGrain: LOCK_GRAIN_OF[grain] } : {}
5385
5415
  });
5386
5416
  seen.add(key);
5387
5417
  };
5388
- for (const s of detected3.stacks) add(s.key, s.project, s.stack, s.dir);
5418
+ for (const s of detected2.stacks) add(s.key, s.project, s.stack, s.dir);
5389
5419
  for (const [key, own] of Object.entries(listed)) {
5390
5420
  if (seen.has(key)) continue;
5391
5421
  const [project, stack] = key.split("/");
@@ -5393,11 +5423,11 @@ function initStacks(detected3, doc, members = []) {
5393
5423
  }
5394
5424
  return out;
5395
5425
  }
5396
- function forgeFor2(doc, detected3, opts) {
5426
+ function forgeFor2(doc, detected2, opts) {
5397
5427
  if (opts.forge) return { kind: opts.forge, because: "--forge" };
5398
5428
  const kind = isObject(doc.forge) ? doc.forge.kind : void 0;
5399
5429
  if (kind) return { kind, because: "plywood.yml says forge.kind" };
5400
- if (detected3.forge) return { kind: detected3.forge.kind, because: detected3.forge.because };
5430
+ if (detected2.forge) return { kind: detected2.forge.kind, because: detected2.forge.because };
5401
5431
  return { kind: "github", because: "origin names no forge init knows, so github" };
5402
5432
  }
5403
5433
  async function chantWriteCiHook() {
@@ -5409,14 +5439,14 @@ async function chantWriteCiHook() {
5409
5439
  }
5410
5440
  }
5411
5441
  async function runInit(opts, table, io) {
5412
- const detected3 = detect(io.cwd);
5413
- const { root } = detected3;
5442
+ const detected2 = detect(io.cwd);
5443
+ const { root } = detected2;
5414
5444
  const files = [];
5415
5445
  const notes = [];
5416
5446
  const todo = [];
5417
5447
  const buckets = [];
5418
5448
  const adopt = [];
5419
- const imported = opts.from ? runImport(root, detected3, opts.from) : void 0;
5449
+ const imported = opts.from ? runImport(root, detected2, opts.from) : void 0;
5420
5450
  if (imported && imported.files.length === 0) notes.push(`--from ${imported.source} found no file to read${imported.notes.length ? `: ${imported.notes.join("; ")}` : ""}`);
5421
5451
  const fail = (message, forge2 = { kind: "github", because: "" }) => ({
5422
5452
  ok: false,
@@ -5431,8 +5461,8 @@ async function runInit(opts, table, io) {
5431
5461
  adopt,
5432
5462
  ...imported ? { imported } : {}
5433
5463
  });
5434
- if (detected3.projects.length === 0) return fail(`no Pulumi.yaml under ${root}; plywood init sets up a repository that has a Pulumi program`);
5435
- if (detected3.stacks.length === 0) notes.push("no Pulumi.<stack>.yaml beside any Pulumi.yaml, so no stack is set up; run pulumi stack init, then plywood init again");
5464
+ if (detected2.projects.length === 0) return fail(`no Pulumi.yaml under ${root}; plywood init sets up a repository that has a Pulumi program`);
5465
+ if (detected2.stacks.length === 0) notes.push("no Pulumi.<stack>.yaml beside any Pulumi.yaml, so no stack is set up; run pulumi stack init, then plywood init again");
5436
5466
  let config2;
5437
5467
  let forge;
5438
5468
  const kept = existingConfig(root);
@@ -5446,10 +5476,10 @@ async function runInit(opts, table, io) {
5446
5476
  const onPlunge = opts.engine === "plunge" && engineOf(config2.doc)?.engine === "plunge";
5447
5477
  const ignored = [opts.preset && "--preset", opts.gate && "--gate", opts.engine && !onPlunge && "--engine", imported && "--from"].filter(Boolean);
5448
5478
  if (ignored.length) notes.push(`${kept} is kept as it is, so ${ignored.join(", ")} changed nothing; set the keys there`);
5449
- if (opts.engine === "plunge" && !onPlunge) notes.push(...keptPlungeLines(kept, detected3, io.env));
5450
- forge = forgeFor2(config2.doc, detected3, opts);
5479
+ if (opts.engine === "plunge" && !onPlunge) notes.push(...keptPlungeLines(kept, detected2, io.env));
5480
+ forge = forgeFor2(config2.doc, detected2, opts);
5451
5481
  } else {
5452
- const made = await newConfigKeys(detected3, opts, io);
5482
+ const made = await newConfigKeys(detected2, opts, io);
5453
5483
  notes.push(...made.notes);
5454
5484
  forge = made.forge;
5455
5485
  const text = plywoodYml(imported ? mergeKeys(made.keys, imported.keys) : made.keys);
@@ -5465,12 +5495,12 @@ async function runInit(opts, table, io) {
5465
5495
  const reviewStacks2 = config2.doc.review_stacks !== void 0 ? await chantReviewStacks() : false;
5466
5496
  if (settings.ci.prEnvironment && !reviewStacks2) delete settings.ci.prEnvironment;
5467
5497
  const reviewSuffix = reviewStacks2 ? reviewSuffixOf(settings.pipeline.reviewStackName) : void 0;
5468
- const stacks2 = initStacks(detected3, doc, settings.members);
5498
+ const stacks2 = initStacks(detected2, doc, settings.members);
5469
5499
  const order = await stackOrder(root, stacks2, settings.pipeline.after);
5470
5500
  const ordered = orderWaves(wavesOf(stacks2, settings.pipeline.waves), order.edges, settings.pipeline.canary);
5471
5501
  const waves = ordered.waves;
5472
5502
  if (order.edges.length || order.outside.length || ordered.unmet.length || !order.reads && stacks2.length > 1) notes.push(...orderLines(order, ordered.unmet));
5473
- const declaration = declarationStep(root, detected3, settings, opts.dryRun);
5503
+ const declaration = declarationStep(root, detected2, settings, opts.dryRun);
5474
5504
  files.push(declaration.file);
5475
5505
  if (existsSync10(join13(root, CHANT_CONFIG_FILE))) {
5476
5506
  const text = readFileSync12(join13(root, CHANT_CONFIG_FILE), "utf8");
@@ -5480,8 +5510,8 @@ async function runInit(opts, table, io) {
5480
5510
  } else files.push(put(root, CHANT_CONFIG_FILE, chantConfigTs(), opts.dryRun));
5481
5511
  files.push(put(root, COMPONENTS_FILE, componentsFile(stacks2, settings.pipeline.after, reviewSuffix ? { reviewSuffix } : {}), opts.dryRun));
5482
5512
  files.push(...providersPresetStep(root, doc, opts.dryRun, notes));
5483
- if (detected3.preCommit) files.push(await preCommitStep(root, detected3.preCommit, opts.dryRun));
5484
- for (const wf of detected3.pulumiActions) {
5513
+ if (detected2.preCommit) files.push(await preCommitStep(root, detected2.preCommit, opts.dryRun));
5514
+ for (const wf of detected2.pulumiActions) {
5485
5515
  notes.push(`${wf} runs pulumi/actions. init leaves it as it is; once plywood's previews and updates replace it, remove it, or keep it for the stacks plywood does not run`);
5486
5516
  }
5487
5517
  const chant = await chantProduct();
@@ -5489,7 +5519,7 @@ async function runInit(opts, table, io) {
5489
5519
  const message = needsChantMessage("Writing the waves spec and the workflow");
5490
5520
  files.push({ file: WAVES_SPEC_FILE2, status: "skipped", note: message }, { file: WORKFLOW_FILE[forge.kind], status: "skipped", note: "needs the same chant" });
5491
5521
  } else {
5492
- let spec = wavesSpecStep(chant, stacks2, doc, settings, detected3.defaultBranch, waves);
5522
+ let spec = wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves);
5493
5523
  if (settings.pipeline.jobs) {
5494
5524
  try {
5495
5525
  spec = withJobSettings(spec, settings.pipeline.jobs, forge.kind, { parallelism: await hasChant4625() });
@@ -5498,12 +5528,15 @@ async function runInit(opts, table, io) {
5498
5528
  spec = void 0;
5499
5529
  }
5500
5530
  }
5501
- if (spec && stacks2.length > 0) files.push(put(root, WAVES_SPEC_FILE2, jsonText(spec), opts.dryRun));
5531
+ if (spec && stacks2.length > 0) {
5532
+ files.push(put(root, WAVES_SPEC_FILE2, wavesSpecText(spec, wavesEngine(settings.pipeline)), opts.dryRun));
5533
+ files.push(put(root, UP_OP_FILE, upOpFile(spec), opts.dryRun));
5534
+ }
5502
5535
  if (spec) files.push(...await workflowStep(chant, root, doc, settings, forge.kind, opts, stacks2.length > 0 ? spec : void 0, table, io));
5503
5536
  }
5504
- if (opts.bucketCheck) notes.push(...await bucketStep(detected3, doc, io.env, buckets));
5505
- adopt.push(...await adoptStep(root, detected3, doc, io.env, declaration.doc, opts.dryRun, files, notes, todo));
5506
- todo.push(...todoLines(detected3, doc, forge.kind, io.env));
5537
+ if (opts.bucketCheck) notes.push(...await bucketStep(detected2, doc, io.env, buckets));
5538
+ adopt.push(...await adoptStep(root, detected2, doc, io.env, declaration.doc, opts.dryRun, files, notes, todo));
5539
+ todo.push(...todoLines(detected2, doc, forge.kind, io.env));
5507
5540
  const failed = files.find((f) => f.status === "failed");
5508
5541
  return {
5509
5542
  ok: !failed,
@@ -5519,9 +5552,9 @@ async function runInit(opts, table, io) {
5519
5552
  ...imported ? { imported } : {}
5520
5553
  };
5521
5554
  }
5522
- function declarationStep(root, detected3, settings, dryRun) {
5555
+ function declarationStep(root, detected2, settings, dryRun) {
5523
5556
  if (existsSync10(join13(root, "chant.workspace.jsonc"))) {
5524
- return { file: { file: "chant.workspace.jsonc", status: "kept", note: `add a member of kind pulumi for each of ${detected3.projects.map((p) => p.dir).join(", ")}, and pin ${PULUMI_LEXICON}@${CHANT_PIN}` } };
5557
+ return { file: { file: "chant.workspace.jsonc", status: "kept", note: `add a member of kind pulumi for each of ${detected2.projects.map((p) => p.dir).join(", ")}, and pin ${PULUMI_LEXICON}@${CHANT_PIN}` } };
5525
5558
  }
5526
5559
  let existing;
5527
5560
  const file = join13(root, DECLARATION_FILE);
@@ -5534,7 +5567,7 @@ function declarationStep(root, detected3, settings, dryRun) {
5534
5567
  return { file: { file: DECLARATION_FILE, status: "failed", note: `is not JSON init can merge into (${e.message})` } };
5535
5568
  }
5536
5569
  }
5537
- const doc = workspaceDeclaration(existing, { name: repoName(root), projects: detected3.projects, ci: settings.ci, lexiconVersion: CHANT_PIN });
5570
+ const doc = workspaceDeclaration(existing, { name: repoName(root), projects: detected2.projects, ci: settings.ci, lexiconVersion: CHANT_PIN });
5538
5571
  if (existing && JSON.stringify(existing) === JSON.stringify(doc)) return { file: { file: DECLARATION_FILE, status: "unchanged" }, doc };
5539
5572
  return { file: put(root, DECLARATION_FILE, jsonText(doc), dryRun), doc };
5540
5573
  }
@@ -5589,7 +5622,7 @@ async function workflowStep(chant, root, doc, settings, forge, opts, spec, table
5589
5622
  try {
5590
5623
  const { noteControlTarget: noteControlTarget2 } = await Promise.resolve().then(() => (init_control2(), control_exports));
5591
5624
  const control3 = await noteControlTarget2(doc, root);
5592
- plan = workflowPlan(chant, doc, forge, opts.env, spec, chantBuildFlags(chantPipelineOptions(settings.pipeline, false)), { reviewStacks: await chantReviewStacks(), prJobs, next: await chantNext(), ...control3 ? { control: control3 } : {} });
5625
+ plan = workflowPlan(chant, doc, forge, opts.env, spec, chantBuildFlags(chantPipelineOptions(settings.pipeline, false)), { reviewStacks: await chantReviewStacks(), prJobs, ...control3 ? { control: control3 } : {} });
5593
5626
  } catch (e) {
5594
5627
  return [{ file: output, status: "failed", note: e.message }];
5595
5628
  }
@@ -5606,16 +5639,16 @@ async function workflowStep(chant, root, doc, settings, forge, opts, spec, table
5606
5639
  ];
5607
5640
  const out = [{ file: output, status: written.status, ...notes.length ? { note: notes.join("; ") } : {} }];
5608
5641
  if (plan.jobs.waves) {
5609
- const code = await writeWaveJobs(chant, forge, plan.jobs.waves, plan.words, table, { ...io, cwd: root });
5642
+ const code = await writeWaveJobs(chant, forge, plan.jobs.waves, plan.words, table, { ...io, cwd: root }, wavesEngine(settings.pipeline));
5610
5643
  if (code !== table.done) out.push({ file: WAVES_OUTPUT_DIR[forge], status: "failed", note: `the waves workflow: chant exited ${code}` });
5611
5644
  }
5612
5645
  return out;
5613
5646
  }
5614
- async function bucketStep(detected3, doc, env, reports) {
5647
+ async function bucketStep(detected2, doc, env, reports) {
5615
5648
  const listed = stacksOf(doc);
5616
5649
  const urls = /* @__PURE__ */ new Set();
5617
- for (const s of detected3.stacks) {
5618
- const project = detected3.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5650
+ for (const s of detected2.stacks) {
5651
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5619
5652
  const b = backendOf(project, { ...listed[s.key]?.backend ? { stack: listed[s.key].backend } : {}, ...typeof doc.backend === "string" ? { all: doc.backend } : {} }, env);
5620
5653
  if (b.url && !isPulumiCloud(b.url)) urls.add(b.url);
5621
5654
  }
@@ -5639,12 +5672,12 @@ async function bucketStep(detected3, doc, env, reports) {
5639
5672
  }
5640
5673
  return lines2;
5641
5674
  }
5642
- async function adoptStep(root, detected3, doc, env, declaration, dryRun, files, notes, todo) {
5675
+ async function adoptStep(root, detected2, doc, env, declaration, dryRun, files, notes, todo) {
5643
5676
  const listed = stacksOf(doc);
5644
5677
  const all = typeof doc.backend === "string" ? doc.backend : void 0;
5645
5678
  const targets = [];
5646
- for (const s of detected3.stacks) {
5647
- const project = detected3.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5679
+ for (const s of detected2.stacks) {
5680
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5648
5681
  const own = listed[s.key];
5649
5682
  const b = backendOf(project, { ...own?.backend ? { stack: own.backend } : {}, ...all ? { all } : {} }, env);
5650
5683
  if (b.url && /^plunge\+/.test(b.url)) targets.push({ key: s.key, project: s.project, projectDir: s.dir, dir: own?.dir ?? s.dir, stack: s.stack, url: b.url });
@@ -5667,17 +5700,17 @@ async function adoptStep(root, detected3, doc, env, declaration, dryRun, files,
5667
5700
  todo.push(...adoptTodo(rows));
5668
5701
  return previews;
5669
5702
  }
5670
- function keptPlungeLines(kept, detected3, env) {
5671
- const step = engineStep({ engine: "plunge", detected: detected3, env });
5703
+ function keptPlungeLines(kept, detected2, env) {
5704
+ const step = engineStep({ engine: "plunge", detected: detected2, env });
5672
5705
  const entries = Object.entries(step.keys);
5673
5706
  if (entries.length === 0) return step.notes;
5674
5707
  const yaml = entries.map(([k, v]) => emitYAMLEntry2(k, v)).join("\n").split("\n").filter((l) => l !== "");
5675
5708
  return [...step.notes, `To move to plunge, set these keys in ${kept}:`, ...yaml.map((l) => ` ${l}`)];
5676
5709
  }
5677
- function todoLines(detected3, doc, forge, env) {
5710
+ function todoLines(detected2, doc, forge, env) {
5678
5711
  const out = [];
5679
5712
  const all = typeof doc.backend === "string" ? doc.backend : void 0;
5680
- const cloud = detected3.projects.some((p) => isPulumiCloud(backendOf(p, all ? { all } : {}, env).url));
5713
+ const cloud = detected2.projects.some((p) => isPulumiCloud(backendOf(p, all ? { all } : {}, env).url));
5681
5714
  const where = forge === "gitlab" ? "CI/CD variables" : "repository secrets";
5682
5715
  const traded = identityClouds(doc);
5683
5716
  if (cloud && traded.includes("pulumi")) {
@@ -5685,9 +5718,9 @@ function todoLines(detected3, doc, forge, env) {
5685
5718
  } else if (cloud) {
5686
5719
  out.push(`Set pulumi_cloud.org and identity.preview.pulumi and identity.up.pulumi in plywood.yml, so each job trades its OIDC token for its own Pulumi Cloud token, or add PULUMI_ACCESS_TOKEN_PREVIEW and PULUMI_ACCESS_TOKEN_UP to the ${where}`);
5687
5720
  }
5688
- if (traded.some((c) => c !== "pulumi")) out.push(`Let each role identity names trust this repository's OIDC tokens: the preview role for pull requests, the up role for ${detected3.defaultBranch} only`);
5721
+ if (traded.some((c) => c !== "pulumi")) out.push(`Let each role identity names trust this repository's OIDC tokens: the preview role for pull requests, the up role for ${detected2.defaultBranch} only`);
5689
5722
  else if (!cloud) out.push("Give the preview job and the update job their cloud access, each its own role through OIDC (identity in plywood.yml); the backend bucket's read and write go with them");
5690
- out.push(`Make ${PRODUCT}/preview a required status on ${detected3.defaultBranch}`);
5723
+ out.push(`Make ${PRODUCT}/preview a required status on ${detected2.defaultBranch}`);
5691
5724
  return out;
5692
5725
  }
5693
5726
  function initLines(r) {
@@ -5732,6 +5765,7 @@ var init_init = __esm({
5732
5765
  init_policy_presets();
5733
5766
  init_approval();
5734
5767
  init_engine();
5768
+ init_locks();
5735
5769
  init_config();
5736
5770
  init_routing();
5737
5771
  init_stacks();
@@ -5748,7 +5782,6 @@ var init_init = __esm({
5748
5782
  init_review_stacks2();
5749
5783
  init_waves_workflow();
5750
5784
  init_stages();
5751
- init_chant_next();
5752
5785
  init_stack_order();
5753
5786
  init_import();
5754
5787
  init_templates();
@@ -6132,7 +6165,6 @@ async function check(args, json, table, io) {
6132
6165
  }
6133
6166
  async function preview(args, json, table, io) {
6134
6167
  const targeted = args.includes("--target");
6135
- const planFlags = ["--refresh", "--target-dependents"].filter((f) => args.includes(f));
6136
6168
  if (args.includes("--target-dependents") && !targeted) return usage("--target-dependents widens a --target preview to the resources that depend on the targets; give a --target", table, io);
6137
6169
  for (let i = 0; i < args.length; i++) {
6138
6170
  if (args[i] === "--target" && (args[i + 1] === void 0 || args[i + 1].startsWith("-"))) return usage("--target takes a resource's URN", table, io);
@@ -6155,16 +6187,12 @@ async function preview(args, json, table, io) {
6155
6187
  const argv = ["components", "pr-plan", "--base", at, "--pr", number, ...gate, ...stack ? ["--member", stack] : [], ...rest, ...json ? ["--json"] : []];
6156
6188
  return withChant(`${PRODUCT} preview`, table, io, async (chant) => {
6157
6189
  if (targeted && !hasChant4620(chant)) return usage(PREVIEW_TARGET_NEEDS_CHANT, table, io);
6158
- if (planFlags.length && !(await chantNext()).planFlags) return usage(previewFlagsNeedChant(planFlags), table, io);
6159
6190
  return chant.runChantInProcess(argv, PRODUCT_WORDS);
6160
6191
  });
6161
6192
  }
6162
- function previewFlagsNeedChant(flags) {
6163
- return `${flags.join(" and ")} ${flags.length === 1 ? "needs" : "need"} a chant whose pr-plan passes ${flags.length === 1 ? "it" : "them"} to the pulumi-up step (chant#4627), which this chant lacks`;
6164
- }
6165
6193
  function readWavesSpec(cwd) {
6166
6194
  const file = join15(cwd, ".chant/op-waves", `${OP_NAME}.spec.json`);
6167
- return existsSync12(file) ? JSON.parse(readFileSync14(file, "utf8")) : void 0;
6195
+ return existsSync12(file) ? unwrapWavesSpec(JSON.parse(readFileSync14(file, "utf8"))) : void 0;
6168
6196
  }
6169
6197
  function pipelineShape(doc, forge, waves) {
6170
6198
  const applyBeforeMerge = applyWhenOf(doc) === "pull-request";
@@ -6225,7 +6253,7 @@ async function ci(args, json, table, io) {
6225
6253
  const noteControl = await noteControlTarget2(config2.doc, io.cwd);
6226
6254
  let plan;
6227
6255
  try {
6228
- plan = workflowPlan(chant, config2.doc, forge, env, waves, [], { reviewStacks: reviewStacks2, prJobs, next: await chantNext(), ...noteControl ? { control: noteControl } : {} });
6256
+ plan = workflowPlan(chant, config2.doc, forge, env, waves, [], { reviewStacks: reviewStacks2, prJobs, ...noteControl ? { control: noteControl } : {} });
6229
6257
  } catch (e) {
6230
6258
  io.err(`${PRODUCT}: ${e.message}`);
6231
6259
  return table.failed;
@@ -6274,7 +6302,7 @@ async function ci(args, json, table, io) {
6274
6302
  if (!json) io.err(`${PRODUCT} ci: chant's generator exited ${written.code} and wrote no ${output}; any file there before is back as it was`);
6275
6303
  return written.code;
6276
6304
  }
6277
- const wavesCode = waveJobs ? await writeWaveJobs(chant, forge, waveJobs, plan.words, table, io) : table.done;
6305
+ const wavesCode = waveJobs ? await writeWaveJobs(chant, forge, waveJobs, plan.words, table, io, wavesEngine(toChantSettings(config2).pipeline)) : table.done;
6278
6306
  if (wavesCode !== table.done) return wavesCode;
6279
6307
  const driftCode = await writeDriftJobs(chant, forge, config2.doc, plan.words, table, io, env);
6280
6308
  if (driftCode !== table.done) return driftCode;
@@ -6289,12 +6317,10 @@ function writtenNotes(plan, file) {
6289
6317
  }
6290
6318
  function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6291
6319
  const shape = pipelineShape(doc, forge, waves);
6292
- const next = opts.next;
6293
6320
  const notes = [];
6294
6321
  const pipeline = toChantSettings({ doc }).pipeline;
6295
- const staleNotes = next?.staleNotes === true;
6296
6322
  const respond = respondOptions(chant, pipeline, shape.applyBeforeMerge, notes);
6297
- const applyMerge = shape.applyBeforeMerge && pipeline.applyMerge ? next?.applyMerge ? pipeline.applyMerge : (notes.push(APPLY_MERGE_NEEDS_CHANT), void 0) : void 0;
6323
+ const applyMerge = shape.applyBeforeMerge ? pipeline.applyMerge : void 0;
6298
6324
  if (approvalOf(doc) === "pr-review" && shape.waves && hasChantApprovalStatus(chant) && !hasChantPullRequestApprovalStatus(chant)) notes.push(APPROVAL_STATUS_NOTE);
6299
6325
  const words = wordsForJobs(shape.jobs);
6300
6326
  const requires = shape.applyBeforeMerge ? requiresArgv(chant, doc) : [];
@@ -6310,7 +6336,8 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6310
6336
  ...opts.control !== void 0 ? { control: { target: opts.control } } : {},
6311
6337
  // plywood#29: runners, env, pass and parallelism on the pull request jobs, with chant#4625 (src/jobs.ts).
6312
6338
  ...opts.prJobs?.options ?? {},
6313
- ...staleNotes ? { staleNotes: true } : {},
6339
+ // chant#4628: the note names the target commit it was previewed against, and a push marks older notes stale.
6340
+ staleNotes: true,
6314
6341
  ...respond.options,
6315
6342
  ...applyMerge ? { applyMerge } : {}
6316
6343
  });
@@ -6330,7 +6357,7 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6330
6357
  ...applyMerge ? applyMergeArgv(applyMerge) : [],
6331
6358
  ...respond.argv,
6332
6359
  ...previewComment ? ["--preview-comment"] : [],
6333
- ...staleNotes ? ["--stale-notes"] : [],
6360
+ "--stale-notes",
6334
6361
  // Review stacks (plywood#15): chant's pr-env jobs, only once the pulumi lexicon can remove what they create.
6335
6362
  ...doc.review_stacks !== void 0 && opts.reviewStacks ? ["--pr-env"] : [],
6336
6363
  ...opts.prJobs?.flags ?? [],
@@ -6387,7 +6414,7 @@ function usage(message, table, io) {
6387
6414
  io.err(`${PRODUCT}: ${message}`);
6388
6415
  return table.failed;
6389
6416
  }
6390
- var CHANT_PASSTHROUGH, VALUED, PREVIEW_TARGET_NEEDS_CHANT, WORKFLOW_FILE, APPLY_MERGE_NEEDS_CHANT, APPROVAL_STATUS_NOTE, CHANT_RESPOND_DEFAULTS, DECIDE_LEFT_OFF;
6417
+ var CHANT_PASSTHROUGH, VALUED, PREVIEW_TARGET_NEEDS_CHANT, WORKFLOW_FILE, APPROVAL_STATUS_NOTE, CHANT_RESPOND_DEFAULTS, DECIDE_LEFT_OFF;
6391
6418
  var init_stages = __esm({
6392
6419
  "src/stages.ts"() {
6393
6420
  "use strict";
@@ -6415,13 +6442,37 @@ var init_stages = __esm({
6415
6442
  forgejo: ".forgejo/workflows/plywood.yml",
6416
6443
  gitlab: ".gitlab-ci.yml"
6417
6444
  };
6418
- APPLY_MERGE_NEEDS_CHANT = "apply.merge is auto, and the installed chant has no pr-apply --merge auto (chant#4636), so a person merges after each update before merge";
6419
6445
  APPROVAL_STATUS_NOTE = `approval is pr-review, and chant posts its approval status only for waves that wait on pr-review; plywood's waves wait on the pull request's own approval, so no ${PRODUCT}/approval status is posted before merge`;
6420
6446
  CHANT_RESPOND_DEFAULTS = Object.freeze({ "wave-refused": "diff", "apply-failed": "triage", description: "off" });
6421
6447
  DECIDE_LEFT_OFF = "decide is set and respond.description is not check, so plywood leaves the decide service off the pull request jobs";
6422
6448
  }
6423
6449
  });
6424
6450
 
6451
+ // src/chant-resolve.ts
6452
+ import * as nodeModule from "node:module";
6453
+ var CHANT_SPECIFIER = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?(?:\/|$)/;
6454
+ function chantResolveHook(from) {
6455
+ return (specifier, context, next) => {
6456
+ try {
6457
+ return next(specifier, context);
6458
+ } catch (err) {
6459
+ const code = err.code;
6460
+ if (!CHANT_SPECIFIER.test(specifier) || code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") throw err;
6461
+ if (context.parentURL === from) throw err;
6462
+ return next(specifier, { ...context, parentURL: from });
6463
+ }
6464
+ };
6465
+ }
6466
+ var installed = false;
6467
+ function installChantResolution(from = import.meta.url) {
6468
+ if (installed) return true;
6469
+ const register = nodeModule.registerHooks;
6470
+ if (typeof register !== "function") return false;
6471
+ register({ resolve: chantResolveHook(from) });
6472
+ installed = true;
6473
+ return true;
6474
+ }
6475
+
6425
6476
  // src/cli.ts
6426
6477
  init_config_check();
6427
6478
 
@@ -6668,8 +6719,8 @@ var HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/plywood-guard.mjs"';
6668
6719
  function withGuardHook(settings) {
6669
6720
  const hooks = typeof settings.hooks === "object" && settings.hooks !== null ? settings.hooks : {};
6670
6721
  const pre = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
6671
- const has2 = JSON.stringify(pre).includes("plywood-guard.mjs");
6672
- if (!has2) pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK_COMMAND }] });
6722
+ const has = JSON.stringify(pre).includes("plywood-guard.mjs");
6723
+ if (!has) pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK_COMMAND }] });
6673
6724
  return { ...settings, hooks: { ...hooks, PreToolUse: pre } };
6674
6725
  }
6675
6726
  async function guard(args, json, table, io) {
@@ -7147,11 +7198,11 @@ async function upgrade(args, json, table, io) {
7147
7198
  return table.done;
7148
7199
  }
7149
7200
  say(` $ ${steps.install.command}`);
7150
- const installed = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7151
- if (installed.code !== 0) {
7201
+ const installed2 = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7202
+ if (installed2.code !== 0) {
7152
7203
  for (const n of notes) say(` note: ${n}`);
7153
- return fail(`npm install failed (exit ${installed.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7154
- ${installed.stderr.trim()}`);
7204
+ return fail(`npm install failed (exit ${installed2.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7205
+ ${installed2.stderr.trim()}`);
7155
7206
  }
7156
7207
  const got = installedVersion(pkgDir);
7157
7208
  if (got !== void 0 && got !== release2.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release2.version}; check the install`);
@@ -7554,7 +7605,7 @@ async function rollbackStack(args, json, table, io) {
7554
7605
 
7555
7606
  // src/cli.ts
7556
7607
  init_words();
7557
- var VERSION3 = true ? "0.0.0" : "0.0.0-dev";
7608
+ var VERSION3 = true ? "0.1.0" : "0.0.0-dev";
7558
7609
  var stdio = {
7559
7610
  out: (s) => process.stdout.write(s + "\n"),
7560
7611
  err: (s) => process.stderr.write(s + "\n")
@@ -7750,5 +7801,6 @@ function config(args, table, emit) {
7750
7801
  }
7751
7802
 
7752
7803
  // src/bin.ts
7804
+ installChantResolution();
7753
7805
  process.exitCode = await main(process.argv.slice(2));
7754
7806
  //# sourceMappingURL=plywood.mjs.map