@intentius/plywood 0.1.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,13 +587,13 @@ var init_control = __esm({
578
587
  var version, dependencies;
579
588
  var init_package = __esm({
580
589
  "package.json"() {
581
- version = "0.1.0";
590
+ version = "0.2.0";
582
591
  dependencies = {
583
- "@intentius/chant": "0.124.57",
584
- "@intentius/chant-lexicon-forgejo": "0.124.57",
585
- "@intentius/chant-lexicon-github": "0.124.57",
586
- "@intentius/chant-lexicon-gitlab": "0.124.57",
587
- "@intentius/chant-lexicon-pulumi": "0.124.57"
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"
588
597
  };
589
598
  }
590
599
  });
@@ -594,8 +603,8 @@ var image_digests_default;
594
603
  var init_image_digests = __esm({
595
604
  "src/image-digests.json"() {
596
605
  image_digests_default = {
597
- "ghcr.io/intentius/plywood-pulumi:0.1.0-pulumi3.272.0": "sha256:730237bed59e0054e6839898658069a4bcbdab0ffb8f893487c864bc2f3436c8",
598
- "ghcr.io/intentius/plywood-plunge:0.1.0-plunge0.1.0": "sha256:76a50f55cc2fd96c19e4ecb625a19863783374c9dc89d1606cc70a36635cc48d"
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"
599
608
  };
600
609
  }
601
610
  });
@@ -923,12 +932,58 @@ var init_images2 = __esm({
923
932
  });
924
933
 
925
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
+ }
926
958
  function wavesMappingOf(doc) {
927
959
  return isObject(doc.waves) ? doc.waves : {};
928
960
  }
929
961
  function stacksOf(doc) {
930
962
  return isObject(doc.stacks) ? doc.stacks : {};
931
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
+ }
932
987
  function wavesMappingProblems(w, listed) {
933
988
  const out = [...onlyKeys(w, WAVES_KEYS, "waves")];
934
989
  const known = (s, key) => {
@@ -982,15 +1037,18 @@ function afterCycle(after) {
982
1037
  }
983
1038
  return void 0;
984
1039
  }
985
- var STACK_KEYS, WAVES_KEYS, STACK_NAME, stacks;
1040
+ var STACK_KEYS, ACTION_KEYS, WAVES_KEYS, STACK_NAME, ACTION_NAME, ACTION_TYPE_PREFIX, stacks;
986
1041
  var init_stacks = __esm({
987
1042
  "src/config/stacks.ts"() {
988
1043
  "use strict";
989
1044
  init_approval();
990
1045
  init_types();
991
- STACK_KEYS = ["dir", "paths", "gate", "backend"];
1046
+ STACK_KEYS = ["dir", "paths", "gate", "backend", "actions"];
1047
+ ACTION_KEYS = ["name", "urn", "description"];
992
1048
  WAVES_KEYS = ["canary", "after"];
993
1049
  STACK_NAME = /^[^/\s]+\/[^/\s]+$/;
1050
+ ACTION_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
1051
+ ACTION_TYPE_PREFIX = "command:";
994
1052
  stacks = {
995
1053
  name: "stacks",
996
1054
  keys: ["stacks", "waves"],
@@ -1006,7 +1064,21 @@ var init_stacks = __esm({
1006
1064
  dir: { type: "string", description: "The directory holding the stack's Pulumi.yaml." },
1007
1065
  paths: { type: "array", items: { type: "string" }, description: "Paths whose change previews the stack, beside its directory." },
1008
1066
  gate: { type: "string", enum: ["always", "on-destructive", "never"], description: "The stack's gate, in place of the top-level gate." },
1009
- 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
+ }
1010
1082
  }
1011
1083
  }
1012
1084
  },
@@ -1044,6 +1116,7 @@ var init_stacks = __esm({
1044
1116
  if (s.dir !== void 0 && typeof s.dir !== "string") out.push(error(`${key}.dir`, "must be a string"));
1045
1117
  if (s.backend !== void 0 && typeof s.backend !== "string") out.push(error(`${key}.backend`, "must be a URL string"));
1046
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`));
1047
1120
  }
1048
1121
  if (isObject(doc.waves)) {
1049
1122
  out.push(...wavesMappingProblems(doc.waves, isObject(doc.stacks) ? listed : void 0));
@@ -2354,227 +2427,6 @@ var init_config_check = __esm({
2354
2427
  }
2355
2428
  });
2356
2429
 
2357
- // src/init/detect.ts
2358
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
2359
- import { basename, dirname as dirname2, join as join2, relative, sep } from "node:path";
2360
- import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
2361
- function repoRoot(dir) {
2362
- let cur = dir;
2363
- for (let i = 0; i < 64; i++) {
2364
- if (existsSync2(join2(cur, ".git"))) return cur;
2365
- const parent = dirname2(cur);
2366
- if (parent === cur) return dir;
2367
- cur = parent;
2368
- }
2369
- return dir;
2370
- }
2371
- function posixRel(root, path) {
2372
- const rel = relative(root, path);
2373
- return rel === "" ? "." : rel.split(sep).join("/");
2374
- }
2375
- function readYamlFile(file) {
2376
- try {
2377
- const doc = parseYAML2(readFileSync2(file, "utf8"));
2378
- return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
2379
- } catch {
2380
- return void 0;
2381
- }
2382
- }
2383
- function findPulumiProjects(root) {
2384
- const out = [];
2385
- const walk = (dir, depth) => {
2386
- let entries;
2387
- try {
2388
- entries = readdirSync(dir).sort();
2389
- } catch {
2390
- return;
2391
- }
2392
- const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
2393
- if (file) {
2394
- const project = readProject(root, dir, join2(dir, file));
2395
- if (project) out.push(project);
2396
- }
2397
- if (depth >= MAX_DEPTH) return;
2398
- for (const name of entries) {
2399
- if (SKIP.has(name) || name.startsWith(".")) continue;
2400
- const path = join2(dir, name);
2401
- try {
2402
- if (statSync(path).isDirectory()) walk(path, depth + 1);
2403
- } catch {
2404
- }
2405
- }
2406
- };
2407
- walk(root, 0);
2408
- return out;
2409
- }
2410
- function readProject(root, dir, file) {
2411
- const doc = readYamlFile(file);
2412
- if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
2413
- const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
2414
- const backend = doc.backend;
2415
- const configDir = typeof doc.stackConfigDir === "string" ? join2(dir, doc.stackConfigDir) : dir;
2416
- return {
2417
- name: doc.name,
2418
- dir: posixRel(root, dir),
2419
- ...typeof runtime === "string" ? { runtime } : {},
2420
- ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
2421
- stacks: stackNames(configDir)
2422
- };
2423
- }
2424
- function stackNames(configDir) {
2425
- let entries;
2426
- try {
2427
- entries = readdirSync(configDir);
2428
- } catch {
2429
- return [];
2430
- }
2431
- const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
2432
- return [...new Set(names)].sort(byEnvironment);
2433
- }
2434
- function environmentOf(stack) {
2435
- const last = stack.split("/").pop().toLowerCase();
2436
- const head = last.split(/[-_.]/)[0];
2437
- return ENV_ORDER.includes(head) ? head : last;
2438
- }
2439
- function rank(stack) {
2440
- const env = environmentOf(stack);
2441
- const at = ENV_ORDER.indexOf(env);
2442
- return at >= 0 ? at : PROD_RANK - 0.5;
2443
- }
2444
- function byEnvironment(a, b) {
2445
- return rank(a) - rank(b) || a.localeCompare(b);
2446
- }
2447
- function stacksOf2(projects) {
2448
- const names = /* @__PURE__ */ new Map();
2449
- for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
2450
- const out = [];
2451
- for (const p of projects) {
2452
- const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
2453
- for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
2454
- }
2455
- return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
2456
- }
2457
- function slug(s) {
2458
- const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2459
- return out === "" ? "root" : out;
2460
- }
2461
- function originUrl(root) {
2462
- const config2 = gitFile(root, "config");
2463
- if (!config2) return void 0;
2464
- let inOrigin = false;
2465
- for (const raw of config2.split(/\r?\n/)) {
2466
- const line = raw.trim();
2467
- if (line.startsWith("[")) {
2468
- inOrigin = /^\[remote\s+"origin"\]$/.test(line);
2469
- continue;
2470
- }
2471
- const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
2472
- if (m) return m[1].trim();
2473
- }
2474
- return void 0;
2475
- }
2476
- function gitFile(root, name) {
2477
- const dotGit = join2(root, ".git");
2478
- let gitDir = dotGit;
2479
- try {
2480
- if (statSync(dotGit).isFile()) {
2481
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync2(dotGit, "utf8"));
2482
- if (!m) return void 0;
2483
- gitDir = join2(root, m[1].trim());
2484
- const common = join2(gitDir, "commondir");
2485
- if (existsSync2(common) && !existsSync2(join2(gitDir, name))) gitDir = join2(gitDir, readFileSync2(common, "utf8").trim());
2486
- }
2487
- const path = join2(gitDir, name);
2488
- return existsSync2(path) ? readFileSync2(path, "utf8") : void 0;
2489
- } catch {
2490
- return void 0;
2491
- }
2492
- }
2493
- function remoteHost(url) {
2494
- const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
2495
- if (scp) return scp[1].toLowerCase();
2496
- try {
2497
- return new URL(url).hostname.toLowerCase() || void 0;
2498
- } catch {
2499
- return void 0;
2500
- }
2501
- }
2502
- function forgeOfRemote(url) {
2503
- const host = remoteHost(url);
2504
- if (!host) return void 0;
2505
- if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
2506
- if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
2507
- if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
2508
- if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
2509
- if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
2510
- return void 0;
2511
- }
2512
- function detectForge(root) {
2513
- const url = originUrl(root);
2514
- const byRemote = url ? forgeOfRemote(url) : void 0;
2515
- if (byRemote) return byRemote;
2516
- if (existsSync2(join2(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
2517
- if (existsSync2(join2(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
2518
- if (existsSync2(join2(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
2519
- return void 0;
2520
- }
2521
- function defaultBranch(root) {
2522
- const head = gitFile(root, "refs/remotes/origin/HEAD");
2523
- const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
2524
- if (remote) return remote.trim();
2525
- const local = gitFile(root, "HEAD");
2526
- const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
2527
- return branch ? branch.trim() : "main";
2528
- }
2529
- function findPulumiActions(root) {
2530
- const out = [];
2531
- for (const d of WORKFLOW_DIRS) {
2532
- let names;
2533
- try {
2534
- names = readdirSync(join2(root, d)).sort();
2535
- } catch {
2536
- continue;
2537
- }
2538
- for (const n of names) {
2539
- if (!/\.ya?ml$/.test(n)) continue;
2540
- try {
2541
- if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync2(join2(root, d, n), "utf8"))) out.push(`${d}/${n}`);
2542
- } catch {
2543
- }
2544
- }
2545
- }
2546
- return out;
2547
- }
2548
- function detect(cwd) {
2549
- const root = repoRoot(cwd);
2550
- const projects = findPulumiProjects(root);
2551
- const forge = detectForge(root);
2552
- const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync2(join2(root, n)));
2553
- return {
2554
- root,
2555
- projects,
2556
- stacks: stacksOf2(projects),
2557
- ...forge ? { forge } : {},
2558
- defaultBranch: defaultBranch(root),
2559
- pulumiActions: findPulumiActions(root),
2560
- ...preCommit ? { preCommit } : {}
2561
- };
2562
- }
2563
- function repoName(root) {
2564
- return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
2565
- }
2566
- var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
2567
- var init_detect = __esm({
2568
- "src/init/detect.ts"() {
2569
- "use strict";
2570
- SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
2571
- MAX_DEPTH = 8;
2572
- ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
2573
- PROD_RANK = ENV_ORDER.indexOf("prod");
2574
- WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
2575
- }
2576
- });
2577
-
2578
2430
  // src/chant-product.ts
2579
2431
  function hasChant4620(chant) {
2580
2432
  return typeof chant.prCommentPreviewJob === "function" && typeof chant.PR_PREVIEW_FLOOR === "string";
@@ -2606,7 +2458,7 @@ var init_chant_product = __esm({
2606
2458
  "src/chant-product.ts"() {
2607
2459
  "use strict";
2608
2460
  REQUIRED2 = ["assertProductWords", "productPipelineJobs", "approveLine", "runChantInProcess"];
2609
- BUILT_CHANT = true ? "0.124.57" : "unknown";
2461
+ BUILT_CHANT = true ? "0.124.58" : "unknown";
2610
2462
  }
2611
2463
  });
2612
2464
 
@@ -2780,18 +2632,18 @@ var init_note_control = __esm({
2780
2632
  });
2781
2633
 
2782
2634
  // src/init/workflow.ts
2783
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
2784
- 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";
2785
2637
  async function writeWorkflow(chant, input) {
2786
- const file = isAbsolute(input.output) ? input.output : join3(input.root, input.output);
2787
- const previous = existsSync3(file) ? readFileSync3(file, "utf8") : void 0;
2788
- 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 });
2789
2641
  const code = await chant.runChantInProcess([...input.argv, "--output", file], input.words);
2790
- if (code !== 0 || !existsSync3(file)) {
2642
+ if (code !== 0 || !existsSync2(file)) {
2791
2643
  if (previous !== void 0) writeFileSync(file, previous);
2792
2644
  return { code: code !== 0 ? code : 1, file, status: "failed", kept: [] };
2793
2645
  }
2794
- const generated = withNoteControl(readFileSync3(file, "utf8"), input.forge, input.noteControl);
2646
+ const generated = withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl);
2795
2647
  const { text, kept } = keepOwnJobs(previous, generated, input.forge);
2796
2648
  writeFileSync(file, text);
2797
2649
  return { code, file, status: text === previous ? "unchanged" : "written", kept };
@@ -2833,8 +2685,8 @@ var init_review_stacks2 = __esm({
2833
2685
  });
2834
2686
 
2835
2687
  // src/waves-workflow.ts
2836
- import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
2837
- 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";
2838
2690
  function wavesEngine(pipeline) {
2839
2691
  const o = chantPipelineOptions({ ...pipeline.image ? { image: pipeline.image } : {}, ...pipeline.engineSetup ? { engineSetup: pipeline.engineSetup } : {} }, false);
2840
2692
  return { ...o.image ? { image: o.image } : {}, ...o.beforeScript?.length ? { beforeScript: o.beforeScript } : {} };
@@ -2896,10 +2748,10 @@ function upOpFile(spec) {
2896
2748
  ].join("\n");
2897
2749
  }
2898
2750
  function writeUpOp(cwd, spec) {
2899
- const path = join4(cwd, UP_OP_FILE);
2751
+ const path = join3(cwd, UP_OP_FILE);
2900
2752
  const text = upOpFile(spec);
2901
- if (existsSync4(path) && readFileSync4(path, "utf8") === text) return false;
2902
- mkdirSync2(dirname4(path), { recursive: true });
2753
+ if (existsSync3(path) && readFileSync3(path, "utf8") === text) return false;
2754
+ mkdirSync2(dirname3(path), { recursive: true });
2903
2755
  writeFileSync2(path, text);
2904
2756
  return true;
2905
2757
  }
@@ -2919,15 +2771,15 @@ function withBinLines(text, lines2) {
2919
2771
  });
2920
2772
  }
2921
2773
  function generatedFor(dir, specFile) {
2922
- if (!existsSync4(dir)) return [];
2923
- return readdirSync2(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => join4(dir, f)).filter((p) => (readFileSync4(p, "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${specFile}`));
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}`));
2924
2776
  }
2925
2777
  async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}) {
2926
- const specPath = join4(io.cwd, waves.specFile);
2778
+ const specPath = join3(io.cwd, waves.specFile);
2927
2779
  const specText = wavesSpecText(waves.spec, engine2);
2928
- const before = existsSync4(specPath) ? readFileSync4(specPath, "utf8") : void 0;
2780
+ const before = existsSync3(specPath) ? readFileSync3(specPath, "utf8") : void 0;
2929
2781
  if (before !== specText) {
2930
- mkdirSync2(dirname4(specPath), { recursive: true });
2782
+ mkdirSync2(dirname3(specPath), { recursive: true });
2931
2783
  writeFileSync2(specPath, specText);
2932
2784
  io.err(`${PRODUCT} ci: wrote ${waves.specFile} with plywood's op and gates; commit it with the workflow`);
2933
2785
  }
@@ -2935,7 +2787,7 @@ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}
2935
2787
  const outDir = WAVES_OUTPUT_DIR[forge];
2936
2788
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", waves.specFile, "--output", outDir], words);
2937
2789
  if (code !== table.done) return code;
2938
- const files = generatedFor(join4(io.cwd, outDir), waves.specFile);
2790
+ const files = generatedFor(join3(io.cwd, outDir), waves.specFile);
2939
2791
  if (files.length === 0) {
2940
2792
  io.err(`${PRODUCT} ci: chant wrote no waves workflow for ${waves.specFile} under ${outDir}`);
2941
2793
  return table.failed;
@@ -2943,7 +2795,7 @@ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}
2943
2795
  const lines2 = hasChant4607(chant) ? void 0 : binLines(waves, words.bin);
2944
2796
  for (const file of files) {
2945
2797
  if (lines2) {
2946
- const text = readFileSync4(file, "utf8");
2798
+ const text = readFileSync3(file, "utf8");
2947
2799
  const next = withBinLines(text, lines2);
2948
2800
  if (next !== text) writeFileSync2(file, next);
2949
2801
  }
@@ -2971,8 +2823,8 @@ var init_waves_workflow = __esm({
2971
2823
  });
2972
2824
 
2973
2825
  // src/agent-jobs.ts
2974
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
2975
- 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";
2976
2828
  function hasChantAgent(chant) {
2977
2829
  return typeof chant?.CI_AGENT_FLOOR === "string";
2978
2830
  }
@@ -3018,9 +2870,9 @@ function agentOpSpec(spec, doc) {
3018
2870
  };
3019
2871
  }
3020
2872
  function writeIfChanged(cwd, file, text) {
3021
- const path = join5(cwd, file);
3022
- if (existsSync5(path) && readFileSync5(path, "utf8") === text) return false;
3023
- 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 });
3024
2876
  writeFileSync3(path, text);
