@intentius/plywood 0.0.0 → 0.2.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
@@ -380,7 +380,7 @@ var init_chant_keys = __esm({
380
380
 
381
381
  // src/words.ts
382
382
  function wordsForJobs(jobs2) {
383
- const commands = Object.fromEntries(Object.entries(PRODUCT_WORDS.commands).filter(([, c]) => jobs2.includes(c)));
383
+ const commands = Object.fromEntries(Object.entries({ ...PRODUCT_WORDS.commands, ...ACTION_COMMAND }).filter(([, c]) => jobs2.includes(c)));
384
384
  return { ...PRODUCT_WORDS, commands };
385
385
  }
386
386
  function driftOpName(stack) {
@@ -417,7 +417,7 @@ function driftIssueBody(input) {
417
417
  function driftClearedComment(stack, observedAt2) {
418
418
  return `plywood's drift check at ${observedAt2} found ${stack} matching its state, so this issue is closed.`;
419
419
  }
420
- var PRODUCT, TAGLINE, OP_NAME, PR_GATE, VERBS, BIN, NOTIFY_WORDS, PRODUCT_WORDS, POLICY_RULE_FORMS, CLOUD_NAMES, NO_IDENTITY, STACK_OP_WORDS, DRIFT_OP_PREFIX, LOCK_GRAIN_WORDS, PLUNGE_TIMING_ATTRIBUTES;
420
+ 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;
421
421
  var init_words = __esm({
422
422
  "src/words.ts"() {
423
423
  "use strict";
@@ -450,18 +450,27 @@ var init_words = __esm({
450
450
  bin: BIN,
451
451
  notify: NOTIFY_WORDS
452
452
  });
453
+ ACTION_COMMAND = Object.freeze({ action: "action" });
453
454
  POLICY_RULE_FORMS = "a CrossGuard rule is crossguard.<pack>.<policy>, a Rego rule <package>.<rule>";
454
455
  CLOUD_NAMES = { aws: "AWS", gcp: "GCP", azure: "Azure", pulumi: "Pulumi Cloud" };
455
456
  NO_IDENTITY = "the job's own credentials (no OIDC trade)";
457
+ TEMPLATE_REQUEST_WORDS = {
458
+ tool: "request_from_template",
459
+ refusal: "Pulumi templates are not available yet: request_from_template opens a pull request for an app from a template once plywood has a template registry and catalog, and the plywood request command that opens the same pull request for a person. Until then it refuses every call and opens nothing. To change a stack now, open a pull request with the change."
460
+ };
456
461
  STACK_OP_WORDS = {
457
462
  usage: {
458
463
  export: `${PRODUCT} export <project/stack> --out <file>`,
459
464
  cancel: `${PRODUCT} cancel <project/stack> [--lock <id>]`,
460
465
  move: `${PRODUCT} move <urn>... --from <project/stack> --to <project/stack>, or ${PRODUCT} move <project/stack> --onto plunge`,
461
- rollback: `${PRODUCT} rollback <project/stack> [--digest <sha256:...>], or ${PRODUCT} rollback --release <name> --env <env>`
466
+ rollback: `${PRODUCT} rollback <project/stack> [--digest <sha256:...>], or ${PRODUCT} rollback --release <name> --env <env>`,
467
+ action: `${PRODUCT} action <project/stack> <name> [--anew], or ${PRODUCT} action --from-event`
462
468
  },
463
469
  waiting: (verb, approve2) => `The ${verb} waits for a second person's approval, and your own does not count. They run '${approve2}', then you run this command again to carry it out.`,
464
- spent: (verb) => `This ${verb} ran once already under its approval, and a request runs once. Run the command again to ask for a new one.`,
470
+ spent: (verb) => verb === "action" ? "This action ran once already under its approval, and a request runs once. Run the command again with --anew to ask for a new run." : `This ${verb} ran once already under its approval, and a request runs once. Run the command again to ask for a new one.`,
471
+ acted: (stack, name, r) => `Ran action ${name} on ${stack}: ${r.engine} replaced ${r.urn}.${r.tail ? `
472
+ ${r.tail}` : ""}`,
473
+ 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`,
465
474
  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.`,
466
475
  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."}`,
467
476
  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.`,
@@ -578,10 +587,13 @@ var init_control = __esm({
578
587
  var version, dependencies;
579
588
  var init_package = __esm({
580
589
  "package.json"() {
581
- version = "0.0.0";
590
+ version = "0.2.0";
582
591
  dependencies = {
583
- "@intentius/chant": "0.124.55",
584
- "@intentius/chant-lexicon-pulumi": "0.124.55"
592
+ "@intentius/chant": "0.124.58",
593
+ "@intentius/chant-lexicon-forgejo": "0.124.58",
594
+ "@intentius/chant-lexicon-github": "0.124.58",
595
+ "@intentius/chant-lexicon-gitlab": "0.124.58",
596
+ "@intentius/chant-lexicon-pulumi": "0.124.58"
585
597
  };
586
598
  }
587
599
  });
@@ -590,7 +602,10 @@ var init_package = __esm({
590
602
  var image_digests_default;
591
603
  var init_image_digests = __esm({
592
604
  "src/image-digests.json"() {
593
- image_digests_default = {};
605
+ image_digests_default = {
606
+ "ghcr.io/intentius/plywood-pulumi:0.2.0-pulumi3.272.0": "sha256:ea268f1dd2fa15f80ddcedd5c3498d24b79cc2b8a590177c5499e2db3f686f18",
607
+ "ghcr.io/intentius/plywood-plunge:0.2.0-plunge0.1.0": "sha256:6a862ad23e39081a32fe8b2331b095ed2de44e527f2719416201b0affa46a1eb"
608
+ };
594
609
  }
595
610
  });
596
611
 
@@ -917,12 +932,58 @@ var init_images2 = __esm({
917
932
  });
918
933
 
919
934
  // src/config/stacks.ts
935
+ function parseUrn(urn) {
936
+ if (!urn.startsWith("urn:pulumi:")) return void 0;
937
+ const parts = urn.slice("urn:pulumi:".length).split("::");
938
+ if (parts.length < 4 || parts.slice(0, 4).some((p) => p === "")) return void 0;
939
+ return { stack: parts[0], project: parts[1], type: parts[2].split("$").at(-1) };
940
+ }
941
+ function actionUrnProblem(stackKey, urn) {
942
+ const parsed = parseUrn(urn);
943
+ if (!parsed) return `is not a resource URN (urn:pulumi:<stack>::<project>::<type>::<name>)`;
944
+ const stack = stackKey.split("/").at(-1);
945
+ if (parsed.stack !== stack) return `names stack ${parsed.stack}, and the action is under ${stackKey}`;
946
+ if (!parsed.type.startsWith(ACTION_TYPE_PREFIX)) {
947
+ return `is a ${parsed.type}, and an action is a command resource (command:local:Command or command:remote:Command), whose replacement runs it`;
948
+ }
949
+ return void 0;
950
+ }
951
+ function actionsOf(doc, key) {
952
+ const a = stacksOf(doc)[key]?.actions;
953
+ return Array.isArray(a) ? a : [];
954
+ }
955
+ function hasActions(doc) {
956
+ return Object.keys(stacksOf(doc)).some((k) => actionsOf(doc, k).length > 0);
957
+ }
920
958
  function wavesMappingOf(doc) {
921
959
  return isObject(doc.waves) ? doc.waves : {};
922
960
  }
923
961
  function stacksOf(doc) {
924
962
  return isObject(doc.stacks) ? doc.stacks : {};
925
963
  }
964
+ function actionProblems(stackKey, actions, key) {
965
+ if (!Array.isArray(actions)) return [error(key, "must be a list of actions, each with a name and a urn")];
966
+ const out = [];
967
+ const seen = /* @__PURE__ */ new Map();
968
+ actions.forEach((a, i) => {
969
+ const at = `${key}[${i}]`;
970
+ if (!isObject(a)) {
971
+ out.push(error(at, "must be a mapping with a name and a urn"));
972
+ return;
973
+ }
974
+ out.push(...onlyKeys(a, ACTION_KEYS, at));
975
+ if (typeof a.name !== "string" || !ACTION_NAME.test(a.name)) out.push(error(`${at}.name`, "must be a name of letters, digits, _, . and -"));
976
+ else if (seen.has(a.name)) out.push(error(`${at}.name`, `${a.name} is the name of actions[${seen.get(a.name)}] too; each action's name is unique in its stack`));
977
+ else seen.set(a.name, i);
978
+ if (typeof a.urn !== "string") out.push(error(`${at}.urn`, "must be the URN of a command resource in the stack"));
979
+ else {
980
+ const problem = actionUrnProblem(stackKey, a.urn);
981
+ if (problem) out.push(error(`${at}.urn`, `${a.urn} ${problem}`));
982
+ }
983
+ if (a.description !== void 0 && typeof a.description !== "string") out.push(error(`${at}.description`, "must be a string"));
984
+ });
985
+ return out;
986
+ }
926
987
  function wavesMappingProblems(w, listed) {
927
988
  const out = [...onlyKeys(w, WAVES_KEYS, "waves")];
928
989
  const known = (s, key) => {
@@ -976,15 +1037,18 @@ function afterCycle(after) {
976
1037
  }
977
1038
  return void 0;
978
1039
  }
979
- var STACK_KEYS, WAVES_KEYS, STACK_NAME, stacks;
1040
+ var STACK_KEYS, ACTION_KEYS, WAVES_KEYS, STACK_NAME, ACTION_NAME, ACTION_TYPE_PREFIX, stacks;
980
1041
  var init_stacks = __esm({
981
1042
  "src/config/stacks.ts"() {
982
1043
  "use strict";
983
1044
  init_approval();
984
1045
  init_types();
985
- STACK_KEYS = ["dir", "paths", "gate", "backend"];
1046
+ STACK_KEYS = ["dir", "paths", "gate", "backend", "actions"];
1047
+ ACTION_KEYS = ["name", "urn", "description"];
986
1048
  WAVES_KEYS = ["canary", "after"];
987
1049
  STACK_NAME = /^[^/\s]+\/[^/\s]+$/;
1050
+ ACTION_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
1051
+ ACTION_TYPE_PREFIX = "command:";
988
1052
  stacks = {
989
1053
  name: "stacks",
990
1054
  keys: ["stacks", "waves"],
@@ -1000,7 +1064,21 @@ var init_stacks = __esm({
1000
1064
  dir: { type: "string", description: "The directory holding the stack's Pulumi.yaml." },
1001
1065
  paths: { type: "array", items: { type: "string" }, description: "Paths whose change previews the stack, beside its directory." },
1002
1066
  gate: { type: "string", enum: ["always", "on-destructive", "never"], description: "The stack's gate, in place of the top-level gate." },
1003
- backend: BACKEND_SCHEMA
1067
+ backend: BACKEND_SCHEMA,
1068
+ actions: {
1069
+ type: "array",
1070
+ description: "The stack's day-2 actions, each run by plywood action <stack> <name> under a second person's approval.",
1071
+ items: {
1072
+ type: "object",
1073
+ additionalProperties: false,
1074
+ required: ["name", "urn"],
1075
+ properties: {
1076
+ name: { type: "string", pattern: ACTION_NAME.source, description: "The action's name, unique in the stack." },
1077
+ urn: { type: "string", pattern: "^urn:pulumi:", description: "The URN of a command:local:Command or command:remote:Command resource in the stack, replaced to run the action." },
1078
+ description: { type: "string", description: "What the action does, for people and agents." }
1079
+ }
1080
+ }
1081
+ }
1004
1082
  }
1005
1083
  }
1006
1084
  },
@@ -1038,6 +1116,7 @@ var init_stacks = __esm({
1038
1116
  if (s.dir !== void 0 && typeof s.dir !== "string") out.push(error(`${key}.dir`, "must be a string"));
1039
1117
  if (s.backend !== void 0 && typeof s.backend !== "string") out.push(error(`${key}.backend`, "must be a URL string"));
1040
1118
  out.push(...stringList(s.paths, `${key}.paths`), ...oneOf(s.gate, GATES, `${key}.gate`));
1119
+ if (s.actions !== void 0) out.push(...actionProblems(name, s.actions, `${key}.actions`));
1041
1120
  }
1042
1121
  if (isObject(doc.waves)) {
1043
1122
  out.push(...wavesMappingProblems(doc.waves, isObject(doc.stacks) ? listed : void 0));
@@ -2348,227 +2427,6 @@ var init_config_check = __esm({
2348
2427
  }
2349
2428
  });
2350
2429
 
2351
- // src/init/detect.ts
2352
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
2353
- import { basename, dirname as dirname2, join as join2, relative, sep } from "node:path";
2354
- import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
2355
- function repoRoot(dir) {
2356
- let cur = dir;
2357
- for (let i = 0; i < 64; i++) {
2358
- if (existsSync2(join2(cur, ".git"))) return cur;
2359
- const parent = dirname2(cur);
2360
- if (parent === cur) return dir;
2361
- cur = parent;
2362
- }
2363
- return dir;
2364
- }
2365
- function posixRel(root, path) {
2366
- const rel = relative(root, path);
2367
- return rel === "" ? "." : rel.split(sep).join("/");
2368
- }
2369
- function readYamlFile(file) {
2370
- try {
2371
- const doc = parseYAML2(readFileSync2(file, "utf8"));
2372
- return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
2373
- } catch {
2374
- return void 0;
2375
- }
2376
- }
2377
- function findPulumiProjects(root) {
2378
- const out = [];
2379
- const walk = (dir, depth) => {
2380
- let entries;
2381
- try {
2382
- entries = readdirSync(dir).sort();
2383
- } catch {
2384
- return;
2385
- }
2386
- const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
2387
- if (file) {
2388
- const project = readProject(root, dir, join2(dir, file));
2389
- if (project) out.push(project);
2390
- }
2391
- if (depth >= MAX_DEPTH) return;
2392
- for (const name of entries) {
2393
- if (SKIP.has(name) || name.startsWith(".")) continue;
2394
- const path = join2(dir, name);
2395
- try {
2396
- if (statSync(path).isDirectory()) walk(path, depth + 1);
2397
- } catch {
2398
- }
2399
- }
2400
- };
2401
- walk(root, 0);
2402
- return out;
2403
- }
2404
- function readProject(root, dir, file) {
2405
- const doc = readYamlFile(file);
2406
- if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
2407
- const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
2408
- const backend = doc.backend;
2409
- const configDir = typeof doc.stackConfigDir === "string" ? join2(dir, doc.stackConfigDir) : dir;
2410
- return {
2411
- name: doc.name,
2412
- dir: posixRel(root, dir),
2413
- ...typeof runtime === "string" ? { runtime } : {},
2414
- ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
2415
- stacks: stackNames(configDir)
2416
- };
2417
- }
2418
- function stackNames(configDir) {
2419
- let entries;
2420
- try {
2421
- entries = readdirSync(configDir);
2422
- } catch {
2423
- return [];
2424
- }
2425
- const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
2426
- return [...new Set(names)].sort(byEnvironment);
2427
- }
2428
- function environmentOf(stack) {
2429
- const last = stack.split("/").pop().toLowerCase();
2430
- const head = last.split(/[-_.]/)[0];
2431
- return ENV_ORDER.includes(head) ? head : last;
2432
- }
2433
- function rank(stack) {
2434
- const env = environmentOf(stack);
2435
- const at = ENV_ORDER.indexOf(env);
2436
- return at >= 0 ? at : PROD_RANK - 0.5;
2437
- }
2438
- function byEnvironment(a, b) {
2439
- return rank(a) - rank(b) || a.localeCompare(b);
2440
- }
2441
- function stacksOf2(projects) {
2442
- const names = /* @__PURE__ */ new Map();
2443
- for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
2444
- const out = [];
2445
- for (const p of projects) {
2446
- const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
2447
- for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
2448
- }
2449
- return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
2450
- }
2451
- function slug(s) {
2452
- const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2453
- return out === "" ? "root" : out;
2454
- }
2455
- function originUrl(root) {
2456
- const config2 = gitFile(root, "config");
2457
- if (!config2) return void 0;
2458
- let inOrigin = false;
2459
- for (const raw of config2.split(/\r?\n/)) {
2460
- const line = raw.trim();
2461
- if (line.startsWith("[")) {
2462
- inOrigin = /^\[remote\s+"origin"\]$/.test(line);
2463
- continue;
2464
- }
2465
- const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
2466
- if (m) return m[1].trim();
2467
- }
2468
- return void 0;
2469
- }
2470
- function gitFile(root, name) {
2471
- const dotGit = join2(root, ".git");
2472
- let gitDir = dotGit;
2473
- try {
2474
- if (statSync(dotGit).isFile()) {
2475
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync2(dotGit, "utf8"));
2476
- if (!m) return void 0;
2477
- gitDir = join2(root, m[1].trim());
2478
- const common = join2(gitDir, "commondir");
2479
- if (existsSync2(common) && !existsSync2(join2(gitDir, name))) gitDir = join2(gitDir, readFileSync2(common, "utf8").trim());
2480
- }
2481
- const path = join2(gitDir, name);
2482
- return existsSync2(path) ? readFileSync2(path, "utf8") : void 0;
2483
- } catch {
2484
- return void 0;
2485
- }
2486
- }
2487
- function remoteHost(url) {
2488
- const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
2489
- if (scp) return scp[1].toLowerCase();
2490
- try {
2491
- return new URL(url).hostname.toLowerCase() || void 0;
2492
- } catch {
2493
- return void 0;
2494
- }
2495
- }
2496
- function forgeOfRemote(url) {
2497
- const host = remoteHost(url);
2498
- if (!host) return void 0;
2499
- if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
2500
- if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
2501
- if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
2502
- if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
2503
- if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
2504
- return void 0;
2505
- }
2506
- function detectForge(root) {
2507
- const url = originUrl(root);
2508
- const byRemote = url ? forgeOfRemote(url) : void 0;
2509
- if (byRemote) return byRemote;
2510
- if (existsSync2(join2(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
2511
- if (existsSync2(join2(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
2512
- if (existsSync2(join2(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
2513
- return void 0;
2514
- }
2515
- function defaultBranch(root) {
2516
- const head = gitFile(root, "refs/remotes/origin/HEAD");
2517
- const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
2518
- if (remote) return remote.trim();
2519
- const local = gitFile(root, "HEAD");
2520
- const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
2521
- return branch ? branch.trim() : "main";
2522
- }
2523
- function findPulumiActions(root) {
2524
- const out = [];
2525
- for (const d of WORKFLOW_DIRS) {
2526
- let names;
2527
- try {
2528
- names = readdirSync(join2(root, d)).sort();
2529
- } catch {
2530
- continue;
2531
- }
2532
- for (const n of names) {
2533
- if (!/\.ya?ml$/.test(n)) continue;
2534
- try {
2535
- if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync2(join2(root, d, n), "utf8"))) out.push(`${d}/${n}`);
2536
- } catch {
2537
- }
2538
- }
2539
- }
2540
- return out;
2541
- }
2542
- function detect(cwd) {
2543
- const root = repoRoot(cwd);
2544
- const projects = findPulumiProjects(root);
2545
- const forge = detectForge(root);
2546
- const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync2(join2(root, n)));
2547
- return {
2548
- root,
2549
- projects,
2550
- stacks: stacksOf2(projects),
2551
- ...forge ? { forge } : {},
2552
- defaultBranch: defaultBranch(root),
2553
- pulumiActions: findPulumiActions(root),
2554
- ...preCommit ? { preCommit } : {}
2555
- };
2556
- }
2557
- function repoName(root) {
2558
- return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
2559
- }
2560
- var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
2561
- var init_detect = __esm({
2562
- "src/init/detect.ts"() {
2563
- "use strict";
2564
- SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
2565
- MAX_DEPTH = 8;
2566
- ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
2567
- PROD_RANK = ENV_ORDER.indexOf("prod");
2568
- WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
2569
- }
2570
- });
2571
-
2572
2430
  // src/chant-product.ts
2573
2431
  function hasChant4620(chant) {
2574
2432
  return typeof chant.prCommentPreviewJob === "function" && typeof chant.PR_PREVIEW_FLOOR === "string";
@@ -2600,44 +2458,11 @@ var init_chant_product = __esm({
2600
2458
  "src/chant-product.ts"() {
2601
2459
  "use strict";
2602
2460
  REQUIRED2 = ["assertProductWords", "productPipelineJobs", "approveLine", "runChantInProcess"];
2603
- BUILT_CHANT = true ? "0.124.55" : "unknown";
2461
+ BUILT_CHANT = true ? "0.124.58" : "unknown";
2604
2462
  }
2605
2463
  });
2606
2464
 
2607
2465
  // 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
2466
  function hasChantRespond(chant) {
2642
2467
  return typeof chant?.CI_RESPOND_FLOOR === "string";
2643
2468
  }
@@ -2651,26 +2476,13 @@ function hasChantPullRequestApprovalStatus(chant) {
2651
2476
  return typeof chant?.APPROVAL_STATUS_PULL_REQUEST_FLOOR === "string";
2652
2477
  }
2653
2478
  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
- })();
2479
+ audit ??= import("@intentius/chant/ci-audit").then((m) => m);
2662
2480
  return audit;
2663
2481
  }
2664
- var CONTENTS_WRITE_PROBE, detected, audit;
2482
+ var audit;
2665
2483
  var init_chant_next = __esm({
2666
2484
  "src/chant-next.ts"() {
2667
2485
  "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
2486
  }
2675
2487
  });
2676
2488
 
@@ -2820,18 +2632,18 @@ var init_note_control = __esm({
2820
2632
  });
2821
2633
 
2822
2634
  // src/init/workflow.ts
2823
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
2824
- import { dirname as dirname3, isAbsolute, join as join3 } from "node:path";
2635
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
2636
+ import { dirname as dirname2, isAbsolute, join as join2 } from "node:path";
2825
2637
  async function writeWorkflow(chant, input) {
2826
- const file = isAbsolute(input.output) ? input.output : join3(input.root, input.output);
2827
- const previous = existsSync3(file) ? readFileSync3(file, "utf8") : void 0;
2828
- mkdirSync(dirname3(file), { recursive: true });
2638
+ const file = isAbsolute(input.output) ? input.output : join2(input.root, input.output);
2639
+ const previous = existsSync2(file) ? readFileSync2(file, "utf8") : void 0;
2640
+ mkdirSync(dirname2(file), { recursive: true });
2829
2641
  const code = await chant.runChantInProcess([...input.argv, "--output", file], input.words);
2830
- if (code !== 0 || !existsSync3(file)) {
2642
+ if (code !== 0 || !existsSync2(file)) {
2831
2643
  if (previous !== void 0) writeFileSync(file, previous);
2832
2644
  return { code: code !== 0 ? code : 1, file, status: "failed", kept: [] };
2833
2645
  }
2834
- const generated = withNoteControl(readFileSync3(file, "utf8"), input.forge, input.noteControl);
2646
+ const generated = withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl);
2835
2647
  const { text, kept } = keepOwnJobs(previous, generated, input.forge);
2836
2648
  writeFileSync(file, text);
2837
2649
  return { code, file, status: text === previous ? "unchanged" : "written", kept };
@@ -2873,8 +2685,76 @@ var init_review_stacks2 = __esm({
2873
2685
  });
2874
2686
 
2875
2687
  // src/waves-workflow.ts
2876
- import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
2877
- import { dirname as dirname4, join as join4 } from "node:path";
2688
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
2689
+ import { dirname as dirname3, join as join3 } from "node:path";
2690
+ function wavesEngine(pipeline) {
2691
+ const o = chantPipelineOptions({ ...pipeline.image ? { image: pipeline.image } : {}, ...pipeline.engineSetup ? { engineSetup: pipeline.engineSetup } : {} }, false);
2692
+ return { ...o.image ? { image: o.image } : {}, ...o.beforeScript?.length ? { beforeScript: o.beforeScript } : {} };
2693
+ }
2694
+ function wavesSpecText(spec, engine2 = {}) {
2695
+ const options = { ...engine2.image ? { image: engine2.image } : {}, ...engine2.beforeScript?.length ? { beforeScript: engine2.beforeScript } : {} };
2696
+ return JSON.stringify(Object.keys(options).length ? { waves: spec, options } : spec, null, 2) + "\n";
2697
+ }
2698
+ function unwrapWavesSpec(doc) {
2699
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
2700
+ const inner = doc.waves;
2701
+ if (Array.isArray(inner)) return doc;
2702
+ return inner && typeof inner === "object" && Array.isArray(inner.waves) ? inner : doc;
2703
+ }
2704
+ function stepId(wave, target) {
2705
+ return `${wave}-${target}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2706
+ }
2707
+ function upOpFile(spec) {
2708
+ const s = spec ?? {};
2709
+ const op = typeof s.op === "string" && s.op !== "" ? s.op : OP_NAME;
2710
+ const taken = /* @__PURE__ */ new Set();
2711
+ const phases = [];
2712
+ for (const [i, w] of (s.waves ?? []).entries()) {
2713
+ const name = typeof w.name === "string" && w.name !== "" ? w.name : `wave-${i + 1}`;
2714
+ const steps = [];
2715
+ for (const run of w.runs ?? []) {
2716
+ const input = run.input ?? {};
2717
+ if (typeof input.member !== "string" || typeof input.dir !== "string") continue;
2718
+ let id = stepId(name, run.target);
2719
+ for (let n = 2; taken.has(id); n++) id = `${stepId(name, run.target)}-${n}`;
2720
+ taken.add(id);
2721
+ const opts = [
2722
+ `dir: ${JSON.stringify(input.dir)}`,
2723
+ ...typeof input.stack === "string" ? [`stack: ${JSON.stringify(input.stack)}`] : [],
2724
+ ...input.engine === "pulumi" || input.engine === "plunge" ? [`engine: ${JSON.stringify(input.engine)}`] : [],
2725
+ `id: ${JSON.stringify(id)}`
2726
+ ];
2727
+ steps.push(`pulumiPreviewStep(${JSON.stringify(input.member)}, { ${opts.join(", ")} })`);
2728
+ }
2729
+ if (steps.length > 0) phases.push(` phase(${JSON.stringify(name)}, [${steps.join(", ")}]),`);
2730
+ }
2731
+ return [
2732
+ `// 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.`,
2733
+ `// The waves workflow runs under this Op's name: each wave job previews and updates its stacks through the spec's`,
2734
+ `// pulumi-up step and records the runs under ${op}. chant's generator needs the Op to exist. Run by hand`,
2735
+ `// (npx chant run ${op}), it previews each stack in wave order and changes nothing.`,
2736
+ `import { Op, phase } from "@intentius/chant/op";`,
2737
+ `import { pulumiPreviewStep } from "@intentius/chant-lexicon-pulumi";`,
2738
+ "",
2739
+ "export default Op({",
2740
+ ` name: ${JSON.stringify(op)},`,
2741
+ ` overview: "The stacks ${PRODUCT}'s waves update on merge, previewed in wave order",`,
2742
+ ` labels: { Surface: "${PRODUCT}-up" },`,
2743
+ " phases: [",
2744
+ ...phases,
2745
+ " ],",
2746
+ "});",
2747
+ ""
2748
+ ].join("\n");
2749
+ }
2750
+ function writeUpOp(cwd, spec) {
2751
+ const path = join3(cwd, UP_OP_FILE);
2752
+ const text = upOpFile(spec);
2753
+ if (existsSync3(path) && readFileSync3(path, "utf8") === text) return false;
2754
+ mkdirSync2(dirname3(path), { recursive: true });
2755
+ writeFileSync2(path, text);
2756
+ return true;
2757
+ }
2878
2758
  function binLines(waves, bin) {
2879
2759
  const binArgs = bin.split(/\s+/).filter(Boolean);
2880
2760
  return waves.jobs.map((job) => {
@@ -2891,22 +2771,23 @@ function withBinLines(text, lines2) {
2891
2771
  });
2892
2772
  }
2893
2773
  function generatedFor(dir, specFile) {
2894
- if (!existsSync4(dir)) return [];
2895
- 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}`));
2774
+ if (!existsSync3(dir)) return [];
2775
+ return readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => join3(dir, f)).filter((p) => (readFileSync3(p, "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${specFile}`));
2896
2776
  }
2897
- async function writeWaveJobs(chant, forge, waves, words, table, io) {
2898
- const specPath = join4(io.cwd, waves.specFile);
2899
- const specText = JSON.stringify(waves.spec, null, 2) + "\n";
2900
- const before = existsSync4(specPath) ? readFileSync4(specPath, "utf8") : void 0;
2777
+ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}) {
2778
+ const specPath = join3(io.cwd, waves.specFile);
2779
+ const specText = wavesSpecText(waves.spec, engine2);
2780
+ const before = existsSync3(specPath) ? readFileSync3(specPath, "utf8") : void 0;
2901
2781
  if (before !== specText) {
2902
- mkdirSync2(dirname4(specPath), { recursive: true });
2782
+ mkdirSync2(dirname3(specPath), { recursive: true });
2903
2783
  writeFileSync2(specPath, specText);
2904
2784
  io.err(`${PRODUCT} ci: wrote ${waves.specFile} with plywood's op and gates; commit it with the workflow`);
2905
2785
  }
2786
+ 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
2787
  const outDir = WAVES_OUTPUT_DIR[forge];
2907
2788
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", waves.specFile, "--output", outDir], words);
2908
2789
  if (code !== table.done) return code;
2909
- const files = generatedFor(join4(io.cwd, outDir), waves.specFile);
2790
+ const files = generatedFor(join3(io.cwd, outDir), waves.specFile);
2910
2791
  if (files.length === 0) {
2911
2792
  io.err(`${PRODUCT} ci: chant wrote no waves workflow for ${waves.specFile} under ${outDir}`);
2912
2793
  return table.failed;
@@ -2914,7 +2795,7 @@ async function writeWaveJobs(chant, forge, waves, words, table, io) {
2914
2795
  const lines2 = hasChant4607(chant) ? void 0 : binLines(waves, words.bin);
2915
2796
  for (const file of files) {
2916
2797
  if (lines2) {
2917
- const text = readFileSync4(file, "utf8");
2798
+ const text = readFileSync3(file, "utf8");
2918
2799
  const next = withBinLines(text, lines2);
2919
2800
  if (next !== text) writeFileSync2(file, next);
2920
2801
  }
@@ -2925,23 +2806,25 @@ async function writeWaveJobs(chant, forge, waves, words, table, io) {
2925
2806
  }
2926
2807
  return table.done;
2927
2808
  }
2928
- var WAVES_OUTPUT_DIR;
2809
+ var WAVES_OUTPUT_DIR, UP_OP_FILE;
2929
2810
  var init_waves_workflow = __esm({
2930
2811
  "src/waves-workflow.ts"() {
2931
2812
  "use strict";
2932
2813
  init_chant_product();
2814
+ init_images();
2933
2815
  init_words();
2934
2816
  WAVES_OUTPUT_DIR = {
2935
2817
  github: ".github/workflows",
2936
2818
  forgejo: ".forgejo/workflows",
2937
2819
  gitlab: "."
2938
2820
  };
2821
+ UP_OP_FILE = "ops/pulumi-up.op.ts";
2939
2822
  }
2940
2823
  });
2941
2824
 
2942
2825
  // src/agent-jobs.ts
2943
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
2944
- import { dirname as dirname5, join as join5 } from "node:path";
2826
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
2827
+ import { dirname as dirname4, join as join4 } from "node:path";
2945
2828
  function hasChantAgent(chant) {
2946
2829
  return typeof chant?.CI_AGENT_FLOOR === "string";
2947
2830
  }
@@ -2987,9 +2870,9 @@ function agentOpSpec(spec, doc) {
2987
2870
  };
2988
2871
  }
2989
2872
  function writeIfChanged(cwd, file, text) {
2990
- const path = join5(cwd, file);
2991
- if (existsSync5(path) && readFileSync5(path, "utf8") === text) return false;
2992
- mkdirSync3(dirname5(path), { recursive: true });
2873
+ const path = join4(cwd, file);
2874
+ if (existsSync4(path) && readFileSync4(path, "utf8") === text) return false;
2875
+ mkdirSync3(dirname4(path), { recursive: true });
2993
2876
  writeFileSync3(path, text);
2994
2877
  return true;
2995
2878
  }
@@ -3016,9 +2899,9 @@ async function writeAgentJobs(chant, forge, env, doc, words, table, io) {
3016
2899
  const outDir = OP_OUTPUT_DIR[forge];
3017
2900
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", AGENT_SPEC_FILE, "--output", outDir], agentWords);
3018
2901
  if (code !== table.done) return code;
3019
- const dir = join5(io.cwd, outDir);
3020
- const files = existsSync5(dir) ? readdirSync3(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync5(join5(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${AGENT_SPEC_FILE}`)) : [];
3021
- for (const f of files) io.err(`${PRODUCT} ci: agent jobs at ${join5(outDir, f).replace(/^\.\//, "")}, answering /${PRODUCT} agent <ask>`);
2902
+ const dir = join4(io.cwd, outDir);
2903
+ const files = existsSync4(dir) ? readdirSync2(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync4(join4(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${AGENT_SPEC_FILE}`)) : [];
2904
+ for (const f of files) io.err(`${PRODUCT} ci: agent jobs at ${join4(outDir, f).replace(/^\.\//, "")}, answering /${PRODUCT} agent <ask>`);
3022
2905
  io.err(`${PRODUCT} ci: the agent job reads the model's key from ${a.keyEnv}; set it from a secret${a.maxRuns === void 0 ? ", and set agent.max_runs to limit how often it runs" : ""}`);
3023
2906
  if (forge === "github" && a.tokenEnv === void 0) io.err(`${PRODUCT} ci: the push job pushes with the job token, which starts no workflow on GitHub, so the agent's commit gets no preview; set agent.token_env to a secret holding a user's or app's token`);
3024
2907
  else if (a.tokenEnv !== void 0) io.err(`${PRODUCT} ci: the push job pushes with ${a.tokenEnv}; set it from a secret`);
@@ -3116,7 +2999,7 @@ __export(jobs_exports, {
3116
2999
  withJobSettings: () => withJobSettings
3117
3000
  });
3118
3001
  function hasChant4625() {
3119
- detected2 ??= (async () => {
3002
+ detected ??= (async () => {
3120
3003
  try {
3121
3004
  const m = await import("@intentius/chant/parallelism");
3122
3005
  return typeof m.mapLimited === "function" && typeof m.parallelismProblem === "function";
@@ -3124,10 +3007,10 @@ function hasChant4625() {
3124
3007
  return false;
3125
3008
  }
3126
3009
  })();
3127
- return detected2;
3010
+ return detected;
3128
3011
  }
3129
- function setChant4625ForTests(has2) {
3130
- detected2 = has2 === "reset" ? void 0 : Promise.resolve(has2);
3012
+ function setChant4625ForTests(has) {
3013
+ detected = has === "reset" ? void 0 : Promise.resolve(has);
3131
3014
  }
3132
3015
  function secretReference(forge, secret) {
3133
3016
  return forge === "gitlab" ? `$${secret}` : `\${{ secrets.${secret} }}`;
@@ -3228,7 +3111,7 @@ async function prJobsFor(pipeline) {
3228
3111
  const { flags, notes } = prLoopJobFlags(pipeline.jobs, pipeline.runner);
3229
3112
  return { options: prLoopJobOptions(pipeline.jobs, pipeline.runner), flags, notes };
3230
3113
  }
3231
- var detected2, JOBS_NEED_CHANT, GROUP_NAME;
3114
+ var detected, JOBS_NEED_CHANT, GROUP_NAME;
3232
3115
  var init_jobs2 = __esm({
3233
3116
  "src/jobs.ts"() {
3234
3117
  "use strict";
@@ -3277,8 +3160,8 @@ var init_refusals = __esm({
3277
3160
  });
3278
3161
 
3279
3162
  // src/up.ts
3280
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
3281
- import { join as join6 } from "node:path";
3163
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
3164
+ import { join as join5 } from "node:path";
3282
3165
  function approveTarget(gate, opts) {
3283
3166
  if (opts.op) return { op: opts.op, gate };
3284
3167
  if (PR_OP.test(gate)) return { op: gate, gate: PR_GATE };
@@ -3359,10 +3242,10 @@ async function approve(args, json, table, io) {
3359
3242
  });
3360
3243
  }
3361
3244
  function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3362
- const path = join6(cwd, file);
3363
- if (!existsSync6(path)) return void 0;
3245
+ const path = join5(cwd, file);
3246
+ if (!existsSync5(path)) return void 0;
3364
3247
  try {
3365
- const spec = JSON.parse(readFileSync6(path, "utf8"));
3248
+ const spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3366
3249
  return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3367
3250
  } catch {
3368
3251
  return void 0;
@@ -3446,11 +3329,11 @@ async function override(args, json, table, io) {
3446
3329
  return withChant(`${PRODUCT} override`, table, io, async (chant) => explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err));
3447
3330
  }
3448
3331
  function readUpSpec(cwd, file = WAVES_SPEC_FILE) {
3449
- const path = join6(cwd, file);
3450
- if (!existsSync6(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3332
+ const path = join5(cwd, file);
3333
+ if (!existsSync5(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3451
3334
  let spec;
3452
3335
  try {
3453
- spec = JSON.parse(readFileSync6(path, "utf8"));
3336
+ spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3454
3337
  } catch (e) {
3455
3338
  return { error: `${file}: ${e.message}` };
3456
3339
  }
@@ -3549,20 +3432,15 @@ async function upBeforeMerge(number, args, json, table, io) {
3549
3432
  "--forge",
3550
3433
  forge,
3551
3434
  ...requiresArgv(chant, config2.doc),
3552
- ...await mergeArgv(config2.doc, io),
3435
+ ...mergeArgv(config2.doc),
3553
3436
  ...json ? ["--json"] : []
3554
3437
  ];
3555
3438
  return explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err, { pr: number });
3556
3439
  });
3557
3440
  }
3558
- async function mergeArgv(doc, io) {
3441
+ function mergeArgv(doc) {
3559
3442
  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);
3443
+ return merge ? applyMergeArgv(merge) : [];
3566
3444
  }
3567
3445
  async function resume(args, json, table, io) {
3568
3446
  const op = takeFlag(args, "--op");
@@ -3597,6 +3475,7 @@ var init_up = __esm({
3597
3475
  init_review_stacks2();
3598
3476
  init_stages();
3599
3477
  init_words();
3478
+ init_waves_workflow();
3600
3479
  OVERRIDE_OP = "policy-override";
3601
3480
  WAVES_SPEC_FILE = `.chant/op-waves/${OP_NAME}.spec.json`;
3602
3481
  UP_STEP_KIND = "pulumi-up";
@@ -3609,9 +3488,9 @@ var init_up = __esm({
3609
3488
  // src/drift.ts
3610
3489
  import { createHash as createHash2 } from "node:crypto";
3611
3490
  import { resolveTargetCloudIdentity } from "@intentius/chant/ci-identity";
3612
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync4, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3491
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3613
3492
  import { tmpdir } from "node:os";
3614
- import { dirname as dirname6, join as join7 } from "node:path";
3493
+ import { dirname as dirname5, join as join6 } from "node:path";
3615
3494
  function driftRows(doc) {
3616
3495
  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);
3617
3496
  }
@@ -3670,7 +3549,7 @@ async function drift2(args, json, table, io) {
3670
3549
  const respond = respondFlag.value ?? driftResponseOf(doc);
3671
3550
  const forge = forgeFlag.value;
3672
3551
  return withChant(`${PRODUCT} drift`, table, io, async (chant) => {
3673
- const dir = mkdtempSync(join7(tmpdir(), "plywood-drift-"));
3552
+ const dir = mkdtempSync(join6(tmpdir(), "plywood-drift-"));
3674
3553
  const results = [];
3675
3554
  try {
3676
3555
  const attribute = await attributionFor(doc, io);
@@ -3750,12 +3629,12 @@ async function driftNotice(chant, doc, results, environment, io) {
3750
3629
  });
3751
3630
  }
3752
3631
  async function readStack(chant, stack, dir, opts) {
3753
- const file = join7(dir, `${driftOpName(stack)}.json`);
3632
+ const file = join6(dir, `${driftOpName(stack)}.json`);
3754
3633
  const argv = ["workspace", "drift", "--member", stack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
3755
3634
  const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
3756
3635
  let doc;
3757
3636
  try {
3758
- if (existsSync7(file)) doc = JSON.parse(readFileSync7(file, "utf8"));
3637
+ if (existsSync6(file)) doc = JSON.parse(readFileSync6(file, "utf8"));
3759
3638
  } catch (e) {
3760
3639
  return { stack, ok: false, drifted: 0, rows: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3761
3640
  }
@@ -3769,12 +3648,7 @@ async function readStack(chant, stack, dir, opts) {
3769
3648
  async function attributionFor(doc, io) {
3770
3649
  const logs = driftSettingsOf(doc).auditLogs;
3771
3650
  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 };
3651
+ return { audit: await chantAudit(), logs };
3778
3652
  }
3779
3653
  function auditLogFor(logs, stack) {
3780
3654
  return Object.entries(logs).find(([glob]) => glob === "*" || glob === "**" || globMatches(glob, stack))?.[1];
@@ -3793,7 +3667,7 @@ async function attributeStack(a, stack, doc) {
3793
3667
  }
3794
3668
  function specHasUpdate(cwd, op) {
3795
3669
  try {
3796
- const spec = JSON.parse(readFileSync7(join7(cwd, DRIFT_SPEC_FILE), "utf8"));
3670
+ const spec = JSON.parse(readFileSync6(join6(cwd, DRIFT_SPEC_FILE), "utf8"));
3797
3671
  return (spec.ops ?? []).some((o) => o.name === op && o.update !== void 0);
3798
3672
  } catch {
3799
3673
  return false;
@@ -3869,9 +3743,9 @@ function withNotifyVariables(fields, notify2) {
3869
3743
  return { ...fields, variables: { ...notify2, ...fields.variables ?? {} } };
3870
3744
  }
3871
3745
  function writeIfChanged2(cwd, file, text) {
3872
- const path = join7(cwd, file);
3873
- if (existsSync7(path) && readFileSync7(path, "utf8") === text) return false;
3874
- mkdirSync4(dirname6(path), { recursive: true });
3746
+ const path = join6(cwd, file);
3747
+ if (existsSync6(path) && readFileSync6(path, "utf8") === text) return false;
3748
+ mkdirSync4(dirname5(path), { recursive: true });
3875
3749
  writeFileSync4(path, text);
3876
3750
  return true;
3877
3751
  }
@@ -3882,19 +3756,18 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3882
3756
  io.err(`${PRODUCT} ci: drift.schedule is set and plywood.yml lists no stacks, so no drift job was written`);
3883
3757
  return table.done;
3884
3758
  }
3885
- const next = await chantNext();
3886
3759
  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}`);
3760
+ 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
3761
  const outDir = OP_OUTPUT_DIR2[forge];
3889
3762
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", DRIFT_SPEC_FILE, "--output", outDir], words);
3890
3763
  if (code !== table.done) return code;
3891
- const dir = join7(io.cwd, outDir);
3892
- const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync7(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
3893
- for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join7(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
3764
+ const dir = join6(io.cwd, outDir);
3765
+ const files = existsSync6(dir) ? readdirSync3(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync6(join6(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
3766
+ for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join6(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
3894
3767
  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
3768
  return table.done;
3896
3769
  }
3897
- var DRIFT_OP_FILE, DRIFT_SPEC_FILE, SNAPSHOTS_REF, SNAPSHOTS_FETCH, DRIFT_STATUSES, forgeOverride, notifyFetch, ATTRIBUTION_NEEDS_CHANT, OP_OUTPUT_DIR2;
3770
+ var DRIFT_OP_FILE, DRIFT_SPEC_FILE, SNAPSHOTS_REF, SNAPSHOTS_FETCH, DRIFT_STATUSES, forgeOverride, notifyFetch, OP_OUTPUT_DIR2;
3898
3771
  var init_drift2 = __esm({
3899
3772
  "src/drift.ts"() {
3900
3773
  "use strict";
@@ -3918,7 +3791,6 @@ var init_drift2 = __esm({
3918
3791
  SNAPSHOTS_REF = "refs/chant/snapshots";
3919
3792
  SNAPSHOTS_FETCH = `git fetch --no-tags origin '+${SNAPSHOTS_REF}:${SNAPSHOTS_REF}' || true`;
3920
3793
  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
3794
  OP_OUTPUT_DIR2 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
3923
3795
  }
3924
3796
  });
@@ -3965,8 +3837,8 @@ var init_tips = __esm({
3965
3837
  });
3966
3838
 
3967
3839
  // src/agents.ts
3968
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
3969
- import { dirname as dirname7, join as join8 } from "node:path";
3840
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
3841
+ import { dirname as dirname6, join as join7 } from "node:path";
3970
3842
  function plywoodMcpTools(ctx) {
3971
3843
  const none = { type: "object", properties: {} };
3972
3844
  const doc = () => {
@@ -3976,7 +3848,7 @@ function plywoodMcpTools(ctx) {
3976
3848
  return [
3977
3849
  {
3978
3850
  name: "stacks",
3979
- description: "The stacks plywood.yml lists, each with its directory, gate and backend, and the waves they update in.",
3851
+ description: "The stacks plywood.yml lists, each with its directory, gate, backend and actions, and the waves they update in.",
3980
3852
  inputSchema: none,
3981
3853
  readOnly: true,
3982
3854
  handler: async () => {
@@ -4010,24 +3882,51 @@ function plywoodMcpTools(ctx) {
4010
3882
  }
4011
3883
  ];
4012
3884
  }
4013
- function plywoodMcpHook(reportUrl) {
3885
+ function plywoodProposeTools() {
3886
+ return [
3887
+ {
3888
+ name: TEMPLATE_REQUEST_WORDS.tool,
3889
+ description: `Open a pull request for an app from a template, reviewed and gated like a person's. ${TEMPLATE_REQUEST_WORDS.refusal}`,
3890
+ inputSchema: {
3891
+ type: "object",
3892
+ properties: {
3893
+ template: { type: "string", description: "The template's name in the catalog." },
3894
+ version: { type: "string", description: "The template's version; the newest when left out." },
3895
+ name: { type: "string", description: "The app's name, which names its directory and the request branch." },
3896
+ app: { type: "string", description: "The app an add-on template joins." },
3897
+ inputs: { type: "object", description: "The template's inputs, by name." }
3898
+ },
3899
+ required: ["template", "name", "inputs"]
3900
+ },
3901
+ forgeWrite: true,
3902
+ handler: async () => {
3903
+ throw new Error(TEMPLATE_REQUEST_WORDS.refusal);
3904
+ }
3905
+ }
3906
+ ];
3907
+ }
3908
+ function plywoodMcpHook(reportUrl, propose = false) {
4014
3909
  return {
4015
3910
  product: PRODUCT,
4016
- tools: (ctx) => plywoodMcpTools(ctx),
3911
+ // A read-only server without the tier refuses to start with a forgeWrite tool, so they go only where chant says it serves them.
3912
+ tools: (ctx) => [...plywoodMcpTools(ctx), ...propose && ctx.propose === true ? plywoodProposeTools() : []],
4017
3913
  readOnly: true,
3914
+ ...propose ? { propose: true } : {},
4018
3915
  ...reportUrl ? { reportUrl } : {},
4019
- instructions: `${PRODUCT} (${TAGLINE}) serves read tools only. To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`
3916
+ instructions: propose ? `${PRODUCT} (${TAGLINE}) serves read tools and ${TEMPLATE_REQUEST_WORDS.tool}, which may open a pull request and nothing else. Pulumi templates are not available yet, so ${TEMPLATE_REQUEST_WORDS.tool} refuses every call. ${READ_INSTRUCTIONS}` : `${PRODUCT} (${TAGLINE}) serves read tools only. ${READ_INSTRUCTIONS}`
4020
3917
  };
4021
3918
  }
4022
3919
  async function mcp(args, _json, table, io) {
4023
- const report = takeFlag(args, "--report-url");
3920
+ const propose = args.includes("--propose");
3921
+ const report = takeFlag(args.filter((a) => a !== "--propose"), "--report-url");
4024
3922
  if (report.missing) return usage("--report-url takes a URL", table, io);
4025
3923
  if (report.rest.length) return usage(`mcp does not take ${report.rest.join(" ")}`, table, io);
4026
3924
  return withChant(`${PRODUCT} mcp`, table, io, (chant) => {
4027
3925
  if (typeof chant.PRODUCT_MCP_HOOK_FLOOR !== "string") {
4028
3926
  io.err(`${PRODUCT} mcp: this chant has no product tool hook (chant#4555), so the server serves chant's read tools and none of ${PRODUCT}'s`);
4029
3927
  }
4030
- return chant.runChantInProcess(["serve", "mcp", "--read-only"], PRODUCT_WORDS, { mcp: plywoodMcpHook(report.value) });
3928
+ const argv = ["serve", "mcp", "--read-only", ...propose ? ["--propose"] : []];
3929
+ return chant.runChantInProcess(argv, PRODUCT_WORDS, { mcp: plywoodMcpHook(report.value, propose) });
4031
3930
  });
4032
3931
  }
4033
3932
  function reviewOpFile(doc) {
@@ -4079,9 +3978,9 @@ function reviewOpSpec(doc) {
4079
3978
  };
4080
3979
  }
4081
3980
  function writeIfChanged3(cwd, file, text) {
4082
- const path = join8(cwd, file);
4083
- if (existsSync8(path) && readFileSync8(path, "utf8") === text) return false;
4084
- mkdirSync5(dirname7(path), { recursive: true });
3981
+ const path = join7(cwd, file);
3982
+ if (existsSync7(path) && readFileSync7(path, "utf8") === text) return false;
3983
+ mkdirSync5(dirname6(path), { recursive: true });
4085
3984
  writeFileSync5(path, text);
4086
3985
  return true;
4087
3986
  }
@@ -4092,15 +3991,15 @@ async function writeReviewJobs(chant, forge, doc, words, table, io) {
4092
3991
  const outDir = OP_OUTPUT_DIR3[forge];
4093
3992
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
4094
3993
  if (code !== table.done) return code;
4095
- const dir = join8(io.cwd, outDir);
4096
- const files = existsSync8(dir) ? readdirSync5(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync8(join8(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
4097
- for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join8(outDir, f).replace(/^\.\//, "")}`);
3994
+ const dir = join7(io.cwd, outDir);
3995
+ const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync7(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
3996
+ for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join7(outDir, f).replace(/^\.\//, "")}`);
4098
3997
  const r = reviewOf(doc);
4099
3998
  io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
4100
3999
  if (forge === "gitlab") io.err(`${PRODUCT} ci: on GitLab the note job runs on the default branch with CHANT_REVIEW_NOTES=1 from a pipeline schedule, and posts with a protected CHANT_FORGE_TOKEN`);
4101
4000
  return table.done;
4102
4001
  }
4103
- var PERSON_VERBS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4002
+ var PERSON_VERBS, READ_INSTRUCTIONS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4104
4003
  var init_agents2 = __esm({
4105
4004
  "src/agents.ts"() {
4106
4005
  "use strict";
@@ -4114,6 +4013,7 @@ var init_agents2 = __esm({
4114
4013
  init_tips();
4115
4014
  init_words();
4116
4015
  PERSON_VERBS = ["up", "approve", "override"];
4016
+ READ_INSTRUCTIONS = `To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`;
4117
4017
  REVIEW_OP = "plywood-review";
4118
4018
  REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4119
4019
  REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
@@ -4123,6 +4023,227 @@ var init_agents2 = __esm({
4123
4023
  }
4124
4024
  });
4125
4025
 
4026
+ // src/init/detect.ts
4027
+ import { existsSync as existsSync8, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync } from "node:fs";
4028
+ import { basename, dirname as dirname7, join as join8, relative, sep } from "node:path";
4029
+ import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
4030
+ function repoRoot(dir) {
4031
+ let cur = dir;
4032
+ for (let i = 0; i < 64; i++) {
4033
+ if (existsSync8(join8(cur, ".git"))) return cur;
4034
+ const parent = dirname7(cur);
4035
+ if (parent === cur) return dir;
4036
+ cur = parent;
4037
+ }
4038
+ return dir;
4039
+ }
4040
+ function posixRel(root, path) {
4041
+ const rel = relative(root, path);
4042
+ return rel === "" ? "." : rel.split(sep).join("/");
4043
+ }
4044
+ function readYamlFile(file) {
4045
+ try {
4046
+ const doc = parseYAML2(readFileSync8(file, "utf8"));
4047
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
4048
+ } catch {
4049
+ return void 0;
4050
+ }
4051
+ }
4052
+ function findPulumiProjects(root) {
4053
+ const out = [];
4054
+ const walk = (dir, depth) => {
4055
+ let entries;
4056
+ try {
4057
+ entries = readdirSync5(dir).sort();
4058
+ } catch {
4059
+ return;
4060
+ }
4061
+ const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
4062
+ if (file) {
4063
+ const project = readProject(root, dir, join8(dir, file));
4064
+ if (project) out.push(project);
4065
+ }
4066
+ if (depth >= MAX_DEPTH) return;
4067
+ for (const name of entries) {
4068
+ if (SKIP.has(name) || name.startsWith(".")) continue;
4069
+ const path = join8(dir, name);
4070
+ try {
4071
+ if (statSync(path).isDirectory()) walk(path, depth + 1);
4072
+ } catch {
4073
+ }
4074
+ }
4075
+ };
4076
+ walk(root, 0);
4077
+ return out;
4078
+ }
4079
+ function readProject(root, dir, file) {
4080
+ const doc = readYamlFile(file);
4081
+ if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
4082
+ const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
4083
+ const backend = doc.backend;
4084
+ const configDir = typeof doc.stackConfigDir === "string" ? join8(dir, doc.stackConfigDir) : dir;
4085
+ return {
4086
+ name: doc.name,
4087
+ dir: posixRel(root, dir),
4088
+ ...typeof runtime === "string" ? { runtime } : {},
4089
+ ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
4090
+ stacks: stackNames(configDir)
4091
+ };
4092
+ }
4093
+ function stackNames(configDir) {
4094
+ let entries;
4095
+ try {
4096
+ entries = readdirSync5(configDir);
4097
+ } catch {
4098
+ return [];
4099
+ }
4100
+ const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
4101
+ return [...new Set(names)].sort(byEnvironment);
4102
+ }
4103
+ function environmentOf(stack) {
4104
+ const last = stack.split("/").pop().toLowerCase();
4105
+ const head = last.split(/[-_.]/)[0];
4106
+ return ENV_ORDER.includes(head) ? head : last;
4107
+ }
4108
+ function rank(stack) {
4109
+ const env = environmentOf(stack);
4110
+ const at = ENV_ORDER.indexOf(env);
4111
+ return at >= 0 ? at : PROD_RANK - 0.5;
4112
+ }
4113
+ function byEnvironment(a, b) {
4114
+ return rank(a) - rank(b) || a.localeCompare(b);
4115
+ }
4116
+ function stacksOf2(projects) {
4117
+ const names = /* @__PURE__ */ new Map();
4118
+ for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
4119
+ const out = [];
4120
+ for (const p of projects) {
4121
+ const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
4122
+ for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
4123
+ }
4124
+ return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
4125
+ }
4126
+ function slug(s) {
4127
+ const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4128
+ return out === "" ? "root" : out;
4129
+ }
4130
+ function originUrl(root) {
4131
+ const config2 = gitFile(root, "config");
4132
+ if (!config2) return void 0;
4133
+ let inOrigin = false;
4134
+ for (const raw of config2.split(/\r?\n/)) {
4135
+ const line = raw.trim();
4136
+ if (line.startsWith("[")) {
4137
+ inOrigin = /^\[remote\s+"origin"\]$/.test(line);
4138
+ continue;
4139
+ }
4140
+ const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
4141
+ if (m) return m[1].trim();
4142
+ }
4143
+ return void 0;
4144
+ }
4145
+ function gitFile(root, name) {
4146
+ const dotGit = join8(root, ".git");
4147
+ let gitDir = dotGit;
4148
+ try {
4149
+ if (statSync(dotGit).isFile()) {
4150
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync8(dotGit, "utf8"));
4151
+ if (!m) return void 0;
4152
+ gitDir = join8(root, m[1].trim());
4153
+ const common = join8(gitDir, "commondir");
4154
+ if (existsSync8(common) && !existsSync8(join8(gitDir, name))) gitDir = join8(gitDir, readFileSync8(common, "utf8").trim());
4155
+ }
4156
+ const path = join8(gitDir, name);
4157
+ return existsSync8(path) ? readFileSync8(path, "utf8") : void 0;
4158
+ } catch {
4159
+ return void 0;
4160
+ }
4161
+ }
4162
+ function remoteHost(url) {
4163
+ const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
4164
+ if (scp) return scp[1].toLowerCase();
4165
+ try {
4166
+ return new URL(url).hostname.toLowerCase() || void 0;
4167
+ } catch {
4168
+ return void 0;
4169
+ }
4170
+ }
4171
+ function forgeOfRemote(url) {
4172
+ const host = remoteHost(url);
4173
+ if (!host) return void 0;
4174
+ if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
4175
+ if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
4176
+ if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
4177
+ if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
4178
+ if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
4179
+ return void 0;
4180
+ }
4181
+ function detectForge(root) {
4182
+ const url = originUrl(root);
4183
+ const byRemote = url ? forgeOfRemote(url) : void 0;
4184
+ if (byRemote) return byRemote;
4185
+ if (existsSync8(join8(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
4186
+ if (existsSync8(join8(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
4187
+ if (existsSync8(join8(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
4188
+ return void 0;
4189
+ }
4190
+ function defaultBranch(root) {
4191
+ const head = gitFile(root, "refs/remotes/origin/HEAD");
4192
+ const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
4193
+ if (remote) return remote.trim();
4194
+ const local = gitFile(root, "HEAD");
4195
+ const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
4196
+ return branch ? branch.trim() : "main";
4197
+ }
4198
+ function findPulumiActions(root) {
4199
+ const out = [];
4200
+ for (const d of WORKFLOW_DIRS) {
4201
+ let names;
4202
+ try {
4203
+ names = readdirSync5(join8(root, d)).sort();
4204
+ } catch {
4205
+ continue;
4206
+ }
4207
+ for (const n of names) {
4208
+ if (!/\.ya?ml$/.test(n)) continue;
4209
+ try {
4210
+ if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync8(join8(root, d, n), "utf8"))) out.push(`${d}/${n}`);
4211
+ } catch {
4212
+ }
4213
+ }
4214
+ }
4215
+ return out;
4216
+ }
4217
+ function detect(cwd) {
4218
+ const root = repoRoot(cwd);
4219
+ const projects = findPulumiProjects(root);
4220
+ const forge = detectForge(root);
4221
+ const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync8(join8(root, n)));
4222
+ return {
4223
+ root,
4224
+ projects,
4225
+ stacks: stacksOf2(projects),
4226
+ ...forge ? { forge } : {},
4227
+ defaultBranch: defaultBranch(root),
4228
+ pulumiActions: findPulumiActions(root),
4229
+ ...preCommit ? { preCommit } : {}
4230
+ };
4231
+ }
4232
+ function repoName(root) {
4233
+ return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
4234
+ }
4235
+ var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
4236
+ var init_detect = __esm({
4237
+ "src/init/detect.ts"() {
4238
+ "use strict";
4239
+ SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
4240
+ MAX_DEPTH = 8;
4241
+ ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
4242
+ PROD_RANK = ENV_ORDER.indexOf("prod");
4243
+ WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
4244
+ }
4245
+ });
4246
+
4126
4247
  // src/policy-presets.ts
4127
4248
  import { readFileSync as readFileSync9 } from "node:fs";
4128
4249
  function providersRego(allowed, namespace = "main") {
@@ -4325,7 +4446,7 @@ function adoptLines(previews, chant) {
4325
4446
  }
4326
4447
  const count = `${v.resources.length} resource${v.resources.length === 1 ? "" : "s"}`;
4327
4448
  if (v.status === "refused") {
4328
- out.push(` ${p.key}: plunge refuses to adopt it:`);
4449
+ out.push(` ${p.key}: plunge refuses to adopt it, so the adopt Op leaves it out:`);
4329
4450
  for (const r of v.refusals) out.push(` ${r.code}: ${r.reason}${r.urn ? ` (${r.urn})` : ""}`);
4330
4451
  } else if (v.status === "already-adopted") {
4331
4452
  out.push(` ${p.key}: already on plunge's records (${count})`);
@@ -4338,7 +4459,10 @@ function adoptLines(previews, chant) {
4338
4459
  return out;
4339
4460
  }
4340
4461
  function stacksToAdopt(stacks2, previews) {
4341
- return stacks2.filter((s) => previews.find((p) => p.key === s.key)?.view?.status !== "already-adopted");
4462
+ return stacks2.filter((s) => {
4463
+ const status = previews.find((p) => p.key === s.key)?.view?.status;
4464
+ return status !== "already-adopted" && status !== "refused";
4465
+ });
4342
4466
  }
4343
4467
  function adoptGate(member, stack, single) {
4344
4468
  return single ? `approve-${ADOPT_OP}` : `approve-${ADOPT_OP}-${member}-${stack}`;
@@ -4401,9 +4525,9 @@ function plungeUrlOf(url) {
4401
4525
  const scheme = url.split("://")[0];
4402
4526
  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
4527
  }
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 };
4528
+ function plungeStacks(detected2, env) {
4529
+ return detected2.stacks.map((s) => {
4530
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
4407
4531
  const b = backendOf(project, {}, env);
4408
4532
  const to = plungeUrlOf(b.url);
4409
4533
  return {
@@ -4534,6 +4658,7 @@ function stepInput(s) {
4534
4658
  stack: s.stack,
4535
4659
  member: s.project,
4536
4660
  ...s.engine ? { engine: s.engine } : {},
4661
+ ...s.backend ? { backend: s.backend } : {},
4537
4662
  ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
4538
4663
  ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
4539
4664
  ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
@@ -4774,9 +4899,9 @@ function repoJoin(...parts) {
4774
4899
  const out = posix2.normalize(parts.filter((p) => p !== "" && p !== ".").join("/") || ".").replace(/^\.\//, "").replace(/\/+$/, "");
4775
4900
  return out === "" ? "." : out;
4776
4901
  }
4777
- function projectAt(detected3, dir) {
4902
+ function projectAt(detected2, dir) {
4778
4903
  const want = repoJoin(dir);
4779
- return detected3.projects.find((p) => repoJoin(p.dir) === want);
4904
+ return detected2.projects.find((p) => repoJoin(p.dir) === want);
4780
4905
  }
4781
4906
  function stackEntry(keys, key) {
4782
4907
  const stacks2 = isObject(keys.stacks) ? keys.stacks : keys.stacks = {};
@@ -4819,10 +4944,10 @@ var init_types2 = __esm({
4819
4944
  // src/import/deployments.ts
4820
4945
  import { readdirSync as readdirSync6 } from "node:fs";
4821
4946
  import { basename as basename2, dirname as dirname8, join as join11 } from "node:path";
4822
- function deploymentFiles(root, detected3, named) {
4947
+ function deploymentFiles(root, detected2, named) {
4823
4948
  if (named.length) return [...named];
4824
4949
  const out = [];
4825
- for (const p of detected3.projects) {
4950
+ for (const p of detected2.projects) {
4826
4951
  let names;
4827
4952
  try {
4828
4953
  names = readdirSync6(join11(root, p.dir)).sort();
@@ -4852,7 +4977,7 @@ var init_deployments = __esm({
4852
4977
  "gitHub.deployPullRequest": "set apply.when: pull-request and update with /plywood up",
4853
4978
  "gitHub.repository": "plywood runs in the repository it is set up in"
4854
4979
  };
4855
- importDeployments = (root, detected3, named) => {
4980
+ importDeployments = (root, detected2, named) => {
4856
4981
  let keys = {};
4857
4982
  const unsupported = [];
4858
4983
  const notes = [];
@@ -4860,7 +4985,7 @@ var init_deployments = __esm({
4860
4985
  const not = (file, setting, why) => {
4861
4986
  unsupported.push({ file, setting, why: why ?? NOT_CARRIED[setting] ?? "plywood has no setting for it" });
4862
4987
  };
4863
- for (const file of deploymentFiles(root, detected3, named)) {
4988
+ for (const file of deploymentFiles(root, detected2, named)) {
4864
4989
  const m = SETTINGS_FILE.exec(basename2(file));
4865
4990
  if (!m) {
4866
4991
  not(file, "(file)", "names no stack: call it Pulumi.<stack>.deploy.yaml (or .json) beside the project's Pulumi.yaml");
@@ -4879,7 +5004,7 @@ var init_deployments = __esm({
4879
5004
  const fileDir = repoJoin(posixRel(root, dirname8(join11(root, file))));
4880
5005
  const source = isObject(settings.sourceContext) && isObject(settings.sourceContext.git) ? settings.sourceContext.git : {};
4881
5006
  const dir = typeof source.repoDir === "string" ? repoJoin(source.repoDir) : fileDir;
4882
- const project = projectAt(detected3, dir)?.name ?? projectAt(detected3, fileDir)?.name;
5007
+ const project = projectAt(detected2, dir)?.name ?? projectAt(detected2, fileDir)?.name;
4883
5008
  if (!project) {
4884
5009
  not(file, "sourceContext.git.repoDir", `${dir} holds no Pulumi.yaml`);
4885
5010
  continue;
@@ -4934,9 +5059,9 @@ var init_deployments = __esm({
4934
5059
  });
4935
5060
 
4936
5061
  // src/import/env0.ts
4937
- function stackFor(detected3, names) {
5062
+ function stackFor(detected2, names) {
4938
5063
  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);
5064
+ 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
5065
  if (hits.length === 1) return hits[0].key;
4941
5066
  }
4942
5067
  return void 0;
@@ -4949,7 +5074,7 @@ var init_env0 = __esm({
4949
5074
  init_types2();
4950
5075
  ENV0_WORKFLOW_FILES = ["env0.workflow.yml", "env0.workflow.yaml"];
4951
5076
  ENV0_FLOW_FILES = ["env0.yml", "env0.yaml"];
4952
- importEnv0 = (root, detected3, named) => {
5077
+ importEnv0 = (root, detected2, named) => {
4953
5078
  const keys = {};
4954
5079
  const unsupported = [];
4955
5080
  const notes = [];
@@ -4967,7 +5092,7 @@ var init_env0 = __esm({
4967
5092
  for (const [id, raw] of Object.entries(envs)) {
4968
5093
  const e = isObject(raw) ? raw : {};
4969
5094
  const names = [id, ...typeof e.name === "string" ? [e.name] : [], ...typeof e.templateName === "string" ? [e.templateName] : []];
4970
- const key = stackFor(detected3, names);
5095
+ const key = stackFor(detected2, names);
4971
5096
  if (key) stackOf.set(id, key);
4972
5097
  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
5098
  for (const k of Object.keys(e).filter((x) => !["name", "templateName", "needs"].includes(x))) {
@@ -5061,7 +5186,7 @@ var init_pulumi_actions = __esm({
5061
5186
  plan: "plywood's update applies the preview its approval names, through chant"
5062
5187
  };
5063
5188
  PULUMI_CLOUD = /^https:\/\/(?:app|api)\.pulumi\.com\/?$/;
5064
- importPulumiActions = (root, detected3, named) => {
5189
+ importPulumiActions = (root, detected2, named) => {
5065
5190
  let keys = {};
5066
5191
  const unsupported = [];
5067
5192
  const notes = [];
@@ -5097,7 +5222,7 @@ var init_pulumi_actions = __esm({
5097
5222
  const dir = repoJoin(typeof w["work-dir"] === "string" ? w["work-dir"] : ".");
5098
5223
  const parts = stackName.split("/");
5099
5224
  const stack = parts[parts.length - 1];
5100
- const project = projectAt(detected3, dir)?.name ?? (parts.length === 3 ? parts[1] : void 0);
5225
+ const project = projectAt(detected2, dir)?.name ?? (parts.length === 3 ? parts[1] : void 0);
5101
5226
  if (!project) {
5102
5227
  unsupported.push({ file, setting: `${where}.with.stack-name`, why: `${dir} holds no Pulumi.yaml, so ${stackName} names no project` });
5103
5228
  return;
@@ -5154,7 +5279,7 @@ var init_spacelift = __esm({
5154
5279
  init_types2();
5155
5280
  SPACELIFT_FILES = [".spacelift/config.yml", ".spacelift/config.yaml"];
5156
5281
  CARRIED2 = ["project_root", "runner_image"];
5157
- importSpacelift = (root, detected3, named) => {
5282
+ importSpacelift = (root, detected2, named) => {
5158
5283
  const keys = {};
5159
5284
  const unsupported = [];
5160
5285
  const notes = [];
@@ -5181,7 +5306,7 @@ var init_spacelift = __esm({
5181
5306
  const own = isObject(raw) ? raw : {};
5182
5307
  const s = { ...defaults, ...own };
5183
5308
  const dir = repoJoin(typeof s.project_root === "string" ? s.project_root : ".");
5184
- const project = projectAt(detected3, dir);
5309
+ const project = projectAt(detected2, dir);
5185
5310
  if (!project) {
5186
5311
  unsupported.push({ file, setting: `stacks.${id}.project_root`, why: `${dir} holds no Pulumi.yaml` });
5187
5312
  continue;
@@ -5230,9 +5355,9 @@ function parseFrom(value) {
5230
5355
  if (!source) return { error: `--from ${value} is not one of ${IMPORT_SOURCES.join(", ")}, and its path names none; give <source>=${value}` };
5231
5356
  return { source, files: [file] };
5232
5357
  }
5233
- function runImport(root, detected3, from) {
5358
+ function runImport(root, detected2, from) {
5234
5359
  try {
5235
- return IMPORTERS[from.source](root, detected3, from.files);
5360
+ return IMPORTERS[from.source](root, detected2, from.files);
5236
5361
  } catch (e) {
5237
5362
  const file = from.files[0] ?? "(its files)";
5238
5363
  return { source: from.source, files: [], keys: {}, unsupported: [{ file, setting: "(file)", why: `could not be read: ${e.message}` }], notes: [] };
@@ -5327,7 +5452,7 @@ function put(root, rel, text, dryRun, note) {
5327
5452
  function existingConfig(root) {
5328
5453
  return CONFIG_NAMES.find((n) => existsSync10(join13(root, n)));
5329
5454
  }
5330
- async function newConfigKeys(detected3, opts, io) {
5455
+ async function newConfigKeys(detected2, opts, io) {
5331
5456
  const notes = [];
5332
5457
  let presetName = opts.preset;
5333
5458
  if (!presetName && io.ask && !opts.yes) {
@@ -5337,31 +5462,31 @@ async function newConfigKeys(detected3, opts, io) {
5337
5462
  const preset2 = presetByName(presetName ?? DEFAULT_PRESET);
5338
5463
  const keys = structuredClone(preset2.keys);
5339
5464
  if (opts.gate) keys.gate = opts.gate;
5340
- const engine2 = engineStep({ engine: opts.engine, detected: detected3, env: io.env });
5465
+ const engine2 = engineStep({ engine: opts.engine, detected: detected2, env: io.env });
5341
5466
  Object.assign(keys, engine2.keys);
5342
5467
  notes.push(...engine2.notes);
5343
5468
  const engineBackends = engine2.keys.backend !== void 0 || engine2.keys.stacks !== void 0;
5344
5469
  let forge;
5345
5470
  if (opts.forge) {
5346
5471
  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 };
5472
+ if (detected2.forge?.kind !== opts.forge) keys.forge = { kind: opts.forge };
5473
+ else if (detected2.forge.url) keys.forge = { kind: opts.forge, url: detected2.forge.url };
5474
+ } else if (detected2.forge) {
5475
+ forge = { kind: detected2.forge.kind, because: detected2.forge.because };
5476
+ if (detected2.forge.url) keys.forge = { kind: detected2.forge.kind, url: detected2.forge.url };
5352
5477
  } else {
5353
5478
  const kind = io.ask && !opts.yes ? await io.ask("Forge", FORGES, "github") : "github";
5354
5479
  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
5480
  keys.forge = { kind };
5356
5481
  }
5357
5482
  const fromEnv = io.env.PULUMI_BACKEND_URL?.trim();
5358
- if (fromEnv && !engineBackends && detected3.projects.some((p) => !p.backendUrl)) {
5483
+ if (fromEnv && !engineBackends && detected2.projects.some((p) => !p.backendUrl)) {
5359
5484
  keys.backend = fromEnv;
5360
5485
  notes.push(`backend ${fromEnv} came from PULUMI_BACKEND_URL; it is in plywood.yml so the jobs use it too`);
5361
5486
  }
5362
5487
  return { keys, notes, forge };
5363
5488
  }
5364
- function initStacks(detected3, doc, members = []) {
5489
+ function initStacks(detected2, doc, members = []) {
5365
5490
  const listed = stacksOf(doc);
5366
5491
  const pinned = new Map(members.filter((m) => m.engineBin && m.engineVersion).map((m) => [m.name, m]));
5367
5492
  const out = [];
@@ -5370,6 +5495,7 @@ function initStacks(detected3, doc, members = []) {
5370
5495
  const own = listed[key] ?? {};
5371
5496
  const backend = own.backend ?? (typeof doc.backend === "string" ? doc.backend : void 0);
5372
5497
  const engine2 = engineOf(doc, backend)?.engine;
5498
+ const grain = lockGrainFor(doc, engine2);
5373
5499
  const stackDir = own.dir ?? dir;
5374
5500
  const packs = packsForStack(stepPolicyPacksOf(doc), stackDir);
5375
5501
  const pin = pinned.get(key);
@@ -5379,13 +5505,18 @@ function initStacks(detected3, doc, members = []) {
5379
5505
  stack,
5380
5506
  dir: stackDir,
5381
5507
  ...engine2 ? { engine: engine2 } : {},
5508
+ // plywood#22: the step's backend, so a preview and an update read this stack's store, not the job's PULUMI_BACKEND_URL.
5509
+ ...backend ? { backend } : {},
5382
5510
  ...pin ? { engineVersion: pin.engineVersion, ...pin.engineRoot ? { engineRoot: pin.engineRoot } : {} } : {},
5383
5511
  ...own.gate ? { gate: own.gate } : {},
5384
- ...packs.length ? { policyPacks: packs } : {}
5512
+ ...packs.length ? { policyPacks: packs } : {},
5513
+ // chant's ledger lock (ruling for ci-products 2026-10-28): each stack's grain on its step, so a plunge stack on the
5514
+ // resource grain leases its resource rows and never waits, and a stock stack (or locks.grain: stack) waits for its stack row.
5515
+ ...grain !== "per-engine" ? { lockGrain: LOCK_GRAIN_OF[grain] } : {}
5385
5516
  });
5386
5517
  seen.add(key);
5387
5518
  };
5388
- for (const s of detected3.stacks) add(s.key, s.project, s.stack, s.dir);
5519
+ for (const s of detected2.stacks) add(s.key, s.project, s.stack, s.dir);
5389
5520
  for (const [key, own] of Object.entries(listed)) {
5390
5521
  if (seen.has(key)) continue;
5391
5522
  const [project, stack] = key.split("/");
@@ -5393,11 +5524,11 @@ function initStacks(detected3, doc, members = []) {
5393
5524
  }
5394
5525
  return out;
5395
5526
  }
5396
- function forgeFor2(doc, detected3, opts) {
5527
+ function forgeFor2(doc, detected2, opts) {
5397
5528
  if (opts.forge) return { kind: opts.forge, because: "--forge" };
5398
5529
  const kind = isObject(doc.forge) ? doc.forge.kind : void 0;
5399
5530
  if (kind) return { kind, because: "plywood.yml says forge.kind" };
5400
- if (detected3.forge) return { kind: detected3.forge.kind, because: detected3.forge.because };
5531
+ if (detected2.forge) return { kind: detected2.forge.kind, because: detected2.forge.because };
5401
5532
  return { kind: "github", because: "origin names no forge init knows, so github" };
5402
5533
  }
5403
5534
  async function chantWriteCiHook() {
@@ -5409,14 +5540,14 @@ async function chantWriteCiHook() {
5409
5540
  }
5410
5541
  }
5411
5542
  async function runInit(opts, table, io) {
5412
- const detected3 = detect(io.cwd);
5413
- const { root } = detected3;
5543
+ const detected2 = detect(io.cwd);
5544
+ const { root } = detected2;
5414
5545
  const files = [];
5415
5546
  const notes = [];
5416
5547
  const todo = [];
5417
5548
  const buckets = [];
5418
5549
  const adopt = [];
5419
- const imported = opts.from ? runImport(root, detected3, opts.from) : void 0;
5550
+ const imported = opts.from ? runImport(root, detected2, opts.from) : void 0;
5420
5551
  if (imported && imported.files.length === 0) notes.push(`--from ${imported.source} found no file to read${imported.notes.length ? `: ${imported.notes.join("; ")}` : ""}`);
5421
5552
  const fail = (message, forge2 = { kind: "github", because: "" }) => ({
5422
5553
  ok: false,
@@ -5431,8 +5562,8 @@ async function runInit(opts, table, io) {
5431
5562
  adopt,
5432
5563
  ...imported ? { imported } : {}
5433
5564
  });
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");
5565
+ if (detected2.projects.length === 0) return fail(`no Pulumi.yaml under ${root}; plywood init sets up a repository that has a Pulumi program`);
5566
+ 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
5567
  let config2;
5437
5568
  let forge;
5438
5569
  const kept = existingConfig(root);
@@ -5446,10 +5577,10 @@ async function runInit(opts, table, io) {
5446
5577
  const onPlunge = opts.engine === "plunge" && engineOf(config2.doc)?.engine === "plunge";
5447
5578
  const ignored = [opts.preset && "--preset", opts.gate && "--gate", opts.engine && !onPlunge && "--engine", imported && "--from"].filter(Boolean);
5448
5579
  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);
5580
+ if (opts.engine === "plunge" && !onPlunge) notes.push(...keptPlungeLines(kept, detected2, io.env));
5581
+ forge = forgeFor2(config2.doc, detected2, opts);
5451
5582
  } else {
5452
- const made = await newConfigKeys(detected3, opts, io);
5583
+ const made = await newConfigKeys(detected2, opts, io);
5453
5584
  notes.push(...made.notes);
5454
5585
  forge = made.forge;
5455
5586
  const text = plywoodYml(imported ? mergeKeys(made.keys, imported.keys) : made.keys);
@@ -5465,12 +5596,12 @@ async function runInit(opts, table, io) {
5465
5596
  const reviewStacks2 = config2.doc.review_stacks !== void 0 ? await chantReviewStacks() : false;
5466
5597
  if (settings.ci.prEnvironment && !reviewStacks2) delete settings.ci.prEnvironment;
5467
5598
  const reviewSuffix = reviewStacks2 ? reviewSuffixOf(settings.pipeline.reviewStackName) : void 0;
5468
- const stacks2 = initStacks(detected3, doc, settings.members);
5599
+ const stacks2 = initStacks(detected2, doc, settings.members);
5469
5600
  const order = await stackOrder(root, stacks2, settings.pipeline.after);
5470
5601
  const ordered = orderWaves(wavesOf(stacks2, settings.pipeline.waves), order.edges, settings.pipeline.canary);
5471
5602
  const waves = ordered.waves;
5472
5603
  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);
5604
+ const declaration = declarationStep(root, detected2, settings, opts.dryRun);
5474
5605
  files.push(declaration.file);
5475
5606
  if (existsSync10(join13(root, CHANT_CONFIG_FILE))) {
5476
5607
  const text = readFileSync12(join13(root, CHANT_CONFIG_FILE), "utf8");
@@ -5480,8 +5611,8 @@ async function runInit(opts, table, io) {
5480
5611
  } else files.push(put(root, CHANT_CONFIG_FILE, chantConfigTs(), opts.dryRun));
5481
5612
  files.push(put(root, COMPONENTS_FILE, componentsFile(stacks2, settings.pipeline.after, reviewSuffix ? { reviewSuffix } : {}), opts.dryRun));
5482
5613
  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) {
5614
+ if (detected2.preCommit) files.push(await preCommitStep(root, detected2.preCommit, opts.dryRun));
5615
+ for (const wf of detected2.pulumiActions) {
5485
5616
  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
5617
  }
5487
5618
  const chant = await chantProduct();
@@ -5489,7 +5620,7 @@ async function runInit(opts, table, io) {
5489
5620
  const message = needsChantMessage("Writing the waves spec and the workflow");
5490
5621
  files.push({ file: WAVES_SPEC_FILE2, status: "skipped", note: message }, { file: WORKFLOW_FILE[forge.kind], status: "skipped", note: "needs the same chant" });
5491
5622
  } else {
5492
- let spec = wavesSpecStep(chant, stacks2, doc, settings, detected3.defaultBranch, waves);
5623
+ let spec = wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves);
5493
5624
  if (settings.pipeline.jobs) {
5494
5625
  try {
5495
5626
  spec = withJobSettings(spec, settings.pipeline.jobs, forge.kind, { parallelism: await hasChant4625() });
@@ -5498,12 +5629,15 @@ async function runInit(opts, table, io) {
5498
5629
  spec = void 0;
5499
5630
  }
5500
5631
  }
5501
- if (spec && stacks2.length > 0) files.push(put(root, WAVES_SPEC_FILE2, jsonText(spec), opts.dryRun));
5632
+ if (spec && stacks2.length > 0) {
5633
+ files.push(put(root, WAVES_SPEC_FILE2, wavesSpecText(spec, wavesEngine(settings.pipeline)), opts.dryRun));
5634
+ files.push(put(root, UP_OP_FILE, upOpFile(spec), opts.dryRun));
5635
+ }
5502
5636
  if (spec) files.push(...await workflowStep(chant, root, doc, settings, forge.kind, opts, stacks2.length > 0 ? spec : void 0, table, io));
5503
5637
  }
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));
5638
+ if (opts.bucketCheck) notes.push(...await bucketStep(detected2, doc, io.env, buckets));
5639
+ adopt.push(...await adoptStep(root, detected2, doc, io.env, declaration.doc, opts.dryRun, files, notes, todo));
5640
+ todo.push(...todoLines(detected2, doc, forge.kind, io.env));
5507
5641
  const failed = files.find((f) => f.status === "failed");
5508
5642
  return {
5509
5643
  ok: !failed,
@@ -5519,9 +5653,9 @@ async function runInit(opts, table, io) {
5519
5653
  ...imported ? { imported } : {}
5520
5654
  };
5521
5655
  }
5522
- function declarationStep(root, detected3, settings, dryRun) {
5656
+ function declarationStep(root, detected2, settings, dryRun) {
5523
5657
  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}` } };
5658
+ 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
5659
  }
5526
5660
  let existing;
5527
5661
  const file = join13(root, DECLARATION_FILE);
@@ -5534,7 +5668,7 @@ function declarationStep(root, detected3, settings, dryRun) {
5534
5668
  return { file: { file: DECLARATION_FILE, status: "failed", note: `is not JSON init can merge into (${e.message})` } };
5535
5669
  }
5536
5670
  }
5537
- const doc = workspaceDeclaration(existing, { name: repoName(root), projects: detected3.projects, ci: settings.ci, lexiconVersion: CHANT_PIN });
5671
+ const doc = workspaceDeclaration(existing, { name: repoName(root), projects: detected2.projects, ci: settings.ci, lexiconVersion: CHANT_PIN });
5538
5672
  if (existing && JSON.stringify(existing) === JSON.stringify(doc)) return { file: { file: DECLARATION_FILE, status: "unchanged" }, doc };
5539
5673
  return { file: put(root, DECLARATION_FILE, jsonText(doc), dryRun), doc };
5540
5674
  }
@@ -5589,7 +5723,7 @@ async function workflowStep(chant, root, doc, settings, forge, opts, spec, table
5589
5723
  try {
5590
5724
  const { noteControlTarget: noteControlTarget2 } = await Promise.resolve().then(() => (init_control2(), control_exports));
5591
5725
  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 } : {} });
5726
+ plan = workflowPlan(chant, doc, forge, opts.env, spec, chantBuildFlags(chantPipelineOptions(settings.pipeline, false)), { reviewStacks: await chantReviewStacks(), prJobs, ...control3 ? { control: control3 } : {} });
5593
5727
  } catch (e) {
5594
5728
  return [{ file: output, status: "failed", note: e.message }];
5595
5729
  }
@@ -5606,16 +5740,16 @@ async function workflowStep(chant, root, doc, settings, forge, opts, spec, table
5606
5740
  ];
5607
5741
  const out = [{ file: output, status: written.status, ...notes.length ? { note: notes.join("; ") } : {} }];
5608
5742
  if (plan.jobs.waves) {
5609
- const code = await writeWaveJobs(chant, forge, plan.jobs.waves, plan.words, table, { ...io, cwd: root });
5743
+ const code = await writeWaveJobs(chant, forge, plan.jobs.waves, plan.words, table, { ...io, cwd: root }, wavesEngine(settings.pipeline));
5610
5744
  if (code !== table.done) out.push({ file: WAVES_OUTPUT_DIR[forge], status: "failed", note: `the waves workflow: chant exited ${code}` });
5611
5745
  }
5612
5746
  return out;
5613
5747
  }
5614
- async function bucketStep(detected3, doc, env, reports) {
5748
+ async function bucketStep(detected2, doc, env, reports) {
5615
5749
  const listed = stacksOf(doc);
5616
5750
  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 };
5751
+ for (const s of detected2.stacks) {
5752
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5619
5753
  const b = backendOf(project, { ...listed[s.key]?.backend ? { stack: listed[s.key].backend } : {}, ...typeof doc.backend === "string" ? { all: doc.backend } : {} }, env);
5620
5754
  if (b.url && !isPulumiCloud(b.url)) urls.add(b.url);
5621
5755
  }
@@ -5639,12 +5773,12 @@ async function bucketStep(detected3, doc, env, reports) {
5639
5773
  }
5640
5774
  return lines2;
5641
5775
  }
5642
- async function adoptStep(root, detected3, doc, env, declaration, dryRun, files, notes, todo) {
5776
+ async function adoptStep(root, detected2, doc, env, declaration, dryRun, files, notes, todo) {
5643
5777
  const listed = stacksOf(doc);
5644
5778
  const all = typeof doc.backend === "string" ? doc.backend : void 0;
5645
5779
  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 };
5780
+ for (const s of detected2.stacks) {
5781
+ const project = detected2.projects.find((p) => p.dir === s.dir && p.name === s.project) ?? { backendUrl: void 0 };
5648
5782
  const own = listed[s.key];
5649
5783
  const b = backendOf(project, { ...own?.backend ? { stack: own.backend } : {}, ...all ? { all } : {} }, env);
5650
5784
  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 +5801,17 @@ async function adoptStep(root, detected3, doc, env, declaration, dryRun, files,
5667
5801
  todo.push(...adoptTodo(rows));
5668
5802
  return previews;
5669
5803
  }
5670
- function keptPlungeLines(kept, detected3, env) {
5671
- const step = engineStep({ engine: "plunge", detected: detected3, env });
5804
+ function keptPlungeLines(kept, detected2, env) {
5805
+ const step = engineStep({ engine: "plunge", detected: detected2, env });
5672
5806
  const entries = Object.entries(step.keys);
5673
5807
  if (entries.length === 0) return step.notes;
5674
5808
  const yaml = entries.map(([k, v]) => emitYAMLEntry2(k, v)).join("\n").split("\n").filter((l) => l !== "");
5675
5809
  return [...step.notes, `To move to plunge, set these keys in ${kept}:`, ...yaml.map((l) => ` ${l}`)];
5676
5810
  }
5677
- function todoLines(detected3, doc, forge, env) {
5811
+ function todoLines(detected2, doc, forge, env) {
5678
5812
  const out = [];
5679
5813
  const all = typeof doc.backend === "string" ? doc.backend : void 0;
5680
- const cloud = detected3.projects.some((p) => isPulumiCloud(backendOf(p, all ? { all } : {}, env).url));
5814
+ const cloud = detected2.projects.some((p) => isPulumiCloud(backendOf(p, all ? { all } : {}, env).url));
5681
5815
  const where = forge === "gitlab" ? "CI/CD variables" : "repository secrets";
5682
5816
  const traded = identityClouds(doc);
5683
5817
  if (cloud && traded.includes("pulumi")) {
@@ -5685,9 +5819,9 @@ function todoLines(detected3, doc, forge, env) {
5685
5819
  } else if (cloud) {
5686
5820
  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
5821
  }
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`);
5822
+ 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
5823
  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}`);
5824
+ out.push(`Make ${PRODUCT}/preview a required status on ${detected2.defaultBranch}`);
5691
5825
  return out;
5692
5826
  }
5693
5827
  function initLines(r) {
@@ -5732,6 +5866,7 @@ var init_init = __esm({
5732
5866
  init_policy_presets();
5733
5867
  init_approval();
5734
5868
  init_engine();
5869
+ init_locks();
5735
5870
  init_config();
5736
5871
  init_routing();
5737
5872
  init_stacks();
@@ -5748,7 +5883,6 @@ var init_init = __esm({
5748
5883
  init_review_stacks2();
5749
5884
  init_waves_workflow();
5750
5885
  init_stages();
5751
- init_chant_next();
5752
5886
  init_stack_order();
5753
5887
  init_import();
5754
5888
  init_templates();
@@ -6132,7 +6266,6 @@ async function check(args, json, table, io) {
6132
6266
  }
6133
6267
  async function preview(args, json, table, io) {
6134
6268
  const targeted = args.includes("--target");
6135
- const planFlags = ["--refresh", "--target-dependents"].filter((f) => args.includes(f));
6136
6269
  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
6270
  for (let i = 0; i < args.length; i++) {
6138
6271
  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 +6288,12 @@ async function preview(args, json, table, io) {
6155
6288
  const argv = ["components", "pr-plan", "--base", at, "--pr", number, ...gate, ...stack ? ["--member", stack] : [], ...rest, ...json ? ["--json"] : []];
6156
6289
  return withChant(`${PRODUCT} preview`, table, io, async (chant) => {
6157
6290
  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
6291
  return chant.runChantInProcess(argv, PRODUCT_WORDS);
6160
6292
  });
6161
6293
  }
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
6294
  function readWavesSpec(cwd) {
6166
6295
  const file = join15(cwd, ".chant/op-waves", `${OP_NAME}.spec.json`);
6167
- return existsSync12(file) ? JSON.parse(readFileSync14(file, "utf8")) : void 0;
6296
+ return existsSync12(file) ? unwrapWavesSpec(JSON.parse(readFileSync14(file, "utf8"))) : void 0;
6168
6297
  }
6169
6298
  function pipelineShape(doc, forge, waves) {
6170
6299
  const applyBeforeMerge = applyWhenOf(doc) === "pull-request";
@@ -6225,7 +6354,7 @@ async function ci(args, json, table, io) {
6225
6354
  const noteControl = await noteControlTarget2(config2.doc, io.cwd);
6226
6355
  let plan;
6227
6356
  try {
6228
- plan = workflowPlan(chant, config2.doc, forge, env, waves, [], { reviewStacks: reviewStacks2, prJobs, next: await chantNext(), ...noteControl ? { control: noteControl } : {} });
6357
+ plan = workflowPlan(chant, config2.doc, forge, env, waves, [], { reviewStacks: reviewStacks2, prJobs, ...noteControl ? { control: noteControl } : {} });
6229
6358
  } catch (e) {
6230
6359
  io.err(`${PRODUCT}: ${e.message}`);
6231
6360
  return table.failed;
@@ -6249,7 +6378,8 @@ async function ci(args, json, table, io) {
6249
6378
  ...jobs2.applyBeforeMerge ? { applyBeforeMerge: jobs2.applyBeforeMerge.command } : {},
6250
6379
  comments: jobs2.comments,
6251
6380
  ...jobs2.waves ? { waves: { specFile: jobs2.waves.specFile, jobs: jobs2.waves.jobs.map((j) => j.command.join(" ")) } } : {},
6252
- ..."control" in control3 ? { control: control3.repo } : {}
6381
+ ..."control" in control3 ? { control: control3.repo } : {},
6382
+ ...jobs2.action ? { action: { command: jobs2.action.command, comment: jobs2.action.comment, inputs: jobs2.action.inputs } } : {}
6253
6383
  };
6254
6384
  if (json && dryRun) io.out(JSON.stringify({ command: "ci", ok: true, exitCode: table.done, dryRun, ...summary }));
6255
6385
  else if (!json) {
@@ -6262,6 +6392,7 @@ async function ci(args, json, table, io) {
6262
6392
  for (const [command, line] of Object.entries(jobs2.comments)) io.err(` comment ${line} (${command})`);
6263
6393
  for (const job of summary.waves?.jobs ?? []) io.err(` wave ${job}`);
6264
6394
  if (jobs2.stale) io.err(` stale ${jobs2.stale.command} (on each push to the target branch)`);
6395
+ if (jobs2.action) io.err(` action ${jobs2.action.command} (${jobs2.action.comment} <stack> <name>, or a dispatch with inputs ${jobs2.action.inputs.member} and ${jobs2.action.inputs.action})`);
6265
6396
  }
6266
6397
  if (!json && "control" in control3) io.err(` control after the last wave, tells ${control3.repo} how the waves ended`);
6267
6398
  else if (!json && "missing" in control3) io.err(` control the waves workflow sends no dispatch: ${control3.missing}; a job of your own after the last wave runs '${PRODUCT} control dispatch'`);
@@ -6274,7 +6405,7 @@ async function ci(args, json, table, io) {
6274
6405
  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
6406
  return written.code;
6276
6407
  }
6277
- const wavesCode = waveJobs ? await writeWaveJobs(chant, forge, waveJobs, plan.words, table, io) : table.done;
6408
+ const wavesCode = waveJobs ? await writeWaveJobs(chant, forge, waveJobs, plan.words, table, io, wavesEngine(toChantSettings(config2).pipeline)) : table.done;
6278
6409
  if (wavesCode !== table.done) return wavesCode;
6279
6410
  const driftCode = await writeDriftJobs(chant, forge, config2.doc, plan.words, table, io, env);
6280
6411
  if (driftCode !== table.done) return driftCode;
@@ -6289,14 +6420,14 @@ function writtenNotes(plan, file) {
6289
6420
  }
6290
6421
  function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6291
6422
  const shape = pipelineShape(doc, forge, waves);
6292
- const next = opts.next;
6293
6423
  const notes = [];
6294
6424
  const pipeline = toChantSettings({ doc }).pipeline;
6295
- const staleNotes = next?.staleNotes === true;
6296
6425
  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;
6426
+ const applyMerge = shape.applyBeforeMerge ? pipeline.applyMerge : void 0;
6298
6427
  if (approvalOf(doc) === "pr-review" && shape.waves && hasChantApprovalStatus(chant) && !hasChantPullRequestApprovalStatus(chant)) notes.push(APPROVAL_STATUS_NOTE);
6299
- const words = wordsForJobs(shape.jobs);
6428
+ const actionJob = actionJobFor(doc, forge, notes);
6429
+ const jobsWanted = actionJob ? [...shape.jobs, "action"] : shape.jobs;
6430
+ const words = wordsForJobs(jobsWanted);
6300
6431
  const requires = shape.applyBeforeMerge ? requiresArgv(chant, doc) : [];
6301
6432
  const previewComment = hasChant4620(chant);
6302
6433
  const jobs2 = chant.productPipelineJobs(forge, env, {
@@ -6310,11 +6441,13 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6310
6441
  ...opts.control !== void 0 ? { control: { target: opts.control } } : {},
6311
6442
  // plywood#29: runners, env, pass and parallelism on the pull request jobs, with chant#4625 (src/jobs.ts).
6312
6443
  ...opts.prJobs?.options ?? {},
6313
- ...staleNotes ? { staleNotes: true } : {},
6444
+ // chant#4628: the note names the target commit it was previewed against, and a push marks older notes stale.
6445
+ staleNotes: true,
6314
6446
  ...respond.options,
6315
- ...applyMerge ? { applyMerge } : {}
6447
+ ...applyMerge ? { applyMerge } : {},
6448
+ ...actionJob ? { actionJob: true } : {}
6316
6449
  });
6317
- const prWords = shape.applyBeforeMerge ? words : wordsForJobs(shape.jobs.filter((j) => j !== "apply"));
6450
+ const prWords = shape.applyBeforeMerge ? words : wordsForJobs(jobsWanted.filter((j) => j !== "apply"));
6318
6451
  const argv = [
6319
6452
  "build",
6320
6453
  ".",
@@ -6330,7 +6463,8 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6330
6463
  ...applyMerge ? applyMergeArgv(applyMerge) : [],
6331
6464
  ...respond.argv,
6332
6465
  ...previewComment ? ["--preview-comment"] : [],
6333
- ...staleNotes ? ["--stale-notes"] : [],
6466
+ "--stale-notes",
6467
+ ...actionJob ? ["--action-job"] : [],
6334
6468
  // Review stacks (plywood#15): chant's pr-env jobs, only once the pulumi lexicon can remove what they create.
6335
6469
  ...doc.review_stacks !== void 0 && opts.reviewStacks ? ["--pr-env"] : [],
6336
6470
  ...opts.prJobs?.flags ?? [],
@@ -6338,6 +6472,11 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6338
6472
  ];
6339
6473
  return { jobs: jobs2, words, prWords, argv, notes, ...applyMerge ? { applyMerge } : {} };
6340
6474
  }
6475
+ function actionJobFor(doc, forge, notes) {
6476
+ if (!hasActions(doc)) return false;
6477
+ if (forge === "gitlab") return notes.push(ACTIONS_NOT_ON_GITLAB), false;
6478
+ return true;
6479
+ }
6341
6480
  function applyMergeArgv(m) {
6342
6481
  return ["--merge", "auto", "--merge-token-env", m.tokenEnv, ...m.method ? ["--merge-method", m.method] : []];
6343
6482
  }
@@ -6387,7 +6526,7 @@ function usage(message, table, io) {
6387
6526
  io.err(`${PRODUCT}: ${message}`);
6388
6527
  return table.failed;
6389
6528
  }
6390
- var CHANT_PASSTHROUGH, VALUED, PREVIEW_TARGET_NEEDS_CHANT, WORKFLOW_FILE, APPLY_MERGE_NEEDS_CHANT, APPROVAL_STATUS_NOTE, CHANT_RESPOND_DEFAULTS, DECIDE_LEFT_OFF;
6529
+ var CHANT_PASSTHROUGH, VALUED, PREVIEW_TARGET_NEEDS_CHANT, WORKFLOW_FILE, ACTIONS_NOT_ON_GITLAB, APPROVAL_STATUS_NOTE, CHANT_RESPOND_DEFAULTS, DECIDE_LEFT_OFF;
6391
6530
  var init_stages = __esm({
6392
6531
  "src/stages.ts"() {
6393
6532
  "use strict";
@@ -6399,6 +6538,7 @@ var init_stages = __esm({
6399
6538
  init_apply();
6400
6539
  init_config();
6401
6540
  init_locks();
6541
+ init_stacks();
6402
6542
  init_routing();
6403
6543
  init_workflow();
6404
6544
  init_review_stacks2();
@@ -6415,13 +6555,38 @@ var init_stages = __esm({
6415
6555
  forgejo: ".forgejo/workflows/plywood.yml",
6416
6556
  gitlab: ".gitlab-ci.yml"
6417
6557
  };
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";
6558
+ 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";
6419
6559
  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
6560
  CHANT_RESPOND_DEFAULTS = Object.freeze({ "wave-refused": "diff", "apply-failed": "triage", description: "off" });
6421
6561
  DECIDE_LEFT_OFF = "decide is set and respond.description is not check, so plywood leaves the decide service off the pull request jobs";
6422
6562
  }
6423
6563
  });
6424
6564
 
6565
+ // src/chant-resolve.ts
6566
+ import * as nodeModule from "node:module";
6567
+ var CHANT_SPECIFIER = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?(?:\/|$)/;
6568
+ function chantResolveHook(from) {
6569
+ return (specifier, context, next) => {
6570
+ try {
6571
+ return next(specifier, context);
6572
+ } catch (err) {
6573
+ const code = err.code;
6574
+ if (!CHANT_SPECIFIER.test(specifier) || code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") throw err;
6575
+ if (context.parentURL === from) throw err;
6576
+ return next(specifier, { ...context, parentURL: from });
6577
+ }
6578
+ };
6579
+ }
6580
+ var installed = false;
6581
+ function installChantResolution(from = import.meta.url) {
6582
+ if (installed) return true;
6583
+ const register = nodeModule.registerHooks;
6584
+ if (typeof register !== "function") return false;
6585
+ register({ resolve: chantResolveHook(from) });
6586
+ installed = true;
6587
+ return true;
6588
+ }
6589
+
6425
6590
  // src/cli.ts
6426
6591
  init_config_check();
6427
6592
 
@@ -6461,1100 +6626,1185 @@ function exitCodes() {
6461
6626
  return cached;
6462
6627
  }
6463
6628
 
6464
- // src/audit.ts
6629
+ // src/actions.ts
6630
+ init_chant_product();
6631
+ init_stacks();
6632
+ init_stages();
6633
+
6634
+ // src/stack-ops.ts
6635
+ init_chant_config();
6636
+ init_identity();
6637
+ init_config();
6638
+ init_stacks();
6639
+ init_adopt();
6465
6640
  init_detect();
6641
+ init_refusals();
6466
6642
  init_stages();
6643
+ init_chant_product();
6467
6644
  init_words();
6468
- var AUDIT_TABLES = { approvals: "gates", updates: "applies", runs: "runs", "review-stacks": "pr_envs" };
6469
- var DEFAULT_AUDIT_TABLE = "approvals";
6470
- function auditTable(word) {
6471
- const w = word ?? DEFAULT_AUDIT_TABLE;
6472
- return AUDIT_TABLES[w] ?? w;
6473
- }
6474
- function auditMember(stack) {
6475
- const [project, name] = stack.split("/");
6476
- return project && name ? slug(project).slice(0, 36).replace(/-+$/, "") : void 0;
6645
+ import { existsSync as existsSync13 } from "node:fs";
6646
+ import { dirname as dirname11, isAbsolute as isAbsolute2, join as join16, resolve as resolve2 } from "node:path";
6647
+ var STACK_OPS_OP = "pulumi-stack-ops";
6648
+ var REQUIRED4 = ["gatedStackExport", "gatedStackCancel", "gatedStackMove"];
6649
+ var cachedLocks;
6650
+ function chantStackLockReader() {
6651
+ cachedLocks ??= (async () => {
6652
+ try {
6653
+ const m = await import("@intentius/chant-lexicon-pulumi");
6654
+ return typeof m.pulumiStackLockIds === "function" ? m.pulumiStackLockIds : void 0;
6655
+ } catch {
6656
+ return void 0;
6657
+ }
6658
+ })();
6659
+ return cachedLocks;
6477
6660
  }
6478
- function auditArgv(args, json) {
6479
- const stack = takeFlag(args, "--stack");
6480
- const since = takeFlag(stack.rest, "--since");
6481
- const at = takeFlag(since.rest, "--at");
6482
- for (const [flag, f] of [["--stack", stack], ["--since", since], ["--at", at]]) if (f.missing) return { error: `${flag} takes a value` };
6483
- const wheres = [];
6484
- const rest = [];
6485
- for (let i = 0; i < at.rest.length; i++) {
6486
- const a = at.rest[i];
6487
- if (a === "--where") {
6488
- const v = at.rest[i + 1];
6489
- if (v === void 0 || v.startsWith("--")) return { error: "--where takes <field><op><value>, such as gate=wave-2" };
6490
- wheres.push("--where", v);
6491
- i++;
6492
- } else rest.push(a);
6493
- }
6494
- const flags = rest.filter((a) => a.startsWith("-"));
6495
- if (flags.length) return { error: `audit does not take ${flags.join(" ")}` };
6496
- if (rest.length > 1) return { error: `audit reads one table: ${Object.keys(AUDIT_TABLES).join(", ")}, or a chant ledger table` };
6497
- let member;
6498
- if (stack.value !== void 0) {
6499
- member = auditMember(stack.value);
6500
- if (!member) return { error: `--stack takes project/stack, not '${stack.value}'` };
6661
+ async function lockToCancel(given, stack, env) {
6662
+ if (given) return { lockId: given, read: false };
6663
+ const reader = await chantStackLockReader();
6664
+ if (!reader) return { error: STACK_OP_WORDS.needsLock };
6665
+ let locks2;
6666
+ try {
6667
+ locks2 = await reader({ dir: stack.dir, stack: stack.stack, ...stack.project ? { project: stack.project } : {}, ...stack.engine ? { engine: stack.engine } : {}, env });
6668
+ } catch (e) {
6669
+ return { error: `${STACK_OP_WORDS.needsLock}. Reading the stack's lock failed: ${e.message}` };
6501
6670
  }
6502
- return [
6503
- "ledger",
6504
- auditTable(rest[0]),
6505
- ...member ? ["--member", member] : [],
6506
- ...since.value ? ["--since", since.value] : [],
6507
- ...wheres,
6508
- ...at.value ? ["--at", at.value] : [],
6509
- ...json ? ["--json"] : []
6510
- ];
6511
- }
6512
- async function audit2(args, json, table, io) {
6513
- const argv = auditArgv(args, json);
6514
- if ("error" in argv) return usage(argv.error, table, io);
6515
- return withChant(`${PRODUCT} audit`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
6671
+ if (!locks2.checked) return { error: STACK_OP_WORDS.lockNotListed(locks2.backend) };
6672
+ if (locks2.ids.length === 0) return { error: STACK_OP_WORDS.noLock(stack.stack) };
6673
+ if (locks2.ids.length > 1) return { error: STACK_OP_WORDS.manyLocks(stack.stack, locks2.ids) };
6674
+ return { lockId: locks2.ids[0], read: true };
6516
6675
  }
6517
-
6518
- // src/cli.ts
6519
- init_agents2();
6520
- init_drift2();
6521
-
6522
- // src/estate.ts
6523
- init_chant_product();
6524
- init_chant_config();
6525
- init_config();
6526
- init_stages();
6527
- init_words();
6528
- import { dirname as dirname11 } from "node:path";
6529
- var estateModule;
6530
- function chantEstate() {
6531
- estateModule ??= (async () => {
6676
+ var cachedOps;
6677
+ var cachedRollback;
6678
+ function chantStackOps() {
6679
+ cachedOps ??= (async () => {
6532
6680
  try {
6533
- const m = await import("@intentius/chant/ci-estate");
6534
- const r = await import("@intentius/chant/pr-report-url");
6535
- if (!["readEstateLedger", "buildLedgerEstate", "writeEstate"].every((f) => typeof m[f] === "function")) return void 0;
6536
- if (typeof r.parseReportUrl !== "function" || typeof r.reportPut !== "function") return void 0;
6537
- return { ...m, parseReportUrl: r.parseReportUrl, reportPut: r.reportPut };
6681
+ const m = await import("@intentius/chant-lexicon-pulumi");
6682
+ return REQUIRED4.every((name) => typeof m[name] === "function") ? m : void 0;
6538
6683
  } catch {
6539
6684
  return void 0;
6540
6685
  }
6541
6686
  })();
6542
- return estateModule;
6543
- }
6544
- var ESTATE_NEEDS_CHANT = `${PRODUCT} estate needs chant >= the release after 0.124.54, which carries @intentius/chant/ci-estate (chant#4612). Run '${PRODUCT} audit' for the ledger's rows on this chant.`;
6545
- function estateSchedules(config2) {
6546
- const drift3 = toChantSettings(config2).pipeline.drift;
6547
- const driftOps = (drift3?.stacks ?? []).map(driftOpName);
6548
- const schedules = drift3?.schedule ? driftOps.map((op) => ({ op, cron: drift3.schedule })) : [];
6549
- return { schedules, driftOps };
6687
+ return cachedOps;
6550
6688
  }
6551
- async function estate(args, json, table, io) {
6552
- const failOverdue = args.includes("--fail-overdue");
6553
- let rest = args.filter((a) => a !== "--fail-overdue");
6554
- const flags = {};
6555
- for (const flag of ["--file", "--report-url", "--prefix"]) {
6556
- const f = takeFlag(rest, flag);
6557
- if (f.missing) return usage(`${flag} takes a value`, table, io);
6558
- flags[flag] = f.value;
6559
- rest = f.rest;
6560
- }
6561
- if (rest.length) return usage(`estate does not take ${rest.join(" ")}`, table, io);
6562
- const chant = await chantEstate();
6563
- if (!chant) {
6564
- if (json) io.out(JSON.stringify({ command: "estate", ok: false, exitCode: table.failed, error: ESTATE_NEEDS_CHANT }));
6565
- else io.err(`${PRODUCT}: ${ESTATE_NEEDS_CHANT}`);
6566
- return table.failed;
6567
- }
6568
- const path = flags["--file"] ?? findConfig(io.cwd);
6569
- let config2;
6570
- if (path) {
6689
+ function chantStackRollback() {
6690
+ cachedRollback ??= (async () => {
6571
6691
  try {
6572
- config2 = loadConfig(path);
6573
- } catch (e) {
6574
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6575
- }
6576
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6577
- if (errors.length) {
6578
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6579
- return table.failed;
6692
+ const m = await import("@intentius/chant-lexicon-pulumi");
6693
+ if (typeof m.pulumiReleaseInputs !== "function") return false;
6694
+ const copy2 = await import("@intentius/chant-lexicon-pulumi/release-copy");
6695
+ return typeof copy2.prepareReleaseCopy === "function";
6696
+ } catch {
6697
+ return false;
6580
6698
  }
6581
- }
6582
- const { schedules, driftOps } = config2 ? estateSchedules(config2) : { schedules: [], driftOps: [] };
6583
- const product = await chantProduct();
6584
- const words = product ? product.assertProductWords(PRODUCT_WORDS) : PRODUCT_WORDS;
6585
- const root = path ? dirname11(path) : io.cwd;
6586
- const load = await chant.readEstateLedger(root);
6587
- const built = chant.buildLedgerEstate({ load, words, schedules, ...driftOps.length ? { driftOps } : {} });
6588
- const url = flags["--report-url"] ?? (config2 ? toChantSettings(config2).ci.reportUrl : void 0);
6589
- let written;
6590
- if (url) {
6591
- const target = chant.parseReportUrl(url, flags["--report-url"] ? "--report-url" : "reports.url");
6592
- if (target && typeof target === "object" && "error" in target) return usage(String(target.error), table, io);
6593
- written = await chant.writeEstate(built, { put: chant.reportPut(target), words, env: io.env, ...flags["--prefix"] ? { prefix: flags["--prefix"] } : {} });
6594
- }
6595
- const code = failOverdue && built.overdue.length > 0 ? table.failed : table.done;
6596
- if (json) {
6597
- io.out(JSON.stringify({ command: "estate", ok: code === table.done, exitCode: code, estate: built.estate, dora: built.dora, overdue: built.overdue, ...written ? { written } : {} }));
6598
- return code;
6599
- }
6600
- const totals = built.estate.totals ?? {};
6601
- io.out(`${PRODUCT} estate: ${Object.entries(totals).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing on the ledger yet"}`);
6602
- for (const o of built.overdue) io.out(` ${o.message}`);
6603
- if (written) io.out(` wrote ${written.keys.join(", ")} under ${url}`);
6604
- else io.out(` no report URL (reports.url or --report-url), so nothing was written`);
6605
- return code;
6699
+ })();
6700
+ return cachedRollback;
6606
6701
  }
6607
-
6608
- // src/notify.ts
6609
- init_stages();
6610
- init_words();
6611
- var RELAY_NEEDS_CHANT = `${PRODUCT} notify relay needs chant >= the release after 0.124.54, which carries chant notify relay (chant#4611).`;
6612
- async function notify(args, json, table, io) {
6613
- if (args[0] !== "relay") return usage(`unknown notify command '${args[0] ?? ""}'. Run '${PRODUCT} notify relay'.`, table, io);
6614
- return withChant(`${PRODUCT} notify relay`, table, io, (chant) => {
6615
- if (typeof chant.startRelayServer !== "function") {
6616
- if (json) io.out(JSON.stringify({ command: "notify relay", ok: false, exitCode: table.failed, error: RELAY_NEEDS_CHANT }));
6617
- else io.err(`${PRODUCT}: ${RELAY_NEEDS_CHANT}`);
6618
- return Promise.resolve(table.failed);
6619
- }
6620
- return chant.runChantInProcess(["notify", ...args], PRODUCT_WORDS);
6621
- });
6702
+ function needsStackOpsMessage(what, feature, after = "0.124.54") {
6703
+ return `${what} needs @intentius/chant-lexicon-pulumi >= the release after ${after}, which carries ${feature}. This plywood was built with chant ${BUILT_CHANT}, which lacks it. Upgrade plywood once that chant is out, or build against a local chant with 'just chant-local'.`;
6622
6704
  }
6623
-
6624
- // src/cli.ts
6625
- init_policy();
6626
-
6627
- // src/guard.ts
6628
- init_stages();
6629
- init_words();
6630
- import { chmodSync, copyFileSync, existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "node:fs";
6631
- import { join as join16 } from "node:path";
6632
- var GUARDED = {
6633
- pulumi: ["up", "update", "destroy"],
6634
- plunge: ["up", "update", "destroy"]
6635
- };
6636
- var VALUED_GLOBALS = /* @__PURE__ */ new Set(["-C", "--cwd", "-s", "--stack", "--color", "--config-file", "--logflow", "-v", "--verbose", "--tracing", "--profiling", "--memprofilerate"]);
6637
- var LAUNCHERS = /* @__PURE__ */ new Set(["env", "npx", "sudo", "command", "exec", "nohup", "time", "xargs"]);
6638
- function segments(line) {
6639
- return line.split(/\|\||&&|[;|\n`]|\$\(|\)/).map((seg) => (seg.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^['"]|['"]$/g, ""))).filter((t) => t.length > 0);
6705
+ function readConfig(cwd) {
6706
+ const path = findConfig(cwd);
6707
+ if (!path) return { error: `no plywood.yml in ${cwd} or above it; run '${PRODUCT} init' first` };
6708
+ const config2 = loadConfig(path);
6709
+ const errors = checkConfig(config2).filter((p) => p.severity === "error");
6710
+ if (errors.length) return { error: errors.map((p) => `${path}: ${p.key} ${p.message}`).join("\n") };
6711
+ return { config: config2, path };
6640
6712
  }
6641
- function guardVerdict(line) {
6642
- for (const words of segments(line)) {
6643
- let i = 0;
6644
- while (i < words.length && (LAUNCHERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || words[i - 1] === "env" && words[i].startsWith("-"))) i++;
6645
- const bin = (words[i] ?? "").split("/").pop();
6646
- const refused = GUARDED[bin];
6647
- if (!refused) continue;
6648
- for (let j = i + 1; j < words.length; j++) {
6649
- const w = words[j];
6650
- if (w.startsWith("-")) {
6651
- if (VALUED_GLOBALS.has(w)) j++;
6652
- continue;
6653
- }
6654
- if (refused.includes(w)) {
6655
- return {
6656
- engine: bin,
6657
- subcommand: w,
6658
- message: `${PRODUCT} guard: an agent session does not run '${bin} ${w}'. Open a pull request; ${PRODUCT} previews it, and the update runs after a person approves the digest.`
6659
- };
6660
- }
6661
- break;
6662
- }
6713
+ function resolveStack(config2, path, key, dir) {
6714
+ const [project, stack, extra] = key.split("/");
6715
+ if (!project || !stack || extra !== void 0) return { error: `a stack is project/stack, as plywood.yml lists it, not '${key}'` };
6716
+ const settings = toChantSettings(config2);
6717
+ const listed = stacksOf(config2.doc);
6718
+ const root = dirname11(path);
6719
+ const member = settings.members.find((m) => m.name === key) ?? settings.memberDefaults;
6720
+ if (dir === void 0 && Object.keys(listed).length > 0 && !(key in listed)) {
6721
+ return { error: `${key} is not under stacks in ${path}; the stacks are ${Object.keys(listed).join(", ")} (or name its directory with --dir)` };
6663
6722
  }
6664
- return void 0;
6723
+ const at = dir ?? listed[key]?.dir ?? ".";
6724
+ return {
6725
+ key,
6726
+ project,
6727
+ stack,
6728
+ dir: isAbsolute2(at) ? at : resolve2(root, at),
6729
+ ...member.engine ? { engine: member.engine } : {},
6730
+ ...member.backend ? { backend: member.backend } : {}
6731
+ };
6665
6732
  }
6666
- var GUARD_TEMPLATES = new URL("../templates/agent-guard/", import.meta.url);
6667
- var HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/plywood-guard.mjs"';
6668
- function withGuardHook(settings) {
6669
- const hooks = typeof settings.hooks === "object" && settings.hooks !== null ? settings.hooks : {};
6670
- 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 }] });
6673
- return { ...settings, hooks: { ...hooks, PreToolUse: pre } };
6733
+ function requestRules(config2) {
6734
+ const p = toChantSettings(config2).pipeline;
6735
+ return { mode: p.approval ?? "ledger", ...p.quorum?.length ? { quorum: p.quorum } : {} };
6674
6736
  }
6675
- async function guard(args, json, table, io) {
6676
- const [sub, ...rest] = args;
6677
- if (sub === "check") {
6678
- const line = (rest[0] === "--" ? rest.slice(1) : rest).join(" ");
6679
- if (!line.trim()) return usage("guard check takes the command line to check", table, io);
6680
- const v = guardVerdict(line);
6681
- if (json) io.out(JSON.stringify({ command: "guard check", ok: !v, exitCode: v ? table.denied : table.done, refused: v ?? null }));
6682
- else io.out(v ? v.message : `${PRODUCT} guard: '${line}' runs`);
6683
- return v ? table.denied : table.done;
6684
- }
6685
- if (sub === "install") {
6686
- const dirFlag = takeFlag(rest, "--dir");
6687
- if (dirFlag.missing) return usage("--dir takes a directory", table, io);
6688
- if (dirFlag.rest.length) return usage(`guard install does not take ${dirFlag.rest.join(" ")}`, table, io);
6689
- const root = dirFlag.value ? join16(io.cwd, dirFlag.value) : io.cwd;
6690
- const written = [];
6691
- const hookDir = join16(root, ".claude/hooks");
6692
- mkdirSync7(hookDir, { recursive: true });
6693
- copyFileSync(new URL("plywood-guard.mjs", GUARD_TEMPLATES), join16(hookDir, "plywood-guard.mjs"));
6694
- written.push(".claude/hooks/plywood-guard.mjs");
6695
- const settingsFile = join16(root, ".claude/settings.json");
6696
- let settings = {};
6697
- if (existsSync13(settingsFile)) {
6698
- try {
6699
- settings = JSON.parse(readFileSync15(settingsFile, "utf8"));
6700
- } catch (e) {
6701
- return usage(`.claude/settings.json is not JSON (${e.message}); fix it, then run this again`, table, io);
6702
- }
6703
- }
6704
- writeFileSync8(settingsFile, JSON.stringify(withGuardHook(settings), null, 2) + "\n");
6705
- written.push(".claude/settings.json");
6706
- const binDir = join16(root, ".plywood/agent-bin");
6707
- mkdirSync7(binDir, { recursive: true });
6708
- for (const name of Object.keys(GUARDED)) {
6709
- const to = join16(binDir, name);
6710
- copyFileSync(new URL(`bin/${name}`, GUARD_TEMPLATES), to);
6711
- chmodSync(to, 493);
6712
- written.push(`.plywood/agent-bin/${name}`);
6713
- }
6714
- if (json) io.out(JSON.stringify({ command: "guard install", ok: true, exitCode: table.done, written }));
6715
- else {
6716
- for (const f of written) io.err(`${PRODUCT} guard: wrote ${f}`);
6717
- io.err(`${PRODUCT} guard: an agent session that runs outside Claude Code puts .plywood/agent-bin first on PATH and sets PLYWOOD_AGENT_GUARD=1`);
6718
- }
6719
- return table.done;
6720
- }
6721
- return usage(`guard takes check <command line> or install [--dir <d>]`, table, io);
6722
- }
6723
-
6724
- // src/identities.ts
6725
- init_jobs2();
6726
- init_chant_config();
6727
- init_identity();
6728
- init_config();
6729
- init_stacks();
6730
- init_stages();
6731
- init_words();
6732
- import { existsSync as existsSync14, readFileSync as readFileSync16 } from "node:fs";
6733
- import { dirname as dirname12, join as join17 } from "node:path";
6734
- import { targetGlobMatch } from "@intentius/chant/ci-identity";
6735
- function identityRows(config2, env) {
6736
- const doc = config2.doc;
6737
- const out = [];
6738
- for (const stack of Object.keys(stacksOf(doc))) {
6739
- const ids = stackIdentities(doc, stack, env, targetGlobMatch);
6740
- for (const cloud of IDENTITY_CLOUDS) {
6741
- const id = ids[cloud];
6742
- if (id) out.push({ stack, cloud, preview: id.preview, up: id.up, rule: id.rule });
6743
- }
6744
- }
6745
- return out;
6737
+ function requesterOf(by, env) {
6738
+ return by ?? env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
6746
6739
  }
6747
- function identityLines(config2, env, rows) {
6748
- const doc = config2.doc;
6749
- const block = chantIdentityOf(doc);
6750
- if (!block) return [`${config2.file ?? "plywood.yml"}: no identity; every job runs as ${NO_IDENTITY}`];
6751
- const lines2 = [`${config2.file ?? "plywood.yml"}: identities in environment ${env}`];
6752
- const clouds = Object.keys(block.cloud);
6753
- lines2.push(` clouds ${clouds.map((c) => CLOUD_NAMES[c]).join(", ")}`);
6754
- if (clouds.includes("pulumi")) {
6755
- const p = pulumiCloudOf(doc);
6756
- lines2.push(` Pulumi Cloud ${p.org}${p.apiUrl ? ` at ${p.apiUrl}` : ""}; ESC environments open with each job's own token`);
6757
- }
6758
- const stacks2 = Object.keys(stacksOf(doc));
6759
- if (stacks2.length === 0) lines2.push(" plywood.yml lists no stacks; the rules apply to each stack plywood init finds");
6760
- for (const stack of stacks2) {
6761
- lines2.push(` ${stack}`);
6762
- for (const cloud of clouds) {
6763
- const r = rows.find((x) => x.stack === stack && x.cloud === cloud);
6764
- lines2.push(r ? ` ${CLOUD_NAMES[cloud].padEnd(12)} preview ${r.preview}, up ${r.up} (${r.rule})` : ` ${CLOUD_NAMES[cloud].padEnd(12)} ${NO_IDENTITY}`);
6765
- }
6766
- }
6767
- lines2.push(" The preview job trades its OIDC token before checkout, and no later step holds a forge token.");
6768
- return lines2;
6740
+ function destStackName(source, dest, config2) {
6741
+ if (dest.project === source.project) return dest.stack;
6742
+ const cloud = !dest.backend || /^https?:\/\//.test(dest.backend);
6743
+ const org = cloud ? pulumiCloudOf(config2.doc).org : void 0;
6744
+ return `${org ?? "organization"}/${dest.project}/${dest.stack}`;
6769
6745
  }
6770
- function configIdentities(args, json, table, io) {
6771
- const file = takeFlag(args, "--file");
6772
- const env = takeFlag(file.rest, "--env");
6773
- if (file.missing || env.missing) return usage(`${file.missing ? "--file" : "--env"} takes a value`, table, io);
6774
- if (env.rest.length) return usage(`config identities does not take ${env.rest.join(" ")}`, table, io);
6775
- const path = file.value ?? findConfig(io.cwd);
6776
- if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
6777
- let config2;
6778
- try {
6779
- config2 = loadConfig(path);
6780
- } catch (e) {
6781
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6782
- }
6783
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6784
- if (errors.length) {
6785
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6786
- if (json) io.out(JSON.stringify({ command: "config identities", ok: false, exitCode: table.failed, file: path, problems: errors }));
6787
- return table.failed;
6788
- }
6789
- const environment = env.value ?? "production";
6790
- const rows = identityRows(config2, environment);
6791
- if (json) io.out(JSON.stringify({ command: "config identities", ok: true, exitCode: table.done, file: path, env: environment, identity: toChantSettings(config2).ci.identity ?? null, stacks: rows }));
6792
- else for (const line of identityLines(config2, environment, rows)) io.out(line);
6793
- return table.done;
6746
+ function approveRequestLine(gate, digest, mode) {
6747
+ return `${PRODUCT} approve ${gate} --op ${STACK_OPS_OP} --plan ${digest}${mode === "sealed" ? " --sign" : ""}`;
6794
6748
  }
6795
- var FORGE_CHECK_MESSAGE = `${PRODUCT} forge check holds the identities next to the forge's environments, protected branches and each cloud's trust policy. The read is chant's (@intentius/chant/forge-check, chant#4608), and the installed chant lacks it; it comes with the chant release after 0.124.54. Run '${PRODUCT} config identities' for what the jobs will hold.`;
6796
- var FORGES2 = ["github", "gitlab", "forgejo"];
6797
- var FORGE_CHECK_FUNCTIONS = ["forgeCheck", "forgeCheckExit", "describeForgeCheck", "forgeCheckTarget", "pipelineContract"];
6798
- var forgeCheckModule;
6799
- function chantForgeCheck() {
6800
- forgeCheckModule ??= (async () => {
6801
- try {
6802
- const fc = await import("@intentius/chant/forge-check");
6803
- const id = await import("@intentius/chant/ci-identity");
6804
- if (!FORGE_CHECK_FUNCTIONS.every((f) => typeof fc[f] === "function") || typeof id.resolveCloudIdentity !== "function") return void 0;
6805
- return { ...fc, resolveCloudIdentity: id.resolveCloudIdentity };
6806
- } catch {
6807
- return void 0;
6808
- }
6809
- })();
6810
- return forgeCheckModule;
6749
+ function useBackend(stacks2, env) {
6750
+ const named = [...new Set(stacks2.map((s) => s.backend).filter((b) => b !== void 0))];
6751
+ if (named.length > 1) return `the stacks are on different backends (${named.join(", ")}); a move needs both on one backend`;
6752
+ if (named[0] && !env.PULUMI_BACKEND_URL) env.PULUMI_BACKEND_URL = named[0];
6753
+ return void 0;
6811
6754
  }
6812
- function workspaceMembers(root) {
6813
- const file = join17(root, "chant.workspace.json");
6814
- if (!existsSync14(file)) return [];
6815
- try {
6816
- const doc = JSON.parse(readFileSync16(file, "utf8"));
6817
- return (doc.members ?? []).map((m) => m.name).filter((n) => typeof n === "string");
6818
- } catch {
6819
- return [];
6755
+ function reportOutcome(verb, outcome, gate, rules, done, json, table, io) {
6756
+ const approveLine = outcome.status === "waiting" || outcome.status === "refused" ? approveRequestLine(gate, outcome.digest, rules.mode) : void 0;
6757
+ let code;
6758
+ let text;
6759
+ switch (outcome.status) {
6760
+ case "waiting":
6761
+ code = table.waiting;
6762
+ text = STACK_OP_WORDS.waiting(verb, approveLine);
6763
+ break;
6764
+ case "refused":
6765
+ code = table.waiting;
6766
+ text = `${outcome.reason}. ${STACK_OP_WORDS.waiting(verb, approveLine)}`;
6767
+ break;
6768
+ case "spent":
6769
+ code = table.failed;
6770
+ text = STACK_OP_WORDS.spent(verb);
6771
+ break;
6772
+ case "done":
6773
+ code = table.done;
6774
+ text = done(outcome.result);
6775
+ break;
6776
+ case "failed":
6777
+ code = table.failed;
6778
+ text = `${PRODUCT} ${verb}: ${outcome.error}`;
6779
+ break;
6820
6780
  }
6781
+ if (json) {
6782
+ io.out(JSON.stringify({
6783
+ command: verb,
6784
+ ok: code === table.done,
6785
+ exitCode: code,
6786
+ status: outcome.status,
6787
+ op: STACK_OPS_OP,
6788
+ gate,
6789
+ digest: outcome.digest,
6790
+ ...approveLine ? { approve: approveLine } : {},
6791
+ ...outcome.status === "done" ? { result: outcome.result } : {},
6792
+ ...outcome.status === "failed" ? { error: outcome.error } : {},
6793
+ ...outcome.status === "refused" ? { refusedBy: outcome.by, reason: outcome.reason } : {}
6794
+ }));
6795
+ } else (code === table.done ? io.out : io.err)(text);
6796
+ return code;
6821
6797
  }
6822
- function upRunner(runner) {
6823
- if (runner === void 0) return void 0;
6824
- const staged = typeof runner === "object" && !Array.isArray(runner) && !("group" in runner);
6825
- const spec = staged ? runner.up ?? runner.default : runner;
6826
- if (spec === void 0) return void 0;
6827
- if (typeof spec === "string") return { labels: [spec] };
6828
- if (Array.isArray(spec)) return { labels: spec.filter((l) => typeof l === "string") };
6829
- const g = spec;
6830
- return typeof g.group === "string" ? { labels: Array.isArray(g.labels) ? g.labels : [], group: g.group } : void 0;
6831
- }
6832
- function contractPipelines(fc, cloud, env, members, runner, jobs2) {
6833
- const applyRunner = upRunner(runner);
6834
- const list = members.length ? [...members] : [void 0];
6835
- return list.flatMap((member) => {
6836
- const identity2 = fc.resolveCloudIdentity(cloud, env, member);
6837
- const name = member ? `${member}/${env}` : env;
6838
- const forgeEnvironment = jobs2 ? environmentFor(name, jobs2)?.name : void 0;
6839
- return identity2 ? [{ name, identity: identity2, ...member ? { member } : {}, ...applyRunner ? { applyRunner } : {}, ...forgeEnvironment ? { forgeEnvironment } : {} }] : [];
6840
- });
6841
- }
6842
- async function forgeCheck(args, json, table, io) {
6843
- if (args[0] !== "check") return usage(`unknown forge command '${args[0] ?? ""}'. Run '${PRODUCT} forge check'.`, table, io);
6844
- const fc = await chantForgeCheck();
6845
- if (!fc) {
6846
- if (json) io.out(JSON.stringify({ command: "forge check", ok: false, exitCode: table.failed, error: FORGE_CHECK_MESSAGE }));
6847
- else io.err(`${PRODUCT}: ${FORGE_CHECK_MESSAGE}`);
6798
+ async function runRequest(verb, json, table, io, call) {
6799
+ const ops = await chantStackOps();
6800
+ if (!ops) {
6801
+ io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} ${verb}`, "the gated stack operations (chant#4578)")}`);
6848
6802
  return table.failed;
6849
6803
  }
6850
- const requireClouds = args.includes("--require-clouds");
6851
- let rest = args.slice(1).filter((a) => a !== "--require-clouds");
6852
- const flags = {};
6853
- for (const flag of ["--file", "--env", "--forge", "--repo", "--branch"]) {
6854
- const f = takeFlag(rest, flag);
6855
- if (f.missing) return usage(`${flag} takes a value`, table, io);
6856
- flags[flag] = f.value;
6857
- rest = f.rest;
6858
- }
6859
- if (rest.length) return usage(`forge check does not take ${rest.join(" ")}`, table, io);
6860
- const forgeFlag = flags["--forge"];
6861
- if (forgeFlag !== void 0 && !FORGES2.includes(forgeFlag)) return usage(`--forge is ${forgeFlag}; give github, gitlab or forgejo`, table, io);
6862
- const path = flags["--file"] ?? findConfig(io.cwd);
6863
- if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
6864
- let config2;
6865
6804
  try {
6866
- config2 = loadConfig(path);
6805
+ return await call(ops);
6867
6806
  } catch (e) {
6868
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6869
- }
6870
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6871
- if (errors.length) {
6872
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6873
- if (json) io.out(JSON.stringify({ command: "forge check", ok: false, exitCode: table.failed, file: path, problems: errors }));
6807
+ const message = e.message;
6808
+ if (json) io.out(JSON.stringify({ command: verb, ok: false, exitCode: table.failed, error: message }));
6809
+ else io.err(`${PRODUCT} ${verb}: ${message}`);
6874
6810
  return table.failed;
6875
6811
  }
6876
- const settings = toChantSettings(config2);
6877
- const kind = forgeFlag ?? settings.pipeline.forge?.kind;
6878
- let target;
6879
- try {
6880
- target = fc.forgeCheckTarget(io.env, { ...kind && FORGES2.includes(kind) ? { kind } : {}, ...flags["--repo"] ? { repo: flags["--repo"] } : {} });
6881
- } catch (e) {
6882
- return usage(e.message, table, io);
6883
- }
6884
- const env = flags["--env"] ?? "production";
6885
- const cloud = settings.ci.identity?.cloud;
6886
- const pipelines = contractPipelines(fc, cloud, env, workspaceMembers(dirname12(path)), settings.pipeline.runner, settings.pipeline.jobs);
6887
- const result = await fc.forgeCheck(
6888
- {
6889
- target,
6890
- words: PRODUCT_WORDS,
6891
- file: path,
6892
- ...requireClouds ? { requireClouds: true } : {},
6893
- contract: (live) => fc.pipelineContract({
6894
- forge: target.kind,
6895
- repository: {
6896
- host: target.host,
6897
- path: live.path ?? target.repo,
6898
- defaultBranch: flags["--branch"] ?? live.defaultBranch ?? "main",
6899
- ...live.ownerId ? { ownerId: live.ownerId } : {},
6900
- ...live.repoId ? { repoId: live.repoId } : {}
6901
- },
6902
- pipelines,
6903
- words: PRODUCT_WORDS
6904
- })
6905
- },
6906
- { fetch: globalThis.fetch, env: io.env }
6907
- );
6908
- if (json) io.out(JSON.stringify(result));
6909
- else io.out(fc.describeForgeCheck(result));
6910
- if (!cloud) io.err(`${PRODUCT} forge check: ${path} sets no identity, so no trust was checked`);
6911
- else if (pipelines.length === 0) io.err(`${PRODUCT} forge check: no identity rule covers environment ${env}, so no trust was checked`);
6912
- return fc.forgeCheckExit(result);
6812
+ }
6813
+ function commonFlags(args) {
6814
+ const by = takeFlag(args, "--by");
6815
+ const dir = takeFlag(by.rest, "--dir");
6816
+ if (by.missing) return { rest: args, missing: "--by" };
6817
+ if (dir.missing) return { rest: args, missing: "--dir" };
6818
+ return { by: by.value, dir: dir.value, rest: dir.rest };
6819
+ }
6820
+ async function exportStack(args, json, table, io) {
6821
+ const common = commonFlags(args);
6822
+ if (common.missing) return usage(`${common.missing} takes a value`, table, io);
6823
+ const out = takeFlag(common.rest, "--out");
6824
+ if (out.missing) return usage("--out takes a file path outside the repository", table, io);
6825
+ const extra = out.rest.filter((a) => a.startsWith("-"));
6826
+ if (extra.length) return usage(`export does not take ${extra.join(" ")}`, table, io);
6827
+ const named = positionals(out.rest);
6828
+ if (named.length !== 1) return usage(`export takes one stack: ${STACK_OP_WORDS.usage.export}`, table, io);
6829
+ 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);
6830
+ const read = readConfig(io.cwd);
6831
+ if ("error" in read) return usage(read.error, table, io);
6832
+ const stack = resolveStack(read.config, read.path, named[0], common.dir);
6833
+ if ("error" in stack) return usage(stack.error, table, io);
6834
+ useBackend([stack], io.env);
6835
+ const rules = requestRules(read.config);
6836
+ const outFile = resolve2(io.cwd, out.value);
6837
+ return runRequest("export", json, table, io, async (ops) => {
6838
+ const outcome = await ops.gatedStackExport({
6839
+ by: requesterOf(common.by, io.env),
6840
+ rules,
6841
+ op: STACK_OPS_OP,
6842
+ ledgerCwd: io.cwd,
6843
+ env: io.env,
6844
+ dir: stack.dir,
6845
+ stack: stack.stack,
6846
+ out: outFile,
6847
+ ...stack.engine ? { engine: stack.engine } : {}
6848
+ });
6849
+ return reportOutcome("export", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.exported(stack.key, r), json, table, io);
6850
+ });
6851
+ }
6852
+ async function cancelStack(args, json, table, io) {
6853
+ const common = commonFlags(args);
6854
+ if (common.missing) return usage(`${common.missing} takes a value`, table, io);
6855
+ const lock = takeFlag(common.rest, "--lock");
6856
+ if (lock.missing) return usage("--lock takes the lock id", table, io);
6857
+ const extra = lock.rest.filter((a) => a.startsWith("-"));
6858
+ if (extra.length) return usage(`cancel does not take ${extra.join(" ")}`, table, io);
6859
+ const named = positionals(lock.rest);
6860
+ if (named.length !== 1) return usage(`cancel takes one stack: ${STACK_OP_WORDS.usage.cancel}`, table, io);
6861
+ const read = readConfig(io.cwd);
6862
+ if ("error" in read) return usage(read.error, table, io);
6863
+ const stack = resolveStack(read.config, read.path, named[0], common.dir);
6864
+ if ("error" in stack) return usage(stack.error, table, io);
6865
+ useBackend([stack], io.env);
6866
+ const lockId = await lockToCancel(lock.value, stack, io.env);
6867
+ if ("error" in lockId) return usage(lockId.error, table, io);
6868
+ if (lockId.read && !json) io.err(`${PRODUCT} cancel: ${stack.key} holds lock ${lockId.lockId}`);
6869
+ const rules = requestRules(read.config);
6870
+ return runRequest("cancel", json, table, io, async (ops) => {
6871
+ const outcome = await ops.gatedStackCancel({
6872
+ by: requesterOf(common.by, io.env),
6873
+ rules,
6874
+ op: STACK_OPS_OP,
6875
+ ledgerCwd: io.cwd,
6876
+ env: io.env,
6877
+ dir: stack.dir,
6878
+ stack: stack.stack,
6879
+ lockId: lockId.lockId,
6880
+ project: stack.project,
6881
+ ...stack.engine ? { engine: stack.engine } : {}
6882
+ });
6883
+ return reportOutcome("cancel", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.cancelled(stack.key, lockId.lockId, r.lockChecked), json, table, io);
6884
+ });
6885
+ }
6886
+ async function moveStack(args, json, table, io) {
6887
+ const onto = takeFlag(args, "--onto");
6888
+ if (onto.missing) return usage("--onto takes plunge", table, io);
6889
+ if (onto.value !== void 0) return moveOntoPlunge(onto.value, onto.rest, json, table, io);
6890
+ const common = commonFlags(onto.rest);
6891
+ if (common.missing) return usage(`${common.missing} takes a value`, table, io);
6892
+ const from = takeFlag(common.rest, "--from");
6893
+ const to = takeFlag(from.rest, "--to");
6894
+ if (from.missing || to.missing) return usage(`${from.missing ? "--from" : "--to"} takes a stack, as project/stack`, table, io);
6895
+ const extra = to.rest.filter((a) => a.startsWith("-"));
6896
+ if (extra.length) return usage(`move does not take ${extra.join(" ")}`, table, io);
6897
+ if (common.dir !== void 0) return usage("move reads each stack's directory from plywood.yml's stacks, so it does not take --dir", table, io);
6898
+ const urns = positionals(to.rest);
6899
+ if (!from.value || !to.value) return usage(`move needs --from and --to: ${STACK_OP_WORDS.usage.move}`, table, io);
6900
+ if (urns.length === 0) return usage(`move names each resource it moves by URN: ${STACK_OP_WORDS.usage.move}`, table, io);
6901
+ const bad = urns.filter((u) => !u.startsWith("urn:pulumi:"));
6902
+ if (bad.length) return usage(`move takes URNs (urn:pulumi:...), not ${bad.join(", ")}; 'pulumi stack --show-urns' lists them`, table, io);
6903
+ if (from.value === to.value) return usage(`--from and --to are both ${from.value}; a move is between two stacks`, table, io);
6904
+ const read = readConfig(io.cwd);
6905
+ if ("error" in read) return usage(read.error, table, io);
6906
+ const source = resolveStack(read.config, read.path, from.value, void 0);
6907
+ if ("error" in source) return usage(source.error, table, io);
6908
+ const dest = resolveStack(read.config, read.path, to.value, void 0);
6909
+ if ("error" in dest) return usage(dest.error, table, io);
6910
+ const backend = useBackend([source, dest], io.env);
6911
+ if (backend) return usage(backend, table, io);
6912
+ const rules = requestRules(read.config);
6913
+ const destName = destStackName(source, dest, read.config);
6914
+ const engine2 = source.engine ?? dest.engine;
6915
+ return runRequest("move", json, table, io, async (ops) => {
6916
+ const outcome = await ops.gatedStackMove({
6917
+ by: requesterOf(common.by, io.env),
6918
+ rules,
6919
+ op: STACK_OPS_OP,
6920
+ ledgerCwd: io.cwd,
6921
+ env: io.env,
6922
+ source: { member: source.key, dir: source.dir, stack: source.stack },
6923
+ dest: { member: dest.key, dir: dest.dir, stack: destName },
6924
+ urns,
6925
+ ...engine2 ? { engine: engine2 } : {}
6926
+ });
6927
+ return reportOutcome("move", outcome, `move:${source.stack}:${destName}`, rules, (r) => STACK_OP_WORDS.moved(source.key, dest.key, r), json, table, io);
6928
+ });
6929
+ }
6930
+ async function moveOntoPlunge(engine2, args, json, table, io) {
6931
+ if (engine2 !== "plunge") return usage(`--onto takes plunge, not '${engine2}'; a move between backends is an export and an import (see '${PRODUCT} export')`, table, io);
6932
+ const extra = args.filter((a) => a.startsWith("-"));
6933
+ if (extra.length) return usage(`move --onto does not take ${extra.join(" ")}`, table, io);
6934
+ const named = positionals(args);
6935
+ if (named.length > 1) return usage(`move --onto plunge takes at most one stack`, table, io);
6936
+ const read = readConfig(io.cwd);
6937
+ if ("error" in read) return usage(read.error, table, io);
6938
+ const opFile = join16(dirname11(read.path), ADOPT_OP_FILE);
6939
+ if (!existsSync13(opFile)) return usage(STACK_OP_WORDS.noAdoptOp(opFile), table, io);
6940
+ if (named[0]) {
6941
+ const stack = resolveStack(read.config, read.path, named[0], void 0);
6942
+ if ("error" in stack) return usage(stack.error, table, io);
6943
+ if (!json) io.err(STACK_OP_WORDS.adoptAll(stack.key));
6944
+ }
6945
+ const argv = ["run", ADOPT_OP, ...json ? ["--json"] : []];
6946
+ return withChant(`${PRODUCT} move --onto plunge`, table, io, async (chant) => {
6947
+ const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
6948
+ if (code === table.waiting && !json) io.err(STACK_OP_WORDS.adoptWaiting);
6949
+ return code === table.waiting ? code : explainRefusal(code, table, io.err);
6950
+ });
6951
+ }
6952
+ async function rollbackStack(args, json, table, io) {
6953
+ const release2 = takeFlag(args, "--release");
6954
+ const env = takeFlag(release2.rest, "--env");
6955
+ const digest = takeFlag(env.rest, "--digest");
6956
+ const actor = takeFlag(digest.rest, "--actor");
6957
+ for (const [flag, f] of [["--release", release2], ["--env", env], ["--digest", digest], ["--actor", actor]]) {
6958
+ if (f.missing) return usage(`${flag} takes a value`, table, io);
6959
+ }
6960
+ const switches = actor.rest.filter((a) => a === "--dry-run" || a === "--events-json");
6961
+ const rest = actor.rest.filter((a) => !switches.includes(a));
6962
+ const extra = rest.filter((a) => a.startsWith("-"));
6963
+ if (extra.length) return usage(`rollback does not take ${extra.join(" ")}`, table, io);
6964
+ const named = positionals(rest);
6965
+ if (named.length > 1) return usage(`rollback takes one stack: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6966
+ let argv;
6967
+ if (release2.value !== void 0) {
6968
+ if (named.length) return usage(STACK_OP_WORDS.releaseIsEnvWide(named[0], environmentOf(named[0])), table, io);
6969
+ if (digest.value !== void 0) return usage("--release takes no --digest: the release names what each stack goes back to", table, io);
6970
+ if (!env.value) return usage(`rollback --release needs --env <env>, the environment whose stacks go back: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6971
+ argv = ["components", "rollback", env.value, "--release", release2.value];
6972
+ } else {
6973
+ if (named.length !== 1) return usage(`rollback takes a stack, or --release <name> --env <env>: ${STACK_OP_WORDS.usage.rollback}`, table, io);
6974
+ const read = readConfig(io.cwd);
6975
+ if ("error" in read) return usage(read.error, table, io);
6976
+ const stack = resolveStack(read.config, read.path, named[0], void 0);
6977
+ if ("error" in stack) return usage(stack.error, table, io);
6978
+ argv = ["components", "rollback", env.value ?? environmentOf(stack.stack), "--component", slug(stack.key), ...digest.value ? ["--digest", digest.value] : []];
6979
+ }
6980
+ argv.push(...actor.value ? ["--actor", actor.value] : [], ...switches, ...json ? ["--json"] : []);
6981
+ if (!await chantStackRollback()) {
6982
+ io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} rollback`, "rollback by release for stacks (chant#4542)")}`);
6983
+ return table.failed;
6984
+ }
6985
+ return withChant(`${PRODUCT} rollback`, table, io, async (chant) => {
6986
+ const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
6987
+ if (code === table.waiting && !json) io.err(STACK_OP_WORDS.rollbackWaiting);
6988
+ return code === table.waiting ? code : explainRefusal(code, table, io.err);
6989
+ });
6913
6990
  }
6914
6991
 
6915
- // src/cli.ts
6916
- init_control2();
6917
-
6918
- // src/telemetry.ts
6919
- init_chant_product();
6920
- init_observe();
6921
- init_config();
6992
+ // src/actions.ts
6922
6993
  init_words();
6923
- function telemetryEnv(doc, env) {
6924
- const t = telemetryOf(doc);
6925
- const out = {};
6926
- const set = (k, v) => {
6927
- if (v !== void 0 && v !== "" && env[k] === void 0) out[k] = v;
6928
- };
6929
- if (!t.traces) {
6930
- set("OTEL_TRACES_EXPORTER", "none");
6931
- return out;
6932
- }
6933
- if (env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT === void 0) set("OTEL_EXPORTER_OTLP_ENDPOINT", t.endpoint);
6934
- if (t.headersEnv) set("OTEL_EXPORTER_OTLP_HEADERS", env[t.headersEnv]);
6935
- if (t.endpoint !== void 0 || env.OTEL_EXPORTER_OTLP_ENDPOINT !== void 0 || env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT !== void 0) set("OTEL_SERVICE_NAME", t.serviceName);
6936
- return out;
6994
+ var actionSubject = (stack, name) => `action:${stack}:${name}`;
6995
+ var cachedAction;
6996
+ function chantStackAction() {
6997
+ cachedAction ??= import("@intentius/chant-lexicon-pulumi").then((m) => m.gatedStackAction);
6998
+ return cachedAction;
6937
6999
  }
6938
- async function applyTelemetry(cwd, env = process.env) {
6939
- let added = {};
7000
+ async function fromEvent(env) {
7001
+ const chant = await chantProduct();
7002
+ if (!chant) return { error: `${PRODUCT} action --from-event reads the action job's comment or inputs through chant's productActionFromEnv, and chant's product entry did not load` };
7003
+ const words = chant.assertProductWords({ ...PRODUCT_WORDS, commands: { ...PRODUCT_WORDS.commands, ...ACTION_COMMAND } });
7004
+ const asked = chant.productActionFromEnv(words, env);
7005
+ if (asked.kind === "refused") return { error: `the action job was asked for no action: ${asked.reason}` };
7006
+ return { stack: asked.member, name: asked.address };
7007
+ }
7008
+ async function action(args, json, table, io) {
7009
+ const event = args.includes("--from-event");
7010
+ const anew = event || args.includes("--anew");
7011
+ const common = commonFlags(args.filter((a) => a !== "--from-event" && a !== "--anew"));
7012
+ if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7013
+ const extra = common.rest.filter((a) => a.startsWith("-"));
7014
+ if (extra.length) return usage(`action does not take ${extra.join(" ")}`, table, io);
7015
+ const named = positionals(common.rest);
7016
+ let key;
7017
+ let name;
7018
+ if (event) {
7019
+ if (named.length) return usage(`action --from-event reads the stack and the action from the job's event, so it takes neither: ${STACK_OP_WORDS.usage.action}`, table, io);
7020
+ const asked = await fromEvent(io.env);
7021
+ if ("error" in asked) return usage(asked.error, table, io);
7022
+ ({ stack: key, name } = asked);
7023
+ } else {
7024
+ if (named.length !== 2) return usage(`action takes a stack and an action's name: ${STACK_OP_WORDS.usage.action}`, table, io);
7025
+ [key, name] = named;
7026
+ }
7027
+ const read = readConfig(io.cwd);
7028
+ if ("error" in read) return usage(read.error, table, io);
7029
+ const stack = resolveStack(read.config, read.path, key, common.dir);
7030
+ if ("error" in stack) return usage(stack.error, table, io);
7031
+ const declared = actionsOf(read.config.doc, stack.key);
7032
+ const chosen = declared.find((a) => a.name === name);
7033
+ if (!chosen) return usage(STACK_OP_WORDS.noAction(stack.key, name, declared.map((a) => a.name)), table, io);
7034
+ const gated = await chantStackAction();
7035
+ useBackend([stack], io.env);
7036
+ const rules = requestRules(read.config);
6940
7037
  try {
6941
- const file = findConfig(cwd);
6942
- if (file) added = telemetryEnv(loadConfig(file).doc, env);
6943
- } catch {
6944
- return {};
7038
+ const outcome = await gated({
7039
+ by: requesterOf(common.by, io.env),
7040
+ rules,
7041
+ op: STACK_OPS_OP,
7042
+ ledgerCwd: io.cwd,
7043
+ env: io.env,
7044
+ dir: stack.dir,
7045
+ stack: stack.stack,
7046
+ urn: chosen.urn,
7047
+ name: chosen.name,
7048
+ ...stack.engine ? { engine: stack.engine } : {},
7049
+ ...anew ? { anew: true } : {}
7050
+ });
7051
+ return reportOutcome("action", outcome, actionSubject(stack.stack, chosen.name), rules, (r) => STACK_OP_WORDS.acted(stack.key, chosen.name, r), json, table, io);
7052
+ } catch (e) {
7053
+ const err = e;
7054
+ const refusal = typeof err.code === "string" && err.code.startsWith("action-") ? err.code : void 0;
7055
+ const urns = Array.isArray(err.urns) ? err.urns : [];
7056
+ if (json) {
7057
+ io.out(JSON.stringify({
7058
+ command: "action",
7059
+ ok: false,
7060
+ exitCode: table.failed,
7061
+ op: STACK_OPS_OP,
7062
+ gate: actionSubject(stack.stack, chosen.name),
7063
+ error: err.message,
7064
+ ...refusal ? { refusal } : {},
7065
+ ...urns.length ? { urns } : {}
7066
+ }));
7067
+ } else io.err(`${PRODUCT} action: ${err.message}`);
7068
+ return table.failed;
6945
7069
  }
6946
- Object.assign(env, added);
6947
- const chant = await chantProduct();
6948
- if (chant && typeof chant.configureOtlp === "function") chant.configureOtlp({ prefix: PRODUCT, env });
6949
- return added;
6950
7070
  }
6951
7071
 
6952
- // src/cli.ts
6953
- init_init();
6954
- init_stages();
6955
- init_tips();
6956
- init_images();
6957
- init_up();
6958
-
6959
- // src/upgrade.ts
6960
- init_config();
6961
- init_images();
7072
+ // src/audit.ts
7073
+ init_detect();
6962
7074
  init_stages();
6963
7075
  init_words();
6964
- import { execFile } from "node:child_process";
6965
- import { existsSync as existsSync15, readFileSync as readFileSync17, writeFileSync as writeFileSync9 } from "node:fs";
6966
- import { dirname as dirname13, join as join18, relative as relative2, resolve as resolve2 } from "node:path";
6967
- var PLYWOOD_PACKAGE = "@intentius/plywood";
6968
- var CHANT_PACKAGE = "@intentius/chant";
6969
- var CHANT_FAMILY = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?$/;
6970
- var VERSION2 = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
6971
- var DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
6972
- function parseUpgradeArgs(args) {
6973
- const chant = takeFlag(args, "--chant");
6974
- const forge = takeFlag(chant.rest, "--forge");
6975
- const env = takeFlag(forge.rest, "--env");
6976
- if (chant.missing || forge.missing || env.missing) return { error: "--chant, --forge and --env each take a value" };
6977
- const flags = /* @__PURE__ */ new Set(["--no-install", "--dry-run"]);
6978
- const unknown = env.rest.filter((a) => a.startsWith("-") && !flags.has(a));
6979
- if (unknown.length) return { error: `upgrade does not take ${unknown.join(" ")}` };
6980
- const words = env.rest.filter((a) => !a.startsWith("-"));
6981
- if (words.length > 1) return { error: `upgrade takes one version, not ${words.join(" ")}` };
6982
- const version2 = words[0]?.replace(/^v/, "");
6983
- if (version2 !== void 0 && !VERSION2.test(version2)) return { error: `${words[0]} is not a version: give it as MAJOR.MINOR.PATCH` };
6984
- if (chant.value !== void 0 && !VERSION2.test(chant.value)) return { error: `--chant ${chant.value} is not a version: give it as MAJOR.MINOR.PATCH` };
6985
- return {
6986
- ...version2 ? { version: version2 } : {},
6987
- ...chant.value ? { chant: chant.value } : {},
6988
- ...forge.value ? { forge: forge.value } : {},
6989
- ...env.value ? { env: env.value } : {},
6990
- install: !env.rest.includes("--no-install"),
6991
- dryRun: env.rest.includes("--dry-run")
6992
- };
7076
+ var AUDIT_TABLES = { approvals: "gates", updates: "applies", runs: "runs", "review-stacks": "pr_envs" };
7077
+ var DEFAULT_AUDIT_TABLE = "approvals";
7078
+ function auditTable(word) {
7079
+ const w = word ?? DEFAULT_AUDIT_TABLE;
7080
+ return AUDIT_TABLES[w] ?? w;
6993
7081
  }
6994
- function findPlywoodPackage(dir) {
6995
- let cur = resolve2(dir);
6996
- for (let depth = 0; depth < 32; depth++) {
6997
- const file = join18(cur, "package.json");
6998
- if (existsSync15(file)) {
6999
- try {
7000
- const pkg = JSON.parse(readFileSync17(file, "utf8"));
7001
- if (DEP_FIELDS.some((f) => isObject2(pkg[f]) && typeof pkg[f][PLYWOOD_PACKAGE] === "string")) return file;
7002
- } catch {
7003
- }
7004
- }
7005
- const up2 = dirname13(cur);
7006
- if (up2 === cur) return void 0;
7007
- cur = up2;
7082
+ function auditMember(stack) {
7083
+ const [project, name] = stack.split("/");
7084
+ return project && name ? slug(project).slice(0, 36).replace(/-+$/, "") : void 0;
7085
+ }
7086
+ function auditArgv(args, json) {
7087
+ const stack = takeFlag(args, "--stack");
7088
+ const since = takeFlag(stack.rest, "--since");
7089
+ const at = takeFlag(since.rest, "--at");
7090
+ for (const [flag, f] of [["--stack", stack], ["--since", since], ["--at", at]]) if (f.missing) return { error: `${flag} takes a value` };
7091
+ const wheres = [];
7092
+ const rest = [];
7093
+ for (let i = 0; i < at.rest.length; i++) {
7094
+ const a = at.rest[i];
7095
+ if (a === "--where") {
7096
+ const v = at.rest[i + 1];
7097
+ if (v === void 0 || v.startsWith("--")) return { error: "--where takes <field><op><value>, such as gate=wave-2" };
7098
+ wheres.push("--where", v);
7099
+ i++;
7100
+ } else rest.push(a);
7008
7101
  }
7009
- return void 0;
7102
+ const flags = rest.filter((a) => a.startsWith("-"));
7103
+ if (flags.length) return { error: `audit does not take ${flags.join(" ")}` };
7104
+ if (rest.length > 1) return { error: `audit reads one table: ${Object.keys(AUDIT_TABLES).join(", ")}, or a chant ledger table` };
7105
+ let member;
7106
+ if (stack.value !== void 0) {
7107
+ member = auditMember(stack.value);
7108
+ if (!member) return { error: `--stack takes project/stack, not '${stack.value}'` };
7109
+ }
7110
+ return [
7111
+ "ledger",
7112
+ auditTable(rest[0]),
7113
+ ...member ? ["--member", member] : [],
7114
+ ...since.value ? ["--since", since.value] : [],
7115
+ ...wheres,
7116
+ ...at.value ? ["--at", at.value] : [],
7117
+ ...json ? ["--json"] : []
7118
+ ];
7010
7119
  }
7011
- function movedRange(range, version2) {
7012
- const m = /^([~^]?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(range.trim());
7013
- if (!m) return void 0;
7014
- return `${m[1]}${version2}`;
7120
+ async function audit2(args, json, table, io) {
7121
+ const argv = auditArgv(args, json);
7122
+ if ("error" in argv) return usage(argv.error, table, io);
7123
+ return withChant(`${PRODUCT} audit`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
7015
7124
  }
7016
- function movePackagePins(text, file, plywood, chant) {
7017
- const pkg = JSON.parse(text);
7018
- const moves = [];
7019
- const left = [];
7020
- for (const field of DEP_FIELDS) {
7021
- const deps = pkg[field];
7022
- if (!isObject2(deps)) continue;
7023
- for (const [name, range] of Object.entries(deps)) {
7024
- if (typeof range !== "string") continue;
7025
- const target = name === PLYWOOD_PACKAGE ? plywood : CHANT_FAMILY.test(name) ? chant : void 0;
7026
- if (target === void 0) continue;
7027
- const next = movedRange(range, target);
7028
- if (next === void 0) {
7029
- left.push(`${field}.${name} is '${range}'; move it to ${target} by hand`);
7030
- continue;
7031
- }
7032
- if (next === range) continue;
7033
- deps[name] = next;
7034
- moves.push({ file, name, from: range, to: next });
7125
+
7126
+ // src/cli.ts
7127
+ init_agents2();
7128
+ init_drift2();
7129
+
7130
+ // src/estate.ts
7131
+ init_chant_product();
7132
+ init_chant_config();
7133
+ init_config();
7134
+ init_stages();
7135
+ init_words();
7136
+ import { dirname as dirname12 } from "node:path";
7137
+ var estateModule;
7138
+ function chantEstate() {
7139
+ estateModule ??= (async () => {
7140
+ try {
7141
+ const m = await import("@intentius/chant/ci-estate");
7142
+ const r = await import("@intentius/chant/pr-report-url");
7143
+ if (!["readEstateLedger", "buildLedgerEstate", "writeEstate"].every((f) => typeof m[f] === "function")) return void 0;
7144
+ if (typeof r.parseReportUrl !== "function" || typeof r.reportPut !== "function") return void 0;
7145
+ return { ...m, parseReportUrl: r.parseReportUrl, reportPut: r.reportPut };
7146
+ } catch {
7147
+ return void 0;
7035
7148
  }
7036
- }
7037
- return { text: moves.length ? jsonText2(pkg, text) : text, moves, left };
7038
- }
7039
- function moveDeclarationPins(text, file, chant) {
7040
- const doc = JSON.parse(text);
7041
- const moves = [];
7042
- const pins = Array.isArray(doc.pins) ? doc.pins : [];
7043
- for (const pin of pins) {
7044
- if (typeof pin.package !== "string" || !CHANT_FAMILY.test(pin.package) || typeof pin.version !== "string") continue;
7045
- if (pin.version === chant) continue;
7046
- moves.push({ file, name: pin.package, from: pin.version, to: chant });
7047
- pin.version = chant;
7048
- }
7049
- return { text: moves.length ? jsonText2(doc, text) : text, moves };
7149
+ })();
7150
+ return estateModule;
7050
7151
  }
7051
- function imageNote(doc, version2) {
7052
- const image = doc.image;
7053
- const name = typeof image === "string" ? image : isObject2(image) && typeof image.name === "string" ? image.name : void 0;
7054
- if (name === void 0) return void 0;
7055
- const m = new RegExp(`^${REGISTRY.replace(/[.]/g, "\\.")}/plywood-(pulumi|plunge):([^@]+)`).exec(name);
7056
- if (m && m[2].startsWith(`${version2}-`)) return void 0;
7057
- if (m) return `plywood.yml's image is plywood's ${m[1]} image at ${m[2]}; name the ${version2} image in it (its tag starts ${version2}-), or drop the key to take the release's own`;
7058
- return `plywood.yml's image is your own (${name}); rebuild it FROM plywood's ${version2} image and name the new digest in it`;
7152
+ var ESTATE_NEEDS_CHANT = `${PRODUCT} estate needs chant >= the release after 0.124.54, which carries @intentius/chant/ci-estate (chant#4612). Run '${PRODUCT} audit' for the ledger's rows on this chant.`;
7153
+ function estateSchedules(config2) {
7154
+ const drift3 = toChantSettings(config2).pipeline.drift;
7155
+ const driftOps = (drift3?.stacks ?? []).map(driftOpName);
7156
+ const schedules = drift3?.schedule ? driftOps.map((op) => ({ op, cron: drift3.schedule })) : [];
7157
+ return { schedules, driftOps };
7059
7158
  }
7060
- async function viewRelease(exec, cwd, env, version2) {
7061
- const spec = `${PLYWOOD_PACKAGE}@${version2 ?? "latest"}`;
7062
- const r = await exec("npm", ["view", spec, "version", "dependencies", "--json"], { cwd, env });
7063
- if (r.code !== 0) {
7064
- const notFound = /E404/.test(r.stderr + r.stdout);
7065
- return { error: notFound ? `npm has no ${spec}` : `npm view ${spec} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] ?? ""}` };
7066
- }
7067
- let parsed;
7068
- try {
7069
- parsed = JSON.parse(r.stdout);
7070
- } catch {
7071
- return { error: `npm view ${spec} printed no JSON` };
7159
+ async function estate(args, json, table, io) {
7160
+ const failOverdue = args.includes("--fail-overdue");
7161
+ let rest = args.filter((a) => a !== "--fail-overdue");
7162
+ const flags = {};
7163
+ for (const flag of ["--file", "--report-url", "--prefix"]) {
7164
+ const f = takeFlag(rest, flag);
7165
+ if (f.missing) return usage(`${flag} takes a value`, table, io);
7166
+ flags[flag] = f.value;
7167
+ rest = f.rest;
7072
7168
  }
7073
- const doc = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
7074
- if (!isObject2(doc) || typeof doc.version !== "string") return { error: `npm view ${spec} named no version` };
7075
- const deps = isObject2(doc.dependencies) ? doc.dependencies : {};
7076
- const chant = deps[CHANT_PACKAGE];
7077
- if (typeof chant !== "string" || !VERSION2.test(chant)) return { error: `${PLYWOOD_PACKAGE}@${doc.version} pins no exact ${CHANT_PACKAGE}` };
7078
- return { version: doc.version, chant };
7079
- }
7080
- async function upgrade(args, json, table, io) {
7081
- const opts = parseUpgradeArgs(args);
7082
- const fail = (message) => {
7083
- if (json) io.out(JSON.stringify({ command: "upgrade", ok: false, exitCode: table.failed, error: message }));
7084
- else io.err(`${PRODUCT}: ${message}`);
7169
+ if (rest.length) return usage(`estate does not take ${rest.join(" ")}`, table, io);
7170
+ const chant = await chantEstate();
7171
+ if (!chant) {
7172
+ if (json) io.out(JSON.stringify({ command: "estate", ok: false, exitCode: table.failed, error: ESTATE_NEEDS_CHANT }));
7173
+ else io.err(`${PRODUCT}: ${ESTATE_NEEDS_CHANT}`);
7085
7174
  return table.failed;
7086
- };
7087
- if ("error" in opts) return fail(opts.error);
7088
- const exec = io.exec ?? execChild;
7089
- const pkgFile = findPlywoodPackage(io.cwd);
7090
- if (!pkgFile) return fail(`no package.json in ${io.cwd} or above it depends on ${PLYWOOD_PACKAGE}; install it with 'npm i -D ${PLYWOOD_PACKAGE}', then run '${PRODUCT} init'`);
7091
- const pkgDir = dirname13(pkgFile);
7092
- const configFile = findConfig(io.cwd);
7093
- const configDir = configFile ? dirname13(configFile) : pkgDir;
7094
- const rel = (f) => relative2(io.cwd, f) || f;
7095
- const release2 = await viewRelease(exec, pkgDir, io.env, opts.version);
7096
- if ("error" in release2) return fail(release2.error);
7097
- const chant = opts.chant ?? release2.chant;
7098
- const pkgText = readFileSync17(pkgFile, "utf8");
7099
- const pkgMove = movePackagePins(pkgText, rel(pkgFile), release2.version, chant);
7100
- const declFile = join18(configDir, "chant.workspace.json");
7101
- const declText = existsSync15(declFile) ? readFileSync17(declFile, "utf8") : void 0;
7102
- let declMove;
7103
- if (declText !== void 0) {
7175
+ }
7176
+ const path = flags["--file"] ?? findConfig(io.cwd);
7177
+ let config2;
7178
+ if (path) {
7104
7179
  try {
7105
- declMove = moveDeclarationPins(declText, rel(declFile), chant);
7180
+ config2 = loadConfig(path);
7106
7181
  } catch (e) {
7107
- return fail(`${rel(declFile)} is not JSON upgrade can move pins in (${e.message})`);
7182
+ return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
7108
7183
  }
7109
- }
7110
- const notes = [...pkgMove.left];
7111
- if (existsSync15(join18(configDir, "chant.workspace.jsonc"))) notes.push(`chant.workspace.jsonc is yours: move its @intentius/chant* pins to ${chant} by hand`);
7112
- if (configFile) {
7113
- try {
7114
- const note = imageNote(loadConfig(configFile).doc, release2.version);
7115
- if (note) notes.push(note);
7116
- } catch {
7184
+ const errors = checkConfig(config2).filter((p) => p.severity === "error");
7185
+ if (errors.length) {
7186
+ for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
7187
+ return table.failed;
7117
7188
  }
7118
- } else {
7119
- notes.push(`no plywood.yml, so there is no workflow to write again; run '${PRODUCT} init' after the install`);
7120
- }
7121
- const moves = [...pkgMove.moves, ...declMove?.moves ?? []];
7122
- const ciArgs = ["--no-install", PRODUCT, "ci", ...opts.forge ? ["--forge", opts.forge] : [], ...opts.env ? ["--env", opts.env] : []];
7123
- const steps = {
7124
- install: { cwd: rel(pkgDir) || ".", command: "npm install --no-audit --no-fund" },
7125
- ci: { cwd: rel(configDir) || ".", command: `npx ${ciArgs.join(" ")}` }
7126
- };
7127
- const envelope = { command: "upgrade", plywood: release2.version, chant, moves, notes, dryRun: opts.dryRun };
7128
- const say = (line) => {
7129
- if (!json) io.err(line);
7130
- };
7131
- say(`${PRODUCT} upgrade: ${PLYWOOD_PACKAGE} ${release2.version}, ${CHANT_PACKAGE} ${chant}`);
7132
- for (const m of moves) say(` ${m.file} ${m.name} ${m.from} -> ${m.to}`);
7133
- if (moves.length === 0) say(" every pin is already there");
7134
- if (opts.dryRun) {
7135
- say(` then: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7136
- for (const n of notes) say(` note: ${n}`);
7137
- if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, steps }));
7138
- return table.done;
7139
- }
7140
- if (pkgMove.text !== pkgText) writeFileSync9(pkgFile, pkgMove.text);
7141
- if (declMove && declText !== void 0 && declMove.text !== declText) writeFileSync9(declFile, declMove.text);
7142
- const files = [...new Set(moves.map((m) => m.file))];
7143
- if (!opts.install) {
7144
- say(` left to run: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7145
- for (const n of notes) say(` note: ${n}`);
7146
- if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, files, steps }));
7147
- return table.done;
7148
7189
  }
7149
- 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) {
7152
- 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()}`);
7190
+ const { schedules, driftOps } = config2 ? estateSchedules(config2) : { schedules: [], driftOps: [] };
7191
+ const product = await chantProduct();
7192
+ const words = product ? product.assertProductWords(PRODUCT_WORDS) : PRODUCT_WORDS;
7193
+ const root = path ? dirname12(path) : io.cwd;
7194
+ const load = await chant.readEstateLedger(root);
7195
+ const built = chant.buildLedgerEstate({ load, words, schedules, ...driftOps.length ? { driftOps } : {} });
7196
+ const url = flags["--report-url"] ?? (config2 ? toChantSettings(config2).ci.reportUrl : void 0);
7197
+ let written;
7198
+ if (url) {
7199
+ const target = chant.parseReportUrl(url, flags["--report-url"] ? "--report-url" : "reports.url");
7200
+ if (target && typeof target === "object" && "error" in target) return usage(String(target.error), table, io);
7201
+ written = await chant.writeEstate(built, { put: chant.reportPut(target), words, env: io.env, ...flags["--prefix"] ? { prefix: flags["--prefix"] } : {} });
7155
7202
  }
7156
- const got = installedVersion(pkgDir);
7157
- if (got !== void 0 && got !== release2.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release2.version}; check the install`);
7158
- let ci2;
7159
- if (configFile) {
7160
- say(` $ ${steps.ci.command}`);
7161
- ci2 = await exec("npx", ciArgs, { cwd: configDir, env: io.env });
7162
- if (!json && ci2.stderr.trim()) io.err(ci2.stderr.trimEnd());
7203
+ const code = failOverdue && built.overdue.length > 0 ? table.failed : table.done;
7204
+ if (json) {
7205
+ io.out(JSON.stringify({ command: "estate", ok: code === table.done, exitCode: code, estate: built.estate, dora: built.dora, overdue: built.overdue, ...written ? { written } : {} }));
7206
+ return code;
7163
7207
  }
7164
- for (const n of notes) say(` note: ${n}`);
7165
- const ok = ci2 === void 0 || ci2.code === 0;
7166
- const lockFile = join18(pkgDir, "package-lock.json");
7167
- const changed = [...files, ...existsSync15(lockFile) ? [rel(lockFile)] : []];
7168
- if (ok) say(` Open one pull request with ${changed.join(", ")} and the files ${PRODUCT} ci wrote.`);
7169
- else say(` ${PRODUCT} ci failed (exit ${ci2?.code}); the pins are moved and installed, so fix plywood.yml and run '${steps.ci.command}'`);
7170
- const code = ok ? table.done : ci2?.code ?? table.failed;
7171
- if (json) io.out(JSON.stringify({ ...envelope, ok, exitCode: code, files: changed, ...ci2 ? { ci: { exitCode: ci2.code } } : {} }));
7208
+ const totals = built.estate.totals ?? {};
7209
+ io.out(`${PRODUCT} estate: ${Object.entries(totals).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing on the ledger yet"}`);
7210
+ for (const o of built.overdue) io.out(` ${o.message}`);
7211
+ if (written) io.out(` wrote ${written.keys.join(", ")} under ${url}`);
7212
+ else io.out(` no report URL (reports.url or --report-url), so nothing was written`);
7172
7213
  return code;
7173
7214
  }
7174
- function installedVersion(dir) {
7175
- try {
7176
- const pkg = JSON.parse(readFileSync17(join18(dir, "node_modules", PLYWOOD_PACKAGE, "package.json"), "utf8"));
7177
- return typeof pkg.version === "string" ? pkg.version : void 0;
7178
- } catch {
7179
- return void 0;
7215
+
7216
+ // src/notify.ts
7217
+ init_stages();
7218
+ init_words();
7219
+ var RELAY_NEEDS_CHANT = `${PRODUCT} notify relay needs chant >= the release after 0.124.54, which carries chant notify relay (chant#4611).`;
7220
+ async function notify(args, json, table, io) {
7221
+ if (args[0] !== "relay") return usage(`unknown notify command '${args[0] ?? ""}'. Run '${PRODUCT} notify relay'.`, table, io);
7222
+ return withChant(`${PRODUCT} notify relay`, table, io, (chant) => {
7223
+ if (typeof chant.startRelayServer !== "function") {
7224
+ if (json) io.out(JSON.stringify({ command: "notify relay", ok: false, exitCode: table.failed, error: RELAY_NEEDS_CHANT }));
7225
+ else io.err(`${PRODUCT}: ${RELAY_NEEDS_CHANT}`);
7226
+ return Promise.resolve(table.failed);
7227
+ }
7228
+ return chant.runChantInProcess(["notify", ...args], PRODUCT_WORDS);
7229
+ });
7230
+ }
7231
+
7232
+ // src/cli.ts
7233
+ init_policy();
7234
+
7235
+ // src/guard.ts
7236
+ init_stages();
7237
+ init_words();
7238
+ import { chmodSync, copyFileSync, existsSync as existsSync14, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "node:fs";
7239
+ import { join as join17 } from "node:path";
7240
+ var GUARDED = {
7241
+ pulumi: ["up", "update", "destroy"],
7242
+ plunge: ["up", "update", "destroy"]
7243
+ };
7244
+ var VALUED_GLOBALS = /* @__PURE__ */ new Set(["-C", "--cwd", "-s", "--stack", "--color", "--config-file", "--logflow", "-v", "--verbose", "--tracing", "--profiling", "--memprofilerate"]);
7245
+ var LAUNCHERS = /* @__PURE__ */ new Set(["env", "npx", "sudo", "command", "exec", "nohup", "time", "xargs"]);
7246
+ function segments(line) {
7247
+ return line.split(/\|\||&&|[;|\n`]|\$\(|\)/).map((seg) => (seg.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^['"]|['"]$/g, ""))).filter((t) => t.length > 0);
7248
+ }
7249
+ function guardVerdict(line) {
7250
+ for (const words of segments(line)) {
7251
+ let i = 0;
7252
+ while (i < words.length && (LAUNCHERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || words[i - 1] === "env" && words[i].startsWith("-"))) i++;
7253
+ const bin = (words[i] ?? "").split("/").pop();
7254
+ const refused = GUARDED[bin];
7255
+ if (!refused) continue;
7256
+ for (let j = i + 1; j < words.length; j++) {
7257
+ const w = words[j];
7258
+ if (w.startsWith("-")) {
7259
+ if (VALUED_GLOBALS.has(w)) j++;
7260
+ continue;
7261
+ }
7262
+ if (refused.includes(w)) {
7263
+ return {
7264
+ engine: bin,
7265
+ subcommand: w,
7266
+ message: `${PRODUCT} guard: an agent session does not run '${bin} ${w}'. Open a pull request; ${PRODUCT} previews it, and the update runs after a person approves the digest.`
7267
+ };
7268
+ }
7269
+ break;
7270
+ }
7180
7271
  }
7272
+ return void 0;
7181
7273
  }
7182
- function jsonText2(value, original) {
7183
- const indent = /^[{[]\r?\n([ \t]+)/.exec(original)?.[1] ?? " ";
7184
- return `${JSON.stringify(value, null, indent)}
7185
- `;
7274
+ var GUARD_TEMPLATES = new URL("../templates/agent-guard/", import.meta.url);
7275
+ var HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/plywood-guard.mjs"';
7276
+ function withGuardHook(settings) {
7277
+ const hooks = typeof settings.hooks === "object" && settings.hooks !== null ? settings.hooks : {};
7278
+ const pre = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
7279
+ const has = JSON.stringify(pre).includes("plywood-guard.mjs");
7280
+ if (!has) pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK_COMMAND }] });
7281
+ return { ...settings, hooks: { ...hooks, PreToolUse: pre } };
7186
7282
  }
7187
- function isObject2(v) {
7188
- return typeof v === "object" && v !== null && !Array.isArray(v);
7283
+ async function guard(args, json, table, io) {
7284
+ const [sub, ...rest] = args;
7285
+ if (sub === "check") {
7286
+ const line = (rest[0] === "--" ? rest.slice(1) : rest).join(" ");
7287
+ if (!line.trim()) return usage("guard check takes the command line to check", table, io);
7288
+ const v = guardVerdict(line);
7289
+ if (json) io.out(JSON.stringify({ command: "guard check", ok: !v, exitCode: v ? table.denied : table.done, refused: v ?? null }));
7290
+ else io.out(v ? v.message : `${PRODUCT} guard: '${line}' runs`);
7291
+ return v ? table.denied : table.done;
7292
+ }
7293
+ if (sub === "install") {
7294
+ const dirFlag = takeFlag(rest, "--dir");
7295
+ if (dirFlag.missing) return usage("--dir takes a directory", table, io);
7296
+ if (dirFlag.rest.length) return usage(`guard install does not take ${dirFlag.rest.join(" ")}`, table, io);
7297
+ const root = dirFlag.value ? join17(io.cwd, dirFlag.value) : io.cwd;
7298
+ const written = [];
7299
+ const hookDir = join17(root, ".claude/hooks");
7300
+ mkdirSync7(hookDir, { recursive: true });
7301
+ copyFileSync(new URL("plywood-guard.mjs", GUARD_TEMPLATES), join17(hookDir, "plywood-guard.mjs"));
7302
+ written.push(".claude/hooks/plywood-guard.mjs");
7303
+ const settingsFile = join17(root, ".claude/settings.json");
7304
+ let settings = {};
7305
+ if (existsSync14(settingsFile)) {
7306
+ try {
7307
+ settings = JSON.parse(readFileSync15(settingsFile, "utf8"));
7308
+ } catch (e) {
7309
+ return usage(`.claude/settings.json is not JSON (${e.message}); fix it, then run this again`, table, io);
7310
+ }
7311
+ }
7312
+ writeFileSync8(settingsFile, JSON.stringify(withGuardHook(settings), null, 2) + "\n");
7313
+ written.push(".claude/settings.json");
7314
+ const binDir = join17(root, ".plywood/agent-bin");
7315
+ mkdirSync7(binDir, { recursive: true });
7316
+ for (const name of Object.keys(GUARDED)) {
7317
+ const to = join17(binDir, name);
7318
+ copyFileSync(new URL(`bin/${name}`, GUARD_TEMPLATES), to);
7319
+ chmodSync(to, 493);
7320
+ written.push(`.plywood/agent-bin/${name}`);
7321
+ }
7322
+ if (json) io.out(JSON.stringify({ command: "guard install", ok: true, exitCode: table.done, written }));
7323
+ else {
7324
+ for (const f of written) io.err(`${PRODUCT} guard: wrote ${f}`);
7325
+ io.err(`${PRODUCT} guard: an agent session that runs outside Claude Code puts .plywood/agent-bin first on PATH and sets PLYWOOD_AGENT_GUARD=1`);
7326
+ }
7327
+ return table.done;
7328
+ }
7329
+ return usage(`guard takes check <command line> or install [--dir <d>]`, table, io);
7189
7330
  }
7190
- var execChild = (bin, args, opts) => new Promise((done) => {
7191
- execFile(bin, args, { cwd: opts.cwd, env: opts.env, maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" }, (error2, stdout, stderr) => {
7192
- const code = error2 ? typeof error2.code === "number" ? error2.code : 1 : 0;
7193
- done({ code, stdout: String(stdout), stderr: String(stderr) });
7194
- });
7195
- });
7196
7331
 
7197
- // src/stack-ops.ts
7332
+ // src/identities.ts
7333
+ init_jobs2();
7198
7334
  init_chant_config();
7199
7335
  init_identity();
7200
7336
  init_config();
7201
7337
  init_stacks();
7202
- init_adopt();
7203
- init_detect();
7204
- init_refusals();
7205
7338
  init_stages();
7206
- init_chant_product();
7207
7339
  init_words();
7208
- import { existsSync as existsSync16 } from "node:fs";
7209
- import { dirname as dirname14, isAbsolute as isAbsolute2, join as join19, resolve as resolve3 } from "node:path";
7210
- var STACK_OPS_OP = "pulumi-stack-ops";
7211
- var REQUIRED4 = ["gatedStackExport", "gatedStackCancel", "gatedStackMove"];
7212
- var cachedLocks;
7213
- function chantStackLockReader() {
7214
- cachedLocks ??= (async () => {
7215
- try {
7216
- const m = await import("@intentius/chant-lexicon-pulumi");
7217
- return typeof m.pulumiStackLockIds === "function" ? m.pulumiStackLockIds : void 0;
7218
- } catch {
7219
- return void 0;
7340
+ import { existsSync as existsSync15, readFileSync as readFileSync16 } from "node:fs";
7341
+ import { dirname as dirname13, join as join18 } from "node:path";
7342
+ import { targetGlobMatch } from "@intentius/chant/ci-identity";
7343
+ function identityRows(config2, env) {
7344
+ const doc = config2.doc;
7345
+ const out = [];
7346
+ for (const stack of Object.keys(stacksOf(doc))) {
7347
+ const ids = stackIdentities(doc, stack, env, targetGlobMatch);
7348
+ for (const cloud of IDENTITY_CLOUDS) {
7349
+ const id = ids[cloud];
7350
+ if (id) out.push({ stack, cloud, preview: id.preview, up: id.up, rule: id.rule });
7220
7351
  }
7221
- })();
7222
- return cachedLocks;
7352
+ }
7353
+ return out;
7223
7354
  }
7224
- async function lockToCancel(given, stack, env) {
7225
- if (given) return { lockId: given, read: false };
7226
- const reader = await chantStackLockReader();
7227
- if (!reader) return { error: STACK_OP_WORDS.needsLock };
7228
- let locks2;
7355
+ function identityLines(config2, env, rows) {
7356
+ const doc = config2.doc;
7357
+ const block = chantIdentityOf(doc);
7358
+ if (!block) return [`${config2.file ?? "plywood.yml"}: no identity; every job runs as ${NO_IDENTITY}`];
7359
+ const lines2 = [`${config2.file ?? "plywood.yml"}: identities in environment ${env}`];
7360
+ const clouds = Object.keys(block.cloud);
7361
+ lines2.push(` clouds ${clouds.map((c) => CLOUD_NAMES[c]).join(", ")}`);
7362
+ if (clouds.includes("pulumi")) {
7363
+ const p = pulumiCloudOf(doc);
7364
+ lines2.push(` Pulumi Cloud ${p.org}${p.apiUrl ? ` at ${p.apiUrl}` : ""}; ESC environments open with each job's own token`);
7365
+ }
7366
+ const stacks2 = Object.keys(stacksOf(doc));
7367
+ if (stacks2.length === 0) lines2.push(" plywood.yml lists no stacks; the rules apply to each stack plywood init finds");
7368
+ for (const stack of stacks2) {
7369
+ lines2.push(` ${stack}`);
7370
+ for (const cloud of clouds) {
7371
+ const r = rows.find((x) => x.stack === stack && x.cloud === cloud);
7372
+ lines2.push(r ? ` ${CLOUD_NAMES[cloud].padEnd(12)} preview ${r.preview}, up ${r.up} (${r.rule})` : ` ${CLOUD_NAMES[cloud].padEnd(12)} ${NO_IDENTITY}`);
7373
+ }
7374
+ }
7375
+ lines2.push(" The preview job trades its OIDC token before checkout, and no later step holds a forge token.");
7376
+ return lines2;
7377
+ }
7378
+ function configIdentities(args, json, table, io) {
7379
+ const file = takeFlag(args, "--file");
7380
+ const env = takeFlag(file.rest, "--env");
7381
+ if (file.missing || env.missing) return usage(`${file.missing ? "--file" : "--env"} takes a value`, table, io);
7382
+ if (env.rest.length) return usage(`config identities does not take ${env.rest.join(" ")}`, table, io);
7383
+ const path = file.value ?? findConfig(io.cwd);
7384
+ if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
7385
+ let config2;
7229
7386
  try {
7230
- locks2 = await reader({ dir: stack.dir, stack: stack.stack, ...stack.project ? { project: stack.project } : {}, ...stack.engine ? { engine: stack.engine } : {}, env });
7387
+ config2 = loadConfig(path);
7231
7388
  } catch (e) {
7232
- return { error: `${STACK_OP_WORDS.needsLock}. Reading the stack's lock failed: ${e.message}` };
7389
+ return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
7233
7390
  }
7234
- if (!locks2.checked) return { error: STACK_OP_WORDS.lockNotListed(locks2.backend) };
7235
- if (locks2.ids.length === 0) return { error: STACK_OP_WORDS.noLock(stack.stack) };
7236
- if (locks2.ids.length > 1) return { error: STACK_OP_WORDS.manyLocks(stack.stack, locks2.ids) };
7237
- return { lockId: locks2.ids[0], read: true };
7391
+ const errors = checkConfig(config2).filter((p) => p.severity === "error");
7392
+ if (errors.length) {
7393
+ for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
7394
+ if (json) io.out(JSON.stringify({ command: "config identities", ok: false, exitCode: table.failed, file: path, problems: errors }));
7395
+ return table.failed;
7396
+ }
7397
+ const environment = env.value ?? "production";
7398
+ const rows = identityRows(config2, environment);
7399
+ if (json) io.out(JSON.stringify({ command: "config identities", ok: true, exitCode: table.done, file: path, env: environment, identity: toChantSettings(config2).ci.identity ?? null, stacks: rows }));
7400
+ else for (const line of identityLines(config2, environment, rows)) io.out(line);
7401
+ return table.done;
7238
7402
  }
7239
- var cachedOps;
7240
- var cachedRollback;
7241
- function chantStackOps() {
7242
- cachedOps ??= (async () => {
7403
+ var FORGE_CHECK_MESSAGE = `${PRODUCT} forge check holds the identities next to the forge's environments, protected branches and each cloud's trust policy. The read is chant's (@intentius/chant/forge-check, chant#4608), and the installed chant lacks it; it comes with the chant release after 0.124.54. Run '${PRODUCT} config identities' for what the jobs will hold.`;
7404
+ var FORGES2 = ["github", "gitlab", "forgejo"];
7405
+ var FORGE_CHECK_FUNCTIONS = ["forgeCheck", "forgeCheckExit", "describeForgeCheck", "forgeCheckTarget", "pipelineContract"];
7406
+ var forgeCheckModule;
7407
+ function chantForgeCheck() {
7408
+ forgeCheckModule ??= (async () => {
7243
7409
  try {
7244
- const m = await import("@intentius/chant-lexicon-pulumi");
7245
- return REQUIRED4.every((name) => typeof m[name] === "function") ? m : void 0;
7410
+ const fc = await import("@intentius/chant/forge-check");
7411
+ const id = await import("@intentius/chant/ci-identity");
7412
+ if (!FORGE_CHECK_FUNCTIONS.every((f) => typeof fc[f] === "function") || typeof id.resolveCloudIdentity !== "function") return void 0;
7413
+ return { ...fc, resolveCloudIdentity: id.resolveCloudIdentity };
7246
7414
  } catch {
7247
7415
  return void 0;
7248
7416
  }
7249
7417
  })();
7250
- return cachedOps;
7418
+ return forgeCheckModule;
7251
7419
  }
7252
- function chantStackRollback() {
7253
- cachedRollback ??= (async () => {
7254
- try {
7255
- const m = await import("@intentius/chant-lexicon-pulumi");
7256
- if (typeof m.pulumiReleaseInputs !== "function") return false;
7257
- const copy2 = await import("@intentius/chant-lexicon-pulumi/release-copy");
7258
- return typeof copy2.prepareReleaseCopy === "function";
7259
- } catch {
7260
- return false;
7261
- }
7262
- })();
7263
- return cachedRollback;
7264
- }
7265
- function needsStackOpsMessage(what, feature) {
7266
- return `${what} needs @intentius/chant-lexicon-pulumi >= the release after 0.124.54, which carries ${feature}. This plywood was built with chant ${BUILT_CHANT}, which lacks it. Upgrade plywood once that chant is out, or build against a local chant with 'just chant-local'.`;
7267
- }
7268
- function readConfig(cwd) {
7269
- const path = findConfig(cwd);
7270
- if (!path) return { error: `no plywood.yml in ${cwd} or above it; run '${PRODUCT} init' first` };
7271
- const config2 = loadConfig(path);
7272
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
7273
- if (errors.length) return { error: errors.map((p) => `${path}: ${p.key} ${p.message}`).join("\n") };
7274
- return { config: config2, path };
7275
- }
7276
- function resolveStack(config2, path, key, dir) {
7277
- const [project, stack, extra] = key.split("/");
7278
- if (!project || !stack || extra !== void 0) return { error: `a stack is project/stack, as plywood.yml lists it, not '${key}'` };
7279
- const settings = toChantSettings(config2);
7280
- const listed = stacksOf(config2.doc);
7281
- const root = dirname14(path);
7282
- const member = settings.members.find((m) => m.name === key) ?? settings.memberDefaults;
7283
- if (dir === void 0 && Object.keys(listed).length > 0 && !(key in listed)) {
7284
- return { error: `${key} is not under stacks in ${path}; the stacks are ${Object.keys(listed).join(", ")} (or name its directory with --dir)` };
7420
+ function workspaceMembers(root) {
7421
+ const file = join18(root, "chant.workspace.json");
7422
+ if (!existsSync15(file)) return [];
7423
+ try {
7424
+ const doc = JSON.parse(readFileSync16(file, "utf8"));
7425
+ return (doc.members ?? []).map((m) => m.name).filter((n) => typeof n === "string");
7426
+ } catch {
7427
+ return [];
7285
7428
  }
7286
- const at = dir ?? listed[key]?.dir ?? ".";
7287
- return {
7288
- key,
7289
- project,
7290
- stack,
7291
- dir: isAbsolute2(at) ? at : resolve3(root, at),
7292
- ...member.engine ? { engine: member.engine } : {},
7293
- ...member.backend ? { backend: member.backend } : {}
7294
- };
7295
- }
7296
- function requestRules(config2) {
7297
- const p = toChantSettings(config2).pipeline;
7298
- return { mode: p.approval ?? "ledger", ...p.quorum?.length ? { quorum: p.quorum } : {} };
7299
7429
  }
7300
- function requesterOf(by, env) {
7301
- return by ?? env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
7302
- }
7303
- function destStackName(source, dest, config2) {
7304
- if (dest.project === source.project) return dest.stack;
7305
- const cloud = !dest.backend || /^https?:\/\//.test(dest.backend);
7306
- const org = cloud ? pulumiCloudOf(config2.doc).org : void 0;
7307
- return `${org ?? "organization"}/${dest.project}/${dest.stack}`;
7308
- }
7309
- function approveRequestLine(gate, digest, mode) {
7310
- return `${PRODUCT} approve ${gate} --op ${STACK_OPS_OP} --plan ${digest}${mode === "sealed" ? " --sign" : ""}`;
7311
- }
7312
- function useBackend(stacks2, env) {
7313
- const named = [...new Set(stacks2.map((s) => s.backend).filter((b) => b !== void 0))];
7314
- if (named.length > 1) return `the stacks are on different backends (${named.join(", ")}); a move needs both on one backend`;
7315
- if (named[0] && !env.PULUMI_BACKEND_URL) env.PULUMI_BACKEND_URL = named[0];
7316
- return void 0;
7430
+ function upRunner(runner) {
7431
+ if (runner === void 0) return void 0;
7432
+ const staged = typeof runner === "object" && !Array.isArray(runner) && !("group" in runner);
7433
+ const spec = staged ? runner.up ?? runner.default : runner;
7434
+ if (spec === void 0) return void 0;
7435
+ if (typeof spec === "string") return { labels: [spec] };
7436
+ if (Array.isArray(spec)) return { labels: spec.filter((l) => typeof l === "string") };
7437
+ const g = spec;
7438
+ return typeof g.group === "string" ? { labels: Array.isArray(g.labels) ? g.labels : [], group: g.group } : void 0;
7317
7439
  }
7318
- function reportOutcome(verb, outcome, gate, rules, done, json, table, io) {
7319
- const approveLine = outcome.status === "waiting" || outcome.status === "refused" ? approveRequestLine(gate, outcome.digest, rules.mode) : void 0;
7320
- let code;
7321
- let text;
7322
- switch (outcome.status) {
7323
- case "waiting":
7324
- code = table.waiting;
7325
- text = STACK_OP_WORDS.waiting(verb, approveLine);
7326
- break;
7327
- case "refused":
7328
- code = table.waiting;
7329
- text = `${outcome.reason}. ${STACK_OP_WORDS.waiting(verb, approveLine)}`;
7330
- break;
7331
- case "spent":
7332
- code = table.failed;
7333
- text = STACK_OP_WORDS.spent(verb);
7334
- break;
7335
- case "done":
7336
- code = table.done;
7337
- text = done(outcome.result);
7338
- break;
7339
- case "failed":
7340
- code = table.failed;
7341
- text = `${PRODUCT} ${verb}: ${outcome.error}`;
7342
- break;
7343
- }
7344
- if (json) {
7345
- io.out(JSON.stringify({
7346
- command: verb,
7347
- ok: code === table.done,
7348
- exitCode: code,
7349
- status: outcome.status,
7350
- op: STACK_OPS_OP,
7351
- gate,
7352
- digest: outcome.digest,
7353
- ...approveLine ? { approve: approveLine } : {},
7354
- ...outcome.status === "done" ? { result: outcome.result } : {},
7355
- ...outcome.status === "failed" ? { error: outcome.error } : {},
7356
- ...outcome.status === "refused" ? { refusedBy: outcome.by, reason: outcome.reason } : {}
7357
- }));
7358
- } else (code === table.done ? io.out : io.err)(text);
7359
- return code;
7440
+ function contractPipelines(fc, cloud, env, members, runner, jobs2) {
7441
+ const applyRunner = upRunner(runner);
7442
+ const list = members.length ? [...members] : [void 0];
7443
+ return list.flatMap((member) => {
7444
+ const identity2 = fc.resolveCloudIdentity(cloud, env, member);
7445
+ const name = member ? `${member}/${env}` : env;
7446
+ const forgeEnvironment = jobs2 ? environmentFor(name, jobs2)?.name : void 0;
7447
+ return identity2 ? [{ name, identity: identity2, ...member ? { member } : {}, ...applyRunner ? { applyRunner } : {}, ...forgeEnvironment ? { forgeEnvironment } : {} }] : [];
7448
+ });
7360
7449
  }
7361
- async function runRequest(verb, json, table, io, call) {
7362
- const ops = await chantStackOps();
7363
- if (!ops) {
7364
- io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} ${verb}`, "the gated stack operations (chant#4578)")}`);
7450
+ async function forgeCheck(args, json, table, io) {
7451
+ if (args[0] !== "check") return usage(`unknown forge command '${args[0] ?? ""}'. Run '${PRODUCT} forge check'.`, table, io);
7452
+ const fc = await chantForgeCheck();
7453
+ if (!fc) {
7454
+ if (json) io.out(JSON.stringify({ command: "forge check", ok: false, exitCode: table.failed, error: FORGE_CHECK_MESSAGE }));
7455
+ else io.err(`${PRODUCT}: ${FORGE_CHECK_MESSAGE}`);
7365
7456
  return table.failed;
7366
7457
  }
7458
+ const requireClouds = args.includes("--require-clouds");
7459
+ let rest = args.slice(1).filter((a) => a !== "--require-clouds");
7460
+ const flags = {};
7461
+ for (const flag of ["--file", "--env", "--forge", "--repo", "--branch"]) {
7462
+ const f = takeFlag(rest, flag);
7463
+ if (f.missing) return usage(`${flag} takes a value`, table, io);
7464
+ flags[flag] = f.value;
7465
+ rest = f.rest;
7466
+ }
7467
+ if (rest.length) return usage(`forge check does not take ${rest.join(" ")}`, table, io);
7468
+ const forgeFlag = flags["--forge"];
7469
+ if (forgeFlag !== void 0 && !FORGES2.includes(forgeFlag)) return usage(`--forge is ${forgeFlag}; give github, gitlab or forgejo`, table, io);
7470
+ const path = flags["--file"] ?? findConfig(io.cwd);
7471
+ if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
7472
+ let config2;
7367
7473
  try {
7368
- return await call(ops);
7474
+ config2 = loadConfig(path);
7369
7475
  } catch (e) {
7370
- const message = e.message;
7371
- if (json) io.out(JSON.stringify({ command: verb, ok: false, exitCode: table.failed, error: message }));
7372
- else io.err(`${PRODUCT} ${verb}: ${message}`);
7476
+ return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
7477
+ }
7478
+ const errors = checkConfig(config2).filter((p) => p.severity === "error");
7479
+ if (errors.length) {
7480
+ for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
7481
+ if (json) io.out(JSON.stringify({ command: "forge check", ok: false, exitCode: table.failed, file: path, problems: errors }));
7373
7482
  return table.failed;
7374
7483
  }
7484
+ const settings = toChantSettings(config2);
7485
+ const kind = forgeFlag ?? settings.pipeline.forge?.kind;
7486
+ let target;
7487
+ try {
7488
+ target = fc.forgeCheckTarget(io.env, { ...kind && FORGES2.includes(kind) ? { kind } : {}, ...flags["--repo"] ? { repo: flags["--repo"] } : {} });
7489
+ } catch (e) {
7490
+ return usage(e.message, table, io);
7491
+ }
7492
+ const env = flags["--env"] ?? "production";
7493
+ const cloud = settings.ci.identity?.cloud;
7494
+ const pipelines = contractPipelines(fc, cloud, env, workspaceMembers(dirname13(path)), settings.pipeline.runner, settings.pipeline.jobs);
7495
+ const result = await fc.forgeCheck(
7496
+ {
7497
+ target,
7498
+ words: PRODUCT_WORDS,
7499
+ file: path,
7500
+ ...requireClouds ? { requireClouds: true } : {},
7501
+ contract: (live) => fc.pipelineContract({
7502
+ forge: target.kind,
7503
+ repository: {
7504
+ host: target.host,
7505
+ path: live.path ?? target.repo,
7506
+ defaultBranch: flags["--branch"] ?? live.defaultBranch ?? "main",
7507
+ ...live.ownerId ? { ownerId: live.ownerId } : {},
7508
+ ...live.repoId ? { repoId: live.repoId } : {}
7509
+ },
7510
+ pipelines,
7511
+ words: PRODUCT_WORDS
7512
+ })
7513
+ },
7514
+ { fetch: globalThis.fetch, env: io.env }
7515
+ );
7516
+ if (json) io.out(JSON.stringify(result));
7517
+ else io.out(fc.describeForgeCheck(result));
7518
+ if (!cloud) io.err(`${PRODUCT} forge check: ${path} sets no identity, so no trust was checked`);
7519
+ else if (pipelines.length === 0) io.err(`${PRODUCT} forge check: no identity rule covers environment ${env}, so no trust was checked`);
7520
+ return fc.forgeCheckExit(result);
7375
7521
  }
7376
- function commonFlags(args) {
7377
- const by = takeFlag(args, "--by");
7378
- const dir = takeFlag(by.rest, "--dir");
7379
- if (by.missing) return { rest: args, missing: "--by" };
7380
- if (dir.missing) return { rest: args, missing: "--dir" };
7381
- return { by: by.value, dir: dir.value, rest: dir.rest };
7522
+
7523
+ // src/cli.ts
7524
+ init_control2();
7525
+
7526
+ // src/telemetry.ts
7527
+ init_chant_product();
7528
+ init_observe();
7529
+ init_config();
7530
+ init_words();
7531
+ function telemetryEnv(doc, env) {
7532
+ const t = telemetryOf(doc);
7533
+ const out = {};
7534
+ const set = (k, v) => {
7535
+ if (v !== void 0 && v !== "" && env[k] === void 0) out[k] = v;
7536
+ };
7537
+ if (!t.traces) {
7538
+ set("OTEL_TRACES_EXPORTER", "none");
7539
+ return out;
7540
+ }
7541
+ if (env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT === void 0) set("OTEL_EXPORTER_OTLP_ENDPOINT", t.endpoint);
7542
+ if (t.headersEnv) set("OTEL_EXPORTER_OTLP_HEADERS", env[t.headersEnv]);
7543
+ if (t.endpoint !== void 0 || env.OTEL_EXPORTER_OTLP_ENDPOINT !== void 0 || env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT !== void 0) set("OTEL_SERVICE_NAME", t.serviceName);
7544
+ return out;
7382
7545
  }
7383
- async function exportStack(args, json, table, io) {
7384
- const common = commonFlags(args);
7385
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7386
- const out = takeFlag(common.rest, "--out");
7387
- if (out.missing) return usage("--out takes a file path outside the repository", table, io);
7388
- const extra = out.rest.filter((a) => a.startsWith("-"));
7389
- if (extra.length) return usage(`export does not take ${extra.join(" ")}`, table, io);
7390
- const named = positionals(out.rest);
7391
- if (named.length !== 1) return usage(`export takes one stack: ${STACK_OP_WORDS.usage.export}`, table, io);
7392
- 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);
7393
- const read = readConfig(io.cwd);
7394
- if ("error" in read) return usage(read.error, table, io);
7395
- const stack = resolveStack(read.config, read.path, named[0], common.dir);
7396
- if ("error" in stack) return usage(stack.error, table, io);
7397
- useBackend([stack], io.env);
7398
- const rules = requestRules(read.config);
7399
- const outFile = resolve3(io.cwd, out.value);
7400
- return runRequest("export", json, table, io, async (ops) => {
7401
- const outcome = await ops.gatedStackExport({
7402
- by: requesterOf(common.by, io.env),
7403
- rules,
7404
- op: STACK_OPS_OP,
7405
- ledgerCwd: io.cwd,
7406
- env: io.env,
7407
- dir: stack.dir,
7408
- stack: stack.stack,
7409
- out: outFile,
7410
- ...stack.engine ? { engine: stack.engine } : {}
7411
- });
7412
- return reportOutcome("export", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.exported(stack.key, r), json, table, io);
7413
- });
7546
+ async function applyTelemetry(cwd, env = process.env) {
7547
+ let added = {};
7548
+ try {
7549
+ const file = findConfig(cwd);
7550
+ if (file) added = telemetryEnv(loadConfig(file).doc, env);
7551
+ } catch {
7552
+ return {};
7553
+ }
7554
+ Object.assign(env, added);
7555
+ const chant = await chantProduct();
7556
+ if (chant && typeof chant.configureOtlp === "function") chant.configureOtlp({ prefix: PRODUCT, env });
7557
+ return added;
7414
7558
  }
7415
- async function cancelStack(args, json, table, io) {
7416
- const common = commonFlags(args);
7417
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7418
- const lock = takeFlag(common.rest, "--lock");
7419
- if (lock.missing) return usage("--lock takes the lock id", table, io);
7420
- const extra = lock.rest.filter((a) => a.startsWith("-"));
7421
- if (extra.length) return usage(`cancel does not take ${extra.join(" ")}`, table, io);
7422
- const named = positionals(lock.rest);
7423
- if (named.length !== 1) return usage(`cancel takes one stack: ${STACK_OP_WORDS.usage.cancel}`, table, io);
7424
- const read = readConfig(io.cwd);
7425
- if ("error" in read) return usage(read.error, table, io);
7426
- const stack = resolveStack(read.config, read.path, named[0], common.dir);
7427
- if ("error" in stack) return usage(stack.error, table, io);
7428
- useBackend([stack], io.env);
7429
- const lockId = await lockToCancel(lock.value, stack, io.env);
7430
- if ("error" in lockId) return usage(lockId.error, table, io);
7431
- if (lockId.read && !json) io.err(`${PRODUCT} cancel: ${stack.key} holds lock ${lockId.lockId}`);
7432
- const rules = requestRules(read.config);
7433
- return runRequest("cancel", json, table, io, async (ops) => {
7434
- const outcome = await ops.gatedStackCancel({
7435
- by: requesterOf(common.by, io.env),
7436
- rules,
7437
- op: STACK_OPS_OP,
7438
- ledgerCwd: io.cwd,
7439
- env: io.env,
7440
- dir: stack.dir,
7441
- stack: stack.stack,
7442
- lockId: lockId.lockId,
7443
- project: stack.project,
7444
- ...stack.engine ? { engine: stack.engine } : {}
7445
- });
7446
- return reportOutcome("cancel", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.cancelled(stack.key, lockId.lockId, r.lockChecked), json, table, io);
7447
- });
7559
+
7560
+ // src/cli.ts
7561
+ init_init();
7562
+ init_stages();
7563
+ init_tips();
7564
+ init_images();
7565
+ init_up();
7566
+
7567
+ // src/upgrade.ts
7568
+ init_config();
7569
+ init_images();
7570
+ init_stages();
7571
+ init_words();
7572
+ import { execFile } from "node:child_process";
7573
+ import { existsSync as existsSync16, readFileSync as readFileSync17, writeFileSync as writeFileSync9 } from "node:fs";
7574
+ import { dirname as dirname14, join as join19, relative as relative2, resolve as resolve3 } from "node:path";
7575
+ var PLYWOOD_PACKAGE = "@intentius/plywood";
7576
+ var CHANT_PACKAGE = "@intentius/chant";
7577
+ var CHANT_FAMILY = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?$/;
7578
+ var VERSION2 = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
7579
+ var DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
7580
+ function parseUpgradeArgs(args) {
7581
+ const chant = takeFlag(args, "--chant");
7582
+ const forge = takeFlag(chant.rest, "--forge");
7583
+ const env = takeFlag(forge.rest, "--env");
7584
+ if (chant.missing || forge.missing || env.missing) return { error: "--chant, --forge and --env each take a value" };
7585
+ const flags = /* @__PURE__ */ new Set(["--no-install", "--dry-run"]);
7586
+ const unknown = env.rest.filter((a) => a.startsWith("-") && !flags.has(a));
7587
+ if (unknown.length) return { error: `upgrade does not take ${unknown.join(" ")}` };
7588
+ const words = env.rest.filter((a) => !a.startsWith("-"));
7589
+ if (words.length > 1) return { error: `upgrade takes one version, not ${words.join(" ")}` };
7590
+ const version2 = words[0]?.replace(/^v/, "");
7591
+ if (version2 !== void 0 && !VERSION2.test(version2)) return { error: `${words[0]} is not a version: give it as MAJOR.MINOR.PATCH` };
7592
+ if (chant.value !== void 0 && !VERSION2.test(chant.value)) return { error: `--chant ${chant.value} is not a version: give it as MAJOR.MINOR.PATCH` };
7593
+ return {
7594
+ ...version2 ? { version: version2 } : {},
7595
+ ...chant.value ? { chant: chant.value } : {},
7596
+ ...forge.value ? { forge: forge.value } : {},
7597
+ ...env.value ? { env: env.value } : {},
7598
+ install: !env.rest.includes("--no-install"),
7599
+ dryRun: env.rest.includes("--dry-run")
7600
+ };
7448
7601
  }
7449
- async function moveStack(args, json, table, io) {
7450
- const onto = takeFlag(args, "--onto");
7451
- if (onto.missing) return usage("--onto takes plunge", table, io);
7452
- if (onto.value !== void 0) return moveOntoPlunge(onto.value, onto.rest, json, table, io);
7453
- const common = commonFlags(onto.rest);
7454
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7455
- const from = takeFlag(common.rest, "--from");
7456
- const to = takeFlag(from.rest, "--to");
7457
- if (from.missing || to.missing) return usage(`${from.missing ? "--from" : "--to"} takes a stack, as project/stack`, table, io);
7458
- const extra = to.rest.filter((a) => a.startsWith("-"));
7459
- if (extra.length) return usage(`move does not take ${extra.join(" ")}`, table, io);
7460
- if (common.dir !== void 0) return usage("move reads each stack's directory from plywood.yml's stacks, so it does not take --dir", table, io);
7461
- const urns = positionals(to.rest);
7462
- if (!from.value || !to.value) return usage(`move needs --from and --to: ${STACK_OP_WORDS.usage.move}`, table, io);
7463
- if (urns.length === 0) return usage(`move names each resource it moves by URN: ${STACK_OP_WORDS.usage.move}`, table, io);
7464
- const bad = urns.filter((u) => !u.startsWith("urn:pulumi:"));
7465
- if (bad.length) return usage(`move takes URNs (urn:pulumi:...), not ${bad.join(", ")}; 'pulumi stack --show-urns' lists them`, table, io);
7466
- if (from.value === to.value) return usage(`--from and --to are both ${from.value}; a move is between two stacks`, table, io);
7467
- const read = readConfig(io.cwd);
7468
- if ("error" in read) return usage(read.error, table, io);
7469
- const source = resolveStack(read.config, read.path, from.value, void 0);
7470
- if ("error" in source) return usage(source.error, table, io);
7471
- const dest = resolveStack(read.config, read.path, to.value, void 0);
7472
- if ("error" in dest) return usage(dest.error, table, io);
7473
- const backend = useBackend([source, dest], io.env);
7474
- if (backend) return usage(backend, table, io);
7475
- const rules = requestRules(read.config);
7476
- const destName = destStackName(source, dest, read.config);
7477
- const engine2 = source.engine ?? dest.engine;
7478
- return runRequest("move", json, table, io, async (ops) => {
7479
- const outcome = await ops.gatedStackMove({
7480
- by: requesterOf(common.by, io.env),
7481
- rules,
7482
- op: STACK_OPS_OP,
7483
- ledgerCwd: io.cwd,
7484
- env: io.env,
7485
- source: { member: source.key, dir: source.dir, stack: source.stack },
7486
- dest: { member: dest.key, dir: dest.dir, stack: destName },
7487
- urns,
7488
- ...engine2 ? { engine: engine2 } : {}
7489
- });
7490
- return reportOutcome("move", outcome, `move:${source.stack}:${destName}`, rules, (r) => STACK_OP_WORDS.moved(source.key, dest.key, r), json, table, io);
7491
- });
7602
+ function findPlywoodPackage(dir) {
7603
+ let cur = resolve3(dir);
7604
+ for (let depth = 0; depth < 32; depth++) {
7605
+ const file = join19(cur, "package.json");
7606
+ if (existsSync16(file)) {
7607
+ try {
7608
+ const pkg = JSON.parse(readFileSync17(file, "utf8"));
7609
+ if (DEP_FIELDS.some((f) => isObject2(pkg[f]) && typeof pkg[f][PLYWOOD_PACKAGE] === "string")) return file;
7610
+ } catch {
7611
+ }
7612
+ }
7613
+ const up2 = dirname14(cur);
7614
+ if (up2 === cur) return void 0;
7615
+ cur = up2;
7616
+ }
7617
+ return void 0;
7492
7618
  }
7493
- async function moveOntoPlunge(engine2, args, json, table, io) {
7494
- if (engine2 !== "plunge") return usage(`--onto takes plunge, not '${engine2}'; a move between backends is an export and an import (see '${PRODUCT} export')`, table, io);
7495
- const extra = args.filter((a) => a.startsWith("-"));
7496
- if (extra.length) return usage(`move --onto does not take ${extra.join(" ")}`, table, io);
7497
- const named = positionals(args);
7498
- if (named.length > 1) return usage(`move --onto plunge takes at most one stack`, table, io);
7499
- const read = readConfig(io.cwd);
7500
- if ("error" in read) return usage(read.error, table, io);
7501
- const opFile = join19(dirname14(read.path), ADOPT_OP_FILE);
7502
- if (!existsSync16(opFile)) return usage(STACK_OP_WORDS.noAdoptOp(opFile), table, io);
7503
- if (named[0]) {
7504
- const stack = resolveStack(read.config, read.path, named[0], void 0);
7505
- if ("error" in stack) return usage(stack.error, table, io);
7506
- if (!json) io.err(STACK_OP_WORDS.adoptAll(stack.key));
7619
+ function movedRange(range, version2) {
7620
+ const m = /^([~^]?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(range.trim());
7621
+ if (!m) return void 0;
7622
+ return `${m[1]}${version2}`;
7623
+ }
7624
+ function movePackagePins(text, file, plywood, chant) {
7625
+ const pkg = JSON.parse(text);
7626
+ const moves = [];
7627
+ const left = [];
7628
+ for (const field of DEP_FIELDS) {
7629
+ const deps = pkg[field];
7630
+ if (!isObject2(deps)) continue;
7631
+ for (const [name, range] of Object.entries(deps)) {
7632
+ if (typeof range !== "string") continue;
7633
+ const target = name === PLYWOOD_PACKAGE ? plywood : CHANT_FAMILY.test(name) ? chant : void 0;
7634
+ if (target === void 0) continue;
7635
+ const next = movedRange(range, target);
7636
+ if (next === void 0) {
7637
+ left.push(`${field}.${name} is '${range}'; move it to ${target} by hand`);
7638
+ continue;
7639
+ }
7640
+ if (next === range) continue;
7641
+ deps[name] = next;
7642
+ moves.push({ file, name, from: range, to: next });
7643
+ }
7507
7644
  }
7508
- const argv = ["run", ADOPT_OP, ...json ? ["--json"] : []];
7509
- return withChant(`${PRODUCT} move --onto plunge`, table, io, async (chant) => {
7510
- const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
7511
- if (code === table.waiting && !json) io.err(STACK_OP_WORDS.adoptWaiting);
7512
- return code === table.waiting ? code : explainRefusal(code, table, io.err);
7513
- });
7645
+ return { text: moves.length ? jsonText2(pkg, text) : text, moves, left };
7514
7646
  }
7515
- async function rollbackStack(args, json, table, io) {
7516
- const release2 = takeFlag(args, "--release");
7517
- const env = takeFlag(release2.rest, "--env");
7518
- const digest = takeFlag(env.rest, "--digest");
7519
- const actor = takeFlag(digest.rest, "--actor");
7520
- for (const [flag, f] of [["--release", release2], ["--env", env], ["--digest", digest], ["--actor", actor]]) {
7521
- if (f.missing) return usage(`${flag} takes a value`, table, io);
7647
+ function moveDeclarationPins(text, file, chant) {
7648
+ const doc = JSON.parse(text);
7649
+ const moves = [];
7650
+ const pins = Array.isArray(doc.pins) ? doc.pins : [];
7651
+ for (const pin of pins) {
7652
+ if (typeof pin.package !== "string" || !CHANT_FAMILY.test(pin.package) || typeof pin.version !== "string") continue;
7653
+ if (pin.version === chant) continue;
7654
+ moves.push({ file, name: pin.package, from: pin.version, to: chant });
7655
+ pin.version = chant;
7522
7656
  }
7523
- const switches = actor.rest.filter((a) => a === "--dry-run" || a === "--events-json");
7524
- const rest = actor.rest.filter((a) => !switches.includes(a));
7525
- const extra = rest.filter((a) => a.startsWith("-"));
7526
- if (extra.length) return usage(`rollback does not take ${extra.join(" ")}`, table, io);
7527
- const named = positionals(rest);
7528
- if (named.length > 1) return usage(`rollback takes one stack: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7529
- let argv;
7530
- if (release2.value !== void 0) {
7531
- if (named.length) return usage(STACK_OP_WORDS.releaseIsEnvWide(named[0], environmentOf(named[0])), table, io);
7532
- if (digest.value !== void 0) return usage("--release takes no --digest: the release names what each stack goes back to", table, io);
7533
- if (!env.value) return usage(`rollback --release needs --env <env>, the environment whose stacks go back: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7534
- argv = ["components", "rollback", env.value, "--release", release2.value];
7535
- } else {
7536
- if (named.length !== 1) return usage(`rollback takes a stack, or --release <name> --env <env>: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7537
- const read = readConfig(io.cwd);
7538
- if ("error" in read) return usage(read.error, table, io);
7539
- const stack = resolveStack(read.config, read.path, named[0], void 0);
7540
- if ("error" in stack) return usage(stack.error, table, io);
7541
- argv = ["components", "rollback", env.value ?? environmentOf(stack.stack), "--component", slug(stack.key), ...digest.value ? ["--digest", digest.value] : []];
7657
+ return { text: moves.length ? jsonText2(doc, text) : text, moves };
7658
+ }
7659
+ function imageNote(doc, version2) {
7660
+ const image = doc.image;
7661
+ const name = typeof image === "string" ? image : isObject2(image) && typeof image.name === "string" ? image.name : void 0;
7662
+ if (name === void 0) return void 0;
7663
+ const m = new RegExp(`^${REGISTRY.replace(/[.]/g, "\\.")}/plywood-(pulumi|plunge):([^@]+)`).exec(name);
7664
+ if (m && m[2].startsWith(`${version2}-`)) return void 0;
7665
+ if (m) return `plywood.yml's image is plywood's ${m[1]} image at ${m[2]}; name the ${version2} image in it (its tag starts ${version2}-), or drop the key to take the release's own`;
7666
+ return `plywood.yml's image is your own (${name}); rebuild it FROM plywood's ${version2} image and name the new digest in it`;
7667
+ }
7668
+ async function viewRelease(exec, cwd, env, version2) {
7669
+ const spec = `${PLYWOOD_PACKAGE}@${version2 ?? "latest"}`;
7670
+ const r = await exec("npm", ["view", spec, "version", "dependencies", "--json"], { cwd, env });
7671
+ if (r.code !== 0) {
7672
+ const notFound = /E404/.test(r.stderr + r.stdout);
7673
+ return { error: notFound ? `npm has no ${spec}` : `npm view ${spec} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] ?? ""}` };
7542
7674
  }
7543
- argv.push(...actor.value ? ["--actor", actor.value] : [], ...switches, ...json ? ["--json"] : []);
7544
- if (!await chantStackRollback()) {
7545
- io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} rollback`, "rollback by release for stacks (chant#4542)")}`);
7675
+ let parsed;
7676
+ try {
7677
+ parsed = JSON.parse(r.stdout);
7678
+ } catch {
7679
+ return { error: `npm view ${spec} printed no JSON` };
7680
+ }
7681
+ const doc = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
7682
+ if (!isObject2(doc) || typeof doc.version !== "string") return { error: `npm view ${spec} named no version` };
7683
+ const deps = isObject2(doc.dependencies) ? doc.dependencies : {};
7684
+ const chant = deps[CHANT_PACKAGE];
7685
+ if (typeof chant !== "string" || !VERSION2.test(chant)) return { error: `${PLYWOOD_PACKAGE}@${doc.version} pins no exact ${CHANT_PACKAGE}` };
7686
+ return { version: doc.version, chant };
7687
+ }
7688
+ async function upgrade(args, json, table, io) {
7689
+ const opts = parseUpgradeArgs(args);
7690
+ const fail = (message) => {
7691
+ if (json) io.out(JSON.stringify({ command: "upgrade", ok: false, exitCode: table.failed, error: message }));
7692
+ else io.err(`${PRODUCT}: ${message}`);
7546
7693
  return table.failed;
7694
+ };
7695
+ if ("error" in opts) return fail(opts.error);
7696
+ const exec = io.exec ?? execChild;
7697
+ const pkgFile = findPlywoodPackage(io.cwd);
7698
+ if (!pkgFile) return fail(`no package.json in ${io.cwd} or above it depends on ${PLYWOOD_PACKAGE}; install it with 'npm i -D ${PLYWOOD_PACKAGE}', then run '${PRODUCT} init'`);
7699
+ const pkgDir = dirname14(pkgFile);
7700
+ const configFile = findConfig(io.cwd);
7701
+ const configDir = configFile ? dirname14(configFile) : pkgDir;
7702
+ const rel = (f) => relative2(io.cwd, f) || f;
7703
+ const release2 = await viewRelease(exec, pkgDir, io.env, opts.version);
7704
+ if ("error" in release2) return fail(release2.error);
7705
+ const chant = opts.chant ?? release2.chant;
7706
+ const pkgText = readFileSync17(pkgFile, "utf8");
7707
+ const pkgMove = movePackagePins(pkgText, rel(pkgFile), release2.version, chant);
7708
+ const declFile = join19(configDir, "chant.workspace.json");
7709
+ const declText = existsSync16(declFile) ? readFileSync17(declFile, "utf8") : void 0;
7710
+ let declMove;
7711
+ if (declText !== void 0) {
7712
+ try {
7713
+ declMove = moveDeclarationPins(declText, rel(declFile), chant);
7714
+ } catch (e) {
7715
+ return fail(`${rel(declFile)} is not JSON upgrade can move pins in (${e.message})`);
7716
+ }
7547
7717
  }
7548
- return withChant(`${PRODUCT} rollback`, table, io, async (chant) => {
7549
- const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
7550
- if (code === table.waiting && !json) io.err(STACK_OP_WORDS.rollbackWaiting);
7551
- return code === table.waiting ? code : explainRefusal(code, table, io.err);
7552
- });
7718
+ const notes = [...pkgMove.left];
7719
+ if (existsSync16(join19(configDir, "chant.workspace.jsonc"))) notes.push(`chant.workspace.jsonc is yours: move its @intentius/chant* pins to ${chant} by hand`);
7720
+ if (configFile) {
7721
+ try {
7722
+ const note = imageNote(loadConfig(configFile).doc, release2.version);
7723
+ if (note) notes.push(note);
7724
+ } catch {
7725
+ }
7726
+ } else {
7727
+ notes.push(`no plywood.yml, so there is no workflow to write again; run '${PRODUCT} init' after the install`);
7728
+ }
7729
+ const moves = [...pkgMove.moves, ...declMove?.moves ?? []];
7730
+ const ciArgs = ["--no-install", PRODUCT, "ci", ...opts.forge ? ["--forge", opts.forge] : [], ...opts.env ? ["--env", opts.env] : []];
7731
+ const steps = {
7732
+ install: { cwd: rel(pkgDir) || ".", command: "npm install --no-audit --no-fund" },
7733
+ ci: { cwd: rel(configDir) || ".", command: `npx ${ciArgs.join(" ")}` }
7734
+ };
7735
+ const envelope = { command: "upgrade", plywood: release2.version, chant, moves, notes, dryRun: opts.dryRun };
7736
+ const say = (line) => {
7737
+ if (!json) io.err(line);
7738
+ };
7739
+ say(`${PRODUCT} upgrade: ${PLYWOOD_PACKAGE} ${release2.version}, ${CHANT_PACKAGE} ${chant}`);
7740
+ for (const m of moves) say(` ${m.file} ${m.name} ${m.from} -> ${m.to}`);
7741
+ if (moves.length === 0) say(" every pin is already there");
7742
+ if (opts.dryRun) {
7743
+ say(` then: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7744
+ for (const n of notes) say(` note: ${n}`);
7745
+ if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, steps }));
7746
+ return table.done;
7747
+ }
7748
+ if (pkgMove.text !== pkgText) writeFileSync9(pkgFile, pkgMove.text);
7749
+ if (declMove && declText !== void 0 && declMove.text !== declText) writeFileSync9(declFile, declMove.text);
7750
+ const files = [...new Set(moves.map((m) => m.file))];
7751
+ if (!opts.install) {
7752
+ say(` left to run: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7753
+ for (const n of notes) say(` note: ${n}`);
7754
+ if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, files, steps }));
7755
+ return table.done;
7756
+ }
7757
+ say(` $ ${steps.install.command}`);
7758
+ const installed2 = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7759
+ if (installed2.code !== 0) {
7760
+ for (const n of notes) say(` note: ${n}`);
7761
+ return fail(`npm install failed (exit ${installed2.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7762
+ ${installed2.stderr.trim()}`);
7763
+ }
7764
+ const got = installedVersion(pkgDir);
7765
+ if (got !== void 0 && got !== release2.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release2.version}; check the install`);
7766
+ let ci2;
7767
+ if (configFile) {
7768
+ say(` $ ${steps.ci.command}`);
7769
+ ci2 = await exec("npx", ciArgs, { cwd: configDir, env: io.env });
7770
+ if (!json && ci2.stderr.trim()) io.err(ci2.stderr.trimEnd());
7771
+ }
7772
+ for (const n of notes) say(` note: ${n}`);
7773
+ const ok = ci2 === void 0 || ci2.code === 0;
7774
+ const lockFile = join19(pkgDir, "package-lock.json");
7775
+ const changed = [...files, ...existsSync16(lockFile) ? [rel(lockFile)] : []];
7776
+ if (ok) say(` Open one pull request with ${changed.join(", ")} and the files ${PRODUCT} ci wrote.`);
7777
+ else say(` ${PRODUCT} ci failed (exit ${ci2?.code}); the pins are moved and installed, so fix plywood.yml and run '${steps.ci.command}'`);
7778
+ const code = ok ? table.done : ci2?.code ?? table.failed;
7779
+ if (json) io.out(JSON.stringify({ ...envelope, ok, exitCode: code, files: changed, ...ci2 ? { ci: { exitCode: ci2.code } } : {} }));
7780
+ return code;
7781
+ }
7782
+ function installedVersion(dir) {
7783
+ try {
7784
+ const pkg = JSON.parse(readFileSync17(join19(dir, "node_modules", PLYWOOD_PACKAGE, "package.json"), "utf8"));
7785
+ return typeof pkg.version === "string" ? pkg.version : void 0;
7786
+ } catch {
7787
+ return void 0;
7788
+ }
7789
+ }
7790
+ function jsonText2(value, original) {
7791
+ const indent = /^[{[]\r?\n([ \t]+)/.exec(original)?.[1] ?? " ";
7792
+ return `${JSON.stringify(value, null, indent)}
7793
+ `;
7794
+ }
7795
+ function isObject2(v) {
7796
+ return typeof v === "object" && v !== null && !Array.isArray(v);
7553
7797
  }
7798
+ var execChild = (bin, args, opts) => new Promise((done) => {
7799
+ execFile(bin, args, { cwd: opts.cwd, env: opts.env, maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" }, (error2, stdout, stderr) => {
7800
+ const code = error2 ? typeof error2.code === "number" ? error2.code : 1 : 0;
7801
+ done({ code, stdout: String(stdout), stderr: String(stderr) });
7802
+ });
7803
+ });
7554
7804
 
7555
7805
  // src/cli.ts
7556
7806
  init_words();
7557
- var VERSION3 = true ? "0.0.0" : "0.0.0-dev";
7807
+ var VERSION3 = true ? "0.2.0" : "0.0.0-dev";
7558
7808
  var stdio = {
7559
7809
  out: (s) => process.stdout.write(s + "\n"),
7560
7810
  err: (s) => process.stderr.write(s + "\n")
@@ -7605,6 +7855,7 @@ function helpText() {
7605
7855
  " move <stack> --onto plunge adopt the stacks onto plunge (plywood.adopt.op.ts)",
7606
7856
  " rollback <stack> [--digest <d>] | --release <name> --env <env>",
7607
7857
  " preview a release's program, then update behind its gate",
7858
+ " action <stack> <name> [--anew] run an action plywood.yml declares: replace its command resource",
7608
7859
  "",
7609
7860
  "Ledger:",
7610
7861
  " audit [approvals|updates|runs|review-stacks] [--stack <s>] [--since <t>] [--where <f>=<v>]...",
@@ -7625,7 +7876,8 @@ function helpText() {
7625
7876
  " control dispatch [--sha <sha>] tell plywood.yml's control repo this commit applied",
7626
7877
  "",
7627
7878
  "Agents:",
7628
- " mcp [--report-url <url>] chant's read-only MCP server over stdio, with plywood's read tools",
7879
+ " mcp [--report-url <url>] [--propose] chant's read-only MCP server over stdio, with plywood's read tools;",
7880
+ " --propose adds request_from_template, which refuses until Pulumi templates exist",
7629
7881
  " guard check <command line> whether the agent guard refuses a command",
7630
7882
  " guard install [--dir <d>] the Claude Code hook and the pulumi and plunge wrappers",
7631
7883
  "",
@@ -7686,6 +7938,7 @@ async function main(argv, io = stdio) {
7686
7938
  if (verb === "cancel") return cancelStack(args.slice(1), json, table, stageIo);
7687
7939
  if (verb === "move") return moveStack(args.slice(1), json, table, stageIo);
7688
7940
  if (verb === "rollback") return rollbackStack(args.slice(1), json, table, stageIo);
7941
+ if (verb === "action") return action(args.slice(1), json, table, stageIo);
7689
7942
  if (verb === "drift") return drift2(args.slice(1), json, table, stageIo);
7690
7943
  if (verb === "mcp") return mcp(args.slice(1), json, table, stageIo);
7691
7944
  if (verb === "guard") return guard(args.slice(1), json, table, stageIo);
@@ -7750,5 +8003,6 @@ function config(args, table, emit) {
7750
8003
  }
7751
8004
 
7752
8005
  // src/bin.ts
8006
+ installChantResolution();
7753
8007
  process.exitCode = await main(process.argv.slice(2));
7754
8008
  //# sourceMappingURL=plywood.mjs.map