@intentius/chant 0.81.0 → 0.83.0

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.
Files changed (67) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/content-digest.d.ts +2 -0
  3. package/dist/content-digest.d.ts.map +1 -1
  4. package/dist/workspace/checks/records.d.ts +33 -0
  5. package/dist/workspace/checks/records.d.ts.map +1 -0
  6. package/dist/workspace/checks.d.ts +8 -0
  7. package/dist/workspace/checks.d.ts.map +1 -1
  8. package/dist/workspace/compose-graph.d.ts +22 -6
  9. package/dist/workspace/compose-graph.d.ts.map +1 -1
  10. package/dist/workspace/graph-cli.d.ts +11 -4
  11. package/dist/workspace/graph-cli.d.ts.map +1 -1
  12. package/dist/workspace/lineage-check.d.ts +5 -2
  13. package/dist/workspace/lineage-check.d.ts.map +1 -1
  14. package/dist/workspace/lineage-cli.d.ts +5 -1
  15. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  16. package/dist/workspace/lineage-init.d.ts +47 -4
  17. package/dist/workspace/lineage-init.d.ts.map +1 -1
  18. package/dist/workspace/lineage-lock.d.ts +23 -2
  19. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  20. package/dist/workspace/lineage-upgrade-cli.d.ts +1 -1
  21. package/dist/workspace/lineage-upgrade.d.ts +8 -2
  22. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  23. package/dist/workspace/reason-codes.d.ts +4 -0
  24. package/dist/workspace/reason-codes.d.ts.map +1 -1
  25. package/dist/workspace/record-assets.d.ts +108 -0
  26. package/dist/workspace/record-assets.d.ts.map +1 -0
  27. package/dist/workspace/records-cli.d.ts +32 -1
  28. package/dist/workspace/records-cli.d.ts.map +1 -1
  29. package/dist/workspace/records.d.ts +41 -0
  30. package/dist/workspace/records.d.ts.map +1 -1
  31. package/dist/workspace/template-pins.d.ts +33 -0
  32. package/dist/workspace/template-pins.d.ts.map +1 -0
  33. package/dist/workspace/tree.d.ts +5 -0
  34. package/dist/workspace/tree.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/init.ts +7 -3
  37. package/src/cli/main.ts +16 -8
  38. package/src/content-digest.ts +5 -0
  39. package/src/workspace/behold-kinds.test.ts +10 -14
  40. package/src/workspace/checks/records.ts +83 -0
  41. package/src/workspace/checks.test.ts +2 -0
  42. package/src/workspace/checks.ts +13 -3
  43. package/src/workspace/compose-graph.test.ts +2 -1
  44. package/src/workspace/compose-graph.ts +23 -6
  45. package/src/workspace/graph-cli.ts +32 -6
  46. package/src/workspace/graph-contract.test.ts +2 -1
  47. package/src/workspace/graph.schema.json +131 -2
  48. package/src/workspace/lineage-check.ts +22 -5
  49. package/src/workspace/lineage-cli.ts +21 -1
  50. package/src/workspace/lineage-init-dir.test.ts +243 -0
  51. package/src/workspace/lineage-init.ts +223 -24
  52. package/src/workspace/lineage-lock.ts +18 -3
  53. package/src/workspace/lineage-upgrade-cli.ts +2 -2
  54. package/src/workspace/lineage-upgrade.test.ts +29 -0
  55. package/src/workspace/lineage-upgrade.ts +130 -13
  56. package/src/workspace/member-commands.ts +1 -1
  57. package/src/workspace/reason-codes.test.ts +2 -1
  58. package/src/workspace/reason-codes.ts +5 -0
  59. package/src/workspace/record-assets.test.ts +319 -0
  60. package/src/workspace/record-assets.ts +207 -0
  61. package/src/workspace/records-cli.ts +117 -14
  62. package/src/workspace/records.schema.json +33 -1
  63. package/src/workspace/records.test.ts +50 -0
  64. package/src/workspace/records.ts +115 -5
  65. package/src/workspace/template-pins.test.ts +69 -0
  66. package/src/workspace/template-pins.ts +117 -0
  67. package/src/workspace/tree.ts +12 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * chant #2647: `chant init --from <dir>[#<member>]`, a template directory on
