@intentius/behold 0.9.1 → 0.10.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/cli.js +431 -115
- package/example-carve/README.md +15 -8
- package/package.json +2 -2
- package/web/app.js +26 -0
- package/web/carve-steps.js +53 -6
package/dist/cli.js
CHANGED
|
@@ -1038,10 +1038,10 @@ var init_src = __esm({
|
|
|
1038
1038
|
});
|
|
1039
1039
|
|
|
1040
1040
|
// src/cli.ts
|
|
1041
|
-
import { resolve as resolve7, dirname as dirname6, join as
|
|
1042
|
-
import { realpathSync, existsSync as
|
|
1041
|
+
import { resolve as resolve7, dirname as dirname6, join as join18 } from "node:path";
|
|
1042
|
+
import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync15, rmSync as rmSync2 } from "node:fs";
|
|
1043
1043
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
1044
|
-
import { spawn as
|
|
1044
|
+
import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
|
|
1045
1045
|
|
|
1046
1046
|
// src/server.ts
|
|
1047
1047
|
import { Hono } from "hono";
|
|
@@ -1120,8 +1120,8 @@ import { serve } from "@hono/node-server";
|
|
|
1120
1120
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1121
1121
|
import { execFile as execFile3 } from "node:child_process";
|
|
1122
1122
|
import { promisify as promisify3 } from "node:util";
|
|
1123
|
-
import { existsSync as
|
|
1124
|
-
import { dirname as dirname4, join as
|
|
1123
|
+
import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
|
|
1124
|
+
import { dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
|
|
1125
1125
|
|
|
1126
1126
|
// src/recents.ts
|
|
1127
1127
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
@@ -3872,7 +3872,14 @@ function summarizePlan(plan, byComponent, nonResource) {
|
|
|
3872
3872
|
}
|
|
3873
3873
|
|
|
3874
3874
|
// src/render.ts
|
|
3875
|
-
import {
|
|
3875
|
+
import {
|
|
3876
|
+
layoutIr,
|
|
3877
|
+
layoutArchitecture,
|
|
3878
|
+
renderSvg,
|
|
3879
|
+
renderMorphHtml,
|
|
3880
|
+
cardSizes,
|
|
3881
|
+
registerPack
|
|
3882
|
+
} from "@intentius/pinhole";
|
|
3876
3883
|
|
|
3877
3884
|
// src/icon-packs.ts
|
|
3878
3885
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
@@ -4189,16 +4196,32 @@ function renderGraph(ir, opts = {}) {
|
|
|
4189
4196
|
return { svg };
|
|
4190
4197
|
}
|
|
4191
4198
|
function renderBanded(ir, opts = {}) {
|
|
4192
|
-
const
|
|
4199
|
+
const plan = bandedPlan(ir, ir.groups.byStack ?? {});
|
|
4200
|
+
const height = plan.height;
|
|
4201
|
+
const layout = {
|
|
4202
|
+
width: plan.width,
|
|
4203
|
+
height,
|
|
4204
|
+
nodes: plan.placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
|
|
4205
|
+
};
|
|
4206
|
+
const svg = renderSvg(ir, layout, {
|
|
4207
|
+
fit: true,
|
|
4208
|
+
hideTitle: true,
|
|
4209
|
+
groups: plan.boxes.map((b) => ({ ...b, y: height - b.y })),
|
|
4210
|
+
...opts.theme ? { theme: opts.theme } : {}
|
|
4211
|
+
});
|
|
4212
|
+
return { svg };
|
|
4213
|
+
}
|
|
4214
|
+
var GAP = 28;
|
|
4215
|
+
var PAD = 24;
|
|
4216
|
+
var TITLE = 34;
|
|
4217
|
+
var BAND_GAP = 26;
|
|
4218
|
+
var MEMBER_GAP = 48;
|
|
4219
|
+
function bandedPlan(ir, bands) {
|
|
4193
4220
|
const size = footprints(ir);
|
|
4194
4221
|
const dims = (id) => size.get(id) ?? { w: NODE_W, h: NODE_H };
|
|
4195
4222
|
const ids = ir.nodes.map((n) => n.id);
|
|
4196
4223
|
const cellW = Math.max(NODE_W, ...ids.map((id) => dims(id).w));
|
|
4197
4224
|
const cellH = Math.max(NODE_H, ...ids.map((id) => dims(id).h));
|
|
4198
|
-
const GAP = 28;
|
|
4199
|
-
const PAD = 24;
|
|
4200
|
-
const TITLE = 34;
|
|
4201
|
-
const BAND_GAP = 26;
|
|
4202
4225
|
const cols = Math.max(1, Math.round(Math.sqrt(ids.length * (cellH + GAP) * 2 / (cellW + GAP))));
|
|
4203
4226
|
const contentW = cols * cellW + (cols - 1) * GAP;
|
|
4204
4227
|
const panelW = contentW + PAD * 2;
|
|
@@ -4227,19 +4250,95 @@ function renderBanded(ir, opts = {}) {
|
|
|
4227
4250
|
boxes.push({ title, x: panelW / 2, y: top + panelH / 2, w: panelW, h: panelH, ...status ? { status } : {} });
|
|
4228
4251
|
top += panelH + BAND_GAP;
|
|
4229
4252
|
}
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
};
|
|
4236
|
-
const svg = renderSvg(ir, layout, {
|
|
4253
|
+
return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
|
|
4254
|
+
}
|
|
4255
|
+
function renderCarveEstate(tfIr, appIr, opts) {
|
|
4256
|
+
const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts);
|
|
4257
|
+
const svg = renderSvg(comp.ir, comp.layout, {
|
|
4237
4258
|
fit: true,
|
|
4238
4259
|
hideTitle: true,
|
|
4239
|
-
groups: boxes
|
|
4260
|
+
groups: comp.boxes,
|
|
4240
4261
|
...opts.theme ? { theme: opts.theme } : {}
|
|
4241
4262
|
});
|
|
4242
|
-
return { svg };
|
|
4263
|
+
return { svg, ir: comp.ir };
|
|
4264
|
+
}
|
|
4265
|
+
function namespaceAppIr(appIr, appTitle) {
|
|
4266
|
+
const prefix = `${appTitle.split(" ")[0]}/`;
|
|
4267
|
+
const appId = (id) => `${prefix}${id}`;
|
|
4268
|
+
return {
|
|
4269
|
+
...appIr,
|
|
4270
|
+
nodes: appIr.nodes.map((n) => ({ ...n, id: appId(n.id) })),
|
|
4271
|
+
edges: appIr.edges.map((e) => ({ ...e, from: appId(e.from), to: appId(e.to) })),
|
|
4272
|
+
groups: {}
|
|
4273
|
+
};
|
|
4274
|
+
}
|
|
4275
|
+
function composeCarveEstate(tfIr, appNs, opts) {
|
|
4276
|
+
const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
|
|
4277
|
+
const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
|
|
4278
|
+
const memberH = (plan) => TITLE + PAD + plan.height + PAD;
|
|
4279
|
+
const memberW = (plan) => plan.width + PAD * 2;
|
|
4280
|
+
const appX0 = memberW(tfPlan) + MEMBER_GAP;
|
|
4281
|
+
const width = appX0 + memberW(appPlan);
|
|
4282
|
+
const height = Math.max(memberH(tfPlan), memberH(appPlan));
|
|
4283
|
+
const offset = (plan, x0) => ({
|
|
4284
|
+
placed: plan.placed.map((p) => ({ id: p.id, x: x0 + PAD + p.x, y: TITLE + PAD + p.y })),
|
|
4285
|
+
boxes: plan.boxes.map((b) => ({ ...b, x: x0 + PAD + b.x, y: TITLE + PAD + b.y }))
|
|
4286
|
+
});
|
|
4287
|
+
const tf = offset(tfPlan, 0);
|
|
4288
|
+
const app = offset(appPlan, appX0);
|
|
4289
|
+
const boxes = [
|
|
4290
|
+
{ title: opts.tfTitle, x: memberW(tfPlan) / 2, y: memberH(tfPlan) / 2, w: memberW(tfPlan), h: memberH(tfPlan) },
|
|
4291
|
+
{ title: opts.appTitle, x: appX0 + memberW(appPlan) / 2, y: memberH(appPlan) / 2, w: memberW(appPlan), h: memberH(appPlan) },
|
|
4292
|
+
...tf.boxes,
|
|
4293
|
+
...app.boxes
|
|
4294
|
+
];
|
|
4295
|
+
const ir = {
|
|
4296
|
+
nodes: [...tfIr.nodes, ...appNs.nodes],
|
|
4297
|
+
edges: [...tfIr.edges, ...appNs.edges],
|
|
4298
|
+
groups: {
|
|
4299
|
+
byStack: {
|
|
4300
|
+
...tfIr.groups.byStack ?? {},
|
|
4301
|
+
[opts.appTitle]: appNs.nodes.map((n) => n.id)
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
};
|
|
4305
|
+
return {
|
|
4306
|
+
ir,
|
|
4307
|
+
layout: {
|
|
4308
|
+
width,
|
|
4309
|
+
height,
|
|
4310
|
+
nodes: [...tf.placed, ...app.placed].map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
|
|
4311
|
+
},
|
|
4312
|
+
boxes: boxes.map((b) => ({ ...b, y: height - b.y }))
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
function renderCarveMorph(tfIr, appIr, carved, opts) {
|
|
4316
|
+
const appNs = namespaceAppIr(appIr, opts.appTitle);
|
|
4317
|
+
const before = composeCarveEstate(tfIr, appNs, opts);
|
|
4318
|
+
const gone = new Set(carved);
|
|
4319
|
+
const bands = tfIr.groups.byStack ?? {};
|
|
4320
|
+
const bandsAfter = {};
|
|
4321
|
+
for (const [band, members] of Object.entries(bands)) {
|
|
4322
|
+
const left = members.filter((id) => !gone.has(id));
|
|
4323
|
+
if (left.length) bandsAfter[band] = left;
|
|
4324
|
+
}
|
|
4325
|
+
const tfAfter = {
|
|
4326
|
+
nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
|
|
4327
|
+
edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
4328
|
+
groups: { byStack: bandsAfter }
|
|
4329
|
+
};
|
|
4330
|
+
const carvedNodes = tfIr.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
|
|
4331
|
+
const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
|
|
4332
|
+
const after = composeCarveEstate(tfAfter, appAfter, opts);
|
|
4333
|
+
const view = (name, comp) => ({
|
|
4334
|
+
name,
|
|
4335
|
+
ir: comp.ir,
|
|
4336
|
+
layout: comp.layout,
|
|
4337
|
+
groups: comp.boxes
|
|
4338
|
+
});
|
|
4339
|
+
return renderMorphHtml([view("Terraform owns it", before), view("chant owns it", after)], {
|
|
4340
|
+
title: opts.title ?? `carve morph \u2014 ${carved.join(", ")}`
|
|
4341
|
+
});
|
|
4243
4342
|
}
|
|
4244
4343
|
var NODE_W = 175;
|
|
4245
4344
|
var NODE_H = 104;
|
|
@@ -4406,8 +4505,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
|
|
|
4406
4505
|
}
|
|
4407
4506
|
|
|
4408
4507
|
// src/carve-actions.ts
|
|
4409
|
-
import { existsSync as
|
|
4410
|
-
import { isAbsolute, join as
|
|
4508
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
4509
|
+
import { isAbsolute, join as join10, relative, resolve as resolve2, sep } from "node:path";
|
|
4411
4510
|
|
|
4412
4511
|
// src/layout.ts
|
|
4413
4512
|
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
|
|
@@ -4591,7 +4690,112 @@ function reanchorEdges(svg, moved) {
|
|
|
4591
4690
|
return out + svg.slice(cursor);
|
|
4592
4691
|
}
|
|
4593
4692
|
|
|
4693
|
+
// src/carve-live.ts
|
|
4694
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
4695
|
+
import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
|
|
4696
|
+
import { join as join9 } from "node:path";
|
|
4697
|
+
var LIVE_CONTAINER = "behold-carve-floci";
|
|
4698
|
+
var LIVE_PORT = 4602;
|
|
4699
|
+
var LIVE_IMAGE = "floci/floci:1.5.34";
|
|
4700
|
+
var LIVE_TARGETS = [
|
|
4701
|
+
"aws_s3_bucket.assets",
|
|
4702
|
+
"aws_s3_bucket_versioning.assets",
|
|
4703
|
+
"aws_s3_bucket_public_access_block.assets",
|
|
4704
|
+
"aws_cloudwatch_log_group.worker"
|
|
4705
|
+
];
|
|
4706
|
+
function armOverride(disabledText, port) {
|
|
4707
|
+
return disabledText.replaceAll("localhost:4566", `localhost:${port}`);
|
|
4708
|
+
}
|
|
4709
|
+
function parsePlanOutput(stdout, exitCode) {
|
|
4710
|
+
const planLine = stdout.split("\n").reverse().find((l) => l.startsWith("Plan:") || l.includes("No changes.")) ?? `terraform plan exited ${exitCode}`;
|
|
4711
|
+
const destroyMatch = planLine.match(/(\d+) to destroy/);
|
|
4712
|
+
return {
|
|
4713
|
+
planLine: planLine.trim(),
|
|
4714
|
+
changes: exitCode === 2,
|
|
4715
|
+
noDestroy: !destroyMatch || destroyMatch[1] === "0"
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
function capture(cmd, args, cwd) {
|
|
4719
|
+
return new Promise((res) => {
|
|
4720
|
+
const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd, shell: process.platform === "win32" });
|
|
4721
|
+
let out = "";
|
|
4722
|
+
child.stdout?.on("data", (d) => out += d.toString());
|
|
4723
|
+
child.stderr?.on("data", (d) => out += d.toString());
|
|
4724
|
+
child.on("error", () => res({ code: -1, stdout: out }));
|
|
4725
|
+
child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
|
|
4726
|
+
});
|
|
4727
|
+
}
|
|
4728
|
+
async function bootScratchFloci() {
|
|
4729
|
+
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
|
|
4730
|
+
if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
|
|
4731
|
+
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
|
|
4732
|
+
return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
|
|
4733
|
+
}
|
|
4734
|
+
const run3 = await capture(
|
|
4735
|
+
"docker",
|
|
4736
|
+
["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
|
|
4737
|
+
process.cwd()
|
|
4738
|
+
);
|
|
4739
|
+
if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
|
|
4740
|
+
for (let i = 0; i < 30; i++) {
|
|
4741
|
+
try {
|
|
4742
|
+
const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
|
|
4743
|
+
if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
|
|
4744
|
+
} catch {
|
|
4745
|
+
}
|
|
4746
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
4747
|
+
}
|
|
4748
|
+
await teardownScratchFloci();
|
|
4749
|
+
return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
|
|
4750
|
+
}
|
|
4751
|
+
async function teardownScratchFloci() {
|
|
4752
|
+
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
4753
|
+
}
|
|
4754
|
+
async function applyIntoFloci(fromDir, step) {
|
|
4755
|
+
const template = join9(fromDir, "floci-override.tf.disabled");
|
|
4756
|
+
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
4757
|
+
writeFileSync3(join9(fromDir, "floci_override.tf"), armOverride(readFileSync8(template, "utf8"), LIVE_PORT));
|
|
4758
|
+
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
4759
|
+
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
4760
|
+
const apply = await step(
|
|
4761
|
+
"terraform",
|
|
4762
|
+
["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
|
|
4763
|
+
fromDir
|
|
4764
|
+
);
|
|
4765
|
+
if (apply !== 0) return `terraform apply exited ${apply}`;
|
|
4766
|
+
return void 0;
|
|
4767
|
+
}
|
|
4768
|
+
async function runLivePlan(fromDir) {
|
|
4769
|
+
const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
|
|
4770
|
+
if (r.code !== 0 && r.code !== 2) {
|
|
4771
|
+
const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
|
|
4772
|
+
return { error: `terraform plan failed (exit ${r.code}):
|
|
4773
|
+
${tail}`, exitCode: r.code };
|
|
4774
|
+
}
|
|
4775
|
+
return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4594
4778
|
// src/carve-actions.ts
|
|
4779
|
+
async function runCarvePlan(demo) {
|
|
4780
|
+
if (!demo?.live) {
|
|
4781
|
+
return refuse2(
|
|
4782
|
+
"carve-action",
|
|
4783
|
+
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
4784
|
+
"run `behold demo carve --live` (needs docker + terraform)"
|
|
4785
|
+
);
|
|
4786
|
+
}
|
|
4787
|
+
const r = await runLivePlan(demo.from);
|
|
4788
|
+
if ("error" in r) {
|
|
4789
|
+
return refuse2("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
4790
|
+
}
|
|
4791
|
+
return {
|
|
4792
|
+
ok: true,
|
|
4793
|
+
command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
|
|
4794
|
+
planLine: r.planLine,
|
|
4795
|
+
changes: r.changes,
|
|
4796
|
+
noDestroy: r.noDestroy
|
|
4797
|
+
};
|
|
4798
|
+
}
|
|
4595
4799
|
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
4596
4800
|
var MAX_ARTIFACTS = 24;
|
|
4597
4801
|
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
@@ -4612,7 +4816,7 @@ function carveWriteBlock(demo) {
|
|
|
4612
4816
|
if (!demo) {
|
|
4613
4817
|
return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
|
|
4614
4818
|
}
|
|
4615
|
-
if (!
|
|
4819
|
+
if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
|
|
4616
4820
|
if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
|
|
4617
4821
|
return unwritableReason(demo.root);
|
|
4618
4822
|
}
|
|
@@ -4629,7 +4833,7 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4629
4833
|
for (const name of entries) {
|
|
4630
4834
|
if (out.length >= MAX_ARTIFACTS) return;
|
|
4631
4835
|
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
4632
|
-
const full =
|
|
4836
|
+
const full = join10(d, name);
|
|
4633
4837
|
let st;
|
|
4634
4838
|
try {
|
|
4635
4839
|
st = statSync2(full);
|
|
@@ -4644,7 +4848,7 @@ function readArtifacts(demo, dir, filter) {
|
|
|
4644
4848
|
if (!filter(rel)) continue;
|
|
4645
4849
|
let text = "";
|
|
4646
4850
|
try {
|
|
4647
|
-
text =
|
|
4851
|
+
text = readFileSync9(full, "utf8");
|
|
4648
4852
|
} catch {
|
|
4649
4853
|
continue;
|
|
4650
4854
|
}
|
|
@@ -4676,7 +4880,7 @@ async function runCarveEmit(demo, select) {
|
|
|
4676
4880
|
const block = carveWriteBlock(demo);
|
|
4677
4881
|
if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
4678
4882
|
mkdirSync3(demo.out, { recursive: true });
|
|
4679
|
-
const reportFile =
|
|
4883
|
+
const reportFile = join10(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
4680
4884
|
const args = [
|
|
4681
4885
|
"carve",
|
|
4682
4886
|
"emit",
|
|
@@ -4705,10 +4909,10 @@ async function runCarveEmit(demo, select) {
|
|
|
4705
4909
|
}
|
|
4706
4910
|
let boundary = null;
|
|
4707
4911
|
try {
|
|
4708
|
-
boundary = JSON.parse(
|
|
4912
|
+
boundary = JSON.parse(readFileSync9(reportFile, "utf8"));
|
|
4709
4913
|
} catch {
|
|
4710
4914
|
}
|
|
4711
|
-
const lintPath = relative(demo.project,
|
|
4915
|
+
const lintPath = relative(demo.project, join10(demo.out, "src")).split(sep).join("/");
|
|
4712
4916
|
const lintArgs = ["lint", lintPath];
|
|
4713
4917
|
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
4714
4918
|
code: 127,
|
|
@@ -4771,8 +4975,8 @@ async function runCarveBridge(demo, select) {
|
|
|
4771
4975
|
}
|
|
4772
4976
|
|
|
4773
4977
|
// src/ops.ts
|
|
4774
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
4775
|
-
import { join as
|
|
4978
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync10, existsSync as existsSync9 } from "node:fs";
|
|
4979
|
+
import { join as join11 } from "node:path";
|
|
4776
4980
|
var APPLY_TARGET_LEXICON = {
|
|
4777
4981
|
cloudformation: "aws",
|
|
4778
4982
|
kubectl: "k8s",
|
|
@@ -4794,11 +4998,11 @@ function discoverOps(projectDir) {
|
|
|
4794
4998
|
const seen = /* @__PURE__ */ new Set();
|
|
4795
4999
|
const out = [];
|
|
4796
5000
|
for (const sub of ["ops", "src", "."]) {
|
|
4797
|
-
const dir =
|
|
4798
|
-
if (!
|
|
5001
|
+
const dir = join11(projectDir, sub);
|
|
5002
|
+
if (!existsSync9(dir)) continue;
|
|
4799
5003
|
for (const f of readdirSync4(dir)) {
|
|
4800
5004
|
if (!f.endsWith(".op.ts")) continue;
|
|
4801
|
-
const content =
|
|
5005
|
+
const content = readFileSync10(join11(dir, f), "utf8");
|
|
4802
5006
|
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
4803
5007
|
if (!name || seen.has(name)) continue;
|
|
4804
5008
|
seen.add(name);
|
|
@@ -5323,9 +5527,9 @@ var OpRunner = class {
|
|
|
5323
5527
|
};
|
|
5324
5528
|
|
|
5325
5529
|
// src/substrates.ts
|
|
5326
|
-
import { spawn as
|
|
5327
|
-
import { existsSync as
|
|
5328
|
-
import { join as
|
|
5530
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
5531
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
|
|
5532
|
+
import { join as join12 } from "node:path";
|
|
5329
5533
|
import { platform } from "node:os";
|
|
5330
5534
|
var DOCKER_DOWN_DETAIL = "docker is down";
|
|
5331
5535
|
function probe(cmd, args) {
|
|
@@ -5333,7 +5537,7 @@ function probe(cmd, args) {
|
|
|
5333
5537
|
let out = "";
|
|
5334
5538
|
let proc;
|
|
5335
5539
|
try {
|
|
5336
|
-
proc =
|
|
5540
|
+
proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
5337
5541
|
} catch {
|
|
5338
5542
|
resolve8({ code: 127, out: "" });
|
|
5339
5543
|
return;
|
|
@@ -5354,11 +5558,11 @@ async function dockerRunning(run3, nameFilter) {
|
|
|
5354
5558
|
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
5355
5559
|
}
|
|
5356
5560
|
function scriptBringUp(projectDir, relPath, label) {
|
|
5357
|
-
return
|
|
5561
|
+
return existsSync10(join12(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
|
|
5358
5562
|
}
|
|
5359
5563
|
function projectLexicons(projectDir) {
|
|
5360
5564
|
try {
|
|
5361
|
-
const src =
|
|
5565
|
+
const src = readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8");
|
|
5362
5566
|
const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
|
|
5363
5567
|
if (!m) return [];
|
|
5364
5568
|
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
|
|
@@ -5410,7 +5614,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5410
5614
|
["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
|
|
5411
5615
|
];
|
|
5412
5616
|
for (const [name, label, marker, script] of forges) {
|
|
5413
|
-
if (!
|
|
5617
|
+
if (!existsSync10(join12(projectDir, marker))) continue;
|
|
5414
5618
|
const c = docker ? await dockerRunning(run3, name) : [];
|
|
5415
5619
|
const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
|
|
5416
5620
|
subs.push({
|
|
@@ -5444,7 +5648,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5444
5648
|
detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
|
|
5445
5649
|
});
|
|
5446
5650
|
}
|
|
5447
|
-
if (
|
|
5651
|
+
if (existsSync10(join12(projectDir, ".github", "workflows"))) {
|
|
5448
5652
|
const gh = await run3("gh", ["auth", "status"]);
|
|
5449
5653
|
const ready = gh.code === 0;
|
|
5450
5654
|
subs.push({
|
|
@@ -5457,7 +5661,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
|
|
|
5457
5661
|
if (lexicons.includes("temporal")) {
|
|
5458
5662
|
let hasProfiles = false;
|
|
5459
5663
|
try {
|
|
5460
|
-
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(
|
|
5664
|
+
hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8"));
|
|
5461
5665
|
} catch {
|
|
5462
5666
|
}
|
|
5463
5667
|
subs.push({
|
|
@@ -5562,7 +5766,7 @@ async function openRollbackBranches(projectDir, env) {
|
|
|
5562
5766
|
|
|
5563
5767
|
// src/estate.ts
|
|
5564
5768
|
import { statSync as statSync3 } from "node:fs";
|
|
5565
|
-
import { join as
|
|
5769
|
+
import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
|
|
5566
5770
|
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
5567
5771
|
async function composeEstate(projectDirs, opts = {}) {
|
|
5568
5772
|
const names = shortStackNames(projectDirs);
|
|
@@ -5622,7 +5826,7 @@ function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
|
5622
5826
|
const have = root.split(sep2).filter(Boolean);
|
|
5623
5827
|
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
5624
5828
|
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
5625
|
-
if (!isDir(
|
|
5829
|
+
if (!isDir(join13(root, ...want.slice(k)))) continue;
|
|
5626
5830
|
return k;
|
|
5627
5831
|
}
|
|
5628
5832
|
return 0;
|
|
@@ -5652,10 +5856,10 @@ function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
|
5652
5856
|
if (!winner || tied) continue;
|
|
5653
5857
|
const prev = best.get(winner.dir);
|
|
5654
5858
|
if (prev === "ambiguous") continue;
|
|
5655
|
-
const
|
|
5656
|
-
if (!prev) best.set(winner.dir, { score: winner.score, join:
|
|
5859
|
+
const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
5860
|
+
if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
|
|
5657
5861
|
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
5658
|
-
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join:
|
|
5862
|
+
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
|
|
5659
5863
|
}
|
|
5660
5864
|
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
5661
5865
|
}
|
|
@@ -5717,8 +5921,8 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
|
5717
5921
|
}
|
|
5718
5922
|
|
|
5719
5923
|
// src/events.ts
|
|
5720
|
-
import { watch, existsSync as
|
|
5721
|
-
import { join as
|
|
5924
|
+
import { watch, existsSync as existsSync11 } from "node:fs";
|
|
5925
|
+
import { join as join14 } from "node:path";
|
|
5722
5926
|
var Broadcaster = class {
|
|
5723
5927
|
listeners = /* @__PURE__ */ new Set();
|
|
5724
5928
|
subscribe(fn) {
|
|
@@ -5736,7 +5940,7 @@ var Broadcaster = class {
|
|
|
5736
5940
|
};
|
|
5737
5941
|
var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
|
|
5738
5942
|
function watchSource(projectDir, onChange, debounceMs = 200) {
|
|
5739
|
-
const dir =
|
|
5943
|
+
const dir = existsSync11(join14(projectDir, "src")) ? join14(projectDir, "src") : projectDir;
|
|
5740
5944
|
let timer;
|
|
5741
5945
|
const watcher = watch(dir, { recursive: true }, (_event, file) => {
|
|
5742
5946
|
const name = typeof file === "string" ? file : "";
|
|
@@ -5840,7 +6044,7 @@ var FrameBuffer = class {
|
|
|
5840
6044
|
};
|
|
5841
6045
|
|
|
5842
6046
|
// src/lanes.ts
|
|
5843
|
-
import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
|
|
6047
|
+
import { renderMorphHtml as renderMorphHtml2, layoutIr as layoutIr2 } from "@intentius/pinhole";
|
|
5844
6048
|
function safeJson(value) {
|
|
5845
6049
|
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
5846
6050
|
}
|
|
@@ -5967,7 +6171,7 @@ function renderLanes(frames, summaries) {
|
|
|
5967
6171
|
ir: f.ir,
|
|
5968
6172
|
layout: layoutIr2(f.ir)
|
|
5969
6173
|
}));
|
|
5970
|
-
const doc =
|
|
6174
|
+
const doc = renderMorphHtml2(views, { title: "Deployment lanes" });
|
|
5971
6175
|
const laneFrames = frames.map((f, i) => ({
|
|
5972
6176
|
t: summaries[i].t,
|
|
5973
6177
|
name: new Date(summaries[i].t).toISOString().slice(11, 19),
|
|
@@ -6014,13 +6218,13 @@ async function emulatorDown(projectDir) {
|
|
|
6014
6218
|
}
|
|
6015
6219
|
|
|
6016
6220
|
// src/demos.ts
|
|
6017
|
-
import { readFileSync as
|
|
6018
|
-
import { join as
|
|
6019
|
-
import { spawn as
|
|
6221
|
+
import { readFileSync as readFileSync12, existsSync as existsSync12, cpSync } from "node:fs";
|
|
6222
|
+
import { join as join15, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
|
|
6223
|
+
import { spawn as spawn5, spawnSync } from "node:child_process";
|
|
6020
6224
|
function loadDemoRegistry(pkgRoot2) {
|
|
6021
6225
|
let raw;
|
|
6022
6226
|
try {
|
|
6023
|
-
raw = JSON.parse(
|
|
6227
|
+
raw = JSON.parse(readFileSync12(join15(pkgRoot2, "demos.json"), "utf8"));
|
|
6024
6228
|
} catch {
|
|
6025
6229
|
return [];
|
|
6026
6230
|
}
|
|
@@ -6061,16 +6265,16 @@ function fetchesFromNetwork(entry) {
|
|
|
6061
6265
|
}
|
|
6062
6266
|
function demoTargetDir(entry, cwd = process.cwd()) {
|
|
6063
6267
|
const legacy = resolve4(cwd, "behold-demo");
|
|
6064
|
-
if (entry.name === "writes" &&
|
|
6268
|
+
if (entry.name === "writes" && existsSync12(legacy)) return legacy;
|
|
6065
6269
|
return resolve4(cwd, "behold-demos", entry.name);
|
|
6066
6270
|
}
|
|
6067
6271
|
async function loadDemo(entry, opts) {
|
|
6068
6272
|
const { pkgRoot: pkgRoot2, target } = opts;
|
|
6069
6273
|
const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
|
|
6070
|
-
if (!
|
|
6274
|
+
if (!existsSync12(target)) {
|
|
6071
6275
|
if (entry.source === "bundled") {
|
|
6072
|
-
const bundled =
|
|
6073
|
-
if (!
|
|
6276
|
+
const bundled = join15(pkgRoot2, entry.dir);
|
|
6277
|
+
if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
|
|
6074
6278
|
say(`copying to ${target} (it's yours \u2014 edit it)`);
|
|
6075
6279
|
try {
|
|
6076
6280
|
cpSync(bundled, target, {
|
|
@@ -6089,7 +6293,7 @@ async function loadDemo(entry, opts) {
|
|
|
6089
6293
|
} else {
|
|
6090
6294
|
say(`reusing ${target}`);
|
|
6091
6295
|
}
|
|
6092
|
-
if (
|
|
6296
|
+
if (existsSync12(join15(target, "package.json")) && !existsSync12(join15(target, "node_modules"))) {
|
|
6093
6297
|
say("npm install\u2026");
|
|
6094
6298
|
if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
|
|
6095
6299
|
return { ok: false, error: `npm install failed in ${target}` };
|
|
@@ -6101,19 +6305,19 @@ async function loadDemo(entry, opts) {
|
|
|
6101
6305
|
return { ok: false, error: `setup failed (${entry.setup})` };
|
|
6102
6306
|
}
|
|
6103
6307
|
}
|
|
6104
|
-
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) =>
|
|
6308
|
+
return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join15(target, d)) : [target] };
|
|
6105
6309
|
}
|
|
6106
6310
|
function runStep(cmd, args, opts = {}) {
|
|
6107
6311
|
return new Promise((res) => {
|
|
6108
|
-
const child =
|
|
6312
|
+
const child = spawn5(cmd, args, { stdio: "inherit", cwd: opts.cwd, shell: opts.shell ?? false });
|
|
6109
6313
|
child.on("error", () => res(-1));
|
|
6110
6314
|
child.on("close", (code) => res(code ?? 1));
|
|
6111
6315
|
});
|
|
6112
6316
|
}
|
|
6113
6317
|
|
|
6114
6318
|
// src/server.ts
|
|
6115
|
-
var pkgRoot =
|
|
6116
|
-
var webRoot =
|
|
6319
|
+
var pkgRoot = join16(dirname4(fileURLToPath3(import.meta.url)), "..");
|
|
6320
|
+
var webRoot = join16(pkgRoot, "web");
|
|
6117
6321
|
var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
|
|
6118
6322
|
function optsFromQuery(url, tierEnvVar, projectDir) {
|
|
6119
6323
|
const q = url.searchParams;
|
|
@@ -6163,7 +6367,7 @@ function tierFailure(tier, message) {
|
|
|
6163
6367
|
function beholdVersion() {
|
|
6164
6368
|
try {
|
|
6165
6369
|
const here = dirname4(fileURLToPath3(import.meta.url));
|
|
6166
|
-
return JSON.parse(
|
|
6370
|
+
return JSON.parse(readFileSync13(join16(here, "..", "package.json"), "utf8")).version ?? "unknown";
|
|
6167
6371
|
} catch {
|
|
6168
6372
|
return "unknown";
|
|
6169
6373
|
}
|
|
@@ -6208,7 +6412,7 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
|
|
|
6208
6412
|
}
|
|
6209
6413
|
}
|
|
6210
6414
|
function carveRoutes(app, reportPath, demo) {
|
|
6211
|
-
const load = () => readCarveReport(reportPath, (p) =>
|
|
6415
|
+
const load = () => readCarveReport(reportPath, (p) => readFileSync13(p, "utf8"));
|
|
6212
6416
|
const demoBlock = () => carveWriteBlock(demo);
|
|
6213
6417
|
const demoInfo = () => {
|
|
6214
6418
|
if (!demo) return null;
|
|
@@ -6224,6 +6428,9 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6224
6428
|
fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
|
|
6225
6429
|
runnable: !block,
|
|
6226
6430
|
...block ? { reason: block } : {},
|
|
6431
|
+
// The live tier, when this boot armed it: the scratch endpoint the
|
|
6432
|
+
// stepper names, and the fact the plan button exists at all.
|
|
6433
|
+
...demo.live ? { live: { endpoint: demo.live.endpoint, container: demo.live.container, applied: demo.live.applied } } : {},
|
|
6227
6434
|
...demo.degraded ? { degraded: demo.degraded } : {},
|
|
6228
6435
|
buildCaveat: BUILD_CAVEAT
|
|
6229
6436
|
};
|
|
@@ -6232,11 +6439,21 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6232
6439
|
const parsed = load();
|
|
6233
6440
|
return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422);
|
|
6234
6441
|
});
|
|
6235
|
-
|
|
6442
|
+
let appGraph;
|
|
6443
|
+
const appGraphOnce = () => {
|
|
6444
|
+
if (!demo) return Promise.resolve(null);
|
|
6445
|
+
appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative3(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
|
|
6446
|
+
return appGraph;
|
|
6447
|
+
};
|
|
6448
|
+
app.get("/api/graph", async (c) => {
|
|
6236
6449
|
const parsed = load();
|
|
6237
6450
|
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
6238
|
-
const
|
|
6239
|
-
const
|
|
6451
|
+
const tfIr = carveReportToIr(parsed.report);
|
|
6452
|
+
const appSide = await appGraphOnce();
|
|
6453
|
+
const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
|
|
6454
|
+
tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
6455
|
+
appTitle: `${appSide.label} \u2014 chant`
|
|
6456
|
+
}) : { ...renderBanded(tfIr), ir: tfIr };
|
|
6240
6457
|
return c.json({
|
|
6241
6458
|
ir,
|
|
6242
6459
|
svg,
|
|
@@ -6248,10 +6465,44 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6248
6465
|
carve: true,
|
|
6249
6466
|
// A demo whose own advisor run failed says so on the statusbar, not
|
|
6250
6467
|
// only in the terminal the viewer isn't looking at.
|
|
6251
|
-
note: carveNote(parsed.report,
|
|
6468
|
+
note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
|
|
6252
6469
|
}
|
|
6253
6470
|
});
|
|
6254
6471
|
});
|
|
6472
|
+
app.get("/carve/morph", async (c) => {
|
|
6473
|
+
const parsed = load();
|
|
6474
|
+
if (!parsed.ok) return c.json(parsed.refusal, 422);
|
|
6475
|
+
const select = c.req.query("select") ?? "";
|
|
6476
|
+
const ranked = (parsed.report.resources ?? []).some((r) => r.address === select);
|
|
6477
|
+
if (!ranked) {
|
|
6478
|
+
return c.json(
|
|
6479
|
+
{
|
|
6480
|
+
error: `"${select}" is not a ranked address in this report`,
|
|
6481
|
+
code: "carve_bad_select",
|
|
6482
|
+
remedy: "pass ?select=<address> naming a resource the report ranks \u2014 GET /api/carve lists them"
|
|
6483
|
+
},
|
|
6484
|
+
400
|
|
6485
|
+
);
|
|
6486
|
+
}
|
|
6487
|
+
const appSide = await appGraphOnce();
|
|
6488
|
+
if (!appSide) {
|
|
6489
|
+
return c.json(
|
|
6490
|
+
{
|
|
6491
|
+
error: "the morph needs the demo estate's chant project, and this server isn't serving a demo copy",
|
|
6492
|
+
code: "carve_no_demo",
|
|
6493
|
+
remedy: "run `behold demo carve` \u2014 the bundled estate has the chant box the card moves into"
|
|
6494
|
+
},
|
|
6495
|
+
404
|
|
6496
|
+
);
|
|
6497
|
+
}
|
|
6498
|
+
const tfIr = carveReportToIr(parsed.report);
|
|
6499
|
+
const html = renderCarveMorph(tfIr, appSide.ir, [select], {
|
|
6500
|
+
tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
|
|
6501
|
+
appTitle: `${appSide.label} \u2014 chant`,
|
|
6502
|
+
title: `carve \u2014 ${select}`
|
|
6503
|
+
});
|
|
6504
|
+
return c.html(html);
|
|
6505
|
+
});
|
|
6255
6506
|
app.get("/api/project", (c) => {
|
|
6256
6507
|
const parsed = load();
|
|
6257
6508
|
return c.json({
|
|
@@ -6314,6 +6565,17 @@ function carveRoutes(app, reportPath, demo) {
|
|
|
6314
6565
|
};
|
|
6315
6566
|
app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
|
|
6316
6567
|
app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
|
|
6568
|
+
app.post("/api/carve/plan", async (c) => {
|
|
6569
|
+
const block = demoBlock();
|
|
6570
|
+
if (block) {
|
|
6571
|
+
return c.json({ error: block, code: "read-only", remedy: "The live plan runs inside a demo copy \u2014 `behold demo carve --live`." }, 403);
|
|
6572
|
+
}
|
|
6573
|
+
if (!(c.req.header("content-type") ?? "").includes("application/json")) {
|
|
6574
|
+
return c.json({ error: "send application/json", code: "carve-action", remedy: "POST {} with content-type application/json" }, 415);
|
|
6575
|
+
}
|
|
6576
|
+
const result = await runCarvePlan(demo);
|
|
6577
|
+
return result.ok ? c.json(result) : c.json(result.refusal, 422);
|
|
6578
|
+
});
|
|
6317
6579
|
app.get("/api/substrates", (c) => c.json({ substrates: [] }));
|
|
6318
6580
|
app.get("/api/history", (c) => c.json({ commits: [] }));
|
|
6319
6581
|
app.get("/api/resources", (c) => c.json({ byComponent: {} }));
|
|
@@ -6410,9 +6672,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6410
6672
|
return c.json({ started: true, name, ran: label });
|
|
6411
6673
|
});
|
|
6412
6674
|
app.post("/api/local/reset", (c) => {
|
|
6413
|
-
const down =
|
|
6414
|
-
const up =
|
|
6415
|
-
if (!
|
|
6675
|
+
const down = join16(cfg.projectDir, "scripts/local/local-down.sh");
|
|
6676
|
+
const up = join16(cfg.projectDir, "scripts/local/local-up.sh");
|
|
6677
|
+
if (!existsSync13(down) || !existsSync13(up)) {
|
|
6416
6678
|
return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
|
|
6417
6679
|
}
|
|
6418
6680
|
if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
|
|
@@ -6490,8 +6752,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6490
6752
|
if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
|
|
6491
6753
|
const body = await c.req.json().catch(() => ({}));
|
|
6492
6754
|
const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
|
|
6493
|
-
if (!dir || !
|
|
6494
|
-
if (!
|
|
6755
|
+
if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
|
|
6756
|
+
if (!existsSync13(join16(dir, "chant.config.ts"))) {
|
|
6495
6757
|
return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
|
|
6496
6758
|
}
|
|
6497
6759
|
switchServedProject([dir]);
|
|
@@ -6535,7 +6797,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6535
6797
|
fetches: fetchesFromNetwork(e),
|
|
6536
6798
|
...e.repo ? { repo: e.repo } : {},
|
|
6537
6799
|
target,
|
|
6538
|
-
loaded:
|
|
6800
|
+
loaded: existsSync13(target),
|
|
6539
6801
|
satisfiable: missing.length === 0,
|
|
6540
6802
|
// #254: the carve walkthrough runs fine here — it just isn't a project
|
|
6541
6803
|
// to switch INTO. Carve mode claims `/api/graph` and `/api/project` at
|
|
@@ -6579,7 +6841,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6579
6841
|
const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
|
|
6580
6842
|
if (!loaded.ok) return c.json({ error: loaded.error }, 500);
|
|
6581
6843
|
const dirs = loaded.serveDirs;
|
|
6582
|
-
if (!
|
|
6844
|
+
if (!existsSync13(join16(dirs[0], "chant.config.ts"))) {
|
|
6583
6845
|
return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
|
|
6584
6846
|
}
|
|
6585
6847
|
if (entry.serve.local) {
|
|
@@ -6785,7 +7047,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
6785
7047
|
srcCompositeEdgesAttached = 0;
|
|
6786
7048
|
}
|
|
6787
7049
|
}
|
|
6788
|
-
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !
|
|
7050
|
+
if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join16(cfg.projectDir, "chant.config.ts"))) {
|
|
6789
7051
|
return c.json(noProjectError(cfg.projectDir), 404);
|
|
6790
7052
|
}
|
|
6791
7053
|
const radial = new URL(c.req.url).searchParams.get("radial") === "1";
|
|
@@ -7125,7 +7387,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
7125
7387
|
});
|
|
7126
7388
|
const rel = relative3(process.cwd(), webRoot) || ".";
|
|
7127
7389
|
app.use("/*", serveStatic({ root: rel }));
|
|
7128
|
-
app.get("/", serveStatic({ path:
|
|
7390
|
+
app.get("/", serveStatic({ path: join16(rel, "index.html") }));
|
|
7129
7391
|
return app;
|
|
7130
7392
|
}
|
|
7131
7393
|
async function bootLocalEmulators(dir, who) {
|
|
@@ -7154,7 +7416,7 @@ async function bootLocalEmulators(dir, who) {
|
|
|
7154
7416
|
async function startServer(cfg) {
|
|
7155
7417
|
if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
|
|
7156
7418
|
for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
|
|
7157
|
-
if (
|
|
7419
|
+
if (existsSync13(join16(dir, "chant.config.ts"))) addRecent(dir);
|
|
7158
7420
|
}
|
|
7159
7421
|
const broadcaster = new Broadcaster();
|
|
7160
7422
|
const frames = new FrameBuffer();
|
|
@@ -7165,10 +7427,10 @@ async function startServer(cfg) {
|
|
|
7165
7427
|
});
|
|
7166
7428
|
const app = createApp(cfg, broadcaster, frames, runner);
|
|
7167
7429
|
const autoSync = cfg.autoSync ?? "off";
|
|
7168
|
-
const
|
|
7430
|
+
const capture2 = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
|
|
7169
7431
|
const onEstateChange = () => {
|
|
7170
7432
|
broadcaster.emit("changed");
|
|
7171
|
-
void
|
|
7433
|
+
void capture2();
|
|
7172
7434
|
};
|
|
7173
7435
|
const onPollDrift = (movedLexicons) => {
|
|
7174
7436
|
onEstateChange();
|
|
@@ -7215,18 +7477,24 @@ async function startServer(cfg) {
|
|
|
7215
7477
|
};
|
|
7216
7478
|
process.stdout.write(` switched \u2192 ${dir}
|
|
7217
7479
|
`);
|
|
7218
|
-
void
|
|
7480
|
+
void capture2();
|
|
7219
7481
|
};
|
|
7220
|
-
if (!carve) void
|
|
7482
|
+
if (!carve) void capture2();
|
|
7221
7483
|
let shuttingDown = false;
|
|
7222
7484
|
const shutdown = () => {
|
|
7223
7485
|
if (shuttingDown) return;
|
|
7224
7486
|
shuttingDown = true;
|
|
7225
7487
|
stopWatch();
|
|
7226
7488
|
stopPoll();
|
|
7227
|
-
const
|
|
7228
|
-
|
|
7229
|
-
|
|
7489
|
+
const downs = [];
|
|
7490
|
+
if (cfg.local && cfg.emulators && cfg.emulators.length) {
|
|
7491
|
+
downs.push(
|
|
7492
|
+
emulatorDown(cfg.projectDir).catch((err) => process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}
|
|
7493
|
+
`))
|
|
7494
|
+
);
|
|
7495
|
+
}
|
|
7496
|
+
if (cfg.carveDemo?.live) downs.push(teardownScratchFloci().catch(() => void 0));
|
|
7497
|
+
void Promise.all(downs).finally(() => process.exit(0));
|
|
7230
7498
|
};
|
|
7231
7499
|
process.on("SIGINT", shutdown);
|
|
7232
7500
|
process.on("SIGTERM", shutdown);
|
|
@@ -7238,7 +7506,8 @@ async function startServer(cfg) {
|
|
|
7238
7506
|
green = carve now, amber = boundary work, grey = leave in Terraform.
|
|
7239
7507
|
` + (cfg.carveDemo ? ` walkthrough: the panel's Carve tab \u2014 advise \u2192 pick \u2192 emit \u2192 bridge \u2192 handoff \u2192 done.
|
|
7240
7508
|
Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.
|
|
7241
|
-
` + (cfg.carveDemo.
|
|
7509
|
+
` + (cfg.carveDemo.live ? ` live: scratch Floci at ${cfg.carveDemo.live.endpoint} \u2014 deleted on Ctrl-C.
|
|
7510
|
+
` : "") + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}
|
|
7242
7511
|
` : "") : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.
|
|
7243
7512
|
`)
|
|
7244
7513
|
);
|
|
@@ -7275,8 +7544,8 @@ async function startServer(cfg) {
|
|
|
7275
7544
|
}
|
|
7276
7545
|
|
|
7277
7546
|
// src/export.ts
|
|
7278
|
-
import { mkdirSync as mkdirSync4, writeFileSync as
|
|
7279
|
-
import { join as
|
|
7547
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
|
|
7548
|
+
import { join as join17, dirname as dirname5, basename } from "node:path";
|
|
7280
7549
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
7281
7550
|
var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
|
|
7282
7551
|
function canonicalKey(path, params) {
|
|
@@ -7324,7 +7593,7 @@ function captureKeys(axes) {
|
|
|
7324
7593
|
return [...keys];
|
|
7325
7594
|
}
|
|
7326
7595
|
function webDir() {
|
|
7327
|
-
return
|
|
7596
|
+
return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
|
|
7328
7597
|
}
|
|
7329
7598
|
function workerName(project, override) {
|
|
7330
7599
|
const raw = override ?? `behold-${basename(project)}`;
|
|
@@ -7335,7 +7604,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7335
7604
|
const app = createApp({ ...cfg, layoutWrites: false });
|
|
7336
7605
|
const proj = await (await app.request("/api/project")).json();
|
|
7337
7606
|
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
|
|
7338
|
-
const snapDir =
|
|
7607
|
+
const snapDir = join17(outDir, "snapshots");
|
|
7339
7608
|
mkdirSync4(snapDir, { recursive: true });
|
|
7340
7609
|
const keyToFile = {};
|
|
7341
7610
|
let ok = 0;
|
|
@@ -7344,7 +7613,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7344
7613
|
const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
|
|
7345
7614
|
const body = await res.text();
|
|
7346
7615
|
const file = slug2(key);
|
|
7347
|
-
|
|
7616
|
+
writeFileSync4(join17(snapDir, file), body);
|
|
7348
7617
|
keyToFile[key] = `snapshots/${file}`;
|
|
7349
7618
|
if (res.ok) ok++;
|
|
7350
7619
|
else failed++;
|
|
@@ -7356,21 +7625,21 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
7356
7625
|
axes,
|
|
7357
7626
|
keyToFile
|
|
7358
7627
|
};
|
|
7359
|
-
|
|
7360
|
-
const html =
|
|
7628
|
+
writeFileSync4(join17(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
7629
|
+
const html = readFileSync14(join17(webDir(), "index.html"), "utf8").replace(
|
|
7361
7630
|
/<\/head>/i,
|
|
7362
7631
|
` <script>window.__BEHOLD_STATIC__ = true;</script>
|
|
7363
7632
|
</head>`
|
|
7364
7633
|
);
|
|
7365
|
-
|
|
7634
|
+
writeFileSync4(join17(outDir, "index.html"), html);
|
|
7366
7635
|
for (const f of readdirSync5(webDir())) {
|
|
7367
7636
|
if (f === "index.html") continue;
|
|
7368
|
-
copyFileSync(
|
|
7637
|
+
copyFileSync(join17(webDir(), f), join17(outDir, f));
|
|
7369
7638
|
}
|
|
7370
|
-
|
|
7639
|
+
writeFileSync4(join17(outDir, "README.md"), BUNDLE_README);
|
|
7371
7640
|
const name = workerName(cfg.projectDir, opts.name);
|
|
7372
|
-
|
|
7373
|
-
|
|
7641
|
+
writeFileSync4(
|
|
7642
|
+
join17(outDir, "wrangler.jsonc"),
|
|
7374
7643
|
JSON.stringify(
|
|
7375
7644
|
{ $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
|
|
7376
7645
|
null,
|
|
@@ -7637,6 +7906,7 @@ var USAGE = `behold \u2014 a live control plane on chant (read-only core)
|
|
|
7637
7906
|
|
|
7638
7907
|
Usage:
|
|
7639
7908
|
behold demo [name] [target-dir] [--port <n>] [--list]
|
|
7909
|
+
behold demo carve [--live] [--port <n>]
|
|
7640
7910
|
behold doctor [project-dir] [--json]
|
|
7641
7911
|
behold preview [project-dir] [--port <n>] [--emulator]
|
|
7642
7912
|
behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
|
|
@@ -7669,7 +7939,10 @@ Usage:
|
|
|
7669
7939
|
up on a throwaway k3d cluster instead. \`behold demo carve\` is the
|
|
7670
7940
|
odd one out: no cluster, no Docker, no cloud \u2014 a half-migrated
|
|
7671
7941
|
Terraform/chant estate plus the six-step carve walkthrough on the
|
|
7672
|
-
panel's Carve tab.
|
|
7942
|
+
panel's Carve tab. Its \`--live\` tier flips that: docker + terraform
|
|
7943
|
+
on PATH, a scratch Floci booted and deleted on exit, the starred
|
|
7944
|
+
resources REALLY applied, and a real \`terraform plan\` button at
|
|
7945
|
+
Handoff. Needs Docker (and per-demo tools --list names).
|
|
7673
7946
|
Loaded demos land in the panel's recents, so switching between them
|
|
7674
7947
|
is the Scope tab \u2014 which lists this whole catalog too (#268), one
|
|
7675
7948
|
click from any served project.
|
|
@@ -7836,7 +8109,7 @@ function warnIfNotChantProject(dir) {
|
|
|
7836
8109
|
if (shape.kind === "estate") {
|
|
7837
8110
|
process.stderr.write(
|
|
7838
8111
|
`behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
|
|
7839
|
-
Serve its members composed: behold serve ${shape.members.map((m) =>
|
|
8112
|
+
Serve its members composed: behold serve ${shape.members.map((m) => join18(dir, m)).join(" ")}
|
|
7840
8113
|
`
|
|
7841
8114
|
);
|
|
7842
8115
|
return;
|
|
@@ -7872,7 +8145,7 @@ async function runCarve(rest) {
|
|
|
7872
8145
|
process.exit(2);
|
|
7873
8146
|
}
|
|
7874
8147
|
const reportPath = resolve7(fileArg);
|
|
7875
|
-
const parsed = readCarveReport(reportPath, (p) =>
|
|
8148
|
+
const parsed = readCarveReport(reportPath, (p) => readFileSync15(p, "utf8"));
|
|
7876
8149
|
if (!parsed.ok) {
|
|
7877
8150
|
process.stderr.write(`behold carve: ${parsed.refusal.error}
|
|
7878
8151
|
${parsed.refusal.remedy}
|
|
@@ -7899,7 +8172,7 @@ async function runDoctor(rest) {
|
|
|
7899
8172
|
}
|
|
7900
8173
|
}
|
|
7901
8174
|
const dir = dirArg ?? ".";
|
|
7902
|
-
if (!
|
|
8175
|
+
if (!existsSync14(resolve7(dir))) {
|
|
7903
8176
|
process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
|
|
7904
8177
|
`);
|
|
7905
8178
|
process.exit(2);
|
|
@@ -7909,14 +8182,16 @@ async function runDoctor(rest) {
|
|
|
7909
8182
|
if (!report.ok) process.exitCode = 1;
|
|
7910
8183
|
}
|
|
7911
8184
|
async function runDemo(rest) {
|
|
7912
|
-
const pkgRoot2 =
|
|
8185
|
+
const pkgRoot2 = join18(dirname6(fileURLToPath5(import.meta.url)), "..");
|
|
7913
8186
|
const registry = loadDemoRegistry(pkgRoot2);
|
|
7914
8187
|
let port = 4600;
|
|
8188
|
+
let live = false;
|
|
7915
8189
|
let name;
|
|
7916
8190
|
let dirArg;
|
|
7917
8191
|
for (let i = 0; i < rest.length; i++) {
|
|
7918
8192
|
const a = rest[i];
|
|
7919
8193
|
if (a === "--port") port = Number(rest[++i]);
|
|
8194
|
+
else if (a === "--live") live = true;
|
|
7920
8195
|
else if (a === "--list") {
|
|
7921
8196
|
if (!registry.length) {
|
|
7922
8197
|
process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n");
|
|
@@ -7969,9 +8244,14 @@ async function runDemo(rest) {
|
|
|
7969
8244
|
process.exit(1);
|
|
7970
8245
|
}
|
|
7971
8246
|
if (entry.serve.carve) {
|
|
7972
|
-
await serveCarveDemo(target, entry.serve.carve, port);
|
|
8247
|
+
await serveCarveDemo(target, entry.serve.carve, port, live);
|
|
7973
8248
|
return;
|
|
7974
8249
|
}
|
|
8250
|
+
if (live) {
|
|
8251
|
+
process.stderr.write(`behold demo ${entry.name}: --live is the carve demo's tier \u2014 only \`behold demo carve --live\` takes it.
|
|
8252
|
+
`);
|
|
8253
|
+
process.exit(2);
|
|
8254
|
+
}
|
|
7975
8255
|
process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
|
|
7976
8256
|
`);
|
|
7977
8257
|
const serveArgs = ["serve", ...loaded.serveDirs, "--port", String(port)];
|
|
@@ -7981,18 +8261,53 @@ async function runDemo(rest) {
|
|
|
7981
8261
|
}
|
|
7982
8262
|
function spawnStep(cmd, args, cwd) {
|
|
7983
8263
|
return new Promise((res) => {
|
|
7984
|
-
const child =
|
|
8264
|
+
const child = spawn6(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" });
|
|
7985
8265
|
child.on("error", () => res(-1));
|
|
7986
8266
|
child.on("close", (code) => res(code ?? 1));
|
|
7987
8267
|
});
|
|
7988
8268
|
}
|
|
7989
|
-
async function serveCarveDemo(target, carve, port) {
|
|
8269
|
+
async function serveCarveDemo(target, carve, port, live = false) {
|
|
7990
8270
|
const at = (rel) => resolve7(target, rel);
|
|
7991
8271
|
const project = at(carve.project);
|
|
7992
8272
|
const from = at(carve.from);
|
|
7993
8273
|
const state = carve.state ? at(carve.state) : void 0;
|
|
7994
8274
|
const committed = at(carve.report);
|
|
7995
|
-
|
|
8275
|
+
let liveInfo;
|
|
8276
|
+
if (live) {
|
|
8277
|
+
for (const bin of ["docker", "terraform"]) {
|
|
8278
|
+
const probe2 = spawnSync2(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
|
|
8279
|
+
if (probe2.status !== 0) {
|
|
8280
|
+
process.stderr.write(`behold demo carve --live: needs ${bin} on PATH (the offline tier doesn't \u2014 drop --live).
|
|
8281
|
+
`);
|
|
8282
|
+
process.exit(2);
|
|
8283
|
+
}
|
|
8284
|
+
}
|
|
8285
|
+
process.stdout.write(`behold demo carve --live \u2192 scratch Floci (${LIVE_CONTAINER}, :${LIVE_PORT}, deleted on exit)\u2026
|
|
8286
|
+
`);
|
|
8287
|
+
const bootErr = await bootScratchFloci();
|
|
8288
|
+
if (bootErr) {
|
|
8289
|
+
process.stderr.write(`behold demo carve --live: ${bootErr}
|
|
8290
|
+
`);
|
|
8291
|
+
process.exit(1);
|
|
8292
|
+
}
|
|
8293
|
+
process.stdout.write("behold demo carve --live \u2192 terraform init + apply (the starred resources, into the scratch Floci)\u2026\n");
|
|
8294
|
+
if (state) rmSync2(state, { force: true });
|
|
8295
|
+
const applyErr = await applyIntoFloci(from, spawnStep);
|
|
8296
|
+
if (applyErr) {
|
|
8297
|
+
await teardownScratchFloci();
|
|
8298
|
+
process.stderr.write(`behold demo carve --live: ${applyErr}
|
|
8299
|
+
`);
|
|
8300
|
+
process.exit(1);
|
|
8301
|
+
}
|
|
8302
|
+
liveInfo = {
|
|
8303
|
+
container: LIVE_CONTAINER,
|
|
8304
|
+
port: LIVE_PORT,
|
|
8305
|
+
endpoint: `http://localhost:${LIVE_PORT}`,
|
|
8306
|
+
applied: LIVE_TARGETS
|
|
8307
|
+
};
|
|
8308
|
+
process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
|
|
8309
|
+
}
|
|
8310
|
+
if (existsSync14(join18(project, "package.json")) && !existsSync14(join18(project, "node_modules"))) {
|
|
7996
8311
|
process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
|
|
7997
8312
|
`);
|
|
7998
8313
|
const code = await spawnStep("npm", ["install"], project);
|
|
@@ -8003,7 +8318,7 @@ async function serveCarveDemo(target, carve, port) {
|
|
|
8003
8318
|
}
|
|
8004
8319
|
}
|
|
8005
8320
|
let degraded;
|
|
8006
|
-
if (!
|
|
8321
|
+
if (!existsSync14(join18(target, "node_modules", "@cdktf", "hcl2json"))) {
|
|
8007
8322
|
process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
|
|
8008
8323
|
const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
|
|
8009
8324
|
if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
|
|
@@ -8019,7 +8334,7 @@ async function serveCarveDemo(target, carve, port) {
|
|
|
8019
8334
|
);
|
|
8020
8335
|
if (code !== 0) degraded = `chant carve advise exited ${code}`;
|
|
8021
8336
|
}
|
|
8022
|
-
const serving = !degraded &&
|
|
8337
|
+
const serving = !degraded && existsSync14(report) ? report : committed;
|
|
8023
8338
|
if (degraded) {
|
|
8024
8339
|
process.stderr.write(
|
|
8025
8340
|
`behold demo carve: ${degraded}
|
|
@@ -8027,7 +8342,7 @@ async function serveCarveDemo(target, carve, port) {
|
|
|
8027
8342
|
`
|
|
8028
8343
|
);
|
|
8029
8344
|
}
|
|
8030
|
-
if (!
|
|
8345
|
+
if (!existsSync14(serving)) {
|
|
8031
8346
|
process.stderr.write(`behold demo carve: no carve report at ${serving}
|
|
8032
8347
|
`);
|
|
8033
8348
|
process.exit(2);
|
|
@@ -8044,6 +8359,7 @@ async function serveCarveDemo(target, carve, port) {
|
|
|
8044
8359
|
...state ? { state } : {},
|
|
8045
8360
|
project,
|
|
8046
8361
|
out: at(carve.out),
|
|
8362
|
+
...liveInfo ? { live: liveInfo } : {},
|
|
8047
8363
|
...degraded ? { degraded: `${degraded} \u2014 showing the committed report shipped with the demo.` } : {}
|
|
8048
8364
|
},
|
|
8049
8365
|
port
|
|
@@ -8072,7 +8388,7 @@ async function runPreview(rest) {
|
|
|
8072
8388
|
process.exit(2);
|
|
8073
8389
|
}
|
|
8074
8390
|
const projectDir = resolve7(dirArg ?? process.cwd());
|
|
8075
|
-
if (!
|
|
8391
|
+
if (!existsSync14(projectDir)) {
|
|
8076
8392
|
process.stderr.write(`behold preview: project not found at ${projectDir}
|
|
8077
8393
|
`);
|
|
8078
8394
|
process.exit(2);
|
|
@@ -8111,7 +8427,7 @@ async function runExportCmd(rest) {
|
|
|
8111
8427
|
injectEmulatorEnv(env);
|
|
8112
8428
|
env ??= "local";
|
|
8113
8429
|
}
|
|
8114
|
-
if (!
|
|
8430
|
+
if (!existsSync14(projectDir)) {
|
|
8115
8431
|
process.stderr.write(`behold export: project not found at ${projectDir}
|
|
8116
8432
|
`);
|
|
8117
8433
|
process.exit(2);
|
package/example-carve/README.md
CHANGED
|
@@ -170,14 +170,21 @@ no AWS account and no network. The only dependency beyond this directory is
|
|
|
170
170
|
`@cdktf/hcl2json`, which chant lazy-loads and names in its error if absent. This
|
|
171
171
|
tier guarantees the first thirty seconds of the video.
|
|
172
172
|
|
|
173
|
-
**`--live` is the full video.**
|
|
174
|
-
`
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
it, chant adopted it, and it
|
|
173
|
+
**`--live` is the full video.** `behold demo carve --live` boots a scratch
|
|
174
|
+
Floci (`behold-carve-floci`, its own port, deleted on exit), arms
|
|
175
|
+
`legacy-tf/floci-override.tf.disabled` (see its header) into the demo copy, and
|
|
176
|
+
`terraform apply -target`s the starred resources into it — so the tfstate the
|
|
177
|
+
advisor reads is one terraform really wrote. At Handoff the stepper gains a
|
|
178
|
+
read-only **terraform plan** button: before `state rm` it shows a no-op, after
|
|
179
|
+
it the carved bucket is simply gone from Terraform's world — `0 to destroy`.
|
|
180
|
+
The line the tier exists for is "Terraform forgot it, chant adopted it, and it
|
|
181
|
+
never blinked."
|
|
182
|
+
|
|
183
|
+
One beat stays caption rather than footage for now: `chant lifecycle diff
|
|
184
|
+
--live` reading the bucket clean while Terraform still owns it. chant's AWS
|
|
185
|
+
observe is CFN-stack-scoped by logical id, so a Terraform-owned resource reads
|
|
186
|
+
confirmed-missing regardless of its existence — chant#1647 tracks the
|
|
187
|
+
physical-identity read path that unlocks it.
|
|
181
188
|
|
|
182
189
|
The live tier needs `docker` and `terraform` on PATH, boots its own throwaway
|
|
183
190
|
Floci and deletes it after, and never touches an existing `floci*` container.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@hono/node-server": "^2.0.5",
|
|
38
38
|
"@intentius/chant": "^0.38.0",
|
|
39
39
|
"@intentius/chant-k8s-client": "^0.44.5",
|
|
40
|
-
"@intentius/pinhole": "^0.3.
|
|
40
|
+
"@intentius/pinhole": "^0.3.4",
|
|
41
41
|
"hono": "^4.12.34"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/web/app.js
CHANGED
|
@@ -1233,6 +1233,32 @@ const carveActions = {
|
|
|
1233
1233
|
},
|
|
1234
1234
|
runEmit: () => runCarveStep("emit"),
|
|
1235
1235
|
runBridge: () => runCarveStep("bridge"),
|
|
1236
|
+
async runPlan() {
|
|
1237
|
+
if (carveState.busy) return;
|
|
1238
|
+
carveState.busy = "plan";
|
|
1239
|
+
carveState.error = null;
|
|
1240
|
+
renderPanelCarve();
|
|
1241
|
+
try {
|
|
1242
|
+
const res = await fetch("/api/carve/plan", {
|
|
1243
|
+
method: "POST",
|
|
1244
|
+
headers: { "content-type": "application/json" },
|
|
1245
|
+
body: "{}",
|
|
1246
|
+
});
|
|
1247
|
+
const body = await res.json().catch(() => ({ error: "plan returned an unreadable body", remedy: "" }));
|
|
1248
|
+
if (!res.ok || body.error) {
|
|
1249
|
+
carveState.error = { step: "handoff", ...body };
|
|
1250
|
+
showToast(`✗ terraform plan: ${body.error || res.status}`, false);
|
|
1251
|
+
} else {
|
|
1252
|
+
carveState.plan = body;
|
|
1253
|
+
showToast(body.noDestroy ? "✓ plan: nothing to destroy" : "✗ plan wants to destroy something", body.noDestroy);
|
|
1254
|
+
}
|
|
1255
|
+
} catch (err) {
|
|
1256
|
+
carveState.error = { step: "handoff", error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
|
|
1257
|
+
} finally {
|
|
1258
|
+
carveState.busy = null;
|
|
1259
|
+
renderPanelCarve();
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1236
1262
|
copy(text, el) {
|
|
1237
1263
|
const done = () => {
|
|
1238
1264
|
el.dataset.copied = "1";
|
package/web/carve-steps.js
CHANGED
|
@@ -63,7 +63,10 @@ export function initialCarveState() {
|
|
|
63
63
|
bridge: null,
|
|
64
64
|
/** Set when the user says they ran the handoff commands. */
|
|
65
65
|
handoff: false,
|
|
66
|
-
/**
|
|
66
|
+
/** POST /api/carve/plan's answer (live tier only). Estate-wide, so a new
|
|
67
|
+
* pick keeps it — the plan is about the armed Terraform, not the pick. */
|
|
68
|
+
plan: null,
|
|
69
|
+
/** Which step is mid-run ("emit" | "bridge" | "plan" | null). */
|
|
67
70
|
busy: null,
|
|
68
71
|
/** The last structured refusal, keyed by step id. */
|
|
69
72
|
error: null,
|
|
@@ -378,7 +381,7 @@ export function renderCarvePanel(host, state, ctx, actions) {
|
|
|
378
381
|
pick: () => renderPick(body, state, ctx, actions),
|
|
379
382
|
emit: () => renderEmit(body, state, ctx, actions),
|
|
380
383
|
bridge: () => renderBridge(body, state, ctx, actions),
|
|
381
|
-
handoff: () => renderHandoff(body, state, actions),
|
|
384
|
+
handoff: () => renderHandoff(body, state, ctx, actions),
|
|
382
385
|
done: () => renderDone(body, state, ctx, actions),
|
|
383
386
|
};
|
|
384
387
|
renderers[step.id]();
|
|
@@ -426,6 +429,17 @@ function renderAdvise(body, ctx) {
|
|
|
426
429
|
`Terraform read from ${ctx.demo.fromLabel}/ in your demo copy. Emit and bridge write only into ${ctx.demo.outLabel}/.`,
|
|
427
430
|
),
|
|
428
431
|
);
|
|
432
|
+
if (ctx.demo.live) {
|
|
433
|
+
body.appendChild(
|
|
434
|
+
el(
|
|
435
|
+
"p",
|
|
436
|
+
"panel-muted carve-live-note",
|
|
437
|
+
`Live tier: the starred resources are REALLY applied into a scratch Floci (${ctx.demo.live.endpoint}, ` +
|
|
438
|
+
"deleted when this server exits) and the tfstate the advisor read was written by terraform, not shipped. " +
|
|
439
|
+
"The observe beat — chant reading the bucket live while Terraform still owns it — waits on chant#1647.",
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
429
443
|
}
|
|
430
444
|
}
|
|
431
445
|
|
|
@@ -544,12 +558,38 @@ function renderBridge(body, state, ctx, actions) {
|
|
|
544
558
|
for (const a of b.proposals || []) body.appendChild(artifactBlock(a, actions));
|
|
545
559
|
}
|
|
546
560
|
|
|
547
|
-
function renderHandoff(body, state, actions) {
|
|
561
|
+
function renderHandoff(body, state, ctx, actions) {
|
|
548
562
|
const runbook = state.bridge && state.bridge.runbook;
|
|
549
563
|
if (!runbook) {
|
|
550
564
|
body.appendChild(el("p", "panel-muted", blockedReason(state, "handoff") || "no runbook yet"));
|
|
551
565
|
return;
|
|
552
566
|
}
|
|
567
|
+
|
|
568
|
+
// The live tier's read-only beat (#254): a REAL `terraform plan`. Before the
|
|
569
|
+
// operator pastes `state rm` it shows a no-op; after, the carved resource is
|
|
570
|
+
// simply absent from Terraform's world — not destroyed. That verdict line is
|
|
571
|
+
// the reason the tier exists.
|
|
572
|
+
if (ctx.demo && ctx.demo.live) {
|
|
573
|
+
const planBtn = el("button", "act carve-plan", state.busy === "plan" ? "planning…" : "▶ terraform plan (read-only)");
|
|
574
|
+
planBtn.disabled = !!state.busy;
|
|
575
|
+
planBtn.title = "Runs terraform plan in the demo copy against the scratch Floci. Reads everything, changes nothing.";
|
|
576
|
+
planBtn.addEventListener("click", actions.runPlan);
|
|
577
|
+
body.appendChild(planBtn);
|
|
578
|
+
if (state.plan) {
|
|
579
|
+
const verdict = el("div", "carve-plan-verdict");
|
|
580
|
+
verdict.appendChild(el("code", null, state.plan.planLine));
|
|
581
|
+
verdict.appendChild(
|
|
582
|
+
el(
|
|
583
|
+
"p",
|
|
584
|
+
"panel-muted",
|
|
585
|
+
state.plan.noDestroy
|
|
586
|
+
? "0 to destroy — the resource never blinked."
|
|
587
|
+
: "The plan wants to destroy something. Stop and read it before touching the estate.",
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
body.appendChild(verdict);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
553
593
|
// The one place the walkthrough refuses to be a button, stated out loud.
|
|
554
594
|
const why = el("div", "carve-refusal carve-human");
|
|
555
595
|
why.appendChild(el("div", "carve-refusal-title", "These two are not buttons, on purpose."));
|
|
@@ -605,13 +645,20 @@ function renderDone(body, state, ctx, actions) {
|
|
|
605
645
|
if (!state.handoff) {
|
|
606
646
|
body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
|
|
607
647
|
}
|
|
648
|
+
if (state.pick) {
|
|
649
|
+
const watch = el("a", "act carve-morph-link", "▶ watch it move");
|
|
650
|
+
watch.href = "/carve/morph?select=" + encodeURIComponent(state.pick.node.id);
|
|
651
|
+
watch.target = "_blank";
|
|
652
|
+
watch.rel = "noopener";
|
|
653
|
+
watch.title = "The estate frame, morphing: the card slides out of the Terraform box and into the chant project box beside last month's carves.";
|
|
654
|
+
body.appendChild(watch);
|
|
655
|
+
}
|
|
608
656
|
body.appendChild(
|
|
609
657
|
el(
|
|
610
658
|
"p",
|
|
611
659
|
"panel-muted carve-honesty",
|
|
612
|
-
"Deferred to the follow-up: the
|
|
613
|
-
"
|
|
614
|
-
"plan showing no destroy) is the other half of that follow-up.",
|
|
660
|
+
"Deferred to the follow-up: the Floci `--live` tier — a real terraform apply into a scratch emulator, " +
|
|
661
|
+
"a real plan showing no destroy.",
|
|
615
662
|
),
|
|
616
663
|
);
|
|
617
664
|
const again = el("button", "act", "↺ start over");
|