3025
2877
  return true;
3026
2878
  }
@@ -3047,9 +2899,9 @@ async function writeAgentJobs(chant, forge, env, doc, words, table, io) {
3047
2899
  const outDir = OP_OUTPUT_DIR[forge];
3048
2900
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", AGENT_SPEC_FILE, "--output", outDir], agentWords);
3049
2901
  if (code !== table.done) return code;
3050
- const dir = join5(io.cwd, outDir);
3051
- 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}`)) : [];
3052
- 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>`);
3053
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" : ""}`);
3054
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`);
3055
2907
  else if (a.tokenEnv !== void 0) io.err(`${PRODUCT} ci: the push job pushes with ${a.tokenEnv}; set it from a secret`);
@@ -3308,8 +3160,8 @@ var init_refusals = __esm({
3308
3160
  });
3309
3161
 
3310
3162
  // src/up.ts
3311
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
3312
- 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";
3313
3165
  function approveTarget(gate, opts) {
3314
3166
  if (opts.op) return { op: opts.op, gate };
3315
3167
  if (PR_OP.test(gate)) return { op: gate, gate: PR_GATE };
@@ -3390,10 +3242,10 @@ async function approve(args, json, table, io) {
3390
3242
  });
3391
3243
  }
3392
3244
  function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3393
- const path = join6(cwd, file);
3394
- if (!existsSync6(path)) return void 0;
3245
+ const path = join5(cwd, file);
3246
+ if (!existsSync5(path)) return void 0;
3395
3247
  try {
3396
- const spec = unwrapWavesSpec(JSON.parse(readFileSync6(path, "utf8")));
3248
+ const spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3397
3249
  return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3398
3250
  } catch {
3399
3251
  return void 0;
@@ -3477,11 +3329,11 @@ async function override(args, json, table, io) {
3477
3329
  return withChant(`${PRODUCT} override`, table, io, async (chant) => explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err));
3478
3330
  }
3479
3331
  function readUpSpec(cwd, file = WAVES_SPEC_FILE) {
3480
- const path = join6(cwd, file);
3481
- 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` };
3482
3334
  let spec;
3483
3335
  try {
3484
- spec = unwrapWavesSpec(JSON.parse(readFileSync6(path, "utf8")));
3336
+ spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3485
3337
  } catch (e) {
3486
3338
  return { error: `${file}: ${e.message}` };
3487
3339
  }
