@intentius/chant 0.1.17 → 0.1.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -0,0 +1,148 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ vendorPull,
7
+ vendorCheck,
8
+ contentHash,
9
+ scopeArchiveFiles,
10
+ loadManifest,
11
+ MANIFEST_FILE,
12
+ } from "./vendor";
13
+
14
+ let root: string;
15
+
16
+ function write(rel: string, content: string): void {
17
+ const full = join(root, rel);
18
+ mkdirSync(join(full, ".."), { recursive: true });
19
+ writeFileSync(full, content);
20
+ }
21
+
22
+ beforeEach(() => {
23
+ root = mkdtempSync(join(tmpdir(), "chant-vendor-"));
24
+ // A local "shared pattern" source.
25
+ write("shared/web-app/index.ts", "export const WebApp = () => ({});\n");
26
+ write("shared/web-app/README.md", "# pattern\n");
27
+ // A project that vendors it.
28
+ writeFileSync(
29
+ join(root, MANIFEST_FILE),
30
+ JSON.stringify({
31
+ vendored: [
32
+ { name: "web-app", source: { type: "local", path: "shared/web-app" }, target: "vendor/web-app", ref: "v1" },
33
+ ],
34
+ }, null, 2),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ rmSync(root, { recursive: true, force: true });
40
+ });
41
+
42
+ describe("vendor pull", () => {
43
+ test("copies the source into target and records a checksum", async () => {
44
+ const result = await vendorPull(root);
45
+ expect(result.success).toBe(true);
46
+ expect(result.pulled[0].fileCount).toBe(2);
47
+
48
+ expect(existsSync(join(root, "vendor/web-app/index.ts"))).toBe(true);
49
+ expect(readFileSync(join(root, "vendor/web-app/README.md"), "utf-8")).toContain("# pattern");
50
+
51
+ // Checksum written back into the manifest.
52
+ const { manifest } = loadManifest(root);
53
+ expect(manifest.vendored[0].checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
54
+ });
55
+
56
+ test("pull <name> filters to one entry; unknown name fails", async () => {
57
+ expect((await vendorPull(root, "web-app")).pulled).toHaveLength(1);
58
+ const miss = await vendorPull(root, "nope");
59
+ expect(miss.success).toBe(false);
60
+ expect(miss.output).toContain("nope");
61
+ });
62
+
63
+ test("fails clearly when the local source is missing", async () => {
64
+ writeFileSync(
65
+ join(root, MANIFEST_FILE),
66
+ JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "missing" }, target: "vendor/x" }] }),
67
+ );
68
+ await expect(vendorPull(root)).rejects.toThrow(/local source not found/);
69
+ });
70
+ });
71
+
72
+ describe("vendor check", () => {
73
+ test("reports ok right after a pull", async () => {
74
+ await vendorPull(root);
75
+ const result = vendorCheck(root);
76
+ expect(result.drift).toBe(false);
77
+ expect(result.entries[0].status).toBe("ok");
78
+ });
79
+
80
+ test("detects drift when a vendored file is edited", async () => {
81
+ await vendorPull(root);
82
+ writeFileSync(join(root, "vendor/web-app/index.ts"), "// locally edited\n");
83
+ const result = vendorCheck(root);
84
+ expect(result.drift).toBe(true);
85
+ expect(result.entries[0].status).toBe("drifted");
86
+ });
87
+
88
+ test("flags a target deleted after pinning as missing", async () => {
89
+ await vendorPull(root);
90
+ rmSync(join(root, "vendor/web-app"), { recursive: true });
91
+ const result = vendorCheck(root);
92
+ expect(result.entries[0].status).toBe("missing");
93
+ expect(result.drift).toBe(true);
94
+ });
95
+
96
+ test("an entry with no checksum is unpinned, not drift", async () => {
97
+ // Manifest never pulled → no checksum recorded.
98
+ const result = vendorCheck(root);
99
+ expect(result.entries[0].status).toBe("unpinned");
100
+ expect(result.drift).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("manifest validation", () => {
105
+ test("rejects an invalid manifest", () => {
106
+ writeFileSync(join(root, MANIFEST_FILE), JSON.stringify({ vendored: [{ name: "x" }] }));
107
+ expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
108
+ });
109
+
110
+ test("rejects a floating updatePolicy (pins only)", () => {
111
+ writeFileSync(
112
+ join(root, MANIFEST_FILE),
113
+ JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "shared/web-app" }, target: "v/x", updatePolicy: "latest" }] }),
114
+ );
115
+ expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
116
+ });
117
+ });
118
+
119
+ describe("contentHash", () => {
120
+ test("is order-independent and content-sensitive", () => {
121
+ const a = new Map([["a", Buffer.from("1")], ["b", Buffer.from("2")]]);
122
+ const b = new Map([["b", Buffer.from("2")], ["a", Buffer.from("1")]]);
123
+ expect(contentHash(a)).toBe(contentHash(b)); // order doesn't matter
124
+ const c = new Map([["a", Buffer.from("1")], ["b", Buffer.from("CHANGED")]]);
125
+ expect(contentHash(a)).not.toBe(contentHash(c));
126
+ });
127
+ });
128
+
129
+ describe("scopeArchiveFiles", () => {
130
+ test("strips the top-level wrapper dir and scopes to a subpath", () => {
131
+ const raw = new Map([
132
+ ["repo-main/composites/web-app/index.ts", Buffer.from("a")],
133
+ ["repo-main/composites/web-app/util.ts", Buffer.from("b")],
134
+ ["repo-main/ops/deploy.op.ts", Buffer.from("c")],
135
+ ["repo-main/README.md", Buffer.from("d")],
136
+ ]);
137
+ const scoped = scopeArchiveFiles(raw, "composites/web-app");
138
+ expect([...scoped.keys()].sort()).toEqual(["index.ts", "util.ts"]);
139
+ });
140
+
141
+ test("no subpath keeps everything below the wrapper dir", () => {
142
+ const raw = new Map([
143
+ ["repo-main/a.ts", Buffer.from("a")],
144
+ ["repo-main/sub/b.ts", Buffer.from("b")],
145
+ ]);
146
+ expect([...scopeArchiveFiles(raw).keys()].sort()).toEqual(["a.ts", "sub/b.ts"]);
147
+ });
148
+ });
@@ -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
+ }
@@ -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,6 +12,7 @@ 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
17
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
17
18
  import { runGraph } from "./handlers/graph";
@@ -155,6 +156,7 @@ Commands:
155
156
  lint Check specifications for issues
156
157
  list List discovered entities
157
158
  describe Show the effective config for one component
159
+ vendor Pull pinned, checksummed patterns into your repo
158
160
  import Import external template into TypeScript
159
161
  migrate <file> Translate a workflow between lexicons
160
162
  (default: --from github --to gitlab)
@@ -291,6 +293,7 @@ const registry: CommandDef[] = [
291
293
  { name: "run", handler: runOp },
292
294
 
293
295
  { name: "graph", handler: runGraph },
296
+ { name: "vendor", handler: runVendor },
294
297
 
295
298
  // State subcommands
296
299
  { name: "lifecycle snapshot", requiresPlugins: true, handler: runLifecycleSnapshot },