@intentius/chant 0.41.20 → 0.42.1
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/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/explain.d.ts +11 -0
- package/dist/cli/handlers/explain.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts +11 -0
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/okf-lexicon.d.ts +36 -0
- package/dist/codegen/okf-lexicon.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +2 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +3 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/okf.d.ts +57 -0
- package/dist/okf.d.ts.map +1 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
- package/dist/terraform/adopt-state.d.ts +33 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +7 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +12 -0
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/excise.d.ts +31 -0
- package/dist/terraform/excise.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +12 -0
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +35 -5
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +89 -0
- package/dist/terraform/manifest.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +9 -4
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/terraform/unified-diff.d.ts +11 -0
- package/dist/terraform/unified-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +381 -0
- package/src/cli/commands/carve-apply.test.ts +94 -2
- package/src/cli/commands/carve-apply.ts +62 -11
- package/src/cli/commands/carve-bridge.test.ts +82 -4
- package/src/cli/commands/carve-bridge.ts +79 -11
- package/src/cli/commands/carve-emit-state.test.ts +110 -1
- package/src/cli/commands/carve-emit.ts +184 -8
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/carve-apply.ts +1 -0
- package/src/cli/handlers/components.ts +4 -2
- package/src/cli/handlers/explain.test.ts +93 -0
- package/src/cli/handlers/explain.ts +60 -0
- package/src/cli/handlers/lifecycle.ts +9 -7
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/registry.ts +2 -0
- package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
- package/src/codegen/docs-rule-scanning.ts +35 -14
- package/src/codegen/okf-lexicon.test.ts +248 -0
- package/src/codegen/okf-lexicon.ts +303 -0
- package/src/codegen/package.ts +29 -4
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
- package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
- package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
- package/src/components/verbs/sbom.ts +1 -1
- package/src/components/verbs/vuln-gate.test.ts +144 -1
- package/src/components/verbs/vuln-gate.ts +61 -10
- package/src/components/verbs/vuln-scan.test.ts +94 -0
- package/src/components/verbs/vuln-scan.ts +44 -9
- package/src/config.test.ts +21 -0
- package/src/config.ts +18 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +32 -13
- package/src/lexicon.ts +3 -0
- package/src/lifecycle/observe.test.ts +18 -0
- package/src/lifecycle/observe.ts +10 -3
- package/src/lifecycle/snapshot.ts +5 -4
- package/src/okf.test.ts +169 -0
- package/src/okf.ts +308 -0
- package/src/terraform/__fixtures__/build-graph.ts +25 -0
- package/src/terraform/adopt-state.test.ts +34 -1
- package/src/terraform/adopt-state.ts +69 -2
- package/src/terraform/bridge.test.ts +38 -14
- package/src/terraform/bridge.ts +48 -14
- package/src/terraform/carve.test.ts +9 -7
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/excise.test.ts +92 -0
- package/src/terraform/excise.ts +139 -0
- package/src/terraform/graduate.test.ts +73 -6
- package/src/terraform/graduate.ts +49 -0
- package/src/terraform/graph.test.ts +56 -10
- package/src/terraform/graph.ts +112 -45
- package/src/terraform/manifest.test.ts +105 -0
- package/src/terraform/manifest.ts +162 -0
- package/src/terraform/parse.test.ts +39 -1
- package/src/terraform/parse.ts +36 -9
- package/src/terraform/score.test.ts +8 -8
- package/src/terraform/state.test.ts +4 -4
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
2
3
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
4
|
import { tmpdir } from "os";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { carveBridge, formatCarveBridge } from "./carve-bridge";
|
|
6
7
|
import { loadHcl2json } from "../../terraform/parse";
|
|
8
|
+
import { readCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
7
9
|
|
|
8
10
|
let parserAvailable = false;
|
|
9
11
|
try {
|
|
@@ -39,10 +41,52 @@ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function manifestFor(target: string, dir: string): CarveManifest {
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
target,
|
|
48
|
+
from: dir,
|
|
49
|
+
boundary: {
|
|
50
|
+
target,
|
|
51
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
52
|
+
peelability: 90,
|
|
53
|
+
inbound: [],
|
|
54
|
+
outbound: [],
|
|
55
|
+
reversible: true,
|
|
56
|
+
diagnostics: [],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
describe("carveBridge", () => {
|
|
43
|
-
test("requires --from
|
|
62
|
+
test("requires --from; without --select it needs a carve manifest", async () => {
|
|
44
63
|
expect((await carveBridge({})).error).toContain("--from");
|
|
45
|
-
|
|
64
|
+
await withEstate(async (dir) => {
|
|
65
|
+
const res = await carveBridge({ from: dir });
|
|
66
|
+
expect(res.ok).toBe(false);
|
|
67
|
+
expect(res.error).toContain("--select");
|
|
68
|
+
expect(res.error).toContain("carve emit");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("composes with the carve manifest: target resolved without --select, bridge recorded", async () => {
|
|
73
|
+
if (!parserAvailable) return;
|
|
74
|
+
await withEstate(async (dir) => {
|
|
75
|
+
const out = join(dir, "carveout");
|
|
76
|
+
writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
|
|
77
|
+
|
|
78
|
+
const res = await carveBridge({ from: dir, output: out });
|
|
79
|
+
expect(res.ok).toBe(true);
|
|
80
|
+
expect(res.selectFromManifest).toBe(true);
|
|
81
|
+
expect(res.plan!.target).toBe("aws_s3_bucket.assets");
|
|
82
|
+
expect(formatCarveBridge(res)).toContain("target from the carve manifest");
|
|
83
|
+
|
|
84
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
85
|
+
expect(m.bridge!.written.length).toBeGreaterThan(0);
|
|
86
|
+
expect(m.bridge!.appliedInPlace).toBe(false);
|
|
87
|
+
// The boundary is refreshed from the estate, not left as the stub.
|
|
88
|
+
expect(m.boundary.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
89
|
+
});
|
|
46
90
|
});
|
|
47
91
|
|
|
48
92
|
test("dry-run: writes runbook + data sources + proposed survivor, touches no .tf in place", async () => {
|
|
@@ -65,6 +109,39 @@ describe("carveBridge", () => {
|
|
|
65
109
|
});
|
|
66
110
|
});
|
|
67
111
|
|
|
112
|
+
test("emits one git-applyable patch carrying the whole survivor edit", async () => {
|
|
113
|
+
if (!parserAvailable) return;
|
|
114
|
+
await withEstate(async (dir) => {
|
|
115
|
+
const out = join(dir, "carveout");
|
|
116
|
+
const res = await carveBridge({ from: dir, select: "aws_s3_bucket.assets", output: out });
|
|
117
|
+
expect(res.ok).toBe(true);
|
|
118
|
+
expect(res.patchPath).toBe(join(out, "aws_s3_bucket-assets-bridge.patch"));
|
|
119
|
+
expect(res.written).toContain(res.patchPath);
|
|
120
|
+
|
|
121
|
+
const patch = readFileSync(res.patchPath!, "utf-8");
|
|
122
|
+
expect(patch).toContain("diff --git a/aws_s3_bucket-assets-datasources.tf b/aws_s3_bucket-assets-datasources.tf");
|
|
123
|
+
expect(patch).toContain("new file mode 100644");
|
|
124
|
+
expect(patch).toContain("diff --git a/api.tf b/api.tf");
|
|
125
|
+
expect(patch).toContain("- ASSETS_BUCKET = aws_s3_bucket.assets.bucket");
|
|
126
|
+
expect(patch).toContain("+ ASSETS_BUCKET = data.aws_s3_bucket.assets.bucket");
|
|
127
|
+
|
|
128
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
129
|
+
expect(m.bridge!.patch).toBe(res.patchPath);
|
|
130
|
+
expect(formatCarveBridge(res)).toContain("git apply");
|
|
131
|
+
|
|
132
|
+
// The patch really applies: `git apply` from the estate reproduces the
|
|
133
|
+
// rewired survivor and the new data-source file.
|
|
134
|
+
try {
|
|
135
|
+
execFileSync("git", ["apply", res.patchPath!], { cwd: dir });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // no git on this machine
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
expect(readFileSync(join(dir, "api.tf"), "utf-8")).toContain("data.aws_s3_bucket.assets.bucket");
|
|
141
|
+
expect(readFileSync(join(dir, "aws_s3_bucket-assets-datasources.tf"), "utf-8")).toContain('data "aws_s3_bucket" "assets"');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
68
145
|
test("--apply-rewrites edits the survivor .tf in place", async () => {
|
|
69
146
|
if (!parserAvailable) return;
|
|
70
147
|
await withEstate(async (dir) => {
|
|
@@ -77,10 +154,11 @@ describe("carveBridge", () => {
|
|
|
77
154
|
expect(res.ok).toBe(true);
|
|
78
155
|
expect(res.appliedInPlace).toBe(true);
|
|
79
156
|
|
|
80
|
-
// api.tf rewritten in place; bucket.tf
|
|
157
|
+
// api.tf rewritten in place; bucket.tf's carved declaration excised
|
|
158
|
+
// (#998 — after `terraform state rm`, the block would re-create it).
|
|
81
159
|
const api = readFileSync(join(dir, "api.tf"), "utf-8");
|
|
82
160
|
expect(api).toContain("data.aws_s3_bucket.assets.bucket");
|
|
83
|
-
expect(readFileSync(join(dir, "bucket.tf"), "utf-8")).
|
|
161
|
+
expect(readFileSync(join(dir, "bucket.tf"), "utf-8")).not.toContain('resource "aws_s3_bucket" "assets"');
|
|
84
162
|
});
|
|
85
163
|
});
|
|
86
164
|
|
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { existsSync, statSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
-
import { join, basename } from "path";
|
|
13
|
+
import { join, basename, relative, resolve } from "path";
|
|
14
14
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
|
-
import { boundaryReport } from "../../terraform/carve";
|
|
15
|
+
import { boundaryReport, type CarveReport } from "../../terraform/carve";
|
|
16
16
|
import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
|
|
17
17
|
import { IDENTITY_ATTR } from "../../terraform/tier-map";
|
|
18
|
+
import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
19
|
+
import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
|
|
18
20
|
|
|
19
21
|
export interface CarveBridgeOptions {
|
|
20
22
|
from?: string;
|
|
23
|
+
/** Optional when the output dir holds a single carve manifest from `carve emit`. */
|
|
21
24
|
select?: string;
|
|
22
25
|
statePath?: string;
|
|
23
26
|
/** Output directory for proposed files (default `<from>/carveout`). */
|
|
@@ -34,6 +37,12 @@ export interface CarveBridgeResult {
|
|
|
34
37
|
written?: string[];
|
|
35
38
|
/** True if survivor files were edited in place. */
|
|
36
39
|
appliedInPlace?: boolean;
|
|
40
|
+
/** The git-applyable `.patch` carrying the whole survivor edit. */
|
|
41
|
+
patchPath?: string;
|
|
42
|
+
/** The carve state manifest this bridge composed with / recorded into. */
|
|
43
|
+
manifestPath?: string;
|
|
44
|
+
/** True when the target came from the manifest, not --select. */
|
|
45
|
+
selectFromManifest?: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
function listTfFiles(dir: string): string[] {
|
|
@@ -45,16 +54,25 @@ function listTfFiles(dir: string): string[] {
|
|
|
45
54
|
|
|
46
55
|
export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridgeResult> {
|
|
47
56
|
if (!opts.from) return { ok: false, error: "chant carve bridge requires --from <terraform-dir>" };
|
|
48
|
-
if (!opts.select) return { ok: false, error: "chant carve bridge requires --select <tf-address>" };
|
|
49
57
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
50
58
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
// Compose with the carve state manifest `carve emit` persisted: without
|
|
62
|
+
// --select it supplies the target (and tfstate), with it it is updated below.
|
|
63
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
64
|
+
const resolved = resolveCarveManifest(outDir, opts.select);
|
|
65
|
+
if (!opts.select && resolved.error) return { ok: false, error: resolved.error };
|
|
66
|
+
const select = opts.select ?? resolved.manifest!.target;
|
|
67
|
+
const statePath = opts.statePath ?? resolved.manifest?.statePath;
|
|
68
|
+
|
|
53
69
|
let plan: BridgePlan;
|
|
70
|
+
let report: CarveReport;
|
|
54
71
|
try {
|
|
55
|
-
const graph = await parseTerraformDir(opts.from, { statePath
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
72
|
+
const graph = await parseTerraformDir(opts.from, { statePath });
|
|
73
|
+
const found = boundaryReport(graph, select);
|
|
74
|
+
if (!found) return { ok: false, error: `${select} not found in ${opts.from}` };
|
|
75
|
+
report = found;
|
|
58
76
|
|
|
59
77
|
// Physical identities for the carved resources, for the data sources.
|
|
60
78
|
const identities = new Map<string, CarvedIdentity>();
|
|
@@ -71,8 +89,7 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
71
89
|
return { ok: false, error: `Failed to build the bridge: ${err instanceof Error ? err.message : String(err)}` };
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
const slug =
|
|
75
|
-
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
92
|
+
const slug = select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
76
93
|
mkdirSync(outDir, { recursive: true });
|
|
77
94
|
const written: string[] = [];
|
|
78
95
|
|
|
@@ -101,19 +118,66 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
|
|
121
|
+
// The whole survivor edit as one git-applyable patch: the data-source file as
|
|
122
|
+
// a new file, plus the rewired references — `cd <from> && git apply <patch>`.
|
|
123
|
+
let patchPath: string | undefined;
|
|
124
|
+
if (plan.dataSources.length || changed.length) {
|
|
125
|
+
const chunks: string[] = [];
|
|
126
|
+
if (plan.dataSources.length) {
|
|
127
|
+
chunks.push(newFileDiff(`${slug}-datasources.tf`, plan.dataSources.map((d) => d.hcl).join("\n\n") + "\n"));
|
|
128
|
+
}
|
|
129
|
+
for (const r of changed) {
|
|
130
|
+
chunks.push(unifiedDiff(relative(opts.from, r.path), r.original, r.rewritten));
|
|
131
|
+
}
|
|
132
|
+
patchPath = join(outDir, `${slug}-bridge.patch`);
|
|
133
|
+
writeFileSync(patchPath, chunks.join(""));
|
|
134
|
+
written.push(patchPath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Record the bridge into the manifest (create one on a standalone bridge),
|
|
138
|
+
// so `carve apply` composes with this step too.
|
|
139
|
+
const applied = Boolean(opts.applyRewrites) && changed.length > 0;
|
|
140
|
+
const manifest: CarveManifest = resolved.manifest ?? {
|
|
141
|
+
version: 1,
|
|
142
|
+
target: select,
|
|
143
|
+
from: resolve(opts.from),
|
|
144
|
+
statePath: statePath ? resolve(statePath) : undefined,
|
|
145
|
+
boundary: report,
|
|
146
|
+
};
|
|
147
|
+
manifest.boundary = report;
|
|
148
|
+
manifest.bridge = {
|
|
149
|
+
written: written.map((w) => resolve(w)),
|
|
150
|
+
appliedInPlace: applied,
|
|
151
|
+
patch: patchPath ? resolve(patchPath) : undefined,
|
|
152
|
+
excised: plan.excised.length ? plan.excised : undefined,
|
|
153
|
+
at: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
const manifestPath = writeCarveManifest(outDir, manifest);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
plan,
|
|
160
|
+
written,
|
|
161
|
+
appliedInPlace: applied,
|
|
162
|
+
patchPath,
|
|
163
|
+
manifestPath,
|
|
164
|
+
selectFromManifest: !opts.select,
|
|
165
|
+
};
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
export function formatCarveBridge(result: CarveBridgeResult): string {
|
|
108
169
|
if (!result.ok || !result.plan) return result.error ?? "carve bridge failed";
|
|
109
170
|
const p = result.plan;
|
|
110
171
|
const L: string[] = [];
|
|
111
|
-
L.push(`Boundary bridge for ${p.target}:`);
|
|
172
|
+
L.push(`Boundary bridge for ${p.target}${result.selectFromManifest ? " (target from the carve manifest)" : ""}:`);
|
|
112
173
|
if (p.dataSources.length) {
|
|
113
174
|
L.push(` ${p.dataSources.length} data source(s) for survivors to read:`);
|
|
114
175
|
for (const d of p.dataSources) L.push(` data.${d.type}.${d.name} (was ${d.address})`);
|
|
115
176
|
} else {
|
|
116
|
-
L.push(" no inbound edges — no
|
|
177
|
+
L.push(" no inbound edges — nothing reads the carved resource, no data source needed.");
|
|
178
|
+
}
|
|
179
|
+
if (p.excised.length) {
|
|
180
|
+
L.push(` carved block(s) excised from the survivor source: ${p.excised.join(", ")}`);
|
|
117
181
|
}
|
|
118
182
|
const changed = p.rewrites.filter((r) => r.changed);
|
|
119
183
|
if (changed.length) {
|
|
@@ -124,6 +188,10 @@ export function formatCarveBridge(result: CarveBridgeResult): string {
|
|
|
124
188
|
for (const d of p.deferredInputs) L.push(` - ${d.note}`);
|
|
125
189
|
}
|
|
126
190
|
L.push("");
|
|
191
|
+
if (result.patchPath && !result.appliedInPlace) {
|
|
192
|
+
L.push(`One git-applyable patch carries the whole edit: git apply --directory=<terraform-dir> ${resolve(result.patchPath)}`);
|
|
193
|
+
L.push(" (--directory is the Terraform dir relative to your repo root; plain `git apply` from the dir works outside a repo.)");
|
|
194
|
+
}
|
|
127
195
|
if (result.appliedInPlace) {
|
|
128
196
|
L.push("Rewritten survivor Terraform in place. Review with `git diff`, then `terraform plan`.");
|
|
129
197
|
} else {
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
6
|
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import { readCarveManifest } from "../../terraform/manifest";
|
|
7
8
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
8
9
|
import type { LexiconPlugin } from "../../lexicon";
|
|
9
10
|
|
|
@@ -89,7 +90,7 @@ describe("carve emit --state (real adoption from tfstate)", () => {
|
|
|
89
90
|
expect(liveImport).not.toHaveBeenCalled(); // offline — no cloud
|
|
90
91
|
|
|
91
92
|
// A real .ts file with the native constructor + props from real state.
|
|
92
|
-
expect(res.emittedFiles).toEqual([join(out, "assets.ts")]);
|
|
93
|
+
expect(res.emittedFiles).toEqual([join(out, "src", "assets.ts")]);
|
|
93
94
|
const emitted = readFileSync(res.emittedFiles![0], "utf-8");
|
|
94
95
|
expect(emitted).toContain("new Bucket({");
|
|
95
96
|
expect(emitted).toContain('BucketName: "myapp-assets-prod"');
|
|
@@ -98,11 +99,119 @@ describe("carve emit --state (real adoption from tfstate)", () => {
|
|
|
98
99
|
// Boundary still classified (the Lambda inbound).
|
|
99
100
|
expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
100
101
|
|
|
102
|
+
// The carve state manifest persists boundary + selector for bridge/apply.
|
|
103
|
+
expect(res.manifestPath).toBe(join(out, "aws_s3_bucket-assets.carve.json"));
|
|
104
|
+
const manifest = readCarveManifest(res.manifestPath!)!;
|
|
105
|
+
expect(manifest.target).toBe("aws_s3_bucket.assets");
|
|
106
|
+
expect(manifest.statePath).toBe(join(dir, "terraform.tfstate"));
|
|
107
|
+
expect(manifest.emit!.source).toBe("tfstate");
|
|
108
|
+
expect(manifest.emit!.files).toEqual([join(out, "src", "assets.ts")]);
|
|
109
|
+
expect(manifest.boundary.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
110
|
+
|
|
101
111
|
const text = formatCarveEmit(res);
|
|
102
112
|
expect(text).toContain("Adopted from Terraform state (offline)");
|
|
113
|
+
expect(text).toContain("State manifest");
|
|
114
|
+
expect(text).toContain("Scaffolded a buildable chant project");
|
|
103
115
|
});
|
|
104
116
|
});
|
|
105
117
|
|
|
118
|
+
test("scaffolds a buildable chant project around the emitted source, never overwriting", async () => {
|
|
119
|
+
if (!parserAvailable) return;
|
|
120
|
+
await withEstate(async (dir) => {
|
|
121
|
+
const out = join(dir, "carveout");
|
|
122
|
+
const res = await carveEmit(
|
|
123
|
+
{ from: dir, select: "aws_s3_bucket.assets", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
124
|
+
{ plugins: [], liveImport },
|
|
125
|
+
);
|
|
126
|
+
expect(res.ok).toBe(true);
|
|
127
|
+
expect(res.scaffolded!.map((f) => f.slice(out.length + 1)).sort()).toEqual([
|
|
128
|
+
"chant.config.ts",
|
|
129
|
+
"package.json",
|
|
130
|
+
"tsconfig.json",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
|
|
134
|
+
expect(pkg.scripts.build).toBe("chant build src --lexicon aws");
|
|
135
|
+
expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-aws"]);
|
|
136
|
+
expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["aws"]');
|
|
137
|
+
|
|
138
|
+
// A second emit into the same dir leaves the scaffold (and edits) alone.
|
|
139
|
+
writeFileSync(join(out, "package.json"), '{"name":"edited"}');
|
|
140
|
+
const again = await carveEmit(
|
|
141
|
+
{ from: dir, select: "aws_s3_bucket.assets", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
142
|
+
{ plugins: [], liveImport },
|
|
143
|
+
);
|
|
144
|
+
expect(again.ok).toBe(true);
|
|
145
|
+
expect(again.scaffolded).toEqual([]);
|
|
146
|
+
expect(readFileSync(join(out, "package.json"), "utf-8")).toBe('{"name":"edited"}');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("deferred outbound inputs become declared build params (#998)", async () => {
|
|
151
|
+
if (!parserAvailable) return;
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-emit-params-"));
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(dir, "main.tf"),
|
|
156
|
+
`
|
|
157
|
+
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
|
|
158
|
+
resource "aws_subnet" "a" {
|
|
159
|
+
vpc_id = aws_vpc.main.id
|
|
160
|
+
cidr_block = "10.0.1.0/24"
|
|
161
|
+
}
|
|
162
|
+
`,
|
|
163
|
+
);
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(dir, "terraform.tfstate"),
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
version: 4,
|
|
168
|
+
resources: [
|
|
169
|
+
{
|
|
170
|
+
mode: "managed",
|
|
171
|
+
type: "aws_subnet",
|
|
172
|
+
name: "a",
|
|
173
|
+
instances: [{ attributes: { id: "subnet-0aa", vpc_id: "vpc-0abc", cidr_block: "10.0.1.0/24" } }],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
mode: "managed",
|
|
177
|
+
type: "aws_vpc",
|
|
178
|
+
name: "main",
|
|
179
|
+
instances: [{ attributes: { id: "vpc-0abc", cidr_block: "10.0.0.0/16" } }],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
const out = join(dir, "carveout");
|
|
185
|
+
const res = await carveEmit(
|
|
186
|
+
{ from: dir, select: "aws_subnet.a", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
187
|
+
{ plugins: [], liveImport },
|
|
188
|
+
);
|
|
189
|
+
expect(res.ok).toBe(true);
|
|
190
|
+
|
|
191
|
+
// The survivor-fed prop is a params reference; the rest stay literal.
|
|
192
|
+
const emitted = readFileSync(join(out, "src", "a.ts"), "utf-8");
|
|
193
|
+
expect(emitted).toContain('import { params } from "@intentius/chant/params";');
|
|
194
|
+
expect(emitted).toContain("VpcId: params.vpc_id as string,");
|
|
195
|
+
expect(emitted).toContain('CidrBlock: "10.0.1.0/24"');
|
|
196
|
+
|
|
197
|
+
// The scaffolded config declares the param, defaulted from state.
|
|
198
|
+
const config = readFileSync(join(out, "chant.config.ts"), "utf-8");
|
|
199
|
+
expect(config).toContain("buildParams: {");
|
|
200
|
+
expect(config).toContain("vpc_id: {");
|
|
201
|
+
expect(config).toContain('default: "vpc-0abc",');
|
|
202
|
+
expect(config).toContain("was aws_vpc.main.id in Terraform");
|
|
203
|
+
|
|
204
|
+
// Recorded in the manifest, reported in the summary.
|
|
205
|
+
const manifest = readCarveManifest(res.manifestPath!)!;
|
|
206
|
+
expect(manifest.emit!.params).toEqual({
|
|
207
|
+
vpc_id: { tfAttr: "vpc_id", survivor: "aws_vpc.main", attrs: ["id"], default: "vpc-0abc" },
|
|
208
|
+
});
|
|
209
|
+
expect(formatCarveEmit(res)).toContain("vpc_id — was aws_vpc.main.id");
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
106
215
|
test("without --state or --env it explains both adoption sources", async () => {
|
|
107
216
|
if (!parserAvailable) return;
|
|
108
217
|
await withEstate(async (dir) => {
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
|
-
import { join } from "path";
|
|
16
|
+
import { basename, join, resolve } from "path";
|
|
17
17
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
18
|
-
import { boundaryReport, type CarveReport } from "../../terraform/carve";
|
|
18
|
+
import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
|
|
19
19
|
import { resolveTier } from "../../terraform/tier-map";
|
|
20
20
|
import { readStateResource } from "../../terraform/state";
|
|
21
|
-
import {
|
|
21
|
+
import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
22
|
+
import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes, type DeferredParam } from "../../terraform/adopt-state";
|
|
23
|
+
import { getChantVersion } from "./init";
|
|
22
24
|
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
23
25
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
24
26
|
|
|
@@ -61,6 +63,12 @@ export interface CarveEmitResult {
|
|
|
61
63
|
source?: "tfstate" | "live";
|
|
62
64
|
/** Emitted file path(s). */
|
|
63
65
|
emittedFiles?: string[];
|
|
66
|
+
/** Project files scaffolded around the emitted source (config, package.json). */
|
|
67
|
+
scaffolded?: string[];
|
|
68
|
+
/** Deferred outbound inputs declared as build parameters in the emitted project (#998). */
|
|
69
|
+
params?: DeferredParam[];
|
|
70
|
+
/** The persisted carve state manifest bridge/apply compose with. */
|
|
71
|
+
manifestPath?: string;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Promise<CarveEmitResult> {
|
|
@@ -113,15 +121,25 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
113
121
|
if (!stateResource) {
|
|
114
122
|
return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
|
|
115
123
|
}
|
|
116
|
-
|
|
124
|
+
// Deferred outbound inputs → real build parameters (#998): each survivor
|
|
125
|
+
// value the carved block read enters the emitted project as a declared
|
|
126
|
+
// param, defaulted to the value the state resolved.
|
|
127
|
+
const params = deferredParams(report, stateResource.attributes);
|
|
128
|
+
const adopted = adoptFromState(stateResource, params);
|
|
117
129
|
if (!adopted) return { ok: false, error: `Could not adopt ${opts.select} from state.` };
|
|
118
130
|
|
|
131
|
+
// The output dir is a buildable chant project: source in src/, plus the
|
|
132
|
+
// config + package.json scaffold (written once, never overwritten).
|
|
119
133
|
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
const srcDir = join(outDir, "src");
|
|
135
|
+
mkdirSync(srcDir, { recursive: true });
|
|
136
|
+
const outPath = join(srcDir, adopted.fileName);
|
|
122
137
|
writeFileSync(outPath, adopted.content);
|
|
138
|
+
const lexicon = tier.mapsTo.split("::")[0]?.toLowerCase() ?? "aws";
|
|
139
|
+
const scaffolded = scaffoldProject(outDir, lexicon, params);
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
|
|
142
|
+
return { ok: true, report, source: "tfstate", emittedFiles: [outPath], scaffolded, manifestPath, params };
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
// ── Adoption path 2: live import (cloud→code) ──
|
|
@@ -140,7 +158,152 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
140
158
|
lexicon: tier.mapsTo.split("::")[0]?.toLowerCase() === "aws" ? "aws" : undefined,
|
|
141
159
|
});
|
|
142
160
|
|
|
143
|
-
|
|
161
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
162
|
+
const manifestPath = persistManifest(outDir, opts, report, tfType, "live", emit.generatedFiles ?? []);
|
|
163
|
+
return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles, manifestPath };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turn the boundary report's outbound edges into build parameters (#998): one
|
|
168
|
+
* per carved-block attribute that read a survivor (`via`), named after that
|
|
169
|
+
* attribute, defaulted to the value the state resolved for it. A non-scalar
|
|
170
|
+
* value (a nested block) is declared without a default (`required: false`) —
|
|
171
|
+
* it documents the deferred input without blocking an out-of-the-box build.
|
|
172
|
+
*/
|
|
173
|
+
function deferredParams(report: CarveReport, attributes: Record<string, unknown>): DeferredParam[] {
|
|
174
|
+
const byName = new Map<string, DeferredParam>();
|
|
175
|
+
for (const edge of report.outbound) {
|
|
176
|
+
for (const tfAttr of edge.via ?? []) {
|
|
177
|
+
const name = deferredParamName(tfAttr);
|
|
178
|
+
const raw = attributes[tfAttr];
|
|
179
|
+
const scalar = typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean" ? raw : undefined;
|
|
180
|
+
const prev = byName.get(name);
|
|
181
|
+
byName.set(name, {
|
|
182
|
+
name,
|
|
183
|
+
tfAttr,
|
|
184
|
+
survivor: prev && prev.survivor !== edge.survivor ? `${prev.survivor}, ${edge.survivor}` : edge.survivor,
|
|
185
|
+
attrs: [...new Set([...(prev?.attrs ?? []), ...edge.attrs])].sort(),
|
|
186
|
+
default: scalar,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Scaffold the emitted source into a buildable chant project: chant.config.ts,
|
|
195
|
+
* package.json, tsconfig.json — same shape `chant init` produces, so
|
|
196
|
+
* `npm install && npm run build` works in the output dir as-is. Existing files
|
|
197
|
+
* are never overwritten (re-emits and user edits survive). Deferred inputs are
|
|
198
|
+
* declared as `buildParams` (#998), defaults taken from the state's resolved
|
|
199
|
+
* values, so the first build reproduces what is live while every survivor-fed
|
|
200
|
+
* value stays overridable (`--param <name>=<value>`).
|
|
201
|
+
*/
|
|
202
|
+
function scaffoldProject(outDir: string, lexicon: string, params: DeferredParam[] = []): string[] {
|
|
203
|
+
const ver = getChantVersion();
|
|
204
|
+
const packageJson = {
|
|
205
|
+
name: "chant-carveout",
|
|
206
|
+
version: "0.1.0",
|
|
207
|
+
type: "module" as const,
|
|
208
|
+
scripts: {
|
|
209
|
+
build: `chant build src --lexicon ${lexicon}`,
|
|
210
|
+
lint: "chant lint src",
|
|
211
|
+
},
|
|
212
|
+
dependencies: {
|
|
213
|
+
"@intentius/chant": `^${ver}`,
|
|
214
|
+
[`@intentius/chant-lexicon-${lexicon}`]: `^${ver}`,
|
|
215
|
+
},
|
|
216
|
+
devDependencies: {
|
|
217
|
+
typescript: "^5.0.0",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const tsconfig = {
|
|
221
|
+
compilerOptions: {
|
|
222
|
+
target: "ES2022",
|
|
223
|
+
module: "NodeNext",
|
|
224
|
+
moduleResolution: "NodeNext",
|
|
225
|
+
strict: true,
|
|
226
|
+
esModuleInterop: true,
|
|
227
|
+
skipLibCheck: true,
|
|
228
|
+
},
|
|
229
|
+
include: ["src"],
|
|
230
|
+
exclude: ["node_modules"],
|
|
231
|
+
};
|
|
232
|
+
const files: Array<[string, string]> = [
|
|
233
|
+
["package.json", JSON.stringify(packageJson, null, 2) + "\n"],
|
|
234
|
+
["chant.config.ts", renderConfig(lexicon, params)],
|
|
235
|
+
["tsconfig.json", JSON.stringify(tsconfig, null, 2) + "\n"],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const written: string[] = [];
|
|
239
|
+
for (const [name, content] of files) {
|
|
240
|
+
const path = join(outDir, name);
|
|
241
|
+
if (existsSync(path)) continue;
|
|
242
|
+
writeFileSync(path, content);
|
|
243
|
+
written.push(path);
|
|
244
|
+
}
|
|
245
|
+
return written;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Render the scaffolded `chant.config.ts`, declaring deferred inputs as `buildParams` (#998). */
|
|
249
|
+
function renderConfig(lexicon: string, params: DeferredParam[]): string {
|
|
250
|
+
const L: string[] = [];
|
|
251
|
+
L.push(`import type { ChantConfig } from "@intentius/chant";`);
|
|
252
|
+
L.push("");
|
|
253
|
+
L.push("export default {");
|
|
254
|
+
L.push(` lexicons: ["${lexicon}"],`);
|
|
255
|
+
if (params.length) {
|
|
256
|
+
L.push(" // Deferred deploy-time inputs — values the Terraform source read from");
|
|
257
|
+
L.push(" // surviving resources. Defaults are the values the tfstate resolved;");
|
|
258
|
+
L.push(" // override per build with --param <name>=<value>.");
|
|
259
|
+
L.push(" buildParams: {");
|
|
260
|
+
for (const p of params) {
|
|
261
|
+
const type = typeof p.default === "number" ? "number" : typeof p.default === "boolean" ? "boolean" : "string";
|
|
262
|
+
L.push(` ${p.name}: {`);
|
|
263
|
+
L.push(` type: ${JSON.stringify(type)},`);
|
|
264
|
+
if (p.default !== undefined) {
|
|
265
|
+
L.push(` default: ${JSON.stringify(p.default)},`);
|
|
266
|
+
} else {
|
|
267
|
+
L.push(` required: false,`);
|
|
268
|
+
}
|
|
269
|
+
L.push(` description: ${JSON.stringify(`was ${p.survivor}.${p.attrs.join("/")} in Terraform (deferred deploy-time input)`)},`);
|
|
270
|
+
L.push(" },");
|
|
271
|
+
}
|
|
272
|
+
L.push(" },");
|
|
273
|
+
}
|
|
274
|
+
L.push("} satisfies ChantConfig;");
|
|
275
|
+
return L.join("\n") + "\n";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Persist the carve state manifest so bridge/apply compose with this emit. */
|
|
279
|
+
function persistManifest(
|
|
280
|
+
outDir: string,
|
|
281
|
+
opts: CarveEmitOptions,
|
|
282
|
+
report: CarveReport,
|
|
283
|
+
tfType: string | undefined,
|
|
284
|
+
source: "tfstate" | "live",
|
|
285
|
+
files: string[],
|
|
286
|
+
params: DeferredParam[] = [],
|
|
287
|
+
): string {
|
|
288
|
+
const manifest: CarveManifest = {
|
|
289
|
+
version: 1,
|
|
290
|
+
target: report.target,
|
|
291
|
+
tfType,
|
|
292
|
+
from: resolve(opts.from!),
|
|
293
|
+
statePath: opts.statePath ? resolve(opts.statePath) : undefined,
|
|
294
|
+
boundary: report,
|
|
295
|
+
emit: {
|
|
296
|
+
source,
|
|
297
|
+
files: files.map((f) => resolve(f)),
|
|
298
|
+
params: params.length
|
|
299
|
+
? Object.fromEntries(
|
|
300
|
+
params.map((p) => [p.name, { tfAttr: p.tfAttr, survivor: p.survivor, attrs: p.attrs, default: p.default }]),
|
|
301
|
+
)
|
|
302
|
+
: undefined,
|
|
303
|
+
at: new Date().toISOString(),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
return writeCarveManifest(outDir, manifest);
|
|
144
307
|
}
|
|
145
308
|
|
|
146
309
|
/** Human-readable emit summary: what was adopted and what boundary work remains. */
|
|
@@ -158,6 +321,19 @@ export function formatCarveEmit(result: CarveEmitResult): string {
|
|
|
158
321
|
if (result.emittedFiles?.length) {
|
|
159
322
|
lines.push(` Emitted: ${result.emittedFiles.join(", ")}`);
|
|
160
323
|
}
|
|
324
|
+
if (result.scaffolded?.length) {
|
|
325
|
+
lines.push(` Scaffolded a buildable chant project: ${result.scaffolded.map((f) => basename(f)).join(", ")} (npm install && npm run build).`);
|
|
326
|
+
}
|
|
327
|
+
if (result.params?.length) {
|
|
328
|
+
lines.push(` Deferred input(s) declared as build params (override with --param <name>=<value>):`);
|
|
329
|
+
for (const p of result.params) {
|
|
330
|
+
const def = p.default !== undefined ? `default ${JSON.stringify(p.default)} from state` : "no default (required: false)";
|
|
331
|
+
lines.push(` ${p.name} — was ${p.survivor}.${p.attrs.join("/")}; ${def}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (result.manifestPath) {
|
|
335
|
+
lines.push(` State manifest: ${result.manifestPath} — carve bridge/apply pick the target up from here.`);
|
|
336
|
+
}
|
|
161
337
|
if (r.carveSet.length > 1) {
|
|
162
338
|
const folded = r.carveSet.filter((m) => m.foldedInto).map((m) => m.address);
|
|
163
339
|
lines.push(` Folded in: ${folded.join(", ")}`);
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { formatSuccess, formatWarning } from "../format";
|
|
|
7
7
|
import { loadPlugin } from "../plugins";
|
|
8
8
|
|
|
9
9
|
/** Read the current chant package version from our own package.json. */
|
|
10
|
-
function getChantVersion(): string {
|
|
10
|
+
export function getChantVersion(): string {
|
|
11
11
|
try {
|
|
12
12
|
const pkgDir = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
|
|
13
13
|
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8"));
|