3
+ * disk. The same files and parameters as the git form for the same tree, a
4
+ * lock whose address is the digest alone, and `chant workspace upgrade`
5
+ * from a directory, which rebuilds its merge base only while the recorded
6
+ * directory still holds what the scope was made from.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
10
+ import { execFileSync } from "node:child_process";
11
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { initFromCommand, parseDirSpec, recordedDirPath } from "./lineage-init";
15
+ import { LOCK_FILE, readLock } from "./lineage-lock";
16
+ import { describeSource, lineageView } from "./lineage-cli";
17
+ import { stageUpgrade, type ChantRunner } from "./lineage-upgrade";
18
+
19
+ const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
20
+
21
+ let root: string;
22
+
23
+ function git(cwd: string, args: string[]): string {
24
+ return execFileSync("git", args, { cwd, encoding: "utf-8", env: ENV, stdio: ["ignore", "pipe", "pipe"] }).trim();
25
+ }
26
+ function put(base: string, rel: string, content: string): void {
27
+ mkdirSync(dirname(join(base, rel)), { recursive: true });
28
+ writeFileSync(join(base, rel), content);
29
+ }
30
+ function read(base: string, rel: string): string {
31
+ return readFileSync(join(base, rel), "utf-8");
32
+ }
33
+ /** Every file under `dir` except the lock, with its content and executable bit. */
34
+ function tree(dir: string, prefix = ""): Record<string, string> {
35
+ const out: Record<string, string> = {};
36
+ for (const e of readdirSync(join(dir, prefix), { withFileTypes: true })) {
37
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
38
+ if (e.isDirectory()) Object.assign(out, tree(dir, rel));
39
+ else if (rel !== LOCK_FILE) out[rel] = `${statSync(join(dir, rel)).mode & 0o100 ? "x " : ""}${readFileSync(join(dir, rel), "utf-8")}`;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** A template directory with a manifest, an executable, a seed and a lock of its own. */
45
+ function writeTemplate(dir: string, version = "1"): void {
46
+ put(dir, "svc/README.md", `template v${version}\n`);
47
+ put(dir, "svc/src/main.ts", 'export const name = "{{chant:name}}";\n');
48
+ put(dir, "svc/run.sh", "#!/bin/sh\n");
49
+ execFileSync("chmod", ["+x", join(dir, "svc/run.sh")]);
50
+ put(dir, "svc/.mcp.json", "{}\n");
51
+ put(dir, `svc/${LOCK_FILE}`, JSON.stringify({ lockVersion: 1, scopes: {} }));
52
+ put(
53
+ dir,
54
+ "svc/chant.template.json",
55
+ JSON.stringify({ parameters: { name: { type: "string", default: "app" }, owner: { type: "string", default: "ops" } }, files: ["src/main.ts"] }),
56
+ );
57
+ }
58
+
59
+ beforeEach(() => {
60
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-init-dir-test-")));
61
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
62
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
63
+ });
64
+ afterEach(() => {
65
+ vi.restoreAllMocks();
66
+ rmSync(root, { recursive: true, force: true });
67
+ });
68
+
69
+ describe("parseDirSpec", () => {
70
+ test("an existing directory is the directory form, with or without #<member>", () => {
71
+ mkdirSync(join(root, "tpl", "svc"), { recursive: true });
72
+ expect(parseDirSpec("tpl#svc", root)).toEqual({ kind: "dir", path: "tpl", abs: join(root, "tpl"), member: "svc" });
73
+ expect(parseDirSpec(join(root, "tpl"), root)).toEqual({ kind: "dir", path: join(root, "tpl"), abs: join(root, "tpl") });
74
+ });
75
+ test("a directory whose name parses as <repo>@<ref> is still a directory", () => {
76
+ mkdirSync(join(root, "starter@v1"));
77
+ expect(parseDirSpec("starter@v1", root)).toMatchObject({ kind: "dir", abs: join(root, "starter@v1") });
78
+ });
79
+ test("<repo>@<ref> with no such directory is the git form", () => {
80
+ expect(parseDirSpec("acme/starter@v1.2.0#service", root)).toBeNull();
81
+ });
82
+ test("a path with no @ that is not a directory is refused", () => {
83
+ expect(() => parseDirSpec("missing", root)).toThrow(/no directory missing, and not <repo>@<ref>/);
84
+ put(root, "file", "x");
85
+ expect(() => parseDirSpec("file", root)).toThrow(/file is not a directory/);
86
+ mkdirSync(join(root, "tpl"));
87
+ expect(() => parseDirSpec("tpl#../x", root)).toThrow(/not a directory in the directory/);
88
+ });
89
+ test("a relative directory is recorded from the project, an absolute one as given", () => {
90
+ expect(recordedDirPath("../tpl", join(root, "tpl"), join(root, "proj"))).toBe("../tpl");
91
+ expect(recordedDirPath("tpl", join(root, "tpl"), root)).toBe("./tpl");
92
+ expect(recordedDirPath(join(root, "tpl"), join(root, "tpl"), join(root, "proj"))).toBe(join(root, "tpl"));
93
+ });
94
+ });
95
+
96
+ describe("chant init --from <dir>", () => {
97
+ test("copies the same files, with the same parameters and digest, as the git form of the same tree", async () => {
98
+ const tpl = join(root, "tpl");
99
+ writeTemplate(tpl);
100
+ git(tpl, ["init", "-q", "-b", "main"]);
101
+ git(tpl, ["add", "-A"]);
102
+ git(tpl, ["commit", "-q", "-m", "v1"]);
103
+
104
+ const fromGit = await initFromCommand({ from: `${tpl}@main#svc`, path: join(root, "a"), params: { name: "billing" } });
105
+ const fromDir = await initFromCommand({ from: `${tpl}#svc`, path: join(root, "b"), params: { name: "billing" } });
106
+ expect(fromGit.error).toBeUndefined();
107
+ expect(fromDir.error).toBeUndefined();
108
+ expect(fromDir.createdFiles).toEqual(fromGit.createdFiles);
109
+ expect(tree(join(root, "b"))).toEqual(tree(join(root, "a")));
110
+ expect(read(join(root, "b"), "src/main.ts")).toBe('export const name = "billing";\n');
111
+ expect(statSync(join(root, "b", "run.sh")).mode & 0o111).not.toBe(0);
112
+ expect(existsSync(join(root, "b", "chant.template.json"))).toBe(false);
113
+
114
+ const a = readLock(join(root, "a"))!.scopes["."];
115
+ const b = readLock(join(root, "b"))!.scopes["."];
116
+ expect(b.parameters).toEqual({ name: "billing", owner: "ops" });
117
+ expect(b.parameters).toEqual(a.parameters);
118
+ expect(b.files).toEqual(a.files);
119
+ expect(b.address).toEqual({ digest: a.address!.digest });
120
+ // The lock shape: the digest alone, a dir source, no ref.
121
+ expect(b.source).toEqual({ type: "dir", path: tpl, member: "svc" });
122
+ expect(b.template).toBe(`dir:${tpl}#svc`);
123
+ expect(b.ref).toBeUndefined();
124
+ expect(fromDir.template).toBe(`dir:${tpl}#svc`);
125
+ expect(fromDir.commit).toBeUndefined();
126
+ expect(a.source).toMatchObject({ type: "git", path: "svc" });
127
+ });
128
+
129
+ test("inside a git checkout, .gitignore is respected and untracked files are copied", async () => {
130
+ const tpl = join(root, "tpl");
131
+ git(root, ["init", "-q", "-b", "main"]);
132
+ put(tpl, "keep.ts", "keep\n");
133
+ put(tpl, ".gitignore", "out/\n*.log\n");
134
+ put(tpl, "out/built.js", "built\n");
135
+ put(tpl, "debug.log", "noise\n");
136
+ git(root, ["add", "tpl/keep.ts", "tpl/.gitignore"]);
137
+ git(root, ["commit", "-q", "-m", "t"]);
138
+ put(tpl, "untracked.ts", "new\n");
139
+
140
+ const result = await initFromCommand({ from: tpl, path: join(root, "proj") });
141
+ expect(result.error).toBeUndefined();
142
+ expect(result.createdFiles.sort()).toEqual([".gitignore", LOCK_FILE, "keep.ts", "untracked.ts"].sort());
143
+ });
144
+
145
+ test("outside git every file is copied, except node_modules, symbolic links and the template's own lock", async () => {
146
+ const tpl = join(root, "tpl");
147
+ writeTemplate(tpl);
148
+ put(tpl, "svc/.gitignore", "*.log\n");
149
+ put(tpl, "svc/debug.log", "copied: no git to read .gitignore\n");
150
+ put(tpl, "svc/node_modules/dep/index.js", "x\n");
151
+ symlinkSync("README.md", join(tpl, "svc", "link.md"));
152
+
153
+ const result = await initFromCommand({ from: `${tpl}#svc`, path: join(root, "proj") });
154
+ expect(result.error).toBeUndefined();
155
+ expect(result.createdFiles.sort()).toEqual([".gitignore", ".mcp.json", LOCK_FILE, "README.md", "debug.log", "run.sh", "src/main.ts"].sort());
156
+ expect(result.warnings).toEqual(["link.md is a symbolic link, not copied", "node_modules is node_modules, not copied"]);
157
+ expect(readLock(join(root, "proj"))!.scopes["."].files).not.toHaveProperty(LOCK_FILE);
158
+ });
159
+
160
+ test("refuses a missing member, a missing directory and an undeclared --param, writing nothing", async () => {
161
+ const tpl = join(root, "tpl");
162
+ writeTemplate(tpl);
163
+ const target = join(root, "proj");
164
+ expect((await initFromCommand({ from: `${tpl}#nope`, path: target })).error).toBe(`${tpl} has no directory nope`);
165
+ expect((await initFromCommand({ from: join(root, "missing"), path: target })).error).toMatch(/no directory .*missing, and not <repo>@<ref>/);
166
+ expect((await initFromCommand({ from: `${tpl}#svc`, path: target, params: { title: "x" } })).error).toBe("unknown parameter title (declared: name, owner)");
167
+ expect(existsSync(target)).toBe(false);
168
+ });
169
+
170
+ test("chant workspace lineage names the source kind and path", async () => {
171
+ const tpl = join(root, "tpl");
172
+ writeTemplate(tpl);
173
+ await initFromCommand({ from: `${tpl}#svc`, path: join(root, "proj") });
174
+ const view = lineageView(join(root, "proj"))!;
175
+ expect(view.scopes[0].source).toEqual({ type: "dir", path: tpl, member: "svc" });
176
+ expect(describeSource(view.scopes[0].source)).toBe(`dir ${tpl}#svc`);
177
+ expect(describeSource({ type: "git", repo: "acme/starter", url: "https://github.com/acme/starter.git", path: "svc" })).toBe("git acme/starter#svc");
178
+ });
179
+ });
180
+
181
+ describe("chant workspace upgrade on a directory scope", () => {
182
+ const passing: ChantRunner = async () => ({ exitCode: 0, output: "" });
183
+ let v1: string;
184
+ let v2: string;
185
+ let proj: string;
186
+
187
+ beforeEach(async () => {
188
+ v1 = join(root, "bundle-v1");
189
+ v2 = join(root, "bundle-v2");
190
+ proj = join(root, "proj");
191
+ writeTemplate(v1);
192
+ put(v1, "svc/src/lib.ts", "one\ntwo\nthree\nfour\nfive\nsix\nseven\n");
193
+ const made = await initFromCommand({ from: `${v1}#svc`, path: proj, params: { name: "billing" } });
194
+ expect(made.error).toBeUndefined();
195
+ git(proj, ["init", "-q", "-b", "main"]);
196
+ put(proj, "src/lib.ts", "ONE (ours)\ntwo\nthree\nfour\nfive\nsix\nseven\n");
197
+ git(proj, ["add", "-A"]);
198
+ git(proj, ["commit", "-q", "-m", "init and edit"]);
199
+
200
+ cpSync(v1, v2, { recursive: true });
201
+ put(v2, "svc/README.md", "template v2\n");
202
+ put(v2, "svc/src/lib.ts", "one\ntwo\nthree\nfour\nfive\nsix\nSEVEN (theirs)\n");
203
+ put(v2, "svc/src/new.ts", "new\n");
204
+ });
205
+
206
+ test("without --to it refuses, naming adopt-lineage", async () => {
207
+ await expect(stageUpgrade({ root: proj, runChant: passing })).rejects.toThrow(/no --to directory\. .*has no git history.*chant workspace adopt-lineage/);
208
+ });
209
+
210
+ test("with --to a directory, the recorded directory is the merge base while it holds the same digest", async () => {
211
+ const staged = await stageUpgrade({ root: proj, to: v2, runChant: passing });
212
+ try {
213
+ expect(staged.merged).toEqual(["src/lib.ts"]);
214
+ expect(staged.written.sort()).toEqual(["README.md", "src/new.ts"]);
215
+ expect(staged.manualSteps).toEqual([]);
216
+ expect(staged.from).toBe(`${v1}#svc`);
217
+ expect(staged.to).toBe(`${v2}#svc`);
218
+ expect(read(staged.worktreeProject, "src/lib.ts")).toBe("ONE (ours)\ntwo\nthree\nfour\nfive\nsix\nSEVEN (theirs)\n");
219
+ // The parameter carried into the new version's files.
220
+ expect(read(staged.worktreeProject, "src/main.ts")).toBe('export const name = "billing";\n');
221
+ const lock = readLock(staged.worktreeProject)!.scopes["."];
222
+ expect(lock.source).toEqual({ type: "dir", path: v2, member: "svc" });
223
+ expect(Object.keys(lock.address!)).toEqual(["digest"]);
224
+ expect(lock.ref).toBeUndefined();
225
+ expect(lock.parameters).toEqual({ name: "billing", owner: "ops" });
226
+ } finally {
227
+ staged.dispose();
228
+ }
229
+ });
230
+
231
+ test("refuses when the recorded directory changed or is gone, naming adopt-lineage", async () => {
232
+ put(v1, "svc/README.md", "replaced in place\n");
233
+ await expect(stageUpgrade({ root: proj, to: v2, runChant: passing })).rejects.toThrow(
234
+ /no longer holds the files the scope was made from.*chant workspace adopt-lineage/,
235
+ );
236
+ rmSync(v1, { recursive: true, force: true });
237
+ await expect(stageUpgrade({ root: proj, to: v2, runChant: passing })).rejects.toThrow(/is gone\. .*chant workspace adopt-lineage/);
238
+ });
239
+
240
+ test("--to must be a directory", async () => {
241
+ await expect(stageUpgrade({ root: proj, to: "v2.0.0", runChant: passing })).rejects.toThrow(/--to v2\.0\.0: a scope made from a directory upgrades from a directory/);
242
+ });
243
+ });
@@ -4,9 +4,14 @@
4
4
  * with one lineage, scope `"."`, so a project made from a template can be
5
5
  * upgraded later without first running adopt-lineage.
6
6
  *
7
- * `--from` fetches with `git`: the one network step, listed in the egress
8
- * catalogue (`test/egress-catalogue.ts`). The commit and the tree of the
9
- * chosen directory become the content address.
7
+ * `--from <repo>@<ref>` fetches with `git`: the one network step, listed in
8
+ * the egress catalogue (`test/egress-catalogue.ts`). The commit and the tree
9
+ * of the chosen directory become the content address.
10
+ *
11
+ * `--from <dir>[#<member>]` copies a template directory already on disk
12
+ * (#2647), for a host that carries the template as plain files with no
13
+ * `.git` and no way to reach its repository. It reaches no network, and the
14
+ * digest of the copied files is the whole content address.
10
15
  *
11
16
  * Plain `chant init` without `--template` writes no lock. That keeps the
12
17
  * output of an existing command unchanged (#2525 rule 2), and this module
@@ -14,7 +19,7 @@
14
19
  */
15
20
 
16
21
  import { execFileSync } from "node:child_process";
17
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
18
23
  import { createRequire } from "node:module";
19
24
  import { tmpdir } from "node:os";
20
25
  import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
@@ -31,6 +36,7 @@ import {
31
36
  } from "./lineage-lock";
32
37
  import { MIGRATIONS_DIR } from "./lineage-migrations";
33
38
  import { TEMPLATE_MANIFEST, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
39
+ import { repinSubstituted, type RepinnedRecord } from "./template-pins";
34
40
 
35
41
  // ── The template spec ────────────────────────────────────────────────────────
36
42
 
@@ -46,24 +52,30 @@ export interface TemplateSpec {
46
52
  id: string;
47
53
  }
48
54
 
49
- /**
50
- * Parse `<repo>@<ref>[#<member>]`. `<repo>` is a git URL, an scp-style
51
- * `git@host:path`, a local path, or `owner/name` for a GitHub repository.
52
- */
53
- export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): TemplateSpec {
55
+ /** Split `<spec>[#<member>]`, refusing a member that leaves the template. */
56
+ function splitMember(spec: string, where: string): { rest: string; member?: string } {
54
57
  let rest = spec.trim();
55
58
  let member: string | undefined;
56
59
  const hash = rest.lastIndexOf("#");
57
60
  if (hash >= 0) {
58
61
  member = rest.slice(hash + 1).replace(/^\/+|\/+$/g, "");
59
62
  rest = rest.slice(0, hash);
60
- if (!member || posix.normalize(member).startsWith("..")) throw new LockError(`--from ${spec}: "#${member}" is not a directory in the repository`);
63
+ if (!member || posix.normalize(member).startsWith("..")) throw new LockError(`--from ${spec}: "#${member}" is not a directory in ${where}`);
61
64
  member = posix.normalize(member);
62
65
  }
66
+ return { rest, member };
67
+ }
68
+
69
+ /**
70
+ * Parse `<repo>@<ref>[#<member>]`. `<repo>` is a git URL, an scp-style
71
+ * `git@host:path`, a local path, or `owner/name` for a GitHub repository.
72
+ */
73
+ export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): TemplateSpec {
74
+ const { rest, member } = splitMember(spec, "the repository");
63
75
  const at = rest.lastIndexOf("@");
64
76
  const lastSep = Math.max(rest.lastIndexOf("/"), rest.lastIndexOf(":"));
65
77
  if (at <= 0 || at < lastSep || at === rest.length - 1) {
66
- throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0`);
78
+ throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0, or an existing directory`);
67
79
  }
68
80
  const repo = rest.slice(0, at);
69
81
  const ref = rest.slice(at + 1);
@@ -181,9 +193,162 @@ export function fetchTemplate(spec: TemplateSpec): FetchedTemplate {
181
193
  }
182
194
  }
183
195
 
196
+ // ── A template directory on disk (#2647) ─────────────────────────────────────
197
+
198
+ export interface DirTemplateSpec {
199
+ kind: "dir";
200
+ /** The directory as written, without `#<member>`. */
201
+ path: string;
202
+ /** The directory, absolute. */
203
+ abs: string;
204
+ /** The directory inside it to instantiate (`#<member>`). */
205
+ member?: string;
206
+ }
207
+
208
+ /**
209
+ * Parse `<path>[#<member>]` when `<path>` is an existing directory, or return
210
+ * null for the git form. A spec without `@` that names no directory is
211
+ * refused here, since it cannot be a `<repo>@<ref>` either. A directory whose
212
+ * name happens to parse as `<repo>@<ref>` is a directory: what is on disk wins.
213
+ */
214
+ export function parseDirSpec(spec: string, cwd: string = process.cwd()): DirTemplateSpec | null {
215
+ const { rest, member } = splitMember(spec, "the directory");
216
+ if (!rest) return null;
217
+ const abs = resolve(cwd, rest);
218
+ let isDir = false;
219
+ try {
220
+ isDir = statSync(abs).isDirectory();
221
+ } catch {
222
+ isDir = false;
223
+ }
224
+ if (isDir) return { kind: "dir", path: rest, abs, ...(member ? { member } : {}) };
225
+ if (rest.includes("@")) return null;
226
+ if (existsSync(abs)) throw new LockError(`--from ${spec}: ${rest} is not a directory`);
227
+ throw new LockError(`--from ${spec}: no directory ${rest}, and not <repo>@<ref>[#<member>] (e.g. acme/starter@v1.2.0)`);
228
+ }
229
+
230
+ /** The template's directory as `#<member>` names it, relative to its root, for messages. */
231
+ export function dirLabel(path: string, member?: string): string {
232
+ return member ? `${path}#${member}` : path;
233
+ }
234
+
235
+ /** The git work tree `dir` sits in, or null when it is in none, git is not installed, or the work tree ignores `dir`. */
236
+ function workTreeOf(dir: string): string | null {
237
+ try {
238
+ if (git(dir, ["rev-parse", "--is-inside-work-tree"]) !== "true") return null;
239
+ const top = git(dir, ["rev-parse", "--show-toplevel"]);
240
+ const rel = relative(top, dir).split(sep).join("/");
241
+ if (rel) {
242
+ try {
243
+ // Exit 0: ignored. A directory the checkout ignores is copied whole.
244
+ git(top, ["check-ignore", "-q", "--", `${rel}/`]);
245
+ return null;
246
+ } catch {
247
+ // Not ignored.
248
+ }
249
+ }
250
+ return top;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Every path under `dir`, relative and posix, except `.git`. Symbolic links are listed, not followed. */
257
+ function walk(dir: string, prefix = ""): string[] {
258
+ const out: string[] = [];
259
+ for (const entry of readdirSync(join(dir, prefix), { withFileTypes: true })) {
260
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
261
+ if (entry.name === ".git") continue;
262
+ if (entry.isDirectory()) out.push(...walk(dir, rel));
263
+ else out.push(rel);
264
+ }
265
+ return out;
266
+ }
267
+
268
+ /**
269
+ * Read the files of a template directory on disk, the way
270
+ * {@link readTemplateTree} reads a commit: symbolic links, submodules and the
271
+ * template's own lineage lock are skipped with the reason, and so is
272
+ * `node_modules`. Inside a git checkout the files are the ones git would
273
+ * commit, tracked or untracked but not ignored. Elsewhere, or when git is not
274
+ * installed, every file is copied. The executable bit is the owner's.
275
+ */
276
+ export function readTemplateDir(abs: string, member: string | undefined, label: string): Omit<FetchedTemplate, "commit" | "tree"> {
277
+ const dir = member ? join(abs, member) : abs;
278
+ let isDir = false;
279
+ try {
280
+ isDir = statSync(dir).isDirectory();
281
+ } catch {
282
+ isDir = false;
283
+ }
284
+ if (!isDir) throw new LockError(`${label} has no directory ${member ?? ""}`.trimEnd());
285
+
286
+ let paths: string[];
287
+ if (workTreeOf(dir)) {
288
+ const listing = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], {
289
+ cwd: dir,
290
+ maxBuffer: 256 * 1024 * 1024,
291
+ }).toString("utf-8");
292
+ paths = [...new Set(listing.split("\0").filter(Boolean))];
293
+ } else {
294
+ paths = walk(dir);
295
+ }
296
+
297
+ const files: FetchedTemplate["files"] = new Map();
298
+ const skipped: FetchedTemplate["skipped"] = [];
299
+ const skippedModules = new Set<string>();
300
+ for (const path of paths.sort()) {
301
+ const segments = path.split("/");
302
+ const nm = segments.indexOf("node_modules");
303
+ if (nm >= 0) {
304
+ const at = segments.slice(0, nm + 1).join("/");
305
+ if (!skippedModules.has(at)) {
306
+ skippedModules.add(at);
307
+ skipped.push({ path: at, reason: "node_modules" });
308
+ }
309
+ continue;
310
+ }
311
+ if (path === LOCK_FILE) {
312
+ skipped.push({ path, reason: "the template's own lineage lock" });
313
+ continue;
314
+ }
315
+ let st;
316
+ try {
317
+ st = lstatSync(join(dir, path));
318
+ } catch {
319
+ // Tracked but deleted from the working files: not part of the template on disk.
320
+ continue;
321
+ }
322
+ if (st.isSymbolicLink()) {
323
+ skipped.push({ path, reason: "a symbolic link" });
324
+ continue;
325
+ }
326
+ if (st.isDirectory()) {
327
+ skipped.push({ path, reason: "a submodule" });
328
+ continue;
329
+ }
330
+ if (!st.isFile()) {
331
+ skipped.push({ path, reason: "not a regular file" });
332
+ continue;
333
+ }
334
+ files.set(path, { data: readFileSync(join(dir, path)), executable: (st.mode & 0o100) !== 0 });
335
+ }
336
+ return { files, skipped };
337
+ }
338
+
339
+ /**
340
+ * How a directory source is recorded: an absolute path as given, a relative
341
+ * one re-expressed from the project, so it resolves from the lock's directory.
342
+ */
343
+ export function recordedDirPath(given: string, abs: string, projectDir: string): string {
344
+ if (isAbsolute(given)) return abs;
345
+ return portableUrl(abs, projectDir);
346
+ }
347
+
184
348
  // ── init --from ──────────────────────────────────────────────────────────────
185
349
 
186
350
  export interface InitFromOptions {
351
+ /** `<repo>@<ref>[#<member>]`, or `<dir>[#<member>]` for a directory on disk (#2647). */
187
352
  from: string;
188
353
  /** Target directory (defaults to cwd). */
189
354
  path?: string;
@@ -197,21 +362,28 @@ export interface InitFromResult {
197
362
  createdFiles: string[];
198
363
  warnings: string[];
199
364
  error?: string;
365
+ /** The git form's spec. */
200
366
  spec?: TemplateSpec;
367
+ /** The directory form's spec (#2647). */
368
+ dir?: DirTemplateSpec;
201
369
  commit?: string;
370
+ /** The lineage's `template` id. */
371
+ template?: string;
202
372
  /** The parameter values used, recorded in the lock. */
203
373
  parameters?: Record<string, string>;
204
374
  }
205
375
 
206
- /** `chant init --from <repo>@<ref>[#<member>] [path]`. */
376
+ /** `chant init --from <repo>@<ref>[#<member>] [path]`, or `--from <dir>[#<member>]`. */
207
377
  export async function initFromCommand(options: InitFromOptions): Promise<InitFromResult> {
208
378
  const targetDir = resolve(options.path ?? ".");
209
379
  const warnings: string[] = [];
210
380
  const createdFiles: string[] = [];
211
381
 
212
- let spec: TemplateSpec;
382
+ let spec: TemplateSpec | undefined;
383
+ let dir: DirTemplateSpec | undefined;
213
384
  try {
214
- spec = parseTemplateSpec(options.from);
385
+ dir = parseDirSpec(options.from) ?? undefined;
386
+ if (!dir) spec = parseTemplateSpec(options.from);
215
387
  } catch (err) {
216
388
  return { success: false, createdFiles, warnings, error: (err as Error).message };
217
389
  }
@@ -227,9 +399,10 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
227
399
  return { success: false, createdFiles, warnings, error: `${LOCK_FILE} already exists; this directory already has a lineage` };
228
400
  }
229
401
 
230
- let fetched: FetchedTemplate;
402
+ let fetched: Omit<FetchedTemplate, "commit" | "tree"> & { commit?: string; tree?: string };
231
403
  try {
232
- fetched = fetchTemplate(spec);
404
+ // Every file is read before anything is written, so a target inside the template directory is safe.
405
+ fetched = dir ? readTemplateDir(dir.abs, dir.member, dir.path) : fetchTemplate(spec!);
233
406
  } catch (err) {
234
407
  return { success: false, createdFiles, warnings, error: (err as Error).message };
235
408
  }
@@ -241,11 +414,13 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
241
414
  // so a refused --param leaves the target untouched.
242
415
  let parameters: Record<string, string>;
243
416
  let contents: Map<string, Buffer>;
417
+ let repinned: RepinnedRecord[];
244
418
  try {
245
419
  const raw = new Map([...fetched.files].map(([path, f]) => [path, f.data]));
246
420
  const manifest = readManifest(raw);
247
421
  parameters = resolveParameters(manifest, options.params ?? {});
248
- contents = substituteParameters(raw, manifest, parameters);
422
+ // Records that pin a substituted file get its new hash, so a copy's pins hold (#2549).
423
+ ({ files: contents, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []));
249
424
  } catch (err) {
250
425
  return { success: false, createdFiles, warnings, error: (err as Error).message };
251
426
  }
@@ -268,23 +443,47 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
268
443
  createdFiles.push(path);
269
444
  }
270
445
 
271
- const lineage: Lineage = {
272
- kind: "template",
273
- template: spec.id,
274
- source: { type: "git", repo: spec.repo, url: portableUrl(spec.url, targetDir), ...(spec.member ? { path: spec.member } : {}) },
275
- ref: spec.ref,
276
- address: { digest: contentDigest(written), commit: fetched.commit, tree: fetched.tree },
446
+ const common = {
277
447
  parameters,
448
+ ...(repinned.length > 0 ? { repinned: repinned.filter((r) => written.has(r.record)) } : {}),
278
449
  migrations: [],
279
450
  files: fileEntries(written, declaredFilesAt(targetDir)),
280
451
  manualSteps: [],
281
452
  };
453
+ let lineage: Lineage;
454
+ if (dir) {
455
+ const path = recordedDirPath(dir.path, dir.abs, targetDir);
456
+ lineage = {
457
+ kind: "template",
458
+ template: `dir:${dirLabel(path, dir.member)}`,
459
+ source: { type: "dir", path, ...(dir.member ? { member: dir.member } : {}) },
460
+ address: { digest: contentDigest(written) },
461
+ ...common,
462
+ };
463
+ } else {
464
+ lineage = {
465
+ kind: "template",
466
+ template: spec!.id,
467
+ source: { type: "git", repo: spec!.repo, url: portableUrl(spec!.url, targetDir), ...(spec!.member ? { path: spec!.member } : {}) },
468
+ ref: spec!.ref,
469
+ address: { digest: contentDigest(written), commit: fetched.commit, tree: fetched.tree },
470
+ ...common,
471
+ };
472
+ }
282
473
  const lock = emptyLock();
283
474
  lock.scopes["."] = lineage;
284
475
  writeLock(targetDir, lock);
285
476
  createdFiles.push(LOCK_FILE);
286
477
 
287
- return { success: true, createdFiles, warnings, spec, commit: fetched.commit, parameters };
478
+ return {
479
+ success: true,
480
+ createdFiles,
481
+ warnings,
482
+ ...(spec ? { spec, commit: fetched.commit } : {}),
483
+ ...(dir ? { dir } : {}),
484
+ template: lineage.template,
485
+ parameters,
486
+ };
288
487
  }
289
488
 
290
489
  /** A local repository is recorded relative to the project, so the lock does not name this machine's paths. */
@@ -4,8 +4,9 @@
4
4
  * The lock records where a project's files came from. It holds one lineage per
5
5
  * scope, keyed by the scope's directory relative to the lock's root:
6
6
  *
7
- * - a project made by `chant init --from <repo>@<ref>` or
8
- * `chant init --template <name>` has one scope, `"."`;
7
+ * - a project made by `chant init --from <repo>@<ref>`,
8
+ * `chant init --from <dir>` or `chant init --template <name>` has one
9
+ * scope, `"."`;
9
10
  * - each `chant vendor` target is a scope of kind `vendor` (copied, no
10
11
  * parameters), which replaces its entry in `vendor.json` (ws-038).
11
12
  *
@@ -65,6 +66,12 @@ const LockFileEntrySchema = z
65
66
  const SourceSchema = z.discriminatedUnion("type", [
66
67
  /** A git repository at a ref, optionally one directory of it (`#<member>`). */
67
68
  z.object({ type: z.literal("git"), repo: z.string().min(1), url: z.string().min(1), path: z.string().optional() }).strict(),
69
+ /**
70
+ * A directory on disk (#2647): `path` as given when absolute, otherwise
71
+ * relative to the lock's directory, and `member` for `#<member>`. It has no
72
+ * history, so its address is the digest alone.
73
+ */
74
+ z.object({ type: z.literal("dir"), path: z.string().min(1), member: z.string().optional() }).strict(),
68
75
  /** A lexicon's `initTemplates`, as `chant init --lexicon <lexicon> --template <template>` renders them. */
69
76
  z.object({ type: z.literal("lexicon"), lexicon: z.string().min(1), template: z.string().min(1) }).strict(),
70
77
  /** `chant vendor` sources, unchanged from `vendor.json`. */
@@ -78,7 +85,8 @@ export type LineageSource = z.infer<typeof SourceSchema>;
78
85
  * `digest` is always present: the content hash of the file set as chant wrote
79
86
  * it (the same hash `vendor.json` called `checksum`). A git source adds the
80
87
  * commit and the tree of the scope's directory; a lexicon template adds the
81
- * package and chant versions that rendered it.
88
+ * package and chant versions that rendered it. A directory source has the
89
+ * digest only.
82
90
  */
83
91
  const AddressSchema = z
84
92
  .object({
@@ -125,6 +133,12 @@ const LineageSchema = z
125
133
  address: AddressSchema.nullable(),
126
134
  /** The parameter values the template was instantiated with. Always empty for vendor scopes. */
127
135
  parameters: z.record(z.string(), z.unknown()),
136
+ /**
137
+ * Records whose evidence pins were re-pinned to the substituted content
138
+ * of a parameterised file (#2549): each record's path in the scope, and the
139
+ * pinned paths. An upgrade re-pins the base and the target the same way.
140
+ */
141
+ repinned: z.array(z.object({ record: z.string().min(1), paths: z.array(z.string().min(1)) }).strict()).optional(),
128
142
  /** Migrations applied since instantiation (#2550). */
129
143
  migrations: z.array(z.string()),
130
144
  /** Per file, relative to the scope directory, in sorted order. */
@@ -241,6 +255,7 @@ function canonical(lock: LineageLock): LineageLock {
241
255
  ...(s.ref !== undefined ? { ref: s.ref } : {}),
242
256
  address: s.address,
243
257
  parameters: s.parameters,
258
+ ...(s.repinned !== undefined ? { repinned: s.repinned } : {}),
244
259
  migrations: s.migrations,
245
260
  files,
246
261
  manualSteps: [...s.manualSteps].sort((a, b) => a.path.localeCompare(b.path)),
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `chant workspace upgrade [<scope>] [--to <ref>] [--allow-code] [--dry-run]
2
+ * `chant workspace upgrade [<scope>] [--to <ref|dir>] [--allow-code] [--dry-run]
3
3
  * [--output <file>] [--json]` (#2550).
4
4
  *
5
5
  * Stages the upgrade in a worktree (see ./lineage-upgrade.ts), then decides
@@ -20,7 +20,7 @@ import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
20
20
  import { LockError } from "./lineage-lock";
21
21
  import { applyStagedUpgrade, describeStaged, stageUpgrade, type ChantRunner, type StagedUpgrade } from "./lineage-upgrade";
22
22
 
23
- const USAGE = "chant workspace upgrade [<scope>] [--to <ref>] [--allow-code] [--dry-run] [--output <patch file>] [--json]";
23
+ const USAGE = "chant workspace upgrade [<scope>] [--to <ref|dir>] [--allow-code] [--dry-run] [--output <patch file>] [--json]";
24
24
 
25
25
  /** The exit code of a gated upgrade: the same as `chant run`'s gated run. */
26
26
  export const UPGRADE_GATED_EXIT = 3;