@intentius/chant 0.1.13 → 0.1.15
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/package.json +1 -1
- package/src/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +38 -12
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +21 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +31 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +91 -0
- package/src/op/builders.ts +3 -3
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +247 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +4 -4
- package/src/op/types.ts +1 -1
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
|
@@ -5,7 +5,7 @@ import type { BuildResult } from "../../build";
|
|
|
5
5
|
import type { ParsedArgs } from "../registry";
|
|
6
6
|
|
|
7
7
|
const buildMock = vi.fn();
|
|
8
|
-
const
|
|
8
|
+
const fetchLifecycleMock = vi.fn();
|
|
9
9
|
const readSnapshotMock = vi.fn();
|
|
10
10
|
const readEnvironmentSnapshotsMock = vi.fn();
|
|
11
11
|
const listSnapshotsMock = vi.fn();
|
|
@@ -13,20 +13,20 @@ const takeSnapshotMock = vi.fn();
|
|
|
13
13
|
const loadChantConfigMock = vi.fn();
|
|
14
14
|
|
|
15
15
|
vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
|
|
16
|
-
vi.mock("../../
|
|
17
|
-
|
|
16
|
+
vi.mock("../../lifecycle/git", () => ({
|
|
17
|
+
fetchLifecycle: () => fetchLifecycleMock(),
|
|
18
18
|
readSnapshot: (...args: unknown[]) => readSnapshotMock(...args),
|
|
19
19
|
readEnvironmentSnapshots: (...args: unknown[]) => readEnvironmentSnapshotsMock(...args),
|
|
20
20
|
listSnapshots: (...args: unknown[]) => listSnapshotsMock(...args),
|
|
21
21
|
}));
|
|
22
|
-
vi.mock("../../
|
|
22
|
+
vi.mock("../../lifecycle/snapshot", () => ({
|
|
23
23
|
takeSnapshot: (...args: unknown[]) => takeSnapshotMock(...args),
|
|
24
24
|
}));
|
|
25
25
|
vi.mock("../../config", () => ({
|
|
26
26
|
loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
|
-
const {
|
|
29
|
+
const { runLifecycleDiff, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleUnknown } = await import("./lifecycle");
|
|
30
30
|
|
|
31
31
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
32
32
|
return {
|
|
@@ -71,7 +71,7 @@ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
71
71
|
...overrides,
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
describe("
|
|
74
|
+
describe("runLifecycleDiff --live", () => {
|
|
75
75
|
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
|
76
76
|
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
77
77
|
let stdoutBuf: string[];
|
|
@@ -83,13 +83,13 @@ describe("runStateDiff --live", () => {
|
|
|
83
83
|
stdoutSpy = vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
84
84
|
stderrSpy = vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
85
85
|
buildMock.mockReset();
|
|
86
|
-
|
|
86
|
+
fetchLifecycleMock.mockReset();
|
|
87
87
|
readSnapshotMock.mockReset();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test("surfaces drift between previous snapshot and live state", async () => {
|
|
91
91
|
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
92
|
-
|
|
92
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
93
93
|
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
94
94
|
lexicon: "aws",
|
|
95
95
|
environment: "prod",
|
|
@@ -113,7 +113,7 @@ describe("runStateDiff --live", () => {
|
|
|
113
113
|
serializers: plugins.map((p) => p.serializer),
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
-
const exit = await
|
|
116
|
+
const exit = await runLifecycleDiff(ctx);
|
|
117
117
|
|
|
118
118
|
expect(exit).toBe(0);
|
|
119
119
|
const output = stdoutBuf.join("\n");
|
|
@@ -126,7 +126,7 @@ describe("runStateDiff --live", () => {
|
|
|
126
126
|
|
|
127
127
|
test("warns and skips lexicons without describeResources", async () => {
|
|
128
128
|
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
|
|
129
|
-
|
|
129
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
130
130
|
readSnapshotMock.mockResolvedValue(null);
|
|
131
131
|
|
|
132
132
|
const plugins: LexiconPlugin[] = [
|
|
@@ -139,7 +139,7 @@ describe("runStateDiff --live", () => {
|
|
|
139
139
|
serializers: plugins.map((p) => p.serializer),
|
|
140
140
|
};
|
|
141
141
|
|
|
142
|
-
const exit = await
|
|
142
|
+
const exit = await runLifecycleDiff(ctx);
|
|
143
143
|
|
|
144
144
|
expect(exit).toBe(1);
|
|
145
145
|
const stderr = stderrBuf.join("\n");
|
|
@@ -149,7 +149,7 @@ describe("runStateDiff --live", () => {
|
|
|
149
149
|
|
|
150
150
|
test("--live with a listArtifacts-only plugin diffs artifacts (no resources path)", async () => {
|
|
151
151
|
buildMock.mockResolvedValue(makeBuildResult({ helm: [] }));
|
|
152
|
-
|
|
152
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
153
153
|
// Previous snapshot has no artifact entry for the new release → expect ARTIFACTS ADDED
|
|
154
154
|
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
155
155
|
lexicon: "helm",
|
|
@@ -175,7 +175,7 @@ describe("runStateDiff --live", () => {
|
|
|
175
175
|
serializers: plugins.map((p) => p.serializer),
|
|
176
176
|
};
|
|
177
177
|
|
|
178
|
-
const exit = await
|
|
178
|
+
const exit = await runLifecycleDiff(ctx);
|
|
179
179
|
|
|
180
180
|
expect(exit).toBe(0);
|
|
181
181
|
const output = stdoutBuf.join("\n");
|
|
@@ -185,7 +185,7 @@ describe("runStateDiff --live", () => {
|
|
|
185
185
|
|
|
186
186
|
test("legacy digest mode still works without --live", async () => {
|
|
187
187
|
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
188
|
-
|
|
188
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
189
189
|
readSnapshotMock.mockResolvedValue(null);
|
|
190
190
|
|
|
191
191
|
const plugins: LexiconPlugin[] = [createMockPlugin({ name: "aws" })];
|
|
@@ -196,7 +196,7 @@ describe("runStateDiff --live", () => {
|
|
|
196
196
|
serializers: plugins.map((p) => p.serializer),
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
-
const exit = await
|
|
199
|
+
const exit = await runLifecycleDiff(ctx);
|
|
200
200
|
|
|
201
201
|
expect(exit).toBe(0);
|
|
202
202
|
const output = stdoutBuf.join("\n");
|
|
@@ -206,7 +206,7 @@ describe("runStateDiff --live", () => {
|
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
-
describe("
|
|
209
|
+
describe("runLifecycleSnapshot", () => {
|
|
210
210
|
let stdoutBuf: string[];
|
|
211
211
|
let stderrBuf: string[];
|
|
212
212
|
|
|
@@ -227,7 +227,7 @@ describe("runStateSnapshot", () => {
|
|
|
227
227
|
plugins: [],
|
|
228
228
|
serializers: [],
|
|
229
229
|
};
|
|
230
|
-
const exit = await
|
|
230
|
+
const exit = await runLifecycleSnapshot(ctx);
|
|
231
231
|
expect(exit).toBe(1);
|
|
232
232
|
expect(stderrBuf.join("\n")).toContain("Environment is required");
|
|
233
233
|
});
|
|
@@ -238,7 +238,7 @@ describe("runStateSnapshot", () => {
|
|
|
238
238
|
plugins: [],
|
|
239
239
|
serializers: [],
|
|
240
240
|
};
|
|
241
|
-
const exit = await
|
|
241
|
+
const exit = await runLifecycleSnapshot(ctx);
|
|
242
242
|
expect(exit).toBe(1);
|
|
243
243
|
expect(stderrBuf.join("\n")).toContain('Unknown environment "unknown"');
|
|
244
244
|
});
|
|
@@ -251,7 +251,7 @@ describe("runStateSnapshot", () => {
|
|
|
251
251
|
plugins,
|
|
252
252
|
serializers: plugins.map((p) => p.serializer),
|
|
253
253
|
};
|
|
254
|
-
const exit = await
|
|
254
|
+
const exit = await runLifecycleSnapshot(ctx);
|
|
255
255
|
expect(exit).toBe(1);
|
|
256
256
|
expect(stderrBuf.join("\n")).toContain("No plugins implement describeResources");
|
|
257
257
|
});
|
|
@@ -275,14 +275,14 @@ describe("runStateSnapshot", () => {
|
|
|
275
275
|
plugins,
|
|
276
276
|
serializers: plugins.map((p) => p.serializer),
|
|
277
277
|
};
|
|
278
|
-
const exit = await
|
|
278
|
+
const exit = await runLifecycleSnapshot(ctx);
|
|
279
279
|
expect(exit).toBe(0);
|
|
280
280
|
expect(stderrBuf.join("\n")).toContain("Snapshot saved");
|
|
281
281
|
expect(takeSnapshotMock).toHaveBeenCalledTimes(1);
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
-
describe("
|
|
285
|
+
describe("runLifecycleShow", () => {
|
|
286
286
|
let stdoutBuf: string[];
|
|
287
287
|
let stderrBuf: string[];
|
|
288
288
|
|
|
@@ -291,7 +291,7 @@ describe("runStateShow", () => {
|
|
|
291
291
|
stderrBuf = [];
|
|
292
292
|
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
293
293
|
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
294
|
-
|
|
294
|
+
fetchLifecycleMock.mockReset();
|
|
295
295
|
readSnapshotMock.mockReset();
|
|
296
296
|
readEnvironmentSnapshotsMock.mockReset();
|
|
297
297
|
});
|
|
@@ -301,12 +301,12 @@ describe("runStateShow", () => {
|
|
|
301
301
|
args: makeArgs({ command: "state", path: "show" }),
|
|
302
302
|
plugins: [], serializers: [],
|
|
303
303
|
};
|
|
304
|
-
expect(await
|
|
304
|
+
expect(await runLifecycleShow(ctx)).toBe(1);
|
|
305
305
|
expect(stderrBuf.join("\n")).toContain("Environment is required");
|
|
306
306
|
});
|
|
307
307
|
|
|
308
308
|
test("specific lexicon: prints snapshot table when found", async () => {
|
|
309
|
-
|
|
309
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
310
310
|
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
311
311
|
lexicon: "aws", environment: "prod", commit: "x", timestamp: "t",
|
|
312
312
|
resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b-1", status: "OK" } },
|
|
@@ -315,25 +315,25 @@ describe("runStateShow", () => {
|
|
|
315
315
|
args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
|
|
316
316
|
plugins: [], serializers: [],
|
|
317
317
|
};
|
|
318
|
-
expect(await
|
|
318
|
+
expect(await runLifecycleShow(ctx)).toBe(0);
|
|
319
319
|
const out = stdoutBuf.join("\n");
|
|
320
320
|
expect(out).toContain("bucket");
|
|
321
321
|
expect(out).toContain("AWS::S3::Bucket");
|
|
322
322
|
});
|
|
323
323
|
|
|
324
324
|
test("specific lexicon: returns 1 when no snapshot found", async () => {
|
|
325
|
-
|
|
325
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
326
326
|
readSnapshotMock.mockResolvedValue(null);
|
|
327
327
|
const ctx = {
|
|
328
328
|
args: makeArgs({ command: "state", path: "show", extraPositional: "prod", extraPositional2: "aws" }),
|
|
329
329
|
plugins: [], serializers: [],
|
|
330
330
|
};
|
|
331
|
-
expect(await
|
|
331
|
+
expect(await runLifecycleShow(ctx)).toBe(1);
|
|
332
332
|
expect(stderrBuf.join("\n")).toContain("No snapshot found");
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
test("no lexicon: lists all lexicons in env", async () => {
|
|
336
|
-
|
|
336
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
337
337
|
readEnvironmentSnapshotsMock.mockResolvedValue(new Map([
|
|
338
338
|
["aws", JSON.stringify({ lexicon: "aws", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
|
|
339
339
|
["gcp", JSON.stringify({ lexicon: "gcp", environment: "prod", commit: "x", timestamp: "t", resources: {} })],
|
|
@@ -342,14 +342,14 @@ describe("runStateShow", () => {
|
|
|
342
342
|
args: makeArgs({ command: "state", path: "show", extraPositional: "prod" }),
|
|
343
343
|
plugins: [], serializers: [],
|
|
344
344
|
};
|
|
345
|
-
expect(await
|
|
345
|
+
expect(await runLifecycleShow(ctx)).toBe(0);
|
|
346
346
|
const out = stdoutBuf.join("\n");
|
|
347
347
|
expect(out).toContain("prod/aws");
|
|
348
348
|
expect(out).toContain("prod/gcp");
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
-
describe("
|
|
352
|
+
describe("runLifecycleLog", () => {
|
|
353
353
|
let stdoutBuf: string[];
|
|
354
354
|
let stderrBuf: string[];
|
|
355
355
|
|
|
@@ -358,23 +358,23 @@ describe("runStateLog", () => {
|
|
|
358
358
|
stderrBuf = [];
|
|
359
359
|
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
360
360
|
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
361
|
-
|
|
361
|
+
fetchLifecycleMock.mockReset();
|
|
362
362
|
listSnapshotsMock.mockReset();
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
test("returns 1 with message when no entries exist", async () => {
|
|
366
|
-
|
|
366
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
367
367
|
listSnapshotsMock.mockResolvedValue([]);
|
|
368
368
|
const ctx = {
|
|
369
369
|
args: makeArgs({ command: "state", path: "log" }),
|
|
370
370
|
plugins: [], serializers: [],
|
|
371
371
|
};
|
|
372
|
-
expect(await
|
|
372
|
+
expect(await runLifecycleLog(ctx)).toBe(1);
|
|
373
373
|
expect(stderrBuf.join("\n")).toContain("No state snapshots");
|
|
374
374
|
});
|
|
375
375
|
|
|
376
376
|
test("prints commit / date / message rows for each entry", async () => {
|
|
377
|
-
|
|
377
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
378
378
|
listSnapshotsMock.mockResolvedValue([
|
|
379
379
|
{ commit: "abcdef1234567890", date: "2026-05-01T00:00:00Z", message: "Snapshot prod" },
|
|
380
380
|
{ commit: "fedcba9876543210", date: "2026-05-02T00:00:00Z", message: "Snapshot staging" },
|
|
@@ -383,7 +383,7 @@ describe("runStateLog", () => {
|
|
|
383
383
|
args: makeArgs({ command: "state", path: "log" }),
|
|
384
384
|
plugins: [], serializers: [],
|
|
385
385
|
};
|
|
386
|
-
expect(await
|
|
386
|
+
expect(await runLifecycleLog(ctx)).toBe(0);
|
|
387
387
|
const out = stdoutBuf.join("\n");
|
|
388
388
|
expect(out).toContain("abcdef1");
|
|
389
389
|
expect(out).toContain("Snapshot prod");
|
|
@@ -391,7 +391,7 @@ describe("runStateLog", () => {
|
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
-
describe("
|
|
394
|
+
describe("runLifecycleUnknown", () => {
|
|
395
395
|
test("returns 1 with subcommand list", async () => {
|
|
396
396
|
const stderrBuf: string[] = [];
|
|
397
397
|
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
@@ -399,7 +399,7 @@ describe("runStateUnknown", () => {
|
|
|
399
399
|
args: makeArgs({ command: "state", path: "garbage" }),
|
|
400
400
|
plugins: [], serializers: [],
|
|
401
401
|
};
|
|
402
|
-
expect(await
|
|
402
|
+
expect(await runLifecycleUnknown(ctx)).toBe(1);
|
|
403
403
|
const stderr = stderrBuf.join("\n");
|
|
404
404
|
expect(stderr).toContain("snapshot");
|
|
405
405
|
expect(stderr).toContain("show");
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { build } from "../../build";
|
|
3
|
-
import { takeSnapshot } from "../../
|
|
4
|
-
import { readSnapshot, readEnvironmentSnapshots, listSnapshots,
|
|
5
|
-
import { computeBuildDigest, diffDigests } from "../../
|
|
6
|
-
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../
|
|
3
|
+
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
4
|
+
import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
|
+
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
|
+
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
|
|
7
|
+
import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
|
|
7
8
|
import { loadChantConfig } from "../../config";
|
|
8
9
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
9
10
|
import type { CommandContext } from "../registry";
|
|
10
|
-
import type {
|
|
11
|
+
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
11
12
|
import type { SerializerResult } from "../../serializer";
|
|
12
|
-
import type {
|
|
13
|
+
import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
|
|
13
14
|
import type { BuildResult } from "../../build";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* chant
|
|
17
|
+
* chant lifecycle snapshot <environment> [lexicon]
|
|
17
18
|
*/
|
|
18
|
-
export async function
|
|
19
|
+
export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number> {
|
|
19
20
|
const { args, plugins } = ctx;
|
|
20
21
|
const environment = args.extraPositional;
|
|
21
22
|
const lexiconFilter = args.extraPositional2;
|
|
22
23
|
|
|
23
24
|
if (!environment) {
|
|
24
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
25
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle snapshot <environment> [lexicon]" }));
|
|
25
26
|
return 1;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -62,10 +63,10 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
|
|
|
62
63
|
try {
|
|
63
64
|
result = await takeSnapshot(environment, observingPlugins, buildResult);
|
|
64
65
|
} catch (err) {
|
|
65
|
-
if (err instanceof
|
|
66
|
+
if (err instanceof StaleLifecycleBranchError) {
|
|
66
67
|
console.error(formatError({
|
|
67
|
-
message: `Another snapshot completed for chant/
|
|
68
|
-
hint: `Pull and retry: \`git fetch origin ${"chant/
|
|
68
|
+
message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
|
|
69
|
+
hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
|
|
69
70
|
}));
|
|
70
71
|
return 1;
|
|
71
72
|
}
|
|
@@ -83,26 +84,26 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
|
|
|
83
84
|
const counts = result.snapshots
|
|
84
85
|
.map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
|
|
85
86
|
.join(" ");
|
|
86
|
-
console.error(formatSuccess(`Snapshot saved to chant/
|
|
87
|
+
console.error(formatSuccess(`Snapshot saved to chant/lifecycle (${counts})`));
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
return result.errors.length > 0 && result.snapshots.length === 0 ? 1 : 0;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
|
-
* chant
|
|
94
|
+
* chant lifecycle show <environment> [lexicon]
|
|
94
95
|
*/
|
|
95
|
-
export async function
|
|
96
|
+
export async function runLifecycleShow(ctx: CommandContext): Promise<number> {
|
|
96
97
|
const environment = ctx.args.extraPositional;
|
|
97
98
|
const lexiconFilter = ctx.args.extraPositional2;
|
|
98
99
|
|
|
99
100
|
if (!environment) {
|
|
100
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
101
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle show <environment> [lexicon]" }));
|
|
101
102
|
return 1;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
// Fetch from remote first
|
|
105
|
-
await
|
|
106
|
+
await fetchLifecycle();
|
|
106
107
|
|
|
107
108
|
if (lexiconFilter) {
|
|
108
109
|
const content = await readSnapshot(environment, lexiconFilter);
|
|
@@ -111,7 +112,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
111
112
|
return 1;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
const snapshot:
|
|
115
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
115
116
|
printSnapshotTable(snapshot);
|
|
116
117
|
} else {
|
|
117
118
|
const snapshots = await readEnvironmentSnapshots(environment);
|
|
@@ -121,7 +122,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
for (const [lexicon, content] of snapshots) {
|
|
124
|
-
const snapshot:
|
|
125
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
125
126
|
console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources — ${snapshot.timestamp}`);
|
|
126
127
|
printSnapshotTable(snapshot);
|
|
127
128
|
}
|
|
@@ -131,15 +132,15 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
/**
|
|
134
|
-
* chant
|
|
135
|
+
* chant lifecycle diff <environment> [lexicon]
|
|
135
136
|
*/
|
|
136
|
-
export async function
|
|
137
|
+
export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
137
138
|
const { args, plugins, serializers } = ctx;
|
|
138
139
|
const environment = args.extraPositional;
|
|
139
140
|
const lexiconFilter = args.extraPositional2;
|
|
140
141
|
|
|
141
142
|
if (!environment) {
|
|
142
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
143
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle diff <environment> [lexicon]" }));
|
|
143
144
|
return 1;
|
|
144
145
|
}
|
|
145
146
|
|
|
@@ -157,17 +158,17 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
// Fetch and read previous snapshot
|
|
160
|
-
await
|
|
161
|
+
await fetchLifecycle();
|
|
161
162
|
|
|
162
163
|
const lexicons = lexiconFilter
|
|
163
164
|
? [lexiconFilter]
|
|
164
165
|
: Array.from(buildResult.manifest.lexicons);
|
|
165
166
|
|
|
166
167
|
if (args.live) {
|
|
167
|
-
return
|
|
168
|
+
return runLifecycleDiffLive({ environment, lexicons, plugins, buildResult });
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
return
|
|
171
|
+
return runLifecycleDiffDigest({ environment, lexicons, buildResult });
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
interface DigestDiffArgs {
|
|
@@ -176,14 +177,14 @@ interface DigestDiffArgs {
|
|
|
176
177
|
buildResult: BuildResult;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
async function
|
|
180
|
+
async function runLifecycleDiffDigest(args: DigestDiffArgs): Promise<number> {
|
|
180
181
|
const currentDigest = computeBuildDigest(args.buildResult);
|
|
181
182
|
|
|
182
183
|
for (const lexicon of args.lexicons) {
|
|
183
184
|
const content = await readSnapshot(args.environment, lexicon);
|
|
184
185
|
let previousDigest = undefined;
|
|
185
186
|
if (content) {
|
|
186
|
-
const snapshot:
|
|
187
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
187
188
|
previousDigest = snapshot.digest;
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -213,11 +214,11 @@ async function runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
|
|
|
213
214
|
interface LiveDiffArgs {
|
|
214
215
|
environment: string;
|
|
215
216
|
lexicons: string[];
|
|
216
|
-
plugins:
|
|
217
|
+
plugins: ObservationLexicon[];
|
|
217
218
|
buildResult: BuildResult;
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
async function
|
|
221
|
+
async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
|
|
221
222
|
let totalDrift = 0;
|
|
222
223
|
let totalLexiconsChecked = 0;
|
|
223
224
|
|
|
@@ -256,7 +257,7 @@ async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
|
|
|
256
257
|
: (rawOutput as SerializerResult).primary;
|
|
257
258
|
|
|
258
259
|
// Read previous snapshot once; both flows pull what they need.
|
|
259
|
-
let prevSnapshot:
|
|
260
|
+
let prevSnapshot: LifecycleSnapshot | undefined;
|
|
260
261
|
const content = await readSnapshot(args.environment, lexiconName);
|
|
261
262
|
if (content) prevSnapshot = JSON.parse(content);
|
|
262
263
|
|
|
@@ -403,12 +404,123 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
|
|
|
403
404
|
}
|
|
404
405
|
|
|
405
406
|
/**
|
|
406
|
-
* chant
|
|
407
|
+
* chant lifecycle plan <environment> [lexicon]
|
|
408
|
+
*
|
|
409
|
+
* Promote the live diff to a typed, read-only change set: per-entity
|
|
410
|
+
* create / update / delete / adopt / noop. Strictly read-only — never
|
|
411
|
+
* mutates, never deploys. Deletes are never proposed without ownership
|
|
412
|
+
* data (added in #121); an undeclared live resource is `adopt`.
|
|
407
413
|
*/
|
|
408
|
-
export async function
|
|
414
|
+
export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
415
|
+
const { args, plugins, serializers } = ctx;
|
|
416
|
+
const environment = args.extraPositional;
|
|
417
|
+
const lexiconFilter = args.extraPositional2;
|
|
418
|
+
|
|
419
|
+
if (!environment) {
|
|
420
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle plan <environment> [lexicon]" }));
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const targetSerializers = lexiconFilter
|
|
425
|
+
? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
|
|
426
|
+
: serializers;
|
|
427
|
+
|
|
428
|
+
const projectPath = resolve(".");
|
|
429
|
+
const buildResult = await build(projectPath, targetSerializers);
|
|
430
|
+
if (buildResult.errors.length > 0) {
|
|
431
|
+
console.error(formatError({ message: "Build failed — fix errors before planning" }));
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await fetchLifecycle();
|
|
436
|
+
|
|
437
|
+
const lexicons = lexiconFilter ? [lexiconFilter] : Array.from(buildResult.manifest.lexicons);
|
|
438
|
+
|
|
439
|
+
const merged: ChangeSet = { env: environment, entries: [] };
|
|
440
|
+
let checked = 0;
|
|
441
|
+
|
|
442
|
+
for (const lexiconName of lexicons) {
|
|
443
|
+
const plugin = plugins.find((p) => p.name === lexiconName);
|
|
444
|
+
if (!plugin) continue;
|
|
445
|
+
if (!plugin.describeResources) {
|
|
446
|
+
// Plan is entity-keyed; artifact-only lexicons have no declared axis.
|
|
447
|
+
if (!args.json) {
|
|
448
|
+
console.error(formatWarning({
|
|
449
|
+
message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const declared = new Set<string>();
|
|
456
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
457
|
+
for (const [name, entity] of buildResult.entities) {
|
|
458
|
+
if (entity.lexicon === lexiconName) {
|
|
459
|
+
declared.add(name);
|
|
460
|
+
entities.set(name, {
|
|
461
|
+
entityType: entity.entityType,
|
|
462
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const rawOutput = buildResult.outputs.get(lexiconName);
|
|
468
|
+
const buildOutput =
|
|
469
|
+
rawOutput === undefined
|
|
470
|
+
? ""
|
|
471
|
+
: typeof rawOutput === "string"
|
|
472
|
+
? rawOutput
|
|
473
|
+
: (rawOutput as SerializerResult).primary;
|
|
474
|
+
|
|
475
|
+
let observedNow: Record<string, ResourceMetadata>;
|
|
476
|
+
try {
|
|
477
|
+
observedNow = await plugin.describeResources({
|
|
478
|
+
environment,
|
|
479
|
+
buildOutput,
|
|
480
|
+
entityNames: Array.from(declared),
|
|
481
|
+
entities,
|
|
482
|
+
owned: args.owned,
|
|
483
|
+
});
|
|
484
|
+
} catch (err) {
|
|
485
|
+
console.error(formatError({
|
|
486
|
+
message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
487
|
+
}));
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const content = await readSnapshot(environment, lexiconName);
|
|
492
|
+
const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
|
|
493
|
+
|
|
494
|
+
const cs = buildChangeSet(environment, { declared, observedNow, observedThen });
|
|
495
|
+
merged.entries.push(...cs.entries);
|
|
496
|
+
checked++;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (checked === 0) {
|
|
500
|
+
console.error(formatWarning({
|
|
501
|
+
message: "No lexicons implement describeResources — nothing to plan",
|
|
502
|
+
}));
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
507
|
+
|
|
508
|
+
if (args.json) {
|
|
509
|
+
console.log(JSON.stringify(merged, null, 2));
|
|
510
|
+
} else {
|
|
511
|
+
console.log(renderChangeSet(merged));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* chant lifecycle log [environment]
|
|
519
|
+
*/
|
|
520
|
+
export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
|
|
409
521
|
const environment = ctx.args.extraPositional;
|
|
410
522
|
|
|
411
|
-
await
|
|
523
|
+
await fetchLifecycle();
|
|
412
524
|
|
|
413
525
|
const entries = await listSnapshots({ environment });
|
|
414
526
|
if (entries.length === 0) {
|
|
@@ -427,15 +539,15 @@ export async function runStateLog(ctx: CommandContext): Promise<number> {
|
|
|
427
539
|
/**
|
|
428
540
|
* Fallback for unknown state subcommands.
|
|
429
541
|
*/
|
|
430
|
-
export async function
|
|
542
|
+
export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
|
|
431
543
|
console.error(formatError({
|
|
432
544
|
message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
|
|
433
|
-
hint: "Available: chant
|
|
545
|
+
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
|
|
434
546
|
}));
|
|
435
547
|
return 1;
|
|
436
548
|
}
|
|
437
549
|
|
|
438
|
-
function printSnapshotTable(snapshot:
|
|
550
|
+
function printSnapshotTable(snapshot: LifecycleSnapshot): void {
|
|
439
551
|
console.log("RESOURCE".padEnd(20) + "TYPE".padEnd(28) + "PHYSICAL ID".padEnd(44) + "STATUS");
|
|
440
552
|
console.log("-".repeat(100));
|
|
441
553
|
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listCommand, printListResult } from "../commands/list";
|
|
2
|
-
import { importCommand, printImportResult } from "../commands/import";
|
|
3
|
-
import {
|
|
2
|
+
import { importCommand, importFromLive, printImportResult } from "../commands/import";
|
|
3
|
+
import type { ResourceSelector } from "../../lexicon";
|
|
4
|
+
import { formatError, formatSuccess, formatWarning } from "../format";
|
|
4
5
|
import type { CommandContext } from "../registry";
|
|
5
6
|
|
|
6
7
|
export async function runList(ctx: CommandContext): Promise<number> {
|
|
@@ -21,6 +22,34 @@ export async function runList(ctx: CommandContext): Promise<number> {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export async function runImport(ctx: CommandContext): Promise<number> {
|
|
25
|
+
const { args } = ctx;
|
|
26
|
+
|
|
27
|
+
// `--from <env>` switches import from a template file to a live source.
|
|
28
|
+
if (args.migrateFrom) {
|
|
29
|
+
const selector: ResourceSelector | undefined =
|
|
30
|
+
args.selectType || args.selectName
|
|
31
|
+
? { type: args.selectType, name: args.selectName }
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
// Live config may carry secrets into generated source — warn before writing.
|
|
35
|
+
console.error(formatWarning({
|
|
36
|
+
message: "Live import may emit sensitive values (keys, tokens, passwords) into generated source. Review before committing.",
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const result = await importFromLive({
|
|
40
|
+
environment: args.migrateFrom,
|
|
41
|
+
lexicon: args.lexicon,
|
|
42
|
+
output: args.output,
|
|
43
|
+
force: args.force,
|
|
44
|
+
selector,
|
|
45
|
+
owned: args.owned,
|
|
46
|
+
verbatim: args.verbatim,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
printImportResult(result);
|
|
50
|
+
return result.success ? 0 : 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
const result = await importCommand({
|
|
25
54
|
templatePath: ctx.args.path,
|
|
26
55
|
output: ctx.args.output,
|