treelay 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,10 @@ edits can be pushed back up into the layers they belong to (`promote`).
15
15
  > append/prepend, tombstones, sidecar/suffix ops, **unified-diff patches with true
16
16
  > 3-way merge**, `.treelay` state, **`explain`** for tracing where any file came
17
17
  > from, and **git/npm layer refs pinned in `treelay.lock`** with vendored
18
- > `mounts` and drift detection. Remaining per SPEC §12: `watch` and `eject`.
18
+ > `mounts` and drift detection. `validate`, `watch` and `eject` complete the
19
+ > SPEC §12 build order — every documented command is implemented. What remains
20
+ > is the **[open]** v2 design work: hunk-level reflux, `by-key` array merging,
21
+ > and the reflux/variables interaction (§8).
19
22
 
20
23
  ## Why not just OverlayFS / copier / Kustomize?
21
24
 
@@ -33,7 +36,10 @@ See [SPEC.md §1](./SPEC.md) for the full comparison.
33
36
  ## Core ideas
34
37
 
35
38
  - **Composition graph** — `parents` (transitive, C3-linearized) and `mixins`,
36
- with precedence `mounts < parents < mixins < self`.
39
+ with precedence `mounts < parents < mixins < self`. Mixins carry their own
40
+ parents in with them. Note the two lists run opposite ways: the *first*
41
+ parent declared outranks the rest (Python's C3), while the *last* mixin
42
+ declared wins.
37
43
  - **Distributable layers** — a parent, mixin or mount can be a local path, a git
38
44
  ref, or an npm package. Whatever a build resolves is pinned in `treelay.lock`,
39
45
  so the next build reproduces it and an upstream that has moved is *reported*
@@ -68,10 +74,25 @@ treelay lock [dir] # resolve every layer ref and pin it
68
74
  # --check --update --drift
69
75
  treelay plan [dir] # print the linearized layer order
70
76
  treelay explain <dir> [file] # trace file provenance (--json for machine output)
77
+ treelay validate [dir] # cycles, failing patches, conflicts, stale lock
78
+ # --drift (network) --json
79
+ treelay watch <src> <dest> # recompile on change
80
+ # --debounce ms --poll
81
+ treelay eject <dest> # drop .treelay state, keep the files (--dry-run)
71
82
  ```
72
83
 
73
84
  `compile`, `update` and `plan` also take `--frozen-lockfile`.
74
85
 
86
+ `validate` is built to be a merge gate: it reports every problem it can find in
87
+ one pass instead of stopping at the first, exits non-zero only on real errors
88
+ (stale pins and missing answers are warnings), and always lists the checks it
89
+ could *not* run — a clean report that quietly skipped half of them would be
90
+ worse than a noisy one.
91
+
92
+ `eject` is one-way. It deletes the baseline that makes `update` a three-way
93
+ merge rather than a guess, and nothing in the output can reconstruct it, so
94
+ `--dry-run` shows what the link was tracking before you cut it.
95
+
75
96
  ### Layers from git and npm, pinned
76
97
 
77
98
  A parent, mixin or mount can live anywhere. The three forms are told apart by
@@ -93,11 +114,11 @@ merge by ordinary layer precedence, which is the point: a leaf can hold
93
114
  override rule as everything else rather than a separate package mechanism.
94
115
 
95
116
  ```console
96
- $ treelay plan edo/soredi/_layer
117
+ $ treelay plan klamath/project/_layer
97
118
  Layers (lowest → highest precedence):
98
119
  1. mount:packages [mounted at packages/, pinned 64cf108ee311]
99
120
  2. core
100
- 3. soredi
121
+ 3. project
101
122
  ```
102
123
 
103
124
  Whatever gets materialized is pinned in `treelay.lock` beside the leaf manifest
@@ -284,6 +305,8 @@ still local until you wire it in.
284
305
 
285
306
  ## Development
286
307
 
308
+ Requires Node 20.19 or newer.
309
+
287
310
  ```bash
288
311
  npm install
289
312
  npm run typecheck # tsc --noEmit
package/SPEC.md CHANGED
@@ -96,7 +96,7 @@ Three origins, told apart by shape alone so nothing has to be declared twice:
96
96
 
97
97
  Any non-local ref may carry **`?path=<subdir>`** to use a subdirectory of the
98
98
  fetched tree as the layer root — the monorepo-of-layers case
99
- (`git+https://…/lake.git?path=core/_layer#v3`). Order follows URL convention:
99
+ (`git+https://…/klamath.git?path=core/_layer#v3`). Order follows URL convention:
100
100
  query before fragment.
101
101
 
102
102
  **Git refs are enforced; npm refs are delegated.** A git ref names a commit, and
@@ -166,6 +166,24 @@ Final stack, lowest → highest:
166
166
  2. `mixins` in declaration order (each strictly above all parents)
167
167
  3. the directory's own files (always win)
168
168
 
169
+ The two lists run in opposite directions, which is worth stating plainly:
170
+ `parents` follows Python, so the **first**-declared base is the most derived and
171
+ outranks the ones after it; `mixins` are layered on in order, so the **last**
172
+ one declared wins. Parents answer "what am I a specialization of" (earlier =
173
+ closer to you); mixins answer "what is layered on top" (later = on top).
174
+
175
+ ### Mixin ancestry **[decided]** ✅ *implemented*
176
+
177
+ A mixin may declare `parents` of its own. Each mixin's ancestry is C3-linearized
178
+ and placed **directly beneath that mixin**, while the whole group stays above
179
+ every one of the leaf's parents — so `parents < mixins < self` still holds when
180
+ read as groups rather than individual layers.
181
+
182
+ A layer that already appears lower in the stack keeps its position rather than
183
+ being re-inserted beneath its mixin. This is the diamond rule again: a shared
184
+ ancestor applies once, at its lowest position, because promoting it upward would
185
+ let it override the very layers it is supposed to sit beneath.
186
+
169
187
  ### Guards **[decided]**
170
188
 
171
189
  - **Cycle detection** — `A → B → A` fails loud with the full path shown.
@@ -190,7 +208,7 @@ in a diff.
190
208
  "resolved": "3f2a1c9e…", // the immutable thing it became
191
209
  "integrity": "sha256:…", // over the materialized tree
192
210
  "path": "core/_layer", // ?path=, when present
193
- "requestedBy": ["edo/soredi/_layer"] // relative to the lockfile
211
+ "requestedBy": ["klamath/project/_layer"] // relative to the lockfile
194
212
  }