@@ -3636,9 +3488,9 @@ var init_up = __esm({
3636
3488
  // src/drift.ts
3637
3489
  import { createHash as createHash2 } from "node:crypto";
3638
3490
  import { resolveTargetCloudIdentity } from "@intentius/chant/ci-identity";
3639
- 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";
3640
3492
  import { tmpdir } from "node:os";
3641
- import { dirname as dirname6, join as join7 } from "node:path";
3493
+ import { dirname as dirname5, join as join6 } from "node:path";
3642
3494
  function driftRows(doc) {
3643
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);
3644
3496
  }
@@ -3697,7 +3549,7 @@ async function drift2(args, json, table, io) {
3697
3549
  const respond = respondFlag.value ?? driftResponseOf(doc);
3698
3550
  const forge = forgeFlag.value;
3699
3551
  return withChant(`${PRODUCT} drift`, table, io, async (chant) => {
3700
- const dir = mkdtempSync(join7(tmpdir(), "plywood-drift-"));
3552
+ const dir = mkdtempSync(join6(tmpdir(), "plywood-drift-"));
3701
3553
  const results = [];
3702
3554
  try {
3703
3555
  const attribute = await attributionFor(doc, io);
@@ -3777,12 +3629,12 @@ async function driftNotice(chant, doc, results, environment, io) {
3777
3629
  });
3778
3630
  }
3779
3631
  async function readStack(chant, stack, dir, opts) {
3780
- const file = join7(dir, `${driftOpName(stack)}.json`);
3632
+ const file = join6(dir, `${driftOpName(stack)}.json`);
3781
3633
  const argv = ["workspace", "drift", "--member", stack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
3782
3634
  const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
3783
3635
  let doc;
3784
3636
  try {
3785
- if (existsSync7(file)) doc = JSON.parse(readFileSync7(file, "utf8"));
3637
+ if (existsSync6(file)) doc = JSON.parse(readFileSync6(file, "utf8"));
3786
3638
  } catch (e) {
3787
3639
  return { stack, ok: false, drifted: 0, rows: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3788
3640
  }
@@ -3815,7 +3667,7 @@ async function attributeStack(a, stack, doc) {
3815
3667
  }
3816
3668
  function specHasUpdate(cwd, op) {
3817
3669
  try {
3818
- const spec = JSON.parse(readFileSync7(join7(cwd, DRIFT_SPEC_FILE), "utf8"));
3670
+ const spec = JSON.parse(readFileSync6(join6(cwd, DRIFT_SPEC_FILE), "utf8"));
3819
3671
  return (spec.ops ?? []).some((o) => o.name === op && o.update !== void 0);
3820
3672
  } catch {
3821
3673
  return false;
@@ -3891,9 +3743,9 @@ function withNotifyVariables(fields, notify2) {
3891
3743
  return { ...fields, variables: { ...notify2, ...fields.variables ?? {} } };
3892
3744
  }
3893
3745
  function writeIfChanged2(cwd, file, text) {
3894
- const path = join7(cwd, file);
3895
- if (existsSync7(path) && readFileSync7(path, "utf8") === text) return false;
3896
- 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 });
3897
3749
  writeFileSync4(path, text);
3898
3750
  return true;
3899
3751
  }
@@ -3909,9 +3761,9 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3909
3761
  const outDir = OP_OUTPUT_DIR2[forge];
3910
3762
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", DRIFT_SPEC_FILE, "--output", outDir], words);
3911
3763
  if (code !== table.done) return code;
3912
- const dir = join7(io.cwd, outDir);
3913
- 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}`)) : [];
3914
- 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}`);
3915
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`);
3916
3768
  return table.done;
3917
3769
  }
@@ -3985,8 +3837,8 @@ var init_tips = __esm({
3985
3837
  });
3986
3838
 
3987
3839
  // src/agents.ts
3988
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
3989
- 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";
3990
3842
  function plywoodMcpTools(ctx) {
3991
3843
  const none = { type: "object", properties: {} };
3992
3844
  const doc = () => {
@@ -3996,7 +3848,7 @@ function plywoodMcpTools(ctx) {
3996
3848
  return [
3997
3849
  {
3998
3850
  name: "stacks",
3999
- 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.",
4000
3852
  inputSchema: none,
4001
3853
  readOnly: true,
4002
3854
  handler: async () => {
@@ -4030,24 +3882,51 @@ function plywoodMcpTools(ctx) {
4030
3882
  }
4031
3883
  ];
4032
3884
  }
4033
- 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) {
4034
3909
  return {
4035
3910
  product: PRODUCT,
4036
- 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() : []],
4037
3913
  readOnly: true,
3914
+ ...propose ? { propose: true } : {},
4038
3915
  ...reportUrl ? { reportUrl } : {},
4039
- 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}`
4040
3917
  };
4041
3918
  }
4042
3919
  async function mcp(args, _json, table, io) {
4043
- const report = takeFlag(args, "--report-url");
3920
+ const propose = args.includes("--propose");
3921
+ const report = takeFlag(args.filter((a) => a !== "--propose"), "--report-url");
4044
3922
  if (report.missing) return usage("--report-url takes a URL", table, io);
4045
3923
  if (report.rest.length) return usage(`mcp does not take ${report.rest.join(" ")}`, table, io);
4046
3924
  return withChant(`${PRODUCT} mcp`, table, io, (chant) => {
4047
3925
  if (typeof chant.PRODUCT_MCP_HOOK_FLOOR !== "string") {
4048
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`);
4049
3927
  }
4050
- 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) });
4051
3930
  });
4052
3931
  }
4053
3932
  function reviewOpFile(doc) {
@@ -4099,9 +3978,9 @@ function reviewOpSpec(doc) {
4099
3978
  };
4100
3979
  }
4101
3980
  function writeIfChanged3(cwd, file, text) {
4102
- const path = join8(cwd, file);
4103
- if (existsSync8(path) && readFileSync8(path, "utf8") === text) return false;
4104
- 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 });
4105
3984
  writeFileSync5(path, text);
4106
3985
  return true;
4107
3986
  }
@@ -4112,15 +3991,15 @@ async function writeReviewJobs(chant, forge, doc, words, table, io) {
4112
3991
  const outDir = OP_OUTPUT_DIR3[forge];
4113
3992
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
4114
3993
  if (code !== table.done) return code;
4115
- const dir = join8(io.cwd, outDir);
4116
- 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}`)) : [];
4117
- 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(/^\.\//, "")}`);
4118
3997
  const r = reviewOf(doc);
4119
3998
  io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
4120
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`);
4121
4000
  return table.done;
4122
4001
  }
4123
- 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;
4124
4003
  var init_agents2 = __esm({
4125
4004
  "src/agents.ts"() {
4126
4005
  "use strict";
@@ -4134,6 +4013,7 @@ var init_agents2 = __esm({
4134
4013
  init_tips();
4135
4014
  init_words();
4136
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.`;
4137
4017
  REVIEW_OP = "plywood-review";
4138
4018
  REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4139
4019
  REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
@@ -4143,19 +4023,240 @@ var init_agents2 = __esm({
4143
4023
  }
4144
4024
  });
4145
4025
 
4146
- // src/policy-presets.ts
4147
- import { readFileSync as readFileSync9 } from "node:fs";
4148
- function providersRego(allowed, namespace = "main") {
4149
- const set = allowed.length ? `{${[...new Set(allowed)].sort().map((p) => JSON.stringify(p)).join(", ")}}` : "set()";
4150
- return preset("plywood_providers.rego.tmpl").replace("{{namespace}}", namespace).replace("{{allowed}}", set);
4151
- }
4152
- function providersPackFiles() {
4153
- return Object.fromEntries(["index.ts", "PulumiPolicy.yaml", "package.json"].map((f) => [f, preset(`plywood-providers/${f}`)]));
4154
- }
4155
- function providersPackConfig(allowed) {
4156
- return JSON.stringify({ [PROVIDERS_POLICY]: { allowed: [...new Set(allowed)].sort() } }, null, 2) + "\n";
4157
- }
4158
- var PROVIDERS_PACK_DIR, PROVIDERS_PACK_CONFIG, PROVIDERS_REGO_FILE, PROVIDERS_POLICY, preset;
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
+
4247
+ // src/policy-presets.ts
4248
+ import { readFileSync as readFileSync9 } from "node:fs";
4249
+ function providersRego(allowed, namespace = "main") {
4250
+ const set = allowed.length ? `{${[...new Set(allowed)].sort().map((p) => JSON.stringify(p)).join(", ")}}` : "set()";
4251
+ return preset("plywood_providers.rego.tmpl").replace("{{namespace}}", namespace).replace("{{allowed}}", set);
4252
+ }
4253
+ function providersPackFiles() {
4254
+ return Object.fromEntries(["index.ts", "PulumiPolicy.yaml", "package.json"].map((f) => [f, preset(`plywood-providers/${f}`)]));
4255
+ }
4256
+ function providersPackConfig(allowed) {
4257
+ return JSON.stringify({ [PROVIDERS_POLICY]: { allowed: [...new Set(allowed)].sort() } }, null, 2) + "\n";
4258
+ }
4259
+ var PROVIDERS_PACK_DIR, PROVIDERS_PACK_CONFIG, PROVIDERS_REGO_FILE, PROVIDERS_POLICY, preset;
4159
4260
  var init_policy_presets = __esm({
4160
4261
  "src/policy-presets.ts"() {
4161
4262
  "use strict";
@@ -6277,7 +6378,8 @@ async function ci(args, json, table, io) {
6277
6378
  ...jobs2.applyBeforeMerge ? { applyBeforeMerge: jobs2.applyBeforeMerge.command } : {},
6278
6379
  comments: jobs2.comments,
6279
6380
  ...jobs2.waves ? { waves: { specFile: jobs2.waves.specFile, jobs: jobs2.waves.jobs.map((j) => j.command.join(" ")) } } : {},
6280
- ..."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 } } : {}
6281
6383
  };
6282
6384
  if (json && dryRun) io.out(JSON.stringify({ command: "ci", ok: true, exitCode: table.done, dryRun, ...summary }));
6283
6385
  else if (!json) {
@@ -6290,6 +6392,7 @@ async function ci(args, json, table, io) {
6290
6392
  for (const [command, line] of Object.entries(jobs2.comments)) io.err(` comment ${line} (${command})`);
6291
6393
  for (const job of summary.waves?.jobs ?? []) io.err(` wave ${job}`);
6292
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})`);
6293
6396
  }
6294
6397
  if (!json && "control" in control3) io.err(` control after the last wave, tells ${control3.repo} how the waves ended`);
6295
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'`);
@@ -6322,7 +6425,9 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6322
6425
  const respond = respondOptions(chant, pipeline, shape.applyBeforeMerge, notes);
6323
6426
  const applyMerge = shape.applyBeforeMerge ? pipeline.applyMerge : void 0;
6324
6427
  if (approvalOf(doc) === "pr-review" && shape.waves && hasChantApprovalStatus(chant) && !hasChantPullRequestApprovalStatus(chant)) notes.push(APPROVAL_STATUS_NOTE);
6325
- 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);
6326
6431
  const requires = shape.applyBeforeMerge ? requiresArgv(chant, doc) : [];
6327
6432
  const previewComment = hasChant4620(chant);
6328
6433
  const jobs2 = chant.productPipelineJobs(forge, env, {
@@ -6339,9 +6444,10 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6339
6444
  // chant#4628: the note names the target commit it was previewed against, and a push marks older notes stale.
6340
6445
  staleNotes: true,
6341
6446
  ...respond.options,
6342
- ...applyMerge ? { applyMerge } : {}
6447
+ ...applyMerge ? { applyMerge } : {},
6448
+ ...actionJob ? { actionJob: true } : {}
6343
6449
  });
6344
- const prWords = shape.applyBeforeMerge ? words : wordsForJobs(shape.jobs.filter((j) => j !== "apply"));
6450
+ const prWords = shape.applyBeforeMerge ? words : wordsForJobs(jobsWanted.filter((j) => j !== "apply"));
6345
6451
  const argv = [
6346
6452
  "build",
6347
6453
  ".",
@@ -6358,6 +6464,7 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6358
6464
  ...respond.argv,
6359
6465
  ...previewComment ? ["--preview-comment"] : [],
6360
6466
  "--stale-notes",
6467
+ ...actionJob ? ["--action-job"] : [],
6361
6468
  // Review stacks (plywood#15): chant's pr-env jobs, only once the pulumi lexicon can remove what they create.
6362
6469
  ...doc.review_stacks !== void 0 && opts.reviewStacks ? ["--pr-env"] : [],
6363
6470
  ...opts.prJobs?.flags ?? [],
@@ -6365,6 +6472,11 @@ function workflowPlan(chant, doc, forge, env, waves, extra = [], opts = {}) {
6365
6472
  ];
6366
6473
  return { jobs: jobs2, words, prWords, argv, notes, ...applyMerge ? { applyMerge } : {} };
6367
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
+ }
6368
6480
  function applyMergeArgv(m) {
6369
6481
  return ["--merge", "auto", "--merge-token-env", m.tokenEnv, ...m.method ? ["--merge-method", m.method] : []];
6370
6482
  }
@@ -6414,7 +6526,7 @@ function usage(message, table, io) {
6414
6526
  io.err(`${PRODUCT}: ${message}`);
6415
6527
  return table.failed;
6416
6528
  }
6417
- var CHANT_PASSTHROUGH, VALUED, PREVIEW_TARGET_NEEDS_CHANT, WORKFLOW_FILE, 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;
6418
6530
  var init_stages = __esm({
6419
6531
  "src/stages.ts"() {
6420
6532
  "use strict";
@@ -6426,6 +6538,7 @@ var init_stages = __esm({
6426
6538
  init_apply();
6427
6539
  init_config();
6428
6540
  init_locks();
6541
+ init_stacks();
6429
6542
  init_routing();
6430
6543
  init_workflow();
6431
6544
  init_review_stacks2();
@@ -6442,6 +6555,7 @@ var init_stages = __esm({
6442
6555
  forgejo: ".forgejo/workflows/plywood.yml",
6443
6556
  gitlab: ".gitlab-ci.yml"
6444
6557
  };
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";
6445
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`;
6446
6560
  CHANT_RESPOND_DEFAULTS = Object.freeze({ "wave-refused": "diff", "apply-failed": "triage", description: "off" });
6447
6561
  DECIDE_LEFT_OFF = "decide is set and respond.description is not check, so plywood leaves the decide service off the pull request jobs";
@@ -6512,1100 +6626,1185 @@ function exitCodes() {
6512
6626
  return cached;
6513
6627
  }
6514
6628
 
6515
- // 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();
6516
6640
  init_detect();
6641
+ init_refusals();
6517
6642
  init_stages();
6643
+ init_chant_product();
6518
6644
  init_words();
6519
- var AUDIT_TABLES = { approvals: "gates", updates: "applies", runs: "runs", "review-stacks": "pr_envs" };
6520
- var DEFAULT_AUDIT_TABLE = "approvals";
6521
- function auditTable(word) {
6522
- const w = word ?? DEFAULT_AUDIT_TABLE;
6523
- return AUDIT_TABLES[w] ?? w;
6524
- }
6525
- function auditMember(stack) {
6526
- const [project, name] = stack.split("/");
6527
- 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;
6528
6660
  }
6529
- function auditArgv(args, json) {
6530
- const stack = takeFlag(args, "--stack");
6531
- const since = takeFlag(stack.rest, "--since");
6532
- const at = takeFlag(since.rest, "--at");
6533
- for (const [flag, f] of [["--stack", stack], ["--since", since], ["--at", at]]) if (f.missing) return { error: `${flag} takes a value` };
6534
- const wheres = [];
6535
- const rest = [];
6536
- for (let i = 0; i < at.rest.length; i++) {
6537
- const a = at.rest[i];
6538
- if (a === "--where") {
6539
- const v = at.rest[i + 1];
6540
- if (v === void 0 || v.startsWith("--")) return { error: "--where takes <field><op><value>, such as gate=wave-2" };
6541
- wheres.push("--where", v);
6542
- i++;
6543
- } else rest.push(a);
6544
- }
6545
- const flags = rest.filter((a) => a.startsWith("-"));
6546
- if (flags.length) return { error: `audit does not take ${flags.join(" ")}` };
6547
- if (rest.length > 1) return { error: `audit reads one table: ${Object.keys(AUDIT_TABLES).join(", ")}, or a chant ledger table` };
6548
- let member;
6549
- if (stack.value !== void 0) {
6550
- member = auditMember(stack.value);
6551
- 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}` };
6552
6670
  }
6553
- return [
6554
- "ledger",
6555
- auditTable(rest[0]),
6556
- ...member ? ["--member", member] : [],
6557
- ...since.value ? ["--since", since.value] : [],
6558
- ...wheres,
6559
- ...at.value ? ["--at", at.value] : [],
6560
- ...json ? ["--json"] : []
6561
- ];
6562
- }
6563
- async function audit2(args, json, table, io) {
6564
- const argv = auditArgv(args, json);
6565
- if ("error" in argv) return usage(argv.error, table, io);
6566
- 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 };
6567
6675
  }
6568
-
6569
- // src/cli.ts
6570
- init_agents2();
6571
- init_drift2();
6572
-
6573
- // src/estate.ts
6574
- init_chant_product();
6575
- init_chant_config();
6576
- init_config();
6577
- init_stages();
6578
- init_words();
6579
- import { dirname as dirname11 } from "node:path";
6580
- var estateModule;
6581
- function chantEstate() {
6582
- estateModule ??= (async () => {
6676
+ var cachedOps;
6677
+ var cachedRollback;
6678
+ function chantStackOps() {
6679
+ cachedOps ??= (async () => {
6583
6680
  try {
6584
- const m = await import("@intentius/chant/ci-estate");
6585
- const r = await import("@intentius/chant/pr-report-url");
6586
- if (!["readEstateLedger", "buildLedgerEstate", "writeEstate"].every((f) => typeof m[f] === "function")) return void 0;
6587
- if (typeof r.parseReportUrl !== "function" || typeof r.reportPut !== "function") return void 0;
6588
- 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;
6589
6683
  } catch {
6590
6684
  return void 0;
6591
6685
  }
6592
6686
  })();
6593
- return estateModule;
6594
- }
6595
- 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.`;
6596
- function estateSchedules(config2) {
6597
- const drift3 = toChantSettings(config2).pipeline.drift;
6598
- const driftOps = (drift3?.stacks ?? []).map(driftOpName);
6599
- const schedules = drift3?.schedule ? driftOps.map((op) => ({ op, cron: drift3.schedule })) : [];
6600
- return { schedules, driftOps };
6687
+ return cachedOps;
6601
6688
  }
6602
- async function estate(args, json, table, io) {
6603
- const failOverdue = args.includes("--fail-overdue");
6604
- let rest = args.filter((a) => a !== "--fail-overdue");
6605
- const flags = {};
6606
- for (const flag of ["--file", "--report-url", "--prefix"]) {
6607
- const f = takeFlag(rest, flag);
6608
- if (f.missing) return usage(`${flag} takes a value`, table, io);
6609
- flags[flag] = f.value;
6610
- rest = f.rest;
6611
- }
6612
- if (rest.length) return usage(`estate does not take ${rest.join(" ")}`, table, io);
6613
- const chant = await chantEstate();
6614
- if (!chant) {
6615
- if (json) io.out(JSON.stringify({ command: "estate", ok: false, exitCode: table.failed, error: ESTATE_NEEDS_CHANT }));
6616
- else io.err(`${PRODUCT}: ${ESTATE_NEEDS_CHANT}`);
6617
- return table.failed;
6618
- }
6619
- const path = flags["--file"] ?? findConfig(io.cwd);
6620
- let config2;
6621
- if (path) {
6689
+ function chantStackRollback() {
6690
+ cachedRollback ??= (async () => {
6622
6691
  try {
6623
- config2 = loadConfig(path);
6624
- } catch (e) {
6625
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6626
- }
6627
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6628
- if (errors.length) {
6629
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6630
- 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;
6631
6698
  }
6632
- }
6633
- const { schedules, driftOps } = config2 ? estateSchedules(config2) : { schedules: [], driftOps: [] };
6634
- const product = await chantProduct();
6635
- const words = product ? product.assertProductWords(PRODUCT_WORDS) : PRODUCT_WORDS;
6636
- const root = path ? dirname11(path) : io.cwd;
6637
- const load = await chant.readEstateLedger(root);
6638
- const built = chant.buildLedgerEstate({ load, words, schedules, ...driftOps.length ? { driftOps } : {} });
6639
- const url = flags["--report-url"] ?? (config2 ? toChantSettings(config2).ci.reportUrl : void 0);
6640
- let written;
6641
- if (url) {
6642
- const target = chant.parseReportUrl(url, flags["--report-url"] ? "--report-url" : "reports.url");
6643
- if (target && typeof target === "object" && "error" in target) return usage(String(target.error), table, io);
6644
- written = await chant.writeEstate(built, { put: chant.reportPut(target), words, env: io.env, ...flags["--prefix"] ? { prefix: flags["--prefix"] } : {} });
6645
- }
6646
- const code = failOverdue && built.overdue.length > 0 ? table.failed : table.done;
6647
- if (json) {
6648
- io.out(JSON.stringify({ command: "estate", ok: code === table.done, exitCode: code, estate: built.estate, dora: built.dora, overdue: built.overdue, ...written ? { written } : {} }));
6649
- return code;
6650
- }
6651
- const totals = built.estate.totals ?? {};
6652
- io.out(`${PRODUCT} estate: ${Object.entries(totals).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing on the ledger yet"}`);
6653
- for (const o of built.overdue) io.out(` ${o.message}`);
6654
- if (written) io.out(` wrote ${written.keys.join(", ")} under ${url}`);
6655
- else io.out(` no report URL (reports.url or --report-url), so nothing was written`);
6656
- return code;
6699
+ })();
6700
+ return cachedRollback;
6657
6701
  }
6658
-
6659
- // src/notify.ts
6660
- init_stages();
6661
- init_words();
6662
- var RELAY_NEEDS_CHANT = `${PRODUCT} notify relay needs chant >= the release after 0.124.54, which carries chant notify relay (chant#4611).`;
6663
- async function notify(args, json, table, io) {
6664
- if (args[0] !== "relay") return usage(`unknown notify command '${args[0] ?? ""}'. Run '${PRODUCT} notify relay'.`, table, io);
6665
- return withChant(`${PRODUCT} notify relay`, table, io, (chant) => {
6666
- if (typeof chant.startRelayServer !== "function") {
6667
- if (json) io.out(JSON.stringify({ command: "notify relay", ok: false, exitCode: table.failed, error: RELAY_NEEDS_CHANT }));
6668
- else io.err(`${PRODUCT}: ${RELAY_NEEDS_CHANT}`);
6669
- return Promise.resolve(table.failed);
6670
- }
6671
- return chant.runChantInProcess(["notify", ...args], PRODUCT_WORDS);
6672
- });
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'.`;
6673
6704
  }
6674
-
6675
- // src/cli.ts
6676
- init_policy();
6677
-
6678
- // src/guard.ts
6679
- init_stages();
6680
- init_words();
6681
- import { chmodSync, copyFileSync, existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "node:fs";
6682
- import { join as join16 } from "node:path";
6683
- var GUARDED = {
6684
- pulumi: ["up", "update", "destroy"],
6685
- plunge: ["up", "update", "destroy"]
6686
- };
6687
- var VALUED_GLOBALS = /* @__PURE__ */ new Set(["-C", "--cwd", "-s", "--stack", "--color", "--config-file", "--logflow", "-v", "--verbose", "--tracing", "--profiling", "--memprofilerate"]);
6688
- var LAUNCHERS = /* @__PURE__ */ new Set(["env", "npx", "sudo", "command", "exec", "nohup", "time", "xargs"]);
6689
- function segments(line) {
6690
- 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 };
6691
6712
  }
6692
- function guardVerdict(line) {
6693
- for (const words of segments(line)) {
6694
- let i = 0;
6695
- 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++;
6696
- const bin = (words[i] ?? "").split("/").pop();
6697
- const refused = GUARDED[bin];
6698
- if (!refused) continue;
6699
- for (let j = i + 1; j < words.length; j++) {
6700
- const w = words[j];
6701
- if (w.startsWith("-")) {
6702
- if (VALUED_GLOBALS.has(w)) j++;
6703
- continue;
6704
- }
6705
- if (refused.includes(w)) {
6706
- return {
6707
- engine: bin,
6708
- subcommand: w,
6709
- 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.`
6710
- };
6711
- }
6712
- break;
6713
- }
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)` };
6714
6722
  }
6715
- 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
+ };
6716
6732
  }
6717
- var GUARD_TEMPLATES = new URL("../templates/agent-guard/", import.meta.url);
6718
- var HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/plywood-guard.mjs"';
6719
- function withGuardHook(settings) {
6720
- const hooks = typeof settings.hooks === "object" && settings.hooks !== null ? settings.hooks : {};
6721
- const pre = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
6722
- const has = JSON.stringify(pre).includes("plywood-guard.mjs");
6723
- if (!has) pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK_COMMAND }] });
6724
- 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 } : {} };
6725
6736
  }
6726
- async function guard(args, json, table, io) {
6727
- const [sub, ...rest] = args;
6728
- if (sub === "check") {
6729
- const line = (rest[0] === "--" ? rest.slice(1) : rest).join(" ");
6730
- if (!line.trim()) return usage("guard check takes the command line to check", table, io);
6731
- const v = guardVerdict(line);
6732
- if (json) io.out(JSON.stringify({ command: "guard check", ok: !v, exitCode: v ? table.denied : table.done, refused: v ?? null }));
6733
- else io.out(v ? v.message : `${PRODUCT} guard: '${line}' runs`);
6734
- return v ? table.denied : table.done;
6735
- }
6736
- if (sub === "install") {
6737
- const dirFlag = takeFlag(rest, "--dir");
6738
- if (dirFlag.missing) return usage("--dir takes a directory", table, io);
6739
- if (dirFlag.rest.length) return usage(`guard install does not take ${dirFlag.rest.join(" ")}`, table, io);
6740
- const root = dirFlag.value ? join16(io.cwd, dirFlag.value) : io.cwd;
6741
- const written = [];
6742
- const hookDir = join16(root, ".claude/hooks");
6743
- mkdirSync7(hookDir, { recursive: true });
6744
- copyFileSync(new URL("plywood-guard.mjs", GUARD_TEMPLATES), join16(hookDir, "plywood-guard.mjs"));
6745
- written.push(".claude/hooks/plywood-guard.mjs");
6746
- const settingsFile = join16(root, ".claude/settings.json");
6747
- let settings = {};
6748
- if (existsSync13(settingsFile)) {
6749
- try {
6750
- settings = JSON.parse(readFileSync15(settingsFile, "utf8"));
6751
- } catch (e) {
6752
- return usage(`.claude/settings.json is not JSON (${e.message}); fix it, then run this again`, table, io);
6753
- }
6754
- }
6755
- writeFileSync8(settingsFile, JSON.stringify(withGuardHook(settings), null, 2) + "\n");
6756
- written.push(".claude/settings.json");
6757
- const binDir = join16(root, ".plywood/agent-bin");
6758
- mkdirSync7(binDir, { recursive: true });
6759
- for (const name of Object.keys(GUARDED)) {
6760
- const to = join16(binDir, name);
6761
- copyFileSync(new URL(`bin/${name}`, GUARD_TEMPLATES), to);
6762
- chmodSync(to, 493);
6763
- written.push(`.plywood/agent-bin/${name}`);
6764
- }
6765
- if (json) io.out(JSON.stringify({ command: "guard install", ok: true, exitCode: table.done, written }));
6766
- else {
6767
- for (const f of written) io.err(`${PRODUCT} guard: wrote ${f}`);
6768
- 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`);
6769
- }
6770
- return table.done;
6771
- }
6772
- return usage(`guard takes check <command line> or install [--dir <d>]`, table, io);
6773
- }
6774
-
6775
- // src/identities.ts
6776
- init_jobs2();
6777
- init_chant_config();
6778
- init_identity();
6779
- init_config();
6780
- init_stacks();
6781
- init_stages();
6782
- init_words();
6783
- import { existsSync as existsSync14, readFileSync as readFileSync16 } from "node:fs";
6784
- import { dirname as dirname12, join as join17 } from "node:path";
6785
- import { targetGlobMatch } from "@intentius/chant/ci-identity";
6786
- function identityRows(config2, env) {
6787
- const doc = config2.doc;
6788
- const out = [];
6789
- for (const stack of Object.keys(stacksOf(doc))) {
6790
- const ids = stackIdentities(doc, stack, env, targetGlobMatch);
6791
- for (const cloud of IDENTITY_CLOUDS) {
6792
- const id = ids[cloud];
6793
- if (id) out.push({ stack, cloud, preview: id.preview, up: id.up, rule: id.rule });
6794
- }
6795
- }
6796
- return out;
6737
+ function requesterOf(by, env) {
6738
+ return by ?? env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
6797
6739
  }
6798
- function identityLines(config2, env, rows) {
6799
- const doc = config2.doc;
6800
- const block = chantIdentityOf(doc);
6801
- if (!block) return [`${config2.file ?? "plywood.yml"}: no identity; every job runs as ${NO_IDENTITY}`];
6802
- const lines2 = [`${config2.file ?? "plywood.yml"}: identities in environment ${env}`];
6803
- const clouds = Object.keys(block.cloud);
6804
- lines2.push(` clouds ${clouds.map((c) => CLOUD_NAMES[c]).join(", ")}`);
6805
- if (clouds.includes("pulumi")) {
6806
- const p = pulumiCloudOf(doc);
6807
- lines2.push(` Pulumi Cloud ${p.org}${p.apiUrl ? ` at ${p.apiUrl}` : ""}; ESC environments open with each job's own token`);
6808
- }
6809
- const stacks2 = Object.keys(stacksOf(doc));
6810
- if (stacks2.length === 0) lines2.push(" plywood.yml lists no stacks; the rules apply to each stack plywood init finds");
6811
- for (const stack of stacks2) {
6812
- lines2.push(` ${stack}`);
6813
- for (const cloud of clouds) {
6814
- const r = rows.find((x) => x.stack === stack && x.cloud === cloud);
6815
- lines2.push(r ? ` ${CLOUD_NAMES[cloud].padEnd(12)} preview ${r.preview}, up ${r.up} (${r.rule})` : ` ${CLOUD_NAMES[cloud].padEnd(12)} ${NO_IDENTITY}`);
6816
- }
6817
- }
6818
- lines2.push(" The preview job trades its OIDC token before checkout, and no later step holds a forge token.");
6819
- 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}`;
6820
6745
  }
6821
- function configIdentities(args, json, table, io) {
6822
- const file = takeFlag(args, "--file");
6823
- const env = takeFlag(file.rest, "--env");
6824
- if (file.missing || env.missing) return usage(`${file.missing ? "--file" : "--env"} takes a value`, table, io);
6825
- if (env.rest.length) return usage(`config identities does not take ${env.rest.join(" ")}`, table, io);
6826
- const path = file.value ?? findConfig(io.cwd);
6827
- if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
6828
- let config2;
6829
- try {
6830
- config2 = loadConfig(path);
6831
- } catch (e) {
6832
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6833
- }
6834
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6835
- if (errors.length) {
6836
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6837
- if (json) io.out(JSON.stringify({ command: "config identities", ok: false, exitCode: table.failed, file: path, problems: errors }));
6838
- return table.failed;
6839
- }
6840
- const environment = env.value ?? "production";
6841
- const rows = identityRows(config2, environment);
6842
- 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 }));
6843
- else for (const line of identityLines(config2, environment, rows)) io.out(line);
6844
- return table.done;
6746
+ function approveRequestLine(gate, digest, mode) {
6747
+ return `${PRODUCT} approve ${gate} --op ${STACK_OPS_OP} --plan ${digest}${mode === "sealed" ? " --sign" : ""}`;
6845
6748
  }
6846
- 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.`;
6847
- var FORGES2 = ["github", "gitlab", "forgejo"];
6848
- var FORGE_CHECK_FUNCTIONS = ["forgeCheck", "forgeCheckExit", "describeForgeCheck", "forgeCheckTarget", "pipelineContract"];
6849
- var forgeCheckModule;
6850
- function chantForgeCheck() {
6851
- forgeCheckModule ??= (async () => {
6852
- try {
6853
- const fc = await import("@intentius/chant/forge-check");
6854
- const id = await import("@intentius/chant/ci-identity");
6855
- if (!FORGE_CHECK_FUNCTIONS.every((f) => typeof fc[f] === "function") || typeof id.resolveCloudIdentity !== "function") return void 0;
6856
- return { ...fc, resolveCloudIdentity: id.resolveCloudIdentity };
6857
- } catch {
6858
- return void 0;
6859
- }
6860
- })();
6861
- 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;
6862
6754
  }
6863
- function workspaceMembers(root) {
6864
- const file = join17(root, "chant.workspace.json");
6865
- if (!existsSync14(file)) return [];
6866
- try {
6867
- const doc = JSON.parse(readFileSync16(file, "utf8"));
6868
- return (doc.members ?? []).map((m) => m.name).filter((n) => typeof n === "string");
6869
- } catch {
6870
- 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;
6871
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;
6872
6797
  }
6873
- function upRunner(runner) {
6874
- if (runner === void 0) return void 0;
6875
- const staged = typeof runner === "object" && !Array.isArray(runner) && !("group" in runner);
6876
- const spec = staged ? runner.up ?? runner.default : runner;
6877
- if (spec === void 0) return void 0;
6878
- if (typeof spec === "string") return { labels: [spec] };
6879
- if (Array.isArray(spec)) return { labels: spec.filter((l) => typeof l === "string") };
6880
- const g = spec;
6881
- return typeof g.group === "string" ? { labels: Array.isArray(g.labels) ? g.labels : [], group: g.group } : void 0;
6882
- }
6883
- function contractPipelines(fc, cloud, env, members, runner, jobs2) {
6884
- const applyRunner = upRunner(runner);
6885
- const list = members.length ? [...members] : [void 0];
6886
- return list.flatMap((member) => {
6887
- const identity2 = fc.resolveCloudIdentity(cloud, env, member);
6888
- const name = member ? `${member}/${env}` : env;
6889
- const forgeEnvironment = jobs2 ? environmentFor(name, jobs2)?.name : void 0;
6890
- return identity2 ? [{ name, identity: identity2, ...member ? { member } : {}, ...applyRunner ? { applyRunner } : {}, ...forgeEnvironment ? { forgeEnvironment } : {} }] : [];
6891
- });
6892
- }
6893
- async function forgeCheck(args, json, table, io) {
6894
- if (args[0] !== "check") return usage(`unknown forge command '${args[0] ?? ""}'. Run '${PRODUCT} forge check'.`, table, io);
6895
- const fc = await chantForgeCheck();
6896
- if (!fc) {
6897
- if (json) io.out(JSON.stringify({ command: "forge check", ok: false, exitCode: table.failed, error: FORGE_CHECK_MESSAGE }));
6898
- 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)")}`);
6899
6802
  return table.failed;
6900
6803
  }
6901
- const requireClouds = args.includes("--require-clouds");
6902
- let rest = args.slice(1).filter((a) => a !== "--require-clouds");
6903
- const flags = {};
6904
- for (const flag of ["--file", "--env", "--forge", "--repo", "--branch"]) {
6905
- const f = takeFlag(rest, flag);
6906
- if (f.missing) return usage(`${flag} takes a value`, table, io);
6907
- flags[flag] = f.value;
6908
- rest = f.rest;
6909
- }
6910
- if (rest.length) return usage(`forge check does not take ${rest.join(" ")}`, table, io);
6911
- const forgeFlag = flags["--forge"];
6912
- if (forgeFlag !== void 0 && !FORGES2.includes(forgeFlag)) return usage(`--forge is ${forgeFlag}; give github, gitlab or forgejo`, table, io);
6913
- const path = flags["--file"] ?? findConfig(io.cwd);
6914
- if (!path) return usage(`no plywood.yml in ${io.cwd} or above it; run '${PRODUCT} init', or pass --file`, table, io);
6915
- let config2;
6916
6804
  try {
6917
- config2 = loadConfig(path);
6805
+ return await call(ops);
6918
6806
  } catch (e) {
6919
- return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
6920
- }
6921
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
6922
- if (errors.length) {
6923
- for (const p of errors) io.err(`${path}: ${p.key} ${p.message}`);
6924
- 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}`);
6925
6810
  return table.failed;
6926
6811
  }
6927
- const settings = toChantSettings(config2);
6928
- const kind = forgeFlag ?? settings.pipeline.forge?.kind;
6929
- let target;
6930
- try {
6931
- target = fc.forgeCheckTarget(io.env, { ...kind && FORGES2.includes(kind) ? { kind } : {}, ...flags["--repo"] ? { repo: flags["--repo"] } : {} });
6932
- } catch (e) {
6933
- return usage(e.message, table, io);
6934
- }
6935
- const env = flags["--env"] ?? "production";
6936
- const cloud = settings.ci.identity?.cloud;
6937
- const pipelines = contractPipelines(fc, cloud, env, workspaceMembers(dirname12(path)), settings.pipeline.runner, settings.pipeline.jobs);
6938
- const result = await fc.forgeCheck(
6939
- {
6940
- target,
6941
- words: PRODUCT_WORDS,
6942
- file: path,
6943
- ...requireClouds ? { requireClouds: true } : {},
6944
- contract: (live) => fc.pipelineContract({
6945
- forge: target.kind,
6946
- repository: {
6947
- host: target.host,
6948
- path: live.path ?? target.repo,
6949
- defaultBranch: flags["--branch"] ?? live.defaultBranch ?? "main",
6950
- ...live.ownerId ? { ownerId: live.ownerId } : {},
6951
- ...live.repoId ? { repoId: live.repoId } : {}
6952
- },
6953
- pipelines,
6954
- words: PRODUCT_WORDS
6955
- })
6956
- },
6957
- { fetch: globalThis.fetch, env: io.env }
6958
- );
6959
- if (json) io.out(JSON.stringify(result));
6960
- else io.out(fc.describeForgeCheck(result));
6961
- if (!cloud) io.err(`${PRODUCT} forge check: ${path} sets no identity, so no trust was checked`);
6962
- else if (pipelines.length === 0) io.err(`${PRODUCT} forge check: no identity rule covers environment ${env}, so no trust was checked`);
6963
- return fc.forgeCheckExit(result);
6964
6812
  }
6965
-
6966
- // src/cli.ts
6967
- init_control2();
6968
-
6969
- // src/telemetry.ts
6970
- init_chant_product();
6971
- init_observe();
6972
- init_config();
6973
- init_words();
6974
- function telemetryEnv(doc, env) {
6975
- const t = telemetryOf(doc);
6976
- const out = {};
6977
- const set = (k, v) => {
6978
- if (v !== void 0 && v !== "" && env[k] === void 0) out[k] = v;
6979
- };
6980
- if (!t.traces) {
6981
- set("OTEL_TRACES_EXPORTER", "none");
6982
- return out;
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));
6983
6944
  }
6984
- if (env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT === void 0) set("OTEL_EXPORTER_OTLP_ENDPOINT", t.endpoint);
6985
- if (t.headersEnv) set("OTEL_EXPORTER_OTLP_HEADERS", env[t.headersEnv]);
6986
- 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);
6987
- return out;
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
+ });
6988
6951
  }
6989
- async function applyTelemetry(cwd, env = process.env) {
6990
- let added = {};
6991
- try {
6992
- const file = findConfig(cwd);
6993
- if (file) added = telemetryEnv(loadConfig(file).doc, env);
6994
- } catch {
6995
- return {};
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);
6996
6959
  }
6997
- Object.assign(env, added);
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
+ });
6990
+ }
6991
+
6992
+ // src/actions.ts
6993
+ init_words();
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;
6999
+ }
7000
+ async function fromEvent(env) {
6998
7001
  const chant = await chantProduct();
6999
- if (chant && typeof chant.configureOtlp === "function") chant.configureOtlp({ prefix: PRODUCT, env });
7000
- return added;
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);
7037
+ try {
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;
7069
+ }
7070
+ }
7071
+
7072
+ // src/audit.ts
7073
+ init_detect();
7074
+ init_stages();
7075
+ init_words();
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;
7081
+ }
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);
7101
+ }
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
+ ];
7119
+ }
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));
7124
+ }
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;
7148
+ }
7149
+ })();
7150
+ return estateModule;
7151
+ }
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 };
7158
+ }
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;
7168
+ }
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}`);
7174
+ return table.failed;
7175
+ }
7176
+ const path = flags["--file"] ?? findConfig(io.cwd);
7177
+ let config2;
7178
+ if (path) {
7179
+ try {
7180
+ config2 = loadConfig(path);
7181
+ } catch (e) {
7182
+ return usage(e instanceof ConfigError ? e.message : `${path}: ${e.message}`, table, io);
7183
+ }
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;
7188
+ }
7189
+ }
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"] } : {} });
7202
+ }
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;
7207
+ }
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`);
7213
+ return code;
7214
+ }
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
+ });
7001
7230
  }
7002
7231
 
7003
7232
  // src/cli.ts
7004
- init_init();
7005
- init_stages();
7006
- init_tips();
7007
- init_images();
7008
- init_up();
7233
+ init_policy();
7009
7234
 
7010
- // src/upgrade.ts
7011
- init_config();
7012
- init_images();
7235
+ // src/guard.ts
7013
7236
  init_stages();
7014
7237
  init_words();
7015
- import { execFile } from "node:child_process";
7016
- import { existsSync as existsSync15, readFileSync as readFileSync17, writeFileSync as writeFileSync9 } from "node:fs";
7017
- import { dirname as dirname13, join as join18, relative as relative2, resolve as resolve2 } from "node:path";
7018
- var PLYWOOD_PACKAGE = "@intentius/plywood";
7019
- var CHANT_PACKAGE = "@intentius/chant";
7020
- var CHANT_FAMILY = /^@intentius\/chant(?:-lexicon-[a-z0-9-]+)?$/;
7021
- var VERSION2 = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
7022
- var DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
7023
- function parseUpgradeArgs(args) {
7024
- const chant = takeFlag(args, "--chant");
7025
- const forge = takeFlag(chant.rest, "--forge");
7026
- const env = takeFlag(forge.rest, "--env");
7027
- if (chant.missing || forge.missing || env.missing) return { error: "--chant, --forge and --env each take a value" };
7028
- const flags = /* @__PURE__ */ new Set(["--no-install", "--dry-run"]);
7029
- const unknown = env.rest.filter((a) => a.startsWith("-") && !flags.has(a));
7030
- if (unknown.length) return { error: `upgrade does not take ${unknown.join(" ")}` };
7031
- const words = env.rest.filter((a) => !a.startsWith("-"));
7032
- if (words.length > 1) return { error: `upgrade takes one version, not ${words.join(" ")}` };
7033
- const version2 = words[0]?.replace(/^v/, "");
7034
- if (version2 !== void 0 && !VERSION2.test(version2)) return { error: `${words[0]} is not a version: give it as MAJOR.MINOR.PATCH` };
7035
- if (chant.value !== void 0 && !VERSION2.test(chant.value)) return { error: `--chant ${chant.value} is not a version: give it as MAJOR.MINOR.PATCH` };
7036
- return {
7037
- ...version2 ? { version: version2 } : {},
7038
- ...chant.value ? { chant: chant.value } : {},
7039
- ...forge.value ? { forge: forge.value } : {},
7040
- ...env.value ? { env: env.value } : {},
7041
- install: !env.rest.includes("--no-install"),
7042
- dryRun: env.rest.includes("--dry-run")
7043
- };
7044
- }
7045
- function findPlywoodPackage(dir) {
7046
- let cur = resolve2(dir);
7047
- for (let depth = 0; depth < 32; depth++) {
7048
- const file = join18(cur, "package.json");
7049
- if (existsSync15(file)) {
7050
- try {
7051
- const pkg = JSON.parse(readFileSync17(file, "utf8"));
7052
- if (DEP_FIELDS.some((f) => isObject2(pkg[f]) && typeof pkg[f][PLYWOOD_PACKAGE] === "string")) return file;
7053
- } catch {
7054
- }
7055
- }
7056
- const up2 = dirname13(cur);
7057
- if (up2 === cur) return void 0;
7058
- cur = up2;
7059
- }
7060
- return void 0;
7061
- }
7062
- function movedRange(range, version2) {
7063
- const m = /^([~^]?)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(range.trim());
7064
- if (!m) return void 0;
7065
- return `${m[1]}${version2}`;
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);
7066
7248
  }
7067
- function movePackagePins(text, file, plywood, chant) {
7068
- const pkg = JSON.parse(text);
7069
- const moves = [];
7070
- const left = [];
7071
- for (const field of DEP_FIELDS) {
7072
- const deps = pkg[field];
7073
- if (!isObject2(deps)) continue;
7074
- for (const [name, range] of Object.entries(deps)) {
7075
- if (typeof range !== "string") continue;
7076
- const target = name === PLYWOOD_PACKAGE ? plywood : CHANT_FAMILY.test(name) ? chant : void 0;
7077
- if (target === void 0) continue;
7078
- const next = movedRange(range, target);
7079
- if (next === void 0) {
7080
- left.push(`${field}.${name} is '${range}'; move it to ${target} by hand`);
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++;
7081
7260
  continue;
7082
7261
  }
7083
- if (next === range) continue;
7084
- deps[name] = next;
7085
- moves.push({ file, name, from: range, to: next });
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;
7086
7270
  }
7087
7271
  }
7088
- return { text: moves.length ? jsonText2(pkg, text) : text, moves, left };
7089
- }
7090
- function moveDeclarationPins(text, file, chant) {
7091
- const doc = JSON.parse(text);
7092
- const moves = [];
7093
- const pins = Array.isArray(doc.pins) ? doc.pins : [];
7094
- for (const pin of pins) {
7095
- if (typeof pin.package !== "string" || !CHANT_FAMILY.test(pin.package) || typeof pin.version !== "string") continue;
7096
- if (pin.version === chant) continue;
7097
- moves.push({ file, name: pin.package, from: pin.version, to: chant });
7098
- pin.version = chant;
7099
- }
7100
- return { text: moves.length ? jsonText2(doc, text) : text, moves };
7272
+ return void 0;
7101
7273
  }
7102
- function imageNote(doc, version2) {
7103
- const image = doc.image;
7104
- const name = typeof image === "string" ? image : isObject2(image) && typeof image.name === "string" ? image.name : void 0;
7105
- if (name === void 0) return void 0;
7106
- const m = new RegExp(`^${REGISTRY.replace(/[.]/g, "\\.")}/plywood-(pulumi|plunge):([^@]+)`).exec(name);
7107
- if (m && m[2].startsWith(`${version2}-`)) return void 0;
7108
- 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`;
7109
- return `plywood.yml's image is your own (${name}); rebuild it FROM plywood's ${version2} image and name the new digest in it`;
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 } };
7110
7282
  }
