@intentius/chant 0.33.1 → 0.34.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/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +49 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +26 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +14 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +19 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/lexicon.d.ts +141 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +4 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +55 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +47 -0
- package/dist/lifecycle/replay.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts +6 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +46 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/onboard.ts +10 -25
- package/src/cli/handlers/graph.test.ts +74 -0
- package/src/cli/handlers/graph.ts +77 -36
- package/src/cli/handlers/lifecycle.test.ts +86 -0
- package/src/cli/handlers/lifecycle.ts +43 -10
- package/src/cli/handlers/search.test.ts +200 -4
- package/src/cli/handlers/search.ts +383 -29
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +27 -0
- package/src/codegen/lexicon-wiring.test.ts +53 -0
- package/src/codegen/publish-order.test.ts +133 -0
- package/src/codegen/release-wiring.test.ts +92 -0
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +51 -1
- package/src/graph-refs.test.ts +59 -0
- package/src/graph-refs.ts +39 -8
- package/src/lexicon.ts +145 -0
- package/src/lifecycle/deep-observe.ts +5 -0
- package/src/lifecycle/live-diff.test.ts +38 -0
- package/src/lifecycle/live-diff.ts +45 -2
- package/src/lifecycle/observe.ts +186 -4
- package/src/lifecycle/replay.ts +141 -0
- package/src/lifecycle/snapshot.test.ts +179 -0
- package/src/lifecycle/snapshot.ts +88 -3
- package/src/lifecycle/types.ts +47 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a recorded snapshot as if it had just been observed (#1266, #1279).
|
|
3
|
+
*
|
|
4
|
+
* Shared by `chant search --at` and `chant graph --at`. A snapshot already holds
|
|
5
|
+
* what an observation is — resources with their physical ids and attributes,
|
|
6
|
+
* and since #1266 the relationships between them — so turning it back into
|
|
7
|
+
* `LiveObservation[]` lets a replay rejoin the live path at `buildLiveGraphIr`.
|
|
8
|
+
* Every fold, overlay and edge reconstruction below that point is shared, and
|
|
9
|
+
* nothing downstream needs to know which source it got.
|
|
10
|
+
*
|
|
11
|
+
* That sharing is the reason this is a module rather than a helper inside one
|
|
12
|
+
* command: `graph` had no `--at` at all, so anyone wanting the raw IR of a
|
|
13
|
+
* recorded estate had to reach for the live endpoint, and a snapshot could
|
|
14
|
+
* answer most questions but never all of them.
|
|
15
|
+
*/
|
|
16
|
+
import type { LiveObservation } from "../graph-ir.js";
|
|
17
|
+
/**
|
|
18
|
+
* Rebuild observations from a recorded snapshot (#1266).
|
|
19
|
+
*
|
|
20
|
+
* A snapshot already holds what an observation is: resources with their
|
|
21
|
+
* physical ids and attributes, and — since #1266 — the relationships between
|
|
22
|
+
* them. Turning it back into `LiveObservation[]` means the replay rejoins the
|
|
23
|
+
* live path at `buildLiveGraphIr`, and every fold, overlay and query below that
|
|
24
|
+
* is shared. Nothing downstream needs to know which source it got.
|
|
25
|
+
*
|
|
26
|
+
* `latest` is the only ref for now. A specific commit is the natural extension
|
|
27
|
+
* and the storage already supports it (`readSnapshotAt`), but "answer from what
|
|
28
|
+
* is recorded" is the question worth settling first.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Whether this environment has a recording, without reading one.
|
|
32
|
+
*
|
|
33
|
+
* Used to turn "the estate could not be read" into "the estate could not be
|
|
34
|
+
* read, and you already have a recording of it". Cheap and best-effort: a
|
|
35
|
+
* failure here means the caller says the plain version of the message, never
|
|
36
|
+
* that the command fails.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hasSnapshot(environment: string): Promise<boolean>;
|
|
39
|
+
export declare function replaySnapshots(environment: string, ref: string, scopedStacks: Set<string>): Promise<{
|
|
40
|
+
observations: LiveObservation[];
|
|
41
|
+
commit: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
} | {
|
|
44
|
+
error: string;
|
|
45
|
+
hint?: string;
|
|
46
|
+
}>;
|
|
47
|
+
//# sourceMappingURL=replay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../src/lifecycle/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD;;;;;;;;;;;;GAYG;AACH;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMvE;AAED,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GACxB,OAAO,CAAC;IAAE,YAAY,EAAE,eAAe,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAuFpH"}
|
|
@@ -17,5 +17,11 @@ export interface TakeSnapshotResult {
|
|
|
17
17
|
export declare function takeSnapshot(environment: string, plugins: ObservationLexicon[], buildResult: BuildResult, opts?: {
|
|
18
18
|
cwd?: string;
|
|
19
19
|
stack?: string;
|
|
20
|
+
region?: string;
|
|
21
|
+
deep?: boolean;
|
|
22
|
+
ambient?: boolean;
|
|
23
|
+
/** Kinds the PROJECT manages, not just this stack — a region whose stack
|
|
24
|
+
* declares no security group still has a default one (#1278). */
|
|
25
|
+
ambientKinds?: string[];
|
|
20
26
|
}): Promise<TakeSnapshotResult>;
|
|
21
27
|
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/lifecycle/snapshot.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAsC,MAAM,YAAY,CAAC;AACzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../../src/lifecycle/snapshot.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAsC,MAAM,YAAY,CAAC;AACzF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C,OAAO,KAAK,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAC;AAgEnE,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;qEACiE;IACjE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB,GACA,OAAO,CAAC,kBAAkB,CAAC,CAmN7B"}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon.js";
|
|
2
2
|
import type { UnobservedEntity } from "../observation.js";
|
|
3
|
+
import type { DeepResourceObservation } from "../deep-observation.js";
|
|
4
|
+
import type { IREdge } from "../graph-ir.js";
|
|
3
5
|
export type { ResourceMetadata, ArtifactMetadata } from "../lexicon.js";
|
|
6
|
+
/**
|
|
7
|
+
* How much of each resource an observation actually read (#1267).
|
|
8
|
+
*
|
|
9
|
+
* `identity` is the thin path: logical name, type, physical id, status. It is
|
|
10
|
+
* what a snapshot recorded before deep reads existed, and it stays the default
|
|
11
|
+
* because a deep read costs more provider calls and a larger record.
|
|
12
|
+
*
|
|
13
|
+
* `deep` additionally carries each resource's normalized property tree, which
|
|
14
|
+
* is what a fold over topology needs — a subnet's route-table association, a
|
|
15
|
+
* security group's rules. A consumer must branch on this rather than assume:
|
|
16
|
+
* asking an `identity` snapshot a property question has no answer, and
|
|
17
|
+
* silently returning nothing would read as "no such resources".
|
|
18
|
+
*/
|
|
19
|
+
export type ObservationDepth = "identity" | "deep";
|
|
4
20
|
/**
|
|
5
21
|
* State snapshot for a single lexicon in an environment.
|
|
6
22
|
*/
|
|
@@ -26,6 +42,36 @@ export interface LifecycleSnapshot {
|
|
|
26
42
|
unobserved?: Record<string, UnobservedEntity>;
|
|
27
43
|
/** Artifact metadata keyed by server-side identifier (lexicon-specific). */
|
|
28
44
|
artifacts?: Record<string, ArtifactMetadata>;
|
|
45
|
+
/**
|
|
46
|
+
* How much of each resource this snapshot read (#1267). Absent means
|
|
47
|
+
* `identity` — every snapshot written before deep reads existed was thin, and
|
|
48
|
+
* treating a missing field as unknown rather than as thin would invalidate
|
|
49
|
+
* them all.
|
|
50
|
+
*/
|
|
51
|
+
depth?: ObservationDepth;
|
|
52
|
+
/**
|
|
53
|
+
* Normalized per-resource property trees, present only at `deep` depth and
|
|
54
|
+
* keyed by the same logical names as `resources`.
|
|
55
|
+
*
|
|
56
|
+
* Kept beside `resources` rather than merged into it so the thin record stays
|
|
57
|
+
* exactly what it always was: a reader that only wants identity does not have
|
|
58
|
+
* to learn a new shape, and an old snapshot and a new one parse the same way.
|
|
59
|
+
*/
|
|
60
|
+
properties?: Record<string, DeepResourceObservation>;
|
|
61
|
+
/**
|
|
62
|
+
* Relationships observed between the recorded resources (#1266).
|
|
63
|
+
*
|
|
64
|
+
* `resources` says what existed; without this a snapshot cannot say how any
|
|
65
|
+
* of it connected, so a fold over topology has nothing to traverse when the
|
|
66
|
+
* snapshot is replayed. That is the difference between a snapshot answering
|
|
67
|
+
* "which instances exist" and answering "which are reachable from the
|
|
68
|
+
* internet" — and the second is the whole reason the graph is worth
|
|
69
|
+
* recording.
|
|
70
|
+
*
|
|
71
|
+
* Absent on every snapshot written before this, which is read as "no
|
|
72
|
+
* relationships recorded" rather than "no relationships existed".
|
|
73
|
+
*/
|
|
74
|
+
edges?: IREdge[];
|
|
29
75
|
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
30
76
|
digest?: BuildDigest;
|
|
31
77
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AACnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAErE;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB;;gFAE4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC9C,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;IACrD;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,qFAAqF;IACrF,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC1C,sCAAsC;IACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/E,iCAAiC;IACjC,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB"}
|
package/package.json
CHANGED
|
@@ -171,40 +171,25 @@ function patchCiWorkflow(root: string, name: string): { patched: boolean; reason
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
|
-
* Patch publish.yml: add prepack line
|
|
174
|
+
* Patch publish.yml: add the prepack line to the test job.
|
|
175
|
+
*
|
|
176
|
+
* No publish step is added. scripts/publish-packages.sh enumerates every
|
|
177
|
+
* non-private workspace package at run time, so a new lexicon is published
|
|
178
|
+
* the moment it exists — that is deliberate. Hand-maintained publish steps
|
|
179
|
+
* were how k8s-client (#1177) and fountain (#1253) each shipped a package
|
|
180
|
+
* the release pipeline could not publish.
|
|
175
181
|
*/
|
|
176
182
|
function patchPublishWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
|
|
177
183
|
const filePath = join(root, ".github/workflows/publish.yml");
|
|
178
184
|
if (!existsSync(filePath)) return { patched: false, reason: "publish.yml not found" };
|
|
179
185
|
|
|
180
186
|
const content = readFileSync(filePath, "utf-8");
|
|
181
|
-
if (content.includes(`
|
|
182
|
-
return { patched: false, reason: `
|
|
187
|
+
if (content.includes(`lexicons/${name} prepack`)) {
|
|
188
|
+
return { patched: false, reason: `prepack for ${name} already present` };
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
const lines = content.split("\n");
|
|
186
|
-
|
|
187
|
-
// Insert prepack line in test job
|
|
188
192
|
insertPrepackAfterEach(lines, name);
|
|
189
|
-
|
|
190
|
-
// Add publish step after the last existing publish step
|
|
191
|
-
let lastPublishRunIdx = -1;
|
|
192
|
-
for (let i = 0; i < lines.length; i++) {
|
|
193
|
-
if (lines[i].includes("npm publish --access public")) {
|
|
194
|
-
lastPublishRunIdx = i;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (lastPublishRunIdx > 0) {
|
|
199
|
-
const block = [
|
|
200
|
-
"",
|
|
201
|
-
` - name: Publish @intentius/chant-lexicon-${name}`,
|
|
202
|
-
` working-directory: lexicons/${name}`,
|
|
203
|
-
" run: npm publish --access public",
|
|
204
|
-
];
|
|
205
|
-
lines.splice(lastPublishRunIdx + 1, 0, ...block);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
193
|
writeFileSync(filePath, lines.join("\n"));
|
|
209
194
|
return { patched: true };
|
|
210
195
|
}
|
|
@@ -246,7 +231,7 @@ export function onboardCommand(options: OnboardOptions): OnboardResult {
|
|
|
246
231
|
|
|
247
232
|
// 3. Publish workflow
|
|
248
233
|
const pubResult = patchPublishWorkflow(root, options.name);
|
|
249
|
-
if (pubResult.patched) patched.push("publish.yml (prepack
|
|
234
|
+
if (pubResult.patched) patched.push("publish.yml (prepack)");
|
|
250
235
|
else skipped.push(`publish.yml: ${pubResult.reason}`);
|
|
251
236
|
|
|
252
237
|
// 4. Dockerfiles
|
|
@@ -43,6 +43,13 @@ vi.mock("../plugins", () => ({
|
|
|
43
43
|
resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
|
|
44
44
|
}));
|
|
45
45
|
const observeMock = vi.fn();
|
|
46
|
+
const replayMock = vi.fn();
|
|
47
|
+
const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
|
|
48
|
+
vi.mock("../../lifecycle/replay", () => ({
|
|
49
|
+
replaySnapshots: (...a: unknown[]) => replayMock(...a),
|
|
50
|
+
hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
|
|
51
|
+
}));
|
|
52
|
+
|
|
46
53
|
vi.mock("../../lifecycle/observe", () => ({
|
|
47
54
|
observeResources: (...a: unknown[]) => observeMock(...a),
|
|
48
55
|
}));
|
|
@@ -435,6 +442,73 @@ describe("runGraph", () => {
|
|
|
435
442
|
expect(out).toContain("web-vpc");
|
|
436
443
|
});
|
|
437
444
|
|
|
445
|
+
// #1279 — `graph` had no `--at`, so anyone wanting the raw IR of a recorded
|
|
446
|
+
// estate had to reach for the live endpoint. A snapshot could answer most
|
|
447
|
+
// questions and never all of them.
|
|
448
|
+
test("--at graphs the recorded snapshot without reading the estate", async () => {
|
|
449
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
450
|
+
loadPluginsMock.mockResolvedValue([
|
|
451
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}),
|
|
452
|
+
enrichLiveAttrs: () => Promise.reject(new Error("must not be called on a replay")) },
|
|
453
|
+
]);
|
|
454
|
+
replayMock.mockResolvedValue({
|
|
455
|
+
observations: [{ lexicon: "aws", resources: {
|
|
456
|
+
web: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" },
|
|
457
|
+
} }],
|
|
458
|
+
commit: "abc1234def",
|
|
459
|
+
timestamp: "2026-07-31T00:00:00.000Z",
|
|
460
|
+
});
|
|
461
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", at: "latest", env: "prod" }), plugins: [], serializers: [] });
|
|
462
|
+
expect(exit).toBe(0);
|
|
463
|
+
expect(observeMock).not.toHaveBeenCalled();
|
|
464
|
+
expect(replayMock).toHaveBeenCalled();
|
|
465
|
+
expect(stdoutBuf.join("\n")).toContain("i-1");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Denied the network, agents read the empty graph as an empty estate and
|
|
469
|
+
// spent their turns retrying --live. The per-entity warnings describe the
|
|
470
|
+
// same failure N times and never name the thing that would answer.
|
|
471
|
+
test("an unreadable estate names the recorded snapshot", async () => {
|
|
472
|
+
observeMock.mockClear();
|
|
473
|
+
hasSnapshotMock.mockResolvedValue(true);
|
|
474
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
475
|
+
loadPluginsMock.mockResolvedValue([
|
|
476
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
477
|
+
]);
|
|
478
|
+
observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
|
|
479
|
+
const errs: string[] = [];
|
|
480
|
+
const spy = vi.spyOn(console, "error").mockImplementation((s: string) => { errs.push(s); });
|
|
481
|
+
await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
482
|
+
spy.mockRestore();
|
|
483
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
484
|
+
expect(errs.join("\n")).toContain("--at latest");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("says nothing about a snapshot when there is none to name", async () => {
|
|
488
|
+
observeMock.mockClear();
|
|
489
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
490
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
491
|
+
loadPluginsMock.mockResolvedValue([
|
|
492
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
493
|
+
]);
|
|
494
|
+
observeMock.mockResolvedValue({ observations: [], errors: ["could not connect"], warnings: [] });
|
|
495
|
+
const errs: string[] = [];
|
|
496
|
+
const spy = vi.spyOn(console, "error").mockImplementation((s: string) => { errs.push(s); });
|
|
497
|
+
await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
498
|
+
spy.mockRestore();
|
|
499
|
+
expect(errs.join("\n")).not.toContain("--at latest");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("--at and --live together is refused rather than guessed at", async () => {
|
|
503
|
+
observeMock.mockClear();
|
|
504
|
+
replayMock.mockClear();
|
|
505
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
506
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", at: "latest", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
507
|
+
expect(exit).toBe(1);
|
|
508
|
+
expect(observeMock).not.toHaveBeenCalled();
|
|
509
|
+
expect(replayMock).not.toHaveBeenCalled();
|
|
510
|
+
});
|
|
511
|
+
|
|
438
512
|
// Regression for #57: a single-stack project (no `*.component.ts` files —
|
|
439
513
|
// discoverComponents returns an empty map) must observe exactly as before,
|
|
440
514
|
// no `stacks` collected.
|
|
@@ -6,6 +6,7 @@ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourc
|
|
|
6
6
|
import { buildDeclaredPerStack } from "../../graph-declared";
|
|
7
7
|
import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
|
|
8
8
|
import { observeResources } from "../../lifecycle/observe";
|
|
9
|
+
import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
|
|
9
10
|
import { loadChantConfig, environmentNames } from "../../config";
|
|
10
11
|
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
11
12
|
import { applyDetail, type DetailLevel } from "../../graph-detail";
|
|
@@ -47,9 +48,18 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
|
47
48
|
}));
|
|
48
49
|
return 1;
|
|
49
50
|
}
|
|
51
|
+
// `--at` graphs a recorded observation instead of reading the estate (#1279).
|
|
52
|
+
// Two different observations with no rule for which wins, so not both.
|
|
53
|
+
if (ctx.args.at && ctx.args.live) {
|
|
54
|
+
console.error(formatError({
|
|
55
|
+
message: "chant graph takes --live or --at, not both",
|
|
56
|
+
hint: "--live reads the estate now; --at graphs a recorded snapshot",
|
|
57
|
+
}));
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
50
60
|
// `--live` graphs the provisioned (observed) infrastructure, not the declared
|
|
51
61
|
// source (epic #776). It only makes sense as a view format; default to `ir`.
|
|
52
|
-
if (ctx.args.live) {
|
|
62
|
+
if (ctx.args.live || ctx.args.at) {
|
|
53
63
|
return runGraphLive(ctx, isViewFormat ? (ctx.args.format as (typeof viewFormats)[number]) : "ir");
|
|
54
64
|
}
|
|
55
65
|
if (isViewFormat) {
|
|
@@ -136,50 +146,81 @@ async function runGraphLive(
|
|
|
136
146
|
}
|
|
137
147
|
}
|
|
138
148
|
} else {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
|
|
150
|
+
}
|
|
151
|
+
// ChantConfig.stacks (with optional per-stack region) — multi-region estates.
|
|
152
|
+
for (const declared of config.stacks ?? []) {
|
|
153
|
+
if (!seenStacks.has(declared.name)) { seenStacks.add(declared.name); stacks.push({ name: declared.name, region: declared.region, src: declared.src }); }
|
|
154
|
+
}
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
157
|
+
// Floci), so this read is self-sufficient even when the ambient shell never
|
|
158
|
+
// exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
|
|
159
|
+
// Scoped to just this describe/enrich pass — restored in `finally` so it
|
|
160
|
+
// never leaks into a later invocation in the same process.
|
|
161
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
|
|
162
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
163
|
+
|
|
164
|
+
let ir: GraphIR;
|
|
165
|
+
let observations: LiveObservation[];
|
|
166
|
+
// A replay is a graph of what was recorded, so it must not reach the provider
|
|
167
|
+
// at all — not for observation and not for enrichment. Anything that quietly
|
|
168
|
+
// called out would make `--at` a live read with an older label on it.
|
|
169
|
+
const replaying = !!args.at && !args.live;
|
|
170
|
+
try {
|
|
171
|
+
if (replaying) {
|
|
172
|
+
const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
|
|
173
|
+
const replay = await replaySnapshots(environment, String(args.at), scoped);
|
|
174
|
+
if ("error" in replay) {
|
|
175
|
+
console.error(formatError({ message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) }));
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
observations = replay.observations;
|
|
179
|
+
console.error(formatWarning({
|
|
180
|
+
message: `graphing snapshot ${replay.commit.slice(0, 7)} taken ${replay.timestamp} — the estate was not read`,
|
|
181
|
+
}));
|
|
182
|
+
} else {
|
|
183
|
+
const observeResult = await observeResources(environment, observing, buildResult, {
|
|
184
|
+
owned: true,
|
|
185
|
+
stacks: [...stacks],
|
|
186
|
+
});
|
|
187
|
+
observations = observeResult.observations;
|
|
188
|
+
const { errors, warnings } = observeResult;
|
|
189
|
+
for (const e of errors) console.error(formatWarning({ message: e }));
|
|
190
|
+
// Unobserved entities (#1089) arrive as warnings — a node missing from the
|
|
191
|
+
// live graph because nobody looked is a different fact from one that isn't
|
|
192
|
+
// deployed, and the diagram alone cannot say which. Capped: an estate with no
|
|
193
|
+
// ownership markers can produce one per declared entity, and a wall of them
|
|
194
|
+
// buries the graph output. The full list is `lifecycle diff --live`.
|
|
195
|
+
const WARN_CAP = 5;
|
|
196
|
+
for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
|
|
197
|
+
if (warnings.length > WARN_CAP) {
|
|
198
|
+
console.error(formatWarning({
|
|
199
|
+
message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
153
203
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Unobserved entities (#1089) arrive as warnings — a node missing from the
|
|
165
|
-
// live graph because nobody looked is a different fact from one that isn't
|
|
166
|
-
// deployed, and the diagram alone cannot say which. Capped: an estate with no
|
|
167
|
-
// ownership markers can produce one per declared entity, and a wall of them
|
|
168
|
-
// buries the graph output. The full list is `lifecycle diff --live`.
|
|
169
|
-
const WARN_CAP = 5;
|
|
170
|
-
for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
|
|
171
|
-
if (warnings.length > WARN_CAP) {
|
|
204
|
+
// Both paths land here: a replay rejoins the live pipeline at exactly this
|
|
205
|
+
// point, which is what makes every fold and overlay below shared.
|
|
206
|
+
ir = buildLiveGraphIr(observations);
|
|
207
|
+
|
|
208
|
+
// The estate was asked for and nothing came back. Say that once, and say
|
|
209
|
+
// that a recording exists — the per-entity warnings above describe the same
|
|
210
|
+
// failure N times without ever naming the thing that would answer. Agents
|
|
211
|
+
// denied the network read the empty graph as an empty estate and spent
|
|
212
|
+
// their turns retrying `--live`.
|
|
213
|
+
if (!replaying && ir.nodes.length === 0 && (await hasSnapshot(environment))) {
|
|
172
214
|
console.error(formatWarning({
|
|
173
|
-
message:
|
|
215
|
+
message: `could not read the estate for "${environment}" — a snapshot of it is recorded; graph that instead with --at latest`,
|
|
174
216
|
}));
|
|
175
217
|
}
|
|
176
218
|
|
|
177
|
-
ir = buildLiveGraphIr(observations);
|
|
178
|
-
|
|
179
219
|
// Enrich node attrs from the fuller live config (#784) so references are
|
|
180
220
|
// present for edge reconstruction — describeResources metadata alone is often
|
|
181
221
|
// too thin (e.g. AWS returns stack outputs, not per-resource references).
|
|
182
|
-
|
|
222
|
+
// Live-only: enrichment is a provider call, so a replay must skip it.
|
|
223
|
+
for (const p of replaying ? [] : observing) {
|
|
183
224
|
if (!p.enrichLiveAttrs) continue;
|
|
184
225
|
try {
|
|
185
226
|
const enriched = await p.enrichLiveAttrs({ environment, owned: true, stacks });
|
|
@@ -734,6 +734,92 @@ describe("runLifecycleSnapshot", () => {
|
|
|
734
734
|
expect(takeSnapshotMock).toHaveBeenCalledTimes(1);
|
|
735
735
|
});
|
|
736
736
|
|
|
737
|
+
// #1261 — each stack declares the region it deploys to. Dropping it here
|
|
738
|
+
// observed every stack against the ambient region, so a multi-region estate
|
|
739
|
+
// snapshotted only the stacks that shared it and reported the rest as
|
|
740
|
+
// "no valid resources or artifacts returned".
|
|
741
|
+
test("multi-stack: each stack's declared region reaches takeSnapshot", async () => {
|
|
742
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
743
|
+
takeSnapshotMock.mockResolvedValue({
|
|
744
|
+
snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
|
|
745
|
+
commit: "sha",
|
|
746
|
+
warnings: [],
|
|
747
|
+
errors: [],
|
|
748
|
+
});
|
|
749
|
+
loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"], stacks: [
|
|
750
|
+
{ name: "app-us-east-1", src: "src/us-east-1", region: "us-east-1" },
|
|
751
|
+
{ name: "app-us-west-2", src: "src/us-west-2", region: "us-west-2" },
|
|
752
|
+
] } });
|
|
753
|
+
const plugins: LexiconPlugin[] = [
|
|
754
|
+
createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
const exit = await runLifecycleSnapshot({
|
|
758
|
+
args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
|
|
759
|
+
plugins,
|
|
760
|
+
serializers: plugins.map((p) => p.serializer),
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(exit).toBe(0);
|
|
764
|
+
// toMatchObject, not toEqual: this test is about region reaching the
|
|
765
|
+
// snapshot, and pinning the whole options object makes it fail whenever an
|
|
766
|
+
// unrelated option is added.
|
|
767
|
+
const opts = takeSnapshotMock.mock.calls.map((c) => c[3]);
|
|
768
|
+
expect(opts[0]).toMatchObject({ stack: "app-us-east-1", region: "us-east-1" });
|
|
769
|
+
expect(opts[1]).toMatchObject({ stack: "app-us-west-2", region: "us-west-2" });
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// #1267 — --deep is opt-in and reaches takeSnapshot; without it the snapshot
|
|
773
|
+
// stays thin, which is what every pre-#1267 snapshot was.
|
|
774
|
+
test("--deep reaches takeSnapshot; absent means identity", async () => {
|
|
775
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
776
|
+
takeSnapshotMock.mockResolvedValue({
|
|
777
|
+
snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
|
|
778
|
+
commit: "sha",
|
|
779
|
+
warnings: [],
|
|
780
|
+
errors: [],
|
|
781
|
+
});
|
|
782
|
+
const plugins: LexiconPlugin[] = [
|
|
783
|
+
createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
|
|
784
|
+
];
|
|
785
|
+
const ctx = (deep?: boolean) => ({
|
|
786
|
+
args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod", ...(deep ? { deep: true } : {}) }),
|
|
787
|
+
plugins,
|
|
788
|
+
serializers: plugins.map((p) => p.serializer),
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
await runLifecycleSnapshot(ctx(true));
|
|
792
|
+
expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ deep: true });
|
|
793
|
+
|
|
794
|
+
takeSnapshotMock.mockClear();
|
|
795
|
+
await runLifecycleSnapshot(ctx());
|
|
796
|
+
expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ deep: undefined });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("stack without a declared region: region stays undefined", async () => {
|
|
800
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
801
|
+
takeSnapshotMock.mockResolvedValue({
|
|
802
|
+
snapshots: [{ lexicon: "aws", environment: "prod", resources: { bucket: meta() } }],
|
|
803
|
+
commit: "sha",
|
|
804
|
+
warnings: [],
|
|
805
|
+
errors: [],
|
|
806
|
+
});
|
|
807
|
+
loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"], stacks: [
|
|
808
|
+
{ name: "app", src: "src/app" },
|
|
809
|
+
] } });
|
|
810
|
+
const plugins: LexiconPlugin[] = [
|
|
811
|
+
createMockPlugin({ name: "aws", describeResources: staticDescribeResources({ bucket: meta() }) }),
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
await runLifecycleSnapshot({
|
|
815
|
+
args: makeArgs({ command: "state", path: "snapshot", extraPositional: "prod" }),
|
|
816
|
+
plugins,
|
|
817
|
+
serializers: plugins.map((p) => p.serializer),
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
expect(takeSnapshotMock.mock.calls[0][3]).toMatchObject({ stack: "app", region: undefined });
|
|
821
|
+
});
|
|
822
|
+
|
|
737
823
|
// #1166 — a snapshot is always a live read, so a declared environment
|
|
738
824
|
// endpoint applies here too, unless the ambient shell already set it.
|
|
739
825
|
describe("declared endpoint (#1166)", () => {
|
|
@@ -62,6 +62,11 @@ interface StackTarget {
|
|
|
62
62
|
/** Build root to synthesize this stack from, scoped so its logical ids match
|
|
63
63
|
* what the stack actually deploys. */
|
|
64
64
|
root: string;
|
|
65
|
+
/** Region the stack is deployed in, from `stacks[].region` (#1261). Without
|
|
66
|
+
* it every stack is observed against the ambient region, so a multi-region
|
|
67
|
+
* estate snapshots only the stacks that happen to share it and reports the
|
|
68
|
+
* rest as "no valid resources or artifacts returned". */
|
|
69
|
+
region?: string;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -75,7 +80,7 @@ interface StackTarget {
|
|
|
75
80
|
function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
|
|
76
81
|
if (args.src) return [{ root: resolve(args.src) }];
|
|
77
82
|
if (config.stacks && config.stacks.length > 0) {
|
|
78
|
-
return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src) }));
|
|
83
|
+
return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src), region: s.region }));
|
|
79
84
|
}
|
|
80
85
|
return [{ root: resolveBuildRoot(args, config) }];
|
|
81
86
|
}
|
|
@@ -133,19 +138,39 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
133
138
|
const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
|
|
134
139
|
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
135
140
|
|
|
141
|
+
// Build every stack first, so the ambient scan (#1278) can be bounded by the
|
|
142
|
+
// kinds the PROJECT manages rather than the ones this stack happens to
|
|
143
|
+
// declare. "Which of my security groups are unused" is a question about the
|
|
144
|
+
// estate; a region whose stack declares no security group still has a default
|
|
145
|
+
// one, and scoping the bound per stack silently drops it.
|
|
146
|
+
const built: Array<{ target: (typeof targets)[number]; buildResult: Awaited<ReturnType<typeof build>> }> = [];
|
|
147
|
+
for (const target of targets) {
|
|
148
|
+
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
149
|
+
const buildResult = await build(target.root, targetSerializers);
|
|
150
|
+
if (buildResult.errors.length > 0) {
|
|
151
|
+
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
152
|
+
anyHardError = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
built.push({ target, buildResult });
|
|
156
|
+
}
|
|
157
|
+
const projectKinds = [
|
|
158
|
+
...new Set(built.flatMap(({ buildResult }) => [...buildResult.entities.values()].map((e) => e.entityType))),
|
|
159
|
+
];
|
|
160
|
+
|
|
136
161
|
try {
|
|
137
|
-
for (const target of
|
|
162
|
+
for (const { target, buildResult } of built) {
|
|
138
163
|
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
139
|
-
const buildResult = await build(target.root, targetSerializers);
|
|
140
|
-
if (buildResult.errors.length > 0) {
|
|
141
|
-
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
142
|
-
anyHardError = true;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
164
|
|
|
146
165
|
let result;
|
|
147
166
|
try {
|
|
148
|
-
result = await takeSnapshot(environment, observingPlugins, buildResult, {
|
|
167
|
+
result = await takeSnapshot(environment, observingPlugins, buildResult, {
|
|
168
|
+
stack: target.stack,
|
|
169
|
+
region: target.region,
|
|
170
|
+
deep: args.deep,
|
|
171
|
+
ambient: args.ambient,
|
|
172
|
+
ambientKinds: projectKinds,
|
|
173
|
+
});
|
|
149
174
|
} catch (err) {
|
|
150
175
|
if (err instanceof StaleLifecycleBranchError) {
|
|
151
176
|
console.error(formatError({
|
|
@@ -214,7 +239,15 @@ export async function runLifecycleShow(ctx: CommandContext): Promise<number> {
|
|
|
214
239
|
|
|
215
240
|
for (const [lexicon, content] of snapshots) {
|
|
216
241
|
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
217
|
-
|
|
242
|
+
// Depth is stated, not inferred (#1267). An identity snapshot cannot
|
|
243
|
+
// answer a property question, and a reader that assumes otherwise reads
|
|
244
|
+
// "no properties recorded" as "no such properties".
|
|
245
|
+
const depth = snapshot.depth ?? "identity";
|
|
246
|
+
const depthNote =
|
|
247
|
+
depth === "deep"
|
|
248
|
+
? ` — deep (${Object.keys(snapshot.properties ?? {}).length} property trees)`
|
|
249
|
+
: " — identity only";
|
|
250
|
+
console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources${depthNote} — ${snapshot.timestamp}`);
|
|
218
251
|
printSnapshotTable(snapshot);
|
|
219
252
|
}
|
|
220
253
|
}
|