@intentius/chant 0.0.18 → 0.0.22
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/bin/chant +4 -1
- package/package.json +20 -1
- package/src/build.test.ts +4 -2
- package/src/build.ts +3 -0
- package/src/builder.test.ts +3 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
- package/src/cli/commands/build.ts +5 -12
- package/src/cli/commands/diff.test.ts +2 -1
- package/src/cli/commands/diff.ts +2 -1
- package/src/cli/commands/init-lexicon.test.ts +0 -9
- package/src/cli/commands/init-lexicon.ts +0 -94
- package/src/cli/commands/init.ts +2 -20
- package/src/cli/handlers/build.ts +3 -3
- package/src/cli/handlers/lint.ts +2 -2
- package/src/cli/handlers/spell.ts +396 -0
- package/src/cli/handlers/state.ts +230 -0
- package/src/cli/lsp/server.test.ts +4 -0
- package/src/cli/main.ts +37 -3
- package/src/cli/mcp/server.test.ts +13 -9
- package/src/cli/mcp/server.ts +220 -6
- package/src/cli/mcp/tools/build.ts +2 -1
- package/src/cli/plugins.ts +1 -1
- package/src/cli/reporters/stylish.test.ts +2 -2
- package/src/cli/reporters/stylish.ts +1 -1
- package/src/composite.test.ts +1 -1
- package/src/config.ts +4 -0
- package/src/declarable.test.ts +2 -1
- package/src/declarable.ts +1 -1
- package/src/discovery/graph.test.ts +40 -0
- package/src/discovery/import.test.ts +5 -5
- package/src/discovery/resolve.test.ts +20 -0
- package/src/discovery/resolve.ts +2 -2
- package/src/index.ts +2 -0
- package/src/lexicon.ts +24 -0
- package/src/lint/rule-options.test.ts +3 -3
- package/src/lint/rule-registry.test.ts +1 -1
- package/src/lint/rules/composite-scope.ts +1 -1
- package/src/serializer-walker.ts +2 -1
- package/src/spell/discovery.ts +183 -0
- package/src/spell/index.ts +3 -0
- package/src/spell/prompt.ts +133 -0
- package/src/spell/types.ts +89 -0
- package/src/state/digest.ts +88 -0
- package/src/state/git.ts +317 -0
- package/src/state/index.ts +4 -0
- package/src/state/snapshot.ts +179 -0
- package/src/state/types.ts +59 -0
- package/src/types.ts +2 -1
- package/src/utils.test.ts +16 -3
- package/src/utils.ts +31 -1
- package/src/validation.test.ts +11 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell types and factory functions.
|
|
3
|
+
*
|
|
4
|
+
* A spell is a structured task definition for agent orchestration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface ContextItem {
|
|
10
|
+
type: "file" | "cmd";
|
|
11
|
+
value: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Task {
|
|
15
|
+
description: string;
|
|
16
|
+
done: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Status = "blocked" | "ready" | "done";
|
|
20
|
+
|
|
21
|
+
export interface SpellDefinition {
|
|
22
|
+
name: string;
|
|
23
|
+
lexicon?: string;
|
|
24
|
+
overview: string;
|
|
25
|
+
context?: (string | ContextItem)[];
|
|
26
|
+
tasks: Task[];
|
|
27
|
+
depends?: string[];
|
|
28
|
+
afterAll?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Validation ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
34
|
+
const MAX_NAME_LENGTH = 64;
|
|
35
|
+
const RESERVED_NAMES = new Set([
|
|
36
|
+
"add", "list", "show", "cast", "done", "rm", "graph",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function validateName(name: string): void {
|
|
40
|
+
if (!name) {
|
|
41
|
+
throw new Error("Spell name must not be empty");
|
|
42
|
+
}
|
|
43
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
44
|
+
throw new Error(`Spell name must be at most ${MAX_NAME_LENGTH} characters: "${name}"`);
|
|
45
|
+
}
|
|
46
|
+
if (!NAME_PATTERN.test(name)) {
|
|
47
|
+
throw new Error(`Spell name must be kebab-case (lowercase letters, numbers, hyphens): "${name}"`);
|
|
48
|
+
}
|
|
49
|
+
if (RESERVED_NAMES.has(name)) {
|
|
50
|
+
throw new Error(`Spell name "${name}" is reserved (conflicts with CLI subcommand)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Factory functions ────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Define a spell. Validates the name and freezes the object.
|
|
58
|
+
*/
|
|
59
|
+
export function spell(def: SpellDefinition): SpellDefinition {
|
|
60
|
+
validateName(def.name);
|
|
61
|
+
if (!def.overview) {
|
|
62
|
+
throw new Error(`Spell "${def.name}" must have a non-empty overview`);
|
|
63
|
+
}
|
|
64
|
+
if (!def.tasks || def.tasks.length === 0) {
|
|
65
|
+
throw new Error(`Spell "${def.name}" must have at least one task`);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({ ...def });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Define a task within a spell.
|
|
72
|
+
*/
|
|
73
|
+
export function task(description: string, opts?: { done?: boolean }): Task {
|
|
74
|
+
return { description, done: opts?.done ?? false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* File context item — contents inlined at cast time.
|
|
79
|
+
*/
|
|
80
|
+
export function file(path: string): ContextItem {
|
|
81
|
+
return { type: "file", value: path };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Command context item — stdout inlined at cast time.
|
|
86
|
+
*/
|
|
87
|
+
export function cmd(command: string): ContextItem {
|
|
88
|
+
return { type: "cmd", value: command };
|
|
89
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build digest: fingerprints of resource declarations + dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* The digest captures *what was declared* at a point in time, enabling
|
|
5
|
+
* diff operations without re-parsing templates.
|
|
6
|
+
*/
|
|
7
|
+
import type { BuildResult } from "../build";
|
|
8
|
+
import type { Declarable } from "../declarable";
|
|
9
|
+
import type { BuildDigest, ResourceDigest, DigestDiff } from "./types";
|
|
10
|
+
import { sortedJsonReplacer } from "../utils";
|
|
11
|
+
import { getRuntime } from "../runtime-adapter";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hash an entity's props deterministically.
|
|
15
|
+
*/
|
|
16
|
+
export function hashProps(props: unknown): string {
|
|
17
|
+
const json = JSON.stringify(props, sortedJsonReplacer);
|
|
18
|
+
return getRuntime().hash(json);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute a full build digest from a BuildResult.
|
|
23
|
+
*/
|
|
24
|
+
export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
|
|
25
|
+
const resources: Record<string, ResourceDigest> = {};
|
|
26
|
+
|
|
27
|
+
for (const [name, entity] of buildResult.entities) {
|
|
28
|
+
const props = "props" in entity && entity.props != null ? entity.props : {};
|
|
29
|
+
resources[name] = {
|
|
30
|
+
type: entity.entityType,
|
|
31
|
+
lexicon: entity.lexicon,
|
|
32
|
+
propsHash: hashProps(props),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Convert dependency Map<string, Set<string>> to Record<string, string[]>
|
|
37
|
+
const dependencies: Record<string, string[]> = {};
|
|
38
|
+
for (const [name, deps] of buildResult.dependencies) {
|
|
39
|
+
dependencies[name] = Array.from(deps);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
resources,
|
|
44
|
+
dependencies,
|
|
45
|
+
outputs: buildResult.manifest.outputs,
|
|
46
|
+
deployOrder: buildResult.manifest.deployOrder,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two digests and categorize resources.
|
|
52
|
+
*/
|
|
53
|
+
export function diffDigests(
|
|
54
|
+
current: BuildDigest,
|
|
55
|
+
previous: BuildDigest | undefined,
|
|
56
|
+
): DigestDiff {
|
|
57
|
+
const added: string[] = [];
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
const changed: string[] = [];
|
|
60
|
+
const unchanged: string[] = [];
|
|
61
|
+
|
|
62
|
+
if (!previous) {
|
|
63
|
+
// No previous digest — everything is added
|
|
64
|
+
added.push(...Object.keys(current.resources));
|
|
65
|
+
return { added, removed, changed, unchanged };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check current resources against previous
|
|
69
|
+
for (const name of Object.keys(current.resources)) {
|
|
70
|
+
const prev = previous.resources[name];
|
|
71
|
+
if (!prev) {
|
|
72
|
+
added.push(name);
|
|
73
|
+
} else if (current.resources[name].propsHash !== prev.propsHash) {
|
|
74
|
+
changed.push(name);
|
|
75
|
+
} else {
|
|
76
|
+
unchanged.push(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for removed resources
|
|
81
|
+
for (const name of Object.keys(previous.resources)) {
|
|
82
|
+
if (!(name in current.resources)) {
|
|
83
|
+
removed.push(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { added, removed, changed, unchanged };
|
|
88
|
+
}
|
package/src/state/git.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git plumbing operations for the chant/state orphan branch.
|
|
3
|
+
*
|
|
4
|
+
* All operations use git plumbing commands — no checkout, no branch switching,
|
|
5
|
+
* no working tree changes.
|
|
6
|
+
*/
|
|
7
|
+
import { getRuntime } from "../runtime-adapter";
|
|
8
|
+
|
|
9
|
+
const STATE_BRANCH = "chant/state";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Write a state snapshot JSON to the orphan branch.
|
|
13
|
+
*
|
|
14
|
+
* Pipeline: hash-object → mktree → commit-tree → update-ref
|
|
15
|
+
*/
|
|
16
|
+
export async function writeSnapshot(
|
|
17
|
+
environment: string,
|
|
18
|
+
lexicon: string,
|
|
19
|
+
json: string,
|
|
20
|
+
opts?: { cwd?: string },
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const rt = getRuntime();
|
|
23
|
+
const cwd = opts?.cwd;
|
|
24
|
+
|
|
25
|
+
// 1. Write blob
|
|
26
|
+
const hashResult = await rt.spawn(
|
|
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.
|
|
32
|
+
const blobResult = await rt.spawn(
|
|
33
|
+
["sh", "-c", `echo '${json.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
|
|
34
|
+
{ cwd },
|
|
35
|
+
);
|
|
36
|
+
if (blobResult.exitCode !== 0) {
|
|
37
|
+
throw new Error(`git hash-object failed: ${blobResult.stderr}`);
|
|
38
|
+
}
|
|
39
|
+
const blobSha = blobResult.stdout.trim();
|
|
40
|
+
|
|
41
|
+
// 2. Read existing tree (if branch exists) to preserve other env/lexicon entries
|
|
42
|
+
const existingTree = await readTree(cwd);
|
|
43
|
+
|
|
44
|
+
// 3. Build new tree entries
|
|
45
|
+
const path = `${environment}/${lexicon}.json`;
|
|
46
|
+
const entries = mergeTreeEntry(existingTree, path, blobSha);
|
|
47
|
+
const treeInput = entries.map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`).join("\n");
|
|
48
|
+
|
|
49
|
+
// mktree needs a nested tree structure. Build env subtree first, then root tree.
|
|
50
|
+
// Build env subtree
|
|
51
|
+
const envEntries = entries
|
|
52
|
+
.filter((e) => e.env === environment)
|
|
53
|
+
.map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`)
|
|
54
|
+
.join("\n");
|
|
55
|
+
|
|
56
|
+
const envTreeResult = await rt.spawn(
|
|
57
|
+
["sh", "-c", `printf '%s\\n' ${shellQuoteLines(envEntries)} | git mktree`],
|
|
58
|
+
{ cwd },
|
|
59
|
+
);
|
|
60
|
+
if (envTreeResult.exitCode !== 0) {
|
|
61
|
+
throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
|
|
62
|
+
}
|
|
63
|
+
const envTreeSha = envTreeResult.stdout.trim();
|
|
64
|
+
|
|
65
|
+
// Build root tree: collect env subtrees
|
|
66
|
+
const rootEntries: string[] = [];
|
|
67
|
+
const envsSeen = new Set<string>();
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (!envsSeen.has(e.env)) {
|
|
70
|
+
envsSeen.add(e.env);
|
|
71
|
+
if (e.env === environment) {
|
|
72
|
+
rootEntries.push(`040000 tree ${envTreeSha}\t${environment}`);
|
|
73
|
+
} else {
|
|
74
|
+
rootEntries.push(`040000 tree ${e.envTreeSha!}\t${e.env}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rootTreeResult = await rt.spawn(
|
|
80
|
+
["sh", "-c", `printf '%s\\n' ${shellQuoteLines(rootEntries.join("\n"))} | git mktree`],
|
|
81
|
+
{ cwd },
|
|
82
|
+
);
|
|
83
|
+
if (rootTreeResult.exitCode !== 0) {
|
|
84
|
+
throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
|
|
85
|
+
}
|
|
86
|
+
const rootTreeSha = rootTreeResult.stdout.trim();
|
|
87
|
+
|
|
88
|
+
// 4. Create commit
|
|
89
|
+
const parentRef = await getStateBranchTip(cwd);
|
|
90
|
+
const parentArgs = parentRef ? ["-p", parentRef] : [];
|
|
91
|
+
const commitResult = await rt.spawn(
|
|
92
|
+
["git", "commit-tree", ...parentArgs, "-m", "State snapshot", rootTreeSha],
|
|
93
|
+
{ cwd },
|
|
94
|
+
);
|
|
95
|
+
if (commitResult.exitCode !== 0) {
|
|
96
|
+
throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
|
|
97
|
+
}
|
|
98
|
+
const commitSha = commitResult.stdout.trim();
|
|
99
|
+
|
|
100
|
+
// 5. Update ref
|
|
101
|
+
const updateResult = await rt.spawn(
|
|
102
|
+
["git", "update-ref", `refs/heads/${STATE_BRANCH}`, commitSha],
|
|
103
|
+
{ cwd },
|
|
104
|
+
);
|
|
105
|
+
if (updateResult.exitCode !== 0) {
|
|
106
|
+
throw new Error(`git update-ref failed: ${updateResult.stderr}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return commitSha;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read a snapshot from the orphan branch.
|
|
114
|
+
*/
|
|
115
|
+
export async function readSnapshot(
|
|
116
|
+
environment: string,
|
|
117
|
+
lexicon: string,
|
|
118
|
+
opts?: { cwd?: string },
|
|
119
|
+
): Promise<string | null> {
|
|
120
|
+
const rt = getRuntime();
|
|
121
|
+
const result = await rt.spawn(
|
|
122
|
+
["git", "show", `${STATE_BRANCH}:${environment}/${lexicon}.json`],
|
|
123
|
+
{ cwd: opts?.cwd },
|
|
124
|
+
);
|
|
125
|
+
if (result.exitCode !== 0) return null;
|
|
126
|
+
return result.stdout;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read all snapshots for an environment (all lexicons).
|
|
131
|
+
*/
|
|
132
|
+
export async function readEnvironmentSnapshots(
|
|
133
|
+
environment: string,
|
|
134
|
+
opts?: { cwd?: string },
|
|
135
|
+
): Promise<Map<string, string>> {
|
|
136
|
+
const rt = getRuntime();
|
|
137
|
+
const snapshots = new Map<string, string>();
|
|
138
|
+
|
|
139
|
+
// List files in the environment directory
|
|
140
|
+
const lsResult = await rt.spawn(
|
|
141
|
+
["git", "ls-tree", "--name-only", `${STATE_BRANCH}:${environment}/`],
|
|
142
|
+
{ cwd: opts?.cwd },
|
|
143
|
+
);
|
|
144
|
+
if (lsResult.exitCode !== 0) return snapshots;
|
|
145
|
+
|
|
146
|
+
const files = lsResult.stdout.trim().split("\n").filter(Boolean);
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
if (file.endsWith(".json")) {
|
|
149
|
+
const lexicon = file.replace(/\.json$/, "");
|
|
150
|
+
const content = await readSnapshot(environment, lexicon, opts);
|
|
151
|
+
if (content) snapshots.set(lexicon, content);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return snapshots;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List snapshot history from the orphan branch.
|
|
160
|
+
*/
|
|
161
|
+
export async function listSnapshots(
|
|
162
|
+
opts?: { cwd?: string; environment?: string },
|
|
163
|
+
): Promise<Array<{ commit: string; date: string; message: string }>> {
|
|
164
|
+
const rt = getRuntime();
|
|
165
|
+
const result = await rt.spawn(
|
|
166
|
+
["git", "log", "--format=%H %aI %s", STATE_BRANCH],
|
|
167
|
+
{ cwd: opts?.cwd },
|
|
168
|
+
);
|
|
169
|
+
if (result.exitCode !== 0) return [];
|
|
170
|
+
|
|
171
|
+
return result.stdout
|
|
172
|
+
.trim()
|
|
173
|
+
.split("\n")
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.map((line) => {
|
|
176
|
+
const [commit, date, ...rest] = line.split(" ");
|
|
177
|
+
return { commit, date, message: rest.join(" ") };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Push the state branch to remote.
|
|
183
|
+
*/
|
|
184
|
+
export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
185
|
+
const rt = getRuntime();
|
|
186
|
+
// Check if remote exists
|
|
187
|
+
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
188
|
+
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
189
|
+
|
|
190
|
+
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
191
|
+
const pushResult = await rt.spawn(
|
|
192
|
+
["git", "push", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
193
|
+
{ cwd: opts?.cwd },
|
|
194
|
+
);
|
|
195
|
+
return pushResult.exitCode === 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch the state branch from remote.
|
|
200
|
+
*/
|
|
201
|
+
export async function fetchState(opts?: { cwd?: string }): Promise<boolean> {
|
|
202
|
+
const rt = getRuntime();
|
|
203
|
+
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
204
|
+
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
205
|
+
|
|
206
|
+
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
207
|
+
const fetchResult = await rt.spawn(
|
|
208
|
+
["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
209
|
+
{ cwd: opts?.cwd },
|
|
210
|
+
);
|
|
211
|
+
return fetchResult.exitCode === 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Get the current HEAD commit SHA of the main working branch.
|
|
216
|
+
*/
|
|
217
|
+
export async function getHeadCommit(opts?: { cwd?: string }): Promise<string> {
|
|
218
|
+
const rt = getRuntime();
|
|
219
|
+
const result = await rt.spawn(["git", "rev-parse", "HEAD"], { cwd: opts?.cwd });
|
|
220
|
+
if (result.exitCode !== 0) {
|
|
221
|
+
throw new Error(`git rev-parse HEAD failed: ${result.stderr}`);
|
|
222
|
+
}
|
|
223
|
+
return result.stdout.trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Internal helpers ────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
interface TreeEntry {
|
|
229
|
+
mode: string;
|
|
230
|
+
type: string;
|
|
231
|
+
sha: string;
|
|
232
|
+
name: string;
|
|
233
|
+
env: string;
|
|
234
|
+
envTreeSha?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function getStateBranchTip(cwd?: string): Promise<string | null> {
|
|
238
|
+
const rt = getRuntime();
|
|
239
|
+
const result = await rt.spawn(
|
|
240
|
+
["git", "rev-parse", "--verify", `refs/heads/${STATE_BRANCH}`],
|
|
241
|
+
{ cwd },
|
|
242
|
+
);
|
|
243
|
+
if (result.exitCode !== 0) return null;
|
|
244
|
+
return result.stdout.trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function readTree(cwd?: string): Promise<TreeEntry[]> {
|
|
248
|
+
const rt = getRuntime();
|
|
249
|
+
const tip = await getStateBranchTip(cwd);
|
|
250
|
+
if (!tip) return [];
|
|
251
|
+
|
|
252
|
+
// List root tree to get env directories
|
|
253
|
+
const rootResult = await rt.spawn(
|
|
254
|
+
["git", "ls-tree", STATE_BRANCH],
|
|
255
|
+
{ cwd },
|
|
256
|
+
);
|
|
257
|
+
if (rootResult.exitCode !== 0) return [];
|
|
258
|
+
|
|
259
|
+
const entries: TreeEntry[] = [];
|
|
260
|
+
const lines = rootResult.stdout.trim().split("\n").filter(Boolean);
|
|
261
|
+
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
// Format: mode type sha\tname
|
|
264
|
+
const match = line.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
|
|
265
|
+
if (!match) continue;
|
|
266
|
+
const [, mode, type, sha, name] = match;
|
|
267
|
+
|
|
268
|
+
if (type === "tree") {
|
|
269
|
+
// This is an env directory — list its contents
|
|
270
|
+
const envResult = await rt.spawn(
|
|
271
|
+
["git", "ls-tree", `${STATE_BRANCH}:${name}/`],
|
|
272
|
+
{ cwd },
|
|
273
|
+
);
|
|
274
|
+
if (envResult.exitCode !== 0) continue;
|
|
275
|
+
|
|
276
|
+
const envLines = envResult.stdout.trim().split("\n").filter(Boolean);
|
|
277
|
+
for (const envLine of envLines) {
|
|
278
|
+
const envMatch = envLine.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
|
|
279
|
+
if (!envMatch) continue;
|
|
280
|
+
entries.push({
|
|
281
|
+
mode: envMatch[1],
|
|
282
|
+
type: envMatch[2],
|
|
283
|
+
sha: envMatch[3],
|
|
284
|
+
name: envMatch[4],
|
|
285
|
+
env: name,
|
|
286
|
+
envTreeSha: sha,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return entries;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function mergeTreeEntry(
|
|
296
|
+
existing: TreeEntry[],
|
|
297
|
+
path: string,
|
|
298
|
+
blobSha: string,
|
|
299
|
+
): TreeEntry[] {
|
|
300
|
+
const [env, filename] = path.split("/");
|
|
301
|
+
const entries = existing.filter(
|
|
302
|
+
(e) => !(e.env === env && e.name === filename),
|
|
303
|
+
);
|
|
304
|
+
entries.push({
|
|
305
|
+
mode: "100644",
|
|
306
|
+
type: "blob",
|
|
307
|
+
sha: blobSha,
|
|
308
|
+
name: filename,
|
|
309
|
+
env,
|
|
310
|
+
});
|
|
311
|
+
return entries;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function shellQuoteLines(input: string): string {
|
|
315
|
+
// Escape for printf in shell
|
|
316
|
+
return `'${input.replace(/'/g, "'\\''")}'`;
|
|
317
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot orchestration: queries plugins for deployed resource metadata,
|
|
3
|
+
* assembles StateSnapshots, computes build digests, and writes to git.
|
|
4
|
+
*/
|
|
5
|
+
import type { LexiconPlugin, ResourceMetadata } from "../lexicon";
|
|
6
|
+
import type { BuildResult } from "../build";
|
|
7
|
+
import type { SerializerResult } from "../serializer";
|
|
8
|
+
import type { StateSnapshot } from "./types";
|
|
9
|
+
import { computeBuildDigest } from "./digest";
|
|
10
|
+
import { writeSnapshot, getHeadCommit, pushState } from "./git";
|
|
11
|
+
import { sortedJsonReplacer } from "../utils";
|
|
12
|
+
|
|
13
|
+
/** Patterns in attribute names that suggest sensitive data. */
|
|
14
|
+
const SENSITIVE_PATTERNS = [
|
|
15
|
+
/password/i,
|
|
16
|
+
/secret/i,
|
|
17
|
+
/token/i,
|
|
18
|
+
/private.?key/i,
|
|
19
|
+
/credential/i,
|
|
20
|
+
/connection.?string/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check for potential sensitive data in resource attributes and return warnings.
|
|
25
|
+
*/
|
|
26
|
+
function checkSensitiveData(
|
|
27
|
+
resources: Record<string, ResourceMetadata>,
|
|
28
|
+
): string[] {
|
|
29
|
+
const warnings: string[] = [];
|
|
30
|
+
for (const [name, meta] of Object.entries(resources)) {
|
|
31
|
+
if (!meta.attributes) continue;
|
|
32
|
+
for (const attrName of Object.keys(meta.attributes)) {
|
|
33
|
+
if (SENSITIVE_PATTERNS.some((p) => p.test(attrName))) {
|
|
34
|
+
warnings.push(
|
|
35
|
+
`Potential sensitive data in ${name}.attributes.${attrName} — ensure it is scrubbed`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return warnings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate ResourceMetadata entries — resources must have at least type and status.
|
|
45
|
+
* Returns { valid, dropped, warnings }.
|
|
46
|
+
*/
|
|
47
|
+
function validateResources(
|
|
48
|
+
resources: Record<string, ResourceMetadata>,
|
|
49
|
+
): {
|
|
50
|
+
valid: Record<string, ResourceMetadata>;
|
|
51
|
+
dropped: string[];
|
|
52
|
+
warnings: string[];
|
|
53
|
+
} {
|
|
54
|
+
const valid: Record<string, ResourceMetadata> = {};
|
|
55
|
+
const dropped: string[] = [];
|
|
56
|
+
const warnings: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const [name, meta] of Object.entries(resources)) {
|
|
59
|
+
if (!meta.type || !meta.status) {
|
|
60
|
+
dropped.push(name);
|
|
61
|
+
warnings.push(`Dropped ${name}: missing type or status`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
valid[name] = meta;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check for sensitive data in valid resources
|
|
68
|
+
warnings.push(...checkSensitiveData(valid));
|
|
69
|
+
|
|
70
|
+
return { valid, dropped, warnings };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TakeSnapshotResult {
|
|
74
|
+
snapshots: StateSnapshot[];
|
|
75
|
+
commit: string;
|
|
76
|
+
warnings: string[];
|
|
77
|
+
errors: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Take state snapshots for all plugins that implement describeResources.
|
|
82
|
+
*/
|
|
83
|
+
export async function takeSnapshot(
|
|
84
|
+
environment: string,
|
|
85
|
+
plugins: LexiconPlugin[],
|
|
86
|
+
buildResult: BuildResult,
|
|
87
|
+
opts?: { cwd?: string },
|
|
88
|
+
): Promise<TakeSnapshotResult> {
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
const snapshots: StateSnapshot[] = [];
|
|
92
|
+
|
|
93
|
+
const headCommit = await getHeadCommit(opts);
|
|
94
|
+
const timestamp = new Date().toISOString();
|
|
95
|
+
const digest = computeBuildDigest(buildResult);
|
|
96
|
+
|
|
97
|
+
for (const plugin of plugins) {
|
|
98
|
+
if (!plugin.describeResources) continue;
|
|
99
|
+
|
|
100
|
+
// Get serialized build output for this lexicon
|
|
101
|
+
const rawOutput = buildResult.outputs.get(plugin.name);
|
|
102
|
+
const buildOutput =
|
|
103
|
+
rawOutput === undefined
|
|
104
|
+
? ""
|
|
105
|
+
: typeof rawOutput === "string"
|
|
106
|
+
? rawOutput
|
|
107
|
+
: (rawOutput as SerializerResult).primary;
|
|
108
|
+
|
|
109
|
+
// Get entity names for this lexicon
|
|
110
|
+
const entityNames: string[] = [];
|
|
111
|
+
for (const [name, entity] of buildResult.entities) {
|
|
112
|
+
if (entity.lexicon === plugin.name) {
|
|
113
|
+
entityNames.push(name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const resources = await plugin.describeResources({
|
|
119
|
+
environment,
|
|
120
|
+
buildOutput,
|
|
121
|
+
entityNames,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const { valid, dropped, warnings: validationWarnings } =
|
|
125
|
+
validateResources(resources);
|
|
126
|
+
warnings.push(...validationWarnings);
|
|
127
|
+
|
|
128
|
+
if (dropped.length > 0) {
|
|
129
|
+
warnings.push(
|
|
130
|
+
`${plugin.name}: dropped ${dropped.length} invalid resource(s)`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Object.keys(valid).length === 0) {
|
|
135
|
+
errors.push(`${plugin.name}: no valid resources returned`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const snapshot: StateSnapshot = {
|
|
140
|
+
lexicon: plugin.name,
|
|
141
|
+
environment,
|
|
142
|
+
commit: headCommit,
|
|
143
|
+
timestamp,
|
|
144
|
+
resources: valid,
|
|
145
|
+
digest,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
snapshots.push(snapshot);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
errors.push(
|
|
151
|
+
`${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Write all successful snapshots to git
|
|
157
|
+
let commitSha = "";
|
|
158
|
+
for (const snapshot of snapshots) {
|
|
159
|
+
const json = JSON.stringify(snapshot, sortedJsonReplacer, 2);
|
|
160
|
+
commitSha = await writeSnapshot(
|
|
161
|
+
snapshot.environment,
|
|
162
|
+
snapshot.lexicon,
|
|
163
|
+
json,
|
|
164
|
+
opts,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Push to remote
|
|
169
|
+
if (snapshots.length > 0) {
|
|
170
|
+
await pushState(opts);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
snapshots,
|
|
175
|
+
commit: commitSha,
|
|
176
|
+
warnings,
|
|
177
|
+
errors,
|
|
178
|
+
};
|
|
179
|
+
}
|