7111
- async function viewRelease(exec, cwd, env, version2) {
7112
- const spec = `${PLYWOOD_PACKAGE}@${version2 ?? "latest"}`;
7113
- const r = await exec("npm", ["view", spec, "version", "dependencies", "--json"], { cwd, env });
7114
- if (r.code !== 0) {
7115
- const notFound = /E404/.test(r.stderr + r.stdout);
7116
- return { error: notFound ? `npm has no ${spec}` : `npm view ${spec} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] ?? ""}` };
7117
- }
7118
- let parsed;
7119
- try {
7120
- parsed = JSON.parse(r.stdout);
7121
- } catch {
7122
- return { error: `npm view ${spec} printed no JSON` };
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;
7123
7292
  }
7124
- const doc = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
7125
- if (!isObject2(doc) || typeof doc.version !== "string") return { error: `npm view ${spec} named no version` };
7126
- const deps = isObject2(doc.dependencies) ? doc.dependencies : {};
7127
- const chant = deps[CHANT_PACKAGE];
7128
- if (typeof chant !== "string" || !VERSION2.test(chant)) return { error: `${PLYWOOD_PACKAGE}@${doc.version} pins no exact ${CHANT_PACKAGE}` };
7129
- return { version: doc.version, chant };
7130
- }
7131
- async function upgrade(args, json, table, io) {
7132
- const opts = parseUpgradeArgs(args);
7133
- const fail = (message) => {
7134
- if (json) io.out(JSON.stringify({ command: "upgrade", ok: false, exitCode: table.failed, error: message }));
7135
- else io.err(`${PRODUCT}: ${message}`);
7136
- return table.failed;
7137
- };
7138
- if ("error" in opts) return fail(opts.error);
7139
- const exec = io.exec ?? execChild;
7140
- const pkgFile = findPlywoodPackage(io.cwd);
7141
- 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'`);
7142
- const pkgDir = dirname13(pkgFile);
7143
- const configFile = findConfig(io.cwd);
7144
- const configDir = configFile ? dirname13(configFile) : pkgDir;
7145
- const rel = (f) => relative2(io.cwd, f) || f;
7146
- const release2 = await viewRelease(exec, pkgDir, io.env, opts.version);
7147
- if ("error" in release2) return fail(release2.error);
7148
- const chant = opts.chant ?? release2.chant;
7149
- const pkgText = readFileSync17(pkgFile, "utf8");
7150
- const pkgMove = movePackagePins(pkgText, rel(pkgFile), release2.version, chant);
7151
- const declFile = join18(configDir, "chant.workspace.json");
7152
- const declText = existsSync15(declFile) ? readFileSync17(declFile, "utf8") : void 0;
7153
- let declMove;
7154
- if (declText !== void 0) {
7155
- try {
7156
- declMove = moveDeclarationPins(declText, rel(declFile), chant);
7157
- } catch (e) {
7158
- return fail(`${rel(declFile)} is not JSON upgrade can move pins in (${e.message})`);
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
+ }
7159
7311
  }
7160
- }
7161
- const notes = [...pkgMove.left];
7162
- if (existsSync15(join18(configDir, "chant.workspace.jsonc"))) notes.push(`chant.workspace.jsonc is yours: move its @intentius/chant* pins to ${chant} by hand`);
7163
- if (configFile) {
7164
- try {
7165
- const note = imageNote(loadConfig(configFile).doc, release2.version);
7166
- if (note) notes.push(note);
7167
- } catch {
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`);
7168
7326
  }
7169
- } else {
7170
- notes.push(`no plywood.yml, so there is no workflow to write again; run '${PRODUCT} init' after the install`);
7171
- }
7172
- const moves = [...pkgMove.moves, ...declMove?.moves ?? []];
7173
- const ciArgs = ["--no-install", PRODUCT, "ci", ...opts.forge ? ["--forge", opts.forge] : [], ...opts.env ? ["--env", opts.env] : []];
7174
- const steps = {
7175
- install: { cwd: rel(pkgDir) || ".", command: "npm install --no-audit --no-fund" },
7176
- ci: { cwd: rel(configDir) || ".", command: `npx ${ciArgs.join(" ")}` }
7177
- };
7178
- const envelope = { command: "upgrade", plywood: release2.version, chant, moves, notes, dryRun: opts.dryRun };
7179
- const say = (line) => {
7180
- if (!json) io.err(line);
7181
- };
7182
- say(`${PRODUCT} upgrade: ${PLYWOOD_PACKAGE} ${release2.version}, ${CHANT_PACKAGE} ${chant}`);
7183
- for (const m of moves) say(` ${m.file} ${m.name} ${m.from} -> ${m.to}`);
7184
- if (moves.length === 0) say(" every pin is already there");
7185
- if (opts.dryRun) {
7186
- say(` then: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7187
- for (const n of notes) say(` note: ${n}`);
7188
- if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, steps }));
7189
- return table.done;
7190
- }
7191
- if (pkgMove.text !== pkgText) writeFileSync9(pkgFile, pkgMove.text);
7192
- if (declMove && declText !== void 0 && declMove.text !== declText) writeFileSync9(declFile, declMove.text);
7193
- const files = [...new Set(moves.map((m) => m.file))];
7194
- if (!opts.install) {
7195
- say(` left to run: ${steps.install.command} (in ${steps.install.cwd}), then ${steps.ci.command} (in ${steps.ci.cwd})`);
7196
- for (const n of notes) say(` note: ${n}`);
7197
- if (json) io.out(JSON.stringify({ ...envelope, ok: true, exitCode: table.done, files, steps }));
7198
7327
  return table.done;
7199
7328
  }
