@intentius/plywood 0.2.0 → 0.3.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
@@ -422,7 +422,7 @@ var init_words = __esm({
422
422
  "src/words.ts"() {
423
423
  "use strict";
424
424
  PRODUCT = "plywood";
425
- TAGLINE = "CI Automation for Pulumi";
425
+ TAGLINE = "Sophisticated automation for Pulumi.";
426
426
  OP_NAME = "pulumi-up";
427
427
  PR_GATE = "up";
428
428
  VERBS = [
@@ -587,13 +587,13 @@ var init_control = __esm({
587
587
  var version, dependencies;
588
588
  var init_package = __esm({
589
589
  "package.json"() {
590
- version = "0.2.0";
590
+ version = "0.3.0";
591
591
  dependencies = {
592
- "@intentius/chant": "0.124.58",
593
- "@intentius/chant-lexicon-forgejo": "0.124.58",
594
- "@intentius/chant-lexicon-github": "0.124.58",
595
- "@intentius/chant-lexicon-gitlab": "0.124.58",
596
- "@intentius/chant-lexicon-pulumi": "0.124.58"
592
+ "@intentius/chant": "0.124.59",
593
+ "@intentius/chant-lexicon-forgejo": "0.124.59",
594
+ "@intentius/chant-lexicon-github": "0.124.59",
595
+ "@intentius/chant-lexicon-gitlab": "0.124.59",
596
+ "@intentius/chant-lexicon-pulumi": "0.124.59"
597
597
  };
598
598
  }
599
599
  });
@@ -603,8 +603,8 @@ var image_digests_default;
603
603
  var init_image_digests = __esm({
604
604
  "src/image-digests.json"() {
605
605
  image_digests_default = {
606
- "ghcr.io/intentius/plywood-pulumi:0.2.0-pulumi3.272.0": "sha256:ea268f1dd2fa15f80ddcedd5c3498d24b79cc2b8a590177c5499e2db3f686f18",
607
- "ghcr.io/intentius/plywood-plunge:0.2.0-plunge0.1.0": "sha256:6a862ad23e39081a32fe8b2331b095ed2de44e527f2719416201b0affa46a1eb"
606
+ "ghcr.io/intentius/plywood-pulumi:0.3.0-pulumi3.272.0": "sha256:39fc85f67be6a281aa5f01b02239aa1f02c492f40f1fb0d145c848cc608aaecd",
607
+ "ghcr.io/intentius/plywood-plunge:0.3.0-plunge0.2.0": "sha256:6647f67defb1719395b29d1a6c9d977b6900e228547c4569563962e4234847e3"
608
608
  };
609
609
  }
610
610
  });
@@ -741,14 +741,14 @@ var init_images = __esm({
741
741
  TOOL_VERSIONS = {
742
742
  pulumi: "3.272.0",
743
743
  /** A plunge release (INTENTIUS/plunge, tag plunge-v<ver>), not the Pulumi version it is built from. */
744
- plunge: "0.1.0"
744
+ plunge: "0.2.0"
745
745
  };
746
746
  ARCHES = ["x64", "arm64"];
747
747
  PINNED_SHA256 = {
748
748
  "pulumi-v3.272.0-linux-x64.tar.gz": "58d545f2b98761051922008ebd381ecef95484d7f7a5ff7faa15d7a780ea84f6",
749
749
  "pulumi-v3.272.0-linux-arm64.tar.gz": "4f0741901b8982c4326b702dabf483c63d2cf77c88ac42dd752b72541284a534",
750
- "plunge-v0.1.0-linux-x64.tar.gz": "22b89da39ab7dc11268c7b625fec3d99c0793c9c47bf8d031fa94e6967ee8187",
751
- "plunge-v0.1.0-linux-arm64.tar.gz": "6855611b8cf8381d73f5ddf0b5c0a0d83bdc2a5bd8c50d415e52bb05a4574f83"
750
+ "plunge-v0.2.0-linux-x64.tar.gz": "c9c73d05cca9ad214b7f44230a6162cdcb6ad64bbfc725bc94688b997b191495",
751
+ "plunge-v0.2.0-linux-arm64.tar.gz": "c176a82f038164085aa19c8ad6530d6022e560e124050e535dbe364ae0473357"
752
752
  };
753
753
  ENGINE_ROOT = "/opt/plywood";
754
754
  JOB_ARCH = `"$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')"`;
@@ -2458,7 +2458,7 @@ var init_chant_product = __esm({
2458
2458
  "src/chant-product.ts"() {
2459
2459
  "use strict";
2460
2460
  REQUIRED2 = ["assertProductWords", "productPipelineJobs", "approveLine", "runChantInProcess"];
2461
- BUILT_CHANT = true ? "0.124.58" : "unknown";
2461
+ BUILT_CHANT = true ? "0.124.59" : "unknown";
2462
2462
  }
2463
2463
  });
2464
2464
 
@@ -2475,14 +2475,37 @@ function hasChantApprovalStatus(chant) {
2475
2475
  function hasChantPullRequestApprovalStatus(chant) {
2476
2476
  return typeof chant?.APPROVAL_STATUS_PULL_REQUEST_FLOOR === "string";
2477
2477
  }
2478
+ function hasChantWavePreview() {
2479
+ wavePreview ??= (async () => {
2480
+ try {
2481
+ const m = await import("@intentius/chant/op");
2482
+ return typeof m.OP_WAVES_PREVIEW_FLOOR === "string";
2483
+ } catch {
2484
+ return false;
2485
+ }
2486
+ })();
2487
+ return wavePreview;
2488
+ }
2489
+ function withWavePreview(spec, forge) {
2490
+ const approvals = spec.approvals && typeof spec.approvals === "object" && !Array.isArray(spec.approvals) ? spec.approvals : {};
2491
+ const next = approvals.preview !== void 0 ? spec : { ...spec, approvals: { ...approvals, preview: true } };
2492
+ if (forge === "gitlab" || next.approvals.preview !== true || !Array.isArray(next.waves)) return next;
2493
+ const waves = next.waves.map((w) => ({
2494
+ ...w,
2495
+ variables: { [WAVE_TOKEN_VARIABLE]: "${{ github.token }}", ...w.variables ?? {} }
2496
+ }));
2497
+ return { ...next, waves };
2498
+ }
2478
2499
  function chantAudit() {
2479
2500
  audit ??= import("@intentius/chant/ci-audit").then((m) => m);
2480
2501
  return audit;
2481
2502
  }
2482
- var audit;
2503
+ var wavePreview, WAVE_TOKEN_VARIABLE, GITLAB_READ_TOKEN, audit;
2483
2504
  var init_chant_next = __esm({
2484
2505
  "src/chant-next.ts"() {
2485
2506
  "use strict";
2507
+ WAVE_TOKEN_VARIABLE = "GITHUB_TOKEN";
2508
+ GITLAB_READ_TOKEN = "CHANT_FORGE_TOKEN";
2486
2509
  }
2487
2510
  });
2488
2511
 
@@ -2631,6 +2654,38 @@ var init_note_control = __esm({
2631
2654
  }
2632
2655
  });
2633
2656
 
2657
+ // src/init/run-group.ts
2658
+ function withRunGroup(text, forge, key) {
2659
+ if (forge !== "forgejo" || TOP_CONCURRENCY.test(text)) return text;
2660
+ const jobs2 = TOP_JOBS.exec(text);
2661
+ if (!jobs2) return text;
2662
+ const name = (TOP_NAME.exec(text)?.[1] ?? "plywood").replace(/^["']|["']$/g, "");
2663
+ const group = key === "commit" ? `${name}-\${{ github.event_name }}-\${{ github.sha }}` : `${name}-\${{ github.event_name == 'pull_request' && github.ref || github.run_id }}`;
2664
+ const cancel = key === "commit" ? "false" : "${{ github.event_name == 'pull_request' }}";
2665
+ const block = [
2666
+ "# Without a group Forgejo cancels the run in progress when a push starts another run of this workflow.",
2667
+ key === "commit" ? "# One group for each commit keeps a merge's wave jobs running to completion when another merge lands (plywood#51)." : "# A push to main and a comment get a group of their own, so the record job runs to completion (plywood#51).",
2668
+ "concurrency:",
2669
+ ` group: ${yamlScalar(group)}`,
2670
+ ` cancel-in-progress: ${yamlScalar(cancel)}`,
2671
+ "",
2672
+ ""
2673
+ ].join("\n");
2674
+ return text.slice(0, jobs2.index) + block + text.slice(jobs2.index);
2675
+ }
2676
+ function yamlScalar(value) {
2677
+ return value === "false" ? value : JSON.stringify(value);
2678
+ }
2679
+ var TOP_CONCURRENCY, TOP_JOBS, TOP_NAME;
2680
+ var init_run_group = __esm({
2681
+ "src/init/run-group.ts"() {
2682
+ "use strict";
2683
+ TOP_CONCURRENCY = /^concurrency:/m;
2684
+ TOP_JOBS = /^jobs:/m;
2685
+ TOP_NAME = /^name:\s*(.+?)\s*$/m;
2686
+ }
2687
+ });
2688
+
2634
2689
  // src/init/workflow.ts
2635
2690
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
2636
2691
  import { dirname as dirname2, isAbsolute, join as join2 } from "node:path";
@@ -2643,7 +2698,7 @@ async function writeWorkflow(chant, input) {
2643
2698
  if (previous !== void 0) writeFileSync(file, previous);
2644
2699
  return { code: code !== 0 ? code : 1, file, status: "failed", kept: [] };
2645
2700
  }
2646
- const generated = withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl);
2701
+ const generated = withRunGroup(withNoteControl(readFileSync2(file, "utf8"), input.forge, input.noteControl), input.forge, "pull-request");
2647
2702
  const { text, kept } = keepOwnJobs(previous, generated, input.forge);
2648
2703
  writeFileSync(file, text);
2649
2704
  return { code, file, status: text === previous ? "unchanged" : "written", kept };
@@ -2653,6 +2708,7 @@ var init_workflow = __esm({
2653
2708
  "use strict";
2654
2709
  init_own_jobs();
2655
2710
  init_note_control();
2711
+ init_run_group();
2656
2712
  }
2657
2713
  });
2658
2714
 
@@ -2794,11 +2850,9 @@ async function writeWaveJobs(chant, forge, waves, words, table, io, engine2 = {}
2794
2850
  }
2795
2851
  const lines2 = hasChant4607(chant) ? void 0 : binLines(waves, words.bin);
2796
2852
  for (const file of files) {
2797
- if (lines2) {
2798
- const text = readFileSync3(file, "utf8");
2799
- const next = withBinLines(text, lines2);
2800
- if (next !== text) writeFileSync2(file, next);
2801
- }
2853
+ const text = readFileSync3(file, "utf8");
2854
+ const next = withRunGroup(lines2 ? withBinLines(text, lines2) : text, forge, "commit");
2855
+ if (next !== text) writeFileSync2(file, next);
2802
2856
  io.err(`${PRODUCT} ci: waves workflow at ${file.slice(io.cwd.length + 1)}`);
2803
2857
  }
2804
2858
  if (forge === "gitlab") {
@@ -2812,6 +2866,7 @@ var init_waves_workflow = __esm({
2812
2866
  "use strict";
2813
2867
  init_chant_product();
2814
2868
  init_images();
2869
+ init_run_group();
2815
2870
  init_words();
2816
2871
  WAVES_OUTPUT_DIR = {
2817
2872
  github: ".github/workflows",
@@ -3111,13 +3166,383 @@ async function prJobsFor(pipeline) {
3111
3166
  const { flags, notes } = prLoopJobFlags(pipeline.jobs, pipeline.runner);
3112
3167
  return { options: prLoopJobOptions(pipeline.jobs, pipeline.runner), flags, notes };
3113
3168
  }
3114
- var detected, JOBS_NEED_CHANT, GROUP_NAME;
3115
- var init_jobs2 = __esm({
3116
- "src/jobs.ts"() {
3169
+ var detected, JOBS_NEED_CHANT, GROUP_NAME;
3170
+ var init_jobs2 = __esm({
3171
+ "src/jobs.ts"() {
3172
+ "use strict";
3173
+ init_images2();
3174
+ JOBS_NEED_CHANT = "needs chant's runners, variables, passed secrets and parallelism on the pull request jobs (chant#4625), which this chant lacks";
3175
+ GROUP_NAME = /[^a-z0-9_-]+/g;
3176
+ }
3177
+ });
3178
+
3179
+ // src/init/detect.ts
3180
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync } from "node:fs";
3181
+ import { basename, dirname as dirname5, join as join5, relative, sep } from "node:path";
3182
+ import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
3183
+ function repoRoot(dir) {
3184
+ let cur = dir;
3185
+ for (let i = 0; i < 64; i++) {
3186
+ if (existsSync5(join5(cur, ".git"))) return cur;
3187
+ const parent = dirname5(cur);
3188
+ if (parent === cur) return dir;
3189
+ cur = parent;
3190
+ }
3191
+ return dir;
3192
+ }
3193
+ function posixRel(root, path) {
3194
+ const rel = relative(root, path);
3195
+ return rel === "" ? "." : rel.split(sep).join("/");
3196
+ }
3197
+ function readYamlFile(file) {
3198
+ try {
3199
+ const doc = parseYAML2(readFileSync5(file, "utf8"));
3200
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
3201
+ } catch {
3202
+ return void 0;
3203
+ }
3204
+ }
3205
+ function findPulumiProjects(root) {
3206
+ const out = [];
3207
+ const walk = (dir, depth) => {
3208
+ let entries;
3209
+ try {
3210
+ entries = readdirSync3(dir).sort();
3211
+ } catch {
3212
+ return;
3213
+ }
3214
+ const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
3215
+ if (file) {
3216
+ const project = readProject(root, dir, join5(dir, file));
3217
+ if (project) out.push(project);
3218
+ }
3219
+ if (depth >= MAX_DEPTH) return;
3220
+ for (const name of entries) {
3221
+ if (SKIP.has(name) || name.startsWith(".")) continue;
3222
+ const path = join5(dir, name);
3223
+ try {
3224
+ if (statSync(path).isDirectory()) walk(path, depth + 1);
3225
+ } catch {
3226
+ }
3227
+ }
3228
+ };
3229
+ walk(root, 0);
3230
+ return out;
3231
+ }
3232
+ function readProject(root, dir, file) {
3233
+ const doc = readYamlFile(file);
3234
+ if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
3235
+ const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
3236
+ const backend = doc.backend;
3237
+ const configDir = typeof doc.stackConfigDir === "string" ? join5(dir, doc.stackConfigDir) : dir;
3238
+ return {
3239
+ name: doc.name,
3240
+ dir: posixRel(root, dir),
3241
+ ...typeof runtime === "string" ? { runtime } : {},
3242
+ ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
3243
+ stacks: stackNames(configDir)
3244
+ };
3245
+ }
3246
+ function stackNames(configDir) {
3247
+ let entries;
3248
+ try {
3249
+ entries = readdirSync3(configDir);
3250
+ } catch {
3251
+ return [];
3252
+ }
3253
+ const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
3254
+ return [...new Set(names)].sort(byEnvironment);
3255
+ }
3256
+ function environmentOf(stack) {
3257
+ const last = stack.split("/").pop().toLowerCase();
3258
+ const head = last.split(/[-_.]/)[0];
3259
+ return ENV_ORDER.includes(head) ? head : last;
3260
+ }
3261
+ function rank(stack) {
3262
+ const env = environmentOf(stack);
3263
+ const at = ENV_ORDER.indexOf(env);
3264
+ return at >= 0 ? at : PROD_RANK - 0.5;
3265
+ }
3266
+ function byEnvironment(a, b) {
3267
+ return rank(a) - rank(b) || a.localeCompare(b);
3268
+ }
3269
+ function stacksOf2(projects) {
3270
+ const names = /* @__PURE__ */ new Map();
3271
+ for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
3272
+ const out = [];
3273
+ for (const p of projects) {
3274
+ const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
3275
+ for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
3276
+ }
3277
+ return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
3278
+ }
3279
+ function slug(s) {
3280
+ const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3281
+ return out === "" ? "root" : out;
3282
+ }
3283
+ function originUrl(root) {
3284
+ const config2 = gitFile(root, "config");
3285
+ if (!config2) return void 0;
3286
+ let inOrigin = false;
3287
+ for (const raw of config2.split(/\r?\n/)) {
3288
+ const line = raw.trim();
3289
+ if (line.startsWith("[")) {
3290
+ inOrigin = /^\[remote\s+"origin"\]$/.test(line);
3291
+ continue;
3292
+ }
3293
+ const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
3294
+ if (m) return m[1].trim();
3295
+ }
3296
+ return void 0;
3297
+ }
3298
+ function gitFile(root, name) {
3299
+ const dotGit = join5(root, ".git");
3300
+ let gitDir = dotGit;
3301
+ try {
3302
+ if (statSync(dotGit).isFile()) {
3303
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync5(dotGit, "utf8"));
3304
+ if (!m) return void 0;
3305
+ gitDir = join5(root, m[1].trim());
3306
+ const common = join5(gitDir, "commondir");
3307
+ if (existsSync5(common) && !existsSync5(join5(gitDir, name))) gitDir = join5(gitDir, readFileSync5(common, "utf8").trim());
3308
+ }
3309
+ const path = join5(gitDir, name);
3310
+ return existsSync5(path) ? readFileSync5(path, "utf8") : void 0;
3311
+ } catch {
3312
+ return void 0;
3313
+ }
3314
+ }
3315
+ function remoteHost(url) {
3316
+ const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
3317
+ if (scp) return scp[1].toLowerCase();
3318
+ try {
3319
+ return new URL(url).hostname.toLowerCase() || void 0;
3320
+ } catch {
3321
+ return void 0;
3322
+ }
3323
+ }
3324
+ function forgeOfRemote(url) {
3325
+ const host = remoteHost(url);
3326
+ if (!host) return void 0;
3327
+ if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
3328
+ if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
3329
+ if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
3330
+ if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
3331
+ if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
3332
+ return void 0;
3333
+ }
3334
+ function detectForge(root) {
3335
+ const url = originUrl(root);
3336
+ const byRemote = url ? forgeOfRemote(url) : void 0;
3337
+ if (byRemote) return byRemote;
3338
+ if (existsSync5(join5(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
3339
+ if (existsSync5(join5(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
3340
+ if (existsSync5(join5(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
3341
+ return void 0;
3342
+ }
3343
+ function defaultBranch(root) {
3344
+ const head = gitFile(root, "refs/remotes/origin/HEAD");
3345
+ const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
3346
+ if (remote) return remote.trim();
3347
+ const local = gitFile(root, "HEAD");
3348
+ const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
3349
+ return branch ? branch.trim() : "main";
3350
+ }
3351
+ function findPulumiActions(root) {
3352
+ const out = [];
3353
+ for (const d of WORKFLOW_DIRS) {
3354
+ let names;
3355
+ try {
3356
+ names = readdirSync3(join5(root, d)).sort();
3357
+ } catch {
3358
+ continue;
3359
+ }
3360
+ for (const n of names) {
3361
+ if (!/\.ya?ml$/.test(n)) continue;
3362
+ try {
3363
+ if (/uses:\s*["']?pulumi\/actions@/.test(readFileSync5(join5(root, d, n), "utf8"))) out.push(`${d}/${n}`);
3364
+ } catch {
3365
+ }
3366
+ }
3367
+ }
3368
+ return out;
3369
+ }
3370
+ function detect(cwd) {
3371
+ const root = repoRoot(cwd);
3372
+ const projects = findPulumiProjects(root);
3373
+ const forge = detectForge(root);
3374
+ const preCommit = [".pre-commit-config.yaml", ".pre-commit-config.yml"].find((n) => existsSync5(join5(root, n)));
3375
+ return {
3376
+ root,
3377
+ projects,
3378
+ stacks: stacksOf2(projects),
3379
+ ...forge ? { forge } : {},
3380
+ defaultBranch: defaultBranch(root),
3381
+ pulumiActions: findPulumiActions(root),
3382
+ ...preCommit ? { preCommit } : {}
3383
+ };
3384
+ }
3385
+ function repoName(root) {
3386
+ return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
3387
+ }
3388
+ var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
3389
+ var init_detect = __esm({
3390
+ "src/init/detect.ts"() {
3391
+ "use strict";
3392
+ SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor", "venv", ".venv", "__pycache__", "target", "bin", "obj"]);
3393
+ MAX_DEPTH = 8;
3394
+ ENV_ORDER = ["local", "dev", "development", "sandbox", "test", "testing", "qa", "ci", "stage", "staging", "preprod", "uat", "prod", "production"];
3395
+ PROD_RANK = ENV_ORDER.indexOf("prod");
3396
+ WORKFLOW_DIRS = [".github/workflows", ".forgejo/workflows", ".gitea/workflows"];
3397
+ }
3398
+ });
3399
+
3400
+ // src/templates.ts
3401
+ import { readFileSync as readFileSync6 } from "node:fs";
3402
+ function template(name) {
3403
+ return readFileSync6(new URL(`../templates/${name}`, import.meta.url), "utf8");
3404
+ }
3405
+ function fill(text, values) {
3406
+ return text.replace(/\{\{(\w+)\}\}/g, (all, key) => key in values ? values[key] : all);
3407
+ }
3408
+ var init_templates = __esm({
3409
+ "src/templates.ts"() {
3410
+ "use strict";
3411
+ }
3412
+ });
3413
+
3414
+ // src/init/render.ts
3415
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
3416
+ function plywoodYml(keys) {
3417
+ const body = Object.entries(keys).filter(([, v]) => v !== void 0).map(([k, v]) => emitYAMLEntry(k, v)).join("\n");
3418
+ const text = fill(template("plywood.yml"), { keys: body });
3419
+ return text.endsWith("\n") ? text : text + "\n";
3420
+ }
3421
+ function chantConfigTs() {
3422
+ return template("chant.config.ts.tmpl");
3423
+ }
3424
+ function componentName(stack) {
3425
+ return slug(stack.key);
3426
+ }
3427
+ function exportName(stack, taken) {
3428
+ let base = stack.key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3429
+ if (base === "" || /^\d/.test(base)) base = `stack_${base}`;
3430
+ let name = base;
3431
+ for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
3432
+ taken.add(name);
3433
+ return name;
3434
+ }
3435
+ function stepInput(s) {
3436
+ return {
3437
+ dir: s.dir,
3438
+ stack: s.stack,
3439
+ member: s.project,
3440
+ ...s.engine ? { engine: s.engine } : {},
3441
+ ...s.backend ? { backend: s.backend } : {},
3442
+ ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
3443
+ ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
3444
+ ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
3445
+ };
3446
+ }
3447
+ function componentsFile(stacks2, after = {}, opts = {}) {
3448
+ const taken = /* @__PURE__ */ new Set();
3449
+ const lines2 = [
3450
+ "// Written by plywood init from the stacks it found; running it again rewrites",
3451
+ "// this file. Each stack is a chant component whose pulumi-up step previews it",
3452
+ "// on a pull request and updates it in its wave.",
3453
+ ""
3454
+ ];
3455
+ for (const s of stacks2) {
3456
+ const dependsOn = (after[s.key] ?? []).filter((u) => stacks2.some((x) => x.key === u)).map((u) => componentName({ key: u }));
3457
+ const step = { kind: "pulumi-up", ...stepInput(s), ...opts.reviewSuffix ? { reviewSuffix: opts.reviewSuffix } : {} };
3458
+ const component = { name: componentName(s), dependsOn, deploy: [{ phase: "up", steps: [step] }] };
3459
+ lines2.push(`export const ${exportName(s, taken)} = ${JSON.stringify(component)};`);
3460
+ }
3461
+ return lines2.join("\n") + "\n";
3462
+ }
3463
+ function wavesOf(stacks2, configured) {
3464
+ const byKey = new Map(stacks2.map((s) => [s.key, s]));
3465
+ if (configured && configured.length > 0) {
3466
+ return configured.map((keys, i) => ({ name: `wave-${i + 1}`, stacks: keys.map((k) => byKey.get(k)).filter((s) => s !== void 0) }));
3467
+ }
3468
+ const out = [];
3469
+ for (const s of stacks2) {
3470
+ const env = environmentOf(s.stack);
3471
+ let name = slug(env);
3472
+ if (!WAVE_NAME.test(name)) name = `wave-${out.length + 1}`;
3473
+ const wave = out.find((w) => w.name === name);
3474
+ if (wave) wave.stacks.push(s);
3475
+ else out.push({ name, stacks: [s] });
3476
+ }
3477
+ return out;
3478
+ }
3479
+ function waveRunner(runner) {
3480
+ if (runner === void 0) return void 0;
3481
+ if (typeof runner === "string" || Array.isArray(runner) || "group" in runner) return runner;
3482
+ const staged = runner;
3483
+ return staged.up ?? staged.default;
3484
+ }
3485
+ function wavesSpec(input) {
3486
+ const waves = (input.ordered ?? wavesOf(input.stacks, input.waves)).filter((w) => w.stacks.length > 0);
3487
+ return {
3488
+ name: OP_NAME,
3489
+ op: OP_NAME,
3490
+ gate: PR_GATE,
3491
+ branches: [input.branch],
3492
+ ...input.runner !== void 0 ? { runner: input.runner } : {},
3493
+ step: { kind: "pulumi-up" },
3494
+ ...input.quorum && input.quorum.length > 0 ? { approvals: { quorum: input.quorum } } : {},
3495
+ ...input.notify ? { notify: input.notify } : {},
3496
+ waves: waves.map((w) => {
3497
+ const gate = w.stacks.map((s) => s.gate ?? input.gate).reduce((a, b) => STRICTNESS[b] > STRICTNESS[a] ? b : a);
3498
+ return { name: w.name, gate, runs: w.stacks.map((s) => ({ target: s.key, input: stepInput(s) })) };
3499
+ })
3500
+ };
3501
+ }
3502
+ function jsonText(v) {
3503
+ return JSON.stringify(v, null, 2) + "\n";
3504
+ }
3505
+ function memberName(project, taken) {
3506
+ let base = slug(project.name).slice(0, 36).replace(/-+$/, "");
3507
+ if (!MEMBER_NAME.test(base)) base = "stack";
3508
+ let name = base;
3509
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
3510
+ taken.add(name);
3511
+ return name;
3512
+ }
3513
+ function workspaceDeclaration(existing, input) {
3514
+ const doc = existing ? structuredClone(existing) : { name: input.name, schema: 1 };
3515
+ const members = Array.isArray(doc.members) ? doc.members : [];
3516
+ const taken = new Set(members.map((m) => String(m.name)));
3517
+ for (const p of input.projects) {
3518
+ if (members.some((m) => m.dir === p.dir || p.dir === "." && m.dir === void 0)) continue;
3519
+ members.push({ name: memberName(p, taken), dir: p.dir, kind: "pulumi" });
3520
+ }
3521
+ doc.members = members;
3522
+ const pins = Array.isArray(doc.pins) ? doc.pins : [];
3523
+ if (!pins.some((p) => p.package === PULUMI_LEXICON)) pins.push({ package: PULUMI_LEXICON, version: input.lexiconVersion });
3524
+ doc.pins = pins;
3525
+ const ci2 = doc.ci && typeof doc.ci === "object" && !Array.isArray(doc.ci) ? doc.ci : {};
3526
+ for (const [k, v] of Object.entries(input.ci)) if (v !== void 0) ci2[k] = v;
3527
+ if (Object.keys(ci2).length > 0) doc.ci = ci2;
3528
+ return doc;
3529
+ }
3530
+ function memberOfDir(doc, dir) {
3531
+ const members = doc && Array.isArray(doc.members) ? doc.members : [];
3532
+ const m = members.find((x) => x.dir === dir || dir === "." && x.dir === void 0);
3533
+ return typeof m?.name === "string" ? m.name : void 0;
3534
+ }
3535
+ var WAVE_NAME, STRICTNESS, MEMBER_NAME, PULUMI_LEXICON;
3536
+ var init_render = __esm({
3537
+ "src/init/render.ts"() {
3117
3538
  "use strict";
3118
- init_images2();
3119
- JOBS_NEED_CHANT = "needs chant's runners, variables, passed secrets and parallelism on the pull request jobs (chant#4625), which this chant lacks";
3120
- GROUP_NAME = /[^a-z0-9_-]+/g;
3539
+ init_templates();
3540
+ init_words();
3541
+ init_detect();
3542
+ WAVE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
3543
+ STRICTNESS = { never: 0, "on-destructive": 1, always: 2 };
3544
+ MEMBER_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
3545
+ PULUMI_LEXICON = "@intentius/chant-lexicon-pulumi";
3121
3546
  }
3122
3547
  });
3123
3548
 
@@ -3150,6 +3575,16 @@ function explainRefusal(code, table, err, ctx = {}) {
3150
3575
  if (refusal) err(`${PRODUCT}: ${refusalLine(refusal, ctx)}`);
3151
3576
  return code;
3152
3577
  }
3578
+ function approveLineIn(output) {
3579
+ const m = /Approve these plans to apply them:[ \t]*([^\r\n]+)/.exec(output);
3580
+ const line = m?.[1]?.trim();
3581
+ return line ? line : void 0;
3582
+ }
3583
+ function waveMovedLine(output) {
3584
+ const approve2 = approveLineIn(output);
3585
+ const how = approve2 ? `If a person has read the plans chant listed above and wants them applied, approve exactly them with: ${approve2}, then run the job again.` : `chant named no approval for these plans. Read the plans above, then approve them with '${PRODUCT} approve <wave gate> --plan <digest>' and run the job again.`;
3586
+ return `A merged wave stopped with exit 4 and nothing in it was applied: its plans are not the ones its pull request previewed (or no preview was recorded for it). ${how} To apply only what a pull request previews, open a new pull request, let '${PRODUCT} run wave --record-plans' record its plans, and merge that.`;
3587
+ }
3153
3588
  var REFUSALS;
3154
3589
  var init_refusals = __esm({
3155
3590
  "src/refusals.ts"() {
@@ -3160,8 +3595,8 @@ var init_refusals = __esm({
3160
3595
  });
3161
3596
 
3162
3597
  // src/up.ts
3163
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
3164
- import { join as join5 } from "node:path";
3598
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
3599
+ import { join as join6 } from "node:path";
3165
3600
  function approveTarget(gate, opts) {
3166
3601
  if (opts.op) return { op: opts.op, gate };
3167
3602
  if (PR_OP.test(gate)) return { op: gate, gate: PR_GATE };
@@ -3242,10 +3677,10 @@ async function approve(args, json, table, io) {
3242
3677
  });
3243
3678
  }
3244
3679
  function wavesSpecAt(cwd, file = WAVES_SPEC_FILE) {
3245
- const path = join5(cwd, file);
3246
- if (!existsSync5(path)) return void 0;
3680
+ const path = join6(cwd, file);
3681
+ if (!existsSync6(path)) return void 0;
3247
3682
  try {
3248
- const spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3683
+ const spec = unwrapWavesSpec(JSON.parse(readFileSync7(path, "utf8")));
3249
3684
  return spec && typeof spec === "object" && !Array.isArray(spec) ? spec : void 0;
3250
3685
  } catch {
3251
3686
  return void 0;
@@ -3329,11 +3764,11 @@ async function override(args, json, table, io) {
3329
3764
  return withChant(`${PRODUCT} override`, table, io, async (chant) => explainRefusal(await chant.runChantInProcess(argv, PRODUCT_WORDS), table, io.err));
3330
3765
  }
3331
3766
  function readUpSpec(cwd, file = WAVES_SPEC_FILE) {
3332
- const path = join5(cwd, file);
3333
- if (!existsSync5(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3767
+ const path = join6(cwd, file);
3768
+ if (!existsSync6(path)) return { error: `no ${file}; '${PRODUCT} init' writes it from plywood.yml's waves` };
3334
3769
  let spec;
3335
3770
  try {
3336
- spec = unwrapWavesSpec(JSON.parse(readFileSync5(path, "utf8")));
3771
+ spec = unwrapWavesSpec(JSON.parse(readFileSync7(path, "utf8")));
3337
3772
  } catch (e) {
3338
3773
  return { error: `${file}: ${e.message}` };
3339
3774
  }
@@ -3488,9 +3923,9 @@ var init_up = __esm({
3488
3923
  // src/drift.ts
3489
3924
  import { createHash as createHash2 } from "node:crypto";
3490
3925
  import { resolveTargetCloudIdentity } from "@intentius/chant/ci-identity";
3491
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3926
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readdirSync as readdirSync4, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
3492
3927
  import { tmpdir } from "node:os";
3493
- import { dirname as dirname5, join as join6 } from "node:path";
3928
+ import { dirname as dirname6, join as join7 } from "node:path";
3494
3929
  function driftRows(doc) {
3495
3930
  return (doc.records ?? []).filter((r) => DRIFT_STATUSES.includes(r.status)).map((r) => ({ urn: r.address, status: r.status, attributes: (r.attributes ?? []).map((a) => a.path).sort() })).sort((a, b) => a.urn < b.urn ? -1 : a.urn > b.urn ? 1 : 0);
3496
3931
  }
@@ -3549,11 +3984,12 @@ async function drift2(args, json, table, io) {
3549
3984
  const respond = respondFlag.value ?? driftResponseOf(doc);
3550
3985
  const forge = forgeFlag.value;
3551
3986
  return withChant(`${PRODUCT} drift`, table, io, async (chant) => {
3552
- const dir = mkdtempSync(join6(tmpdir(), "plywood-drift-"));
3987
+ const dir = mkdtempSync(join7(tmpdir(), "plywood-drift-"));
3553
3988
  const results = [];
3554
3989
  try {
3555
3990
  const attribute = await attributionFor(doc, io);
3556
- for (const stack of stacks2) results.push(await readStack(chant, stack, dir, { resource: resource.value, env: envFlag.value, ...attribute ? { attribute } : {} }));
3991
+ const root = dirname6(path);
3992
+ for (const stack of stacks2) results.push(await readStack(chant, stack, driftMember(root, doc, stack), dir, { resource: resource.value, env: envFlag.value, ...attribute ? { attribute } : {} }));
3557
3993
  } finally {
3558
3994
  rmSync(dir, { recursive: true, force: true });
3559
3995
  }
@@ -3628,13 +4064,31 @@ async function driftNotice(chant, doc, results, environment, io) {
3628
4064
  ...notifyFetch ? { fetch: notifyFetch } : {}
3629
4065
  });
3630
4066
  }
3631
- async function readStack(chant, stack, dir, opts) {
3632
- const file = join6(dir, `${driftOpName(stack)}.json`);
3633
- const argv = ["workspace", "drift", "--member", stack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
4067
+ function driftMember(root, doc, stack) {
4068
+ const project = stack.includes("/") ? stack.slice(0, stack.indexOf("/")) : stack;
4069
+ let declaration;
4070
+ try {
4071
+ declaration = JSON.parse(readFileSync8(join7(root, "chant.workspace.json"), "utf8"));
4072
+ } catch {
4073
+ return project;
4074
+ }
4075
+ const dir = stacksOf(doc)[stack]?.dir;
4076
+ const byDir = dir !== void 0 ? memberOfDir(declaration, dir) : void 0;
4077
+ if (byDir) return byDir;
4078
+ const names = (Array.isArray(declaration.members) ? declaration.members : []).flatMap((m) => typeof m.name === "string" ? [m.name] : []);
4079
+ if (names.includes(project)) return project;
4080
+ const slugged = names.find((n) => n === slug(project));
4081
+ if (slugged) return slugged;
4082
+ return names.length === 1 ? names[0] : project;
4083
+ }
4084
+ async function readStack(chant, stack, member, dir, opts) {
4085
+ const file = join7(dir, `${driftOpName(stack)}.json`);
4086
+ const pulumiStack = stack.slice(stack.indexOf("/") + 1);
4087
+ const argv = ["workspace", "drift", "--member", member, "--stack", pulumiStack, ...opts.resource ? ["--resource", opts.resource] : [], ...opts.env ? ["--env", opts.env] : [], "-o", file];
3634
4088
  const code = await chant.runChantInProcess(argv, PRODUCT_WORDS);
3635
4089
  let doc;
3636
4090
  try {
3637
- if (existsSync6(file)) doc = JSON.parse(readFileSync6(file, "utf8"));
4091
+ if (existsSync7(file)) doc = JSON.parse(readFileSync8(file, "utf8"));
3638
4092
  } catch (e) {
3639
4093
  return { stack, ok: false, drifted: 0, rows: [], error: `chant wrote a drift document plywood cannot read: ${e.message}` };
3640
4094
  }
@@ -3667,7 +4121,7 @@ async function attributeStack(a, stack, doc) {
3667
4121
  }
3668
4122
  function specHasUpdate(cwd, op) {
3669
4123
  try {
3670
- const spec = JSON.parse(readFileSync6(join6(cwd, DRIFT_SPEC_FILE), "utf8"));
4124
+ const spec = JSON.parse(readFileSync8(join7(cwd, DRIFT_SPEC_FILE), "utf8"));
3671
4125
  return (spec.ops ?? []).some((o) => o.name === op && o.update !== void 0);
3672
4126
  } catch {
3673
4127
  return false;
@@ -3743,9 +4197,9 @@ function withNotifyVariables(fields, notify2) {
3743
4197
  return { ...fields, variables: { ...notify2, ...fields.variables ?? {} } };
3744
4198
  }
3745
4199
  function writeIfChanged2(cwd, file, text) {
3746
- const path = join6(cwd, file);
3747
- if (existsSync6(path) && readFileSync6(path, "utf8") === text) return false;
3748
- mkdirSync4(dirname5(path), { recursive: true });
4200
+ const path = join7(cwd, file);
4201
+ if (existsSync7(path) && readFileSync8(path, "utf8") === text) return false;
4202
+ mkdirSync4(dirname6(path), { recursive: true });
3749
4203
  writeFileSync4(path, text);
3750
4204
  return true;
3751
4205
  }
@@ -3761,9 +4215,9 @@ async function writeDriftJobs(chant, forge, doc, words, table, io, env = "produc
3761
4215
  const outDir = OP_OUTPUT_DIR2[forge];
3762
4216
  const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", DRIFT_SPEC_FILE, "--output", outDir], words);
3763
4217
  if (code !== table.done) return code;
3764
- const dir = join6(io.cwd, outDir);
3765
- const files = existsSync6(dir) ? readdirSync3(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync6(join6(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
3766
- for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join6(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
4218
+ const dir = join7(io.cwd, outDir);
4219
+ const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync8(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${DRIFT_SPEC_FILE}`)) : [];
4220
+ for (const f of files) io.err(`${PRODUCT} ci: drift job at ${join7(outDir, f).replace(/^\.\//, "")} on ${settings.schedule}`);
3767
4221
  if (files.length && forge === "gitlab") io.err(`${PRODUCT} ci: GitLab runs the drift job from a pipeline schedule; include the file from .gitlab-ci.yml and add a schedule`);
3768
4222
  return table.done;
3769
4223
  }
@@ -3781,6 +4235,8 @@ var init_drift2 = __esm({
3781
4235
  init_drift();
3782
4236
  init_config();
3783
4237
  init_stacks();
4238
+ init_detect();
4239
+ init_render();
3784
4240
  init_routing();
3785
4241
  init_images();
3786
4242
  init_stages();
@@ -3837,8 +4293,8 @@ var init_tips = __esm({
3837
4293
  });
3838
4294
 
3839
4295
  // src/agents.ts
3840
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
3841
- import { dirname as dirname6, join as join7 } from "node:path";
4296
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
4297
+ import { dirname as dirname7, join as join8 } from "node:path";
3842
4298
  function plywoodMcpTools(ctx) {
3843
4299
  const none = { type: "object", properties: {} };
3844
4300
  const doc = () => {
@@ -3948,304 +4404,83 @@ function reviewOpFile(doc) {
3948
4404
  "",
3949
4405
  "export default Op({",
3950
4406
  ` name: ${JSON.stringify(REVIEW_OP)},`,
3951
- ` overview: "A model reviews the pull request against its stacks' previews",`,
3952
- " phases: [",
3953
- ` phase("Preview", [shell(${JSON.stringify(REVIEW_PREVIEW_LINE)})]),`,
3954
- ` phase("Review", [reviewAgent({ ${fields} })]),`,
3955
- " ],",
3956
- "});",
3957
- ""
3958
- ].join("\n");
3959
- }
3960
- function reviewOpSpec(doc) {
3961
- const settings = toChantSettings({ doc });
3962
- const p = settings.pipeline;
3963
- const engine2 = chantPipelineOptions({ ...p.image ? { image: p.image } : {}, ...p.engineSetup ? { engineSetup: p.engineSetup } : {} }, false);
3964
- return {
3965
- ops: [
3966
- {
3967
- name: REVIEW_OP,
3968
- trigger: { kind: "pull_request" },
3969
- reviewAgent: true,
3970
- // The preview in the review job takes the preview identity; id-token is the one write the review job may hold.
3971
- ...settings.ci.identity ? { permissions: { "id-token": "write" } } : {}
3972
- }
3973
- ],
3974
- options: {
3975
- ...engine2.beforeScript ? { beforeScript: engine2.beforeScript } : {},
3976
- ...engine2.image ? { image: engine2.image } : {}
3977
- }
3978
- };
3979
- }
3980
- function writeIfChanged3(cwd, file, text) {
3981
- const path = join7(cwd, file);
3982
- if (existsSync7(path) && readFileSync7(path, "utf8") === text) return false;
3983
- mkdirSync5(dirname6(path), { recursive: true });
3984
- writeFileSync5(path, text);
3985
- return true;
3986
- }
3987
- async function writeReviewJobs(chant, forge, doc, words, table, io) {
3988
- if (!reviewOf(doc)) return table.done;
3989
- if (writeIfChanged3(io.cwd, REVIEW_OP_FILE, reviewOpFile(doc))) io.err(`${PRODUCT} ci: wrote ${REVIEW_OP_FILE}; commit it with the workflow`);
3990
- if (writeIfChanged3(io.cwd, REVIEW_SPEC_FILE, JSON.stringify(reviewOpSpec(doc), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${REVIEW_SPEC_FILE}`);
3991
- const outDir = OP_OUTPUT_DIR3[forge];
3992
- const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
3993
- if (code !== table.done) return code;
3994
- const dir = join7(io.cwd, outDir);
3995
- const files = existsSync7(dir) ? readdirSync4(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync7(join7(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
3996
- for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join7(outDir, f).replace(/^\.\//, "")}`);
3997
- const r = reviewOf(doc);
3998
- io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
3999
- if (forge === "gitlab") io.err(`${PRODUCT} ci: on GitLab the note job runs on the default branch with CHANT_REVIEW_NOTES=1 from a pipeline schedule, and posts with a protected CHANT_FORGE_TOKEN`);
4000
- return table.done;
4001
- }
4002
- var PERSON_VERBS, READ_INSTRUCTIONS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4003
- var init_agents2 = __esm({
4004
- "src/agents.ts"() {
4005
- "use strict";
4006
- init_chant_config();
4007
- init_config_check();
4008
- init_agents();
4009
- init_config();
4010
- init_stacks();
4011
- init_images();
4012
- init_stages();
4013
- init_tips();
4014
- init_words();
4015
- PERSON_VERBS = ["up", "approve", "override"];
4016
- READ_INSTRUCTIONS = `To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`;
4017
- REVIEW_OP = "plywood-review";
4018
- REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4019
- REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
4020
- REVIEW_CHANGE_SET = ".chant/pr/change-set.json";
4021
- REVIEW_PREVIEW_LINE = `${BIN} components pr-plan --base "origin/\${GITHUB_BASE_REF:-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" --pr "\${CI_MERGE_REQUEST_IID:-$(node -p 'require(process.env.GITHUB_EVENT_PATH).pull_request.number')}" --gate ${PR_GATE} --no-status`;
4022
- OP_OUTPUT_DIR3 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
4023
- }
4024
- });
4025
-
4026
- // src/init/detect.ts
4027
- import { existsSync as existsSync8, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync } from "node:fs";
4028
- import { basename, dirname as dirname7, join as join8, relative, sep } from "node:path";
4029
- import { parseYAML as parseYAML2 } from "@intentius/chant/yaml";
4030
- function repoRoot(dir) {
4031
- let cur = dir;
4032
- for (let i = 0; i < 64; i++) {
4033
- if (existsSync8(join8(cur, ".git"))) return cur;
4034
- const parent = dirname7(cur);
4035
- if (parent === cur) return dir;
4036
- cur = parent;
4037
- }
4038
- return dir;
4039
- }
4040
- function posixRel(root, path) {
4041
- const rel = relative(root, path);
4042
- return rel === "" ? "." : rel.split(sep).join("/");
4043
- }
4044
- function readYamlFile(file) {
4045
- try {
4046
- const doc = parseYAML2(readFileSync8(file, "utf8"));
4047
- return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
4048
- } catch {
4049
- return void 0;
4050
- }
4051
- }
4052
- function findPulumiProjects(root) {
4053
- const out = [];
4054
- const walk = (dir, depth) => {
4055
- let entries;
4056
- try {
4057
- entries = readdirSync5(dir).sort();
4058
- } catch {
4059
- return;
4060
- }
4061
- const file = ["Pulumi.yaml", "Pulumi.yml"].find((n) => entries.includes(n));
4062
- if (file) {
4063
- const project = readProject(root, dir, join8(dir, file));
4064
- if (project) out.push(project);
4065
- }
4066
- if (depth >= MAX_DEPTH) return;
4067
- for (const name of entries) {
4068
- if (SKIP.has(name) || name.startsWith(".")) continue;
4069
- const path = join8(dir, name);
4070
- try {
4071
- if (statSync(path).isDirectory()) walk(path, depth + 1);
4072
- } catch {
4073
- }
4074
- }
4075
- };
4076
- walk(root, 0);
4077
- return out;
4078
- }
4079
- function readProject(root, dir, file) {
4080
- const doc = readYamlFile(file);
4081
- if (!doc || typeof doc.name !== "string" || doc.name === "") return void 0;
4082
- const runtime = typeof doc.runtime === "string" ? doc.runtime : doc.runtime?.name;
4083
- const backend = doc.backend;
4084
- const configDir = typeof doc.stackConfigDir === "string" ? join8(dir, doc.stackConfigDir) : dir;
4085
- return {
4086
- name: doc.name,
4087
- dir: posixRel(root, dir),
4088
- ...typeof runtime === "string" ? { runtime } : {},
4089
- ...backend && typeof backend.url === "string" ? { backendUrl: backend.url } : {},
4090
- stacks: stackNames(configDir)
4091
- };
4092
- }
4093
- function stackNames(configDir) {
4094
- let entries;
4095
- try {
4096
- entries = readdirSync5(configDir);
4097
- } catch {
4098
- return [];
4099
- }
4100
- const names = entries.map((n) => /^Pulumi\.(.+)\.ya?ml$/.exec(n)?.[1]).filter((s) => s !== void 0 && s !== "");
4101
- return [...new Set(names)].sort(byEnvironment);
4102
- }
4103
- function environmentOf(stack) {
4104
- const last = stack.split("/").pop().toLowerCase();
4105
- const head = last.split(/[-_.]/)[0];
4106
- return ENV_ORDER.includes(head) ? head : last;
4107
- }
4108
- function rank(stack) {
4109
- const env = environmentOf(stack);
4110
- const at = ENV_ORDER.indexOf(env);
4111
- return at >= 0 ? at : PROD_RANK - 0.5;
4112
- }
4113
- function byEnvironment(a, b) {
4114
- return rank(a) - rank(b) || a.localeCompare(b);
4115
- }
4116
- function stacksOf2(projects) {
4117
- const names = /* @__PURE__ */ new Map();
4118
- for (const p of projects) names.set(p.name, (names.get(p.name) ?? 0) + 1);
4119
- const out = [];
4120
- for (const p of projects) {
4121
- const project = (names.get(p.name) ?? 0) > 1 ? `${p.name}-${slug(p.dir)}` : p.name;
4122
- for (const stack of p.stacks) out.push({ key: `${project}/${stack}`, project: p.name, stack, dir: p.dir });
4123
- }
4124
- return out.sort((x, y) => byEnvironment(x.stack, y.stack) || x.key.localeCompare(y.key));
4125
- }
4126
- function slug(s) {
4127
- const out = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4128
- return out === "" ? "root" : out;
4129
- }
4130
- function originUrl(root) {
4131
- const config2 = gitFile(root, "config");
4132
- if (!config2) return void 0;
4133
- let inOrigin = false;
4134
- for (const raw of config2.split(/\r?\n/)) {
4135
- const line = raw.trim();
4136
- if (line.startsWith("[")) {
4137
- inOrigin = /^\[remote\s+"origin"\]$/.test(line);
4138
- continue;
4139
- }
4140
- const m = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
4141
- if (m) return m[1].trim();
4142
- }
4143
- return void 0;
4144
- }
4145
- function gitFile(root, name) {
4146
- const dotGit = join8(root, ".git");
4147
- let gitDir = dotGit;
4148
- try {
4149
- if (statSync(dotGit).isFile()) {
4150
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync8(dotGit, "utf8"));
4151
- if (!m) return void 0;
4152
- gitDir = join8(root, m[1].trim());
4153
- const common = join8(gitDir, "commondir");
4154
- if (existsSync8(common) && !existsSync8(join8(gitDir, name))) gitDir = join8(gitDir, readFileSync8(common, "utf8").trim());
4155
- }
4156
- const path = join8(gitDir, name);
4157
- return existsSync8(path) ? readFileSync8(path, "utf8") : void 0;
4158
- } catch {
4159
- return void 0;
4160
- }
4161
- }
4162
- function remoteHost(url) {
4163
- const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
4164
- if (scp) return scp[1].toLowerCase();
4165
- try {
4166
- return new URL(url).hostname.toLowerCase() || void 0;
4167
- } catch {
4168
- return void 0;
4169
- }
4170
- }
4171
- function forgeOfRemote(url) {
4172
- const host = remoteHost(url);
4173
- if (!host) return void 0;
4174
- if (host === "github.com" || host.endsWith(".github.com") || host.startsWith("github.")) return { kind: "github", because: `origin is on ${host}` };
4175
- if (host === "gitlab.com") return { kind: "gitlab", because: `origin is on ${host}` };
4176
- if (host === "codeberg.org") return { kind: "forgejo", because: `origin is on ${host}` };
4177
- if (/(^|[.-])gitlab([.-]|$)/.test(host)) return { kind: "gitlab", because: `origin is on ${host}`, url: `https://${host}` };
4178
- if (/(^|[.-])(forgejo|gitea)([.-]|$)/.test(host)) return { kind: "forgejo", because: `origin is on ${host}`, url: `https://${host}` };
4179
- return void 0;
4180
- }
4181
- function detectForge(root) {
4182
- const url = originUrl(root);
4183
- const byRemote = url ? forgeOfRemote(url) : void 0;
4184
- if (byRemote) return byRemote;
4185
- if (existsSync8(join8(root, ".forgejo"))) return { kind: "forgejo", because: "the repository has .forgejo/" };
4186
- if (existsSync8(join8(root, ".gitlab-ci.yml"))) return { kind: "gitlab", because: "the repository has .gitlab-ci.yml" };
4187
- if (existsSync8(join8(root, ".github", "workflows"))) return { kind: "github", because: "the repository has .github/workflows/" };
4188
- return void 0;
4189
- }
4190
- function defaultBranch(root) {
4191
- const head = gitFile(root, "refs/remotes/origin/HEAD");
4192
- const remote = head && /ref:\s*refs\/remotes\/origin\/(.+)/.exec(head)?.[1];
4193
- if (remote) return remote.trim();
4194
- const local = gitFile(root, "HEAD");
4195
- const branch = local && /ref:\s*refs\/heads\/(.+)/.exec(local)?.[1];
4196
- return branch ? branch.trim() : "main";
4407
+ ` overview: "A model reviews the pull request against its stacks' previews",`,
4408
+ " phases: [",
4409
+ ` phase("Preview", [shell(${JSON.stringify(REVIEW_PREVIEW_LINE)})]),`,
4410
+ ` phase("Review", [reviewAgent({ ${fields} })]),`,
4411
+ " ],",
4412
+ "});",
4413
+ ""
4414
+ ].join("\n");
4197
4415
  }
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 {
4416
+ function reviewOpSpec(doc) {
4417
+ const settings = toChantSettings({ doc });
4418
+ const p = settings.pipeline;
4419
+ const engine2 = chantPipelineOptions({ ...p.image ? { image: p.image } : {}, ...p.engineSetup ? { engineSetup: p.engineSetup } : {} }, false);
4420
+ return {
4421
+ ops: [
4422
+ {
4423
+ name: REVIEW_OP,
4424
+ trigger: { kind: "pull_request" },
4425
+ reviewAgent: true,
4426
+ // The preview in the review job takes the preview identity; id-token is the one write the review job may hold.
4427
+ ...settings.ci.identity ? { permissions: { "id-token": "write" } } : {}
4212
4428
  }
4429
+ ],
4430
+ options: {
4431
+ ...engine2.beforeScript ? { beforeScript: engine2.beforeScript } : {},
4432
+ ...engine2.image ? { image: engine2.image } : {}
4213
4433
  }
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
4434
  };
4231
4435
  }
4232
- function repoName(root) {
4233
- return slug(basename(root)).slice(0, 40).replace(/-+$/, "") || "plywood";
4436
+ function writeIfChanged3(cwd, file, text) {
4437
+ const path = join8(cwd, file);
4438
+ if (existsSync8(path) && readFileSync9(path, "utf8") === text) return false;
4439
+ mkdirSync5(dirname7(path), { recursive: true });
4440
+ writeFileSync5(path, text);
4441
+ return true;
4234
4442
  }
4235
- var SKIP, MAX_DEPTH, ENV_ORDER, PROD_RANK, WORKFLOW_DIRS;
4236
- var init_detect = __esm({
4237
- "src/init/detect.ts"() {
4443
+ async function writeReviewJobs(chant, forge, doc, words, table, io) {
4444
+ if (!reviewOf(doc)) return table.done;
4445
+ if (writeIfChanged3(io.cwd, REVIEW_OP_FILE, reviewOpFile(doc))) io.err(`${PRODUCT} ci: wrote ${REVIEW_OP_FILE}; commit it with the workflow`);
4446
+ if (writeIfChanged3(io.cwd, REVIEW_SPEC_FILE, JSON.stringify(reviewOpSpec(doc), null, 2) + "\n")) io.err(`${PRODUCT} ci: wrote ${REVIEW_SPEC_FILE}`);
4447
+ const outDir = OP_OUTPUT_DIR3[forge];
4448
+ const code = await chant.runChantInProcess(["run", "--generate", forge, "--spec", REVIEW_SPEC_FILE, "--output", outDir], words);
4449
+ if (code !== table.done) return code;
4450
+ const dir = join8(io.cwd, outDir);
4451
+ const files = existsSync8(dir) ? readdirSync5(dir).filter((f) => /\.ya?ml$/.test(f) && (readFileSync9(join8(dir, f), "utf8").split("\n", 1)[0] ?? "").includes(`--spec ${REVIEW_SPEC_FILE}`)) : [];
4452
+ for (const f of files) io.err(`${PRODUCT} ci: review jobs at ${join8(outDir, f).replace(/^\.\//, "")}`);
4453
+ const r = reviewOf(doc);
4454
+ io.err(`${PRODUCT} ci: the review job reads the model's key from ${r.keyEnv}; set it from a secret`);
4455
+ 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`);
4456
+ return table.done;
4457
+ }
4458
+ var PERSON_VERBS, READ_INSTRUCTIONS, REVIEW_OP, REVIEW_OP_FILE, REVIEW_SPEC_FILE, REVIEW_CHANGE_SET, REVIEW_PREVIEW_LINE, OP_OUTPUT_DIR3;
4459
+ var init_agents2 = __esm({
4460
+ "src/agents.ts"() {
4238
4461
  "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"];
4462
+ init_chant_config();
4463
+ init_config_check();
4464
+ init_agents();
4465
+ init_config();
4466
+ init_stacks();
4467
+ init_images();
4468
+ init_stages();
4469
+ init_tips();
4470
+ init_words();
4471
+ PERSON_VERBS = ["up", "approve", "override"];
4472
+ READ_INSTRUCTIONS = `To change a stack, open a pull request: ${PRODUCT} previews it, and its update runs after a person approves the digest.`;
4473
+ REVIEW_OP = "plywood-review";
4474
+ REVIEW_OP_FILE = "ops/plywood-review.op.ts";
4475
+ REVIEW_SPEC_FILE = ".chant/ops/plywood-review.spec.json";
4476
+ REVIEW_CHANGE_SET = ".chant/pr/change-set.json";
4477
+ REVIEW_PREVIEW_LINE = `${BIN} components pr-plan --base "origin/\${GITHUB_BASE_REF:-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" --pr "\${CI_MERGE_REQUEST_IID:-$(node -p 'require(process.env.GITHUB_EVENT_PATH).pull_request.number')}" --gate ${PR_GATE} --no-status`;
4478
+ OP_OUTPUT_DIR3 = { github: ".github/workflows", forgejo: ".forgejo/workflows", gitlab: "." };
4244
4479
  }
4245
4480
  });
4246
4481
 
4247
4482
  // src/policy-presets.ts
4248
- import { readFileSync as readFileSync9 } from "node:fs";
4483
+ import { readFileSync as readFileSync10 } from "node:fs";
4249
4484
  function providersRego(allowed, namespace = "main") {
4250
4485
  const set = allowed.length ? `{${[...new Set(allowed)].sort().map((p) => JSON.stringify(p)).join(", ")}}` : "set()";
4251
4486
  return preset("plywood_providers.rego.tmpl").replace("{{namespace}}", namespace).replace("{{allowed}}", set);
@@ -4264,7 +4499,7 @@ var init_policy_presets = __esm({
4264
4499
  PROVIDERS_PACK_CONFIG = "policy/plywood-providers.json";
4265
4500
  PROVIDERS_REGO_FILE = "plywood_providers.rego";
4266
4501
  PROVIDERS_POLICY = "provider-allowlist";
4267
- preset = (rel) => readFileSync9(new URL(`../presets/policy/${rel}`, import.meta.url), "utf8");
4502
+ preset = (rel) => readFileSync10(new URL(`../presets/policy/${rel}`, import.meta.url), "utf8");
4268
4503
  }
4269
4504
  });
4270
4505
 
@@ -4617,155 +4852,6 @@ var init_presets = __esm({
4617
4852
  }
4618
4853
  });
4619
4854
 
4620
- // src/templates.ts
4621
- import { readFileSync as readFileSync10 } from "node:fs";
4622
- function template(name) {
4623
- return readFileSync10(new URL(`../templates/${name}`, import.meta.url), "utf8");
4624
- }
4625
- function fill(text, values) {
4626
- return text.replace(/\{\{(\w+)\}\}/g, (all, key) => key in values ? values[key] : all);
4627
- }
4628
- var init_templates = __esm({
4629
- "src/templates.ts"() {
4630
- "use strict";
4631
- }
4632
- });
4633
-
4634
- // src/init/render.ts
4635
- import { emitYAMLEntry } from "@intentius/chant/yaml";
4636
- function plywoodYml(keys) {
4637
- const body = Object.entries(keys).filter(([, v]) => v !== void 0).map(([k, v]) => emitYAMLEntry(k, v)).join("\n");
4638
- const text = fill(template("plywood.yml"), { keys: body });
4639
- return text.endsWith("\n") ? text : text + "\n";
4640
- }
4641
- function chantConfigTs() {
4642
- return template("chant.config.ts.tmpl");
4643
- }
4644
- function componentName(stack) {
4645
- return slug(stack.key);
4646
- }
4647
- function exportName(stack, taken) {
4648
- let base = stack.key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
4649
- if (base === "" || /^\d/.test(base)) base = `stack_${base}`;
4650
- let name = base;
4651
- for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
4652
- taken.add(name);
4653
- return name;
4654
- }
4655
- function stepInput(s) {
4656
- return {
4657
- dir: s.dir,
4658
- stack: s.stack,
4659
- member: s.project,
4660
- ...s.engine ? { engine: s.engine } : {},
4661
- ...s.backend ? { backend: s.backend } : {},
4662
- ...s.engineVersion ? { engineVersion: s.engineVersion, ...s.engineRoot ? { engineRoot: s.engineRoot } : {} } : {},
4663
- ...s.lockGrain ? { lockGrain: s.lockGrain } : {},
4664
- ...s.policyPacks?.length ? { policyPacks: s.policyPacks } : {}
4665
- };
4666
- }
4667
- function componentsFile(stacks2, after = {}, opts = {}) {
4668
- const taken = /* @__PURE__ */ new Set();
4669
- const lines2 = [
4670
- "// Written by plywood init from the stacks it found; running it again rewrites",
4671
- "// this file. Each stack is a chant component whose pulumi-up step previews it",
4672
- "// on a pull request and updates it in its wave.",
4673
- ""
4674
- ];
4675
- for (const s of stacks2) {
4676
- const dependsOn = (after[s.key] ?? []).filter((u) => stacks2.some((x) => x.key === u)).map((u) => componentName({ key: u }));
4677
- const step = { kind: "pulumi-up", ...stepInput(s), ...opts.reviewSuffix ? { reviewSuffix: opts.reviewSuffix } : {} };
4678
- const component = { name: componentName(s), dependsOn, deploy: [{ phase: "up", steps: [step] }] };
4679
- lines2.push(`export const ${exportName(s, taken)} = ${JSON.stringify(component)};`);
4680
- }
4681
- return lines2.join("\n") + "\n";
4682
- }
4683
- function wavesOf(stacks2, configured) {
4684
- const byKey = new Map(stacks2.map((s) => [s.key, s]));
4685
- if (configured && configured.length > 0) {
4686
- return configured.map((keys, i) => ({ name: `wave-${i + 1}`, stacks: keys.map((k) => byKey.get(k)).filter((s) => s !== void 0) }));
4687
- }
4688
- const out = [];
4689
- for (const s of stacks2) {
4690
- const env = environmentOf(s.stack);
4691
- let name = slug(env);
4692
- if (!WAVE_NAME.test(name)) name = `wave-${out.length + 1}`;
4693
- const wave = out.find((w) => w.name === name);
4694
- if (wave) wave.stacks.push(s);
4695
- else out.push({ name, stacks: [s] });
4696
- }
4697
- return out;
4698
- }
4699
- function waveRunner(runner) {
4700
- if (runner === void 0) return void 0;
4701
- if (typeof runner === "string" || Array.isArray(runner) || "group" in runner) return runner;
4702
- const staged = runner;
4703
- return staged.up ?? staged.default;
4704
- }
4705
- function wavesSpec(input) {
4706
- const waves = (input.ordered ?? wavesOf(input.stacks, input.waves)).filter((w) => w.stacks.length > 0);
4707
- return {
4708
- name: OP_NAME,
4709
- op: OP_NAME,
4710
- gate: PR_GATE,
4711
- branches: [input.branch],
4712
- ...input.runner !== void 0 ? { runner: input.runner } : {},
4713
- step: { kind: "pulumi-up" },
4714
- ...input.quorum && input.quorum.length > 0 ? { approvals: { quorum: input.quorum } } : {},
4715
- ...input.notify ? { notify: input.notify } : {},
4716
- waves: waves.map((w) => {
4717
- const gate = w.stacks.map((s) => s.gate ?? input.gate).reduce((a, b) => STRICTNESS[b] > STRICTNESS[a] ? b : a);
4718
- return { name: w.name, gate, runs: w.stacks.map((s) => ({ target: s.key, input: stepInput(s) })) };
4719
- })
4720
- };
4721
- }
4722
- function jsonText(v) {
4723
- return JSON.stringify(v, null, 2) + "\n";
4724
- }
4725
- function memberName(project, taken) {
4726
- let base = slug(project.name).slice(0, 36).replace(/-+$/, "");
4727
- if (!MEMBER_NAME.test(base)) base = "stack";
4728
- let name = base;
4729
- for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
4730
- taken.add(name);
4731
- return name;
4732
- }
4733
- function workspaceDeclaration(existing, input) {
4734
- const doc = existing ? structuredClone(existing) : { name: input.name, schema: 1 };
4735
- const members = Array.isArray(doc.members) ? doc.members : [];
4736
- const taken = new Set(members.map((m) => String(m.name)));
4737
- for (const p of input.projects) {
4738
- if (members.some((m) => m.dir === p.dir || p.dir === "." && m.dir === void 0)) continue;
4739
- members.push({ name: memberName(p, taken), dir: p.dir, kind: "pulumi" });
4740
- }
4741
- doc.members = members;
4742
- const pins = Array.isArray(doc.pins) ? doc.pins : [];
4743
- if (!pins.some((p) => p.package === PULUMI_LEXICON)) pins.push({ package: PULUMI_LEXICON, version: input.lexiconVersion });
4744
- doc.pins = pins;
4745
- const ci2 = doc.ci && typeof doc.ci === "object" && !Array.isArray(doc.ci) ? doc.ci : {};
4746
- for (const [k, v] of Object.entries(input.ci)) if (v !== void 0) ci2[k] = v;
4747
- if (Object.keys(ci2).length > 0) doc.ci = ci2;
4748
- return doc;
4749
- }
4750
- function memberOfDir(doc, dir) {
4751
- const members = doc && Array.isArray(doc.members) ? doc.members : [];
4752
- const m = members.find((x) => x.dir === dir || dir === "." && x.dir === void 0);
4753
- return typeof m?.name === "string" ? m.name : void 0;
4754
- }
4755
- var WAVE_NAME, STRICTNESS, MEMBER_NAME, PULUMI_LEXICON;
4756
- var init_render = __esm({
4757
- "src/init/render.ts"() {
4758
- "use strict";
4759
- init_templates();
4760
- init_words();
4761
- init_detect();
4762
- WAVE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
4763
- STRICTNESS = { never: 0, "on-destructive": 1, always: 2 };
4764
- MEMBER_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
4765
- PULUMI_LEXICON = "@intentius/chant-lexicon-pulumi";
4766
- }
4767
- });
4768
-
4769
4855
  // src/stack-order.ts
4770
4856
  import { join as join9 } from "node:path";
4771
4857
  function chantReferenceReader() {
@@ -5400,6 +5486,11 @@ __export(init_exports, {
5400
5486
  import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync6 } from "node:fs";
5401
5487
  import { emitYAMLEntry as emitYAMLEntry2 } from "@intentius/chant/yaml";
5402
5488
  import { dirname as dirname9, join as join13, posix as posix3 } from "node:path";
5489
+ function chantPin() {
5490
+ const pin = dependencies["@intentius/chant"];
5491
+ if (!pin) throw new Error("packages/plywood/package.json has no @intentius/chant dependency");
5492
+ return pin;
5493
+ }
5403
5494
  function parseInitArgs(args) {
5404
5495
  if (args.includes("--print-prompt")) return { printPrompt: true };
5405
5496
  let rest = args;
@@ -5620,7 +5711,7 @@ async function runInit(opts, table, io) {
5620
5711
  const message = needsChantMessage("Writing the waves spec and the workflow");
5621
5712
  files.push({ file: WAVES_SPEC_FILE2, status: "skipped", note: message }, { file: WORKFLOW_FILE[forge.kind], status: "skipped", note: "needs the same chant" });
5622
5713
  } else {
5623
- let spec = wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves);
5714
+ let spec = await wavesSpecStep(chant, stacks2, doc, settings, detected2.defaultBranch, waves, forge.kind);
5624
5715
  if (settings.pipeline.jobs) {
5625
5716
  try {
5626
5717
  spec = withJobSettings(spec, settings.pipeline.jobs, forge.kind, { parallelism: await hasChant4625() });
@@ -5638,6 +5729,9 @@ async function runInit(opts, table, io) {
5638
5729
  if (opts.bucketCheck) notes.push(...await bucketStep(detected2, doc, io.env, buckets));
5639
5730
  adopt.push(...await adoptStep(root, detected2, doc, io.env, declaration.doc, opts.dryRun, files, notes, todo));
5640
5731
  todo.push(...todoLines(detected2, doc, forge.kind, io.env));
5732
+ if (forge.kind === "gitlab" && chant && stacks2.length > 0 && await hasChantWavePreview()) {
5733
+ todo.push(`Add a ${GITLAB_READ_TOKEN} CI/CD variable (a project access token with the read_api scope, masked and protected) so a wave can read the merge request it applies; without it the wave refuses`);
5734
+ }
5641
5735
  const failed = files.find((f) => f.status === "failed");
5642
5736
  return {
5643
5737
  ok: !failed,
@@ -5702,7 +5796,7 @@ async function preCommitStep(root, config2, dryRun) {
5702
5796
  const after = readFileSync12(join13(root, DECLARATION_FILE), "utf8");
5703
5797
  return { file: DECLARATION_FILE, status: after === before ? "unchanged" : "written", note: `${config2} runs as the check hook pre-commit; the check job's image needs pre-commit` };
5704
5798
  }
5705
- function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered) {
5799
+ async function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered, forge) {
5706
5800
  const runner = waveRunner(chantPipelineOptions(settings.pipeline, false).runner);
5707
5801
  const base = wavesSpec({
5708
5802
  stacks: stacks2,
@@ -5714,7 +5808,8 @@ function wavesSpecStep(chant, stacks2, doc, settings, branch, ordered) {
5714
5808
  // notify.relay only on a chant with the relay (chant#4611); an older chant refuses the setting.
5715
5809
  ...settings.pipeline.notify ? { notify: { ...notifyForChant(settings.pipeline.notify, chant) } } : {}
5716
5810
  });
5717
- return chant.productWavesSpec ? chant.productWavesSpec(base, PRODUCT_WORDS) : base;
5811
+ const named = chant.productWavesSpec ? chant.productWavesSpec(base, PRODUCT_WORDS) : base;
5812
+ return await hasChantWavePreview() ? withWavePreview(named, forge) : named;
5718
5813
  }
5719
5814
  async function workflowStep(chant, root, doc, settings, forge, opts, spec, table, io) {
5720
5815
  const output = WORKFLOW_FILE[forge];
@@ -5872,6 +5967,7 @@ var init_init = __esm({
5872
5967
  init_stacks();
5873
5968
  init_types();
5874
5969
  init_images();
5970
+ init_chant_next();
5875
5971
  init_jobs2();
5876
5972
  init_backend();
5877
5973
  init_adopt();
@@ -5891,7 +5987,7 @@ var init_init = __esm({
5891
5987
  CHANT_CONFIG_FILE = "chant.config.ts";
5892
5988
  COMPONENTS_FILE = "plywood.component.ts";
5893
5989
  WAVES_SPEC_FILE2 = `.chant/op-waves/${OP_NAME}.spec.json`;
5894
- CHANT_PIN = dependencies["@intentius/chant"] ?? "0.124.54";
5990
+ CHANT_PIN = chantPin();
5895
5991
  }
5896
5992
  });
5897
5993
 
@@ -6244,8 +6340,25 @@ async function withChant(what, table, io, run) {
6244
6340
  return run(chant);
6245
6341
  }
6246
6342
  function passthrough(argv, table, io) {
6343
+ if (argv[0] === "run" && argv[1] === "wave") return runWave(argv, table, io);
6247
6344
  return withChant(`${PRODUCT} ${argv[0]}`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
6248
6345
  }
6346
+ async function runWave(argv, table, io) {
6347
+ const seen = [];
6348
+ const write = process.stderr.write;
6349
+ process.stderr.write = ((chunk, ...rest) => {
6350
+ seen.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
6351
+ return write.call(process.stderr, chunk, ...rest);
6352
+ });
6353
+ let code;
6354
+ try {
6355
+ code = await withChant(`${PRODUCT} run wave`, table, io, (chant) => chant.runChantInProcess(argv, PRODUCT_WORDS));
6356
+ } finally {
6357
+ process.stderr.write = write;
6358
+ }
6359
+ if (code === table.moved) io.err(`${PRODUCT}: ${waveMovedLine(seen.join(""))}`);
6360
+ return code;
6361
+ }
6249
6362
  async function check(args, json, table, io) {
6250
6363
  const file = takeFlag(args, "--file");
6251
6364
  if (file.missing) return usage("--file takes a path", table, io);
@@ -6544,6 +6657,7 @@ var init_stages = __esm({
6544
6657
  init_review_stacks2();
6545
6658
  init_waves_workflow();
6546
6659
  init_drift2();
6660
+ init_refusals();
6547
6661
  init_agents2();
6548
6662
  init_agent_jobs();
6549
6663
  init_words();
@@ -7804,7 +7918,7 @@ var execChild = (bin, args, opts) => new Promise((done) => {
7804
7918
 
7805
7919
  // src/cli.ts
7806
7920
  init_words();
7807
- var VERSION3 = true ? "0.2.0" : "0.0.0-dev";
7921
+ var VERSION3 = true ? "0.3.0" : "0.0.0-dev";
7808
7922
  var stdio = {
7809
7923
  out: (s) => process.stdout.write(s + "\n"),
7810
7924
  err: (s) => process.stderr.write(s + "\n")