@intentius/plywood 0.3.5 → 0.3.8

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
@@ -20,7 +20,7 @@ var __export = (target, all) => {
20
20
  var define_PLYWOOD_IMAGE_DIGESTS_default;
21
21
  var init_define_PLYWOOD_IMAGE_DIGESTS = __esm({
22
22
  "<define:__PLYWOOD_IMAGE_DIGESTS__>"() {
23
- define_PLYWOOD_IMAGE_DIGESTS_default = { "ghcr.io/intentius/plywood-pulumi:0.3.5-pulumi3.272.0": "sha256:a1288d42eaba8ec556ed3ea40a24cfda94a8413576b7f0129b30ab7620639f07", "ghcr.io/intentius/plywood-plunge:0.3.5-plunge0.2.0": "sha256:4384ee4b92c8b0cb00c4833bc0118a820a0c3620ad78dc50d9e21fb0d9fff370" };
23
+ define_PLYWOOD_IMAGE_DIGESTS_default = { "ghcr.io/intentius/plywood-pulumi:0.3.8-pulumi3.272.0": "sha256:b26afcdd7dd108768663bd5b3cef524b5323b5f5fff8ccb79d717c2895713ae3", "ghcr.io/intentius/plywood-plunge:0.3.8-plunge0.2.0": "sha256:48878580388a54ad5635c378d4848424354f9ca4b8f67434b7614a8210446b3e" };
24
24
  }
25
25
  });
26
26
 
@@ -409,38 +409,24 @@ function wordsForJobs(jobs2) {
409
409
  function driftOpName(stack) {
410
410
  return DRIFT_OP_PREFIX + stack.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
411
411
  }
412
- function driftIssueMarker(stack) {
413
- return `<!-- plywood:drift ${stack} -->`;
414
- }
415
412
  function driftIssueTitle(stack) {
416
413
  return `plywood: ${stack} has drifted`;
417
414
  }
418
- function cell(s) {
419
- return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
420
- }
421
- function driftIssueBody(input) {
422
- const { stack, rows } = input;
423
- const lines2 = [
424
- driftIssueMarker(stack),
425
- `plywood's drift check read ${stack} against the cloud at ${input.observedAt} and found ${rows.length} resource${rows.length === 1 ? "" : "s"} that differ from the stack's state.`,
426
- "",
427
- "| URN | Status | Attributes |",
428
- "|---|---|---|",
429
- ...rows.map((r) => `| \`${cell(r.urn)}\` | ${r.status} | ${r.attributes.length ? r.attributes.map((a) => `\`${cell(a)}\``).join(", ") : ""} |`),
430
- "",
431
- ...input.attribution?.length ? ["Who changed each attribute, from the known writes and the audit log:", "", ...input.attribution, ""] : [],
432
- `To put the cloud back to the program, merge a change to ${stack} or run \`${PRODUCT} up\`. To keep the cloud's values, change the program to match and let its preview show no changes.`,
433
- ...input.respond === "up" ? ["", `respond.drift is up, so this run also asked for an update of ${stack}'s wave; its gate decides.`] : [],
434
- ...input.runUrl ? ["", `Read by ${input.runUrl}.`] : [],
435
- "",
436
- `<!-- plywood:drift-fingerprint ${input.fingerprint} -->`
437
- ];
438
- return lines2.join("\n");
439
- }
440
- function driftClearedComment(stack, observedAt2) {
441
- return `plywood's drift check at ${observedAt2} found ${stack} matching its state, so this issue is closed.`;
415
+ function driftIssueWords(stack, respond) {
416
+ return {
417
+ product: PRODUCT,
418
+ title: () => driftIssueTitle(stack),
419
+ intro: ({ drifted, observedAt: observedAt2 }) => `plywood's drift check read ${stack} against the cloud at ${observedAt2} and found ${drifted.length} resource${drifted.length === 1 ? "" : "s"} that differ from the stack's state.`,
420
+ address: "URN",
421
+ advice: () => [
422
+ `To put the cloud back to the program, merge a change to ${stack} or run \`${PRODUCT} up\`. To keep the cloud's values, change the program to match and let its preview show no changes.`,
423
+ ...respond === "up" ? ["", `respond.drift is up, so this run also asked for an update of ${stack}'s wave; its gate decides.`] : []
424
+ ].join("\n"),
425
+ foundBy: (runUrl) => `Read by ${runUrl}.`,
426
+ cleared: ({ observedAt: observedAt2 }) => `plywood's drift check at ${observedAt2} found ${stack} matching its state, so this issue is closed.`
427
+ };
442
428
  }
443
- var PRODUCT, TAGLINE, OP_NAME, PR_GATE, VERBS, BIN, NOTIFY_WORDS, PRODUCT_WORDS, ACTION_COMMAND, POLICY_RULE_FORMS, CLOUD_NAMES, NO_IDENTITY, TEMPLATE_REQUEST_WORDS, STACK_OP_WORDS, DRIFT_OP_PREFIX, LOCK_GRAIN_WORDS, PLUNGE_TIMING_ATTRIBUTES;
429
+ var PRODUCT, TAGLINE, OP_NAME, PR_GATE, VERBS, BIN, NOTIFY_WORDS, PRODUCT_WORDS, ACTION_COMMAND, POLICY_RULE_FORMS, CLOUD_NAMES, NO_IDENTITY, TEMPLATE_REQUEST_WORDS, STACK_OP_WORDS, DRIFT_OP_PREFIX, DRIFT_ATTRIBUTION_HEADING, LOCK_GRAIN_WORDS, PLUNGE_TIMING_ATTRIBUTES;
444
430
  var init_words = __esm({
445
431
  "src/words.ts"() {
446
432
  "use strict";
@@ -484,7 +470,7 @@ var init_words = __esm({
484
470
  };
485
471
  STACK_OP_WORDS = {
486
472
  usage: {
487
- export: `${PRODUCT} export <project/stack> --out <file>`,
473
+ export: `${PRODUCT} export <project/stack> --out <file> [--version <n>]`,
488
474
  cancel: `${PRODUCT} cancel <project/stack> [--lock <id>]`,
489
475
  move: `${PRODUCT} move <urn>... --from <project/stack> --to <project/stack>, or ${PRODUCT} move <project/stack> --onto plunge`,
490
476
  rollback: `${PRODUCT} rollback <project/stack> [--digest <sha256:...>], or ${PRODUCT} rollback --release <name> --env <env>`,
@@ -495,7 +481,7 @@ var init_words = __esm({
495
481
  acted: (stack, name, r) => `Ran action ${name} on ${stack}: ${r.engine} replaced ${r.urn}.${r.tail ? `
496
482
  ${r.tail}` : ""}`,
497
483
  noAction: (stack, name, declared) => declared.length ? `${stack} has no action ${name} in plywood.yml; its actions are ${declared.join(", ")}` : `${stack} declares no actions in plywood.yml; add one under stacks.${stack}.actions with a name and the URN of a command resource`,
498
- exported: (stack, r) => `Exported ${stack} to ${r.file} (${r.resources} resources, ${r.bytes} bytes, sha256 ${r.sha256}). The file is readable by you alone; the ledger keeps its digest and size, never its content.`,
484
+ exported: (stack, r) => `Exported version ${r.version} of ${stack} to ${r.file} (${r.resources} resources, ${r.bytes} bytes, sha256 ${r.sha256}). The file is readable by you alone; the ledger keeps its digest and size, never its content.`,
499
485
  cancelled: (stack, lock, checked) => `Released lock ${lock} on ${stack}.${checked ? "" : " The backend's lock could not be read, so it was not checked against the approved one first."}`,
500
486
  moved: (from, to, r) => `Moved ${r.moved.length} resource${r.moved.length === 1 ? "" : "s"} from ${from} to ${to} on ${r.engine}. Both stacks preview no change.`,
501
487
  needsLock: "cancel needs --lock <id>, the lock the stack holds: the id pulumi printed when it refused the update, or the file name under .pulumi/locks/ on a file:// backend",
@@ -509,6 +495,7 @@ ${r.tail}` : ""}`,
509
495
  releaseIsEnvWide: (stack, env) => `--release rolls back every stack the release records in an environment, so it takes --env, not a stack. Run '${PRODUCT} rollback --release <name> --env ${env}', or '${PRODUCT} rollback ${stack}' for this stack's previous release.`
510
496
  };
511
497
  DRIFT_OP_PREFIX = "plywood-drift-";
498
+ DRIFT_ATTRIBUTION_HEADING = "Who changed each attribute, from the known writes and the audit log:";
512
499
  LOCK_GRAIN_WORDS = {
513
500
  perEngine: (engine3) => engine3 === "plunge" ? "resource, plunge's default (no ci.lockGrain; chant's pulumi lexicon sets it)" : engine3 === "pulumi" ? "stack, stock Pulumi's default (no ci.lockGrain; chant's pulumi lexicon sets it)" : "per engine: resource on plunge, stack on stock Pulumi (no ci.lockGrain; chant's pulumi lexicon sets it)"
514
501
  };
@@ -618,47 +605,29 @@ var init_control = __esm({
618
605
  var version, dependencies;
619
606
  var init_package = __esm({
620
607
  "package.json"() {
621
- version = "0.3.5";
608
+ version = "0.3.8";
622
609
  dependencies = {
623
- "@intentius/chant": "0.124.79",
624
- "@intentius/chant-lexicon-forgejo": "0.124.79",
625
- "@intentius/chant-lexicon-github": "0.124.79",
626
- "@intentius/chant-lexicon-gitlab": "0.124.79",
627
- "@intentius/chant-lexicon-pulumi": "0.124.79"
610
+ "@intentius/chant": "0.124.87",
611
+ "@intentius/chant-lexicon-forgejo": "0.124.87",
612
+ "@intentius/chant-lexicon-github": "0.124.87",
613
+ "@intentius/chant-lexicon-gitlab": "0.124.87",
614
+ "@intentius/chant-lexicon-pulumi": "0.124.87"
628
615
  };
629
616
  }
630
617
  });
631
618
 
632
619
  // src/images.ts
633
- import { CI_DOCKER_ARCH, ciFetchScript, plungeInstallLine, pulumiInstallLine } from "@intentius/chant-lexicon-pulumi/ci-setup";
620
+ import { ciToolSha256, derivedImageDockerfile, productImageDockerfile } from "@intentius/chant/ci-image";
621
+ import { plungeInstallLine, pulumiInstallLine } from "@intentius/chant-lexicon-pulumi/ci-setup";
634
622
  function engineDir(engine3, version2) {
635
623
  return `${ENGINE_ROOT}/${engine3}-${version2}`;
636
624
  }
637
625
  function engineBin(engine3, version2) {
638
626
  return `${engineDir(engine3, version2)}/${engine3}`;
639
627
  }
640
- function release(engine3, version2) {
641
- if (engine3 === "pulumi") {
642
- return {
643
- base: `https://github.com/pulumi/pulumi/releases/download/v${version2}`,
644
- archive: (arch) => `pulumi-v${version2}-linux-${arch}.tar.gz`,
645
- checksums: `pulumi-${version2}-checksums.txt`
646
- };
647
- }
648
- return {
649
- base: `https://github.com/INTENTIUS/plunge/releases/download/plunge-v${version2}`,
650
- archive: (arch) => `plunge-v${version2}-linux-${arch}.tar.gz`,
651
- checksums: `plunge-v${version2}-checksums.txt`
652
- };
653
- }
654
628
  function pinnedSums(engine3, version2) {
655
- const r = release(engine3, version2);
656
- const out = {};
657
- for (const arch of ARCHES) {
658
- const sum = PINNED_SHA256[r.archive(arch)];
659
- if (sum) out[arch] = sum;
660
- }
661
- return out;
629
+ const sums = ciToolSha256(engine3, version2);
630
+ return { ...sums.amd64 ? { x64: sums.amd64 } : {}, ...sums.arm64 ? { arm64: sums.arm64 } : {} };
662
631
  }
663
632
  function installLine(engine3, version2, link = true) {
664
633
  const sums = pinnedSums(engine3, version2);
@@ -704,7 +673,7 @@ function chantPipelineOptions(p) {
704
673
  const lines2 = p.engineSetup ?? [];
705
674
  return { ...p.image ? { image: p.image } : {}, ...p.runner !== void 0 ? { runner: p.runner } : {}, ...lines2.length ? { beforeScript: [...lines2] } : {} };
706
675
  }
707
- var digests, REGISTRY, TOOL_VERSIONS, ARCHES, PINNED_SHA256, ENGINE_ROOT, DIGEST, RELEASE_LABEL_ARG, LABEL_REF, PLUGIN;
676
+ var digests, REGISTRY, TOOL_VERSIONS, ENGINE_ROOT, DIGEST, PACK_DIR, RELEASE_LABEL_ARG, LABEL_REF, DOCKERFILE_HEADER, PLUGIN;
708
677
  var init_images = __esm({
709
678
  "src/images.ts"() {
710
679
  "use strict";
@@ -717,17 +686,17 @@ var init_images = __esm({
717
686
  /** A plunge release (INTENTIUS/plunge, tag plunge-v<ver>), not the Pulumi version it is built from. */
718
687
  plunge: "0.2.0"
719
688
  };
720
- ARCHES = ["x64", "arm64"];
721
- PINNED_SHA256 = {
722
- "pulumi-v3.272.0-linux-x64.tar.gz": "58d545f2b98761051922008ebd381ecef95484d7f7a5ff7faa15d7a780ea84f6",
723
- "pulumi-v3.272.0-linux-arm64.tar.gz": "4f0741901b8982c4326b702dabf483c63d2cf77c88ac42dd752b72541284a534",
724
- "plunge-v0.2.0-linux-x64.tar.gz": "c9c73d05cca9ad214b7f44230a6162cdcb6ad64bbfc725bc94688b997b191495",
725
- "plunge-v0.2.0-linux-arm64.tar.gz": "c176a82f038164085aa19c8ad6530d6022e560e124050e535dbe364ae0473357"
726
- };
727
689
  ENGINE_ROOT = "/opt/plywood";
728
690
  DIGEST = /@sha256:[0-9a-f]{64}$/;
691
+ PACK_DIR = "images/pack";
729
692
  RELEASE_LABEL_ARG = "CHANT_RELEASE_LABEL";
730
693
  LABEL_REF = `\${${RELEASE_LABEL_ARG}}`;
694
+ DOCKERFILE_HEADER = [
695
+ "Rendered from packages/plywood/src/images.ts by packages/plywood/scripts/build-images.mjs.",
696
+ "Edit the module, then run npm run images -w @intentius/plywood. Build from the repository root",
697
+ `after npm pack -w @intentius/plywood --pack-destination ${PACK_DIR}, with --build-arg ${RELEASE_LABEL_ARG}=<the packed version>`,
698
+ "(just images passes the committed one; a release passes its label); nothing pushes it."
699
+ ];
731
700
  PLUGIN = /^(?:(resource|converter|language|analyzer|tool):)?([a-z0-9][a-z0-9-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
732
701
  }
733
702
  });
@@ -2666,8 +2635,9 @@ var init_agent_jobs = __esm({
2666
2635
 
2667
2636
  // src/drift-notify.ts
2668
2637
  import { createHash } from "node:crypto";
2669
- function runFingerprint(results, stackFingerprint) {
2670
- const parts = results.filter((r) => r.ok && r.drifted > 0).map((r) => `${r.stack} ${stackFingerprint(r.rows)}`).sort();
2638
+ import { driftIssueFingerprint } from "@intentius/chant/drift-issue";
2639
+ function runFingerprint(results) {
2640
+ const parts = results.filter((r) => r.ok && r.drifted > 0).map((r) => `${r.stack} ${driftIssueFingerprint(r.records)}`).sort();
2671
2641
  return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
2672
2642
  }
2673
2643
  function notifiesDrift(notify2) {
@@ -2677,7 +2647,7 @@ async function sendDriftNotice(input) {
2677
2647
  if (!notifiesDrift(input.notify)) return void 0;
2678
2648
  const drifted = input.results.filter((r) => r.ok && r.drifted > 0);
2679
2649
  if (drifted.length === 0) return void 0;
2680
- const fingerprint = runFingerprint(input.results, input.stackFingerprint);
2650
+ const fingerprint = runFingerprint(input.results);
2681
2651
  if (drifted.every((r) => r.issue?.action === "unchanged")) {
2682
2652
  return { sent: false, reason: "no drift event was sent: every drifted stack's finding is the one its issue already holds", fingerprint };
2683
2653
  }
@@ -3589,26 +3559,23 @@ var init_up = __esm({
3589
3559
  });
3590
3560
 
3591
3561
  // src/drift.ts
3592
- import { createHash as createHash2 } from "node:crypto";
3593
3562
  import { resolveTargetCloudIdentity } from "@intentius/chant/ci-identity";
3563
+ import { ForgeFlagError, forgeTargetOptionsFromFlags, forgeTargetWith } from "@intentius/chant/forge-flags";
3564
+ import { forgeClient } from "@intentius/chant/pr-forge";
3565
+ import { keepDriftIssue } from "@intentius/chant/drift-issue";
3594
3566
  import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync4, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3595
3567
  import { tmpdir } from "node:os";
3596
3568
  import { dirname as dirname6, join as join7 } from "node:path";
3597
3569
  function driftRows(doc) {
3598
3570
  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);
3599
3571
  }
3600
- function driftFingerprint(rows) {
3601
- const text = rows.map((r) => `${r.urn} ${r.status} ${r.attributes.join(",")}`).join("\n");
3602
- return createHash2("sha256").update(text).digest("hex").slice(0, 16);
3603
- }
3604
3572
  function observedAt(doc) {
3605
3573
  const times = (doc.records ?? []).map((r) => r.observedAt).filter((t) => typeof t === "string").sort();
3606
3574
  return times[times.length - 1] ?? (/* @__PURE__ */ new Date()).toISOString();
3607
3575
  }
3608
3576
  async function forgeFor(kind, env) {
3609
3577
  if (forgeOverride) return forgeOverride(kind, env);
3610
- const m = await import("@intentius/chant/pr-forge");
3611
- return m.forgeClient(m.forgeTargetFromEnv(env, { kind }));
3578
+ return forgeClient(forgeTargetWith(env, { kind }));
3612
3579
  }
3613
3580
  function wavesHolding(doc, stacks2) {
3614
3581
  if (!Array.isArray(doc.waves)) return "all";
@@ -3626,7 +3593,13 @@ async function drift2(args, json, table, io) {
3626
3593
  }
3627
3594
  const unknown = stackFlag.rest.filter((a) => a.startsWith("-"));
3628
3595
  if (unknown.length) return usage(`drift does not take ${unknown.join(" ")}`, table, io);
3629
- if (forgeFlag.value !== void 0 && !FORGES.includes(forgeFlag.value)) return usage(`--forge ${forgeFlag.value} is not one of ${FORGES.join(", ")}`, table, io);
3596
+ let forge;
3597
+ try {
3598
+ forge = forgeTargetOptionsFromFlags({ forge: forgeFlag.value }).kind;
3599
+ } catch (e) {
3600
+ if (e instanceof ForgeFlagError) return usage(e.message, table, io);
3601
+ throw e;
3602
+ }
3630
3603
  if (respondFlag.value !== void 0 && !DRIFT_RESPONSES.includes(respondFlag.value)) return usage(`--respond takes one of ${DRIFT_RESPONSES.join(", ")}`, table, io);
3631
3604
  const path = findConfig(io.cwd);
3632
3605
  if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init' first`, table, io);
@@ -3650,7 +3623,6 @@ async function drift2(args, json, table, io) {
3650
3623
  if (strangers.length) return usage(`${strangers.join(", ")} ${strangers.length === 1 ? "is" : "are"} not under stacks in ${path}`, table, io);
3651
3624
  if (resource.value !== void 0 && stacks2.length !== 1) return usage("--resource reads one resource of one stack; name the stack", table, io);
3652
3625
  const respond = respondFlag.value ?? driftResponseOf(doc);
3653
- const forge = forgeFlag.value;
3654
3626
  return withChant(`${PRODUCT} drift`, table, io, async (chant) => {
3655
3627
  const dir = mkdtempSync(join7(tmpdir(), "plywood-drift-"));
3656
3628
  const results = [];
@@ -3703,7 +3675,7 @@ async function drift2(args, json, table, io) {
3703
3675
  const failed = results.some((r) => !r.ok) || upkeepFailed;
3704
3676
  const code = failed ? table.failed : upCode !== void 0 && upCode !== table.done ? upCode : drifted.length ? table.drift : table.done;
3705
3677
  if (json) {
3706
- io.out(JSON.stringify({ command: "drift", ok: code === table.done, exitCode: code, respond, stacks: results, ...notice ? { notify: notice } : {} }));
3678
+ io.out(JSON.stringify({ command: "drift", ok: code === table.done, exitCode: code, respond, stacks: results.map(({ records: _, ...r }) => r), ...notice ? { notify: notice } : {} }));
3707
3679
  } else {
3708
3680
  if (notice) io.err(`${PRODUCT} drift: ${notice.sent ? `sent the drift event, fingerprint ${notice.fingerprint}` : notice.reason}`);
3709
3681
  for (const r of results) {
@@ -3725,7 +3697,6 @@ async function driftNotice(chant, doc, results, environment, io) {
3725
3697
  return sendDriftNotice({
3726
3698
  notify: notify2,
3727
3699
  results,
3728
- stackFingerprint: driftFingerprint,
3729
3700
  chant: { ...await chantDriftNotify(), words: chant.assertProductWords(PRODUCT_WORDS) },
3730
3701
  env: io.env,
3731
3702
  ...environment ? { environment } : {},
@@ -3759,20 +3730,21 @@ async function readStack(chant, stack, member, dir, opts) {
3759
3730
  try {
3760
3731
  if (existsSync7(file)) doc = JSON.parse(readFileSync8(file, "utf8"));
3761
3732
  } catch (e) {
3762
- return { stack, ok: false, drifted: 0, rows: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3733
+ return { stack, ok: false, drifted: 0, rows: [], records: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3763
3734
  }
3764
3735
  if (code !== 0 || !doc || doc.error) {
3765
- return { stack, ok: false, drifted: 0, rows: [], error: doc?.error ? `${doc.error.code}: ${doc.error.message}` : `chant workspace drift exited ${code}` };
3736
+ return { stack, ok: false, drifted: 0, rows: [], records: [], error: doc?.error ? `${doc.error.code}: ${doc.error.message}` : `chant workspace drift exited ${code}` };
3766
3737
  }
3767
3738
  const rows = driftRows(doc);
3739
+ const records = (doc.records ?? []).map((r) => ({ ...r, member: r.member ?? member }));
3768
3740
  const unread = (doc.records ?? []).filter((r) => r.status === "not-observed");
3769
3741
  if (rows.length === 0 && unread.length > 0) {
3770
3742
  const first = unread[0];
3771
3743
  const why = first.unobserved?.detail ?? first.unobserved?.reason ?? "no reason given";
3772
- return { stack, ok: false, drifted: 0, rows: [], error: `${unread.length} resource${unread.length === 1 ? "" : "s"} could not be read, so the stack's drift is unknown: ${first.address}: ${why}` };
3744
+ return { stack, ok: false, drifted: 0, rows: [], records: [], error: `${unread.length} resource${unread.length === 1 ? "" : "s"} could not be read, so the stack's drift is unknown: ${first.address}: ${why}` };
3773
3745
  }
3774
3746
  const attribution = rows.length && opts.attribute ? await attributeStack(opts.attribute, stack, doc) : void 0;
3775
- return { stack, ok: true, drifted: rows.length, rows, observedAt: observedAt(doc), ...attribution ? { attribution } : {} };
3747
+ return { stack, ok: true, drifted: rows.length, rows, records, observedAt: observedAt(doc), ...attribution ? { attribution } : {} };
3776
3748
  }
3777
3749
  async function withBackend(backend, read) {
3778
3750
  if (backend === void 0) return read();
@@ -3813,13 +3785,13 @@ function specHasUpdate(cwd, op) {
3813
3785
  return false;
3814
3786
  }
3815
3787
  }
3788
+ function driftIssueNotes(attribution) {
3789
+ return attribution?.length ? [DRIFT_ATTRIBUTION_HEADING, "", ...attribution] : void 0;
3790
+ }
3816
3791
  async function keepIssue(client, r, respond) {
3817
- const at = r.observedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3818
- const marker = driftIssueMarker(r.stack);
3819
- if (r.drifted === 0) return client.trackIssue(marker, null, { closeOnClear: true, clearComment: driftClearedComment(r.stack, at) });
3820
- const fingerprint = driftFingerprint(r.rows);
3821
- const body = driftIssueBody({ stack: r.stack, rows: r.rows, observedAt: at, fingerprint, ...client.runUrl ? { runUrl: client.runUrl } : {}, respond, ...r.attribution?.length ? { attribution: r.attribution } : {} });
3822
- return client.trackIssue(marker, { title: driftIssueTitle(r.stack), body }, { sameWhen: fingerprint });
3792
+ const notes = driftIssueNotes(r.attribution);
3793
+ const kept = await keepDriftIssue(client, { words: driftIssueWords(r.stack, respond), environment: r.stack, records: r.records, ...client.runUrl ? { runUrl: client.runUrl } : {}, ...notes ? { notes } : {} });
3794
+ return { action: kept.action, ...kept.number !== void 0 ? { number: kept.number } : {}, ...kept.url ? { url: kept.url } : {} };
3823
3795
  }
3824
3796
  function driftOpFile(doc, stacks2, schedule) {
3825
3797
  const listed = stacksOf(doc);
@@ -3923,7 +3895,6 @@ var init_drift2 = __esm({
3923
3895
  init_stacks();
3924
3896
  init_detect();
3925
3897
  init_render();
3926
- init_routing();
3927
3898
  init_images();
3928
3899
  init_stages();
3929
3900
  init_up();
@@ -4533,14 +4504,12 @@ function chantReferenceReader() {
4533
4504
  }
4534
4505
  async function exportOf(reader, root, s) {
4535
4506
  const dir = join9(root, s.dir);
4536
- if (reader.memberExportOf) {
4537
- try {
4538
- const found = await reader.memberExportOf(dir, { stack: s.stack });
4539
- if (found !== void 0) return found;
4540
- } catch {
4541
- }
4507
+ try {
4508
+ const found = await reader.memberExportOf(dir, { stack: s.stack });
4509
+ if (found !== void 0) return found;
4510
+ } catch {
4542
4511
  }
4543
- return reader.readMemberExport?.(dir);
4512
+ return reader.readMemberExport(dir);
4544
4513
  }
4545
4514
  async function stackOrder(root, stacks2, after = {}) {
4546
4515
  const edges = [];
@@ -5647,9 +5616,7 @@ __export(control_exports, {
5647
5616
  wavesControl: () => wavesControl
5648
5617
  });
5649
5618
  import { existsSync as existsSync11, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "node:fs";
5650
- import { createRequire } from "node:module";
5651
5619
  import { dirname as dirname10, join as join14 } from "node:path";
5652
- import { pathToFileURL } from "node:url";
5653
5620
  import { emitYAMLEntry as emitYAMLEntry3, parseYAML as parseYAML4 } from "@intentius/chant/yaml";
5654
5621
  async function wavesControl(cwd) {
5655
5622
  const file = findConfig(cwd);
@@ -5744,14 +5711,14 @@ async function control2(args, json, table, io) {
5744
5711
  return table.failed;
5745
5712
  }
5746
5713
  }
5747
- async function forgeControlPipeline(cwd, forge) {
5748
- const name = `@intentius/chant-lexicon-${forge}/components/control-pipeline`;
5749
- try {
5750
- const require2 = createRequire(join14(cwd, "package.json"));
5751
- const m = await import(pathToFileURL(require2.resolve(name)).href);
5752
- return typeof m.generateControlPipeline === "function" ? m.generateControlPipeline : void 0;
5753
- } catch {
5754
- return void 0;
5714
+ async function forgeControlPipeline(forge) {
5715
+ switch (forge) {
5716
+ case "github":
5717
+ return (await import("@intentius/chant-lexicon-github/components/control-pipeline")).generateControlPipeline;
5718
+ case "gitlab":
5719
+ return (await import("@intentius/chant-lexicon-gitlab/components/control-pipeline")).generateControlPipeline;
5720
+ case "forgejo":
5721
+ return (await import("@intentius/chant-lexicon-forgejo/components/control-pipeline")).generateControlPipeline;
5755
5722
  }
5756
5723
  }
5757
5724
  async function controlInit(chant, args, emit, table, io) {
@@ -5762,10 +5729,7 @@ async function controlInit(chant, args, emit, table, io) {
5762
5729
  const dryRun = forgeFlag.rest.includes("--dry-run");
5763
5730
  const forge = forgeFlag.value ?? "github";
5764
5731
  if (!FORGES.includes(forge)) return usage(`--forge ${forge} is not one of ${FORGES.join(", ")}`, table, io);
5765
- const generate = await forgeControlPipeline(io.cwd, forge);
5766
- if (!generate) {
5767
- return usage(`control init writes the control repo's workflow with @intentius/chant-lexicon-${forge}'s generateControlPipeline (chant#4568); install it in the control repo at the chant release that has it`, table, io);
5768
- }
5732
+ const generate = await forgeControlPipeline(forge);
5769
5733
  const file = generate({ words: PRODUCT_WORDS });
5770
5734
  const path = join14(io.cwd, file.path);
5771
5735
  const before = existsSync11(path) ? readFileSync13(path, "utf8") : void 0;
@@ -6168,7 +6132,9 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6168
6132
  staleNotes: true,
6169
6133
  ...respond.options,
6170
6134
  ...applyMerge ? { applyMerge } : {},
6171
- ...actionJob ? { actionJob: true } : {}
6135
+ ...actionJob ? { actionJob: true } : {},
6136
+ // chant#4785: GitLab starts no pipeline on a note, so a scheduled note-commands job answers the comments.
6137
+ ...forge === "gitlab" ? { noteCommands: true } : {}
6172
6138
  });
6173
6139
  const prWords = shape.applyBeforeMerge ? words : wordsForJobs(jobsWanted.filter((j) => j !== "apply"));
6174
6140
  const argv = [
@@ -6188,6 +6154,7 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6188
6154
  "--preview-comment",
6189
6155
  "--stale-notes",
6190
6156
  ...actionJob ? ["--action-job"] : [],
6157
+ ...forge === "gitlab" ? ["--note-commands"] : [],
6191
6158
  // Review stacks (plywood#15): chant's pr-env jobs.
6192
6159
  ...doc.review_stacks !== void 0 ? ["--pr-env"] : [],
6193
6160
  ...opts.prJobs?.flags ?? [],
@@ -6264,7 +6231,7 @@ var init_stages = __esm({
6264
6231
  forgejo: ".forgejo/workflows/plywood.yml",
6265
6232
  gitlab: ".gitlab-ci.yml"
6266
6233
  };
6267
- ACTIONS_NOT_ON_GITLAB = "plywood.yml declares actions, and a GitLab pipeline starts on no comment or dispatch with inputs, so it has no action job; run 'plywood action <stack> <name>' from a pipeline of your own";
6234
+ ACTIONS_NOT_ON_GITLAB = "plywood.yml declares actions, and chant's GitLab pipeline has no action job: GitLab has no dispatch with inputs, and the note-commands job answers no /plywood action; run 'plywood action <stack> <name>' from a pipeline of your own";
6268
6235
  CHANT_RESPOND_DEFAULTS = Object.freeze({ "wave-refused": "diff", "apply-failed": "triage", description: "off" });
6269
6236
  DECIDE_LEFT_OFF = "decide is set and respond.description is not check, so plywood leaves the decide service off the pull request jobs";
6270
6237
  }
@@ -6272,32 +6239,7 @@ var init_stages = __esm({
6272
6239
 
6273
6240
  // src/bin.ts
6274
6241
  init_define_PLYWOOD_IMAGE_DIGESTS();
6275
-
6276
- // src/chant-resolve.ts
6277
- init_define_PLYWOOD_IMAGE_DIGESTS();
6278
- import * as nodeModule from "node:module";
6279
- var CHANT_SPECIFIER = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?(?:\/|$)/;
6280
- function chantResolveHook(from) {
6281
- return (specifier, context, next) => {
6282
- try {
6283
- return next(specifier, context);
6284
- } catch (err) {
6285
- const code = err.code;
6286
- if (!CHANT_SPECIFIER.test(specifier) || code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") throw err;
6287
- if (context.parentURL === from) throw err;
6288
- return next(specifier, { ...context, parentURL: from });
6289
- }
6290
- };
6291
- }
6292
- var installed = false;
6293
- function installChantResolution(from = import.meta.url) {
6294
- if (installed) return true;
6295
- const register = nodeModule.registerHooks;
6296
- if (typeof register !== "function") return false;
6297
- register({ resolve: chantResolveHook(from) });
6298
- installed = true;
6299
- return true;
6300
- }
6242
+ import { installChantResolveFallback } from "@intentius/chant/chant-install";
6301
6243
 
6302
6244
  // src/cli.ts
6303
6245
  init_define_PLYWOOD_IMAGE_DIGESTS();
@@ -6349,7 +6291,7 @@ async function lockToCancel(given, stack, env) {
6349
6291
  }
6350
6292
  var cachedOps;
6351
6293
  function chantStackOps() {
6352
- cachedOps ??= import("@intentius/chant-lexicon-pulumi").then((m) => m);
6294
+ cachedOps ??= import("@intentius/chant-lexicon-pulumi");
6353
6295
  return cachedOps;
6354
6296
  }
6355
6297
  function readConfig(cwd) {
@@ -6424,7 +6366,7 @@ function reportOutcome(verb, outcome, gate, rules, done, json, table, io) {
6424
6366
  text = done(outcome.result);
6425
6367
  break;
6426
6368
  case "failed":
6427
- code = table.failed;
6369
+ code = outcome.check === "moved" ? table.moved : table.failed;
6428
6370
  text = `${PRODUCT} ${verb}: ${outcome.error}`;
6429
6371
  break;
6430
6372
  }
@@ -6439,7 +6381,7 @@ function reportOutcome(verb, outcome, gate, rules, done, json, table, io) {
6439
6381
  digest: outcome.digest,
6440
6382
  ...approveLine ? { approve: approveLine } : {},
6441
6383
  ...outcome.status === "done" ? { result: outcome.result } : {},
6442
- ...outcome.status === "failed" ? { error: outcome.error } : {},
6384
+ ...outcome.status === "failed" ? { error: outcome.error, ...outcome.check ? { check: outcome.check } : {} } : {},
6443
6385
  ...outcome.status === "refused" ? { refusedBy: outcome.by, reason: outcome.reason } : {}
6444
6386
  }));
6445
6387
  } else (code === table.done ? io.out : io.err)(text);
@@ -6451,9 +6393,11 @@ async function runRequest(verb, json, table, io, call) {
6451
6393
  return await call(ops);
6452
6394
  } catch (e) {
6453
6395
  const message = e.message;
6454
- if (json) io.out(JSON.stringify({ command: verb, ok: false, exitCode: table.failed, error: message }));
6396
+ const refusal = e instanceof ops.PulumiStackOpRefused ? e.code : void 0;
6397
+ const code = refusal === "live-run" || refusal === "held" ? table.held : table.failed;
6398
+ if (json) io.out(JSON.stringify({ command: verb, ok: false, exitCode: code, error: message, ...refusal ? { refusal } : {} }));
6455
6399
  else io.err(`${PRODUCT} ${verb}: ${message}`);
6456
- return table.failed;
6400
+ return code;
6457
6401
  }
6458
6402
  }
6459
6403
  function commonFlags(args) {
@@ -6468,9 +6412,11 @@ async function exportStack(args, json, table, io) {
6468
6412
  if (common.missing) return usage(`${common.missing} takes a value`, table, io);
6469
6413
  const out = takeFlag(common.rest, "--out");
6470
6414
  if (out.missing) return usage("--out takes a file path outside the repository", table, io);
6471
- const extra = out.rest.filter((a) => a.startsWith("-"));
6415
+ const version2 = takeFlag(out.rest, "--version");
6416
+ if (version2.missing) return usage("--version takes the update number, or the sha256: digest of an export, that the stack holds now", table, io);
6417
+ const extra = version2.rest.filter((a) => a.startsWith("-"));
6472
6418
  if (extra.length) return usage(`export does not take ${extra.join(" ")}`, table, io);
6473
- const named = positionals(out.rest);
6419
+ const named = positionals(version2.rest);
6474
6420
  if (named.length !== 1) return usage(`export takes one stack: ${STACK_OP_WORDS.usage.export}`, table, io);
6475
6421
  if (!out.value) return usage(`export needs --out <file>, a path on your machine outside every git working tree: ${STACK_OP_WORDS.usage.export}`, table, io);
6476
6422
  const read = readConfig(io.cwd);
@@ -6490,11 +6436,19 @@ async function exportStack(args, json, table, io) {
6490
6436
  dir: stack.dir,
6491
6437
  stack: stack.stack,
6492
6438
  out: outFile,
6439
+ ...version2.value ? { version: version2.value } : {},
6493
6440
  ...stack.engine ? { engine: stack.engine } : {}
6494
6441
  });
6495
6442
  return reportOutcome("export", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.exported(stack.key, r), json, table, io);
6496
6443
  });
6497
6444
  }
6445
+ function cancelForge(config2, root) {
6446
+ const f = toChantSettings(config2).pipeline.forge;
6447
+ const detected = f?.kind ? void 0 : detectForge(root);
6448
+ const kind = f?.kind ?? detected?.kind;
6449
+ const url = f?.url ?? detected?.url;
6450
+ return { ...kind ? { kind } : {}, ...url ? { url } : {} };
6451
+ }
6498
6452
  async function cancelStack(args, json, table, io) {
6499
6453
  const common = commonFlags(args);
6500
6454
  if (common.missing) return usage(`${common.missing} takes a value`, table, io);
@@ -6524,7 +6478,8 @@ async function cancelStack(args, json, table, io) {
6524
6478
  stack: stack.stack,
6525
6479
  lockId: lockId.lockId,
6526
6480
  project: stack.project,
6527
- ...stack.engine ? { engine: stack.engine } : {}
6481
+ ...stack.engine ? { engine: stack.engine } : {},
6482
+ forge: cancelForge(read.config, dirname11(read.path))
6528
6483
  });
6529
6484
  return reportOutcome("cancel", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.cancelled(stack.key, lockId.lockId, r.lockChecked), json, table, io);
6530
6485
  });
@@ -6596,11 +6551,11 @@ async function moveOntoPlunge(engine3, args, json, table, io) {
6596
6551
  });
6597
6552
  }
6598
6553
  async function rollbackStack(args, json, table, io) {
6599
- const release2 = takeFlag(args, "--release");
6600
- const env = takeFlag(release2.rest, "--env");
6554
+ const release = takeFlag(args, "--release");
6555
+ const env = takeFlag(release.rest, "--env");
6601
6556
  const digest = takeFlag(env.rest, "--digest");
6602
6557
  const actor = takeFlag(digest.rest, "--actor");
6603
- for (const [flag, f] of [["--release", release2], ["--env", env], ["--digest", digest], ["--actor", actor]]) {
6558
+ for (const [flag, f] of [["--release", release], ["--env", env], ["--digest", digest], ["--actor", actor]]) {
6604
6559
  if (f.missing) return usage(`${flag} takes a value`, table, io);
6605
6560
  }
6606
6561
  const switches = actor.rest.filter((a) => a === "--dry-run" || a === "--events-json");
@@ -6610,11 +6565,11 @@ async function rollbackStack(args, json, table, io) {
6610
6565
  const named = positionals(rest);
6611
6566
  if (named.length > 1) return usage(`rollback takes one stack: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6612
6567
  let argv;
6613
- if (release2.value !== void 0) {
6568
+ if (release.value !== void 0) {
6614
6569
  if (named.length) return usage(STACK_OP_WORDS.releaseIsEnvWide(named[0], environmentOf(named[0])), table, io);
6615
6570
  if (digest.value !== void 0) return usage("--release takes no --digest: the release names what each stack goes back to", table, io);
6616
6571
  if (!env.value) return usage(`rollback --release needs --env <env>, the environment whose stacks go back: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6617
- argv = ["components", "rollback", env.value, "--release", release2.value];
6572
+ argv = ["components", "rollback", env.value, "--release", release.value];
6618
6573
  } else {
6619
6574
  if (named.length !== 1) return usage(`rollback takes a stack, or --release <name> --env <env>: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6620
6575
  const read = readConfig(io.cwd);
@@ -7259,11 +7214,11 @@ async function upgrade(args, json, table, io) {
7259
7214
  const configFile = findConfig(io.cwd);
7260
7215
  const configDir = configFile ? dirname14(configFile) : pkgDir;
7261
7216
  const rel = (f) => relative2(io.cwd, f) || f;
7262
- const release2 = await viewRelease(exec, pkgDir, io.env, opts.version);
7263
- if ("error" in release2) return fail(release2.error);
7264
- const chant = opts.chant ?? release2.chant;
7217
+ const release = await viewRelease(exec, pkgDir, io.env, opts.version);
7218
+ if ("error" in release) return fail(release.error);
7219
+ const chant = opts.chant ?? release.chant;
7265
7220
  const pkgText = readFileSync16(pkgFile, "utf8");
7266
- const pkgMove = movePackagePins(pkgText, rel(pkgFile), release2.version, chant);
7221
+ const pkgMove = movePackagePins(pkgText, rel(pkgFile), release.version, chant);
7267
7222
  const declFile = join19(configDir, "chant.workspace.json");
7268
7223
  const declText = existsSync15(declFile) ? readFileSync16(declFile, "utf8") : void 0;
7269
7224
  let declMove;
@@ -7278,7 +7233,7 @@ async function upgrade(args, json, table, io) {
7278
7233
  if (existsSync15(join19(configDir, "chant.workspace.jsonc"))) notes.push(`chant.workspace.jsonc is yours: move its @intentius/chant* pins to ${chant} by hand`);
7279
7234
  if (configFile) {
7280
7235
  try {
7281
- const note = imageNote(loadConfig(configFile).doc, release2.version);
7236
+ const note = imageNote(loadConfig(configFile).doc, release.version);
7282
7237
  if (note) notes.push(note);
7283
7238
  } catch {
7284
7239
  }
@@ -7291,11 +7246,11 @@ async function upgrade(args, json, table, io) {
7291
7246
  install: { cwd: rel(pkgDir) || ".", command: "npm install --no-audit --no-fund" },
7292
7247
  ci: { cwd: rel(configDir) || ".", command: `npx ${ciArgs.join(" ")}` }
7293
7248
  };
7294
- const envelope = { command: "upgrade", plywood: release2.version, chant, moves, notes, dryRun: opts.dryRun };
7249
+ const envelope = { command: "upgrade", plywood: release.version, chant, moves, notes, dryRun: opts.dryRun };
7295
7250
  const say = (line) => {
7296
7251
  if (!json) io.err(line);
7297
7252
  };
7298
- say(`${PRODUCT} upgrade: ${PLYWOOD_PACKAGE} ${release2.version}, ${CHANT_PACKAGE} ${chant}`);
7253
+ say(`${PRODUCT} upgrade: ${PLYWOOD_PACKAGE} ${release.version}, ${CHANT_PACKAGE} ${chant}`);
7299
7254
  for (const m of moves) say(` ${m.file} ${m.name} ${m.from} -> ${m.to}`);
7300
7255
  if (moves.length === 0) say(" every pin is already there");
7301
7256
  if (opts.dryRun) {
@@ -7314,14 +7269,14 @@ async function upgrade(args, json, table, io) {
7314
7269
  return table.done;
7315
7270
  }
7316
7271
  say(` $ ${steps.install.command}`);
7317
- const installed2 = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7318
- if (installed2.code !== 0) {
7272
+ const installed = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7273
+ if (installed.code !== 0) {
7319
7274
  for (const n of notes) say(` note: ${n}`);
7320
- return fail(`npm install failed (exit ${installed2.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7321
- ${installed2.stderr.trim()}`);
7275
+ return fail(`npm install failed (exit ${installed.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7276
+ ${installed.stderr.trim()}`);
7322
7277
  }
7323
7278
  const got = installedVersion(pkgDir);
7324
- if (got !== void 0 && got !== release2.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release2.version}; check the install`);
7279
+ if (got !== void 0 && got !== release.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release.version}; check the install`);
7325
7280
  let ci2;
7326
7281
  if (configFile) {
7327
7282
  say(` $ ${steps.ci.command}`);
@@ -7363,7 +7318,7 @@ var execChild = (bin, args, opts) => new Promise((done) => {
7363
7318
 
7364
7319
  // src/cli.ts
7365
7320
  init_words();
7366
- var VERSION3 = true ? "0.3.5" : "0.0.0-dev";
7321
+ var VERSION3 = true ? "0.3.8" : "0.0.0-dev";
7367
7322
  var stdio = {
7368
7323
  out: (s) => process.stdout.write(s + "\n"),
7369
7324
  err: (s) => process.stderr.write(s + "\n")
@@ -7408,7 +7363,7 @@ function helpText() {
7408
7363
  " override <stack> --rule <id>... --reason <text> override a policy denial, spent once its update runs",
7409
7364
  "",
7410
7365
  "Stack operations (each waits for a second person's approval):",
7411
- " export <stack> --out <file> the stack's export, to a file on your machine",
7366
+ " export <stack> --out <file> [--version <v>] the stack's export, or one version of it, to a file on your machine",
7412
7367
  " cancel <stack> [--lock <id>] release a stuck stack lock; on file:// it reads the id",
7413
7368
  " move <urn>... --from <stack> --to <stack> move resources; both stacks preview no change",
7414
7369
  " move <stack> --onto plunge adopt the stacks onto plunge (plywood.adopt.op.ts)",
@@ -7560,6 +7515,6 @@ function config(args, table, emit) {
7560
7515
  }
7561
7516
 
7562
7517
  // src/bin.ts
7563
- installChantResolution();
7518
+ installChantResolveFallback(import.meta.url);
7564
7519
  process.exitCode = await main(process.argv.slice(2));
7565
7520
  //# sourceMappingURL=plywood.mjs.map