7200
- say(` $ ${steps.install.command}`);
7201
- const installed2 = await exec("npm", ["install", "--no-audit", "--no-fund"], { cwd: pkgDir, env: io.env });
7202
- if (installed2.code !== 0) {
7203
- for (const n of notes) say(` note: ${n}`);
7204
- return fail(`npm install failed (exit ${installed2.code}); package.json is moved, so fix the install and run '${steps.ci.command}'
7205
- ${installed2.stderr.trim()}`);
7206
- }
7207
- const got = installedVersion(pkgDir);
7208
- if (got !== void 0 && got !== release2.version) notes.push(`node_modules holds ${PLYWOOD_PACKAGE} ${got}, not ${release2.version}; check the install`);
7209
- let ci2;
7210
- if (configFile) {
7211
- say(` $ ${steps.ci.command}`);
7212
- ci2 = await exec("npx", ciArgs, { cwd: configDir, env: io.env });
7213
- if (!json && ci2.stderr.trim()) io.err(ci2.stderr.trimEnd());
7214
- }
7215
- for (const n of notes) say(` note: ${n}`);
7216
- const ok = ci2 === void 0 || ci2.code === 0;
7217
- const lockFile = join18(pkgDir, "package-lock.json");
7218
- const changed = [...files, ...existsSync15(lockFile) ? [rel(lockFile)] : []];
7219
- if (ok) say(` Open one pull request with ${changed.join(", ")} and the files ${PRODUCT} ci wrote.`);
7220
- else say(` ${PRODUCT} ci failed (exit ${ci2?.code}); the pins are moved and installed, so fix plywood.yml and run '${steps.ci.command}'`);
7221
- const code = ok ? table.done : ci2?.code ?? table.failed;
7222
- if (json) io.out(JSON.stringify({ ...envelope, ok, exitCode: code, files: changed, ...ci2 ? { ci: { exitCode: ci2.code } } : {} }));
7223
- return code;
7224
- }
7225
- function installedVersion(dir) {
7226
- try {
7227
- const pkg = JSON.parse(readFileSync17(join18(dir, "node_modules", PLYWOOD_PACKAGE, "package.json"), "utf8"));
7228
- return typeof pkg.version === "string" ? pkg.version : void 0;
7229
- } catch {
7230
- return void 0;
7231
- }
7232
- }
7233
- function jsonText2(value, original) {
7234
- const indent = /^[{[]\r?\n([ \t]+)/.exec(original)?.[1] ?? " ";
7235
- return `${JSON.stringify(value, null, indent)}
7236
- `;
7237
- }
7238
- function isObject2(v) {
7239
- return typeof v === "object" && v !== null && !Array.isArray(v);
7329
+ return usage(`guard takes check <command line> or install [--dir <d>]`, table, io);
7240
7330
  }
7241
- var execChild = (bin, args, opts) => new Promise((done) => {
7242
- execFile(bin, args, { cwd: opts.cwd, env: opts.env, maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" }, (error2, stdout, stderr) => {
7243
- const code = error2 ? typeof error2.code === "number" ? error2.code : 1 : 0;
7244
- done({ code, stdout: String(stdout), stderr: String(stderr) });
7245
- });
7246
- });
7247
7331
 
7248
- // src/stack-ops.ts
7332
+ // src/identities.ts
7333
+ init_jobs2();
7249
7334
  init_chant_config();
7250
7335
  init_identity();
7251
7336
  init_config();
7252
7337
  init_stacks();
7253
- init_adopt();
7254
- init_detect();
7255
- init_refusals();
7256
7338
  init_stages();
7257
- init_chant_product();
7258
7339
  init_words();
7259
- import { existsSync as existsSync16 } from "node:fs";
7260
- import { dirname as dirname14, isAbsolute as isAbsolute2, join as join19, resolve as resolve3 } from "node:path";
7261
- var STACK_OPS_OP = "pulumi-stack-ops";
7262
- var REQUIRED4 = ["gatedStackExport", "gatedStackCancel", "gatedStackMove"];
7263
- var cachedLocks;
7264
- function chantStackLockReader() {
7265
- cachedLocks ??= (async () => {
7266
- try {
7267
- const m = await import("@intentius/chant-lexicon-pulumi");
7268
- return typeof m.pulumiStackLockIds === "function" ? m.pulumiStackLockIds : void 0;
7269
- } catch {
7270
- 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 });
7271
7351
  }
7272
- })();
7273
- return cachedLocks;
7352
+ }
7353
+ return out;
7274
7354
  }
