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.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/SPEC.md +920 -0
- package/dist/chunk-QKQUPZVD.js +2665 -0
- package/dist/chunk-QKQUPZVD.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +293 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1399 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Moffatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# treelay
|
|
2
|
+
|
|
3
|
+
An inheritance/composition system for directory trees — think `extends`/mixins
|
|
4
|
+
for the filesystem. A directory declares inheritance **parents** and **mixins**;
|
|
5
|
+
`treelay compile` resolves the whole graph and materializes a flat output
|
|
6
|
+
directory. The output stays **linked** to its template, so template changes can
|
|
7
|
+
be pulled into an already-created, already-edited project (`update`), and local
|
|
8
|
+
edits can be pushed back up into the layers they belong to (`promote`).
|
|
9
|
+
|
|
10
|
+
> **Status: early, working — the link is bidirectional and layers are
|
|
11
|
+
> distributable.** The architecture and full design are specified in
|
|
12
|
+
> [SPEC.md](./SPEC.md). Resolution (C3), value resolution, **`compile`**,
|
|
13
|
+
> **`update`** (pulls template changes *down*) and **`status`/`promote`/`extract`**
|
|
14
|
+
> (push local edits *up*) are implemented and tested, with rendering, deep-merge,
|
|
15
|
+
> append/prepend, tombstones, sidecar/suffix ops, **unified-diff patches with true
|
|
16
|
+
> 3-way merge**, `.treelay` state, **`explain`** for tracing where any file came
|
|
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`.
|
|
19
|
+
|
|
20
|
+
## Why not just OverlayFS / copier / Kustomize?
|
|
21
|
+
|
|
22
|
+
- **OverlayFS** is a runtime union *mount* (Linux, root, ephemeral). treelay is a
|
|
23
|
+
build-time *compiler* producing plain, portable files.
|
|
24
|
+
- **copier** does the living-template update loop, but has no inheritance or
|
|
25
|
+
mixins — each template keeps an isolated answers file. treelay composes a graph
|
|
26
|
+
of parents + mixins (C3-linearized) with **one merged questionnaire**.
|
|
27
|
+
- **Kustomize** does structured patch composition, but only for Kubernetes YAML
|
|
28
|
+
and with throwaway output. treelay works on arbitrary file trees and keeps the
|
|
29
|
+
link alive.
|
|
30
|
+
|
|
31
|
+
See [SPEC.md §1](./SPEC.md) for the full comparison.
|
|
32
|
+
|
|
33
|
+
## Core ideas
|
|
34
|
+
|
|
35
|
+
- **Composition graph** — `parents` (transitive, C3-linearized) and `mixins`,
|
|
36
|
+
with precedence `mounts < parents < mixins < self`.
|
|
37
|
+
- **Distributable layers** — a parent, mixin or mount can be a local path, a git
|
|
38
|
+
ref, or an npm package. Whatever a build resolves is pinned in `treelay.lock`,
|
|
39
|
+
so the next build reproduces it and an upstream that has moved is *reported*
|
|
40
|
+
rather than silently followed.
|
|
41
|
+
- **Per-file merge** — replace, deep-merge (JSON/YAML), 3-way text patch,
|
|
42
|
+
structured patch (RFC 7386/6902), append/prepend, tombstone delete.
|
|
43
|
+
- **`.treelay` sidecars** — the canonical operation format carrying strategy, the
|
|
44
|
+
recorded base (hash for drift detection, content for a true 3-way), and the
|
|
45
|
+
patch payload. A patch that can't be reconciled fails the build rather than
|
|
46
|
+
writing something half-merged.
|
|
47
|
+
- **Template variables** — declared across the graph and merged first, then
|
|
48
|
+
content is rendered (LiquidJS, sandboxed). Suffix opt-in (`*.tmpl`).
|
|
49
|
+
- **Two-way link** — `update` pulls template changes down; `promote`/`extract`
|
|
50
|
+
push instance edits up.
|
|
51
|
+
- **Living updates** — `update` merges the new template output against what it
|
|
52
|
+
produced last time and what you have since edited. Your changes survive; files
|
|
53
|
+
you never touched advance cleanly; genuine collisions surface as conflicts
|
|
54
|
+
rather than being guessed at.
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
treelay compile <src> <dest> # materialize template → destination
|
|
60
|
+
treelay update <dest> # pull template changes down (3-way merge)
|
|
61
|
+
# --on-conflict markers|rej --dry-run
|
|
62
|
+
treelay status <dest> # list local changes vs baseline (--json)
|
|
63
|
+
treelay promote <dest> [files...] --to <layer> # push edits up into a layer
|
|
64
|
+
# --dry-run --no-verify
|
|
65
|
+
treelay extract <dest> [files...] --as <path> # capture edits as a new layer
|
|
66
|
+
# --mixin --name
|
|
67
|
+
treelay lock [dir] # resolve every layer ref and pin it
|
|
68
|
+
# --check --update --drift
|
|
69
|
+
treelay plan [dir] # print the linearized layer order
|
|
70
|
+
treelay explain <dir> [file] # trace file provenance (--json for machine output)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`compile`, `update` and `plan` also take `--frozen-lockfile`.
|
|
74
|
+
|
|
75
|
+
### Layers from git and npm, pinned
|
|
76
|
+
|
|
77
|
+
A parent, mixin or mount can live anywhere. The three forms are told apart by
|
|
78
|
+
shape, so nothing has to be declared twice:
|
|
79
|
+
|
|
80
|
+
```jsonc
|
|
81
|
+
{
|
|
82
|
+
"parents": ["../core", "@acme/node-base@^2", "github:acme/base#v2"],
|
|
83
|
+
"mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" }
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Add `?path=core/_layer` to any non-local ref to use a subdirectory of the
|
|
88
|
+
fetched tree as the layer root.
|
|
89
|
+
|
|
90
|
+
**`mounts` vendor a whole tree into the output** at a fixed subpath. Mount paths
|
|
91
|
+
merge by ordinary layer precedence, which is the point: a leaf can hold
|
|
92
|
+
`packages/` back at an older pin while its parents float, using the same
|
|
93
|
+
override rule as everything else rather than a separate package mechanism.
|
|
94
|
+
|
|
95
|
+
```console
|
|
96
|
+
$ treelay plan edo/soredi/_layer
|
|
97
|
+
Layers (lowest → highest precedence):
|
|
98
|
+
1. mount:packages [mounted at packages/, pinned 64cf108ee311]
|
|
99
|
+
2. core
|
|
100
|
+
3. soredi
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Whatever gets materialized is pinned in `treelay.lock` beside the leaf manifest
|
|
104
|
+
— canonical ref → exact commit (or version) plus an integrity hash over the
|
|
105
|
+
tree. It is deterministically serialized, so re-resolving an unchanged tree
|
|
106
|
+
produces byte-identical output and never shows up in a diff.
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ treelay lock . --check # CI: is the lockfile complete and current?
|
|
110
|
+
$ treelay lock . --drift # has anything moved upstream? (exits 1 if so)
|
|
111
|
+
1 ref(s) have moved upstream since treelay.lock was written:
|
|
112
|
+
git+https://host/acme/packages.git#v1.2.0
|
|
113
|
+
pinned 64cf108ee311
|
|
114
|
+
v1.2.0 is now 3991f53a3bf4 (packages/)
|
|
115
|
+
This build used the pinned revisions. Run `treelay lock --update` to advance them.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Drift is reported, never followed.** `compile` and `update` keep producing the
|
|
119
|
+
locked revision even after a branch advances — that is what pinning means, and
|
|
120
|
+
an update that quietly recomposed at a newer commit would make "pull my
|
|
121
|
+
template's changes down" mean something different depending on the day.
|
|
122
|
+
`treelay lock --update` is the only thing that advances a pin, and
|
|
123
|
+
`--frozen-lockfile` refuses to resolve anything the lock does not already pin.
|
|
124
|
+
|
|
125
|
+
Two asymmetries worth knowing:
|
|
126
|
+
|
|
127
|
+
- **Git pins are enforced; npm pins are recorded.** treelay materializes a git
|
|
128
|
+
commit from its own cache, so a build reproduces regardless of the branch. npm
|
|
129
|
+
layers resolve through the installed `node_modules` — treelay checks the
|
|
130
|
+
version satisfies the range and records it, but installation stays your
|
|
131
|
+
package manager's job. Rolling one back is `npm ci`'s job, not treelay's.
|
|
132
|
+
- **Offline means *unknown*, not *unchanged*.** A drift probe that could not
|
|
133
|
+
reach the remote says so rather than reporting in sync.
|
|
134
|
+
|
|
135
|
+
### Pulling template changes into a project you've edited
|
|
136
|
+
|
|
137
|
+
This is the headline: a compiled project stays linked to its template, so the
|
|
138
|
+
template can keep evolving after the project exists.
|
|
139
|
+
|
|
140
|
+
```console
|
|
141
|
+
$ treelay update ./my-service
|
|
142
|
+
New variables: region
|
|
143
|
+
U .github/workflows/ci.yml # you never touched it — updated cleanly
|
|
144
|
+
M pipeline.yml # both changed, merged
|
|
145
|
+
D legacy.cfg # template dropped it, you hadn't edited it
|
|
146
|
+
… 2 file(s) with local edits left as-is.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Update reloads the answers it was built with and asks **only** about variables
|
|
150
|
+
the new template version introduced. Files you created yourself are never
|
|
151
|
+
touched. Genuine collisions are reported and written as conflicts rather than
|
|
152
|
+
guessed at — and `update` exits non-zero so CI notices:
|
|
153
|
+
|
|
154
|
+
```console
|
|
155
|
+
$ treelay update ./my-service --on-conflict rej
|
|
156
|
+
C pipeline.yml ← conflict
|
|
157
|
+
|
|
158
|
+
1 conflict(s). Your files are unchanged; incoming versions are in *.rej.
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--on-conflict markers` (the default) writes diff3 markers in place, including
|
|
162
|
+
the base section so you can see what the template *used* to produce. `rej` keeps
|
|
163
|
+
the working file byte-identical and drops the incoming version at `<file>.rej` —
|
|
164
|
+
use it when a file has to stay parseable. `--dry-run` shows the plan and writes
|
|
165
|
+
nothing.
|
|
166
|
+
|
|
167
|
+
Run it twice and the second run is a no-op; the baseline advances to whatever
|
|
168
|
+
the template produced, so a conflict is never re-offered once you've dealt with
|
|
169
|
+
it.
|
|
170
|
+
|
|
171
|
+
### Tracing where a file came from
|
|
172
|
+
|
|
173
|
+
`explain` is the debugging story for a system whose whole job is "this file came
|
|
174
|
+
from somewhere non-obvious." Point it at a source layer or a compiled
|
|
175
|
+
destination; omit the file to explain the entire composition.
|
|
176
|
+
|
|
177
|
+
```console
|
|
178
|
+
$ treelay explain ./my-service src/config.json
|
|
179
|
+
Layers (lowest → highest precedence):
|
|
180
|
+
1. @acme/node-base (parent)
|
|
181
|
+
2. with-ci (mixin)
|
|
182
|
+
3. my-service (self)
|
|
183
|
+
|
|
184
|
+
src/config.json ← with-ci (deep-merge)
|
|
185
|
+
1. @acme/node-base parent create src/config.json
|
|
186
|
+
2. with-ci mixin deep-merge src/config.json
|
|
187
|
+
3. my-service self merge src/config.json.treelay [sidecar, base sha256:ab12cd3…]
|
|
188
|
+
folded in: @acme/node-base, sidecar
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Layers that cannot be written to — fetched git/npm layers, and mounts — are
|
|
192
|
+
tagged `[read-only]`, which is what filters them out as promotion targets (§8).
|
|
193
|
+
Fetched layers also show the revision in use, so `explain` answers "which
|
|
194
|
+
version of the base am I actually on?" without a second command.
|
|
195
|
+
|
|
196
|
+
A compiled destination explains itself — it reconstructs its own graph from the
|
|
197
|
+
lockfile lineage and re-renders with the answers it was built with:
|
|
198
|
+
|
|
199
|
+
```console
|
|
200
|
+
$ treelay explain ./build --json | jq '.files["src/config.json"].winner'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Notes on behaviour worth knowing:
|
|
204
|
+
|
|
205
|
+
- **Tombstoned files still appear**, marked not-present, so you can see *what*
|
|
206
|
+
deleted them rather than just finding them missing.
|
|
207
|
+
- **A patch that cannot be applied is described, not thrown on** — `explain`
|
|
208
|
+
stays usable precisely when `compile` is failing.
|
|
209
|
+
- `winner`/`strategy`/`patchedFrom` mirror what `compile` records, and a test
|
|
210
|
+
asserts the two agree so they cannot drift apart.
|
|
211
|
+
|
|
212
|
+
### Pushing an edit back up (reflux)
|
|
213
|
+
|
|
214
|
+
The mirror image of `update`. You edited a file in the compiled project and
|
|
215
|
+
realised it belongs in a layer, so every sibling project gets it too. `status`
|
|
216
|
+
is `git status` plus blame — it tells you not just what changed but where it
|
|
217
|
+
could go:
|
|
218
|
+
|
|
219
|
+
```console
|
|
220
|
+
$ treelay status ./out
|
|
221
|
+
A extra.ts ← local-only (no template origin)
|
|
222
|
+
M notes.txt ← produced by @acme/base
|
|
223
|
+
|
|
224
|
+
$ treelay promote ./out notes.txt --to @acme/base
|
|
225
|
+
Promoted into @acme/base:
|
|
226
|
+
rewrite notes.txt → notes.txt
|
|
227
|
+
Round-trip verified: the destination reproduces from the template.
|
|
228
|
+
|
|
229
|
+
@acme/base is consumed beyond this project — 1 other layer inherits it. This edit reaches all of them on their next update.
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
After a verified promote the change flows down by inheritance, so it stops
|
|
233
|
+
showing up as local drift — `status` now lists only `extra.ts`.
|
|
234
|
+
|
|
235
|
+
**Three guards stand between you and a bad promotion:**
|
|
236
|
+
|
|
237
|
+
1. **Shadowing** — promoting somewhere a higher layer would override is refused,
|
|
238
|
+
with the layer to use instead:
|
|
239
|
+
```console
|
|
240
|
+
$ treelay promote ./out a.txt --to base
|
|
241
|
+
Promoting a.txt to base has no effect; with-ci overrides this file at a higher precedence.
|
|
242
|
+
Promote to with-ci or to the leaf instead (§8 guard 1).
|
|
243
|
+
```
|
|
244
|
+
2. **Round-trip verification** — after writing, treelay recompiles and asserts
|
|
245
|
+
the destination reproduces byte-for-byte. If it doesn't, the layer writes are
|
|
246
|
+
rolled back and nothing is left half-applied. Pass `--no-verify` to skip it.
|
|
247
|
+
3. **Blast radius** — promoting reaches every sibling layer and every compiled
|
|
248
|
+
destination downstream of the target. That reach is reported (on stderr)
|
|
249
|
+
rather than left for someone else to discover on their next `update`.
|
|
250
|
+
|
|
251
|
+
Where the change lands is chosen for you: a layer that already produces the file
|
|
252
|
+
gets its **source rewritten**; a layer above the producer gets a **sidecar with
|
|
253
|
+
only the delta** (recording both `base` and `baseContent`, so it still merges
|
|
254
|
+
cleanly after the parent drifts); a locally-added file is **created**; a deletion
|
|
255
|
+
becomes a **tombstone**.
|
|
256
|
+
|
|
257
|
+
`extract` does the same thing into a brand-new layer:
|
|
258
|
+
|
|
259
|
+
```console
|
|
260
|
+
$ treelay extract ./out a.txt --as ../house-style --mixin
|
|
261
|
+
Extracted 1 file(s) → /work/house-style
|
|
262
|
+
a.txt
|
|
263
|
+
Wired in as a mixin of the leaf; round-trip verified.
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Without `--mixin` the layer is created but left out of the graph — treelay says
|
|
267
|
+
so and deliberately skips verification and rebaselining, because the edits are
|
|
268
|
+
still local until you wire it in.
|
|
269
|
+
|
|
270
|
+
### Composition rules worth knowing
|
|
271
|
+
|
|
272
|
+
- **`.git` is never composed**, in either form — a real directory *or* the
|
|
273
|
+
gitlink *file* that a git submodule checkout carries. Layers vendored as
|
|
274
|
+
submodules are safe to compose; `.gitignore`/`.gitmodules` are ordinary
|
|
275
|
+
content and compose normally. (SPEC §4)
|
|
276
|
+
- **`treelay.lock` never composes.** Like the manifest, it is layer metadata,
|
|
277
|
+
not content — otherwise a layer's pins would be published into every tree
|
|
278
|
+
built from it, and the leaf's own lockfile would show up as a generated file
|
|
279
|
+
that `update` reports as changed.
|
|
280
|
+
- **The destination may live inside the source tree.** Compiling into a
|
|
281
|
+
gitignored `build/` within the source repo is supported: the destination is
|
|
282
|
+
pruned from the layer walk, so recompiles never re-consume their own output.
|
|
283
|
+
A destination *equal to* a layer root is refused. (SPEC §7)
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
npm install
|
|
289
|
+
npm run typecheck # tsc --noEmit
|
|
290
|
+
npm test # vitest
|
|
291
|
+
npm run build # tsup → dist/
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT
|