195
213
  }
196
214
  }
@@ -810,9 +828,12 @@ treelay explain <dir> [file] [--set k=v] [--answers f] [--json]
810
828
  # trace which layers touched a file, in order, with patches
811
829
  # <dir> = a source layer, or a compiled destination
812
830
  # omit [file] to explain every path in the composition
813
- treelay validate [dir] # cycles? patches apply? unresolved conflicts? drift vs lock?
814
- treelay watch <src> <dest> # recompile on change
815
- treelay eject <dest> # flatten + drop .treelay state (sever the template link)
831
+ treelay validate [dir] [--set k=v] [--answers f] [--drift] [--json]
832
+ # cycles? patches apply? unresolved conflicts? drift vs lock?
833
+ treelay watch <src> <dest> [--set k=v] [--debounce ms] [--poll]
834
+ # recompile on change
835
+ treelay eject <dest> [--dry-run]
836
+ # flatten + drop .treelay state (sever the template link)
816
837
  ```
817
838
 
818
839
  `promote` and `extract` always end with the §8 round-trip recompile-and-verify,
@@ -820,6 +841,15 @@ and both refuse read-only or shadowed targets with a clear explanation.
820
841
 
821
842
  `compile`, `update` and `plan` all accept `--frozen-lockfile` (§3).
822
843
 
844
+ `validate` **collects** rather than stopping at the first problem: it is
845
+ producing a report, not an artifact, so a finding only suppresses the checks
846
+ that genuinely cannot run without it, and whatever went unchecked is always
847
+ stated. Errors exit non-zero; warnings (stale pins, missing answers) do not, so
848
+ it can be a merge gate. `watch` re-resolves the whole graph on every pass —
849
+ editing a manifest can reshape the layer stack, so nothing from the previous
850
+ pass can be assumed still valid. `eject` is one-way: the baseline it removes is
851
+ the merge base `update` needs, and no part of the output can reconstruct it.
852
+
823
853
  `plan` and `explain` are not nice-to-haves — they are the debugging story for a
824
854
  system whose entire job is "this file came from somewhere non-obvious." Build
825
855
  `plan` before `compile`.
@@ -913,8 +943,12 @@ De-risk by building resolution first, then output, then the bidirectional loops:
913
943
  7. ✅ `explain` — per-file provenance (source layers or a compiled destination; `--json`)
914
944
  8. ✅ `status` + file-level `promote` / `extract` — reflux (pushes changes *up*)
915
945
  9. ✅ npm/git layer resolution + `mounts` + `treelay.lock` (pins, drift, `--frozen-lockfile`)
916
- 10. `watch` / `eject` next
946
+ 10. `validate` / `watch` / `eject` — the remaining §9 surface
917
947
 
918
948
  Demoable and trustworthy after step 3; templated scaffolding works at step 4;
919
949
  the headline pull-down lands at step 6, and the bidirectional link closes at
920
950
  step 8.
951
+
952
+ Every command in §9 is now implemented. What remains is the **[open]** design
953
+ work rather than build order: hunk-level reflux, `by-key` array merging, and the
954
+ reflux/variables interaction (§8) — all deferred to v2 on purpose.
@@ -8,14 +8,14 @@ var CycleError = class extends Error {
8
8
  path;
9
9
  };
10
10
  var InconsistentHierarchyError = class extends Error {
11
- constructor(message) {
12
- super(`Inconsistent hierarchy: ${message}`);
11
+ constructor(message2) {
12
+ super(`Inconsistent hierarchy: ${message2}`);
13
13
  this.name = "InconsistentHierarchyError";
14
14
  }
15
15
  };
16
16
  var MergeConflictError = class extends Error {
17
- constructor(file, message) {
18
- super(`Merge conflict in ${file}: ${message}`);
17
+ constructor(file, message2) {
18
+ super(`Merge conflict in ${file}: ${message2}`);
19
19
  this.file = file;
20
20
  this.name = "MergeConflictError";
21
21
  }
@@ -135,7 +135,20 @@ function templateTarget(path, suffix = DEFAULT_TEMPLATE_SUFFIX) {
135
135
 
136
136
  // src/variables.ts
137
137
  import { createInterface } from "readline/promises";
138
+ import { Writable } from "stream";
138
139
  import { parse as parseYaml2 } from "yaml";
140
+ var MaskableOutput = class extends Writable {
141
+ constructor(target = process.stdout) {
142
+ super();
143
+ this.target = target;
144
+ }
145
+ target;
146
+ muted = false;
147
+ _write(chunk, _encoding, done) {
148
+ if (!this.muted) this.target.write(chunk);
149
+ done();
150
+ }
151
+ };
139
152
  function mergeVariableDecls(layers) {
140
153
  const merged = {};
141
154
  for (const layer of layers) {
@@ -196,11 +209,28 @@ async function resolveValues(graph, options = {}) {
196
209
  const resolved = new Set(Object.keys(provided).filter((n) => decls[n]));
197
210
  let pending = Object.keys(decls).filter((n) => !resolved.has(n));
198
211
  let rl;
212
+ let out;
199
213
  const promptVar = async (name, decl, fallback) => {
200
- rl ??= createInterface({ input: process.stdin, output: process.stdout });
214
+ if (!rl) {
215
+ out = new MaskableOutput();
216
+ rl = createInterface({
217
+ input: process.stdin,
218
+ output: out,
219
+ // readline infers `terminal` from `output.isTTY`, which a proxy stream
220
+ // does not have. Without this it would disable echo and line editing
221
+ // even in a real terminal.
222
+ terminal: process.stdout.isTTY === true
223
+ });
224
+ }
201
225
  const hint = decl.choices ? ` ${JSON.stringify(decl.choices)}` : "";
202
226
  const def = fallback !== void 0 ? ` [${String(fallback)}]` : "";
203
- const answer = (await rl.question(`${decl.prompt}${hint}${def} `)).trim();
227
+ const pendingAnswer = rl.question(`${decl.prompt}${hint}${def} `);
228
+ if (decl.secret && out) out.muted = true;
229
+ const answer = (await pendingAnswer).trim();
230
+ if (decl.secret && out) {
231
+ out.muted = false;
232
+ out.write("\n");
233
+ }
204
234
  return answer === "" ? fallback : coerce(decl.type, answer);
205
235
  };
206
236
  try {
@@ -466,8 +496,8 @@ import { execFileSync } from "child_process";
466
496
  import { existsSync as existsSync3, rmSync as rmSync2 } from "fs";
467
497
  import { join as join4 } from "path";
468
498
  var GitFetchError = class extends Error {
469
- constructor(url, message) {
470
- super(`git layer ${url}: ${message}`);
499
+ constructor(url, message2) {
500
+ super(`git layer ${url}: ${message2}`);
471
501
  this.url = url;
472
502
  this.name = "GitFetchError";
473
503
  }
@@ -580,8 +610,8 @@ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
580
610
  import { dirname, join as join5 } from "path";
581
611
  import { satisfies, validRange } from "semver";
582
612
  var NpmResolveError = class extends Error {
583
- constructor(packageName, message) {
584
- super(`npm layer ${packageName}: ${message}`);
613
+ constructor(packageName, message2) {
614
+ super(`npm layer ${packageName}: ${message2}`);
585
615
  this.packageName = packageName;
586
616
  this.name = "NpmResolveError";
587
617
  }
@@ -775,7 +805,17 @@ function resolve(srcDir, options = {}) {
775
805
  const parentsOf = (layer) => (layer.manifest.parents ?? []).map((ref) => loadRef(ctx, ref, layer));
776
806
  const mro = c3Linearize(self, parentsOf, (l) => l.id);
777
807
  const parentsLowToHigh = mro.slice(1).reverse();
778
- const mixins = (self.manifest.mixins ?? []).map((ref) => loadRef(ctx, ref, self));
808
+ const mixinGroups = (self.manifest.mixins ?? []).flatMap((ref) => {
809
+ const mixin = loadRef(ctx, ref, self);
810
+ return c3Linearize(mixin, parentsOf, (l) => l.id).reverse();
811
+ });
812
+ const placed = /* @__PURE__ */ new Set([self.id, ...parentsLowToHigh.map((l) => l.id)]);
813
+ const mixins = [];
814
+ for (const layer of mixinGroups) {
815
+ if (placed.has(layer.id)) continue;
816
+ placed.add(layer.id);
817
+ mixins.push(layer);
818
+ }
779
819
  const declared = [...parentsLowToHigh, ...mixins, self];
780
820
  const mounts = resolveMounts(ctx, declared);
781
821
  const layers = [...mounts, ...declared];
@@ -944,11 +984,11 @@ function assertParseable(file, patch) {
944
984
  try {
945
985
  hunks = parsePatch(patch).reduce((n, p) => n + p.hunks.length, 0);
946
986
  } 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)." : "";
987
+ const message2 = err instanceof Error ? err.message : String(err);
988
+ const hint = /invalid line/i.test(message2) ? "\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
989
  throw new MergeConflictError(
950
990
  file,
951
- `patch payload is not a valid unified diff \u2014 ${message}${hint}`
991
+ `patch payload is not a valid unified diff \u2014 ${message2}${hint}`
952
992
  );
953
993
  }
954
994
  if (hunks === 0) {
@@ -2569,6 +2609,298 @@ var LayerTransaction = class {
2569
2609
  }
2570
2610
  };
2571
2611
 
2612
+ // src/eject.ts
2613
+ import { rmSync as rmSync6 } from "fs";
2614
+ var NotEjectableError = class extends Error {
2615
+ constructor(destDir) {
2616
+ super(
2617
+ `${destDir} has no .treelay state \u2014 it is not a compiled destination, so there is no template link to sever.`
2618
+ );
2619
+ this.name = "NotEjectableError";
2620
+ }
2621
+ };
2622
+ function eject(destDir, options = {}) {
2623
+ if (!hasState(destDir)) throw new NotEjectableError(destDir);
2624
+ const state = readState(destDir);
2625
+ const entries = Object.entries(state.manifest);
2626
+ const source = sourceOf(state);
2627
+ const result = {
2628
+ ...source !== void 0 ? { source } : {},
2629
+ lineage: state.lock.lineage,
2630
+ // `owned` marks a *user*-created file, so the template's own files are the
2631
+ // ones where it is false (§7).
2632
+ tracked: entries.filter(([, m]) => !m.owned).map(([path]) => path).sort(),
2633
+ userOwned: entries.filter(([, m]) => m.owned).map(([path]) => path).sort(),
2634
+ removed: false
2635
+ };
2636
+ if (options.dryRun) return result;
2637
+ rmSync6(statePaths(destDir).dir, { recursive: true, force: true });
2638
+ return { ...result, removed: true };
2639
+ }
2640
+ function formatEject(result, destDir) {
2641
+ const lines = [];
2642
+ const verb = result.removed ? "Ejected" : "Would eject";
2643
+ lines.push(`${verb} ${destDir}.`);
2644
+ if (result.source) lines.push(` was tracking: ${result.source}`);
2645
+ if (result.lineage.length) {
2646
+ lines.push(` composed from ${result.lineage.length} layer(s)`);
2647
+ }
2648
+ lines.push(` ${result.tracked.length} template-owned file(s) left in place`);
2649
+ if (result.userOwned.length) {
2650
+ lines.push(` ${result.userOwned.length} file(s) you created, untouched`);
2651
+ }
2652
+ lines.push(
2653
+ result.removed ? `
2654
+ \`treelay update\` no longer works here. Re-linking means compiling the template into a fresh directory and copying your changes across.` : `
2655
+ Nothing was removed. Re-run without --dry-run to sever the link.`
2656
+ );
2657
+ return lines.join("\n");
2658
+ }
2659
+
2660
+ // src/validate.ts
2661
+ async function validate(srcDir, options = {}) {
2662
+ const issues = [];
2663
+ const skipped = [];
2664
+ const { values: given, drift: probeDrift, ...resolveOptions } = options;
2665
+ let graph;
2666
+ try {
2667
+ graph = resolve(srcDir, resolveOptions);
2668
+ } catch (err) {
2669
+ issues.push(classifyResolveError(err));
2670
+ skipped.push("patches and conflicts (the graph did not resolve)");
2671
+ skipped.push("lockfile freshness (the graph did not resolve)");
2672
+ return { ok: false, issues, skipped };
2673
+ }
2674
+ if (graph.lockDirty) {
2675
+ issues.push({
2676
+ severity: "warning",
2677
+ code: "lock-stale",
2678
+ message: `${lockfilePath(srcDir)} does not match what this tree resolves to.`,
2679
+ remedy: `Run \`treelay lock ${srcDir}\` and commit the result.`
2680
+ });
2681
+ }
2682
+ let values;
2683
+ try {
2684
+ values = await resolveValues(graph, { ...given ? { set: given } : {}, prompt: false });
2685
+ } catch (err) {
2686
+ issues.push({
2687
+ severity: "warning",
2688
+ code: "variables-unresolved",
2689
+ message: message(err),
2690
+ remedy: "Pass --answers or --set so validate can render and compose the tree; without values it cannot check patches or conflicts."
2691
+ });
2692
+ skipped.push("patches and conflicts (no values to render with)");
2693
+ }
2694
+ if (values) {
2695
+ try {
2696
+ const files = await composeToMemory(graph, values);
2697
+ return finish(issues, skipped, graph, files.size, probeDrift, srcDir);
2698
+ } catch (err) {
2699
+ issues.push(classifyComposeError(err));
2700
+ }
2701
+ }
2702
+ return finish(issues, skipped, graph, void 0, probeDrift, srcDir);
2703
+ }
2704
+ function finish(issues, skipped, graph, fileCount, probeDrift, srcDir) {
2705
+ if (probeDrift) {
2706
+ const reports = checkDrift(graph);
2707
+ if (hasDrift(reports)) {
2708
+ issues.push({
2709
+ severity: "warning",
2710
+ code: "drift",
2711
+ message: formatDrift(reports).trim(),
2712
+ remedy: `Run \`treelay lock ${srcDir} --update\` to advance the pins.`
2713
+ });
2714
+ }
2715
+ } else {
2716
+ skipped.push("upstream drift (needs the network; pass --drift)");
2717
+ }
2718
+ return {
2719
+ ok: !issues.some((i) => i.severity === "error"),
2720
+ issues,
2721
+ layerCount: graph.layers.length,
2722
+ ...fileCount !== void 0 ? { fileCount } : {},
2723
+ skipped
2724
+ };
2725
+ }
2726
+ var message = (err) => err instanceof Error ? err.message : String(err);
2727
+ function classifyResolveError(err) {
2728
+ const name = err instanceof Error ? err.name : "";
2729
+ if (name === "CycleError") {
2730
+ return {
2731
+ severity: "error",
2732
+ code: "cycle",
2733
+ message: message(err),
2734
+ remedy: "Break the loop: a layer cannot be its own ancestor."
2735
+ };
2736
+ }
2737
+ if (name === "InconsistentHierarchyError") {
2738
+ return {
2739
+ severity: "error",
2740
+ code: "hierarchy",
2741
+ message: message(err),
2742
+ remedy: "Two layers demand incompatible orderings of the same ancestors. Reorder the conflicting `parents` lists so they agree."
2743
+ };
2744
+ }
2745
+ return { severity: "error", code: "resolve-failed", message: message(err) };
2746
+ }
2747
+ function classifyComposeError(err) {
2748
+ if (err instanceof Error && err.name === "MergeConflictError") {
2749
+ return {
2750
+ severity: "error",
2751
+ code: "merge-conflict",
2752
+ message: message(err),
2753
+ remedy: "Either the patch no longer applies to the inherited file, or two layers edit the same lines. `treelay explain <dir> <file>` shows who touched it."
2754
+ };
2755
+ }
2756
+ return { severity: "error", code: "compose-failed", message: message(err) };
2757
+ }
2758
+ function formatValidation(report, srcDir) {
2759
+ const lines = [];
2760
+ const errors = report.issues.filter((i) => i.severity === "error");
2761
+ const warnings = report.issues.filter((i) => i.severity === "warning");
2762
+ for (const issue of [...errors, ...warnings]) {
2763
+ lines.push(`${issue.severity === "error" ? "\u2717" : "!"} ${issue.code}: ${issue.message}`);
2764
+ if (issue.remedy) lines.push(` ${issue.remedy}`);
2765
+ }
2766
+ if (lines.length) lines.push("");
2767
+ const scope = report.layerCount === void 0 ? "" : ` (${report.layerCount} layer(s)` + (report.fileCount === void 0 ? "" : `, ${report.fileCount} file(s)`) + ")";
2768
+ lines.push(
2769
+ errors.length ? `${errors.length} error(s), ${warnings.length} warning(s) in ${srcDir}${scope}.` : warnings.length ? `No errors, ${warnings.length} warning(s) in ${srcDir}${scope}.` : `${srcDir} is valid${scope}.`
2770
+ );
2771
+ for (const s of report.skipped) lines.push(` not checked: ${s}`);
2772
+ return lines.join("\n");
2773
+ }
2774
+
2775
+ // src/watch.ts
2776
+ import { watch as chokidarWatch } from "chokidar";
2777
+ import { relative as relative4, resolve as resolvePath6, isAbsolute as isAbsolute3, sep as sep2 } from "path";
2778
+ var WatchTargetError = class extends Error {
2779
+ constructor(destDir, existing, incoming) {
2780
+ super(
2781
+ `${destDir} was compiled from ${existing}, not ${incoming}. Watching it would overwrite that project's files. Pick an empty destination.`
2782
+ );
2783
+ this.name = "WatchTargetError";
2784
+ }
2785
+ };
2786
+ function isInside(parent, child) {
2787
+ const rel = relative4(parent, child);
2788
+ return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
2789
+ }
2790
+ function watchRoots(layerDirs, destDir) {
2791
+ const roots = [];
2792
+ for (const dir of layerDirs) {
2793
+ if (isInside(dir, destDir) && dir === destDir) continue;
2794
+ if (!roots.some((r) => isInside(r, dir))) {
2795
+ for (let i = roots.length - 1; i >= 0; i--) {
2796
+ if (isInside(dir, roots[i])) roots.splice(i, 1);
2797
+ }
2798
+ roots.push(dir);
2799
+ }
2800
+ }
2801
+ return roots.sort();
2802
+ }
2803
+ async function watch(srcDir, destDir, options = {}) {
2804
+ const {
2805
+ values,
2806
+ debounceMs = 120,
2807
+ usePolling,
2808
+ pollIntervalMs,
2809
+ onEvent,
2810
+ ...resolveOptions
2811
+ } = options;
2812
+ const src = resolvePath6(srcDir);
2813
+ const dest = resolvePath6(destDir);
2814
+ if (hasState(dest)) {
2815
+ const existing = sourceOf(readState(dest));
2816
+ if (existing && resolvePath6(existing) !== src) {
2817
+ throw new WatchTargetError(dest, existing, src);
2818
+ }
2819
+ }
2820
+ let watcher;
2821
+ let roots = [src];
2822
+ const syncRoots = (dirs) => {
2823
+ const next = watchRoots([src, ...dirs], dest);
2824
+ const added = next.filter((r) => !roots.includes(r));
2825
+ roots = next;
2826
+ if (added.length && watcher) watcher.add(added);
2827
+ };
2828
+ const build = async (trigger) => {
2829
+ const started = Date.now();
2830
+ try {
2831
+ const graph = resolve(src, resolveOptions);
2832
+ const composed = await resolveValues(graph, {
2833
+ ...values ? { set: values } : {},
2834
+ prompt: false
2835
+ });
2836
+ const result = await compile(graph, { destDir: dest, values: composed });
2837
+ syncRoots(
2838
+ graph.layers.filter((l) => !l.origin || l.origin.kind === "local").map((l) => l.dir)
2839
+ );
2840
+ onEvent?.({
2841
+ kind: "compiled",
2842
+ files: Object.keys(result.files).length,
2843
+ ms: Date.now() - started,
2844
+ ...trigger ? { trigger } : {}
2845
+ });
2846
+ } catch (err) {
2847
+ onEvent?.({
2848
+ kind: "failed",
2849
+ error: err instanceof Error ? err : new Error(String(err)),
2850
+ ...trigger ? { trigger } : {}
2851
+ });
2852
+ }
2853
+ };
2854
+ await build();
2855
+ let timer;
2856
+ let pending;
2857
+ let closed = false;
2858
+ watcher = chokidarWatch(roots, {
2859
+ ignoreInitial: true,
2860
+ ...usePolling ? { usePolling: true } : {},
2861
+ ...pollIntervalMs !== void 0 ? { interval: pollIntervalMs } : {},
2862
+ // The output must never feed the loop that produces it, and neither should
2863
+ // the state directory compile itself writes on every pass.
2864
+ ignored: (path) => isInside(dest, path) || path.split(sep2).includes(STATE_DIR) || path.split(sep2).includes("node_modules") || path.split(sep2).includes(".git")
2865
+ });
2866
+ watcher.on("all", (_event, path) => {
2867
+ if (closed) return;
2868
+ pending ??= path;
2869
+ if (timer) clearTimeout(timer);
2870
+ timer = setTimeout(() => {
2871
+ const trigger = pending;
2872
+ pending = void 0;
2873
+ timer = void 0;
2874
+ void build(trigger);
2875
+ }, debounceMs);
2876
+ });
2877
+ await new Promise((res) => watcher.once("ready", () => res()));
2878
+ onEvent?.({ kind: "ready", roots });
2879
+ return {
2880
+ // A getter, not a snapshot: `syncRoots` replaces the array when the graph
2881
+ // gains a layer, and a stale copy would misreport what is being watched.
2882
+ get roots() {
2883
+ return roots;
2884
+ },
2885
+ trigger: () => build(),
2886
+ close: async () => {
2887
+ closed = true;
2888
+ if (timer) clearTimeout(timer);
2889
+ await watcher.close();
2890
+ }
2891
+ };
2892
+ }
2893
+ function formatWatchEvent(event) {
2894
+ switch (event.kind) {
2895
+ case "ready":
2896
+ return `Watching ${event.roots.length} layer(s). Ctrl-C to stop.`;
2897
+ case "compiled":
2898
+ return `Compiled ${event.files} file(s) in ${event.ms}ms` + (event.trigger ? ` \u2190 ${event.trigger}` : "");
2899
+ case "failed":
2900
+ return `Build failed: ${event.error.message}`;
2901
+ }
2902
+ }
2903
+
2572
2904
  export {
2573
2905
  CycleError,
2574
2906
  InconsistentHierarchyError,
@@ -2660,6 +2992,14 @@ export {
2660
2992
  status,
2661
2993
  formatStatus,
2662
2994
  promote,
2663
- extract
2995
+ extract,
2996
+ NotEjectableError,
2997
+ eject,
2998
+ formatEject,
2999
+ validate,
3000
+ formatValidation,
3001
+ WatchTargetError,
3002
+ watch,
3003
+ formatWatchEvent
2664
3004
  };
2665
- //# sourceMappingURL=chunk-QKQUPZVD.js.map
3005
+ //# sourceMappingURL=chunk-CP7GIVGA.js.map