7275
- async function lockToCancel(given, stack, env) {
7276
- if (given) return { lockId: given, read: false };
7277
- const reader = await chantStackLockReader();
7278
- if (!reader) return { error: STACK_OP_WORDS.needsLock };
7279
- 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;
7280
7386
  try {
7281
- locks2 = await reader({ dir: stack.dir, stack: stack.stack, ...stack.project ? { project: stack.project } : {}, ...stack.engine ? { engine: stack.engine } : {}, env });
7387
+ config2 = loadConfig(path);
7282
7388
  } catch (e) {
7283
- 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);
7284
7390
  }
7285
- if (!locks2.checked) return { error: STACK_OP_WORDS.lockNotListed(locks2.backend) };
7286
- if (locks2.ids.length === 0) return { error: STACK_OP_WORDS.noLock(stack.stack) };
7287
- if (locks2.ids.length > 1) return { error: STACK_OP_WORDS.manyLocks(stack.stack, locks2.ids) };
7288
- 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;
7289
7402
  }
7290
- var cachedOps;
7291
- var cachedRollback;
7292
- function chantStackOps() {
7293
- 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 () => {
7294
7409
  try {
7295
- const m = await import("@intentius/chant-lexicon-pulumi");
7296
- 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 };
7297
7414
  } catch {
7298
7415
  return void 0;
7299
7416
  }
7300
7417
  })();
