treelay 0.1.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.
@@ -0,0 +1,2665 @@
1
+ // src/errors.ts
2
+ var CycleError = class extends Error {
3
+ constructor(path) {
4
+ super(`Inheritance cycle detected: ${path.join(" \u2192 ")}`);
5
+ this.path = path;
6
+ this.name = "CycleError";
7
+ }
8
+ path;
9
+ };
10
+ var InconsistentHierarchyError = class extends Error {
11
+ constructor(message) {
12
+ super(`Inconsistent hierarchy: ${message}`);
13
+ this.name = "InconsistentHierarchyError";
14
+ }
15
+ };
16
+ var MergeConflictError = class extends Error {
17
+ constructor(file, message) {
18
+ super(`Merge conflict in ${file}: ${message}`);
19
+ this.file = file;
20
+ this.name = "MergeConflictError";
21
+ }
22
+ file;
23
+ };
24
+ var NotImplementedError = class extends Error {
25
+ constructor(what) {
26
+ super(`Not implemented yet: ${what}`);
27
+ this.name = "NotImplementedError";
28
+ }
29
+ };
30
+
31
+ // src/c3.ts
32
+ function c3Linearize(root, parentsOf, key = (n) => String(n)) {
33
+ const lin = (node, path) => {
34
+ const k = key(node);
35
+ if (path.includes(k)) {
36
+ throw new CycleError([...path, k]);
37
+ }
38
+ const parents = parentsOf(node);
39
+ if (parents.length === 0) return [node];
40
+ const nextPath = [...path, k];
41
+ const sequences = parents.map((p) => lin(p, nextPath));
42
+ sequences.push([...parents]);
43
+ return [node, ...merge(sequences, key)];
44
+ };
45
+ return lin(root, []);
46
+ }
47
+ function merge(sequences, key) {
48
+ const lists = sequences.map((s) => [...s]).filter((s) => s.length > 0);
49
+ const result = [];
50
+ while (lists.length > 0) {
51
+ let candidate;
52
+ for (const list of lists) {
53
+ const head = list[0];
54
+ const headKey = key(head);
55
+ const inSomeTail = lists.some(
56
+ (other) => other.slice(1).some((n) => key(n) === headKey)
57
+ );
58
+ if (!inSomeTail) {
59
+ candidate = head;
60
+ break;
61
+ }
62
+ }
63
+ if (candidate === void 0) {
64
+ const remaining = lists.map((l) => l.map(key).join(", ")).join(" | ");
65
+ throw new InconsistentHierarchyError(
66
+ `cannot linearize conflicting precedence among: ${remaining}`
67
+ );
68
+ }
69
+ result.push(candidate);
70
+ const candidateKey = key(candidate);
71
+ for (let i = lists.length - 1; i >= 0; i--) {
72
+ const list = lists[i];
73
+ if (list.length > 0 && key(list[0]) === candidateKey) {
74
+ list.shift();
75
+ }
76
+ if (list.length === 0) {
77
+ lists.splice(i, 1);
78
+ }
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+
84
+ // src/manifest.ts
85
+ import { readFileSync, existsSync, accessSync, constants } from "fs";
86
+ import { join } from "path";
87
+ import { parse as parseYaml } from "yaml";
88
+ var MANIFEST_NAMES = ["treelay.json", "treelay.yaml", "treelay.yml"];
89
+ function loadManifest(dir) {
90
+ for (const name of MANIFEST_NAMES) {
91
+ const file = join(dir, name);
92
+ if (existsSync(file)) {
93
+ const raw = readFileSync(file, "utf8");
94
+ return name.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
95
+ }
96
+ }
97
+ const pkgPath = join(dir, "package.json");
98
+ if (existsSync(pkgPath)) {
99
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
100
+ if (pkg.treelay) {
101
+ return { ...pkg.name !== void 0 ? { name: pkg.name } : {}, ...pkg.treelay };
102
+ }
103
+ }
104
+ return {};
105
+ }
106
+ function isWritable(dir) {
107
+ try {
108
+ accessSync(dir, constants.W_OK);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ // src/render.ts
116
+ import { Liquid } from "liquidjs";
117
+ var DEFAULT_TEMPLATE_SUFFIX = ".tmpl";
118
+ function createEngine() {
119
+ return new Liquid({
120
+ // No `root`/`fs` ⇒ `{% include %}`/`{% render %}` from disk is unavailable.
121
+ strictVariables: true,
122
+ strictFilters: true
123
+ });
124
+ }
125
+ var engine = createEngine();
126
+ async function renderString(template, values) {
127
+ return engine.parseAndRender(template, values);
128
+ }
129
+ function templateTarget(path, suffix = DEFAULT_TEMPLATE_SUFFIX) {
130
+ if (path.endsWith(suffix)) {
131
+ return { render: true, outPath: path.slice(0, -suffix.length) };
132
+ }
133
+ return { render: false, outPath: path };
134
+ }
135
+
136
+ // src/variables.ts
137
+ import { createInterface } from "readline/promises";
138
+ import { parse as parseYaml2 } from "yaml";
139
+ function mergeVariableDecls(layers) {
140
+ const merged = {};
141
+ for (const layer of layers) {
142
+ const vars = layer.manifest.variables ?? {};
143
+ for (const [name, decl] of Object.entries(vars)) {
144
+ merged[name] = { ...merged[name], ...decl };
145
+ }
146
+ }
147
+ return merged;
148
+ }
149
+ var DEFER = /* @__PURE__ */ Symbol("defer");
150
+ function coerce(type, raw) {
151
+ if (raw === void 0 || raw === null) return raw;
152
+ switch (type) {
153
+ case "string":
154
+ case "path":
155
+ return String(raw);
156
+ case "number": {
157
+ if (typeof raw === "number") return raw;
158
+ const n = Number(raw);
159
+ if (Number.isNaN(n)) throw new Error(`expected a number, got "${raw}"`);
160
+ return n;
161
+ }
162
+ case "boolean":
163
+ if (typeof raw === "boolean") return raw;
164
+ return raw === "true" || raw === "1" || raw === "yes";
165
+ case "json":
166
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
167
+ case "yaml":
168
+ return typeof raw === "string" ? parseYaml2(raw) : raw;
169
+ }
170
+ }
171
+ function isFalsy(rendered) {
172
+ const t = rendered.trim();
173
+ return t === "" || t === "false";
174
+ }
175
+ async function resolveValues(graph, options = {}) {
176
+ const engine2 = createEngine();
177
+ const decls = graph.variables;
178
+ const values = {};
179
+ const tryRender = async (tmpl) => {
180
+ try {
181
+ return await engine2.parseAndRender(tmpl, values);
182
+ } catch {
183
+ return DEFER;
184
+ }
185
+ };
186
+ const evalDefault = async (decl) => {
187
+ if (typeof decl.default !== "string") return decl.default;
188
+ const r = await tryRender(decl.default);
189
+ if (r === DEFER) return DEFER;
190
+ return coerce(decl.type, r);
191
+ };
192
+ const provided = { ...options.answers, ...options.set };
193
+ for (const [name, raw] of Object.entries(provided)) {
194
+ values[name] = decls[name] ? coerce(decls[name].type, raw) : raw;
195
+ }
196
+ const resolved = new Set(Object.keys(provided).filter((n) => decls[n]));
197
+ let pending = Object.keys(decls).filter((n) => !resolved.has(n));
198
+ let rl;
199
+ const promptVar = async (name, decl, fallback) => {
200
+ rl ??= createInterface({ input: process.stdin, output: process.stdout });
201
+ const hint = decl.choices ? ` ${JSON.stringify(decl.choices)}` : "";
202
+ const def = fallback !== void 0 ? ` [${String(fallback)}]` : "";
203
+ const answer = (await rl.question(`${decl.prompt}${hint}${def} `)).trim();
204
+ return answer === "" ? fallback : coerce(decl.type, answer);
205
+ };
206
+ try {
207
+ let guard = pending.length + 1;
208
+ while (pending.length && guard-- > 0) {
209
+ const next = [];
210
+ let progressed = false;
211
+ for (const name of pending) {
212
+ const decl = decls[name];
213
+ if (decl.when !== void 0) {
214
+ const w = await tryRender(decl.when);
215
+ if (w === DEFER) {
216
+ next.push(name);
217
+ continue;
218
+ }
219
+ if (isFalsy(w)) {
220
+ resolved.add(name);
221
+ progressed = true;
222
+ continue;
223
+ }
224
+ }
225
+ if (decl.computed) {
226
+ if (decl.default === void 0) {
227
+ throw new Error(`computed variable "${name}" has no default`);
228
+ }
229
+ const r = await evalDefault(decl);
230
+ if (r === DEFER) {
231
+ next.push(name);
232
+ continue;
233
+ }
234
+ values[name] = r;
235
+ resolved.add(name);
236
+ progressed = true;
237
+ continue;
238
+ }
239
+ const fallback = decl.default !== void 0 ? await evalDefault(decl) : void 0;
240
+ if (fallback === DEFER) {
241
+ next.push(name);
242
+ continue;
243
+ }
244
+ if (decl.prompt !== void 0 && options.prompt) {
245
+ values[name] = await promptVar(name, decl, fallback);
246
+ } else if (fallback !== void 0) {
247
+ values[name] = fallback;
248
+ } else {
249
+ next.push(name);
250
+ continue;
251
+ }
252
+ resolved.add(name);
253
+ progressed = true;
254
+ }
255
+ pending = next;
256
+ if (!progressed) break;
257
+ }
258
+ } finally {
259
+ rl?.close();
260
+ }
261
+ if (pending.length) {
262
+ throw new Error(
263
+ `Cannot resolve variables: ${pending.join(", ")} (no value/default, or a dependency cycle among defaults)`
264
+ );
265
+ }
266
+ for (const [name, decl] of Object.entries(decls)) {
267
+ if (!(name in values)) continue;
268
+ if (decl.choices && !decl.choices.includes(values[name])) {
269
+ throw new Error(
270
+ `Invalid ${name}: ${JSON.stringify(values[name])} not in ` + JSON.stringify(decl.choices)
271
+ );
272
+ }
273
+ if (decl.validate) {
274
+ const msg = (await engine2.parseAndRender(decl.validate, values)).trim();
275
+ if (msg) throw new Error(`Invalid ${name}: ${msg}`);
276
+ }
277
+ }
278
+ return values;
279
+ }
280
+
281
+ // src/hash.ts
282
+ import { createHash } from "crypto";
283
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
284
+ import { join as join2 } from "path";
285
+ function hashContent(data) {
286
+ return "sha256:" + createHash("sha256").update(data).digest("hex");
287
+ }
288
+ function matchesHash(data, recorded) {
289
+ return hashContent(data) === recorded.trim();
290
+ }
291
+ function hashTree(dir) {
292
+ const digest = createHash("sha256");
293
+ for (const rel of listTreeFiles(dir)) {
294
+ digest.update(rel);
295
+ digest.update("\0");
296
+ digest.update(createHash("sha256").update(readFileSync2(join2(dir, rel))).digest());
297
+ digest.update("\n");
298
+ }
299
+ return "sha256:" + digest.digest("hex");
300
+ }
301
+ var TREE_HASH_PRUNE = /* @__PURE__ */ new Set([".git", "node_modules"]);
302
+ function listTreeFiles(dir, prefix = "") {
303
+ const out = [];
304
+ for (const e of readdirSync(dir, { withFileTypes: true }).sort(
305
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
306
+ )) {
307
+ if (TREE_HASH_PRUNE.has(e.name)) continue;
308
+ const rel = prefix === "" ? e.name : `${prefix}/${e.name}`;
309
+ if (e.isDirectory()) out.push(...listTreeFiles(join2(dir, e.name), rel));
310
+ else if (e.isFile()) out.push(rel);
311
+ }
312
+ return out;
313
+ }
314
+
315
+ // src/refs.ts
316
+ import { isAbsolute } from "path";
317
+ var InvalidRefError = class extends Error {
318
+ constructor(ref, reason) {
319
+ super(`Invalid layer reference "${ref}": ${reason}`);
320
+ this.ref = ref;
321
+ this.name = "InvalidRefError";
322
+ }
323
+ ref;
324
+ };
325
+ var GITHUB_SHORTHAND = /^github:([^/#?]+)\/([^/#?]+?)(?:\.git)?(?=[#?]|$)/;
326
+ var WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
327
+ function splitSuffixes(rest) {
328
+ let body = rest;
329
+ let committish;
330
+ let subdir;
331
+ const hash = body.indexOf("#");
332
+ if (hash !== -1) {
333
+ committish = body.slice(hash + 1);
334
+ body = body.slice(0, hash);
335
+ }
336
+ const q = body.indexOf("?");
337
+ if (q !== -1) {
338
+ const params = new URLSearchParams(body.slice(q + 1));
339
+ const p = params.get("path");
340
+ if (p) subdir = normalizeSubdir(p);
341
+ body = body.slice(0, q);
342
+ }
343
+ return {
344
+ body,
345
+ ...subdir !== void 0 ? { subdir } : {},
346
+ ...committish !== void 0 && committish !== "" ? { committish } : {}
347
+ };
348
+ }
349
+ function normalizeSubdir(p) {
350
+ const clean = p.split("\\").join("/").replace(/^\/+|\/+$/g, "");
351
+ if (clean.split("/").includes("..")) {
352
+ throw new InvalidRefError(p, "`path=` may not escape the fetched tree with `..`");
353
+ }
354
+ return clean;
355
+ }
356
+ function isLocalRef(ref) {
357
+ return parseRef(ref).kind === "local";
358
+ }
359
+ function parseRef(ref) {
360
+ const raw = ref.trim();
361
+ if (raw === "") throw new InvalidRefError(ref, "empty reference");
362
+ if (raw.startsWith("file:")) {
363
+ return { kind: "local", raw, path: raw.slice("file:".length) };
364
+ }
365
+ if (raw.startsWith(".") || isAbsolute(raw) || WINDOWS_DRIVE.test(raw)) {
366
+ return { kind: "local", raw, path: raw };
367
+ }
368
+ const gh = GITHUB_SHORTHAND.exec(raw);
369
+ if (gh) {
370
+ const { subdir: subdir2, committish } = splitSuffixes(raw.slice(gh[0].length));
371
+ return git(raw, `https://github.com/${gh[1]}/${gh[2]}.git`, committish, subdir2);
372
+ }
373
+ if (raw.startsWith("git+")) {
374
+ const { body: body2, subdir: subdir2, committish } = splitSuffixes(raw.slice("git+".length));
375
+ if (body2 === "") throw new InvalidRefError(ref, "git ref has no URL");
376
+ return git(raw, body2, committish, subdir2);
377
+ }
378
+ if (raw.includes("://")) {
379
+ const { body: body2, subdir: subdir2, committish } = splitSuffixes(raw);
380
+ if (body2.endsWith(".git")) return git(raw, body2, committish, subdir2);
381
+ throw new InvalidRefError(
382
+ ref,
383
+ "URLs must be prefixed with `git+` (or end in `.git`) so the transport is explicit"
384
+ );
385
+ }
386
+ const npmBody = raw.startsWith("npm:") ? raw.slice("npm:".length) : raw;
387
+ const { body, subdir } = splitSuffixes(npmBody);
388
+ return npm(raw, body, subdir);
389
+ }
390
+ function git(raw, url, committish, subdir) {
391
+ return {
392
+ kind: "git",
393
+ raw,
394
+ url,
395
+ committish: committish ?? "HEAD",
396
+ ...subdir !== void 0 ? { subdir } : {}
397
+ };
398
+ }
399
+ function npm(raw, body, subdir) {
400
+ const at = body.indexOf("@", body.startsWith("@") ? 1 : 0);
401
+ const name = at === -1 ? body : body.slice(0, at);
402
+ const range = at === -1 ? "*" : body.slice(at + 1);
403
+ if (name === "") throw new InvalidRefError(raw, "npm ref has no package name");
404
+ return {
405
+ kind: "npm",
406
+ raw,
407
+ name,
408
+ range: range === "" ? "*" : range,
409
+ ...subdir !== void 0 ? { subdir } : {}
410
+ };
411
+ }
412
+ function canonicalRef(ref) {
413
+ const parsed = typeof ref === "string" ? parseRef(ref) : ref;
414
+ const query = parsed.kind !== "local" && parsed.subdir ? `?path=${parsed.subdir}` : "";
415
+ switch (parsed.kind) {
416
+ case "local":
417
+ return parsed.raw;
418
+ case "git":
419
+ return `git+${parsed.url}${query}#${parsed.committish}`;
420
+ case "npm":
421
+ return `npm:${parsed.name}@${parsed.range}${query}`;
422
+ }
423
+ }
424
+ function pinnedRef(ref, revision) {
425
+ const query = ref.subdir ? `?path=${ref.subdir}` : "";
426
+ return ref.kind === "git" ? `git+${ref.url}${query}#${revision}` : `npm:${ref.name}@${revision}${query}`;
427
+ }
428
+ function isCommitSha(committish) {
429
+ return /^[0-9a-f]{40}$/i.test(committish);
430
+ }
431
+
432
+ // src/fetch/cache.ts
433
+ import { createHash as createHash2 } from "crypto";
434
+ import { existsSync as existsSync2, mkdirSync, rmSync } from "fs";
435
+ import { homedir } from "os";
436
+ import { join as join3 } from "path";
437
+ function cacheRoot(override) {
438
+ return override ?? process.env["TREELAY_CACHE_DIR"] ?? join3(homedir(), ".cache", "treelay");
439
+ }
440
+ function locationKey(location) {
441
+ const slug = location.replace(/^[a-z+]+:\/\//i, "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "layer";
442
+ const digest = createHash2("sha256").update(location).digest("hex").slice(0, 16);
443
+ return `${slug}-${digest}`;
444
+ }
445
+ function gitRepoDir(location, root) {
446
+ return join3(cacheRoot(root), "git", locationKey(location));
447
+ }
448
+ function gitMirrorDir(location, root) {
449
+ return join3(gitRepoDir(location, root), "repo.git");
450
+ }
451
+ function gitRevisionDir(location, revision, root) {
452
+ return join3(gitRepoDir(location, root), "rev", revision);
453
+ }
454
+ function freshDir(dir) {
455
+ rmSync(dir, { recursive: true, force: true });
456
+ mkdirSync(dir, { recursive: true });
457
+ return dir;
458
+ }
459
+ function ensureCacheDir(dir) {
460
+ if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
461
+ return dir;
462
+ }
463
+
464
+ // src/fetch/git.ts
465
+ import { execFileSync } from "child_process";
466
+ import { existsSync as existsSync3, rmSync as rmSync2 } from "fs";
467
+ import { join as join4 } from "path";
468
+ var GitFetchError = class extends Error {
469
+ constructor(url, message) {
470
+ super(`git layer ${url}: ${message}`);
471
+ this.url = url;
472
+ this.name = "GitFetchError";
473
+ }
474
+ url;
475
+ };
476
+ function git2(args, cwd) {
477
+ try {
478
+ return execFileSync("git", args, {
479
+ encoding: "utf8",
480
+ ...cwd ? { cwd } : {},
481
+ stdio: ["ignore", "pipe", "pipe"],
482
+ env: {
483
+ ...process.env,
484
+ // Never stop for credentials: a hung prompt inside a build is worse
485
+ // than a clear "authentication failed".
486
+ GIT_TERMINAL_PROMPT: "0",
487
+ GIT_ADVICE: "0"
488
+ }
489
+ }).trim();
490
+ } catch (err) {
491
+ const e = err;
492
+ const detail = (e.stderr?.toString() ?? e.message ?? "").trim();
493
+ throw new Error(detail || `git ${args.join(" ")} failed`);
494
+ }
495
+ }
496
+ function ensureMirror(url, cacheDir) {
497
+ const mirror = gitMirrorDir(url, cacheDir);
498
+ if (existsSync3(join4(mirror, "HEAD"))) return mirror;
499
+ ensureCacheDir(gitRepoDir(url, cacheDir));
500
+ try {
501
+ git2(["clone", "--mirror", "--quiet", url, mirror]);
502
+ } catch (err) {
503
+ rmSync2(mirror, { recursive: true, force: true });
504
+ throw new GitFetchError(url, `clone failed \u2014 ${err.message}`);
505
+ }
506
+ return mirror;
507
+ }
508
+ function updateMirror(url, mirror) {
509
+ try {
510
+ git2(["--git-dir", mirror, "fetch", "--prune", "--quiet", "origin", "+refs/*:refs/*"]);
511
+ } catch (err) {
512
+ throw new GitFetchError(url, `fetch failed \u2014 ${err.message}`);
513
+ }
514
+ }
515
+ function hasCommit(mirror, committish) {
516
+ try {
517
+ git2(["--git-dir", mirror, "rev-parse", "--verify", "--quiet", `${committish}^{commit}`]);
518
+ return true;
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+ function resolveGitRevision(ref, cacheDir) {
524
+ const mirror = ensureMirror(ref.url, cacheDir);
525
+ if (!isCommitSha(ref.committish) || !hasCommit(mirror, ref.committish)) {
526
+ updateMirror(ref.url, mirror);
527
+ }
528
+ try {
529
+ return git2(["--git-dir", mirror, "rev-parse", `${ref.committish}^{commit}`]);
530
+ } catch {
531
+ throw new GitFetchError(
532
+ ref.url,
533
+ `no such revision "${ref.committish}". It may have been deleted, renamed, or never pushed.`
534
+ );
535
+ }
536
+ }
537
+ function liveGitRevision(ref) {
538
+ if (isCommitSha(ref.committish)) return ref.committish;
539
+ try {
540
+ const lines = git2(["ls-remote", ref.url, ref.committish, `${ref.committish}^{}`]).split("\n").filter((l) => l.trim() !== "");
541
+ const peeled = lines.find((l) => l.split(/\s+/)[1]?.endsWith("^{}"));
542
+ return (peeled ?? lines[0])?.split(/\s+/)[0];
543
+ } catch {
544
+ return void 0;
545
+ }
546
+ }
547
+ function materializeGit(ref, revision, cacheDir) {
548
+ const dest = gitRevisionDir(ref.url, revision, cacheDir);
549
+ const root = ref.subdir ? join4(dest, ref.subdir) : dest;
550
+ if (!existsSync3(dest)) {
551
+ const mirror = ensureMirror(ref.url, cacheDir);
552
+ if (!hasCommit(mirror, revision)) updateMirror(ref.url, mirror);
553
+ freshDir(dest);
554
+ const tar = join4(dest, ".treelay-archive.tar");
555
+ try {
556
+ git2(["--git-dir", mirror, "archive", "--format=tar", "-o", tar, revision]);
557
+ execFileSync("tar", ["-xf", tar, "-C", dest], { stdio: "ignore" });
558
+ } catch (err) {
559
+ rmSync2(dest, { recursive: true, force: true });
560
+ throw new GitFetchError(
561
+ ref.url,
562
+ `could not extract ${revision.slice(0, 12)} \u2014 ${err.message}`
563
+ );
564
+ } finally {
565
+ rmSync2(tar, { force: true });
566
+ }
567
+ }
568
+ if (!existsSync3(root)) {
569
+ throw new GitFetchError(
570
+ ref.url,
571
+ `subdirectory "${ref.subdir}" does not exist at ${revision.slice(0, 12)}`
572
+ );
573
+ }
574
+ return { dir: root, integrity: hashTree(root) };
575
+ }
576
+
577
+ // src/fetch/npm.ts
578
+ import { createRequire } from "module";
579
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
580
+ import { dirname, join as join5 } from "path";
581
+ import { satisfies, validRange } from "semver";
582
+ var NpmResolveError = class extends Error {
583
+ constructor(packageName, message) {
584
+ super(`npm layer ${packageName}: ${message}`);
585
+ this.packageName = packageName;
586
+ this.name = "NpmResolveError";
587
+ }
588
+ packageName;
589
+ };
590
+ function packageDir(ref, fromDir) {
591
+ for (let dir = fromDir; ; dir = dirname(dir)) {
592
+ const candidate = join5(dir, "node_modules", ref.name);
593
+ if (existsSync4(join5(candidate, "package.json"))) return candidate;
594
+ if (dirname(dir) === dir) break;
595
+ }
596
+ try {
597
+ const require3 = createRequire(join5(fromDir, "package.json"));
598
+ return dirname(require3.resolve(`${ref.name}/package.json`));
599
+ } catch {
600
+ throw new NpmResolveError(
601
+ ref.name,
602
+ `not installed under ${fromDir}. treelay resolves npm layers from node_modules rather than installing them \u2014 run your package manager first (e.g. \`npm install ${ref.name}\`).`
603
+ );
604
+ }
605
+ }
606
+ function liveNpmVersion(ref, fromDir) {
607
+ try {
608
+ const pkg = JSON.parse(
609
+ readFileSync3(join5(packageDir(ref, fromDir), "package.json"), "utf8")
610
+ );
611
+ return pkg.version;
612
+ } catch {
613
+ return void 0;
614
+ }
615
+ }
616
+ function materializeNpm(ref, fromDir) {
617
+ const pkgRoot = packageDir(ref, fromDir);
618
+ const pkg = JSON.parse(readFileSync3(join5(pkgRoot, "package.json"), "utf8"));
619
+ const version = pkg.version ?? "0.0.0";
620
+ if (ref.range !== "*" && validRange(ref.range) && !satisfies(version, ref.range)) {
621
+ throw new NpmResolveError(
622
+ ref.name,
623
+ `installed version ${version} does not satisfy "${ref.range}". Update the dependency, or relax the ref in treelay.json.`
624
+ );
625
+ }
626
+ const root = ref.subdir ? join5(pkgRoot, ref.subdir) : pkgRoot;
627
+ if (!existsSync4(root)) {
628
+ throw new NpmResolveError(
629
+ ref.name,
630
+ `subdirectory "${ref.subdir}" does not exist in ${pkgRoot}`
631
+ );
632
+ }
633
+ return { dir: root, revision: version, integrity: hashTree(root) };
634
+ }
635
+
636
+ // src/fetch/index.ts
637
+ import { existsSync as existsSync5 } from "fs";
638
+ var LockMissingError = class extends Error {
639
+ constructor(ref) {
640
+ super(
641
+ `${ref} is not in treelay.lock, and resolution is frozen. Run \`treelay lock\` and commit the result, or drop --frozen-lockfile.`
642
+ );
643
+ this.ref = ref;
644
+ this.name = "LockMissingError";
645
+ }
646
+ ref;
647
+ };
648
+ var IntegrityError = class extends Error {
649
+ constructor(ref, expected, actual) {
650
+ super(
651
+ `${ref}: cached content does not match treelay.lock.
652
+ expected ${expected}
653
+ actual ${actual}
654
+ The cache entry has been modified or corrupted. Delete it (or the whole cache) and re-resolve.`
655
+ );
656
+ this.name = "IntegrityError";
657
+ }
658
+ };
659
+ function fetchLayer(ref, options) {
660
+ const key = canonicalRef(ref);
661
+ const locked = options.updateRefs ? void 0 : options.lock.refs[key];
662
+ if (!locked && options.frozen) throw new LockMissingError(key);
663
+ const { dir, revision, integrity } = ref.kind === "git" ? fetchGit(ref, locked?.resolved, options) : materializeNpm(ref, options.fromDir);
664
+ if (locked && locked.resolved === revision && locked.integrity !== integrity) {
665
+ throw new IntegrityError(key, locked.integrity, integrity);
666
+ }
667
+ const entry = {
668
+ kind: ref.kind,
669
+ source: ref.kind === "git" ? ref.url : ref.name,
670
+ requested: ref.kind === "git" ? ref.committish : ref.range,
671
+ resolved: revision,
672
+ integrity,
673
+ ...ref.subdir !== void 0 ? { path: ref.subdir } : {}
674
+ };
675
+ const changed = locked === void 0 || locked.resolved !== entry.resolved || locked.integrity !== entry.integrity;
676
+ return { ref: key, dir, revision, integrity, entry, changed };
677
+ }
678
+ function fetchGit(ref, lockedRevision, options) {
679
+ const revision = lockedRevision ?? resolveGitRevision(ref, options.cacheDir);
680
+ const { dir, integrity } = materializeGit(ref, revision, options.cacheDir);
681
+ return { dir, revision, integrity };
682
+ }
683
+ function verifyIntegrity(dir, expected) {
684
+ return existsSync5(dir) && hashTree(dir) === expected;
685
+ }
686
+ function liveRevision(ref, fromDir) {
687
+ return ref.kind === "git" ? liveGitRevision(ref) : liveNpmVersion(ref, fromDir);
688
+ }
689
+
690
+ // src/lockfile.ts
691
+ import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync } from "fs";
692
+ import { join as join6 } from "path";
693
+ var LOCKFILE_NAME = "treelay.lock";
694
+ var LOCKFILE_VERSION = 1;
695
+ function emptyLock() {
696
+ return { lockfileVersion: LOCKFILE_VERSION, refs: {} };
697
+ }
698
+ function lockfilePath(leafDir) {
699
+ return join6(leafDir, LOCKFILE_NAME);
700
+ }
701
+ var LockfileError = class extends Error {
702
+ constructor(file, reason) {
703
+ super(`${file}: ${reason}`);
704
+ this.name = "LockfileError";
705
+ }
706
+ };
707
+ function readLock(leafDir) {
708
+ const file = lockfilePath(leafDir);
709
+ if (!existsSync6(file)) return emptyLock();
710
+ let parsed;
711
+ try {
712
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
713
+ } catch (err) {
714
+ throw new LockfileError(file, `not valid JSON (${err.message})`);
715
+ }
716
+ if (typeof parsed !== "object" || parsed === null) {
717
+ throw new LockfileError(file, "expected a JSON object");
718
+ }
719
+ const lock = parsed;
720
+ if (lock.lockfileVersion !== LOCKFILE_VERSION) {
721
+ throw new LockfileError(
722
+ file,
723
+ `lockfileVersion ${String(lock.lockfileVersion)} was written by a different version of treelay (this one writes ${LOCKFILE_VERSION}). Delete it and re-run \`treelay lock\` to regenerate.`
724
+ );
725
+ }
726
+ return { lockfileVersion: LOCKFILE_VERSION, refs: lock.refs ?? {} };
727
+ }
728
+ function serializeLock(lock) {
729
+ const refs = {};
730
+ for (const key of Object.keys(lock.refs).sort()) {
731
+ const e = lock.refs[key];
732
+ refs[key] = {
733
+ kind: e.kind,
734
+ source: e.source,
735
+ requested: e.requested,
736
+ resolved: e.resolved,
737
+ integrity: e.integrity,
738
+ ...e.path !== void 0 ? { path: e.path } : {},
739
+ ...e.requestedBy?.length ? { requestedBy: [...e.requestedBy].sort() } : {}
740
+ };
741
+ }
742
+ return JSON.stringify({ lockfileVersion: lock.lockfileVersion, refs }, null, 2) + "\n";
743
+ }
744
+ function writeLock(leafDir, lock) {
745
+ const file = lockfilePath(leafDir);
746
+ const text = serializeLock(lock);
747
+ if (existsSync6(file) && readFileSync4(file, "utf8") === text) return false;
748
+ writeFileSync(file, text);
749
+ return true;
750
+ }
751
+ function locksEqual(a, b) {
752
+ return serializeLock(a) === serializeLock(b);
753
+ }
754
+
755
+ // src/resolve.ts
756
+ import { isAbsolute as isAbsolute2, relative, resolve as resolvePath } from "path";
757
+ import { existsSync as existsSync7, statSync } from "fs";
758
+ var MountError = class extends Error {
759
+ constructor(mountPath, reason) {
760
+ super(`Invalid mount "${mountPath}": ${reason}`);
761
+ this.name = "MountError";
762
+ }
763
+ };
764
+ function resolve(srcDir, options = {}) {
765
+ const root = isAbsolute2(srcDir) ? srcDir : resolvePath(process.cwd(), srcDir);
766
+ const existing = options.noLock ? emptyLock() : readLock(root);
767
+ const ctx = {
768
+ root,
769
+ options,
770
+ cache: /* @__PURE__ */ new Map(),
771
+ existing,
772
+ next: emptyLock()
773
+ };
774
+ const self = loadLocal(ctx, root);
775
+ const parentsOf = (layer) => (layer.manifest.parents ?? []).map((ref) => loadRef(ctx, ref, layer));
776
+ const mro = c3Linearize(self, parentsOf, (l) => l.id);
777
+ const parentsLowToHigh = mro.slice(1).reverse();
778
+ const mixins = (self.manifest.mixins ?? []).map((ref) => loadRef(ctx, ref, self));
779
+ const declared = [...parentsLowToHigh, ...mixins, self];
780
+ const mounts = resolveMounts(ctx, declared);
781
+ const layers = [...mounts, ...declared];
782
+ const variables = mergeVariableDecls(layers);
783
+ return {
784
+ layers,
785
+ variables,
786
+ lock: ctx.next,
787
+ lockDirty: !options.noLock && !locksEqual(existing, ctx.next),
788
+ lockDir: root
789
+ };
790
+ }
791
+ function resolveMounts(ctx, declared) {
792
+ const winners = /* @__PURE__ */ new Map();
793
+ for (const layer of declared) {
794
+ for (const [path, ref] of Object.entries(layer.manifest.mounts ?? {})) {
795
+ winners.set(normalizeMount(path), { ref, from: layer });
796
+ }
797
+ }
798
+ if (!winners.size) return [];
799
+ const paths = [...winners.keys()].sort();
800
+ for (let i = 1; i < paths.length; i++) {
801
+ const prev = paths[i - 1];
802
+ if (paths[i].startsWith(prev + "/")) {
803
+ throw new MountError(
804
+ paths[i],
805
+ `it nests inside mount "${prev}". Overlapping mounts have no unambiguous owner \u2014 give them disjoint paths.`
806
+ );
807
+ }
808
+ }
809
+ return paths.map((path) => {
810
+ const { ref, from } = winners.get(path);
811
+ const base = loadRef(ctx, ref, from);
812
+ return {
813
+ ...base,
814
+ // A mount is a distinct stack position, so it needs a distinct identity:
815
+ // the same ref may legitimately be mounted at two paths.
816
+ id: `mount:${path}`,
817
+ mountPath: path,
818
+ // Vendored trees are never promotion targets. Reflux maps output paths
819
+ // back to layer paths, and a mounted layer's output paths carry a prefix
820
+ // its sources do not — so it is read-only until that mapping exists (§8).
821
+ writable: false
822
+ };
823
+ });
824
+ }
825
+ function normalizeMount(path) {
826
+ const clean = path.split("\\").join("/").replace(/^\.\//, "").replace(/\/+$/, "");
827
+ if (clean === "" || clean === ".") {
828
+ throw new MountError(path, "a mount needs a non-empty subpath");
829
+ }
830
+ if (isAbsolute2(clean) || clean.startsWith("/")) {
831
+ throw new MountError(path, "mount paths are relative to the composed tree root");
832
+ }
833
+ if (clean.split("/").includes("..")) {
834
+ throw new MountError(path, "mount paths may not escape the composed tree");
835
+ }
836
+ return clean;
837
+ }
838
+ function loadLocal(ctx, dir) {
839
+ const cached = ctx.cache.get(dir);
840
+ if (cached) return cached;
841
+ const layer = {
842
+ id: dir,
843
+ dir,
844
+ manifest: loadManifest(dir),
845
+ writable: isWritable(dir),
846
+ origin: { kind: "local" }
847
+ };
848
+ ctx.cache.set(dir, layer);
849
+ return layer;
850
+ }
851
+ function loadRef(ctx, ref, from) {
852
+ const parsed = parseRef(ref);
853
+ if (parsed.kind === "local") return loadLocal(ctx, resolveLocalRef(ref, from.dir));
854
+ const key = canonicalRef(parsed);
855
+ const cached = ctx.cache.get(key);
856
+ if (cached) {
857
+ recordRequester(ctx, key, from);
858
+ return cached;
859
+ }
860
+ const fetched = fetchLayer(parsed, {
861
+ fromDir: from.dir,
862
+ lock: ctx.existing,
863
+ ...ctx.options.frozen ? { frozen: true } : {},
864
+ ...ctx.options.updateRefs ? { updateRefs: true } : {},
865
+ ...ctx.options.cacheDir ? { cacheDir: ctx.options.cacheDir } : {}
866
+ });
867
+ ctx.next.refs[key] = fetched.entry;
868
+ recordRequester(ctx, key, from);
869
+ const layer = {
870
+ id: key,
871
+ dir: fetched.dir,
872
+ manifest: loadManifest(fetched.dir),
873
+ // A cache checkout is shared by every project on the machine and is keyed
874
+ // by an immutable revision. Editing it would silently change other builds,
875
+ // so fetched layers are never promotion targets (§8).
876
+ writable: false,
877
+ origin: {
878
+ kind: parsed.kind,
879
+ ref: key,
880
+ revision: fetched.revision,
881
+ integrity: fetched.integrity
882
+ }
883
+ };
884
+ ctx.cache.set(key, layer);
885
+ return layer;
886
+ }
887
+ function recordRequester(ctx, key, from) {
888
+ const entry = ctx.next.refs[key];
889
+ if (!entry) return;
890
+ const who = from.origin?.kind === "local" || from.origin === void 0 ? relative(ctx.root, from.dir).split("\\").join("/") || "." : from.id;
891
+ const list = new Set(entry.requestedBy ?? []);
892
+ list.add(who);
893
+ entry.requestedBy = [...list].sort();
894
+ }
895
+ function resolveLocalRef(ref, fromDir) {
896
+ const parsed = parseRef(ref);
897
+ const dir = resolvePath(fromDir, parsed.kind === "local" ? parsed.path : ref);
898
+ if (!existsSync7(dir) || !statSync(dir).isDirectory()) {
899
+ throw new Error(`Layer not found: "${ref}" (resolved to ${dir})`);
900
+ }
901
+ return dir;
902
+ }
903
+ function resolveRef(ref, fromDir, options = {}) {
904
+ const parsed = parseRef(ref);
905
+ if (parsed.kind === "local") return resolveLocalRef(ref, fromDir);
906
+ return fetchLayer(parsed, {
907
+ fromDir,
908
+ lock: options.lock ?? emptyLock(),
909
+ ...options.frozen ? { frozen: true } : {},
910
+ ...options.updateRefs ? { updateRefs: true } : {},
911
+ ...options.cacheDir ? { cacheDir: options.cacheDir } : {}
912
+ }).dir;
913
+ }
914
+
915
+ // src/merge/patch.ts
916
+ import { applyPatch, parsePatch } from "diff";
917
+ import { merge as diff3, mergeDiff3 } from "node-diff3";
918
+ var MERGE_LABELS = {
919
+ ours: "ours (your edits)",
920
+ base: "base (last template output)",
921
+ theirs: "theirs (new template)"
922
+ };
923
+ function mergeText3(args) {
924
+ const { base, ours, theirs, labels = MERGE_LABELS } = args;
925
+ if (ours === theirs) return { clean: true, text: ours };
926
+ if (ours === base) return { clean: true, text: theirs };
927
+ if (theirs === base) return { clean: true, text: ours };
928
+ const merged = mergeDiff3(toLines(ours), toLines(base), toLines(theirs), {
929
+ label: { a: labels.ours, o: labels.base, b: labels.theirs }
930
+ });
931
+ return {
932
+ clean: !merged.conflict,
933
+ text: fromLines(merged.result)
934
+ };
935
+ }
936
+ function toLines(text) {
937
+ return text.split("\n");
938
+ }
939
+ function fromLines(lines) {
940
+ return lines.join("\n");
941
+ }
942
+ function assertParseable(file, patch) {
943
+ let hunks = 0;
944
+ try {
945
+ hunks = parsePatch(patch).reduce((n, p) => n + p.hunks.length, 0);
946
+ } catch (err) {
947
+ const message = err instanceof Error ? err.message : String(err);
948
+ const hint = /invalid line/i.test(message) ? "\nCheck the `@@ -old,COUNT +new,COUNT @@` header: the counts must match the number of lines in the hunk body (context + removals for the first, context + additions for the second)." : "";
949
+ throw new MergeConflictError(
950
+ file,
951
+ `patch payload is not a valid unified diff \u2014 ${message}${hint}`
952
+ );
953
+ }
954
+ if (hunks === 0) {
955
+ throw new MergeConflictError(
956
+ file,
957
+ "patch payload contains no hunks (expected unified-diff `@@` sections)"
958
+ );
959
+ }
960
+ }
961
+ function preview(text, maxLines = 20) {
962
+ const lines = toLines(text);
963
+ return lines.length <= maxLines ? text : fromLines(lines.slice(0, maxLines)) + `
964
+ \u2026 (${lines.length - maxLines} more lines)`;
965
+ }
966
+ function applyPatch3Way(args) {
967
+ const { file, current, patch, base } = args;
968
+ assertParseable(file, patch);
969
+ if (base === void 0) {
970
+ const applied = applyPatch(current, patch);
971
+ if (applied === false) {
972
+ throw new MergeConflictError(
973
+ file,
974
+ `patch does not apply to the inherited content and no base was recorded, so it cannot be reconciled.
975
+ Record a \`base:\`/\`baseContent:\` in the sidecar to enable a three-way merge.
976
+ --- patch ---
977
+ ${preview(patch)}`
978
+ );
979
+ }
980
+ return applied;
981
+ }
982
+ const patched = applyPatch(base, patch);
983
+ if (patched === false) {
984
+ throw new MergeConflictError(
985
+ file,
986
+ `patch does not apply to its own recorded base \u2014 the sidecar is inconsistent (\`base\`/\`baseContent\` does not match the patch payload).
987
+ --- patch ---
988
+ ${preview(patch)}`
989
+ );
990
+ }
991
+ if (current === base) return patched;
992
+ const merged = diff3(toLines(current), toLines(base), toLines(patched));
993
+ if (merged.conflict) {
994
+ throw new MergeConflictError(
995
+ file,
996
+ `three-way merge conflict \u2014 the inherited file changed in the same region the patch edits.
997
+ Re-author the patch against the current content, or resolve by hand.
998
+ --- conflict ---
999
+ ${preview(fromLines(merged.result))}`
1000
+ );
1001
+ }
1002
+ return fromLines(merged.result);
1003
+ }
1004
+
1005
+ // src/sidecar.ts
1006
+ import { parse as parseYaml3 } from "yaml";
1007
+ var SIDECAR_SUFFIX = ".treelay";
1008
+ function isSidecar(path) {
1009
+ return path.endsWith(SIDECAR_SUFFIX) && path !== SIDECAR_SUFFIX;
1010
+ }
1011
+ function sidecarTarget(path) {
1012
+ return path.slice(0, -SIDECAR_SUFFIX.length);
1013
+ }
1014
+ function parseSidecar(yaml) {
1015
+ const op = parseYaml3(yaml);
1016
+ if (!op || typeof op.op !== "string") {
1017
+ throw new Error(`Invalid .treelay sidecar: missing "op"`);
1018
+ }
1019
+ return op;
1020
+ }
1021
+ var SUFFIX_OPS = {
1022
+ ".patch": "patch",
1023
+ ".append": "append",
1024
+ ".prepend": "prepend",
1025
+ ".delete": "delete"
1026
+ };
1027
+ function desugarSuffix(path) {
1028
+ for (const [suffix, op] of Object.entries(SUFFIX_OPS)) {
1029
+ if (path.endsWith(suffix)) {
1030
+ return { op, target: path.slice(0, -suffix.length) };
1031
+ }
1032
+ }
1033
+ return void 0;
1034
+ }
1035
+
1036
+ // src/merge/deepMerge.ts
1037
+ function isObject(v) {
1038
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1039
+ }
1040
+ function deepMerge(base, over, arrays = "replace") {
1041
+ if (Array.isArray(base) && Array.isArray(over)) {
1042
+ switch (arrays) {
1043
+ case "concat":
1044
+ return [...base, ...over];
1045
+ case "replace":
1046
+ return over;
1047
+ case "by-key":
1048
+ return over;
1049
+ }
1050
+ }
1051
+ if (isObject(base) && isObject(over)) {
1052
+ const result = { ...base };
1053
+ for (const [k, v] of Object.entries(over)) {
1054
+ result[k] = k in base ? deepMerge(base[k], v, arrays) : v;
1055
+ }
1056
+ return result;
1057
+ }
1058
+ return over;
1059
+ }
1060
+
1061
+ // src/merge/structured.ts
1062
+ import { createRequire as createRequire2 } from "module";
1063
+ var require2 = createRequire2(import.meta.url);
1064
+ var { applyPatch: applyJsonPatchLib } = require2("fast-json-patch");
1065
+ var jsonMergePatch = require2("json-merge-patch");
1066
+ function applyMergePatch(target, patch) {
1067
+ const clone = target === void 0 ? void 0 : structuredClone(target);
1068
+ return jsonMergePatch.apply(clone, patch);
1069
+ }
1070
+ function generateMergePatch(before, after) {
1071
+ return jsonMergePatch.generate(before, after);
1072
+ }
1073
+ function applyJsonPatch(target, ops) {
1074
+ const { newDocument } = applyJsonPatchLib(
1075
+ structuredClone(target),
1076
+ ops,
1077
+ /* validateOperation */
1078
+ true,
1079
+ /* mutateDocument */
1080
+ false
1081
+ );
1082
+ return newDocument;
1083
+ }
1084
+ function isObject2(v) {
1085
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1086
+ }
1087
+ function patchesConflict(a, b) {
1088
+ if (isObject2(a) && isObject2(b)) {
1089
+ for (const key of Object.keys(a)) {
1090
+ if (key in b && patchesConflict(a[key], b[key])) return true;
1091
+ }
1092
+ return false;
1093
+ }
1094
+ return JSON.stringify(a) !== JSON.stringify(b);
1095
+ }
1096
+ function mergeStructured3(base, ours, theirs) {
1097
+ const ourPatch = generateMergePatch(base, ours);
1098
+ const theirPatch = generateMergePatch(base, theirs);
1099
+ if (ourPatch === void 0) return { clean: true, value: theirs };
1100
+ if (theirPatch === void 0) return { clean: true, value: ours };
1101
+ if (patchesConflict(ourPatch, theirPatch)) return { clean: false, value: void 0 };
1102
+ return {
1103
+ clean: true,
1104
+ value: applyMergePatch(applyMergePatch(base, theirPatch), ourPatch)
1105
+ };
1106
+ }
1107
+
1108
+ // src/merge/index.ts
1109
+ import picomatch from "picomatch";
1110
+ var STRUCTURED = /\.(json|ya?ml|toml)$/i;
1111
+ var BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|woff2?|ttf|zip|gz)$/i;
1112
+ function defaultStrategy(path) {
1113
+ if (STRUCTURED.test(path)) return "deep-merge";
1114
+ if (BINARY.test(path)) return "replace";
1115
+ return "replace";
1116
+ }
1117
+ function strategyFor(path, globs = {}) {
1118
+ for (const [glob, strategy] of Object.entries(globs)) {
1119
+ if (picomatch.isMatch(path, glob)) return strategy;
1120
+ }
1121
+ return defaultStrategy(path);
1122
+ }
1123
+
1124
+ // src/layer-files.ts
1125
+ import { relative as relative2, resolve as resolvePath2 } from "path";
1126
+ import fg from "fast-glob";
1127
+ var ALWAYS_IGNORE = [
1128
+ "**/node_modules/**",
1129
+ "**/.git/**",
1130
+ "**/.git",
1131
+ ".treelay/**",
1132
+ "**/.treelay/**",
1133
+ "**/treelay.json",
1134
+ "**/treelay.yaml",
1135
+ "**/treelay.yml",
1136
+ // The lockfile is manifest metadata, not content. Composing it would publish
1137
+ // a layer's pins into every tree built from it — and, worse, make the leaf's
1138
+ // own lockfile a *generated* file that `update` then reports as a change.
1139
+ "**/treelay.lock"
1140
+ ];
1141
+ var SelfCompileError = class extends Error {
1142
+ constructor(dir) {
1143
+ super(
1144
+ `Destination is a layer root: ${dir}
1145
+ Compiling a layer into itself would overwrite the sources being read. Choose a destination outside the layer, or a subdirectory of it (e.g. ${dir}/build).`
1146
+ );
1147
+ this.name = "SelfCompileError";
1148
+ }
1149
+ };
1150
+ function destExclusions(layerDir, destDir) {
1151
+ if (!destDir) return [];
1152
+ const layerAbs = resolvePath2(layerDir);
1153
+ const destAbs = resolvePath2(destDir);
1154
+ if (destAbs === layerAbs) throw new SelfCompileError(layerAbs);
1155
+ const rel = relative2(layerAbs, destAbs);
1156
+ if (rel === "" || rel.startsWith("..") || resolvePath2(layerAbs, rel) !== destAbs) {
1157
+ return [];
1158
+ }
1159
+ const posix = rel.split("\\").join("/");
1160
+ return [posix, `${posix}/**`];
1161
+ }
1162
+ function mountTarget(layer, target) {
1163
+ return layer.mountPath ? `${layer.mountPath}/${target}` : target;
1164
+ }
1165
+ function listLayerFiles(layer, destDir) {
1166
+ return fg.sync("**/*", {
1167
+ cwd: layer.dir,
1168
+ dot: true,
1169
+ onlyFiles: true,
1170
+ ignore: [
1171
+ ...ALWAYS_IGNORE,
1172
+ ...layer.manifest.ignore ?? [],
1173
+ ...destExclusions(layer.dir, destDir)
1174
+ ]
1175
+ }).sort();
1176
+ }
1177
+ function classifyEntry(source, templateSuffix) {
1178
+ const { render: isTemplate, outPath: inner } = templateTarget(source, templateSuffix);
1179
+ if (isSidecar(inner)) {
1180
+ return {
1181
+ source,
1182
+ inner,
1183
+ isTemplate,
1184
+ kind: "sidecar",
1185
+ rawTarget: sidecarTarget(inner)
1186
+ };
1187
+ }
1188
+ const sugar = desugarSuffix(inner);
1189
+ if (sugar) {
1190
+ return {
1191
+ source,
1192
+ inner,
1193
+ isTemplate,
1194
+ kind: "suffix",
1195
+ op: sugar.op,
1196
+ rawTarget: sugar.target
1197
+ };
1198
+ }
1199
+ return { source, inner, isTemplate, kind: "file", rawTarget: inner };
1200
+ }
1201
+ function enumerateLayer(layer, destDir) {
1202
+ const suffix = layer.manifest.templateSuffix ?? DEFAULT_TEMPLATE_SUFFIX;
1203
+ return listLayerFiles(layer, destDir).map((rel) => classifyEntry(rel, suffix));
1204
+ }
1205
+
1206
+ // src/state.ts
1207
+ import {
1208
+ mkdirSync as mkdirSync2,
1209
+ readFileSync as readFileSync5,
1210
+ writeFileSync as writeFileSync2,
1211
+ existsSync as existsSync8,
1212
+ rmSync as rmSync3
1213
+ } from "fs";
1214
+ import { dirname as dirname2, join as join7 } from "path";
1215
+ var STATE_DIR = ".treelay";
1216
+ var statePaths = (destDir) => ({
1217
+ dir: join7(destDir, STATE_DIR),
1218
+ lock: join7(destDir, STATE_DIR, "lock.json"),
1219
+ answers: join7(destDir, STATE_DIR, "answers.json"),
1220
+ baseline: join7(destDir, STATE_DIR, "baseline.json"),
1221
+ /** Content snapshot of the last template output — the diff3 merge base (§7). */
1222
+ baselineDir: join7(destDir, STATE_DIR, "baseline"),
1223
+ manifest: join7(destDir, STATE_DIR, "manifest.json")
1224
+ });
1225
+ function sourceOf(state) {
1226
+ return state.lock.lineage[state.lock.lineage.length - 1];
1227
+ }
1228
+ function hasState(destDir) {
1229
+ return existsSync8(statePaths(destDir).lock);
1230
+ }
1231
+ function stripSecrets(values, decls) {
1232
+ const out = {};
1233
+ for (const [k, v] of Object.entries(values)) {
1234
+ if (decls[k]?.secret) continue;
1235
+ out[k] = v;
1236
+ }
1237
+ return out;
1238
+ }
1239
+ function writeState(destDir, state, decls, snapshot) {
1240
+ const paths = statePaths(destDir);
1241
+ mkdirSync2(paths.dir, { recursive: true });
1242
+ const json = (v) => JSON.stringify(v, null, 2) + "\n";
1243
+ writeFileSync2(paths.lock, json(state.lock));
1244
+ writeFileSync2(paths.answers, json(stripSecrets(state.answers, decls)));
1245
+ writeFileSync2(paths.baseline, json(state.baseline));
1246
+ writeFileSync2(paths.manifest, json(state.manifest));
1247
+ if (snapshot) {
1248
+ rmSync3(paths.baselineDir, { recursive: true, force: true });
1249
+ for (const [rel, data] of snapshot) {
1250
+ const abs = join7(paths.baselineDir, rel);
1251
+ ensureDir(abs);
1252
+ writeFileSync2(abs, data);
1253
+ }
1254
+ }
1255
+ }
1256
+ function readBaselineFile(destDir, rel, expectedHash) {
1257
+ const abs = join7(statePaths(destDir).baselineDir, rel);
1258
+ if (!existsSync8(abs)) return void 0;
1259
+ const data = readFileSync5(abs);
1260
+ if (expectedHash !== void 0 && hashContent(data) !== expectedHash) {
1261
+ return void 0;
1262
+ }
1263
+ return data;
1264
+ }
1265
+ function readState(destDir) {
1266
+ const paths = statePaths(destDir);
1267
+ const read = (p) => JSON.parse(readFileSync5(p, "utf8"));
1268
+ return {
1269
+ lock: read(paths.lock),
1270
+ answers: read(paths.answers),
1271
+ baseline: read(paths.baseline),
1272
+ manifest: read(paths.manifest)
1273
+ };
1274
+ }
1275
+ function lockFromGraph(graph) {
1276
+ const versions = {};
1277
+ for (const ref of Object.keys(graph.lock?.refs ?? {}).sort()) {
1278
+ versions[ref] = graph.lock.refs[ref].resolved;
1279
+ }
1280
+ return {
1281
+ lineage: graph.layers.map((l) => l.id),
1282
+ versions
1283
+ };
1284
+ }
1285
+ function ensureDir(filePath) {
1286
+ mkdirSync2(dirname2(filePath), { recursive: true });
1287
+ }
1288
+
1289
+ // src/compile.ts
1290
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1291
+ import { join as join8 } from "path";
1292
+ import fg2 from "fast-glob";
1293
+
1294
+ // src/serde.ts
1295
+ import { parse as parseYaml4, stringify as stringifyYaml } from "yaml";
1296
+ function structuredFormat(path) {
1297
+ if (/\.json$/i.test(path)) return "json";
1298
+ if (/\.ya?ml$/i.test(path)) return "yaml";
1299
+ return void 0;
1300
+ }
1301
+ function parseStructured(path, text) {
1302
+ const fmt = structuredFormat(path);
1303
+ if (fmt === "json") return text.trim() === "" ? {} : JSON.parse(text);
1304
+ if (fmt === "yaml") return parseYaml4(text) ?? {};
1305
+ throw new Error(`Not a structured file: ${path}`);
1306
+ }
1307
+ function stringifyStructured(path, data) {
1308
+ const fmt = structuredFormat(path);
1309
+ if (fmt === "json") return JSON.stringify(data, null, 2) + "\n";
1310
+ if (fmt === "yaml") return stringifyYaml(data);
1311
+ throw new Error(`Not a structured file: ${path}`);
1312
+ }
1313
+
1314
+ // src/compile.ts
1315
+ function needsRender(s) {
1316
+ return s.includes("{{") || s.includes("{%");
1317
+ }
1318
+ async function composeFiles(graph, values = {}, destDir) {
1319
+ const acc = /* @__PURE__ */ new Map();
1320
+ for (const layer of graph.layers) {
1321
+ await applyLayer(layer, acc, values, destDir);
1322
+ }
1323
+ return acc;
1324
+ }
1325
+ function summarize(acc) {
1326
+ const result = { files: {} };
1327
+ const baseline = {};
1328
+ const manifest = {};
1329
+ for (const [rel, entry] of acc) {
1330
+ result.files[rel] = {
1331
+ fromLayer: entry.fromLayer,
1332
+ strategy: entry.strategy,
1333
+ owned: false,
1334
+ ...entry.patchedFrom.length ? { patchedFrom: entry.patchedFrom } : {}
1335
+ };
1336
+ baseline[rel] = hashContent(entry.data);
1337
+ manifest[rel] = { owned: false, fromLayer: entry.fromLayer };
1338
+ }
1339
+ return { result, baseline, manifest };
1340
+ }
1341
+ async function compile(graph, options) {
1342
+ const values = options.values ?? {};
1343
+ const acc = await composeFiles(graph, values, options.destDir);
1344
+ const { result, baseline, manifest } = summarize(acc);
1345
+ const snapshot = /* @__PURE__ */ new Map();
1346
+ for (const [rel, entry] of acc) {
1347
+ const abs = join8(options.destDir, rel);
1348
+ ensureDir(abs);
1349
+ writeFileSync3(abs, entry.data);
1350
+ snapshot.set(rel, entry.data);
1351
+ }
1352
+ const state = {
1353
+ lock: lockFromGraph(graph),
1354
+ answers: values,
1355
+ baseline,
1356
+ manifest
1357
+ };
1358
+ writeState(options.destDir, state, graph.variables, snapshot);
1359
+ if (options.writeLockfile !== false && graph.lockDirty && graph.lockDir && graph.lock) {
1360
+ writeLock(graph.lockDir, graph.lock);
1361
+ }
1362
+ return result;
1363
+ }
1364
+ async function applyLayer(layer, acc, values, destDir) {
1365
+ const suffix = layer.manifest.templateSuffix ?? DEFAULT_TEMPLATE_SUFFIX;
1366
+ const renderAllText = layer.manifest.render === "all-text";
1367
+ const arrays = layer.manifest.arrays ?? "replace";
1368
+ const entries = fg2.sync("**/*", {
1369
+ cwd: layer.dir,
1370
+ dot: true,
1371
+ onlyFiles: true,
1372
+ // A destination nested inside this layer is pruned so a recompile never
1373
+ // folds its own prior output back in (§7).
1374
+ ignore: [
1375
+ ...ALWAYS_IGNORE,
1376
+ ...layer.manifest.ignore ?? [],
1377
+ ...destExclusions(layer.dir, destDir)
1378
+ ]
1379
+ });
1380
+ const ops = [];
1381
+ for (const rel of entries.sort()) {
1382
+ const raw = readFileSync6(join8(layer.dir, rel));
1383
+ const { render: isTmpl, outPath: inner } = templateTarget(rel, suffix);
1384
+ if (isSidecar(inner)) {
1385
+ const op = sidecarToOp(raw.toString("utf8"), inner, isTmpl, values);
1386
+ op.target = mountTarget(layer, op.target);
1387
+ ops.push(op);
1388
+ continue;
1389
+ }
1390
+ const sugar = desugarSuffix(inner);
1391
+ if (sugar) {
1392
+ ops.push({
1393
+ kind: sugar.op,
1394
+ target: mountTarget(layer, await renderPath(sugar.target, values)),
1395
+ render: isTmpl,
1396
+ content: raw.toString("utf8")
1397
+ });
1398
+ continue;
1399
+ }
1400
+ const rendered = await renderPath(inner, values);
1401
+ if (rendered.trim() === "") continue;
1402
+ const target = mountTarget(layer, rendered);
1403
+ const doRenderContent = isTmpl || renderAllText && looksTextual(inner);
1404
+ const data = doRenderContent ? Buffer.from(await renderString(raw.toString("utf8"), values), "utf8") : raw;
1405
+ mergeFile(acc, target, data, layer, arrays);
1406
+ }
1407
+ for (const op of ops) await applyOp(acc, op, values);
1408
+ }
1409
+ async function renderPath(path, values) {
1410
+ return needsRender(path) ? renderString(path, values) : path;
1411
+ }
1412
+ function sidecarToOp(yamlText, innerPath, isTmpl, _values) {
1413
+ const sc = parseSidecar(yamlText);
1414
+ const op = {
1415
+ kind: sc.op,
1416
+ target: sidecarTarget(innerPath),
1417
+ render: sc.render ?? false
1418
+ };
1419
+ if (sc.when !== void 0) op.when = sc.when;
1420
+ if (sc.base !== void 0) op.base = sc.base;
1421
+ if (sc.baseContent !== void 0) op.baseContent = sc.baseContent;
1422
+ if (sc.content !== void 0) op.content = sc.content;
1423
+ if (sc.merge !== void 0) op.merge = sc.merge;
1424
+ if (sc.jsonPatch !== void 0) op.jsonPatch = sc.jsonPatch;
1425
+ if (sc.patch !== void 0) op.content = sc.patch;
1426
+ void isTmpl;
1427
+ return op;
1428
+ }
1429
+ function mergeFile(acc, target, data, layer, arrays) {
1430
+ const strategy = strategyFor(target, layer.manifest.merge);
1431
+ const existing = acc.get(target);
1432
+ if (!existing) {
1433
+ acc.set(target, { data, strategy, fromLayer: layer.id, patchedFrom: [] });
1434
+ return;
1435
+ }
1436
+ switch (strategy) {
1437
+ case "replace":
1438
+ existing.data = data;
1439
+ existing.fromLayer = layer.id;
1440
+ existing.strategy = strategy;
1441
+ break;
1442
+ case "deep-merge": {
1443
+ const merged = deepMerge(
1444
+ parseStructured(target, existing.data.toString("utf8")),
1445
+ parseStructured(target, data.toString("utf8")),
1446
+ arrays ?? "replace"
1447
+ );
1448
+ existing.data = Buffer.from(stringifyStructured(target, merged), "utf8");
1449
+ existing.patchedFrom.push(existing.fromLayer);
1450
+ existing.fromLayer = layer.id;
1451
+ existing.strategy = strategy;
1452
+ break;
1453
+ }
1454
+ case "append":
1455
+ existing.data = Buffer.concat([existing.data, data]);
1456
+ existing.patchedFrom.push(layer.id);
1457
+ break;
1458
+ case "prepend":
1459
+ existing.data = Buffer.concat([data, existing.data]);
1460
+ existing.patchedFrom.push(layer.id);
1461
+ break;
1462
+ case "delete":
1463
+ acc.delete(target);
1464
+ break;
1465
+ case "patch":
1466
+ existing.data = Buffer.from(
1467
+ applyPatch3Way({
1468
+ file: target,
1469
+ current: existing.data.toString("utf8"),
1470
+ patch: data.toString("utf8")
1471
+ }),
1472
+ "utf8"
1473
+ );
1474
+ existing.strategy = "patch";
1475
+ existing.patchedFrom.push(layer.id);
1476
+ break;
1477
+ }
1478
+ }
1479
+ async function applyOp(acc, op, values) {
1480
+ if (op.when !== void 0) {
1481
+ const w = (await renderString(op.when, values)).trim();
1482
+ if (w === "" || w === "false") return;
1483
+ }
1484
+ const existing = acc.get(op.target);
1485
+ const content = op.content !== void 0 && op.render ? await renderString(op.content, values) : op.content;
1486
+ switch (op.kind) {
1487
+ case "delete":
1488
+ acc.delete(op.target);
1489
+ return;
1490
+ case "replace": {
1491
+ const data = Buffer.from(content ?? "", "utf8");
1492
+ if (existing) {
1493
+ existing.data = data;
1494
+ existing.patchedFrom.push("sidecar");
1495
+ } else {
1496
+ acc.set(op.target, {
1497
+ data,
1498
+ strategy: "replace",
1499
+ fromLayer: "sidecar",
1500
+ patchedFrom: []
1501
+ });
1502
+ }
1503
+ return;
1504
+ }
1505
+ case "append":
1506
+ case "prepend": {
1507
+ const add = Buffer.from(content ?? "", "utf8");
1508
+ const base = existing?.data ?? Buffer.alloc(0);
1509
+ const data = op.kind === "append" ? Buffer.concat([base, add]) : Buffer.concat([add, base]);
1510
+ if (existing) {
1511
+ existing.data = data;
1512
+ existing.patchedFrom.push("sidecar");
1513
+ } else {
1514
+ acc.set(op.target, {
1515
+ data,
1516
+ strategy: op.kind,
1517
+ fromLayer: "sidecar",
1518
+ patchedFrom: []
1519
+ });
1520
+ }
1521
+ return;
1522
+ }
1523
+ case "merge": {
1524
+ const baseData = existing ? parseStructured(op.target, existing.data.toString("utf8")) : {};
1525
+ const result = op.jsonPatch ? applyJsonPatch(baseData, op.jsonPatch) : applyMergePatch(baseData, op.merge ?? {});
1526
+ setStructured(acc, op, existing, result);
1527
+ return;
1528
+ }
1529
+ case "patch": {
1530
+ if (!existing) {
1531
+ throw new MergeConflictError(
1532
+ op.target,
1533
+ "patch has nothing to apply to \u2014 the file is not produced by any lower layer (never created, or removed by a tombstone). Ship the full file instead of a patch, or drop the tombstone."
1534
+ );
1535
+ }
1536
+ const current = existing.data.toString("utf8");
1537
+ existing.data = Buffer.from(
1538
+ applyPatch3Way({
1539
+ file: op.target,
1540
+ current,
1541
+ patch: content ?? "",
1542
+ ...resolvePatchBase(op, current)
1543
+ }),
1544
+ "utf8"
1545
+ );
1546
+ existing.strategy = "patch";
1547
+ existing.patchedFrom.push("sidecar");
1548
+ return;
1549
+ }
1550
+ }
1551
+ }
1552
+ function resolvePatchBase(op, current) {
1553
+ if (op.baseContent !== void 0) {
1554
+ if (op.base !== void 0 && hashContent(op.baseContent) !== op.base.trim()) {
1555
+ throw new MergeConflictError(
1556
+ op.target,
1557
+ `sidecar is inconsistent: \`baseContent\` hashes to ${hashContent(op.baseContent)} but \`base\` records ${op.base}.`
1558
+ );
1559
+ }
1560
+ return { base: op.baseContent };
1561
+ }
1562
+ if (op.base !== void 0 && hashContent(current) === op.base.trim()) {
1563
+ return { base: current };
1564
+ }
1565
+ return {};
1566
+ }
1567
+ function setStructured(acc, op, existing, value) {
1568
+ const data = Buffer.from(stringifyStructured(op.target, value), "utf8");
1569
+ if (existing) {
1570
+ existing.data = data;
1571
+ existing.patchedFrom.push("sidecar");
1572
+ } else {
1573
+ acc.set(op.target, {
1574
+ data,
1575
+ strategy: "deep-merge",
1576
+ fromLayer: "sidecar",
1577
+ patchedFrom: []
1578
+ });
1579
+ }
1580
+ }
1581
+ function looksTextual(path) {
1582
+ return !/\.(png|jpe?g|gif|webp|ico|pdf|woff2?|ttf|zip|gz|tar|exe|bin)$/i.test(
1583
+ path
1584
+ );
1585
+ }
1586
+
1587
+ // src/drift.ts
1588
+ function hasDrift(reports) {
1589
+ return reports.some((r) => r.status === "moved");
1590
+ }
1591
+ function checkDrift(graph, fromDir) {
1592
+ const lock = graph.lock;
1593
+ if (!lock) return [];
1594
+ const anchor = fromDir ?? graph.lockDir ?? process.cwd();
1595
+ const reports = [];
1596
+ for (const key of Object.keys(lock.refs).sort()) {
1597
+ const entry = lock.refs[key];
1598
+ const layers = graph.layers.filter((l) => l.origin?.ref === key).map((l) => l.manifest.name ?? l.id);
1599
+ const parsed = parseRef(key);
1600
+ const immutable = parsed.kind === "git" && parsed.committish === entry.resolved || parsed.kind === "npm" && parsed.range === entry.resolved;
1601
+ const current = immutable ? entry.resolved : liveRevision(parsed, anchor);
1602
+ const status2 = immutable ? "immutable" : current === void 0 ? "unknown" : current === entry.resolved ? "in-sync" : "moved";
1603
+ reports.push({
1604
+ ref: key,
1605
+ requested: entry.requested,
1606
+ locked: entry.resolved,
1607
+ ...current !== void 0 ? { current } : {},
1608
+ status: status2,
1609
+ layers
1610
+ });
1611
+ }
1612
+ return reports;
1613
+ }
1614
+ function short(rev) {
1615
+ return /^[0-9a-f]{40}$/i.test(rev) ? rev.slice(0, 12) : rev;
1616
+ }
1617
+ function formatDrift(reports) {
1618
+ const moved = reports.filter((r) => r.status === "moved");
1619
+ if (!moved.length) return "";
1620
+ const lines = moved.map((r) => {
1621
+ const who = r.layers.length ? ` (${r.layers.join(", ")})` : "";
1622
+ return ` ${r.ref}
1623
+ pinned ${short(r.locked)}
1624
+ ${r.requested} is now ${short(r.current)}${who}`;
1625
+ });
1626
+ return `${moved.length} ref(s) have moved upstream since treelay.lock was written:
1627
+ ` + lines.join("\n") + `
1628
+ This build used the pinned revisions. Run \`treelay lock --update\` to advance them.`;
1629
+ }
1630
+
1631
+ // src/update.ts
1632
+ import {
1633
+ existsSync as existsSync9,
1634
+ readFileSync as readFileSync7,
1635
+ rmSync as rmSync4,
1636
+ writeFileSync as writeFileSync4,
1637
+ statSync as statSync2
1638
+ } from "fs";
1639
+ import { join as join9 } from "path";
1640
+ function isBinary(data) {
1641
+ return data.includes(0);
1642
+ }
1643
+ function mergeOne(path, base, ours, theirs) {
1644
+ const text = mergeText3({ base, ours, theirs });
1645
+ if (text.clean) return text;
1646
+ if (structuredFormat(path)) {
1647
+ try {
1648
+ const merged = mergeStructured3(
1649
+ parseStructured(path, base),
1650
+ parseStructured(path, ours),
1651
+ parseStructured(path, theirs)
1652
+ );
1653
+ if (merged.clean) {
1654
+ return { clean: true, text: stringifyStructured(path, merged.value) };
1655
+ }
1656
+ } catch {
1657
+ }
1658
+ }
1659
+ return text;
1660
+ }
1661
+ async function planFrom(destDir, options) {
1662
+ const state = readState(destDir);
1663
+ const src = sourceOf(state);
1664
+ if (!src) {
1665
+ throw new Error(
1666
+ `${destDir}: .treelay/lock.json records no lineage, so there is no template to update from. Recompile with \`treelay compile <src> ${destDir}\`.`
1667
+ );
1668
+ }
1669
+ if (!existsSync9(src) || !statSync2(src).isDirectory()) {
1670
+ throw new Error(
1671
+ `${destDir}: the template it was compiled from is gone (${src}). Restore it, or recompile from its new location.`
1672
+ );
1673
+ }
1674
+ const graph = resolve(src, options.frozen ? { frozen: true } : {});
1675
+ const newVariables = Object.keys(graph.variables).filter(
1676
+ (name) => !(name in state.answers)
1677
+ );
1678
+ const values = await resolveValues(graph, {
1679
+ answers: state.answers,
1680
+ ...options.set ? { set: options.set } : {},
1681
+ prompt: options.prompt !== false
1682
+ });
1683
+ const theirs = await composeFiles(graph, values, destDir);
1684
+ const decisions = [];
1685
+ const files = {};
1686
+ const conflicts = [];
1687
+ const paths = [
1688
+ .../* @__PURE__ */ new Set([...Object.keys(state.baseline), ...theirs.keys()])
1689
+ ].sort();
1690
+ for (const path of paths) {
1691
+ const decision = decide(destDir, path, state, theirs, options);
1692
+ decisions.push(decision);
1693
+ files[path] = decision.resolution;
1694
+ if (decision.resolution === "conflict") conflicts.push(path);
1695
+ }
1696
+ return {
1697
+ decisions,
1698
+ plan: { files, conflicts, newVariables, drift: checkDrift(graph) },
1699
+ theirs,
1700
+ values,
1701
+ state,
1702
+ destLock: lockFromGraph(graph),
1703
+ variables: graph.variables
1704
+ };
1705
+ }
1706
+ function decide(destDir, path, state, theirsMap, options) {
1707
+ const baseHash = state.baseline[path];
1708
+ const theirsEntry = theirsMap.get(path);
1709
+ const abs = join9(destDir, path);
1710
+ const ours = existsSync9(abs) ? readFileSync7(abs) : void 0;
1711
+ if (!theirsEntry) {
1712
+ if (!ours) return { path, resolution: "unchanged" };
1713
+ if (baseHash !== void 0 && hashContent(ours) === baseHash) {
1714
+ return { path, resolution: "delete", remove: true };
1715
+ }
1716
+ return conflictOver(path, ours, void 0, options);
1717
+ }
1718
+ const theirs = theirsEntry.data;
1719
+ if (baseHash === void 0) {
1720
+ if (!ours) return { path, resolution: "take-theirs", write: theirs };
1721
+ if (ours.equals(theirs)) return { path, resolution: "unchanged" };
1722
+ return conflictOver(path, ours, theirs, options);
1723
+ }
1724
+ if (!ours) {
1725
+ if (hashContent(theirs) === baseHash) return { path, resolution: "keep-ours" };
1726
+ return conflictOver(path, void 0, theirs, options);
1727
+ }
1728
+ const oursUnchanged = hashContent(ours) === baseHash;
1729
+ const theirsUnchanged = hashContent(theirs) === baseHash;
1730
+ if (oursUnchanged && theirsUnchanged) return { path, resolution: "unchanged" };
1731
+ if (oursUnchanged) return { path, resolution: "take-theirs", write: theirs };
1732
+ if (theirsUnchanged) return { path, resolution: "keep-ours" };
1733
+ if (ours.equals(theirs)) return { path, resolution: "unchanged" };
1734
+ const base = readBaselineFile(destDir, path, baseHash);
1735
+ if (!base || isBinary(base) || isBinary(ours) || isBinary(theirs)) {
1736
+ return conflictOver(path, ours, theirs, options);
1737
+ }
1738
+ const merged = mergeOne(
1739
+ path,
1740
+ base.toString("utf8"),
1741
+ ours.toString("utf8"),
1742
+ theirs.toString("utf8")
1743
+ );
1744
+ if (merged.clean) {
1745
+ return { path, resolution: "merged", write: Buffer.from(merged.text, "utf8") };
1746
+ }
1747
+ return conflictOver(path, ours, theirs, options, Buffer.from(merged.text, "utf8"));
1748
+ }
1749
+ function conflictOver(path, ours, theirs, options, markers) {
1750
+ if ((options.onConflict ?? "markers") === "rej") {
1751
+ return {
1752
+ path,
1753
+ resolution: "conflict",
1754
+ ...ours ? {} : { write: Buffer.alloc(0) },
1755
+ ...theirs ? { rej: theirs } : { rej: Buffer.alloc(0) }
1756
+ };
1757
+ }
1758
+ const write = markers ?? theirs ?? ours;
1759
+ return { path, resolution: "conflict", ...write ? { write } : {} };
1760
+ }
1761
+ async function planUpdate(destDir, options = {}) {
1762
+ const { plan } = await planFrom(destDir, { ...options, prompt: false });
1763
+ return plan;
1764
+ }
1765
+ async function update(destDir, options = {}) {
1766
+ const { decisions, plan, theirs, values, variables, destLock } = await planFrom(
1767
+ destDir,
1768
+ options
1769
+ );
1770
+ for (const d of decisions) {
1771
+ const abs = join9(destDir, d.path);
1772
+ if (d.remove) {
1773
+ rmSync4(abs, { force: true });
1774
+ continue;
1775
+ }
1776
+ if (d.write) {
1777
+ ensureDir(abs);
1778
+ writeFileSync4(abs, d.write);
1779
+ }
1780
+ if (d.rej) {
1781
+ ensureDir(abs);
1782
+ writeFileSync4(abs + ".rej", d.rej);
1783
+ }
1784
+ }
1785
+ const { baseline, manifest } = summarize(theirs);
1786
+ const snapshot = /* @__PURE__ */ new Map();
1787
+ for (const [rel, entry] of theirs) snapshot.set(rel, entry.data);
1788
+ writeState(
1789
+ destDir,
1790
+ { lock: destLock, answers: values, baseline, manifest },
1791
+ variables,
1792
+ snapshot
1793
+ );
1794
+ return plan;
1795
+ }
1796
+
1797
+ // src/explain.ts
1798
+ import { readFileSync as readFileSync8 } from "fs";
1799
+ import { basename, join as join10, resolve as resolvePath3 } from "path";
1800
+ function summarizeLayers(graph) {
1801
+ const self = graph.layers[graph.layers.length - 1];
1802
+ const mixinIds = /* @__PURE__ */ new Set();
1803
+ if (self) {
1804
+ for (const ref of self.manifest.mixins ?? []) {
1805
+ try {
1806
+ const parsed = parseRef(ref);
1807
+ mixinIds.add(
1808
+ parsed.kind === "local" ? resolvePath3(self.dir, parsed.path) : canonicalRef(parsed)
1809
+ );
1810
+ } catch {
1811
+ }
1812
+ }
1813
+ }
1814
+ return graph.layers.map((layer, i) => ({
1815
+ id: layer.id,
1816
+ name: displayName(layer),
1817
+ role: layer.mountPath ? "mount" : layer === self ? "self" : mixinIds.has(layer.id) ? "mixin" : "parent",
1818
+ position: i + 1,
1819
+ writable: layer.writable,
1820
+ ...layer.origin?.ref ? { ref: layer.origin.ref } : {},
1821
+ ...layer.origin?.revision ? { revision: layer.origin.revision } : {},
1822
+ ...layer.mountPath ? { mountPath: layer.mountPath } : {}
1823
+ }));
1824
+ }
1825
+ function displayName(layer) {
1826
+ if (layer.manifest.name) return layer.manifest.name;
1827
+ if (layer.mountPath) return `${layer.mountPath}/`;
1828
+ return layer.origin?.ref ?? basename(layer.dir);
1829
+ }
1830
+ async function tryRenderPath(path, values) {
1831
+ if (!path.includes("{{") && !path.includes("{%")) return { path };
1832
+ try {
1833
+ return { path: await renderString(path, values) };
1834
+ } catch {
1835
+ return { path, note: "path left unrendered (no value for its variables)" };
1836
+ }
1837
+ }
1838
+ async function explain(graph, options = {}) {
1839
+ const values = options.values ?? {};
1840
+ const summaries = summarizeLayers(graph);
1841
+ const live = /* @__PURE__ */ new Map();
1842
+ const history = /* @__PURE__ */ new Map();
1843
+ const deleted = /* @__PURE__ */ new Set();
1844
+ const record = (target, c) => {
1845
+ const list = history.get(target);
1846
+ if (list) list.push(c);
1847
+ else history.set(target, [c]);
1848
+ };
1849
+ for (const [i, layer] of graph.layers.entries()) {
1850
+ const summary = summaries[i];
1851
+ const arrays = layer.manifest.arrays ?? "replace";
1852
+ void arrays;
1853
+ const entries = enumerateLayer(layer, options.destDir);
1854
+ const ops = [];
1855
+ for (const entry of entries) {
1856
+ if (entry.kind !== "file") {
1857
+ ops.push(entry);
1858
+ continue;
1859
+ }
1860
+ const rendered = await tryRenderPath(entry.rawTarget, values);
1861
+ if (rendered.path.trim() === "") continue;
1862
+ const target = mountTarget(layer, rendered.path);
1863
+ const strategy = strategyFor(target, layer.manifest.merge);
1864
+ const existing = live.get(target);
1865
+ const base = {
1866
+ layer: layer.id,
1867
+ name: summary.name,
1868
+ role: summary.role,
1869
+ position: summary.position,
1870
+ source: entry.source,
1871
+ strategy,
1872
+ kind: entry.kind,
1873
+ ...rendered.note ? { note: rendered.note } : {}
1874
+ };
1875
+ if (!existing) {
1876
+ live.set(target, { strategy, fromLayer: layer.id, patchedFrom: [] });
1877
+ deleted.delete(target);
1878
+ record(target, { ...base, action: "create" });
1879
+ continue;
1880
+ }
1881
+ switch (strategy) {
1882
+ case "replace":
1883
+ existing.fromLayer = layer.id;
1884
+ existing.strategy = strategy;
1885
+ record(target, { ...base, action: "replace" });
1886
+ break;
1887
+ case "deep-merge":
1888
+ existing.patchedFrom.push(existing.fromLayer);
1889
+ existing.fromLayer = layer.id;
1890
+ existing.strategy = strategy;
1891
+ record(target, { ...base, action: "deep-merge" });
1892
+ break;
1893
+ case "append":
1894
+ case "prepend":
1895
+ existing.patchedFrom.push(layer.id);
1896
+ record(target, { ...base, action: strategy });
1897
+ break;
1898
+ case "delete":
1899
+ live.delete(target);
1900
+ deleted.add(target);
1901
+ record(target, { ...base, action: "delete" });
1902
+ break;
1903
+ case "patch":
1904
+ record(target, {
1905
+ ...base,
1906
+ action: "patch",
1907
+ note: "unified-diff patch via merge glob (\xA75) \u2014 described, not applied"
1908
+ });
1909
+ break;
1910
+ }
1911
+ }
1912
+ for (const entry of ops) {
1913
+ await explainOp(entry, layer, summary, values, live, deleted, record);
1914
+ }
1915
+ }
1916
+ const files = {};
1917
+ for (const path of [...history.keys()].sort()) {
1918
+ const state = live.get(path);
1919
+ files[path] = {
1920
+ path,
1921
+ contributions: history.get(path),
1922
+ present: state !== void 0,
1923
+ patchedFrom: state?.patchedFrom ?? [],
1924
+ ...state ? { winner: state.fromLayer, strategy: state.strategy } : {}
1925
+ };
1926
+ }
1927
+ return { layers: summaries, files };
1928
+ }
1929
+ async function explainOp(entry, layer, summary, values, live, deleted, record) {
1930
+ let op = entry.op;
1931
+ let baseHash;
1932
+ let when;
1933
+ if (entry.kind === "sidecar") {
1934
+ try {
1935
+ const sc = parseSidecar(readFileSync8(join10(layer.dir, entry.source), "utf8"));
1936
+ op = sc.op;
1937
+ baseHash = sc.base;
1938
+ when = sc.when;
1939
+ } catch (err) {
1940
+ record(mountTarget(layer, entry.rawTarget), {
1941
+ layer: layer.id,
1942
+ name: summary.name,
1943
+ role: summary.role,
1944
+ position: summary.position,
1945
+ source: entry.source,
1946
+ action: "replace",
1947
+ strategy: "replace",
1948
+ kind: entry.kind,
1949
+ skipped: true,
1950
+ note: `unreadable sidecar: ${err instanceof Error ? err.message : String(err)}`
1951
+ });
1952
+ return;
1953
+ }
1954
+ }
1955
+ if (!op) return;
1956
+ const rendered = await tryRenderPath(entry.rawTarget, values);
1957
+ const target = mountTarget(layer, rendered.path);
1958
+ const contribution = {
1959
+ layer: layer.id,
1960
+ name: summary.name,
1961
+ role: summary.role,
1962
+ position: summary.position,
1963
+ source: entry.source,
1964
+ action: op,
1965
+ strategy: op === "merge" ? "deep-merge" : op,
1966
+ kind: entry.kind,
1967
+ ...baseHash ? { base: baseHash } : {},
1968
+ ...rendered.note ? { note: rendered.note } : {}
1969
+ };
1970
+ if (when !== void 0) {
1971
+ let truthy = true;
1972
+ try {
1973
+ const w = (await renderString(when, values)).trim();
1974
+ truthy = !(w === "" || w === "false");
1975
+ } catch {
1976
+ truthy = false;
1977
+ }
1978
+ if (!truthy) {
1979
+ record(target, {
1980
+ ...contribution,
1981
+ skipped: true,
1982
+ note: `skipped: \`when: ${when}\` evaluated falsy`
1983
+ });
1984
+ return;
1985
+ }
1986
+ }
1987
+ const existing = live.get(target);
1988
+ switch (op) {
1989
+ case "delete":
1990
+ live.delete(target);
1991
+ deleted.add(target);
1992
+ break;
1993
+ case "replace":
1994
+ case "append":
1995
+ case "prepend":
1996
+ if (existing) existing.patchedFrom.push("sidecar");
1997
+ else
1998
+ live.set(target, {
1999
+ strategy: op === "replace" ? "replace" : op,
2000
+ fromLayer: "sidecar",
2001
+ patchedFrom: []
2002
+ });
2003
+ break;
2004
+ case "merge":
2005
+ if (existing) existing.patchedFrom.push("sidecar");
2006
+ else
2007
+ live.set(target, {
2008
+ strategy: "deep-merge",
2009
+ fromLayer: "sidecar",
2010
+ patchedFrom: []
2011
+ });
2012
+ break;
2013
+ case "patch":
2014
+ contribution.note = "unified-diff 3-way patch (\xA75) \u2014 described, not applied" + (baseHash ? "" : "; no recorded base \u21D2 best-effort apply");
2015
+ break;
2016
+ }
2017
+ record(target, contribution);
2018
+ }
2019
+ async function explainFile(graph, path, options = {}) {
2020
+ const result = await explain(graph, options);
2021
+ return result.files[path];
2022
+ }
2023
+ async function explainDest(destDir) {
2024
+ if (!hasState(destDir)) {
2025
+ throw new Error(
2026
+ `No .treelay state in ${destDir} \u2014 not a compiled destination. Point \`explain\` at a source layer directory instead.`
2027
+ );
2028
+ }
2029
+ const state = readState(destDir);
2030
+ const src = state.lock.lineage[state.lock.lineage.length - 1];
2031
+ if (!src) throw new Error(`Corrupt lockfile in ${destDir}: empty lineage.`);
2032
+ const result = await explain(resolve(src), {
2033
+ values: state.answers,
2034
+ destDir
2035
+ });
2036
+ for (const [path, entry] of Object.entries(state.manifest)) {
2037
+ const file = result.files[path];
2038
+ if (file) file.owned = entry.owned;
2039
+ }
2040
+ return result;
2041
+ }
2042
+ function formatExplanation(result, only) {
2043
+ const lines = [];
2044
+ lines.push("Layers (lowest \u2192 highest precedence):");
2045
+ for (const l of result.layers) {
2046
+ const marks = [
2047
+ l.mountPath ? `mounted at ${l.mountPath}/` : void 0,
2048
+ l.revision ? `pinned ${shortRevision(l.revision)}` : void 0,
2049
+ l.writable ? void 0 : "read-only"
2050
+ ].filter(Boolean);
2051
+ const flags = marks.length ? ` [${marks.join(", ")}]` : "";
2052
+ lines.push(` ${l.position}. ${l.name} (${l.role})${flags}`);
2053
+ }
2054
+ lines.push("");
2055
+ const paths = only ? [only] : Object.keys(result.files);
2056
+ if (only && !result.files[only]) {
2057
+ lines.push(`No layer contributes to "${only}".`);
2058
+ return lines.join("\n");
2059
+ }
2060
+ for (const path of paths) {
2061
+ const file = result.files[path];
2062
+ const status2 = file.present ? `\u2190 ${nameOf(result, file.winner)} (${file.strategy})` : "\u2190 removed (tombstoned)";
2063
+ lines.push(`${path} ${status2}`);
2064
+ for (const c of file.contributions) {
2065
+ const marks = [
2066
+ c.skipped ? "skipped" : void 0,
2067
+ c.kind === "sidecar" ? "sidecar" : c.kind === "suffix" ? "suffix" : void 0,
2068
+ c.base ? `base ${c.base.slice(0, 14)}\u2026` : void 0
2069
+ ].filter(Boolean);
2070
+ const suffix = marks.length ? ` [${marks.join(", ")}]` : "";
2071
+ lines.push(
2072
+ ` ${c.position}. ${pad(c.name, 18)} ${pad(c.role, 7)} ${pad(c.action, 11)} ${c.source}${suffix}`
2073
+ );
2074
+ if (c.note) lines.push(` note: ${c.note}`);
2075
+ }
2076
+ if (file.patchedFrom.length) {
2077
+ const names = file.patchedFrom.map((id) => nameOf(result, id));
2078
+ lines.push(` folded in: ${names.join(", ")}`);
2079
+ }
2080
+ if (file.owned) lines.push(" user-owned (not produced by the template)");
2081
+ lines.push("");
2082
+ }
2083
+ return lines.join("\n").trimEnd();
2084
+ }
2085
+ function shortRevision(rev) {
2086
+ return /^[0-9a-f]{40}$/i.test(rev) ? rev.slice(0, 12) : rev;
2087
+ }
2088
+ function nameOf(result, id) {
2089
+ if (!id) return "\u2014";
2090
+ return result.layers.find((l) => l.id === id)?.name ?? id;
2091
+ }
2092
+ function pad(s, n) {
2093
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
2094
+ }
2095
+
2096
+ // src/blast-radius.ts
2097
+ import { readFileSync as readFileSync9 } from "fs";
2098
+ import { dirname as dirname3, join as join11, resolve as resolvePath4, sep } from "path";
2099
+ import fg3 from "fast-glob";
2100
+ var MANIFEST_GLOBS = [
2101
+ "**/treelay.json",
2102
+ "**/treelay.yaml",
2103
+ "**/treelay.yml",
2104
+ "**/package.json"
2105
+ ];
2106
+ var SCAN_IGNORE = ["**/node_modules/**", "**/.git/**"];
2107
+ function blastRadius(layerDir, options) {
2108
+ const target = resolvePath4(layerDir);
2109
+ const searchRoot = resolvePath4(options.searchRoot);
2110
+ const depth = options.depth ?? 8;
2111
+ const dependents = /* @__PURE__ */ new Set();
2112
+ const destinations = /* @__PURE__ */ new Set();
2113
+ for (const rel of fg3.sync(MANIFEST_GLOBS, {
2114
+ cwd: searchRoot,
2115
+ dot: true,
2116
+ onlyFiles: true,
2117
+ deep: depth,
2118
+ ignore: SCAN_IGNORE
2119
+ })) {
2120
+ const dir = resolvePath4(searchRoot, dirname3(rel));
2121
+ if (dir === target) continue;
2122
+ if (options.excludeLayer && dir === resolvePath4(options.excludeLayer)) continue;
2123
+ const manifest = loadManifest(dir);
2124
+ const refs = [...manifest.parents ?? [], ...manifest.mixins ?? []];
2125
+ for (const ref of refs) {
2126
+ let resolved;
2127
+ try {
2128
+ resolved = resolveRef(ref, dir);
2129
+ } catch {
2130
+ continue;
2131
+ }
2132
+ if (resolved === target) dependents.add(dir);
2133
+ }
2134
+ }
2135
+ for (const rel of fg3.sync(`**/${STATE_DIR}/lock.json`, {
2136
+ cwd: searchRoot,
2137
+ dot: true,
2138
+ onlyFiles: true,
2139
+ deep: depth + 1,
2140
+ ignore: SCAN_IGNORE
2141
+ })) {
2142
+ const destDir = resolvePath4(searchRoot, dirname3(dirname3(rel)));
2143
+ if (options.excludeDest && destDir === resolvePath4(options.excludeDest)) continue;
2144
+ try {
2145
+ const lock = JSON.parse(readFileSync9(join11(searchRoot, rel), "utf8"));
2146
+ if (lock.lineage?.includes(target)) destinations.add(destDir);
2147
+ } catch {
2148
+ continue;
2149
+ }
2150
+ }
2151
+ return {
2152
+ layer: target,
2153
+ searchRoot,
2154
+ dependents: [...dependents].sort(),
2155
+ destinations: [...destinations].sort(),
2156
+ bounded: true
2157
+ };
2158
+ }
2159
+ function describeBlastRadius(radius, layerName2) {
2160
+ const n = radius.dependents.length;
2161
+ const d = radius.destinations.length;
2162
+ if (n === 0 && d === 0) {
2163
+ return `${layerName2} has no other known consumers under ${radius.searchRoot}.`;
2164
+ }
2165
+ const parts = [];
2166
+ if (n) parts.push(`${n} other layer${n === 1 ? "" : "s"} inherit${n === 1 ? "s" : ""} it`);
2167
+ if (d) parts.push(`${d} compiled destination${d === 1 ? "" : "s"} include${d === 1 ? "s" : ""} it`);
2168
+ return `${layerName2} is consumed beyond this project \u2014 ${parts.join(", ")}. This edit reaches all of them on their next update.`;
2169
+ }
2170
+ function commonAncestor(paths) {
2171
+ if (paths.length === 0) return resolvePath4(".");
2172
+ const split = paths.map((p) => resolvePath4(p).split(sep));
2173
+ const first = split[0];
2174
+ let i = 0;
2175
+ while (i < first.length && split.every((parts) => parts[i] === first[i])) i++;
2176
+ return split[0].slice(0, i).join(sep) || sep;
2177
+ }
2178
+
2179
+ // src/verify.ts
2180
+ import { readFileSync as readFileSync10, existsSync as existsSync10 } from "fs";
2181
+ import { join as join12 } from "path";
2182
+ async function composeToMemory(graph, values, destDir) {
2183
+ const composed = await composeFiles(graph, values, destDir);
2184
+ const files = /* @__PURE__ */ new Map();
2185
+ for (const [rel, entry] of composed) files.set(rel, entry.data);
2186
+ return files;
2187
+ }
2188
+ async function roundTripVerify(destDir, graph, values, options = {}) {
2189
+ const composed = await composeToMemory(graph, values, destDir);
2190
+ const mismatches = [];
2191
+ for (const [rel, expected] of composed) {
2192
+ const abs = join12(destDir, rel);
2193
+ if (!existsSync10(abs)) {
2194
+ mismatches.push({
2195
+ path: rel,
2196
+ kind: "missing-in-dest",
2197
+ detail: "the template produces this file but the destination lacks it"
2198
+ });
2199
+ continue;
2200
+ }
2201
+ const actual = readFileSync10(abs);
2202
+ if (!actual.equals(expected)) {
2203
+ mismatches.push({
2204
+ path: rel,
2205
+ kind: "content-differs",
2206
+ detail: `recompiled output differs from the working copy (${expected.length} vs ${actual.length} bytes)`
2207
+ });
2208
+ }
2209
+ }
2210
+ for (const rel of options.expectAbsent ?? []) {
2211
+ if (composed.has(rel)) {
2212
+ mismatches.push({
2213
+ path: rel,
2214
+ kind: "should-have-been-removed",
2215
+ detail: "the template still produces this file after the promotion"
2216
+ });
2217
+ }
2218
+ }
2219
+ return { ok: mismatches.length === 0, mismatches, composed };
2220
+ }
2221
+ function describeMismatches(mismatches) {
2222
+ return mismatches.map((m) => ` ${m.path} \u2014 ${m.kind}: ${m.detail}`).join("\n");
2223
+ }
2224
+
2225
+ // src/reflux.ts
2226
+ import {
2227
+ existsSync as existsSync11,
2228
+ mkdirSync as mkdirSync3,
2229
+ readFileSync as readFileSync11,
2230
+ rmSync as rmSync5,
2231
+ writeFileSync as writeFileSync5
2232
+ } from "fs";
2233
+ import { dirname as dirname4, join as join13, relative as relative3, resolve as resolvePath5 } from "path";
2234
+ import { createPatch } from "diff";
2235
+ import fg4 from "fast-glob";
2236
+ import { stringify as stringifyYaml2 } from "yaml";
2237
+ var WHOLESALE_ACTIONS = /* @__PURE__ */ new Set(["create", "replace", "delete"]);
2238
+ var STRUCTURED2 = /\.(json|ya?ml|toml)$/i;
2239
+ async function loadContext(destDir) {
2240
+ const state = readState(destDir);
2241
+ const srcDir = sourceOf(state);
2242
+ if (!srcDir) throw new Error(`Corrupt lockfile in ${destDir}: empty lineage.`);
2243
+ const graph = resolve(srcDir);
2244
+ const explanation = await explain(graph, { values: state.answers, destDir });
2245
+ return {
2246
+ destDir,
2247
+ srcDir,
2248
+ graph,
2249
+ values: state.answers,
2250
+ explanation,
2251
+ baseline: state.baseline,
2252
+ manifest: state.manifest
2253
+ };
2254
+ }
2255
+ function destFiles(destDir) {
2256
+ return fg4.sync("**/*", {
2257
+ cwd: destDir,
2258
+ dot: true,
2259
+ onlyFiles: true,
2260
+ ignore: [`${STATE_DIR}/**`]
2261
+ }).sort();
2262
+ }
2263
+ var layerName = (layer) => layer.manifest.name ?? relative3(dirname4(layer.dir), layer.dir);
2264
+ function layerById(graph, id) {
2265
+ return graph.layers.find((l) => l.id === id);
2266
+ }
2267
+ function nameOf2(graph, id) {
2268
+ const layer = layerById(graph, id);
2269
+ return layer ? layerName(layer) : id;
2270
+ }
2271
+ function positionOf(graph, id) {
2272
+ return graph.layers.findIndex((l) => l.id === id) + 1;
2273
+ }
2274
+ function shadowers(explanation, path, position) {
2275
+ const file = explanation.files[path];
2276
+ if (!file) return [];
2277
+ return file.contributions.filter(
2278
+ (c) => c.position > position && !c.skipped && WHOLESALE_ACTIONS.has(c.action)
2279
+ );
2280
+ }
2281
+ async function status(destDir) {
2282
+ const ctx = await loadContext(destDir);
2283
+ const present = new Set(destFiles(destDir));
2284
+ const changes = [];
2285
+ for (const [path, recorded] of Object.entries(ctx.baseline)) {
2286
+ if (!present.has(path)) {
2287
+ changes.push(annotate(ctx, { path, kind: "deleted" }));
2288
+ continue;
2289
+ }
2290
+ const actual = hashContent(readFileSync11(join13(destDir, path)));
2291
+ if (actual !== recorded) {
2292
+ changes.push(annotate(ctx, { path, kind: "modified" }));
2293
+ }
2294
+ }
2295
+ for (const path of present) {
2296
+ if (path in ctx.baseline) continue;
2297
+ changes.push(annotate(ctx, { path, kind: "added" }));
2298
+ }
2299
+ return changes.sort((a, b) => a.path.localeCompare(b.path));
2300
+ }
2301
+ function annotate(ctx, change) {
2302
+ const file = ctx.explanation.files[change.path];
2303
+ const producing = file?.winner ?? ctx.manifest[change.path]?.fromLayer;
2304
+ const targets = ctx.graph.layers.filter((l) => l.writable).filter((l) => shadowers(ctx.explanation, change.path, positionOf(ctx.graph, l.id)).length === 0).map((l) => l.id);
2305
+ return {
2306
+ ...change,
2307
+ ...producing ? { producingLayer: producing } : {},
2308
+ ...file?.patchedFrom.length ? { patchedBy: file.patchedFrom } : {},
2309
+ ...file ? {} : { owned: true },
2310
+ targets
2311
+ };
2312
+ }
2313
+ function formatStatus(changes, graph) {
2314
+ if (changes.length === 0) return "No changes vs baseline.";
2315
+ const mark = { modified: "M", added: "A", deleted: "D" };
2316
+ const width = Math.max(...changes.map((c) => c.path.length));
2317
+ return changes.map((c) => {
2318
+ const path = c.path.padEnd(width);
2319
+ if (!c.producingLayer) return ` A ${path} \u2190 local-only (no template origin)`;
2320
+ const patched = c.patchedBy?.length ? ` (+ patched by ${c.patchedBy.map((id) => nameOf2(graph, id)).join(", ")})` : "";
2321
+ return ` ${mark[c.kind]} ${path} \u2190 produced by ${nameOf2(graph, c.producingLayer)}${patched}`;
2322
+ }).join("\n");
2323
+ }
2324
+ async function promote(destDir, changes, options = {}) {
2325
+ if (changes.length === 0) throw new Error("promote: no changes given.");
2326
+ const ctx = await loadContext(destDir);
2327
+ const target = resolveTarget(ctx, changes, options.to);
2328
+ const position = positionOf(ctx.graph, target.id);
2329
+ if (!target.writable) {
2330
+ throw new Error(
2331
+ `Cannot promote into ${layerName(target)}: the layer is read-only.
2332
+ Promote into a writable layer above it, or capture the change as a patch there (\xA78).`
2333
+ );
2334
+ }
2335
+ for (const change of changes) {
2336
+ const blocked = shadowers(ctx.explanation, change.path, position);
2337
+ if (blocked.length) {
2338
+ const names = [...new Set(blocked.map((c) => c.name))].join(", ");
2339
+ throw new Error(
2340
+ `Promoting ${change.path} to ${layerName(target)} has no effect; ${names} overrides this file at a higher precedence.
2341
+ Promote to ${names} or to the leaf instead (\xA78 guard 1).`
2342
+ );
2343
+ }
2344
+ }
2345
+ const tx = new LayerTransaction();
2346
+ let landed;
2347
+ try {
2348
+ landed = [];
2349
+ for (const change of changes) {
2350
+ landed.push(await landChange(ctx, change, target, position, tx));
2351
+ }
2352
+ } catch (err) {
2353
+ tx.rollback();
2354
+ throw err;
2355
+ }
2356
+ const verify = options.verify !== false;
2357
+ if (verify) {
2358
+ const regraph = resolve(ctx.srcDir);
2359
+ const removed = changes.filter((c) => c.kind === "deleted").map((c) => c.path);
2360
+ const result = await roundTripVerify(destDir, regraph, ctx.values, {
2361
+ expectAbsent: removed
2362
+ });
2363
+ if (!result.ok) {
2364
+ tx.rollback();
2365
+ throw new Error(
2366
+ `Round-trip verification failed after promoting to ${layerName(target)} \u2014 the recompiled template does not reproduce the working copy, so the promotion was rolled back (\xA78 guard 2):
2367
+ ${describeMismatches(result.mismatches)}`
2368
+ );
2369
+ }
2370
+ rebaseline(ctx, regraph, result.composed);
2371
+ }
2372
+ const searchRoot = options.searchRoot ?? commonAncestor([...ctx.graph.layers.map((l) => l.dir), destDir]);
2373
+ const radius = blastRadius(target.dir, {
2374
+ searchRoot,
2375
+ excludeDest: destDir,
2376
+ excludeLayer: ctx.srcDir
2377
+ });
2378
+ return {
2379
+ target: target.id,
2380
+ targetName: layerName(target),
2381
+ landed,
2382
+ verified: verify,
2383
+ blastRadius: radius,
2384
+ blastRadiusWarning: describeBlastRadius(radius, layerName(target))
2385
+ };
2386
+ }
2387
+ function resolveTarget(ctx, changes, to) {
2388
+ if (to) {
2389
+ const dir = to.startsWith(".") || to.startsWith("/") ? resolveRef(to, ctx.srcDir) : to;
2390
+ const layer2 = ctx.graph.layers.find((l) => l.id === dir || layerName(l) === to);
2391
+ if (!layer2) {
2392
+ const known = ctx.graph.layers.map((l) => layerName(l)).join(", ");
2393
+ throw new Error(`Unknown promotion target "${to}". Layers in this composition: ${known}.`);
2394
+ }
2395
+ return layer2;
2396
+ }
2397
+ const suggestions = new Set(
2398
+ changes.map((c) => c.producingLayer ?? ctx.explanation.files[c.path]?.winner ?? ctx.srcDir)
2399
+ );
2400
+ if (suggestions.size > 1) {
2401
+ const names = [...suggestions].map((id) => nameOf2(ctx.graph, id)).join(", ");
2402
+ throw new Error(
2403
+ `These changes come from different layers (${names}), so there is no single suggested target. Promote them separately, or pass an explicit target.`
2404
+ );
2405
+ }
2406
+ const [only] = [...suggestions];
2407
+ const layer = layerById(ctx.graph, only);
2408
+ if (!layer) throw new Error(`Suggested target ${only} is not part of the composition.`);
2409
+ return layer;
2410
+ }
2411
+ async function landChange(ctx, change, target, position, tx) {
2412
+ const file = ctx.explanation.files[change.path];
2413
+ if (change.kind === "deleted") {
2414
+ const own2 = file?.contributions.find((c) => c.layer === target.id);
2415
+ if (own2 && file.contributions.length === 1) {
2416
+ tx.remove(join13(target.dir, own2.source));
2417
+ return { path: change.path, mode: "tombstone", wrote: own2.source };
2418
+ }
2419
+ const sidecar = change.path + SIDECAR_SUFFIX;
2420
+ tx.write(join13(target.dir, sidecar), stringifyYaml2({ op: "delete" }));
2421
+ return { path: change.path, mode: "tombstone", wrote: sidecar };
2422
+ }
2423
+ const desired = readFileSync11(join13(ctx.destDir, change.path), "utf8");
2424
+ const own = file?.contributions.find((c) => c.layer === target.id && c.kind === "file");
2425
+ if (own) {
2426
+ tx.write(join13(target.dir, own.source), desired);
2427
+ return { path: change.path, mode: "rewrite", wrote: own.source };
2428
+ }
2429
+ const inherited = await inheritedBelow(ctx, position, change.path);
2430
+ if (inherited !== void 0) {
2431
+ const sidecar = change.path + SIDECAR_SUFFIX;
2432
+ tx.write(
2433
+ join13(target.dir, sidecar),
2434
+ buildPatchSidecar(change.path, inherited, desired)
2435
+ );
2436
+ return { path: change.path, mode: "patch", wrote: sidecar };
2437
+ }
2438
+ tx.write(join13(target.dir, change.path), desired);
2439
+ return { path: change.path, mode: "create", wrote: change.path };
2440
+ }
2441
+ async function inheritedBelow(ctx, position, path) {
2442
+ if (position <= 1) return void 0;
2443
+ const below = {
2444
+ layers: ctx.graph.layers.slice(0, position - 1),
2445
+ variables: ctx.graph.variables
2446
+ };
2447
+ const composed = await composeToMemory(below, ctx.values);
2448
+ return composed.get(path)?.toString("utf8");
2449
+ }
2450
+ function buildPatchSidecar(path, base, desired) {
2451
+ if (STRUCTURED2.test(path)) {
2452
+ return stringifyYaml2({
2453
+ op: "merge",
2454
+ base: hashContent(base),
2455
+ merge: generateMergePatch(parseStructured(path, base), parseStructured(path, desired))
2456
+ });
2457
+ }
2458
+ return stringifyYaml2({
2459
+ op: "patch",
2460
+ base: hashContent(base),
2461
+ baseContent: base,
2462
+ patch: createPatch(path, base, desired, void 0, void 0, { context: 3 })
2463
+ });
2464
+ }
2465
+ function rebaseline(ctx, graph, composed) {
2466
+ const baseline = {};
2467
+ const manifest = {};
2468
+ for (const [path, data] of composed) {
2469
+ baseline[path] = hashContent(data);
2470
+ manifest[path] = {
2471
+ owned: false,
2472
+ fromLayer: ctx.manifest[path]?.fromLayer ?? ctx.srcDir
2473
+ };
2474
+ }
2475
+ writeState(
2476
+ ctx.destDir,
2477
+ { lock: lockFromGraph(graph), answers: ctx.values, baseline, manifest },
2478
+ graph.variables,
2479
+ composed
2480
+ );
2481
+ }
2482
+ async function extract(destDir, changes, options) {
2483
+ if (changes.length === 0) throw new Error("extract: no changes given.");
2484
+ const ctx = await loadContext(destDir);
2485
+ const layerDir = resolvePath5(ctx.srcDir, options.as);
2486
+ if (existsSync11(join13(layerDir, "treelay.json"))) {
2487
+ throw new Error(`A layer already exists at ${layerDir}. Choose another path.`);
2488
+ }
2489
+ const name = options.name ?? layerDir.split("/").filter(Boolean).pop();
2490
+ const tx = new LayerTransaction();
2491
+ const written = [];
2492
+ try {
2493
+ tx.write(join13(layerDir, "treelay.json"), JSON.stringify({ name }, null, 2) + "\n");
2494
+ for (const change of changes) {
2495
+ if (change.kind === "deleted") {
2496
+ const sidecar = change.path + SIDECAR_SUFFIX;
2497
+ tx.write(join13(layerDir, sidecar), stringifyYaml2({ op: "delete" }));
2498
+ written.push(sidecar);
2499
+ continue;
2500
+ }
2501
+ tx.write(
2502
+ join13(layerDir, change.path),
2503
+ readFileSync11(join13(destDir, change.path), "utf8")
2504
+ );
2505
+ written.push(change.path);
2506
+ }
2507
+ if (options.asMixin) wireMixin(ctx, options.as, tx);
2508
+ } catch (err) {
2509
+ tx.rollback();
2510
+ throw err;
2511
+ }
2512
+ const wired = options.asMixin === true;
2513
+ const shouldVerify = wired && options.verify !== false;
2514
+ let verified = false;
2515
+ if (shouldVerify) {
2516
+ const regraph = resolve(ctx.srcDir);
2517
+ const removed = changes.filter((c) => c.kind === "deleted").map((c) => c.path);
2518
+ const result = await roundTripVerify(destDir, regraph, ctx.values, {
2519
+ expectAbsent: removed
2520
+ });
2521
+ if (!result.ok) {
2522
+ tx.rollback();
2523
+ throw new Error(
2524
+ `Round-trip verification failed after extracting ${name} \u2014 rolled back (\xA78 guard 2):
2525
+ ${describeMismatches(result.mismatches)}`
2526
+ );
2527
+ }
2528
+ rebaseline(ctx, regraph, result.composed);
2529
+ verified = true;
2530
+ }
2531
+ return { layer: layerDir, name, files: written.sort(), wired, verified };
2532
+ }
2533
+ function wireMixin(ctx, ref, tx) {
2534
+ const manifestPath = join13(ctx.srcDir, "treelay.json");
2535
+ if (!existsSync11(manifestPath)) {
2536
+ throw new Error(
2537
+ `Cannot wire the extracted layer in: ${ctx.srcDir} has no treelay.json. Extract without --mixin and add it by hand.`
2538
+ );
2539
+ }
2540
+ const raw = readFileSync11(manifestPath, "utf8");
2541
+ const manifest = JSON.parse(raw);
2542
+ const mixins = [...manifest.mixins ?? [], ref];
2543
+ tx.write(manifestPath, JSON.stringify({ ...manifest, mixins }, null, 2) + "\n");
2544
+ }
2545
+ var LayerTransaction = class {
2546
+ undo = [];
2547
+ write(path, content) {
2548
+ const existed = existsSync11(path);
2549
+ const previous = existed ? readFileSync11(path) : void 0;
2550
+ mkdirSync3(dirname4(path), { recursive: true });
2551
+ writeFileSync5(path, content);
2552
+ this.undo.push(() => {
2553
+ if (previous !== void 0) writeFileSync5(path, previous);
2554
+ else rmSync5(path, { force: true });
2555
+ });
2556
+ }
2557
+ remove(path) {
2558
+ if (!existsSync11(path)) return;
2559
+ const previous = readFileSync11(path);
2560
+ rmSync5(path, { force: true });
2561
+ this.undo.push(() => {
2562
+ mkdirSync3(dirname4(path), { recursive: true });
2563
+ writeFileSync5(path, previous);
2564
+ });
2565
+ }
2566
+ rollback() {
2567
+ for (const step of this.undo.reverse()) step();
2568
+ this.undo = [];
2569
+ }
2570
+ };
2571
+
2572
+ export {
2573
+ CycleError,
2574
+ InconsistentHierarchyError,
2575
+ MergeConflictError,
2576
+ NotImplementedError,
2577
+ c3Linearize,
2578
+ loadManifest,
2579
+ createEngine,
2580
+ renderString,
2581
+ templateTarget,
2582
+ mergeVariableDecls,
2583
+ resolveValues,
2584
+ hashContent,
2585
+ matchesHash,
2586
+ hashTree,
2587
+ InvalidRefError,
2588
+ isLocalRef,
2589
+ parseRef,
2590
+ canonicalRef,
2591
+ pinnedRef,
2592
+ isCommitSha,
2593
+ cacheRoot,
2594
+ locationKey,
2595
+ GitFetchError,
2596
+ NpmResolveError,
2597
+ LockMissingError,
2598
+ IntegrityError,
2599
+ fetchLayer,
2600
+ verifyIntegrity,
2601
+ liveRevision,
2602
+ LOCKFILE_NAME,
2603
+ LOCKFILE_VERSION,
2604
+ emptyLock,
2605
+ lockfilePath,
2606
+ LockfileError,
2607
+ readLock,
2608
+ serializeLock,
2609
+ writeLock,
2610
+ locksEqual,
2611
+ MountError,
2612
+ resolve,
2613
+ resolveRef,
2614
+ MERGE_LABELS,
2615
+ mergeText3,
2616
+ applyPatch3Way,
2617
+ isSidecar,
2618
+ sidecarTarget,
2619
+ parseSidecar,
2620
+ desugarSuffix,
2621
+ deepMerge,
2622
+ applyMergePatch,
2623
+ applyJsonPatch,
2624
+ mergeStructured3,
2625
+ defaultStrategy,
2626
+ strategyFor,
2627
+ ALWAYS_IGNORE,
2628
+ SelfCompileError,
2629
+ destExclusions,
2630
+ mountTarget,
2631
+ listLayerFiles,
2632
+ classifyEntry,
2633
+ enumerateLayer,
2634
+ STATE_DIR,
2635
+ statePaths,
2636
+ sourceOf,
2637
+ hasState,
2638
+ writeState,
2639
+ readBaselineFile,
2640
+ readState,
2641
+ composeFiles,
2642
+ summarize,
2643
+ compile,
2644
+ hasDrift,
2645
+ checkDrift,
2646
+ formatDrift,
2647
+ planUpdate,
2648
+ update,
2649
+ summarizeLayers,
2650
+ explain,
2651
+ explainFile,
2652
+ explainDest,
2653
+ formatExplanation,
2654
+ blastRadius,
2655
+ describeBlastRadius,
2656
+ commonAncestor,
2657
+ composeToMemory,
2658
+ roundTripVerify,
2659
+ describeMismatches,
2660
+ status,
2661
+ formatStatus,
2662
+ promote,
2663
+ extract
2664
+ };
2665
+ //# sourceMappingURL=chunk-QKQUPZVD.js.map