@intentius/chant 0.81.0 → 0.82.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.
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAiG9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA2apD;AAujBD;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBzE;AAID;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EA8HvC,CAAC;AAiKF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAiG9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA2apD;AAyjBD;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBzE;AAID;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EA8HvC,CAAC;AAiKF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
@@ -9,12 +9,14 @@
9
9
  * project (#2525 rule 4).
10
10
  */
11
11
  import type { CommandContext } from "../cli/registry.js";
12
- import { type ManualStep } from "./lineage-lock.js";
12
+ import { type LineageSource, type ManualStep } from "./lineage-lock.js";
13
13
  export interface LineageScopeView {
14
14
  scope: string;
15
15
  kind: "template" | "vendor";
16
16
  name?: string;
17
17
  template: string;
18
+ /** Where the files came from, as the lock records it: its `type` and the repository, directory, lexicon or URL. */
19
+ source: LineageSource;
18
20
  ref?: string;
19
21
  address: Record<string, unknown> | null;
20
22
  files: number;
@@ -22,6 +24,8 @@ export interface LineageScopeView {
22
24
  missing: string[];
23
25
  manualSteps: ManualStep[];
24
26
  }
27
+ /** One line naming a scope's source kind and where it points (#2647). */
28
+ export declare function describeSource(source: LineageSource): string;
25
29
  /** The lock as `--json` prints it: one view per scope, with its state in the tree. */
26
30
  export declare function lineageView(root: string): {
27
31
  lock: string;
@@ -1 +1 @@
1
- {"version":3,"file":"lineage-cli.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAA6E,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAI5H,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B;AAED,sFAAsF;AACtF,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,GAAG,IAAI,CAmB7F;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmC9E"}
1
+ {"version":3,"file":"lineage-cli.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAA6E,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAIhJ,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,mHAAmH;IACnH,MAAM,EAAE,aAAa,CAAC;IACtB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAa5D;AAED,sFAAsF;AACtF,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,GAAG,IAAI,CAoB7F;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAoC9E"}
@@ -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
@@ -59,7 +64,40 @@ export declare function readTemplateTree(scratch: string, commit: string, member
59
64
  * never copied: it describes the template's lineage, not the new project's.
60
65
  */
61
66
  export declare function fetchTemplate(spec: TemplateSpec): FetchedTemplate;
67
+ export interface DirTemplateSpec {
68
+ kind: "dir";
69
+ /** The directory as written, without `#<member>`. */
70
+ path: string;
71
+ /** The directory, absolute. */
72
+ abs: string;
73
+ /** The directory inside it to instantiate (`#<member>`). */
74
+ member?: string;
75
+ }
76
+ /**
77
+ * Parse `<path>[#<member>]` when `<path>` is an existing directory, or return
78
+ * null for the git form. A spec without `@` that names no directory is
79
+ * refused here, since it cannot be a `<repo>@<ref>` either. A directory whose
80
+ * name happens to parse as `<repo>@<ref>` is a directory: what is on disk wins.
81
+ */
82
+ export declare function parseDirSpec(spec: string, cwd?: string): DirTemplateSpec | null;
83
+ /** The template's directory as `#<member>` names it, relative to its root, for messages. */
84
+ export declare function dirLabel(path: string, member?: string): string;
85
+ /**
86
+ * Read the files of a template directory on disk, the way
87
+ * {@link readTemplateTree} reads a commit: symbolic links, submodules and the
88
+ * template's own lineage lock are skipped with the reason, and so is
89
+ * `node_modules`. Inside a git checkout the files are the ones git would
90
+ * commit, tracked or untracked but not ignored. Elsewhere, or when git is not
91
+ * installed, every file is copied. The executable bit is the owner's.
92
+ */
93
+ export declare function readTemplateDir(abs: string, member: string | undefined, label: string): Omit<FetchedTemplate, "commit" | "tree">;
94
+ /**
95
+ * How a directory source is recorded: an absolute path as given, a relative
96
+ * one re-expressed from the project, so it resolves from the lock's directory.
97
+ */
98
+ export declare function recordedDirPath(given: string, abs: string, projectDir: string): string;
62
99
  export interface InitFromOptions {
100
+ /** `<repo>@<ref>[#<member>]`, or `<dir>[#<member>]` for a directory on disk (#2647). */
63
101
  from: string;
64
102
  /** Target directory (defaults to cwd). */
65
103
  path?: string;
@@ -72,12 +110,17 @@ export interface InitFromResult {
72
110
  createdFiles: string[];
73
111
  warnings: string[];
74
112
  error?: string;
113
+ /** The git form's spec. */
75
114
  spec?: TemplateSpec;
115
+ /** The directory form's spec (#2647). */
116
+ dir?: DirTemplateSpec;
76
117
  commit?: string;
118
+ /** The lineage's `template` id. */
119
+ template?: string;
77
120
  /** The parameter values used, recorded in the lock. */
78
121
  parameters?: Record<string, string>;
79
122
  }
80
- /** `chant init --from <repo>@<ref>[#<member>] [path]`. */
123
+ /** `chant init --from <repo>@<ref>[#<member>] [path]`, or `--from <dir>[#<member>]`. */
81
124
  export declare function initFromCommand(options: InitFromOptions): Promise<InitFromResult>;
82
125
  export interface TemplateLockInput {
83
126
  targetDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"lineage-init.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAuBH,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6GAA6G;IAC7G,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,YAAY,CAwCzF;AAID,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC1D,mDAAmD;IACnD,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED,wBAAgB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAQvD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,GACZ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IAAC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;CAAE,CAiCxF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG,eAAe,CAgBjE;AAID,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yGAAyG;IACzG,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,0DAA0D;AAC1D,wBAAsB,eAAe,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAiFvF;AAgCD,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,mHAAmH;IACnH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CA0BzE"}
1
+ {"version":3,"file":"lineage-init.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-init.ts"],"names":[],"mappings":"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"}
@@ -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
  *
@@ -44,6 +45,10 @@ declare const SourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
44
45
  repo: z.ZodString;
45
46
  url: z.ZodString;
46
47
  path: z.ZodOptional<z.ZodString>;
48
+ }, z.core.$strict>, z.ZodObject<{
49
+ type: z.ZodLiteral<"dir">;
50
+ path: z.ZodString;
51
+ member: z.ZodOptional<z.ZodString>;
47
52
  }, z.core.$strict>, z.ZodObject<{
48
53
  type: z.ZodLiteral<"lexicon">;
49
54
  lexicon: z.ZodString;
@@ -90,6 +95,10 @@ declare const LineageSchema: z.ZodObject<{
90
95
  repo: z.ZodString;
91
96
  url: z.ZodString;
92
97
  path: z.ZodOptional<z.ZodString>;
98
+ }, z.core.$strict>, z.ZodObject<{
99
+ type: z.ZodLiteral<"dir">;
100
+ path: z.ZodString;
101
+ member: z.ZodOptional<z.ZodString>;
93
102
  }, z.core.$strict>, z.ZodObject<{
94
103
  type: z.ZodLiteral<"lexicon">;
95
104
  lexicon: z.ZodString;
@@ -148,6 +157,10 @@ declare const LockSchema: z.ZodObject<{
148
157
  repo: z.ZodString;
149
158
  url: z.ZodString;
150
159
  path: z.ZodOptional<z.ZodString>;
160
+ }, z.core.$strict>, z.ZodObject<{
161
+ type: z.ZodLiteral<"dir">;
162
+ path: z.ZodString;
163
+ member: z.ZodOptional<z.ZodString>;
151
164
  }, z.core.$strict>, z.ZodObject<{
152
165
  type: z.ZodLiteral<"lexicon">;
153
166
  lexicon: z.ZodString;
@@ -1 +1 @@
1
- {"version":3,"file":"lineage-lock.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-lock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"lineage-lock.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-lock.ts"],"names":[],"mappings":"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"}
@@ -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
@@ -9,7 +9,8 @@
9
9
  * 1. Fetch the target version of the template. For a git source that is one
10
10
  * `git fetch` of the target ref, plus the commit the scope was made from.
11
11
  * This is the command's only network step, catalogued in
12
- * `test/egress-catalogue.ts`. A local repository reaches nothing.
12
+ * `test/egress-catalogue.ts`. A local repository reaches nothing, and
13
+ * neither does a directory source (#2647), which reads `--to <dir>`.
13
14
  * 2. Rebuild the merge base offline: each recorded file's content at the base
14
15
  * commit, kept only when it has the hash the lock recorded. A file the
15
16
  * project never edited is its own merge base. Vendor sources keep no
@@ -63,7 +64,12 @@ export interface UpgradeOptions {
63
64
  root: string;
64
65
  /** The scope to upgrade: `"."` or a vendor scope's directory. */
65
66
  scope?: string;
66
- /** The target ref (git) or label (vendor). Defaults to the ref the scope is pinned at. */
67
+ /**
68
+ * The target ref (git) or label (vendor). Defaults to the ref the scope is
69
+ * pinned at. For a directory source, the directory holding the new version,
70
+ * as `<dir>[#<member>]`, resolved from the current directory; the member
71
+ * defaults to the recorded one.
72
+ */
67
73
  to?: string;
68
74
  /** Run migrations whose body is code. */
69
75
  allowCode?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"lineage-upgrade.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-upgrade.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"lineage-upgrade.d.ts","sourceRoot":"","sources":["../../src/workspace/lineage-upgrade.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
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",
@@ -54,14 +54,15 @@ export async function runInitLexicon(ctx: CommandContext): Promise<number> {
54
54
  * `chant init --from <repo>@<ref>[#<member>] [--param <name>=<value>]... [path]`
55
55
  * (#2540, #2627): copy a template repository at a ref, substitute the
56
56
  * parameters its `chant.template.json` declares, and record its lineage.
57
+ * `--from <dir>[#<member>]` copies a template directory on disk instead (#2647).
57
58
  * The template brings its own lexicons and configuration, so `--lexicon` and
58
59
  * `--template` do not apply.
59
60
  */
60
61
  async function runInitFrom(ctx: CommandContext): Promise<number> {
61
62
  const { args } = ctx;
62
- const usage = "Usage: chant init --from <repo>@<ref>[#<member>] [--param <name>=<value>]... [path]";
63
+ const usage = "Usage: chant init --from <repo>@<ref>[#<member>] | <dir>[#<member>] [--param <name>=<value>]... [path]";
63
64
  if (!args.migrateFrom) {
64
- console.error(formatError({ message: "--from needs a template: <repo>@<ref>[#<member>]", hint: usage }));
65
+ console.error(formatError({ message: "--from needs a template: <repo>@<ref>[#<member>] or <dir>[#<member>]", hint: usage }));
65
66
  return 1;
66
67
  }
67
68
  if (args.lexicon || args.template) {
@@ -95,7 +96,10 @@ async function runInitFrom(ctx: CommandContext): Promise<number> {
95
96
  console.log(formatSuccess("Created:"));
96
97
  for (const file of result.createdFiles) console.log(` ${file}`);
97
98
  console.log("");
98
- console.log(`Lineage: ${result.spec!.id} at ${result.spec!.ref} (${result.commit!.slice(0, 12)}), recorded in .chant/workspace.lock.json`);
99
+ const from = result.spec
100
+ ? `${result.spec.id} at ${result.spec.ref} (${result.commit!.slice(0, 12)})`
101
+ : `${result.template} (a directory, recorded by digest only)`;
102
+ console.log(`Lineage: ${from}, recorded in .chant/workspace.lock.json`);
99
103
  const parameters = Object.entries(result.parameters ?? {});
100
104
  if (parameters.length > 0) console.log(`Parameters: ${parameters.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")}`);
101
105
  return 0;
package/src/cli/main.ts CHANGED
@@ -549,7 +549,9 @@ Usage:
549
549
  Commands:
550
550
  init Initialize a new chant project
551
551
  (--from <repo>@<ref>[#<member>] copies a template
552
- repository and records its lineage; --param
552
+ repository and records its lineage, and --from
553
+ <dir>[#<member>] copies a template directory on
554
+ disk; --param
553
555
  <name>=<value> sets a parameter the template's
554
556
  chant.template.json declares, repeatable)
555
557
  init lexicon <name> Scaffold a new lexicon plugin project
@@ -729,7 +731,7 @@ Workspace (level 1, #2524):
729
731
  manual steps. Needs no workspace file
730
732
  workspace lineage resolve <path>
731
733
  Close a manual step once the file is merged by hand
732
- workspace upgrade [<scope>] [--to <ref>] [--allow-code] [--dry-run] [--output <file>]
734
+ workspace upgrade [<scope>] [--to <ref|dir>] [--allow-code] [--dry-run] [--output <file>]
733
735
  Bring a lineage scope to a newer template version: fetch
734
736
  it, migrate and merge per file in a worktree, run build,
735
737
  lint and workspace check there, then gate on the digest
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * behold's two closed member kinds, read through chant's workspace (#2545).
3
3
  *
4
- * `terraform` ships from the terraform lexicon's `./workspace-kinds` subpath,
5
- * and `choudoufu` from the kinds-only package
6
- * `@intentius/workspace-kind-choudoufu`. A workspace that pins both lists
7
- * members of either kind through `chant workspace ls --json`, with the kinds
8
- * behold gave the same directories, and reading the kinds imports neither
9
- * package.
4
+ * Both ship from the terraform lexicon's `./workspace-kinds` subpath, since
5
+ * a choudoufu estate is a Terraform root the lexicon's choudoufu mode reads.
6
+ * A workspace that pins the lexicon lists members of either kind through
7
+ * `chant workspace ls --json`, with the kinds behold gave the same
8
+ * directories, and reading the kinds never imports the lexicon.
10
9
  *
11
- * The installed packages are the repo's own: their package.json and kinds
12
- * file are copied as they ship, and every code entry their `exports` name is
10
+ * The installed package is the repo's own: its package.json and kinds file
11
+ * are copied as they ship, and every code entry its `exports` names is
13
12
  * replaced by a module that leaves a marker file when it is imported.
14
13
  */
15
14
 
@@ -25,7 +24,6 @@ import { workingTree } from "./tree";
25
24
 
26
25
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
27
26
  const TERRAFORM = join(REPO, "lexicons", "terraform");
28
- const CHOUDOUFU = join(REPO, "packages", "workspace-kind-choudoufu");
29
27
  const FIXTURES = join(TERRAFORM, "src", "__fixtures__");
30
28
 
31
29
  const scratch: string[] = [];
@@ -65,7 +63,7 @@ function install(root: string, from: string): string {
65
63
  function workspace(): { root: string; installed: string[] } {
66
64
  const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-behold-kinds-")));
67
65
  scratch.push(root);
68
- const installed = [install(root, TERRAFORM), install(root, CHOUDOUFU)];
66
+ const installed = [install(root, TERRAFORM)];
69
67
  // Estates in the shapes behold serves: a stock root, a choudoufu root with
70
68
  // the sidecar, one with the live block, and a chant project beside .tf files.
71
69
  cpSync(join(FIXTURES, "no-backend"), join(root, "estates", "stock"), { recursive: true });
@@ -82,7 +80,6 @@ function workspace(): { root: string; installed: string[] } {
82
80
  schema: 1,
83
81
  pins: [
84
82
  { package: "@intentius/chant-lexicon-terraform", version: version(TERRAFORM) },
85
- { package: "@intentius/workspace-kind-choudoufu", version: version(CHOUDOUFU) },
86
83
  ],
87
84
  members: [
88
85
  { name: "stock", dir: "estates/stock", kind: "terraform" },
@@ -106,7 +103,7 @@ describe("behold's member kinds from their packages (#2545)", () => {
106
103
  });
107
104
  afterEach(() => vi.restoreAllMocks());
108
105
 
109
- test("chant workspace ls --json lists terraform and choudoufu members, readable, and imports neither package", async () => {
106
+ test("chant workspace ls --json lists terraform and choudoufu members, readable, and never imports the lexicon", async () => {
110
107
  const { root, installed } = workspace();
111
108
  const code = await runWorkspaceLs({ args: parseArgs(["workspace", "ls", root, "--json"]), plugins: [] } as never);
112
109
  expect(code).toBe(0);
@@ -126,12 +123,11 @@ describe("behold's member kinds from their packages (#2545)", () => {
126
123
  const { root } = workspace();
127
124
  const pins = [
128
125
  { package: "@intentius/chant-lexicon-terraform", version: version(TERRAFORM), path: null },
129
- { package: "@intentius/workspace-kind-choudoufu", version: version(CHOUDOUFU), path: null },
130
126
  ];
131
127
  const { registry, problems } = loadKindRegistry(pins, root);
132
128
  expect(problems).toEqual([]);
133
129
  expect(registry.get("terraform")).toMatchObject({ source: "@intentius/chant-lexicon-terraform", precedence: 400 });
134
- expect(registry.get("choudoufu")).toMatchObject({ source: "@intentius/workspace-kind-choudoufu", precedence: 450 });
130
+ expect(registry.get("choudoufu")).toMatchObject({ source: "@intentius/chant-lexicon-terraform", precedence: 450 });
135
131
  const tree = workingTree(root);
136
132
  const winner = (dir: string) => resolveKind(registry, tree, dir).winner?.name;
137
133
  expect(winner("estates/stock")).toBe("terraform");
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { formatError, formatSuccess } from "../cli/format";
13
13
  import type { CommandContext } from "../cli/registry";
14
- import { LOCK_FILE, LockError, readLock, resolveManualStep, scopeStatus, writeLock, type ManualStep } from "./lineage-lock";
14
+ import { LOCK_FILE, LockError, readLock, resolveManualStep, scopeStatus, writeLock, type LineageSource, type ManualStep } from "./lineage-lock";
15
15
 
16
16
  const USAGE = "chant workspace lineage [--json] | chant workspace lineage resolve <path>";
17
17
 
@@ -20,6 +20,8 @@ export interface LineageScopeView {
20
20
  kind: "template" | "vendor";
21
21
  name?: string;
22
22
  template: string;
23
+ /** Where the files came from, as the lock records it: its `type` and the repository, directory, lexicon or URL. */
24
+ source: LineageSource;
23
25
  ref?: string;
24
26
  address: Record<string, unknown> | null;
25
27
  files: number;
@@ -28,6 +30,22 @@ export interface LineageScopeView {
28
30
  manualSteps: ManualStep[];
29
31
  }
30
32
 
33
+ /** One line naming a scope's source kind and where it points (#2647). */
34
+ export function describeSource(source: LineageSource): string {
35
+ switch (source.type) {
36
+ case "git":
37
+ return `git ${source.repo}${source.path ? `#${source.path}` : ""}`;
38
+ case "dir":
39
+ return `dir ${source.path}${source.member ? `#${source.member}` : ""}`;
40
+ case "lexicon":
41
+ return `lexicon ${source.lexicon}/${source.template}`;
42
+ case "local":
43
+ return `local ${source.path}`;
44
+ case "archive":
45
+ return `archive ${source.url}${source.subpath ? `#${source.subpath}` : ""}`;
46
+ }
47
+ }
48
+
31
49
  /** The lock as `--json` prints it: one view per scope, with its state in the tree. */
32
50
  export function lineageView(root: string): { lock: string; scopes: LineageScopeView[] } | null {
33
51
  const lock = readLock(root);
@@ -39,6 +57,7 @@ export function lineageView(root: string): { lock: string; scopes: LineageScopeV
39
57
  kind: lineage.kind,
40
58
  ...(lineage.name !== undefined ? { name: lineage.name } : {}),
41
59
  template: lineage.template,
60
+ source: lineage.source,
42
61
  ...(lineage.ref !== undefined ? { ref: lineage.ref } : {}),
43
62
  address: lineage.address,
44
63
  files: Object.keys(lineage.files).length,
@@ -73,6 +92,7 @@ export async function runWorkspaceLineage(ctx: CommandContext): Promise<number>
73
92
  const pin = s.ref ? `@${s.ref}` : "";
74
93
  const at = typeof s.address?.commit === "string" ? ` (${s.address.commit.slice(0, 12)})` : "";
75
94
  lines.push(`${s.scope} ${s.kind}${s.name ? ` ${s.name}` : ""} ${s.template}${pin}${at}`);
95
+ lines.push(` source: ${describeSource(s.source)}`);
76
96
  lines.push(` ${s.files} file(s), ${s.edited.length} edited, ${s.missing.length} deleted`);
77
97
  for (const m of s.manualSteps) lines.push(` manual step: ${s.scope === "." ? "" : `${s.scope}/`}${m.path} (${m.reason})`);
78
98
  }
@@ -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";
@@ -46,24 +51,30 @@ export interface TemplateSpec {
46
51
  id: string;
47
52
  }
48
53
 
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 {
54
+ /** Split `<spec>[#<member>]`, refusing a member that leaves the template. */
55
+ function splitMember(spec: string, where: string): { rest: string; member?: string } {
54
56
  let rest = spec.trim();
55
57
  let member: string | undefined;
56
58
  const hash = rest.lastIndexOf("#");
57
59
  if (hash >= 0) {
58
60
  member = rest.slice(hash + 1).replace(/^\/+|\/+$/g, "");
59
61
  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`);
62
+ if (!member || posix.normalize(member).startsWith("..")) throw new LockError(`--from ${spec}: "#${member}" is not a directory in ${where}`);
61
63
  member = posix.normalize(member);
62
64
  }
65
+ return { rest, member };
66
+ }
67
+
68
+ /**
69
+ * Parse `<repo>@<ref>[#<member>]`. `<repo>` is a git URL, an scp-style
70
+ * `git@host:path`, a local path, or `owner/name` for a GitHub repository.
71
+ */
72
+ export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): TemplateSpec {
73
+ const { rest, member } = splitMember(spec, "the repository");
63
74
  const at = rest.lastIndexOf("@");
64
75
  const lastSep = Math.max(rest.lastIndexOf("/"), rest.lastIndexOf(":"));
65
76
  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`);
77
+ throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0, or an existing directory`);
67
78
  }
68
79
  const repo = rest.slice(0, at);
69
80
  const ref = rest.slice(at + 1);
@@ -181,9 +192,162 @@ export function fetchTemplate(spec: TemplateSpec): FetchedTemplate {
181
192
  }
182
193
  }
183
194
 
195
+ // ── A template directory on disk (#2647) ─────────────────────────────────────
196
+
197
+ export interface DirTemplateSpec {
198
+ kind: "dir";
199
+ /** The directory as written, without `#<member>`. */
200
+ path: string;
201
+ /** The directory, absolute. */
202
+ abs: string;
203
+ /** The directory inside it to instantiate (`#<member>`). */
204
+ member?: string;
205
+ }
206
+
207
+ /**
208
+ * Parse `<path>[#<member>]` when `<path>` is an existing directory, or return
209
+ * null for the git form. A spec without `@` that names no directory is
210
+ * refused here, since it cannot be a `<repo>@<ref>` either. A directory whose
211
+ * name happens to parse as `<repo>@<ref>` is a directory: what is on disk wins.
212
+ */
213
+ export function parseDirSpec(spec: string, cwd: string = process.cwd()): DirTemplateSpec | null {
214
+ const { rest, member } = splitMember(spec, "the directory");
215
+ if (!rest) return null;
216
+ const abs = resolve(cwd, rest);
217
+ let isDir = false;
218
+ try {
219
+ isDir = statSync(abs).isDirectory();
220
+ } catch {
221
+ isDir = false;
222
+ }
223
+ if (isDir) return { kind: "dir", path: rest, abs, ...(member ? { member } : {}) };
224
+ if (rest.includes("@")) return null;
225
+ if (existsSync(abs)) throw new LockError(`--from ${spec}: ${rest} is not a directory`);
226
+ throw new LockError(`--from ${spec}: no directory ${rest}, and not <repo>@<ref>[#<member>] (e.g. acme/starter@v1.2.0)`);
227
+ }
228
+
229
+ /** The template's directory as `#<member>` names it, relative to its root, for messages. */
230
+ export function dirLabel(path: string, member?: string): string {
231
+ return member ? `${path}#${member}` : path;
232
+ }
233
+
234
+ /** The git work tree `dir` sits in, or null when it is in none, git is not installed, or the work tree ignores `dir`. */
235
+ function workTreeOf(dir: string): string | null {
236
+ try {
237
+ if (git(dir, ["rev-parse", "--is-inside-work-tree"]) !== "true") return null;
238
+ const top = git(dir, ["rev-parse", "--show-toplevel"]);
239
+ const rel = relative(top, dir).split(sep).join("/");
240
+ if (rel) {
241
+ try {
242
+ // Exit 0: ignored. A directory the checkout ignores is copied whole.
243
+ git(top, ["check-ignore", "-q", "--", `${rel}/`]);
244
+ return null;
245
+ } catch {
246
+ // Not ignored.
247
+ }
248
+ }
249
+ return top;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /** Every path under `dir`, relative and posix, except `.git`. Symbolic links are listed, not followed. */
256
+ function walk(dir: string, prefix = ""): string[] {
257
+ const out: string[] = [];
258
+ for (const entry of readdirSync(join(dir, prefix), { withFileTypes: true })) {
259
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
260
+ if (entry.name === ".git") continue;
261
+ if (entry.isDirectory()) out.push(...walk(dir, rel));
262
+ else out.push(rel);
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Read the files of a template directory on disk, the way
269
+ * {@link readTemplateTree} reads a commit: symbolic links, submodules and the
270
+ * template's own lineage lock are skipped with the reason, and so is
271
+ * `node_modules`. Inside a git checkout the files are the ones git would
272
+ * commit, tracked or untracked but not ignored. Elsewhere, or when git is not
273
+ * installed, every file is copied. The executable bit is the owner's.
274
+ */
275
+ export function readTemplateDir(abs: string, member: string | undefined, label: string): Omit<FetchedTemplate, "commit" | "tree"> {
276
+ const dir = member ? join(abs, member) : abs;
277
+ let isDir = false;
278
+ try {
279
+ isDir = statSync(dir).isDirectory();
280
+ } catch {
281
+ isDir = false;
282
+ }
283
+ if (!isDir) throw new LockError(`${label} has no directory ${member ?? ""}`.trimEnd());
284
+
285
+ let paths: string[];
286
+ if (workTreeOf(dir)) {
287
+ const listing = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], {
288
+ cwd: dir,
289
+ maxBuffer: 256 * 1024 * 1024,
290
+ }).toString("utf-8");
291
+ paths = [...new Set(listing.split("\0").filter(Boolean))];
292
+ } else {
293
+ paths = walk(dir);
294
+ }
295
+
296
+ const files: FetchedTemplate["files"] = new Map();
297
+ const skipped: FetchedTemplate["skipped"] = [];
298
+ const skippedModules = new Set<string>();
299
+ for (const path of paths.sort()) {
300
+ const segments = path.split("/");
301
+ const nm = segments.indexOf("node_modules");
302
+ if (nm >= 0) {
303
+ const at = segments.slice(0, nm + 1).join("/");
304
+ if (!skippedModules.has(at)) {
305
+ skippedModules.add(at);
306
+ skipped.push({ path: at, reason: "node_modules" });
307
+ }
308
+ continue;
309
+ }
310
+ if (path === LOCK_FILE) {
311
+ skipped.push({ path, reason: "the template's own lineage lock" });
312
+ continue;
313
+ }
314
+ let st;
315
+ try {
316
+ st = lstatSync(join(dir, path));
317
+ } catch {
318
+ // Tracked but deleted from the working files: not part of the template on disk.
319
+ continue;
320
+ }
321
+ if (st.isSymbolicLink()) {
322
+ skipped.push({ path, reason: "a symbolic link" });
323
+ continue;
324
+ }
325
+ if (st.isDirectory()) {
326
+ skipped.push({ path, reason: "a submodule" });
327
+ continue;
328
+ }
329
+ if (!st.isFile()) {
330
+ skipped.push({ path, reason: "not a regular file" });
331
+ continue;
332
+ }
333
+ files.set(path, { data: readFileSync(join(dir, path)), executable: (st.mode & 0o100) !== 0 });
334
+ }
335
+ return { files, skipped };
336
+ }
337
+
338
+ /**
339
+ * How a directory source is recorded: an absolute path as given, a relative
340
+ * one re-expressed from the project, so it resolves from the lock's directory.
341
+ */
342
+ export function recordedDirPath(given: string, abs: string, projectDir: string): string {
343
+ if (isAbsolute(given)) return abs;
344
+ return portableUrl(abs, projectDir);
345
+ }
346
+
184
347
  // ── init --from ──────────────────────────────────────────────────────────────
185
348
 
186
349
  export interface InitFromOptions {
350
+ /** `<repo>@<ref>[#<member>]`, or `<dir>[#<member>]` for a directory on disk (#2647). */
187
351
  from: string;
188
352
  /** Target directory (defaults to cwd). */
189
353
  path?: string;
@@ -197,21 +361,28 @@ export interface InitFromResult {
197
361
  createdFiles: string[];
198
362
  warnings: string[];
199
363
  error?: string;
364
+ /** The git form's spec. */
200
365
  spec?: TemplateSpec;
366
+ /** The directory form's spec (#2647). */
367
+ dir?: DirTemplateSpec;
201
368
  commit?: string;
369
+ /** The lineage's `template` id. */
370
+ template?: string;
202
371
  /** The parameter values used, recorded in the lock. */
203
372
  parameters?: Record<string, string>;
204
373
  }
205
374
 
206
- /** `chant init --from <repo>@<ref>[#<member>] [path]`. */
375
+ /** `chant init --from <repo>@<ref>[#<member>] [path]`, or `--from <dir>[#<member>]`. */
207
376
  export async function initFromCommand(options: InitFromOptions): Promise<InitFromResult> {
208
377
  const targetDir = resolve(options.path ?? ".");
209
378
  const warnings: string[] = [];
210
379
  const createdFiles: string[] = [];
211
380
 
212
- let spec: TemplateSpec;
381
+ let spec: TemplateSpec | undefined;
382
+ let dir: DirTemplateSpec | undefined;
213
383
  try {
214
- spec = parseTemplateSpec(options.from);
384
+ dir = parseDirSpec(options.from) ?? undefined;
385
+ if (!dir) spec = parseTemplateSpec(options.from);
215
386
  } catch (err) {
216
387
  return { success: false, createdFiles, warnings, error: (err as Error).message };
217
388
  }
@@ -227,9 +398,10 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
227
398
  return { success: false, createdFiles, warnings, error: `${LOCK_FILE} already exists; this directory already has a lineage` };
228
399
  }
229
400
 
230
- let fetched: FetchedTemplate;
401
+ let fetched: Omit<FetchedTemplate, "commit" | "tree"> & { commit?: string; tree?: string };
231
402
  try {
232
- fetched = fetchTemplate(spec);
403
+ // Every file is read before anything is written, so a target inside the template directory is safe.
404
+ fetched = dir ? readTemplateDir(dir.abs, dir.member, dir.path) : fetchTemplate(spec!);
233
405
  } catch (err) {
234
406
  return { success: false, createdFiles, warnings, error: (err as Error).message };
235
407
  }
@@ -268,23 +440,46 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
268
440
  createdFiles.push(path);
269
441
  }
270
442
 
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 },
443
+ const common = {
277
444
  parameters,
278
445
  migrations: [],
279
446
  files: fileEntries(written, declaredFilesAt(targetDir)),
280
447
  manualSteps: [],
281
448
  };
449
+ let lineage: Lineage;
450
+ if (dir) {
451
+ const path = recordedDirPath(dir.path, dir.abs, targetDir);
452
+ lineage = {
453
+ kind: "template",
454
+ template: `dir:${dirLabel(path, dir.member)}`,
455
+ source: { type: "dir", path, ...(dir.member ? { member: dir.member } : {}) },
456
+ address: { digest: contentDigest(written) },
457
+ ...common,
458
+ };
459
+ } else {
460
+ lineage = {
461
+ kind: "template",
462
+ template: spec!.id,
463
+ source: { type: "git", repo: spec!.repo, url: portableUrl(spec!.url, targetDir), ...(spec!.member ? { path: spec!.member } : {}) },
464
+ ref: spec!.ref,
465
+ address: { digest: contentDigest(written), commit: fetched.commit, tree: fetched.tree },
466
+ ...common,
467
+ };
468
+ }
282
469
  const lock = emptyLock();
283
470
  lock.scopes["."] = lineage;
284
471
  writeLock(targetDir, lock);
285
472
  createdFiles.push(LOCK_FILE);
286
473
 
287
- return { success: true, createdFiles, warnings, spec, commit: fetched.commit, parameters };
474
+ return {
475
+ success: true,
476
+ createdFiles,
477
+ warnings,
478
+ ...(spec ? { spec, commit: fetched.commit } : {}),
479
+ ...(dir ? { dir } : {}),
480
+ template: lineage.template,
481
+ parameters,
482
+ };
288
483
  }
289
484
 
290
485
  /** 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({
@@ -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;
@@ -9,7 +9,8 @@
9
9
  * 1. Fetch the target version of the template. For a git source that is one
10
10
  * `git fetch` of the target ref, plus the commit the scope was made from.
11
11
  * This is the command's only network step, catalogued in
12
- * `test/egress-catalogue.ts`. A local repository reaches nothing.
12
+ * `test/egress-catalogue.ts`. A local repository reaches nothing, and
13
+ * neither does a directory source (#2647), which reads `--to <dir>`.
13
14
  * 2. Rebuild the merge base offline: each recorded file's content at the base
14
15
  * commit, kept only when it has the hash the lock recorded. A file the
15
16
  * project never edited is its own merge base. Vendor sources keep no
@@ -39,8 +40,21 @@ import { basename, join, relative, resolve, sep } from "node:path";
39
40
  import { computePlanDigest } from "../lifecycle/plan-digest";
40
41
  import type { ResolvedGateApproval } from "../op/gate-approval";
41
42
  import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
42
- import { git, readTemplateTree } from "./lineage-init";
43
- import { LOCK_FILE, LockError, fileHash, readLock, renderLock, scopeKey, writeLock, type Lineage, type LineageLock, type ManualStep } from "./lineage-lock";
43
+ import { dirLabel, git, parseDirSpec, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
44
+ import {
45
+ LOCK_FILE,
46
+ LockError,
47
+ contentDigest,
48
+ fileHash,
49
+ readLock,
50
+ renderLock,
51
+ scopeKey,
52
+ writeLock,
53
+ type Lineage,
54
+ type LineageLock,
55
+ type LineageSource,
56
+ type ManualStep,
57
+ } from "./lineage-lock";
44
58
  import { mergeFile } from "./lineage-merge";
45
59
  import { assertCodeAllowed, planMigrations, runMigration, splitMigrations, type LoadedMigration } from "./lineage-migrations";
46
60
  import { applyUpstream, type UpdateResult } from "./lineage-update";
@@ -95,7 +109,12 @@ export interface UpgradeOptions {
95
109
  root: string;
96
110
  /** The scope to upgrade: `"."` or a vendor scope's directory. */
97
111
  scope?: string;
98
- /** The target ref (git) or label (vendor). Defaults to the ref the scope is pinned at. */
112
+ /**
113
+ * The target ref (git) or label (vendor). Defaults to the ref the scope is
114
+ * pinned at. For a directory source, the directory holding the new version,
115
+ * as `<dir>[#<member>]`, resolved from the current directory; the member
116
+ * defaults to the recorded one.
117
+ */
99
118
  to?: string;
100
119
  /** Run migrations whose body is code. */
101
120
  allowCode?: boolean;
@@ -163,8 +182,10 @@ interface Upstream {
163
182
  modules: Map<string, Buffer>;
164
183
  commit?: string;
165
184
  tree?: string;
166
- /** The parameter values substituted into `files` (#2627), for a git source. */
185
+ /** The parameter values substituted into `files` (#2627), for a git or directory source. */
167
186
  parameters?: Record<string, string>;
187
+ /** The source the lock records after the upgrade, when it moves (a directory source). */
188
+ source?: LineageSource;
168
189
  }
169
190
 
170
191
  /**
@@ -249,6 +270,76 @@ function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
249
270
  }
250
271
  }
251
272
 
273
+ /**
274
+ * Why a directory scope cannot be upgraded, with the way out. A directory
275
+ * has no history, so the merge base exists only while the files the scope
276
+ * was made from are still on disk.
277
+ */
278
+ function dirRefusal(lineage: Lineage, why: string): UpgradeError {
279
+ const source = lineage.source as Extract<LineageSource, { type: "dir" }>;
280
+ return new UpgradeError(
281
+ `${why}. The scope was made from the directory ${dirLabel(source.path, source.member)}, which has no git history to rebuild a merge base from. ` +
282
+ `Upgrade with --to <dir> while ${source.path} still holds the files the scope was made from, or adopt the scope into a git lineage with \`chant workspace adopt-lineage\` (#2551).`,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * The upstream of a directory source (#2647): the new version from the
288
+ * directory `to` names, and the merge base from the recorded directory, used
289
+ * only when its files, instantiated with the recorded parameters, still have
290
+ * the recorded digest. Anything else is refused: a base that is not the one
291
+ * the scope was made from would turn the project's edits into upstream
292
+ * changes. Reaches no network.
293
+ */
294
+ function readDir(root: string, lineage: Lineage, to: string | undefined): Upstream {
295
+ const source = lineage.source;
296
+ if (source.type !== "dir") throw new UpgradeError("not a directory source");
297
+ if (!to) throw dirRefusal(lineage, "no --to directory");
298
+
299
+ let target;
300
+ try {
301
+ target = parseDirSpec(to);
302
+ } catch {
303
+ target = null;
304
+ }
305
+ if (!target) throw new UpgradeError(`--to ${to}: a scope made from a directory upgrades from a directory, and ${to} is not one`);
306
+ const member = to.includes("#") ? target.member : source.member;
307
+ const label = dirLabel(target.path, member);
308
+ const read = readTemplateDir(target.abs, member, label);
309
+ const raw = new Map<string, Buffer>();
310
+ const executable = new Set<string>();
311
+ for (const [path, f] of read.files) {
312
+ raw.set(path, f.data);
313
+ if (f.executable) executable.add(path);
314
+ }
315
+ const instantiated = instantiate(raw, lineage.parameters, label);
316
+
317
+ // The merge base: the recorded directory, only while it holds exactly what the scope was made from.
318
+ const original = resolve(root, source.path);
319
+ const originalLabel = dirLabel(source.path, source.member);
320
+ let base: Map<string, Buffer>;
321
+ try {
322
+ const old = readTemplateDir(original, source.member, originalLabel);
323
+ base = splitMigrations(instantiate(new Map([...old.files].map(([path, f]) => [path, f.data])), lineage.parameters, originalLabel).files).files;
324
+ } catch {
325
+ throw dirRefusal(lineage, `${originalLabel} is gone`);
326
+ }
327
+ if (!lineage.address || contentDigest(base) !== lineage.address.digest) {
328
+ throw dirRefusal(lineage, `${originalLabel} no longer holds the files the scope was made from (its digest is not ${lineage.address?.digest ?? "recorded"})`);
329
+ }
330
+
331
+ const split = splitMigrations(instantiated.files);
332
+ return {
333
+ files: split.files,
334
+ executable,
335
+ base,
336
+ migrations: split.migrations,
337
+ modules: split.modules,
338
+ parameters: instantiated.parameters,
339
+ source: { type: "dir", path: recordedDirPath(target.path, target.abs, root), ...(member ? { member } : {}) },
340
+ };
341
+ }
342
+
252
343
  async function fetchUpstream(root: string, lineage: Lineage, ref: string | undefined): Promise<Upstream> {
253
344
  const source = lineage.source;
254
345
  if (source.type === "git") {
@@ -260,6 +351,7 @@ async function fetchUpstream(root: string, lineage: Lineage, ref: string | undef
260
351
  const files = await resolveVendorSource(source, root);
261
352
  return { files, executable: new Set(), base: new Map(), migrations: [], modules: new Map() };
262
353
  }
354
+ if (source.type === "dir") return readDir(root, lineage, ref);
263
355
  throw new UpgradeError(
264
356
  `scope made by \`chant init --template\` (${lineage.template}) cannot be upgraded yet: its merge base is the older lexicon's render, which is not available offline. Upgrade git-sourced and vendor scopes for now.`,
265
357
  );
@@ -404,9 +496,10 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
404
496
  );
405
497
  }
406
498
 
407
- // 1. Fetch.
408
- const ref = options.to ?? lineage.ref;
409
- const upstream = await fetchUpstream(root, lineage, ref);
499
+ // 1. Fetch. A directory source has no ref: `--to` names the directory.
500
+ const fromDir = lineage.source.type === "dir";
501
+ const ref = fromDir ? undefined : options.to ?? lineage.ref;
502
+ const upstream = await fetchUpstream(root, lineage, fromDir ? options.to : ref);
410
503
 
411
504
  // 2. The merge base, offline from here on.
412
505
  const base = rebuildBase(join(root, scope), lineage, upstream.base);
@@ -469,6 +562,7 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
469
562
  if (ref !== undefined) staged.ref = ref;
470
563
  if (upstream.parameters) staged.parameters = upstream.parameters;
471
564
  if (upstream.commit) staged.address = { ...staged.address!, commit: upstream.commit, tree: upstream.tree };
565
+ if (upstream.source) staged.source = upstream.source;
472
566
  writeLock(worktreeProject, next);
473
567
  const lockText = renderLock(next);
474
568
 
@@ -512,8 +606,8 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
512
606
  scope,
513
607
  template: lineage.template,
514
608
  kind: lineage.kind,
515
- from: lineage.ref ?? null,
516
- to: staged.ref ?? null,
609
+ from: fromDir ? sourcePin(lineage.source) : lineage.ref ?? null,
610
+ to: fromDir ? sourcePin(staged.source) : staged.ref ?? null,
517
611
  ...(upstream.commit ? { commit: { ...(lineage.address?.commit ? { from: lineage.address.commit } : {}), to: upstream.commit } } : {}),
518
612
  migrations: plan.chain.map((m) => m.migration.id),
519
613
  written: result.written,
@@ -542,6 +636,11 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
542
636
  }
543
637
  }
544
638
 
639
+ /** A directory source's pin, for the summary: the directory it reads. */
640
+ function sourcePin(source: LineageSource): string | null {
641
+ return source.type === "dir" ? dirLabel(source.path, source.member) : null;
642
+ }
643
+
545
644
  /**
546
645
  * `chant workspace check` in the worktree. A finding already present at HEAD
547
646
  * is not the upgrade's doing, and the upgrade's own manual steps are what the