7301
- return cachedOps;
7302
- }
7303
- function chantStackRollback() {
7304
- cachedRollback ??= (async () => {
7305
- try {
7306
- const m = await import("@intentius/chant-lexicon-pulumi");
7307
- if (typeof m.pulumiReleaseInputs !== "function") return false;
7308
- const copy2 = await import("@intentius/chant-lexicon-pulumi/release-copy");
7309
- return typeof copy2.prepareReleaseCopy === "function";
7310
- } catch {
7311
- return false;
7312
- }
7313
- })();
7314
- return cachedRollback;
7315
- }
7316
- function needsStackOpsMessage(what, feature) {
7317
- 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'.`;
7318
- }
7319
- function readConfig(cwd) {
7320
- const path = findConfig(cwd);
7321
- if (!path) return { error: `no plywood.yml in ${cwd} or above it; run '${PRODUCT} init' first` };
7322
- const config2 = loadConfig(path);
7323
- const errors = checkConfig(config2).filter((p) => p.severity === "error");
7324
- if (errors.length) return { error: errors.map((p) => `${path}: ${p.key} ${p.message}`).join("\n") };
7325
- return { config: config2, path };
7418
+ return forgeCheckModule;
7326
7419
  }
7327
- function resolveStack(config2, path, key, dir) {
7328
- const [project, stack, extra] = key.split("/");
7329
- if (!project || !stack || extra !== void 0) return { error: `a stack is project/stack, as plywood.yml lists it, not '${key}'` };
7330
- const settings = toChantSettings(config2);
7331
- const listed = stacksOf(config2.doc);
7332
- const root = dirname14(path);
7333
- const member = settings.members.find((m) => m.name === key) ?? settings.memberDefaults;
7334
- if (dir === void 0 && Object.keys(listed).length > 0 && !(key in listed)) {
7335
- 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 [];
7336
7428
  }
7337
- const at = dir ?? listed[key]?.dir ?? ".";
7338
- return {
7339
- key,
7340
- project,
7341
- stack,
7342
- dir: isAbsolute2(at) ? at : resolve3(root, at),
7343
- ...member.engine ? { engine: member.engine } : {},
7344
- ...member.backend ? { backend: member.backend } : {}
7345
- };
7346
7429
  }
7347
- function requestRules(config2) {
7348
- const p = toChantSettings(config2).pipeline;
7349
- return { mode: p.approval ?? "ledger", ...p.quorum?.length ? { quorum: p.quorum } : {} };
7350
- }
7351
- function requesterOf(by, env) {
7352
- return by ?? env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
7353
- }
7354
- function destStackName(source, dest, config2) {
7355
- if (dest.project === source.project) return dest.stack;
7356
- const cloud = !dest.backend || /^https?:\/\//.test(dest.backend);
7357
- const org = cloud ? pulumiCloudOf(config2.doc).org : void 0;
7358
- return `${org ?? "organization"}/${dest.project}/${dest.stack}`;
7359
- }
7360
- function approveRequestLine(gate, digest, mode) {
7361
- return `${PRODUCT} approve ${gate} --op ${STACK_OPS_OP} --plan ${digest}${mode === "sealed" ? " --sign" : ""}`;
7362
- }
7363
- function useBackend(stacks2, env) {
7364
- const named = [...new Set(stacks2.map((s) => s.backend).filter((b) => b !== void 0))];
7365
- if (named.length > 1) return `the stacks are on different backends (${named.join(", ")}); a move needs both on one backend`;
7366
- if (named[0] && !env.PULUMI_BACKEND_URL) env.PULUMI_BACKEND_URL = named[0];
7367
- return void 0;
7368
- }
7369
- function reportOutcome(verb, outcome, gate, rules, done, json, table, io) {
7370
- const approveLine = outcome.status === "waiting" || outcome.status === "refused" ? approveRequestLine(gate, outcome.digest, rules.mode) : void 0;
7371
- let code;
7372
- let text;
7373
- switch (outcome.status) {
7374
- case "waiting":
7375
- code = table.waiting;
7376
- text = STACK_OP_WORDS.waiting(verb, approveLine);
7377
- break;
7378
- case "refused":
7379
- code = table.waiting;
7380
- text = `${outcome.reason}. ${STACK_OP_WORDS.waiting(verb, approveLine)}`;
7381
- break;
7382
- case "spent":
7383
- code = table.failed;
7384
- text = STACK_OP_WORDS.spent(verb);
7385
- break;
7386
- case "done":
7387
- code = table.done;
7388
- text = done(outcome.result);
7389
- break;
7390
- case "failed":
7391
- code = table.failed;
7392
- text = `${PRODUCT} ${verb}: ${outcome.error}`;
7393
- break;
7394
- }
7395
- if (json) {
7396
- io.out(JSON.stringify({
7397
- command: verb,
7398
- ok: code === table.done,
7399
- exitCode: code,
7400
- status: outcome.status,
7401
- op: STACK_OPS_OP,
7402
- gate,
7403
- digest: outcome.digest,
7404
- ...approveLine ? { approve: approveLine } : {},
7405
- ...outcome.status === "done" ? { result: outcome.result } : {},
7406
- ...outcome.status === "failed" ? { error: outcome.error } : {},
7407
- ...outcome.status === "refused" ? { refusedBy: outcome.by, reason: outcome.reason } : {}
7408
- }));
7409
- } else (code === table.done ? io.out : io.err)(text);
7410
- return code;
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;
7411
7439
  }
