@intentius/chant 0.1.7 → 0.1.8
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/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +7 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +1 -0
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/op/types.ts +13 -0
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
package/src/cli/main.test.ts
CHANGED
package/src/cli/main.ts
CHANGED
|
@@ -36,6 +36,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
36
36
|
help: false,
|
|
37
37
|
profile: undefined,
|
|
38
38
|
report: undefined,
|
|
39
|
+
live: false,
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
let i = 0;
|
|
@@ -64,6 +65,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
64
65
|
result.profile = args[++i];
|
|
65
66
|
} else if (arg === "--report") {
|
|
66
67
|
result.report = true;
|
|
68
|
+
} else if (arg === "--live") {
|
|
69
|
+
result.live = true;
|
|
67
70
|
} else if (!arg.startsWith("-")) {
|
|
68
71
|
if (!result.command) {
|
|
69
72
|
result.command = arg;
|
|
@@ -114,6 +117,7 @@ State:
|
|
|
114
117
|
state snapshot <env> Query API, save metadata to orphan branch
|
|
115
118
|
state show <env> Show latest state snapshot
|
|
116
119
|
state diff <env> Compare current build against last snapshot
|
|
120
|
+
--live: query cloud now and detect drift
|
|
117
121
|
state log [env] History of state snapshots
|
|
118
122
|
|
|
119
123
|
Lexicon development:
|
|
@@ -52,7 +52,12 @@ export function createSearchHandler(
|
|
|
52
52
|
|
|
53
53
|
for (const plugin of candidates) {
|
|
54
54
|
const resources = plugin.mcpResources?.() ?? [];
|
|
55
|
-
|
|
55
|
+
// Catalog URIs were unscoped ("resource-catalog") before lexicon
|
|
56
|
+
// namespacing landed; new lexicons emit "<lexicon>:resource-catalog"
|
|
57
|
+
// to avoid cross-lexicon collision. Support both.
|
|
58
|
+
const catalog = resources.find(
|
|
59
|
+
(r) => r.uri === "resource-catalog" || r.uri.endsWith(":resource-catalog"),
|
|
60
|
+
);
|
|
56
61
|
if (!catalog) continue;
|
|
57
62
|
|
|
58
63
|
let entries: CatalogEntry[];
|
package/src/cli/registry.ts
CHANGED
|
@@ -61,15 +61,18 @@ export function createSkillsLoader(
|
|
|
61
61
|
/**
|
|
62
62
|
* Create an MCP diff tool contribution for a lexicon.
|
|
63
63
|
*
|
|
64
|
-
* All lexicons (except Azure) expose an identical "diff" tool that
|
|
65
|
-
* current build output against previous output using the lexicon's
|
|
64
|
+
* All lexicons (except Azure) expose an identical-shape "diff" tool that
|
|
65
|
+
* compares current build output against previous output using the lexicon's
|
|
66
|
+
* serializer. The tool name is namespaced by `lexiconName` (e.g. `gcp:diff`)
|
|
67
|
+
* so multiple lexicons loaded together don't collide on the same tool key.
|
|
66
68
|
*/
|
|
67
69
|
export function createDiffTool(
|
|
68
70
|
serializer: Serializer,
|
|
69
71
|
description: string,
|
|
72
|
+
lexiconName: string,
|
|
70
73
|
): McpToolContribution {
|
|
71
74
|
return {
|
|
72
|
-
name:
|
|
75
|
+
name: `${lexiconName}:diff`,
|
|
73
76
|
description,
|
|
74
77
|
inputSchema: {
|
|
75
78
|
type: "object" as const,
|
|
@@ -96,21 +99,26 @@ export function createDiffTool(
|
|
|
96
99
|
/**
|
|
97
100
|
* Create an MCP resource that serves the lexicon's meta.json as a catalog.
|
|
98
101
|
*
|
|
99
|
-
* Most lexicons expose a "resource-catalog" resource with identical
|
|
102
|
+
* Most lexicons expose a "resource-catalog" resource with identical
|
|
103
|
+
* structure. The URI is namespaced by `lexiconName` (e.g.
|
|
104
|
+
* `gcp:resource-catalog`) so multiple lexicons loaded together don't
|
|
105
|
+
* collide on the same resource key.
|
|
100
106
|
*
|
|
101
107
|
* @param importMetaUrl — The plugin's import.meta.url (used to locate generated JSON)
|
|
102
108
|
* @param name — Display name (e.g. "AWS Resource Catalog")
|
|
103
109
|
* @param description — Resource description
|
|
104
110
|
* @param lexiconJsonFile — Filename of the generated lexicon JSON (e.g. "lexicon-aws.json")
|
|
111
|
+
* @param lexiconName — Short lexicon name (e.g. "aws", "gcp"). Becomes the URI prefix.
|
|
105
112
|
*/
|
|
106
113
|
export function createCatalogResource(
|
|
107
114
|
importMetaUrl: string,
|
|
108
115
|
name: string,
|
|
109
116
|
description: string,
|
|
110
117
|
lexiconJsonFile: string,
|
|
118
|
+
lexiconName: string,
|
|
111
119
|
): McpResourceContribution {
|
|
112
120
|
return {
|
|
113
|
-
uri:
|
|
121
|
+
uri: `${lexiconName}:resource-catalog`,
|
|
114
122
|
name,
|
|
115
123
|
description,
|
|
116
124
|
mimeType: "application/json",
|
package/src/lexicon.ts
CHANGED
|
@@ -195,12 +195,48 @@ export interface LexiconPlugin {
|
|
|
195
195
|
mcpResources?(): McpResourceContribution[];
|
|
196
196
|
|
|
197
197
|
// State
|
|
198
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Query deployed resources and return API metadata. Opt-in.
|
|
200
|
+
*
|
|
201
|
+
* Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
|
|
202
|
+
* AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
|
|
203
|
+
*
|
|
204
|
+
* `entities` carries the chant-side entity declarations for this lexicon,
|
|
205
|
+
* keyed by chant entity name (e.g. the export name from a `*.ts` file).
|
|
206
|
+
* Implementations that need to map cloud-side names back to chant entity
|
|
207
|
+
* names (e.g. Temporal — server-side namespace `prod` ↔ chant entity `ns`
|
|
208
|
+
* declared with `name: "prod"`) read this; implementations that already
|
|
209
|
+
* have name parity (e.g. AWS CloudFormation logical IDs == chant entity
|
|
210
|
+
* names) can ignore it.
|
|
211
|
+
*
|
|
212
|
+
* `entityNames` is preserved as a convenience for the simple case.
|
|
213
|
+
*/
|
|
199
214
|
describeResources?(options: {
|
|
200
215
|
environment: string;
|
|
201
216
|
buildOutput: string;
|
|
202
217
|
entityNames: string[];
|
|
218
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
203
219
|
}): Promise<Record<string, ResourceMetadata>>;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* List runtime artifacts in the given environment. Opt-in.
|
|
223
|
+
*
|
|
224
|
+
* Use this for lexicons whose chant entities describe *authoring*
|
|
225
|
+
* primitives rather than 1:1 cloud resources — e.g. Helm (charts vs
|
|
226
|
+
* releases), Docker (Compose vs running containers), Flyway (migration
|
|
227
|
+
* scripts vs applied migrations). The contract is context-keyed: given an
|
|
228
|
+
* environment, list all artifacts visible there. There is no `declared`
|
|
229
|
+
* comparison axis — `state diff --live` reports added/removed/changed
|
|
230
|
+
* between snapshots, not vs. declared.
|
|
231
|
+
*
|
|
232
|
+
* `entities` is passed for cases where the lexicon needs to know what
|
|
233
|
+
* was declared in order to enumerate (e.g. Flyway needs the declared
|
|
234
|
+
* `Flyway::Environment` entities to know which DBs to query).
|
|
235
|
+
*/
|
|
236
|
+
listArtifacts?(options: {
|
|
237
|
+
environment: string;
|
|
238
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
239
|
+
}): Promise<Record<string, ArtifactMetadata>>;
|
|
204
240
|
}
|
|
205
241
|
|
|
206
242
|
/**
|
|
@@ -219,6 +255,26 @@ export interface ResourceMetadata {
|
|
|
219
255
|
attributes?: Record<string, unknown>;
|
|
220
256
|
}
|
|
221
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Metadata about a runtime artifact, returned by listArtifacts. Same shape
|
|
260
|
+
* as ResourceMetadata; the conceptual distinction is whether the lexicon's
|
|
261
|
+
* chant entities have 1:1 runtime equivalents (resources) or whether the
|
|
262
|
+
* runtime artifacts are created by tooling outside chant's entity model
|
|
263
|
+
* (artifacts — e.g. Helm releases, Docker containers, Flyway migrations).
|
|
264
|
+
*/
|
|
265
|
+
export interface ArtifactMetadata {
|
|
266
|
+
/** Artifact type (e.g. Helm::Release, Docker::Container) */
|
|
267
|
+
type: string;
|
|
268
|
+
/** Server-side identifier */
|
|
269
|
+
physicalId?: string;
|
|
270
|
+
/** Provider-specific status string */
|
|
271
|
+
status: string;
|
|
272
|
+
/** ISO timestamp of last update */
|
|
273
|
+
lastUpdated?: string;
|
|
274
|
+
/** Provider-specific properties */
|
|
275
|
+
attributes?: Record<string, unknown>;
|
|
276
|
+
}
|
|
277
|
+
|
|
222
278
|
/**
|
|
223
279
|
* Type guard to check if a value is a LexiconPlugin.
|
|
224
280
|
* Checks for required lifecycle methods in addition to name/serializer.
|
package/src/lint/config.test.ts
CHANGED
|
@@ -641,4 +641,25 @@ describe("loadConfig", () => {
|
|
|
641
641
|
const config = loadConfig(subDir);
|
|
642
642
|
expect(config).toEqual(DEFAULT_CONFIG);
|
|
643
643
|
});
|
|
644
|
+
|
|
645
|
+
test("accepts ChantConfig-shape nested lint key in chant.config.json", () => {
|
|
646
|
+
const configPath = join(TEST_DIR, "chant.config.json");
|
|
647
|
+
writeFileSync(
|
|
648
|
+
configPath,
|
|
649
|
+
JSON.stringify({
|
|
650
|
+
lexicons: ["aws"],
|
|
651
|
+
lint: {
|
|
652
|
+
rules: {
|
|
653
|
+
"test-rule": "error",
|
|
654
|
+
"noisy-rule": "off",
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const config = loadConfig(TEST_DIR);
|
|
661
|
+
|
|
662
|
+
expect(config.rules?.["test-rule"]).toBe("error");
|
|
663
|
+
expect(config.rules?.["noisy-rule"]).toBe("off");
|
|
664
|
+
});
|
|
644
665
|
});
|
package/src/lint/config.ts
CHANGED
|
@@ -223,18 +223,34 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
|
|
|
223
223
|
throw new Error(`Failed to read config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
let
|
|
226
|
+
let raw: unknown;
|
|
227
227
|
try {
|
|
228
|
-
|
|
228
|
+
raw = JSON.parse(content);
|
|
229
229
|
} catch (err) {
|
|
230
230
|
throw new Error(`Failed to parse config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Validate config structure
|
|
234
|
-
if (typeof
|
|
234
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
235
235
|
throw new Error(`Invalid config file ${configPath}: must be an object`);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// Accept both shapes: lint fields at top level (legacy) OR nested under a
|
|
239
|
+
// "lint" key (matches chant.config.ts ChantConfig shape). When the JSON has
|
|
240
|
+
// both a top-level "rules"/"extends"/etc AND a "lint": {...}, prefer the
|
|
241
|
+
// nested one — that matches the explicit ChantConfig contract.
|
|
242
|
+
const rawObj = raw as Record<string, unknown>;
|
|
243
|
+
let config: LintConfig;
|
|
244
|
+
if (
|
|
245
|
+
rawObj.lint &&
|
|
246
|
+
typeof rawObj.lint === "object" &&
|
|
247
|
+
!Array.isArray(rawObj.lint)
|
|
248
|
+
) {
|
|
249
|
+
config = rawObj.lint as LintConfig;
|
|
250
|
+
} else {
|
|
251
|
+
config = rawObj as LintConfig;
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
// Validate with Zod schema
|
|
239
255
|
const parseResult = LintConfigSchema.safeParse(config);
|
|
240
256
|
if (!parseResult.success) {
|
package/src/op/types.ts
CHANGED
|
@@ -46,6 +46,19 @@ export interface ActivityStep {
|
|
|
46
46
|
* Default: "fastIdempotent"
|
|
47
47
|
*/
|
|
48
48
|
profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate";
|
|
49
|
+
/**
|
|
50
|
+
* Surface this activity's return value as a workflow search attribute.
|
|
51
|
+
*
|
|
52
|
+
* The serializer captures the awaited result into a temporary, then emits
|
|
53
|
+
* `upsertSearchAttributes({ <name>: [String(<from-path>)] })` immediately
|
|
54
|
+
* after. Useful for filtering runs by outcome (e.g. `Drift: "true"/"false"`
|
|
55
|
+
* from a stateDiff activity).
|
|
56
|
+
*
|
|
57
|
+
* `from` is a dot-path into the return value (e.g. `"drifted"` for
|
|
58
|
+
* `{ drifted: boolean }`); when omitted, the whole return value is
|
|
59
|
+
* stringified.
|
|
60
|
+
*/
|
|
61
|
+
outcomeAttribute?: { name: string; from?: string };
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export interface GateStep {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { computeBuildDigest, diffDigests, hashProps } from "./digest";
|
|
3
|
+
import type { BuildResult } from "../build";
|
|
4
|
+
import type { BuildDigest } from "./types";
|
|
5
|
+
|
|
6
|
+
function makeBuildResult(entitiesByLexicon: Record<string, Array<{ name: string; type: string; props: unknown }>>): BuildResult {
|
|
7
|
+
const entities = new Map();
|
|
8
|
+
for (const [lexicon, list] of Object.entries(entitiesByLexicon)) {
|
|
9
|
+
for (const item of list) {
|
|
10
|
+
entities.set(item.name, { lexicon, entityType: item.type, props: item.props });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
outputs: new Map(Object.keys(entitiesByLexicon).map((l) => [l, "{}"])),
|
|
15
|
+
entities,
|
|
16
|
+
dependencies: new Map(),
|
|
17
|
+
errors: [],
|
|
18
|
+
warnings: [],
|
|
19
|
+
manifest: {
|
|
20
|
+
lexicons: Object.keys(entitiesByLexicon),
|
|
21
|
+
outputs: {},
|
|
22
|
+
deployOrder: Object.keys(entitiesByLexicon),
|
|
23
|
+
},
|
|
24
|
+
sourceFileCount: 1,
|
|
25
|
+
} as unknown as BuildResult;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("hashProps", () => {
|
|
29
|
+
test("produces the same hash for identical props", () => {
|
|
30
|
+
expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ a: 1, b: 2 }));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("is order-independent (sorted JSON serialization)", () => {
|
|
34
|
+
expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ b: 2, a: 1 }));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("produces different hashes for different props", () => {
|
|
38
|
+
expect(hashProps({ a: 1 })).not.toBe(hashProps({ a: 2 }));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("computeBuildDigest", () => {
|
|
43
|
+
test("emits one entry per entity with type, lexicon, and propsHash", () => {
|
|
44
|
+
const buildResult = makeBuildResult({
|
|
45
|
+
aws: [{ name: "bucket", type: "AWS::S3::Bucket", props: { name: "data" } }],
|
|
46
|
+
});
|
|
47
|
+
const digest = computeBuildDigest(buildResult);
|
|
48
|
+
expect(digest.resources["bucket"]).toMatchObject({
|
|
49
|
+
type: "AWS::S3::Bucket",
|
|
50
|
+
lexicon: "aws",
|
|
51
|
+
propsHash: expect.any(String),
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("missing props default to empty object", () => {
|
|
56
|
+
const buildResult = makeBuildResult({ aws: [{ name: "x", type: "T", props: undefined }] });
|
|
57
|
+
const digest = computeBuildDigest(buildResult);
|
|
58
|
+
expect(digest.resources["x"].propsHash).toBe(hashProps({}));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("mirrors the build manifest's deployOrder and outputs", () => {
|
|
62
|
+
const buildResult = makeBuildResult({
|
|
63
|
+
aws: [{ name: "b", type: "T", props: {} }],
|
|
64
|
+
gcp: [{ name: "g", type: "T", props: {} }],
|
|
65
|
+
});
|
|
66
|
+
const digest = computeBuildDigest(buildResult);
|
|
67
|
+
expect(digest.deployOrder).toEqual(["aws", "gcp"]);
|
|
68
|
+
expect(digest.outputs).toEqual({});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("diffDigests", () => {
|
|
73
|
+
function makeDigest(resources: Record<string, string>): BuildDigest {
|
|
74
|
+
const out: BuildDigest["resources"] = {};
|
|
75
|
+
for (const [name, propsHash] of Object.entries(resources)) {
|
|
76
|
+
out[name] = { type: "T", lexicon: "aws", propsHash };
|
|
77
|
+
}
|
|
78
|
+
return { resources: out, dependencies: {}, outputs: {}, deployOrder: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("no previous digest → everything is added", () => {
|
|
82
|
+
const result = diffDigests(makeDigest({ a: "x", b: "y" }), undefined);
|
|
83
|
+
expect(result.added).toEqual(["a", "b"]);
|
|
84
|
+
expect(result.removed).toEqual([]);
|
|
85
|
+
expect(result.changed).toEqual([]);
|
|
86
|
+
expect(result.unchanged).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("identical digests → all unchanged", () => {
|
|
90
|
+
const d = makeDigest({ a: "x" });
|
|
91
|
+
const result = diffDigests(d, d);
|
|
92
|
+
expect(result.unchanged).toEqual(["a"]);
|
|
93
|
+
expect(result.added).toEqual([]);
|
|
94
|
+
expect(result.changed).toEqual([]);
|
|
95
|
+
expect(result.removed).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("different propsHash → changed", () => {
|
|
99
|
+
const result = diffDigests(makeDigest({ a: "x2" }), makeDigest({ a: "x1" }));
|
|
100
|
+
expect(result.changed).toEqual(["a"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resource gone from current → removed", () => {
|
|
104
|
+
const result = diffDigests(makeDigest({}), makeDigest({ a: "x" }));
|
|
105
|
+
expect(result.removed).toEqual(["a"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("mixed: added + removed + changed + unchanged", () => {
|
|
109
|
+
const previous = makeDigest({ a: "x1", b: "y", c: "z" });
|
|
110
|
+
const current = makeDigest({ a: "x2", b: "y", d: "w" });
|
|
111
|
+
const result = diffDigests(current, previous);
|
|
112
|
+
expect(result.added.sort()).toEqual(["d"]);
|
|
113
|
+
expect(result.changed.sort()).toEqual(["a"]);
|
|
114
|
+
expect(result.unchanged.sort()).toEqual(["b"]);
|
|
115
|
+
expect(result.removed.sort()).toEqual(["c"]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
writeSnapshot,
|
|
8
|
+
readSnapshot,
|
|
9
|
+
readEnvironmentSnapshots,
|
|
10
|
+
listSnapshots,
|
|
11
|
+
getHeadCommit,
|
|
12
|
+
pushState,
|
|
13
|
+
StaleStateBranchError,
|
|
14
|
+
} from "./git";
|
|
15
|
+
|
|
16
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
17
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
18
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function initRepo(dir: string): Promise<void> {
|
|
22
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
23
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
24
|
+
git(["config", "user.name", "Test"], dir);
|
|
25
|
+
// Need at least one commit so HEAD exists.
|
|
26
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
27
|
+
git(["add", "README.md"], dir);
|
|
28
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("state/git", () => {
|
|
32
|
+
test("writeSnapshot creates the orphan branch and writes JSON addressable by readSnapshot", async () => {
|
|
33
|
+
await withTestDir(async (dir) => {
|
|
34
|
+
await initRepo(dir);
|
|
35
|
+
const json = JSON.stringify({ resources: { bucket: { type: "T", status: "OK" } } });
|
|
36
|
+
const sha = await writeSnapshot("prod", "aws", json, { cwd: dir });
|
|
37
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
38
|
+
|
|
39
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
40
|
+
expect(out).not.toBeNull();
|
|
41
|
+
expect(JSON.parse(out!)).toEqual(JSON.parse(json));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("readSnapshot returns null for missing env/lexicon", async () => {
|
|
46
|
+
await withTestDir(async (dir) => {
|
|
47
|
+
await initRepo(dir);
|
|
48
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
49
|
+
expect(out).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("subsequent writes preserve other env+lexicon entries", async () => {
|
|
54
|
+
await withTestDir(async (dir) => {
|
|
55
|
+
await initRepo(dir);
|
|
56
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
|
|
57
|
+
await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
|
|
58
|
+
await writeSnapshot("staging", "aws", JSON.stringify({ c: 3 }), { cwd: dir });
|
|
59
|
+
|
|
60
|
+
expect(await readSnapshot("prod", "aws", { cwd: dir })).toBeTruthy();
|
|
61
|
+
expect(await readSnapshot("prod", "gcp", { cwd: dir })).toBeTruthy();
|
|
62
|
+
expect(await readSnapshot("staging", "aws", { cwd: dir })).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("re-writing the same env+lexicon updates the entry rather than duplicating", async () => {
|
|
67
|
+
await withTestDir(async (dir) => {
|
|
68
|
+
await initRepo(dir);
|
|
69
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
|
|
70
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
|
|
71
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
72
|
+
expect(JSON.parse(out!)).toEqual({ v: 2 });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("readEnvironmentSnapshots returns all lexicons for an env", async () => {
|
|
77
|
+
await withTestDir(async (dir) => {
|
|
78
|
+
await initRepo(dir);
|
|
79
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
|
|
80
|
+
await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
|
|
81
|
+
const all = await readEnvironmentSnapshots("prod", { cwd: dir });
|
|
82
|
+
expect([...all.keys()].sort()).toEqual(["aws", "gcp"]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("listSnapshots returns commit history of the orphan branch", async () => {
|
|
87
|
+
await withTestDir(async (dir) => {
|
|
88
|
+
await initRepo(dir);
|
|
89
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
|
|
90
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
|
|
91
|
+
const log = await listSnapshots({ cwd: dir });
|
|
92
|
+
expect(log.length).toBe(2);
|
|
93
|
+
expect(log[0].commit).toMatch(/^[0-9a-f]{40}$/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("getHeadCommit returns the working-branch HEAD sha", async () => {
|
|
98
|
+
await withTestDir(async (dir) => {
|
|
99
|
+
await initRepo(dir);
|
|
100
|
+
const head = await getHeadCommit({ cwd: dir });
|
|
101
|
+
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Concurrent push rejection (#30) ─────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a "remote ↔ clone" pair where `clone` has `remote` configured as
|
|
109
|
+
* `origin`. Returns the clone path; the caller writes snapshots there.
|
|
110
|
+
*/
|
|
111
|
+
async function setupClonePair(): Promise<{ clonePath: string; remotePath: string; cleanup: () => Promise<void> }> {
|
|
112
|
+
const remotePath = join(import.meta.dirname ?? "/tmp", `chant-state-remote-${Date.now()}-${Math.random()}`);
|
|
113
|
+
const clonePath = join(import.meta.dirname ?? "/tmp", `chant-state-clone-${Date.now()}-${Math.random()}`);
|
|
114
|
+
const { mkdir, rm } = await import("node:fs/promises");
|
|
115
|
+
await mkdir(remotePath, { recursive: true });
|
|
116
|
+
git(["init", "-q", "--bare", "-b", "main"], remotePath);
|
|
117
|
+
git(["clone", "-q", remotePath, clonePath], import.meta.dirname ?? "/tmp");
|
|
118
|
+
git(["config", "user.email", "test@chant.dev"], clonePath);
|
|
119
|
+
git(["config", "user.name", "Test"], clonePath);
|
|
120
|
+
writeFileSync(join(clonePath, "README.md"), "fixture\n");
|
|
121
|
+
git(["add", "README.md"], clonePath);
|
|
122
|
+
git(["commit", "-q", "-m", "init"], clonePath);
|
|
123
|
+
git(["push", "-q", "origin", "main"], clonePath);
|
|
124
|
+
return {
|
|
125
|
+
clonePath,
|
|
126
|
+
remotePath,
|
|
127
|
+
cleanup: async () => {
|
|
128
|
+
await rm(remotePath, { recursive: true, force: true });
|
|
129
|
+
await rm(clonePath, { recursive: true, force: true });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("first push to remote succeeds (no remote ref yet)", async () => {
|
|
135
|
+
const { clonePath, cleanup } = await setupClonePair();
|
|
136
|
+
try {
|
|
137
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
138
|
+
const ok = await pushState({ cwd: clonePath });
|
|
139
|
+
expect(ok).toBe(true);
|
|
140
|
+
} finally {
|
|
141
|
+
await cleanup();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("subsequent push from same clone (after fetch) succeeds via lease", async () => {
|
|
146
|
+
const { clonePath, cleanup } = await setupClonePair();
|
|
147
|
+
try {
|
|
148
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
149
|
+
expect(await pushState({ cwd: clonePath })).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Pull the remote ref into local remote-tracking, then commit + push again
|
|
152
|
+
git(["fetch", "-q", "origin", "+refs/heads/chant/state:refs/remotes/origin/chant/state"], clonePath);
|
|
153
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: clonePath });
|
|
154
|
+
expect(await pushState({ cwd: clonePath })).toBe(true);
|
|
155
|
+
} finally {
|
|
156
|
+
await cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("concurrent write rejected: second push throws StaleStateBranchError", async () => {
|
|
161
|
+
// Simulate two concurrent operators by setting up two clones of the same remote.
|
|
162
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
163
|
+
const cloneB = join(import.meta.dirname ?? "/tmp", `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
164
|
+
try {
|
|
165
|
+
git(["clone", "-q", remotePath, cloneB], import.meta.dirname ?? "/tmp");
|
|
166
|
+
git(["config", "user.email", "test@chant.dev"], cloneB);
|
|
167
|
+
git(["config", "user.name", "Test"], cloneB);
|
|
168
|
+
|
|
169
|
+
// Operator A writes + pushes first.
|
|
170
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
171
|
+
expect(await pushState({ cwd: cloneA })).toBe(true);
|
|
172
|
+
|
|
173
|
+
// Operator B writes from the same baseline (chant/state doesn't exist
|
|
174
|
+
// on cloneB's remote-tracking yet) and tries to push — should fail
|
|
175
|
+
// with StaleStateBranchError because A's push moved the remote ref.
|
|
176
|
+
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
177
|
+
await expect(pushState({ cwd: cloneB })).rejects.toBeInstanceOf(StaleStateBranchError);
|
|
178
|
+
} finally {
|
|
179
|
+
await cleanup();
|
|
180
|
+
const { rm } = await import("node:fs/promises");
|
|
181
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("StaleStateBranchError carries the expected SHA used as the lease", async () => {
|
|
186
|
+
const err = new StaleStateBranchError(null, "stale info: ...");
|
|
187
|
+
expect(err.name).toBe("StaleStateBranchError");
|
|
188
|
+
expect(err.expected).toBeNull();
|
|
189
|
+
expect(err.message).toContain("moved");
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/state/git.ts
CHANGED
|
@@ -22,13 +22,8 @@ export async function writeSnapshot(
|
|
|
22
22
|
const rt = getRuntime();
|
|
23
23
|
const cwd = opts?.cwd;
|
|
24
24
|
|
|
25
|
-
// 1. Write blob
|
|
26
|
-
|
|
27
|
-
["git", "hash-object", "-w", "--stdin"],
|
|
28
|
-
{ cwd },
|
|
29
|
-
);
|
|
30
|
-
// hash-object reads from stdin, but spawn doesn't support piping directly.
|
|
31
|
-
// Use a shell pipeline instead.
|
|
25
|
+
// 1. Write blob — hash-object reads from stdin, but spawn() doesn't expose
|
|
26
|
+
// a stdin handle, so we run via a shell pipeline (`echo … | git hash-object`).
|
|
32
27
|
const blobResult = await rt.spawn(
|
|
33
28
|
["sh", "-c", `echo '${json.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
|
|
34
29
|
{ cwd },
|
|
@@ -179,20 +174,77 @@ export async function listSnapshots(
|
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
/**
|
|
182
|
-
*
|
|
177
|
+
* Thrown by pushState when the remote chant/state branch has moved since
|
|
178
|
+
* the local snapshot was prepared — i.e. another snapshot for this or a
|
|
179
|
+
* different env was pushed concurrently. The caller should fetch and retry.
|
|
180
|
+
*/
|
|
181
|
+
export class StaleStateBranchError extends Error {
|
|
182
|
+
readonly expected: string | null;
|
|
183
|
+
constructor(expected: string | null, stderr: string) {
|
|
184
|
+
super(
|
|
185
|
+
"chant/state remote branch has moved since this run started — " +
|
|
186
|
+
"another snapshot was pushed concurrently. " +
|
|
187
|
+
`git stderr: ${stderr.trim()}`,
|
|
188
|
+
);
|
|
189
|
+
this.name = "StaleStateBranchError";
|
|
190
|
+
this.expected = expected;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Look up the remote-tracking SHA for chant/state, if any. Returns null when
|
|
196
|
+
* the remote ref doesn't exist locally yet (e.g. first-ever snapshot).
|
|
197
|
+
*/
|
|
198
|
+
export async function getRemoteStateBranchSha(
|
|
199
|
+
remote: string,
|
|
200
|
+
opts?: { cwd?: string },
|
|
201
|
+
): Promise<string | null> {
|
|
202
|
+
const rt = getRuntime();
|
|
203
|
+
const ref = `refs/remotes/${remote}/${STATE_BRANCH}`;
|
|
204
|
+
const result = await rt.spawn(["git", "rev-parse", "--verify", ref], { cwd: opts?.cwd });
|
|
205
|
+
if (result.exitCode !== 0) return null;
|
|
206
|
+
return result.stdout.trim() || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Push the state branch to remote with --force-with-lease.
|
|
211
|
+
*
|
|
212
|
+
* If the remote chant/state ref has advanced past the local remote-tracking
|
|
213
|
+
* SHA captured at the start of this push, the push is rejected and we throw
|
|
214
|
+
* StaleStateBranchError so the caller can surface a recovery hint.
|
|
215
|
+
*
|
|
216
|
+
* Returns false (without throwing) only when no remote is configured at all.
|
|
183
217
|
*/
|
|
184
218
|
export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
185
219
|
const rt = getRuntime();
|
|
186
|
-
// Check if remote exists
|
|
187
220
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
188
221
|
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
189
222
|
|
|
190
223
|
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
224
|
+
|
|
225
|
+
// Capture the lease SHA — if null, the remote ref doesn't exist yet
|
|
226
|
+
// (first-time push) and we send `--force-with-lease=ref:` (empty SHA),
|
|
227
|
+
// which git interprets as "ref does not exist on remote".
|
|
228
|
+
const expected = await getRemoteStateBranchSha(remote, opts);
|
|
229
|
+
const lease = `refs/heads/${STATE_BRANCH}:${expected ?? ""}`;
|
|
230
|
+
|
|
191
231
|
const pushResult = await rt.spawn(
|
|
192
|
-
["git", "push", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
232
|
+
["git", "push", `--force-with-lease=${lease}`, remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
193
233
|
{ cwd: opts?.cwd },
|
|
194
234
|
);
|
|
195
|
-
|
|
235
|
+
|
|
236
|
+
if (pushResult.exitCode !== 0) {
|
|
237
|
+
const stderr = pushResult.stderr ?? "";
|
|
238
|
+
if (
|
|
239
|
+
stderr.includes("stale info") ||
|
|
240
|
+
stderr.includes("rejected") ||
|
|
241
|
+
stderr.includes("non-fast-forward")
|
|
242
|
+
) {
|
|
243
|
+
throw new StaleStateBranchError(expected, stderr);
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
/**
|