@intentius/chant 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/build.test.ts +48 -1
- package/src/build.ts +113 -2
- package/src/cli/commands/build.ts +33 -3
- package/src/cli/commands/vendor.test.ts +148 -0
- package/src/cli/commands/vendor.ts +263 -0
- package/src/cli/handlers/build.ts +1 -0
- package/src/cli/handlers/graph.ts +55 -2
- package/src/cli/handlers/lifecycle.ts +57 -0
- package/src/cli/handlers/vendor.ts +61 -0
- package/src/cli/main.ts +19 -2
- package/src/cli/registry.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lifecycle/affected.test.ts +161 -0
- package/src/lifecycle/affected.ts +202 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lint/config.ts +9 -0
- package/src/lint/policy.ts +81 -0
- package/src/lint/post-synth.test.ts +30 -0
- package/src/lint/post-synth.ts +24 -2
- package/src/op/builders.ts +14 -0
- package/src/op/index.ts +1 -1
- package/src/op/op.test.ts +15 -1
- package/src/op/types.ts +1 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant vendor` — pull reusable patterns (composites as source, Ops, init
|
|
3
|
+
* templates, example skeletons) from a remote source into your own repo, pinned
|
|
4
|
+
* and auditable.
|
|
5
|
+
*
|
|
6
|
+
* This is for patterns you copy in and **own/adapt**, recorded in a manifest
|
|
7
|
+
* (`vendor.json`) with a checksum so provenance is verifiable. It is NOT a
|
|
8
|
+
* package manager: lexicons stay npm dependencies (the typed API you import,
|
|
9
|
+
* never edit). Vendoring exists for the source npm handles badly — code you want
|
|
10
|
+
* in-repo, reviewable in diffs, and adaptable.
|
|
11
|
+
*
|
|
12
|
+
* Reuses the existing fetch/extract infra (`codegen/fetch.ts`); the only new
|
|
13
|
+
* machinery is the manifest + copy-to-repo + checksum.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { fetchWithRetry, extractFromTar, extractFromZip } from "../../codegen/fetch";
|
|
28
|
+
|
|
29
|
+
// ── Manifest schema ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SourceSchema = z.discriminatedUnion("type", [
|
|
32
|
+
z.object({ type: z.literal("local"), path: z.string().min(1) }),
|
|
33
|
+
z.object({ type: z.literal("archive"), url: z.string().url(), subpath: z.string().optional() }),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const EntrySchema = z.object({
|
|
37
|
+
/** Stable identity for the vendored artifact (used by `pull <name>`). */
|
|
38
|
+
name: z.string().min(1),
|
|
39
|
+
source: SourceSchema,
|
|
40
|
+
/** Path in your repo to write the pulled content. */
|
|
41
|
+
target: z.string().min(1),
|
|
42
|
+
/** The pin — a git tag / version label, for provenance (informational). */
|
|
43
|
+
ref: z.string().optional(),
|
|
44
|
+
/** sha256 of the pulled content. Written by `pull`, verified by `check`. */
|
|
45
|
+
checksum: z.string().optional(),
|
|
46
|
+
/** Only "pin" (explicit-bump) is supported; floating refs are out of scope. */
|
|
47
|
+
updatePolicy: z.literal("pin").optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const VendorManifestSchema = z.object({
|
|
51
|
+
vendored: z.array(EntrySchema),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export type VendorEntry = z.infer<typeof EntrySchema>;
|
|
55
|
+
export type VendorManifest = z.infer<typeof VendorManifestSchema>;
|
|
56
|
+
|
|
57
|
+
export const MANIFEST_FILE = "vendor.json";
|
|
58
|
+
|
|
59
|
+
// ── Content hashing ───────────────────────────────────────────────────────��─
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Deterministic sha256 over a file set — independent of fetch/extract order.
|
|
63
|
+
* Hashes each `path\0content\0` in sorted-path order.
|
|
64
|
+
*/
|
|
65
|
+
export function contentHash(files: Map<string, Buffer>): string {
|
|
66
|
+
const h = createHash("sha256");
|
|
67
|
+
for (const path of [...files.keys()].sort()) {
|
|
68
|
+
h.update(path);
|
|
69
|
+
h.update("\0");
|
|
70
|
+
h.update(files.get(path)!);
|
|
71
|
+
h.update("\0");
|
|
72
|
+
}
|
|
73
|
+
return `sha256:${h.digest("hex")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Source resolution → a { relpath → bytes } file set ──────────────────────
|
|
77
|
+
|
|
78
|
+
/** Walk a local directory into a relpath→bytes map (or a single file). */
|
|
79
|
+
function readLocal(absPath: string): Map<string, Buffer> {
|
|
80
|
+
const files = new Map<string, Buffer>();
|
|
81
|
+
const stat = statSync(absPath);
|
|
82
|
+
if (stat.isFile()) {
|
|
83
|
+
files.set(absPath.split(sep).pop()!, readFileSync(absPath));
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
const walk = (dir: string): void => {
|
|
87
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
88
|
+
const full = join(dir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) walk(full);
|
|
90
|
+
else if (entry.isFile()) {
|
|
91
|
+
files.set(relative(absPath, full).split(sep).join("/"), readFileSync(full));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(absPath);
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fetch + extract an archive (tar.gz/tgz/zip), scoped to an optional subpath. */
|
|
100
|
+
async function readArchive(url: string, subpath?: string): Promise<Map<string, Buffer>> {
|
|
101
|
+
const resp = await fetchWithRetry(url);
|
|
102
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
103
|
+
|
|
104
|
+
let raw: Map<string, Buffer>;
|
|
105
|
+
if (url.endsWith(".zip")) {
|
|
106
|
+
raw = await extractFromZip(Buffer.from(bytes));
|
|
107
|
+
} else {
|
|
108
|
+
// .tar.gz / .tgz — gunzip then untar (matches fetchAndExtractTar).
|
|
109
|
+
const { gunzipSync } = await import("fflate");
|
|
110
|
+
raw = extractFromTar(gunzipSync(bytes));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return scopeArchiveFiles(raw, subpath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalize an extracted archive: strip the single top-level wrapper directory
|
|
118
|
+
* (e.g. "repo-main/") that archives commonly add, then scope to `subpath`.
|
|
119
|
+
* Pure — separated from the network fetch so the scoping is unit-testable.
|
|
120
|
+
*/
|
|
121
|
+
export function scopeArchiveFiles(
|
|
122
|
+
raw: Map<string, Buffer>,
|
|
123
|
+
subpath?: string,
|
|
124
|
+
): Map<string, Buffer> {
|
|
125
|
+
const prefix = subpath ? subpath.replace(/^\/+|\/+$/g, "") + "/" : "";
|
|
126
|
+
const files = new Map<string, Buffer>();
|
|
127
|
+
for (const [name, data] of raw) {
|
|
128
|
+
const slash = name.indexOf("/");
|
|
129
|
+
const rel = slash >= 0 ? name.slice(slash + 1) : name;
|
|
130
|
+
if (!rel || !rel.startsWith(prefix)) continue;
|
|
131
|
+
const local = rel.slice(prefix.length);
|
|
132
|
+
if (local) files.set(local, data);
|
|
133
|
+
}
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function resolveSource(entry: VendorEntry, manifestDir: string): Promise<Map<string, Buffer>> {
|
|
138
|
+
if (entry.source.type === "local") {
|
|
139
|
+
const abs = resolve(manifestDir, entry.source.path);
|
|
140
|
+
if (!existsSync(abs)) throw new Error(`local source not found: ${entry.source.path}`);
|
|
141
|
+
return readLocal(abs);
|
|
142
|
+
}
|
|
143
|
+
return readArchive(entry.source.url, entry.source.subpath);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── File I/O ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function writeFiles(targetDir: string, files: Map<string, Buffer>): void {
|
|
149
|
+
if (existsSync(targetDir)) rmSync(targetDir, { recursive: true });
|
|
150
|
+
for (const [rel, data] of files) {
|
|
151
|
+
const full = join(targetDir, rel);
|
|
152
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
153
|
+
writeFileSync(full, data);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readTarget(targetDir: string): Map<string, Buffer> {
|
|
158
|
+
if (!existsSync(targetDir)) return new Map();
|
|
159
|
+
return readLocal(targetDir);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Manifest load/save ────────────────────────────────────────────────────��─
|
|
163
|
+
|
|
164
|
+
export function loadManifest(manifestDir: string): { manifest: VendorManifest; path: string } {
|
|
165
|
+
const path = join(manifestDir, MANIFEST_FILE);
|
|
166
|
+
if (!existsSync(path)) {
|
|
167
|
+
throw new Error(`no ${MANIFEST_FILE} found in ${manifestDir}`);
|
|
168
|
+
}
|
|
169
|
+
const parsed = VendorManifestSchema.safeParse(JSON.parse(readFileSync(path, "utf-8")));
|
|
170
|
+
if (!parsed.success) {
|
|
171
|
+
throw new Error(`invalid ${MANIFEST_FILE}: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
172
|
+
}
|
|
173
|
+
return { manifest: parsed.data, path };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function saveManifest(path: string, manifest: VendorManifest): void {
|
|
177
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── pull ─────────────────────────────────────────────────────────────────��──
|
|
181
|
+
|
|
182
|
+
export interface VendorPullResult {
|
|
183
|
+
success: boolean;
|
|
184
|
+
pulled: Array<{ name: string; target: string; checksum: string; fileCount: number }>;
|
|
185
|
+
output: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Pull each vendored entry (or just `only`): resolve the source, write it into
|
|
190
|
+
* `target`, and record the content checksum back into the manifest.
|
|
191
|
+
*/
|
|
192
|
+
export async function vendorPull(manifestDir: string, only?: string): Promise<VendorPullResult> {
|
|
193
|
+
const { manifest, path } = loadManifest(manifestDir);
|
|
194
|
+
const entries = only ? manifest.vendored.filter((e) => e.name === only) : manifest.vendored;
|
|
195
|
+
if (only && entries.length === 0) {
|
|
196
|
+
return { success: false, pulled: [], output: `no vendored entry named "${only}"` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const pulled: VendorPullResult["pulled"] = [];
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const files = await resolveSource(entry, manifestDir);
|
|
202
|
+
const checksum = contentHash(files);
|
|
203
|
+
writeFiles(resolve(manifestDir, entry.target), files);
|
|
204
|
+
entry.checksum = checksum;
|
|
205
|
+
pulled.push({ name: entry.name, target: entry.target, checksum, fileCount: files.size });
|
|
206
|
+
}
|
|
207
|
+
saveManifest(path, manifest);
|
|
208
|
+
|
|
209
|
+
const output = pulled
|
|
210
|
+
.map((p) => ` ${p.name} → ${p.target} (${p.fileCount} file(s), ${p.checksum.slice(0, 19)}…)`)
|
|
211
|
+
.join("\n");
|
|
212
|
+
return { success: true, pulled, output };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── check ────────────────────────────────────────────────────────────────��──
|
|
216
|
+
|
|
217
|
+
export interface VendorCheckResult {
|
|
218
|
+
success: boolean;
|
|
219
|
+
/** True if any vendored target diverged from its recorded checksum. */
|
|
220
|
+
drift: boolean;
|
|
221
|
+
entries: Array<{ name: string; status: "ok" | "drifted" | "unpinned" | "missing" }>;
|
|
222
|
+
output: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Verify each target's working copy against its recorded checksum. Editing
|
|
227
|
+
* vendored files is allowed — `check` only reports that they diverged from the
|
|
228
|
+
* pin. The caller decides whether drift is fatal (CI) or a warning (local).
|
|
229
|
+
*/
|
|
230
|
+
export function vendorCheck(manifestDir: string): VendorCheckResult {
|
|
231
|
+
const { manifest } = loadManifest(manifestDir);
|
|
232
|
+
const entries: VendorCheckResult["entries"] = [];
|
|
233
|
+
let drift = false;
|
|
234
|
+
|
|
235
|
+
for (const entry of manifest.vendored) {
|
|
236
|
+
const targetAbs = resolve(manifestDir, entry.target);
|
|
237
|
+
if (!entry.checksum) {
|
|
238
|
+
entries.push({ name: entry.name, status: "unpinned" });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!existsSync(targetAbs)) {
|
|
242
|
+
entries.push({ name: entry.name, status: "missing" });
|
|
243
|
+
drift = true;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const actual = contentHash(readTarget(targetAbs));
|
|
247
|
+
if (actual === entry.checksum) {
|
|
248
|
+
entries.push({ name: entry.name, status: "ok" });
|
|
249
|
+
} else {
|
|
250
|
+
entries.push({ name: entry.name, status: "drifted" });
|
|
251
|
+
drift = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const label: Record<string, string> = {
|
|
256
|
+
ok: "ok",
|
|
257
|
+
drifted: "DRIFTED (working copy differs from the pin)",
|
|
258
|
+
unpinned: "unpinned — run `chant vendor pull` to record a checksum",
|
|
259
|
+
missing: "MISSING — target not found; run `chant vendor pull`",
|
|
260
|
+
};
|
|
261
|
+
const output = entries.map((e) => ` ${e.name}: ${label[e.status]}`).join("\n");
|
|
262
|
+
return { success: !drift, drift, entries, output };
|
|
263
|
+
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { discoverOps } from "../../op/discover";
|
|
2
|
-
import {
|
|
3
|
+
import { discover } from "../../discovery/index";
|
|
4
|
+
import { partitionByLexicon, computeStackGraph } from "../../build";
|
|
5
|
+
import { formatError, formatWarning, formatBold } from "../format";
|
|
3
6
|
import type { CommandContext } from "../registry";
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `chant graph` — the Op dependency graph by default; `--stacks` renders the
|
|
10
|
+
* cross-stack apply-ordering graph (edges, order, waves) chant computes from
|
|
11
|
+
* cross-lexicon references.
|
|
12
|
+
*/
|
|
13
|
+
export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
14
|
+
if (ctx.args.stacks) return runStackGraph(ctx);
|
|
15
|
+
return runOpGraph();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function runOpGraph(): Promise<number> {
|
|
6
19
|
const { ops, errors } = await discoverOps();
|
|
7
20
|
for (const err of errors) console.error(formatError({ message: err }));
|
|
8
21
|
|
|
@@ -21,3 +34,43 @@ export async function runGraph(_ctx: CommandContext): Promise<number> {
|
|
|
21
34
|
if (!hasEdges) console.log("No Op dependencies");
|
|
22
35
|
return 0;
|
|
23
36
|
}
|
|
37
|
+
|
|
38
|
+
async function runStackGraph(ctx: CommandContext): Promise<number> {
|
|
39
|
+
const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
|
|
40
|
+
const result = await discover(projectPath);
|
|
41
|
+
if (result.errors.length > 0) {
|
|
42
|
+
for (const e of result.errors) console.error(formatError({ message: e.message }));
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lexicons = [...partitionByLexicon(result.entities).keys()];
|
|
47
|
+
const graph = computeStackGraph(result.entities, lexicons);
|
|
48
|
+
|
|
49
|
+
if (ctx.args.json) {
|
|
50
|
+
console.log(JSON.stringify(graph, null, 2));
|
|
51
|
+
return graph.cycles.length > 0 ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (graph.nodes.length === 0) {
|
|
55
|
+
console.log("No stacks found");
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(formatBold("Apply order (waves apply top-to-bottom; a wave's stacks are parallel-safe):"));
|
|
60
|
+
graph.waves.forEach((wave, i) => console.log(` ${i + 1}. ${wave.join(", ")}`));
|
|
61
|
+
|
|
62
|
+
if (graph.edges.length > 0) {
|
|
63
|
+
console.log(formatBold("\nDependencies (consumer → producer):"));
|
|
64
|
+
for (const { from, to } of graph.edges) console.log(` ${from} → ${to}`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log("\nNo cross-stack dependencies — all stacks are independent.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (graph.cycles.length > 0) {
|
|
70
|
+
for (const cycle of graph.cycles) {
|
|
71
|
+
console.error(formatWarning({ message: `Dependency cycle among stacks: ${cycle.join(" ↔ ")}` }));
|
|
72
|
+
}
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle,
|
|
|
5
5
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
6
|
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
|
|
7
7
|
import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
|
|
8
|
+
import { affectedStacks } from "../../lifecycle/affected";
|
|
8
9
|
import { loadChantConfig } from "../../config";
|
|
9
10
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
10
11
|
import type { CommandContext } from "../registry";
|
|
@@ -576,3 +577,59 @@ function printSnapshotTable(snapshot: LifecycleSnapshot): void {
|
|
|
576
577
|
);
|
|
577
578
|
}
|
|
578
579
|
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* chant lifecycle affected --base <ref> [--head <ref>] [--include-dependents] [--json]
|
|
583
|
+
*
|
|
584
|
+
* Read-only: report which stacks a change affects (directly-changed via artifact
|
|
585
|
+
* diff, dependents via the cross-stack graph, external-input as indeterminate).
|
|
586
|
+
* Returns the set; fanning plan/apply over it is an Op the user composes.
|
|
587
|
+
*/
|
|
588
|
+
export async function runLifecycleAffected(ctx: CommandContext): Promise<number> {
|
|
589
|
+
const { args, plugins } = ctx;
|
|
590
|
+
if (!args.base) {
|
|
591
|
+
console.error(formatError({
|
|
592
|
+
message: "Base ref is required: chant lifecycle affected --base <ref> [--head <ref>] [--include-dependents]",
|
|
593
|
+
}));
|
|
594
|
+
return 1;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
598
|
+
const projectPath = resolveBuildRoot(args, config);
|
|
599
|
+
|
|
600
|
+
let result;
|
|
601
|
+
try {
|
|
602
|
+
result = await affectedStacks({
|
|
603
|
+
projectPath,
|
|
604
|
+
serializers: plugins.map((p) => p.serializer),
|
|
605
|
+
baseRef: args.base,
|
|
606
|
+
headRef: args.head,
|
|
607
|
+
includeDependents: args.includeDependents,
|
|
608
|
+
});
|
|
609
|
+
} catch (err) {
|
|
610
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
611
|
+
return 1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (args.json) {
|
|
615
|
+
console.log(JSON.stringify(result, null, 2));
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (result.changed.length === 0 && result.dependents.length === 0) {
|
|
620
|
+
console.error(formatSuccess("No stacks affected"));
|
|
621
|
+
} else {
|
|
622
|
+
console.log(formatBold("Directly changed:"));
|
|
623
|
+
console.log(result.changed.length ? result.changed.map((s) => ` ${s}`).join("\n") : " (none)");
|
|
624
|
+
if (args.includeDependents) {
|
|
625
|
+
console.log(formatBold("\nDependents (consume a changed stack):"));
|
|
626
|
+
console.log(result.dependents.length ? result.dependents.map((s) => ` ${s}`).join("\n") : " (none)");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (result.indeterminate.length > 0) {
|
|
630
|
+
console.error(formatWarning({
|
|
631
|
+
message: `External-input stacks — cannot confirm from source: ${result.indeterminate.join(", ")}`,
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { vendorPull, vendorCheck } from "../commands/vendor";
|
|
3
|
+
import { formatError, formatSuccess, formatWarning } from "../format";
|
|
4
|
+
import type { CommandContext } from "../registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `chant vendor [pull|check]` — pull pinned, checksummed patterns into the repo,
|
|
8
|
+
* or check vendored targets against their recorded pin. Defaults to `pull`.
|
|
9
|
+
*
|
|
10
|
+
* The manifest (`vendor.json`) lives in the current directory.
|
|
11
|
+
*/
|
|
12
|
+
export async function runVendor(ctx: CommandContext): Promise<number> {
|
|
13
|
+
const { args } = ctx;
|
|
14
|
+
// `chant vendor` → pull; `chant vendor pull|check [name]`.
|
|
15
|
+
const sub = args.path === "." ? "pull" : args.path;
|
|
16
|
+
const manifestDir = resolve(".");
|
|
17
|
+
|
|
18
|
+
if (sub === "pull") {
|
|
19
|
+
try {
|
|
20
|
+
const result = await vendorPull(manifestDir, args.extraPositional);
|
|
21
|
+
if (!result.success) {
|
|
22
|
+
console.error(formatError({ message: result.output }));
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
console.log(result.output);
|
|
26
|
+
console.error(formatSuccess(`Vendored ${result.pulled.length} artifact(s)`));
|
|
27
|
+
return 0;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (sub === "check") {
|
|
35
|
+
try {
|
|
36
|
+
const result = vendorCheck(manifestDir);
|
|
37
|
+
console.log(result.output);
|
|
38
|
+
if (!result.drift) {
|
|
39
|
+
console.error(formatSuccess("All vendored artifacts match their pin"));
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
// Drift is allowed (you may edit vendored files); fail only in CI so a
|
|
43
|
+
// pipeline catches unrecorded changes, warn locally.
|
|
44
|
+
if (process.env.CI) {
|
|
45
|
+
console.error(formatError({ message: "Vendored content drifted from the manifest pin" }));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
console.error(formatWarning({ message: "Vendored content drifted from the manifest pin (run `chant vendor pull` to re-pin)" }));
|
|
49
|
+
return 0;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.error(formatError({
|
|
57
|
+
message: `Unknown vendor subcommand: ${sub}`,
|
|
58
|
+
hint: "Available: chant vendor pull [name], chant vendor check",
|
|
59
|
+
}));
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -12,8 +12,9 @@ import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDe
|
|
|
12
12
|
import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
|
|
13
13
|
import { runInit, runInitLexicon } from "./handlers/init";
|
|
14
14
|
import { runList, runDescribe, runImport, runUpdate, runDoctor } from "./handlers/misc";
|
|
15
|
+
import { runVendor } from "./handlers/vendor";
|
|
15
16
|
import { runMigrate } from "./handlers/migrate";
|
|
16
|
-
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
17
|
+
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
17
18
|
import { runGraph } from "./handlers/graph";
|
|
18
19
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
19
20
|
|
|
@@ -50,6 +51,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
50
51
|
reportFile: undefined,
|
|
51
52
|
skill: undefined,
|
|
52
53
|
src: undefined,
|
|
54
|
+
env: undefined,
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
let i = 0;
|
|
@@ -114,6 +116,16 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
114
116
|
result.skill = args[++i];
|
|
115
117
|
} else if (arg === "--src") {
|
|
116
118
|
result.src = args[++i];
|
|
119
|
+
} else if (arg === "--env") {
|
|
120
|
+
result.env = args[++i];
|
|
121
|
+
} else if (arg === "--stacks") {
|
|
122
|
+
result.stacks = true;
|
|
123
|
+
} else if (arg === "--base") {
|
|
124
|
+
result.base = args[++i];
|
|
125
|
+
} else if (arg === "--head") {
|
|
126
|
+
result.head = args[++i];
|
|
127
|
+
} else if (arg === "--include-dependents") {
|
|
128
|
+
result.includeDependents = true;
|
|
117
129
|
} else if (arg === "--local") {
|
|
118
130
|
result.local = true;
|
|
119
131
|
} else if (arg === "--temporal") {
|
|
@@ -155,6 +167,7 @@ Commands:
|
|
|
155
167
|
lint Check specifications for issues
|
|
156
168
|
list List discovered entities
|
|
157
169
|
describe Show the effective config for one component
|
|
170
|
+
vendor Pull pinned, checksummed patterns into your repo
|
|
158
171
|
import Import external template into TypeScript
|
|
159
172
|
migrate <file> Translate a workflow between lexicons
|
|
160
173
|
(default: --from github --to gitlab)
|
|
@@ -167,7 +180,7 @@ Ops:
|
|
|
167
180
|
run cancel <name> Cancel the active workflow run (requires --force)
|
|
168
181
|
run log <name> Show run history for an Op
|
|
169
182
|
|
|
170
|
-
graph Show Op dependency graph
|
|
183
|
+
graph Show Op dependency graph (--stacks for cross-stack order)
|
|
171
184
|
|
|
172
185
|
Lifecycle (alias: lc):
|
|
173
186
|
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
|
@@ -175,6 +188,7 @@ Lifecycle (alias: lc):
|
|
|
175
188
|
lifecycle diff <env> Compare current build against last snapshot
|
|
176
189
|
--live: query cloud now and detect drift
|
|
177
190
|
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
191
|
+
lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
|
|
178
192
|
--json: emit the ChangeSet as JSON
|
|
179
193
|
lifecycle log [env] History of lifecycle snapshots
|
|
180
194
|
|
|
@@ -199,6 +213,7 @@ Options:
|
|
|
199
213
|
- list: text (default) or json
|
|
200
214
|
- lint: stylish (default), json, or sarif
|
|
201
215
|
-d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab)
|
|
216
|
+
--env <name> Environment for organizational policy evaluation (build)
|
|
202
217
|
-t, --template <name> Init template (e.g. node-pipeline, docker-build)
|
|
203
218
|
--skill <name> Init: install only this skill from the lexicon
|
|
204
219
|
--fix Auto-fix fixable issues (lint command)
|
|
@@ -291,12 +306,14 @@ const registry: CommandDef[] = [
|
|
|
291
306
|
{ name: "run", handler: runOp },
|
|
292
307
|
|
|
293
308
|
{ name: "graph", handler: runGraph },
|
|
309
|
+
{ name: "vendor", handler: runVendor },
|
|
294
310
|
|
|
295
311
|
// State subcommands
|
|
296
312
|
{ name: "lifecycle snapshot", requiresPlugins: true, handler: runLifecycleSnapshot },
|
|
297
313
|
{ name: "lifecycle show", handler: runLifecycleShow },
|
|
298
314
|
{ name: "lifecycle diff", requiresPlugins: true, handler: runLifecycleDiff },
|
|
299
315
|
{ name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
|
|
316
|
+
{ name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
|
|
300
317
|
{ name: "lifecycle log", handler: runLifecycleLog },
|
|
301
318
|
|
|
302
319
|
// Serve subcommands
|
package/src/cli/registry.ts
CHANGED
|
@@ -53,6 +53,16 @@ export interface ParsedArgs {
|
|
|
53
53
|
verbatim?: boolean;
|
|
54
54
|
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
55
55
|
src?: string;
|
|
56
|
+
/** `chant build --env <name>` — environment for organizational policy evaluation */
|
|
57
|
+
env?: string;
|
|
58
|
+
/** `chant graph --stacks` — render the cross-stack apply-ordering graph */
|
|
59
|
+
stacks?: boolean;
|
|
60
|
+
/** `chant lifecycle affected --base <ref>` — base git ref to diff against */
|
|
61
|
+
base?: string;
|
|
62
|
+
/** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
|
|
63
|
+
head?: string;
|
|
64
|
+
/** `chant lifecycle affected --include-dependents` — add downstream consumers */
|
|
65
|
+
includeDependents?: boolean;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/**
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from "./lint/declarative";
|
|
|
32
32
|
export * from "./lint/selectors";
|
|
33
33
|
export * from "./lint/named-checks";
|
|
34
34
|
export * from "./lint/post-synth";
|
|
35
|
+
export * from "./lint/policy";
|
|
35
36
|
export * from "./lint/rule-loader";
|
|
36
37
|
export * from "./lint/discover";
|
|
37
38
|
export * from "./import/parser";
|