@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/content-digest.d.ts +2 -0
- package/dist/content-digest.d.ts.map +1 -1
- package/dist/workspace/checks/records.d.ts +33 -0
- package/dist/workspace/checks/records.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +8 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +22 -6
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/graph-cli.d.ts +11 -4
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-check.d.ts +5 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +5 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-init.d.ts +47 -4
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +23 -2
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +8 -2
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +4 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts +108 -0
- package/dist/workspace/record-assets.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +32 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +41 -0
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/template-pins.d.ts +33 -0
- package/dist/workspace/template-pins.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +5 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/init.ts +7 -3
- package/src/cli/main.ts +16 -8
- package/src/content-digest.ts +5 -0
- package/src/workspace/behold-kinds.test.ts +10 -14
- package/src/workspace/checks/records.ts +83 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +13 -3
- package/src/workspace/compose-graph.test.ts +2 -1
- package/src/workspace/compose-graph.ts +23 -6
- package/src/workspace/graph-cli.ts +32 -6
- package/src/workspace/graph-contract.test.ts +2 -1
- package/src/workspace/graph.schema.json +131 -2
- package/src/workspace/lineage-check.ts +22 -5
- package/src/workspace/lineage-cli.ts +21 -1
- package/src/workspace/lineage-init-dir.test.ts +243 -0
- package/src/workspace/lineage-init.ts +223 -24
- package/src/workspace/lineage-lock.ts +18 -3
- package/src/workspace/lineage-upgrade-cli.ts +2 -2
- package/src/workspace/lineage-upgrade.test.ts +29 -0
- package/src/workspace/lineage-upgrade.ts +130 -13
- package/src/workspace/member-commands.ts +1 -1
- package/src/workspace/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +5 -0
- package/src/workspace/record-assets.test.ts +319 -0
- package/src/workspace/record-assets.ts +207 -0
- package/src/workspace/records-cli.ts +117 -14
- package/src/workspace/records.schema.json +33 -1
- package/src/workspace/records.test.ts +50 -0
- package/src/workspace/records.ts +115 -5
- package/src/workspace/template-pins.test.ts +69 -0
- package/src/workspace/template-pins.ts +117 -0
- 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
|
|
8
|
-
* catalogue (`test/egress-catalogue.ts`). The commit and the tree
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
8
|
-
* `chant init --template <name>` has one
|
|
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;
|