7412
- async function runRequest(verb, json, table, io, call) {
7413
- const ops = await chantStackOps();
7414
- if (!ops) {
7415
- io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} ${verb}`, "the gated stack operations (chant#4578)")}`);
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
+ });
7449
+ }
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}`);
7416
7456
  return table.failed;
7417
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;
7418
7473
  try {
7419
- return await call(ops);
7474
+ config2 = loadConfig(path);
7420
7475
  } catch (e) {
7421
- const message = e.message;
7422
- if (json) io.out(JSON.stringify({ command: verb, ok: false, exitCode: table.failed, error: message }));
7423
- 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 }));
7424
7482
  return table.failed;
7425
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);
7426
7521
  }
7427
- function commonFlags(args) {
7428
- const by = takeFlag(args, "--by");
7429
- const dir = takeFlag(by.rest, "--dir");
7430
- if (by.missing) return { rest: args, missing: "--by" };
7431
- if (dir.missing) return { rest: args, missing: "--dir" };
7432
- 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;
7433
7545
  }
7434
- async function exportStack(args, json, table, io) {
7435
- const common = commonFlags(args);
7436
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7437
- const out = takeFlag(common.rest, "--out");
7438
- if (out.missing) return usage("--out takes a file path outside the repository", table, io);
7439
- const extra = out.rest.filter((a) => a.startsWith("-"));
7440
- if (extra.length) return usage(`export does not take ${extra.join(" ")}`, table, io);
7441
- const named = positionals(out.rest);
7442
- if (named.length !== 1) return usage(`export takes one stack: ${STACK_OP_WORDS.usage.export}`, table, io);
7443
- 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);
7444
- const read = readConfig(io.cwd);
7445
- if ("error" in read) return usage(read.error, table, io);
7446
- const stack = resolveStack(read.config, read.path, named[0], common.dir);
7447
- if ("error" in stack) return usage(stack.error, table, io);
7448
- useBackend([stack], io.env);
7449
- const rules = requestRules(read.config);
7450
- const outFile = resolve3(io.cwd, out.value);
7451
- return runRequest("export", json, table, io, async (ops) => {
7452
- const outcome = await ops.gatedStackExport({
7453
- by: requesterOf(common.by, io.env),
7454
- rules,
7455
- op: STACK_OPS_OP,
7456
- ledgerCwd: io.cwd,
7457
- env: io.env,
7458
- dir: stack.dir,
7459
- stack: stack.stack,
7460
- out: outFile,
7461
- ...stack.engine ? { engine: stack.engine } : {}
7462
- });
7463
- return reportOutcome("export", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.exported(stack.key, r), json, table, io);
7464
- });
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;
7465
7558
  }
7466
- async function cancelStack(args, json, table, io) {
7467
- const common = commonFlags(args);
7468
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7469
- const lock = takeFlag(common.rest, "--lock");
7470
- if (lock.missing) return usage("--lock takes the lock id", table, io);
7471
- const extra = lock.rest.filter((a) => a.startsWith("-"));
7472
- if (extra.length) return usage(`cancel does not take ${extra.join(" ")}`, table, io);
7473
- const named = positionals(lock.rest);
7474
- if (named.length !== 1) return usage(`cancel takes one stack: ${STACK_OP_WORDS.usage.cancel}`, table, io);
7475
- const read = readConfig(io.cwd);
7476
- if ("error" in read) return usage(read.error, table, io);
7477
- const stack = resolveStack(read.config, read.path, named[0], common.dir);
7478
- if ("error" in stack) return usage(stack.error, table, io);
7479
- useBackend([stack], io.env);
7480
- const lockId = await lockToCancel(lock.value, stack, io.env);
7481
- if ("error" in lockId) return usage(lockId.error, table, io);
7482
- if (lockId.read && !json) io.err(`${PRODUCT} cancel: ${stack.key} holds lock ${lockId.lockId}`);
7483
- const rules = requestRules(read.config);
7484
- return runRequest("cancel", json, table, io, async (ops) => {
7485
- const outcome = await ops.gatedStackCancel({
7486
- by: requesterOf(common.by, io.env),
7487
- rules,
7488
- op: STACK_OPS_OP,
7489
- ledgerCwd: io.cwd,
7490
- env: io.env,
7491
- dir: stack.dir,
7492
- stack: stack.stack,
7493
- lockId: lockId.lockId,
7494
- project: stack.project,
7495
- ...stack.engine ? { engine: stack.engine } : {}
7496
- });
7497
- return reportOutcome("cancel", outcome, stack.stack, rules, (r) => STACK_OP_WORDS.cancelled(stack.key, lockId.lockId, r.lockChecked), json, table, io);
7498
- });
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
+ };
7601
+ }
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;
7618
+ }
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
+ }
7644
+ }
7645
+ return { text: moves.length ? jsonText2(pkg, text) : text, moves, left };
7499
7646
  }
7500
- async function moveStack(args, json, table, io) {
7501
- const onto = takeFlag(args, "--onto");
7502
- if (onto.missing) return usage("--onto takes plunge", table, io);
7503
- if (onto.value !== void 0) return moveOntoPlunge(onto.value, onto.rest, json, table, io);
7504
- const common = commonFlags(onto.rest);
7505
- if (common.missing) return usage(`${common.missing} takes a value`, table, io);
7506
- const from = takeFlag(common.rest, "--from");
7507
- const to = takeFlag(from.rest, "--to");
7508
- if (from.missing || to.missing) return usage(`${from.missing ? "--from" : "--to"} takes a stack, as project/stack`, table, io);
7509
- const extra = to.rest.filter((a) => a.startsWith("-"));
7510
- if (extra.length) return usage(`move does not take ${extra.join(" ")}`, table, io);
7511
- 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);
7512
- const urns = positionals(to.rest);
7513
- if (!from.value || !to.value) return usage(`move needs --from and --to: ${STACK_OP_WORDS.usage.move}`, table, io);
7514
- if (urns.length === 0) return usage(`move names each resource it moves by URN: ${STACK_OP_WORDS.usage.move}`, table, io);
7515
- const bad = urns.filter((u) => !u.startsWith("urn:pulumi:"));
7516
- if (bad.length) return usage(`move takes URNs (urn:pulumi:...), not ${bad.join(", ")}; 'pulumi stack --show-urns' lists them`, table, io);
7517
- if (from.value === to.value) return usage(`--from and --to are both ${from.value}; a move is between two stacks`, table, io);
7518
- const read = readConfig(io.cwd);
7519
- if ("error" in read) return usage(read.error, table, io);
7520
- const source = resolveStack(read.config, read.path, from.value, void 0);
7521
- if ("error" in source) return usage(source.error, table, io);
7522
- const dest = resolveStack(read.config, read.path, to.value, void 0);
7523
- if ("error" in dest) return usage(dest.error, table, io);
7524
- const backend = useBackend([source, dest], io.env);
7525
- if (backend) return usage(backend, table, io);
7526
- const rules = requestRules(read.config);
7527
- const destName = destStackName(source, dest, read.config);
7528
- const engine2 = source.engine ?? dest.engine;
7529
- return runRequest("move", json, table, io, async (ops) => {
7530
- const outcome = await ops.gatedStackMove({
7531
- by: requesterOf(common.by, io.env),
7532
- rules,
7533
- op: STACK_OPS_OP,
7534
- ledgerCwd: io.cwd,
7535
- env: io.env,
7536
- source: { member: source.key, dir: source.dir, stack: source.stack },
7537
- dest: { member: dest.key, dir: dest.dir, stack: destName },
7538
- urns,
7539
- ...engine2 ? { engine: engine2 } : {}
7540
- });
7541
- return reportOutcome("move", outcome, `move:${source.stack}:${destName}`, rules, (r) => STACK_OP_WORDS.moved(source.key, dest.key, r), json, table, io);
7542
- });
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;
7656
+ }
7657
+ return { text: moves.length ? jsonText2(doc, text) : text, moves };
7543
7658
  }
7544
- async function moveOntoPlunge(engine2, args, json, table, io) {
7545
- 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);
7546
- const extra = args.filter((a) => a.startsWith("-"));
7547
- if (extra.length) return usage(`move --onto does not take ${extra.join(" ")}`, table, io);
7548
- const named = positionals(args);
7549
- if (named.length > 1) return usage(`move --onto plunge takes at most one stack`, table, io);
7550
- const read = readConfig(io.cwd);
7551
- if ("error" in read) return usage(read.error, table, io);
7552
- const opFile = join19(dirname14(read.path), ADOPT_OP_FILE);
7553
- if (!existsSync16(opFile)) return usage(STACK_OP_WORDS.noAdoptOp(opFile), table, io);
7554
- if (named[0]) {
7555
- const stack = resolveStack(read.config, read.path, named[0], void 0);
7556
- if ("error" in stack) return usage(stack.error, table, io);
7557
- if (!json) io.err(STACK_OP_WORDS.adoptAll(stack.key));
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] ?? ""}` };
7558
7674
  }
7559
- const argv = ["run", ADOPT_OP, ...json ? ["--json"] : []];
7560
- return withChant(`${PRODUCT} move --onto plunge`, table, io, async (chant) => {
7561
- const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
7562
- if (code === table.waiting && !json) io.err(STACK_OP_WORDS.adoptWaiting);
7563
- return code === table.waiting ? code : explainRefusal(code, table, io.err);
7564
- });
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 };
7565
7687
  }
7566
- async function rollbackStack(args, json, table, io) {
7567
- const release2 = takeFlag(args, "--release");
7568
- const env = takeFlag(release2.rest, "--env");
7569
- const digest = takeFlag(env.rest, "--digest");
7570
- const actor = takeFlag(digest.rest, "--actor");
7571
- for (const [flag, f] of [["--release", release2], ["--env", env], ["--digest", digest], ["--actor", actor]]) {
7572
- if (f.missing) return usage(`${flag} takes a value`, table, io);
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}`);
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
+ }
7573
7717
  }
7574
- const switches = actor.rest.filter((a) => a === "--dry-run" || a === "--events-json");
7575
- const rest = actor.rest.filter((a) => !switches.includes(a));
7576
- const extra = rest.filter((a) => a.startsWith("-"));
7577
- if (extra.length) return usage(`rollback does not take ${extra.join(" ")}`, table, io);
7578
- const named = positionals(rest);
7579
- if (named.length > 1) return usage(`rollback takes one stack: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7580
- let argv;
7581
- if (release2.value !== void 0) {
7582
- if (named.length) return usage(STACK_OP_WORDS.releaseIsEnvWide(named[0], environmentOf(named[0])), table, io);
7583
- if (digest.value !== void 0) return usage("--release takes no --digest: the release names what each stack goes back to", table, io);
7584
- if (!env.value) return usage(`rollback --release needs --env <env>, the environment whose stacks go back: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7585
- argv = ["components", "rollback", env.value, "--release", release2.value];
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
+ }
7586
7726
  } else {
7587
- if (named.length !== 1) return usage(`rollback takes a stack, or --release <name> --env <env>: ${STACK_OP_WORDS.usage.rollback}`, table, io);
7588
- const read = readConfig(io.cwd);
7589
- if ("error" in read) return usage(read.error, table, io);
7590
- const stack = resolveStack(read.config, read.path, named[0], void 0);
7591
- if ("error" in stack) return usage(stack.error, table, io);
7592
- argv = ["components", "rollback", env.value ?? environmentOf(stack.stack), "--component", slug(stack.key), ...digest.value ? ["--digest", digest.value] : []];
7727
+ notes.push(`no plywood.yml, so there is no workflow to write again; run '${PRODUCT} init' after the install`);
7593
7728
  }
7594
- argv.push(...actor.value ? ["--actor", actor.value] : [], ...switches, ...json ? ["--json"] : []);
7595
- if (!await chantStackRollback()) {
7596
- io.err(`${PRODUCT}: ${needsStackOpsMessage(`${PRODUCT} rollback`, "rollback by release for stacks (chant#4542)")}`);
7597
- return table.failed;
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;
7598
7747
  }
7599
- return withChant(`${PRODUCT} rollback`, table, io, async (chant) => {
7600
- const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
7601
- if (code === table.waiting && !json) io.err(STACK_OP_WORDS.rollbackWaiting);
7602
- return code === table.waiting ? code : explainRefusal(code, table, io.err);
7603
- });
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);
7604
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
+ });
7605
7804
 
7606
7805
  // src/cli.ts
7607
7806
  init_words();
7608
- var VERSION3 = true ? "0.1.0" : "0.0.0-dev";
7807
+ var VERSION3 = true ? "0.2.0" : "0.0.0-dev";
7609
7808
  var stdio = {
7610
7809
  out: (s) => process.stdout.write(s + "\n"),
7611
7810
  err: (s) => process.stderr.write(s + "\n")
@@ -7656,6 +7855,7 @@ function helpText() {
7656
7855
  " move <stack> --onto plunge adopt the stacks onto plunge (plywood.adopt.op.ts)",
7657
7856
  " rollback <stack> [--digest <d>] | --release <name> --env <env>",
7658
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",
7659
7859
  "",
7660
7860
  "Ledger:",
7661
7861
  " audit [approvals|updates|runs|review-stacks] [--stack <s>] [--since <t>] [--where <f>=<v>]...",
@@ -7676,7 +7876,8 @@ function helpText() {
7676
7876
  " control dispatch [--sha <sha>] tell plywood.yml's control repo this commit applied",
7677
7877
  "",
7678
7878
  "Agents:",
7679
- " 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",
7680
7881
  " guard check <command line> whether the agent guard refuses a command",
7681
7882
  " guard install [--dir <d>] the Claude Code hook and the pulumi and plunge wrappers",
7682
7883
  "",
@@ -7737,6 +7938,7 @@ async function main(argv, io = stdio) {
7737
7938
  if (verb === "cancel") return cancelStack(args.slice(1), json, table, stageIo);
7738
7939
  if (verb === "move") return moveStack(args.slice(1), json, table, stageIo);
7739
7940
  if (verb === "rollback") return rollbackStack(args.slice(1), json, table, stageIo);
7941
+ if (verb === "action") return action(args.slice(1), json, table, stageIo);
7740
7942
  if (verb === "drift") return drift2(args.slice(1), json, table, stageIo);
7741
7943
  if (verb === "mcp") return mcp(args.slice(1), json, table, stageIo);
7742
7944
  if (verb === "guard") return guard(args.slice(1), json, table, stageIo);