@orkestrel/scaffold 0.0.1

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/bin/scaffold.js +1539 -0
  4. package/dist/bin/scaffold.js.map +1 -0
  5. package/dist/host/AGENTS.md +939 -0
  6. package/dist/host/CLAUDE.md +495 -0
  7. package/dist/host/LICENSE +21 -0
  8. package/dist/host/claude/agents/builder.md +48 -0
  9. package/dist/host/claude/agents/checker.md +37 -0
  10. package/dist/host/claude/agents/composer.md +64 -0
  11. package/dist/host/claude/agents/grok.md +50 -0
  12. package/dist/host/claude/agents/orkestrel.md +236 -0
  13. package/dist/host/claude/agents/planner.md +44 -0
  14. package/dist/host/claude/agents/researcher.md +38 -0
  15. package/dist/host/claude/agents/reviewer.md +47 -0
  16. package/dist/host/claude/agents/scout.md +35 -0
  17. package/dist/host/claude/agents/verifier.md +34 -0
  18. package/dist/host/claude/settings.json +26 -0
  19. package/dist/host/dotfiles/editorconfig +17 -0
  20. package/dist/host/dotfiles/gitattributes +3 -0
  21. package/dist/host/dotfiles/gitignore +40 -0
  22. package/dist/host/dotfiles/oxfmtrc.json +18 -0
  23. package/dist/host/dotfiles/oxlintignore +20 -0
  24. package/dist/host/dotfiles/oxlintrc.json +58 -0
  25. package/dist/host/dotfiles/prettierignore +5 -0
  26. package/dist/host/github/workflows/ci.yml +64 -0
  27. package/dist/host/guides/src/guide.md +312 -0
  28. package/dist/host/guides/src/scaffold.md +2152 -0
  29. package/dist/host/manifest.json +137 -0
  30. package/dist/host/scripts/cursor.sh +74 -0
  31. package/dist/host/scripts/deps.sh +38 -0
  32. package/dist/host/scripts/ollama.sh +163 -0
  33. package/dist/src/core/index.cjs +3728 -0
  34. package/dist/src/core/index.cjs.map +1 -0
  35. package/dist/src/core/index.d.cts +1941 -0
  36. package/dist/src/core/index.d.ts +1941 -0
  37. package/dist/src/core/index.js +3636 -0
  38. package/dist/src/core/index.js.map +1 -0
  39. package/dist/src/server/index.cjs +1595 -0
  40. package/dist/src/server/index.cjs.map +1 -0
  41. package/dist/src/server/index.d.cts +779 -0
  42. package/dist/src/server/index.d.ts +779 -0
  43. package/dist/src/server/index.js +1572 -0
  44. package/dist/src/server/index.js.map +1 -0
  45. package/package.json +113 -0
@@ -0,0 +1,1572 @@
1
+ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { flattenText, isBlockquoteNode, isParagraphNode, parseDocument, walkNodes } from "@orkestrel/markdown";
5
+ import { DEFAULT_ENGINES, DEFAULT_VERSION, HOST_PATHS, ScaffoldError, blueprint, devDependenciesFor, manifestToDependencies, rangeToFreshness } from "../core/index.js";
6
+ import { Emitter } from "@orkestrel/emitter";
7
+ //#region src/server/helpers.ts
8
+ /**
9
+ * Locate this MODULE's own installed package root — the nearest ancestor of
10
+ * `import.meta.url` holding a `package.json` — and return its vendored
11
+ * `dist/host` data root. THE single source of truth for the default
12
+ * `Materializer` / `scaffold` bin host: once installed, walking up from the
13
+ * module's own file (not `process.cwd()`, which points at whichever project
14
+ * happens to be running) resolves to `node_modules/@orkestrel/scaffold`, the
15
+ * correct default host — the package ships its vendored data with itself.
16
+ * `dist/host` may not exist yet when this resolves from SOURCE under a test
17
+ * runner; that is fine — existence is checked at the point of use, not here.
18
+ *
19
+ * @returns The absolute vendored `dist/host` path.
20
+ * @throws `ScaffoldError('TARGET', …)` when no ancestor of this module's own
21
+ * location holds a `package.json`.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { hostRoot } from '@orkestrel/scaffold/server'
26
+ *
27
+ * hostRoot() // '/…/node_modules/@orkestrel/scaffold/dist/host'
28
+ * ```
29
+ */
30
+ function hostRoot() {
31
+ let dir = dirname(fileURLToPath(import.meta.url));
32
+ for (;;) {
33
+ if (existsSync(join(dir, "package.json"))) return join(dir, "dist", "host");
34
+ const parent = dirname(dir);
35
+ if (parent === dir) throw new ScaffoldError("TARGET", "No package root found above the module location", { module: import.meta.url });
36
+ dir = parent;
37
+ }
38
+ }
39
+ /**
40
+ * Filter a manifest record's entries down to `@orkestrel/`-prefixed keys with
41
+ * string values — the shared `dependencies` / `peerDependencies` /
42
+ * `devDependencies` reader `deriveBlueprint` uses for every dependency-shaped
43
+ * field.
44
+ *
45
+ * @param value - The candidate manifest field value (e.g. `parsed.dependencies`).
46
+ * @returns The `@orkestrel/`-prefixed `[name, range]` entries; `[]` when
47
+ * `value` is not a plain object (per `isRecord`).
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { selectOrkestrelEntries } from '@orkestrel/scaffold/server'
52
+ *
53
+ * selectOrkestrelEntries({ '@orkestrel/core': '^1.0.0', lodash: '^4.0.0' })
54
+ * // [['@orkestrel/core', '^1.0.0']]
55
+ * ```
56
+ */
57
+ function selectOrkestrelEntries(value) {
58
+ if (!isRecord(value)) return [];
59
+ return Object.entries(value).filter((entry) => typeof entry[1] === "string" && entry[0].startsWith("@orkestrel/"));
60
+ }
61
+ /**
62
+ * Reconstruct a `Blueprint` from an EXISTING repo at `target` — the faithful
63
+ * inverse `audit` / `repair` / `mirror` need to diff a live package against
64
+ * its own would-be scaffold, rather than a fresh, dependency-less stand-in.
65
+ *
66
+ * @param target - The existing package directory to derive a `Blueprint` from.
67
+ * @remarks
68
+ * `name` strips the `@orkestrel/` prefix off `manifest.name` — a non-`@orkestrel`
69
+ * name is a coded `TARGET` failure, since this tool derives only `@orkestrel`
70
+ * packages. `surfaces` is read off the LIVE line: every surface the package
71
+ * carries has a `src/<surface>/` directory, so each of `'core' | 'browser' |
72
+ * 'server'` is included iff that directory exists at `target`; a target with
73
+ * NONE of the three is also a coded `TARGET` failure. `dependencies` /
74
+ * `peers` are the `@orkestrel/`-prefixed entries of `manifest.dependencies` /
75
+ * `manifest.peerDependencies` (a peer flagged `peerDependenciesMeta[name]
76
+ * .optional === true` carries `optional: true`). `extras` is EVERY entry of
77
+ * `manifest.devDependencies` (not only `@orkestrel/`-prefixed ones — an
78
+ * external extra like `zod` must round-trip too), EXCLUDING the generated
79
+ * devDependency baseline (`devDependenciesFor([])`'s keys, which already
80
+ * cover `@orkestrel/guide` and `@orkestrel/scaffold`) every scaffolded
81
+ * package already carries, never a package-specific extra. A devDependency
82
+ * ALSO present in
83
+ * `manifest.peerDependencies` or `manifest.dependencies` (e.g. a peer
84
+ * dev-installed for local testing) is likewise excluded from `extras` — it
85
+ * already surfaces as a `peer`/`dependency` above, and double-counting it as
86
+ * an `extra` would land it in `peers ∩ extras`, a blocking `validateBlueprint`
87
+ * gate. `overrides` is always `[]` — derivation cannot know a caller's
88
+ * template-override intent.
89
+ * @returns The reconstructed `Blueprint`.
90
+ * @throws `ScaffoldError('TARGET', …)` when `target`'s manifest is unreadable
91
+ * (via `readManifest`), is not valid JSON, its `name` is not `@orkestrel`-
92
+ * prefixed, or `target` carries none of the three surface directories.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { deriveBlueprint } from '@orkestrel/scaffold/server'
97
+ *
98
+ * deriveBlueprint('./packages/router') // { name: 'router', surfaces: ['core', 'server'], … }
99
+ * ```
100
+ */
101
+ function deriveBlueprint(target) {
102
+ const BASELINE_EXTRAS = /* @__PURE__ */ new Set([
103
+ ...Object.keys(devDependenciesFor([])),
104
+ "@orkestrel/guide",
105
+ "@orkestrel/scaffold"
106
+ ]);
107
+ const text = readManifest(target);
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ } catch (error) {
112
+ throw new ScaffoldError("TARGET", `Manifest at ${target} is not valid JSON`, {
113
+ target,
114
+ error
115
+ });
116
+ }
117
+ if (!isRecord(parsed)) throw new ScaffoldError("TARGET", `Manifest at ${target} is not a JSON object`, { target });
118
+ const rawName = parsed.name;
119
+ if (typeof rawName !== "string" || !rawName.startsWith("@orkestrel/")) throw new ScaffoldError("TARGET", `Manifest name "${String(rawName)}" is not an @orkestrel package`, {
120
+ target,
121
+ name: rawName
122
+ });
123
+ const name = rawName.slice(11);
124
+ const description = typeof parsed.description === "string" ? parsed.description : void 0;
125
+ const keywords = Array.isArray(parsed.keywords) && parsed.keywords.every((word) => typeof word === "string") ? parsed.keywords : [];
126
+ const version = typeof parsed.version === "string" ? parsed.version : DEFAULT_VERSION;
127
+ const engines = isRecord(parsed.engines) && typeof parsed.engines.node === "string" ? parsed.engines.node : DEFAULT_ENGINES;
128
+ const surfaces = [
129
+ "core",
130
+ "browser",
131
+ "server"
132
+ ].filter((surface) => existsSync(join(target, "src", surface)));
133
+ if (surfaces.length === 0) throw new ScaffoldError("TARGET", `No surface directory found under ${target}/src`, { target });
134
+ const dependencies = selectOrkestrelEntries(parsed.dependencies).map(([depName, range]) => ({
135
+ name: depName,
136
+ range
137
+ }));
138
+ const peersMeta = isRecord(parsed.peerDependenciesMeta) ? parsed.peerDependenciesMeta : void 0;
139
+ const peers = selectOrkestrelEntries(parsed.peerDependencies).map(([depName, range]) => {
140
+ const meta = peersMeta !== void 0 ? peersMeta[depName] : void 0;
141
+ return isRecord(meta) && meta.optional === true ? {
142
+ name: depName,
143
+ range,
144
+ optional: true
145
+ } : {
146
+ name: depName,
147
+ range
148
+ };
149
+ });
150
+ const peerAndDependencyNames = /* @__PURE__ */ new Set([...selectOrkestrelEntries(parsed.peerDependencies).map(([depName]) => depName), ...selectOrkestrelEntries(parsed.dependencies).map(([depName]) => depName)]);
151
+ const devDependencies = isRecord(parsed.devDependencies) ? parsed.devDependencies : {};
152
+ return blueprint(name, {
153
+ description,
154
+ keywords,
155
+ surfaces,
156
+ dependencies,
157
+ peers,
158
+ extras: Object.entries(devDependencies).filter((entry) => typeof entry[1] === "string").filter(([depName]) => !BASELINE_EXTRAS.has(depName) && !peerAndDependencyNames.has(depName)).map(([depName, range]) => ({
159
+ name: depName,
160
+ range
161
+ })),
162
+ version,
163
+ engines,
164
+ overrides: []
165
+ });
166
+ }
167
+ /**
168
+ * Whether a target path is absent, empty, or contains nothing but a `.git`
169
+ * directory — the green-field target law `Materializer.materialize` enforces.
170
+ *
171
+ * @param target - The candidate target directory path.
172
+ * @returns `true` when `target` is safe to materialize a fresh package into.
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * import { isVacant } from '@orkestrel/scaffold/server'
177
+ *
178
+ * isVacant('./packages/router-new') // true — absent, empty, or only a .git dir
179
+ * ```
180
+ */
181
+ function isVacant(target) {
182
+ if (!existsSync(target)) return true;
183
+ if (!statSync(target).isDirectory()) return false;
184
+ const entries = readdirSync(target);
185
+ return entries.length === 0 || entries.length === 1 && entries[0] === ".git";
186
+ }
187
+ /**
188
+ * Read a target's current content at a set of relative paths into a
189
+ * `Record<string, string>` — the I/O that feeds the pure `diffPlan`.
190
+ *
191
+ * @param target - The target directory to read from.
192
+ * @param paths - The plan-relative artifact paths to probe.
193
+ * @returns A record keyed by path; a directory entry maps to `''` (presence
194
+ * only — a `host`-origin directory artifact is audited by presence, never
195
+ * content), an absent path is OMITTED entirely (never an empty-string
196
+ * placeholder for a missing file, so `diffPlan` reports it `missing`).
197
+ * @throws `ScaffoldError('TARGET', …)` when an EXISTING path fails to read
198
+ * (e.g. `EACCES` / `EPERM`) — carries the offending relative `path` (and
199
+ * the resolved `full` path) in `context`. An absent path is never an
200
+ * error — it is simply omitted, per the return contract above.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * import { readTarget } from '@orkestrel/scaffold/server'
205
+ *
206
+ * readTarget('./packages/router', ['package.json', 'src/core/index.ts'])
207
+ * // { 'package.json': '{ "name": … }', 'src/core/index.ts': '…' }
208
+ * ```
209
+ */
210
+ function readTarget(target, paths) {
211
+ const current = {};
212
+ for (const path of paths) {
213
+ const full = join(target, path);
214
+ if (!existsSync(full)) continue;
215
+ try {
216
+ current[path] = statSync(full).isDirectory() ? "" : readFileSync(full, "utf8");
217
+ } catch (error) {
218
+ throw new ScaffoldError("TARGET", `Failed to read target file at ${path}`, {
219
+ path,
220
+ full,
221
+ error
222
+ });
223
+ }
224
+ }
225
+ return current;
226
+ }
227
+ /**
228
+ * Read `target/package.json` text — the read that feeds `manifestToDependencies`.
229
+ *
230
+ * @param target - The target directory to read the manifest from.
231
+ * @returns The manifest file's raw text.
232
+ * @throws `ScaffoldError('TARGET', …)` when the manifest is absent or
233
+ * unreadable (e.g. `EACCES` / `EPERM`) — carries the resolved `full` path
234
+ * in `context`.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * import { readManifest } from '@orkestrel/scaffold/server'
239
+ *
240
+ * readManifest('./packages/router') // '{ "name": "@orkestrel/router", … }'
241
+ * ```
242
+ */
243
+ function readManifest(target) {
244
+ const full = join(target, "package.json");
245
+ try {
246
+ return readFileSync(full, "utf8");
247
+ } catch (error) {
248
+ throw new ScaffoldError("TARGET", `Failed to read manifest at ${full}`, {
249
+ target,
250
+ full,
251
+ error
252
+ });
253
+ }
254
+ }
255
+ /**
256
+ * Whether `value` is a plain object (not `null`, not an array).
257
+ *
258
+ * @param value - The candidate value.
259
+ * @returns `true` when `value` narrows to `Record<string, unknown>`.
260
+ *
261
+ * @example
262
+ * ```ts
263
+ * import { isRecord } from '@orkestrel/scaffold/server'
264
+ *
265
+ * isRecord({ a: 1 }) // true
266
+ * isRecord(null) // false
267
+ * ```
268
+ */
269
+ function isRecord(value) {
270
+ return typeof value === "object" && value !== null && !Array.isArray(value);
271
+ }
272
+ /**
273
+ * Whether `value` is a well-formed `host/manifest.json` entry — a string
274
+ * `storage`, a string `destination`, and a boolean `executable`.
275
+ *
276
+ * @param value - The candidate raw manifest entry.
277
+ * @returns `true` when `value` narrows to `ManifestEntry`.
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * import { isManifestEntry } from '@orkestrel/scaffold/server'
282
+ *
283
+ * isManifestEntry({ storage: 'a', destination: 'b', executable: false }) // true
284
+ * isManifestEntry({ storage: 'a', destination: 'b' }) // false — missing `executable`
285
+ * ```
286
+ */
287
+ function isManifestEntry(value) {
288
+ if (!isRecord(value)) return false;
289
+ return typeof value.storage === "string" && typeof value.destination === "string" && typeof value.executable === "boolean";
290
+ }
291
+ /**
292
+ * Read and validate a vendored host root's `manifest.json`, when present.
293
+ *
294
+ * @param host - The host root to probe.
295
+ * @returns The parsed entries, or `undefined` when `host` has no
296
+ * `manifest.json` — the raw-repo-root fallback (`Materializer` then maps
297
+ * an artifact's `source` to `host` 1:1, no vendored staging indirection).
298
+ * @throws `ScaffoldError('TARGET', …)` when `manifest.json` exists but is
299
+ * unreadable, is not valid JSON, or is not an array of `ManifestEntry`.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * import { readHostManifest } from '@orkestrel/scaffold/server'
304
+ *
305
+ * readHostManifest('./dist/host') // readonly ManifestEntry[] | undefined
306
+ * ```
307
+ */
308
+ function readHostManifest(host) {
309
+ const full = join(host, "manifest.json");
310
+ if (!existsSync(full)) return void 0;
311
+ let text;
312
+ try {
313
+ text = readFileSync(full, "utf8");
314
+ } catch (error) {
315
+ throw new ScaffoldError("TARGET", `Failed to read host manifest at ${full}`, {
316
+ host,
317
+ full,
318
+ error
319
+ });
320
+ }
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(text);
324
+ } catch (error) {
325
+ throw new ScaffoldError("TARGET", `Host manifest at ${full} is not valid JSON`, {
326
+ host,
327
+ full,
328
+ error
329
+ });
330
+ }
331
+ if (!Array.isArray(parsed) || !parsed.every(isManifestEntry)) throw new ScaffoldError("TARGET", `Host manifest at ${full} is not an array of manifest entries`, {
332
+ host,
333
+ full
334
+ });
335
+ return parsed;
336
+ }
337
+ /**
338
+ * Recursively list a directory's files as root-relative paths.
339
+ *
340
+ * @param root - The directory to list.
341
+ * @returns Root-relative file paths (posix-style `/` separators), or `[]`
342
+ * when `root` is absent.
343
+ *
344
+ * @example
345
+ * ```ts
346
+ * import { listFiles } from '@orkestrel/scaffold/server'
347
+ *
348
+ * listFiles('./dist/host/.claude/agents') // ['scout.md', 'builder.md', …]
349
+ * ```
350
+ */
351
+ function listFiles(root) {
352
+ if (!existsSync(root)) return [];
353
+ const files = [];
354
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
355
+ const full = join(root, entry.name);
356
+ if (entry.isDirectory()) for (const nested of listFiles(full)) files.push(`${entry.name}/${nested}`);
357
+ else files.push(entry.name);
358
+ }
359
+ return files;
360
+ }
361
+ /**
362
+ * Map a repo-relative path to its vendored-host STAGING path, per the
363
+ * dotfile-mapping rule `stageHost` writes into `manifest.json`.
364
+ *
365
+ * @param path - The repo-relative source path (e.g. `.claude/agents/scout.md`).
366
+ * @returns The mapped storage path: a leading-dot TOP-LEVEL FILE maps to
367
+ * `dotfiles/<name-without-dot>`; a leading-dot DIRECTORY segment loses its
368
+ * dot wherever it appears; an undotted path is unchanged.
369
+ *
370
+ * @example
371
+ * ```ts
372
+ * import { storagePath } from '@orkestrel/scaffold/server'
373
+ *
374
+ * storagePath('.gitignore') // 'dotfiles/gitignore'
375
+ * storagePath('.claude/agents/scout.md') // 'claude/agents/scout.md'
376
+ * storagePath('.github/workflows/ci.yml') // 'github/workflows/ci.yml'
377
+ * storagePath('AGENTS.md') // 'AGENTS.md'
378
+ * ```
379
+ */
380
+ function storagePath(path) {
381
+ const segments = path.split("/");
382
+ if (segments.length === 1) {
383
+ const name = segments[0];
384
+ return name.startsWith(".") ? `dotfiles/${name.slice(1)}` : name;
385
+ }
386
+ return segments.map((segment) => segment.startsWith(".") ? segment.slice(1) : segment).join("/");
387
+ }
388
+ /**
389
+ * Stage the vendored host set (byte-preserved copies + `manifest.json`) from
390
+ * a repo root into an output directory — the BUILD-time primitive the
391
+ * `build:host` npm script now calls directly (replacing a standalone build
392
+ * script); `Materializer.materialize` is the RUNTIME reader of what this
393
+ * writes (via `hostRoot` / `readHostManifest`).
394
+ *
395
+ * @param root - The repo root every `paths` entry resolves against.
396
+ * @param out - The output directory to wipe and stage into (typically `dist/host`).
397
+ * @param paths - The repo-relative file/directory entries to stage; defaults
398
+ * to the package's own vendored set (`HOST_PATHS`) — a caller passes an
399
+ * explicit list only to stage an arbitrary/test set.
400
+ * @remarks
401
+ * `out` is wiped (`rmSync(out, { recursive: true, force: true })`) BEFORE
402
+ * staging, so a stale file left over from a prior run never lingers. Each
403
+ * `paths` entry is walked to its per-file leaves (a directory recursively,
404
+ * via `listFiles`; a file, itself) and copied byte-for-byte
405
+ * (`copyFileSync`) to `<out>/<storagePath(path)>`. The `executable` flag
406
+ * `entries` reports is derived from the `.sh` suffix on `destination` —
407
+ * deterministic on every build platform (Windows `stat` carries no execute
408
+ * bit); all vendored executables are shell scripts by construction.
409
+ * `manifest.json` is written LAST, as `entries` code-unit sorted by
410
+ * `destination`, tab-indented JSON with a trailing newline.
411
+ * @returns The written manifest's entries (`{ storage, destination, executable }`).
412
+ * @throws `ScaffoldError('TARGET', …)` naming the offending path when a
413
+ * `paths` entry has no source under `root`, or naming BOTH colliding
414
+ * destinations when two entries map to the same `storagePath` (the guard
415
+ * run BEFORE `manifest.json` is written).
416
+ *
417
+ * @example
418
+ * ```ts
419
+ * import { stageHost } from '@orkestrel/scaffold/server'
420
+ *
421
+ * const entries = stageHost(process.cwd(), 'dist/host')
422
+ * entries.length // number of files staged
423
+ * ```
424
+ */
425
+ function stageHost(root, out, paths = HOST_PATHS) {
426
+ const destinations = [];
427
+ for (const path of paths) {
428
+ const absolute = join(root, path);
429
+ if (!existsSync(absolute)) throw new ScaffoldError("TARGET", `Missing host source at ${path}`, {
430
+ path,
431
+ root
432
+ });
433
+ if (statSync(absolute).isDirectory()) for (const nested of listFiles(absolute)) destinations.push(`${path}/${nested}`);
434
+ else destinations.push(path);
435
+ }
436
+ rmSync(out, {
437
+ recursive: true,
438
+ force: true
439
+ });
440
+ const entries = [];
441
+ for (const destination of destinations) {
442
+ const storage = storagePath(destination);
443
+ const sourceAbsolute = join(root, destination);
444
+ const destinationAbsolute = join(out, storage);
445
+ mkdirSync(dirname(destinationAbsolute), { recursive: true });
446
+ copyFileSync(sourceAbsolute, destinationAbsolute);
447
+ const executable = destination.endsWith(".sh");
448
+ entries.push({
449
+ storage,
450
+ destination,
451
+ executable
452
+ });
453
+ }
454
+ entries.sort((a, b) => a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0);
455
+ const byStorage = /* @__PURE__ */ new Map();
456
+ for (const entry of entries) {
457
+ const existing = byStorage.get(entry.storage);
458
+ if (existing !== void 0) throw new ScaffoldError("TARGET", `Storage path collision at "${entry.storage}" — destinations "${existing}" and "${entry.destination}" both map to it`, {
459
+ storage: entry.storage,
460
+ destinations: [existing, entry.destination]
461
+ });
462
+ byStorage.set(entry.storage, entry.destination);
463
+ }
464
+ mkdirSync(out, { recursive: true });
465
+ writeFileSync(join(out, "manifest.json"), `${JSON.stringify(entries, null, " ")}\n`);
466
+ return entries;
467
+ }
468
+ /**
469
+ * Resolve the absolute host-storage path for a host-origin artifact's
470
+ * `source`, manifest-aware.
471
+ *
472
+ * @param manifest - The host's parsed `manifest.json` entries, or `undefined`
473
+ * when the host carries none (raw-repo-root fallback).
474
+ * @param source - The artifact's `source` (or `path`) to resolve.
475
+ * @param host - The resolved host root the path is joined against.
476
+ * @returns `join(host, source)` when `manifest` is `undefined` (no vendored
477
+ * staging indirection); when `manifest` is present, `join(host,
478
+ * entries[0].storage)` for the SINGLE manifest entry whose `destination`
479
+ * equals `source`, or `undefined` when zero or more than one entry matches
480
+ * (`source` names a directory, or the manifest is ambiguous — no single
481
+ * storage file to point at).
482
+ *
483
+ * @example
484
+ * ```ts
485
+ * import { locateHostSource } from '@orkestrel/scaffold/server'
486
+ *
487
+ * locateHostSource(undefined, 'package.json', './dist/host') // './dist/host/package.json'
488
+ * locateHostSource([{ storage: 'pkg.tmpl', destination: 'package.json', executable: false }], 'package.json', './dist/host')
489
+ * // './dist/host/pkg.tmpl'
490
+ * ```
491
+ */
492
+ function locateHostSource(manifest, source, host) {
493
+ if (manifest === void 0) return join(host, source);
494
+ const entries = manifest.filter((entry) => entry.destination === source);
495
+ if (entries.length !== 1) return void 0;
496
+ return join(host, entries[0].storage);
497
+ }
498
+ /**
499
+ * Rehydrate a `Plan`'s `host`-origin artifacts with their real byte content
500
+ * read from `host` — manifest-aware, via `locateHostSource`.
501
+ *
502
+ * @param plan - The plan to hydrate.
503
+ * @param host - The resolved host root to read from.
504
+ * @returns A new `Plan` whose file-shaped `host` artifacts carry `content`;
505
+ * `template` / `computed` artifacts and directory-shaped `host` artifacts
506
+ * (no single storage file to read) pass through untouched.
507
+ * @throws `ScaffoldError('TARGET', …)` when a resolved, existing host source
508
+ * file fails to read.
509
+ *
510
+ * @example
511
+ * ```ts
512
+ * import { hydratePlan } from '@orkestrel/scaffold/server'
513
+ *
514
+ * const hydrated = hydratePlan(plan, './dist/host')
515
+ * ```
516
+ */
517
+ function hydratePlan(plan, host) {
518
+ const manifest = readHostManifest(host);
519
+ const artifacts = plan.artifacts.map((artifact) => {
520
+ if (artifact.origin !== "host") return artifact;
521
+ const source = artifact.source ?? artifact.path;
522
+ const full = locateHostSource(manifest, source, host);
523
+ if (full === void 0 || !existsSync(full) || statSync(full).isDirectory()) return artifact;
524
+ try {
525
+ return {
526
+ ...artifact,
527
+ content: readFileSync(full, "utf8")
528
+ };
529
+ } catch (error) {
530
+ throw new ScaffoldError("TARGET", `Failed to read host artifact at ${source}`, {
531
+ source,
532
+ full,
533
+ error
534
+ });
535
+ }
536
+ });
537
+ return {
538
+ ...plan,
539
+ artifacts
540
+ };
541
+ }
542
+ /**
543
+ * The `prune`-owned directories — a hard allowlist; `pruneTargets` (and the
544
+ * `Materializer.prune` that consumes it) never scans anything outside these two.
545
+ *
546
+ * @example
547
+ * ```ts
548
+ * import { PRUNE_DIRECTORIES } from '@orkestrel/scaffold/server'
549
+ *
550
+ * PRUNE_DIRECTORIES // ['.claude/agents', 'scripts']
551
+ * ```
552
+ */
553
+ var PRUNE_DIRECTORIES = [".claude/agents", "scripts"];
554
+ /**
555
+ * The vendored set of destination-relative paths under `directory` (one of
556
+ * `PRUNE_DIRECTORIES`) that `pruneTargets` must NOT report — read from the
557
+ * manifest's `destination`s when `host` has one, else listed straight off
558
+ * `host/<directory>`.
559
+ *
560
+ * @param host - The vendored host root to establish the allowlist from.
561
+ * @param directory - The prune directory (one of `PRUNE_DIRECTORIES`) to scope the allowlist to.
562
+ * @remarks
563
+ * FAIL CLOSED: before returning any allowlist (even an empty one), the
564
+ * vendored source must be POSITIVELY established, or a caller would treat an
565
+ * unresolved host as "vendors nothing" and report every file under
566
+ * `target/<directory>` as unexpected. A missing `host` root, or (no
567
+ * `manifest.json` AND no `host/<directory>`), is a coded `TARGET` failure —
568
+ * the distinction this guards is missing-host vs genuinely-empty-vendor: a
569
+ * `host` that EXISTS and vendors zero files in `directory` (an existing empty
570
+ * dir, or a manifest with zero entries for it) remains a valid empty allowlist.
571
+ * @returns The allowed destination-relative paths under `directory`.
572
+ * @throws `ScaffoldError('TARGET', …)` when `host` does not exist, or when
573
+ * `host` has no `manifest.json` and no `host/<directory>` either.
574
+ *
575
+ * @example
576
+ * ```ts
577
+ * import { vendoredPruneSet } from '@orkestrel/scaffold/server'
578
+ *
579
+ * vendoredPruneSet('./dist/host', '.claude/agents') // Set { '.claude/agents/scout.md', … }
580
+ * ```
581
+ */
582
+ function vendoredPruneSet(host, directory) {
583
+ if (!existsSync(host)) throw new ScaffoldError("TARGET", `Cannot establish vendored source for prune: host root not found at ${host}`, {
584
+ host,
585
+ directory
586
+ });
587
+ const manifest = readHostManifest(host);
588
+ if (manifest !== void 0) return new Set(manifest.filter((entry) => entry.destination.startsWith(`${directory}/`)).map((entry) => entry.destination));
589
+ const hostDirectory = join(host, directory);
590
+ if (!existsSync(hostDirectory)) throw new ScaffoldError("TARGET", `Cannot establish vendored source for prune: no manifest.json and no host directory at ${hostDirectory}`, {
591
+ host,
592
+ directory
593
+ });
594
+ return new Set(listFiles(hostDirectory).map((relative) => `${directory}/${relative}`));
595
+ }
596
+ /**
597
+ * List the repo-relative POSIX paths under `target`'s prune directories
598
+ * (`.claude/agents`, `scripts`) that the vendored `host` allowlist does NOT
599
+ * declare — THE single source of truth for prune drift, consumed by both
600
+ * `Materializer.prune` (which deletes exactly these paths) and the bin's
601
+ * audit/preview UX (which now shows them honestly instead of a
602
+ * structurally-always-zero `audit.foreign`).
603
+ *
604
+ * @param target - The target directory to scan for unexpected files.
605
+ * @param host - The vendored host root the allowlist is derived from.
606
+ * @returns The unexpected relative paths (e.g. `.claude/agents/rogue.md`); `[]`
607
+ * when a prune directory is absent under `target`, or when none of its
608
+ * files are unexpected. Pure read — never deletes anything.
609
+ * @throws `ScaffoldError('TARGET', …)` when `host` cannot positively
610
+ * establish a vendored allowlist for a prune directory that DOES exist
611
+ * under `target` (see `vendoredPruneSet`'s fail-closed remarks).
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * import { pruneTargets } from '@orkestrel/scaffold/server'
616
+ *
617
+ * pruneTargets('./packages/router', hostRoot()) // ['.claude/agents/rogue.md']
618
+ * ```
619
+ */
620
+ function pruneTargets(target, host) {
621
+ const paths = [];
622
+ for (const directory of PRUNE_DIRECTORIES) {
623
+ const root = join(target, directory);
624
+ if (!existsSync(root)) continue;
625
+ const allowed = vendoredPruneSet(host, directory);
626
+ for (const relative of listFiles(root)) {
627
+ const path = `${directory}/${relative}`;
628
+ if (!allowed.has(path)) paths.push(path);
629
+ }
630
+ }
631
+ return paths;
632
+ }
633
+ /**
634
+ * List a fleet root's `@orkestrel/*` package directories.
635
+ *
636
+ * @param root - The fleet root directory to scan.
637
+ * @returns Absolute, code-unit-sorted paths of `root`'s immediate child
638
+ * directories whose `package.json` parses and whose `name` starts with
639
+ * `@orkestrel/`. A child with an unreadable or unparsable `package.json`,
640
+ * or a non-`@orkestrel` name, is skipped silently — it simply is not a
641
+ * fleet member.
642
+ *
643
+ * @example
644
+ * ```ts
645
+ * import { discoverPackages } from '@orkestrel/scaffold/server'
646
+ *
647
+ * discoverPackages('./packages') // ['/abs/packages/router', '/abs/packages/budget']
648
+ * ```
649
+ */
650
+ function discoverPackages(root) {
651
+ const packages = [];
652
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
653
+ if (!entry.isDirectory()) continue;
654
+ const directory = join(root, entry.name);
655
+ const manifestPath = join(directory, "package.json");
656
+ if (!existsSync(manifestPath)) continue;
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
660
+ } catch {
661
+ continue;
662
+ }
663
+ if (!isRecord(parsed)) continue;
664
+ const name = parsed.name;
665
+ if (typeof name === "string" && name.startsWith("@orkestrel/")) packages.push(directory);
666
+ }
667
+ return packages.sort();
668
+ }
669
+ /**
670
+ * Build the fleet package catalog — one `CatalogEntry` per `@orkestrel/*`
671
+ * package discovered under each root, its description drawn from its own
672
+ * guide's FIRST blockquote.
673
+ *
674
+ * @param roots - The fleet root directories to scan (each walked via `discoverPackages`).
675
+ * @remarks
676
+ * Per discovered package directory: `name` / `version` come from its own
677
+ * `package.json`; `description` is the first paragraph of the guide's opening
678
+ * blockquote — the flattened text of the FIRST `ParagraphNode` among the
679
+ * FIRST `BlockquoteNode` found's (depth-first, pre-order, via `walkNodes`)
680
+ * TOP-LEVEL children in its `guides/src/<short>.md` (`<short>` = `name` with
681
+ * the `@orkestrel/` prefix stripped), parsed with `@orkestrel/markdown`'s
682
+ * `parseDocument` — a multi-paragraph blockquote overview yields only its
683
+ * FIRST paragraph, never the whole quote glued together; embedded newlines
684
+ * collapse to single spaces, and surrounding whitespace trims. A
685
+ * missing/unreadable guide, a guide carrying no blockquote, or a blockquote
686
+ * with no top-level paragraph child, yields `description: ''`, never a
687
+ * thrown error. Entries merge across `roots` (a later root's entry for a
688
+ * repeated `name` wins), then code-unit sort by `name`. An unreadable ROOT
689
+ * itself is NOT wrapped here — whatever `discoverPackages` throws for it
690
+ * propagates as-is; the bin layer is responsible for coding that failure
691
+ * `TARGET`.
692
+ * @returns The merged, sorted `CatalogEntry[]`.
693
+ *
694
+ * @example
695
+ * ```ts
696
+ * import { catalogPackages } from '@orkestrel/scaffold/server'
697
+ *
698
+ * catalogPackages(['/repos']) // [{ name: '@orkestrel/contract', version: '0.0.5', description: '…' }, …]
699
+ * ```
700
+ */
701
+ function catalogPackages(roots) {
702
+ const merged = /* @__PURE__ */ new Map();
703
+ for (const root of roots) for (const directory of discoverPackages(root)) {
704
+ let parsed;
705
+ try {
706
+ parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
707
+ } catch {
708
+ continue;
709
+ }
710
+ if (!isRecord(parsed)) continue;
711
+ const name = parsed.name;
712
+ if (typeof name !== "string" || !name.startsWith("@orkestrel/")) continue;
713
+ const version = typeof parsed.version === "string" ? parsed.version : DEFAULT_VERSION;
714
+ const guidePath = join(directory, "guides", "src", `${name.slice(11)}.md`);
715
+ let description = "";
716
+ if (existsSync(guidePath)) try {
717
+ const document = parseDocument(readFileSync(guidePath, "utf8"));
718
+ let quote;
719
+ for (const node of walkNodes(document)) if (isBlockquoteNode(node)) {
720
+ quote = node;
721
+ break;
722
+ }
723
+ if (quote !== void 0) {
724
+ const paragraph = quote.children.find((child) => isParagraphNode(child));
725
+ if (paragraph !== void 0) description = flattenText(paragraph).replace(/\s+/g, " ").trim();
726
+ }
727
+ } catch {
728
+ description = "";
729
+ }
730
+ merged.set(name, {
731
+ name,
732
+ version,
733
+ description
734
+ });
735
+ }
736
+ return [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
737
+ }
738
+ //#endregion
739
+ //#region src/server/Materializer.ts
740
+ /**
741
+ * The materialization entity (server) — the only impure surface in the
742
+ * package, writing a `Plan` to `node:fs` behind an explicit call.
743
+ *
744
+ * @remarks
745
+ * `materialize` is green-field: it refuses any target `isVacant` rejects
746
+ * (`ScaffoldError('TARGET', …)`), then byte-copies each `host` artifact from
747
+ * the `host` root and writes each `template` / `computed` artifact's rendered
748
+ * `content`, failing fast on any write error (`ScaffoldError('WRITE', …)`).
749
+ * `repair` is into-existing: it skips the vacancy check and writes ONLY the
750
+ * `missing` / `stale` artifacts an `Audit` names, leaving `aligned` ones
751
+ * untouched. `prune` deletes stale files under `target/.claude/agents/` and
752
+ * `target/scripts/` that the vendored `host` no longer names — the retired
753
+ * `mirror.sh`/`scaffold.sh` cleanup step, now a method. After `destroy()`
754
+ * every method throws `DESTROYED`; teardown is idempotent, emitter last.
755
+ *
756
+ * @remarks
757
+ * `host`-origin copies are MANIFEST-AWARE: when the resolved `host` root
758
+ * carries a `manifest.json` (this package's own vendored `dist/host`), each
759
+ * artifact's `source` (a destination-relative path) is looked up in the
760
+ * manifest to find its un-dotted STORAGE path plus an `executable` bit
761
+ * (applied via `chmodSync` after the copy) — the vendored-package shape,
762
+ * where storage names avoid leading dots npm would otherwise mangle. When
763
+ * `host` carries no `manifest.json` (a caller-supplied raw repo root, e.g. a
764
+ * sibling checkout or a test fixture), `source` maps to `host` 1:1, exactly
765
+ * as before.
766
+ *
767
+ * @remarks
768
+ * A manifest-present `source` with ZERO matching entries degrades to a stub
769
+ * ONLY when that `source` is a dependency-guide pointer — one that starts
770
+ * with `guides/src/` and ends with `.md` (the `guides/src/<dep>.md` pointer
771
+ * `Compiler` emits for any dependency outside this package's vendored set).
772
+ * That is the ONLY zero-match case that is legitimate: every `HOST_PATHS`
773
+ * source is always staged by `stageHost`, so a non-guide zero-match means a
774
+ * corrupted or truncated `manifest.json`, not an intentionally-unvendored
775
+ * artifact. For a guide pointer, a short stub file is written at the
776
+ * destination and reported exactly like a successful copy, mirroring the
777
+ * READ path (`hydratePlan` leaves such artifacts `content`-undefined, so
778
+ * `diffPlan` audits them by PRESENCE only). For every OTHER zero-match, the
779
+ * fail-closed `ScaffoldError('TARGET', …)` is thrown — degrading an
780
+ * unscoped zero-match would otherwise let a corrupted manifest silently stub
781
+ * an unrecoverable artifact (e.g. `AGENTS.md` — `pull` only ever fetches
782
+ * dependency guides) or write a FILE named `.claude` over what should be a
783
+ * directory artifact. The raw-root fallback (`manifest === undefined`, a
784
+ * caller-supplied `--from`) keeps its own throw regardless: an EXPLICITLY
785
+ * named source failing to resolve is a different, caller-error failure
786
+ * class, not a "not vendored" degrade.
787
+ *
788
+ * @remarks
789
+ * Defense in depth at the filesystem trust boundary: EVERY resolved
790
+ * destination (`materialize` and `repair`, both origins) is asserted to stay
791
+ * within `resolve(target)` before any write, and every `host`-origin copy
792
+ * source is asserted to stay within `resolve(host)` before any read — a
793
+ * traversal segment (`../`) in an artifact's `path` or `source` cannot escape
794
+ * either root, even if a gate upstream (e.g. an ungated `Plan` built by hand)
795
+ * let it through. A destination violation throws `ScaffoldError('WRITE', …)`;
796
+ * a source violation throws `ScaffoldError('TARGET', …)`.
797
+ *
798
+ * @remarks
799
+ * The containment check is REAL-PATH aware, not merely lexical: both the
800
+ * root (`target` / `host`) and the candidate destination/source are resolved
801
+ * through `realpathSync` on their DEEPEST EXISTING ancestor before the prefix
802
+ * comparison, so a symlinked subdirectory planted inside an otherwise
803
+ * legitimate root cannot smuggle a write (or read) outside it — `repair`,
804
+ * which has no `isVacant` gate, is covered exactly like `materialize`. A path
805
+ * segment that does not yet exist on disk (the still-to-be-created file/dir
806
+ * a write is about to create) is rejoined onto the resolved existing
807
+ * ancestor rather than realpath'd itself.
808
+ *
809
+ * @example
810
+ * ```ts
811
+ * import { blueprint, blueprintToPlan } from '@orkestrel/scaffold'
812
+ * import { createMaterializer } from '@orkestrel/scaffold/server'
813
+ *
814
+ * const plan = blueprintToPlan(blueprint('budget', { surfaces: ['core'] }))
815
+ * const materializer = createMaterializer()
816
+ * materializer.materialize(plan, './packages/budget-new')
817
+ * materializer.destroy()
818
+ * ```
819
+ */
820
+ var Materializer = class Materializer {
821
+ #emitter;
822
+ #host;
823
+ #manifestLoaded = false;
824
+ #manifestEntries;
825
+ #destroyed = false;
826
+ constructor(options) {
827
+ this.#emitter = new Emitter({
828
+ on: options?.on,
829
+ error: options?.error
830
+ });
831
+ this.#host = options?.host ?? hostRoot();
832
+ }
833
+ get emitter() {
834
+ return this.#emitter;
835
+ }
836
+ materialize(plan, target) {
837
+ this.#ensureAlive();
838
+ if (!isVacant(target)) throw new ScaffoldError("TARGET", "materialize requires a vacant target", { target });
839
+ const written = [];
840
+ const copied = [];
841
+ for (const artifact of plan.artifacts) if (artifact.origin === "host") {
842
+ this.#copy(artifact, target);
843
+ copied.push(artifact.path);
844
+ } else {
845
+ this.#write(artifact, target);
846
+ written.push(artifact.path);
847
+ }
848
+ const result = {
849
+ target,
850
+ written,
851
+ copied,
852
+ skipped: [],
853
+ removed: []
854
+ };
855
+ this.#emitter.emit("done", result);
856
+ return result;
857
+ }
858
+ repair(plan, audit, target) {
859
+ this.#ensureAlive();
860
+ const drifted = new Map(audit.findings.map((finding) => [finding.path, finding.drift]));
861
+ const written = [];
862
+ const copied = [];
863
+ const skipped = [];
864
+ for (const artifact of plan.artifacts) {
865
+ const drift = drifted.get(artifact.path);
866
+ if (drift !== "missing" && drift !== "stale") {
867
+ skipped.push(artifact.path);
868
+ continue;
869
+ }
870
+ if (artifact.origin === "host") {
871
+ this.#copy(artifact, target);
872
+ copied.push(artifact.path);
873
+ } else {
874
+ this.#write(artifact, target);
875
+ written.push(artifact.path);
876
+ }
877
+ }
878
+ const result = {
879
+ target,
880
+ written,
881
+ copied,
882
+ skipped,
883
+ removed: []
884
+ };
885
+ this.#emitter.emit("done", result);
886
+ return result;
887
+ }
888
+ prune(target) {
889
+ this.#ensureAlive();
890
+ const removed = [];
891
+ for (const path of pruneTargets(target, this.#host)) {
892
+ unlinkSync(Materializer.#assertContained(target, path, "WRITE", path));
893
+ removed.push(path);
894
+ this.#emitter.emit("remove", path);
895
+ }
896
+ const result = {
897
+ target,
898
+ written: [],
899
+ copied: [],
900
+ skipped: [],
901
+ removed
902
+ };
903
+ this.#emitter.emit("done", result);
904
+ return result;
905
+ }
906
+ destroy() {
907
+ if (this.#destroyed) return;
908
+ this.#destroyed = true;
909
+ this.#emitter.emit("destroy");
910
+ this.#emitter.destroy();
911
+ }
912
+ #copy(artifact, target) {
913
+ const source = artifact.source ?? artifact.path;
914
+ const manifest = this.#manifest();
915
+ if (manifest === void 0) {
916
+ const from = Materializer.#assertContained(this.#host, source, "TARGET", artifact.path);
917
+ const to = Materializer.#assertContained(target, artifact.path, "WRITE", artifact.path);
918
+ try {
919
+ mkdirSync(dirname(to), { recursive: true });
920
+ cpSync(from, to, { recursive: true });
921
+ } catch (error) {
922
+ this.#emitter.emit("error", error);
923
+ throw new ScaffoldError("WRITE", `Failed to copy host artifact at ${artifact.path}`, {
924
+ path: artifact.path,
925
+ error
926
+ });
927
+ }
928
+ this.#emitter.emit("copy", artifact.path);
929
+ return;
930
+ }
931
+ const entries = manifest.filter((entry) => entry.destination === source || entry.destination.startsWith(`${source}/`));
932
+ if (entries.length === 0) {
933
+ if (!source.startsWith("guides/src/") || !source.endsWith(".md")) throw new ScaffoldError("TARGET", `Manifest entry for "${source}" is missing — the vendored manifest may be corrupted or truncated`, {
934
+ target,
935
+ source
936
+ });
937
+ const to = Materializer.#assertContained(target, artifact.path, "WRITE", artifact.path);
938
+ try {
939
+ mkdirSync(dirname(to), { recursive: true });
940
+ writeFileSync(to, Materializer.#stub(source), "utf8");
941
+ } catch (error) {
942
+ this.#emitter.emit("error", error);
943
+ throw new ScaffoldError("WRITE", `Failed to write host artifact stub at ${artifact.path}`, {
944
+ path: artifact.path,
945
+ error
946
+ });
947
+ }
948
+ this.#emitter.emit("copy", artifact.path);
949
+ return;
950
+ }
951
+ for (const entry of entries) {
952
+ const from = Materializer.#assertContained(this.#host, entry.storage, "TARGET", artifact.path);
953
+ const to = Materializer.#assertContained(target, entry.destination, "WRITE", artifact.path);
954
+ try {
955
+ mkdirSync(dirname(to), { recursive: true });
956
+ cpSync(from, to);
957
+ if (entry.executable) chmodSync(to, 493);
958
+ } catch (error) {
959
+ this.#emitter.emit("error", error);
960
+ throw new ScaffoldError("WRITE", `Failed to copy host artifact at ${artifact.path}`, {
961
+ path: artifact.path,
962
+ error
963
+ });
964
+ }
965
+ }
966
+ this.#emitter.emit("copy", artifact.path);
967
+ }
968
+ #manifest() {
969
+ if (!this.#manifestLoaded) {
970
+ this.#manifestEntries = readHostManifest(this.#host);
971
+ this.#manifestLoaded = true;
972
+ }
973
+ return this.#manifestEntries;
974
+ }
975
+ #write(artifact, target) {
976
+ const to = Materializer.#assertContained(target, artifact.path, "WRITE", artifact.path);
977
+ try {
978
+ mkdirSync(dirname(to), { recursive: true });
979
+ writeFileSync(to, artifact.content ?? "", "utf8");
980
+ } catch (error) {
981
+ this.#emitter.emit("error", error);
982
+ throw new ScaffoldError("WRITE", `Failed to write artifact at ${artifact.path}`, {
983
+ path: artifact.path,
984
+ error
985
+ });
986
+ }
987
+ this.#emitter.emit("write", artifact.path);
988
+ }
989
+ static #assertContained(root, relative, code, path) {
990
+ const resolvedRoot = Materializer.#resolveReal(resolve(root));
991
+ const resolvedCandidate = Materializer.#resolveReal(resolve(root, relative));
992
+ if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + sep)) throw new ScaffoldError(code, `Artifact path "${path}" escapes the ${code === "WRITE" ? "target" : "host"} root`, {
993
+ path,
994
+ root
995
+ });
996
+ return resolve(root, relative);
997
+ }
998
+ static #stub(source) {
999
+ return `> Vendored guide for @orkestrel/${source.slice(11, source.length - 3)} — run \`scaffold pull\` to fetch it.\n`;
1000
+ }
1001
+ static #resolveReal(path) {
1002
+ if (existsSync(path)) return realpathSync(path);
1003
+ const parent = dirname(path);
1004
+ if (parent === path) return path;
1005
+ return join(Materializer.#resolveReal(parent), relative(parent, path));
1006
+ }
1007
+ #ensureAlive() {
1008
+ if (this.#destroyed) throw new ScaffoldError("DESTROYED", "Materializer has been destroyed");
1009
+ }
1010
+ };
1011
+ //#endregion
1012
+ //#region src/server/Sync.ts
1013
+ /**
1014
+ * The upstream-synchronization entity (server) — the impure FETCH sibling of
1015
+ * `Materializer`, Promise-based and network-only.
1016
+ *
1017
+ * @remarks
1018
+ * Every method reads upstream over HTTPS with a 10-second per-request
1019
+ * `AbortSignal.timeout` and bounded `concurrency` (default 6, never an
1020
+ * unbounded `Promise.all`). The default COLLECT posture captures each
1021
+ * dependency's `freshness` (`404` → `missing`, transport / other non-2xx →
1022
+ * `failed`) into the result; `strict` mode instead throws
1023
+ * `ScaffoldError('FETCH', …)` naming the failing URL. `guides`'s optional
1024
+ * `current` parameter is a caller-supplied local-mirror content map keyed by
1025
+ * dependency NAME (the `diffPlan` caller-supplied-reference pattern): WITH the
1026
+ * map, a fetched guide byte-equal to its entry verdicts `current`, anything
1027
+ * differing or absent from the map verdicts `behind`; WITHOUT the map, every
1028
+ * successful fetch verdicts `behind` (no reference means it needs syncing).
1029
+ * `pull` builds that map itself from the TARGET's own `guides/src/<short>.md`
1030
+ * mirrors, so its verdicts are target-relative; `write` commits only the
1031
+ * `behind` guides (never `current`, `missing`, or `failed`, which carries no
1032
+ * trustworthy content) under the same realpath-anchored containment law
1033
+ * `Materializer` enforces. After `destroy()` every method throws `DESTROYED`;
1034
+ * teardown is idempotent, emitter last.
1035
+ *
1036
+ * @example
1037
+ * ```ts
1038
+ * import { createSync } from '@orkestrel/scaffold/server'
1039
+ *
1040
+ * const sync = createSync()
1041
+ * const report = await sync.pull('.')
1042
+ * if (report.failed === 0) await sync.write(report, '.')
1043
+ * sync.destroy()
1044
+ * ```
1045
+ */
1046
+ var Sync = class Sync {
1047
+ static #DEFAULT_TIMEOUT = 1e4;
1048
+ static #DEFAULT_CONCURRENCY = 6;
1049
+ static #DEFAULT_LIMIT = 5242880;
1050
+ #emitter;
1051
+ #guidesBase;
1052
+ #branch;
1053
+ #guidesTimeout;
1054
+ #registryBase;
1055
+ #registryTimeout;
1056
+ #concurrency;
1057
+ #retries;
1058
+ #strict;
1059
+ #limit;
1060
+ #destroyed = false;
1061
+ constructor(options) {
1062
+ this.#emitter = new Emitter({
1063
+ on: options?.on,
1064
+ error: options?.error
1065
+ });
1066
+ this.#guidesBase = options?.guides?.base ?? "raw.githubusercontent.com";
1067
+ this.#branch = options?.guides?.branch ?? "main";
1068
+ this.#guidesTimeout = options?.guides?.timeout ?? Sync.#DEFAULT_TIMEOUT;
1069
+ this.#registryBase = options?.registry?.base ?? "registry.npmjs.org";
1070
+ this.#registryTimeout = options?.registry?.timeout ?? Sync.#DEFAULT_TIMEOUT;
1071
+ this.#concurrency = options?.concurrency ?? Sync.#DEFAULT_CONCURRENCY;
1072
+ this.#retries = options?.retries ?? 0;
1073
+ this.#strict = options?.strict ?? false;
1074
+ this.#limit = options?.limit ?? Sync.#DEFAULT_LIMIT;
1075
+ }
1076
+ get emitter() {
1077
+ return this.#emitter;
1078
+ }
1079
+ async guides(deps, current) {
1080
+ this.#ensureAlive();
1081
+ return Sync.#runPool(deps, this.#concurrency, async (dep) => {
1082
+ const short = Sync.#shortName(dep.name);
1083
+ const url = this.#guideUrl(short);
1084
+ const outcome = await Sync.#fetchText(url, this.#guidesTimeout, this.#retries, this.#limit);
1085
+ const guide = Sync.#toGuideSync(dep.name, short, outcome, current);
1086
+ if (outcome.kind === "failed") this.#emitter.emit("error", outcome.error);
1087
+ this.#emitter.emit("guide", dep.name);
1088
+ if (this.#strict && (guide.freshness === "missing" || guide.freshness === "failed")) throw new ScaffoldError("FETCH", `Failed to fetch guide at ${url}`, {
1089
+ url,
1090
+ name: dep.name
1091
+ });
1092
+ return guide;
1093
+ });
1094
+ }
1095
+ async versions(deps) {
1096
+ this.#ensureAlive();
1097
+ return Sync.#runPool(deps, this.#concurrency, async (dep) => {
1098
+ const url = this.#registryUrl(dep.name);
1099
+ const outcome = await Sync.#fetchText(url, this.#registryTimeout, this.#retries, this.#limit);
1100
+ const version = Sync.#toVersionSync(dep, outcome);
1101
+ if (outcome.kind === "failed") this.#emitter.emit("error", outcome.error);
1102
+ this.#emitter.emit("version", dep.name);
1103
+ if (this.#strict && (version.freshness === "missing" || version.freshness === "failed")) throw new ScaffoldError("FETCH", `Failed to fetch registry version at ${url}`, {
1104
+ url,
1105
+ name: dep.name
1106
+ });
1107
+ return version;
1108
+ });
1109
+ }
1110
+ /**
1111
+ * The fleet package catalog, sourced from the npm registry — the
1112
+ * AUTHORITATIVE enumeration (never a caller-supplied root).
1113
+ *
1114
+ * @returns One {@link CatalogEntry} per `@orkestrel/*` package the registry
1115
+ * lists, code-unit sorted by `name`.
1116
+ * @remarks
1117
+ * Three fetches build each entry: (1) the org's exact package-list
1118
+ * (`-/org/orkestrel/package`) enumerates every published name — an
1119
+ * unreachable or malformed response throws a coded `ScaffoldError('FETCH')`
1120
+ * UNCONDITIONALLY (never gated by `strict`), since without it there is no
1121
+ * catalog to build; (2) each name's own registry packument supplies
1122
+ * `version` (`dist-tags.latest`) and a registry-path `description`
1123
+ * fallback — a failed/malformed packument keeps the entry (degraded:
1124
+ * `version: ''`) rather than dropping it, since the org list already
1125
+ * proved the package exists; (3) each name's own guide
1126
+ * (`guides/src/<short>.md`, same canonical URL as `guides()`, fetched
1127
+ * unauthenticated — every fleet repo is public) supplies the PREFERRED
1128
+ * `description` — its first blockquote's first paragraph — falling back
1129
+ * to the packument description when the guide 404s, faults, or carries no
1130
+ * blockquote; a 404 STAYS LISTED (it is a reachability signal, not an
1131
+ * absence) with a note reading `guide unreachable (HTTP 404 — repo
1132
+ * private or guide missing?)`. Emits `package` once per entry with a
1133
+ * combined human-readable `note` (empty when both fetches succeeded)
1134
+ * alongside the existing `error` events for each degraded sub-fetch.
1135
+ */
1136
+ async catalog() {
1137
+ this.#ensureAlive();
1138
+ const orgUrl = this.#orgUrl();
1139
+ const orgOutcome = await Sync.#fetchText(orgUrl, this.#registryTimeout, this.#retries, this.#limit);
1140
+ const names = Sync.#toOrgPackages(orgOutcome, orgUrl);
1141
+ return [...await Sync.#runPool(names, this.#concurrency, async (name) => {
1142
+ const packumentOutcome = await Sync.#fetchText(this.#registryUrl(name), this.#registryTimeout, this.#retries, this.#limit);
1143
+ const packument = Sync.#toPackument(packumentOutcome);
1144
+ const short = Sync.#shortName(name);
1145
+ const guideOutcome = await Sync.#fetchText(this.#guideUrl(short), this.#guidesTimeout, this.#retries, this.#limit);
1146
+ const guide = Sync.#toGuideDescription(guideOutcome);
1147
+ if (packumentOutcome.kind === "failed") this.#emitter.emit("error", packumentOutcome.error);
1148
+ if (guideOutcome.kind === "failed") this.#emitter.emit("error", guideOutcome.error);
1149
+ const description = guide.description ?? packument.description;
1150
+ const note = Sync.#combineNote(guide.note, packument.note);
1151
+ this.#emitter.emit("package", name, note);
1152
+ return {
1153
+ name,
1154
+ version: packument.version,
1155
+ description
1156
+ };
1157
+ })].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1158
+ }
1159
+ async pull(target) {
1160
+ this.#ensureAlive();
1161
+ const deps = manifestToDependencies(readManifest(target));
1162
+ const current = {};
1163
+ for (const dep of deps) {
1164
+ const full = join(target, "guides", "src", `${Sync.#shortName(dep.name)}.md`);
1165
+ if (!existsSync(full)) continue;
1166
+ try {
1167
+ current[dep.name] = readFileSync(full, "utf8");
1168
+ } catch {}
1169
+ }
1170
+ const guides = await this.guides(deps, current);
1171
+ const versions = await this.versions(deps);
1172
+ const failed = [...guides, ...versions].filter((entry) => entry.freshness === "missing" || entry.freshness === "failed").length;
1173
+ const report = {
1174
+ target,
1175
+ guides,
1176
+ versions,
1177
+ clean: failed === 0 && guides.every((guide) => guide.freshness === "current") && versions.every((version) => version.freshness === "current"),
1178
+ failed
1179
+ };
1180
+ this.#emitter.emit("done", report);
1181
+ return report;
1182
+ }
1183
+ async write(report, target) {
1184
+ this.#ensureAlive();
1185
+ const written = [];
1186
+ for (const guide of report.guides) {
1187
+ if (guide.freshness !== "behind") continue;
1188
+ const to = Sync.#assertContained(target, guide.path);
1189
+ try {
1190
+ mkdirSync(dirname(to), { recursive: true });
1191
+ writeFileSync(to, guide.content, "utf8");
1192
+ } catch (error) {
1193
+ this.#emitter.emit("error", error);
1194
+ throw new ScaffoldError("WRITE", `Failed to write guide at ${guide.path}`, {
1195
+ path: guide.path,
1196
+ error
1197
+ });
1198
+ }
1199
+ written.push(guide.path);
1200
+ this.#emitter.emit("write", guide.path);
1201
+ }
1202
+ return written;
1203
+ }
1204
+ destroy() {
1205
+ if (this.#destroyed) return;
1206
+ this.#destroyed = true;
1207
+ this.#emitter.emit("destroy");
1208
+ this.#emitter.destroy();
1209
+ }
1210
+ #guideUrl(short) {
1211
+ return `${Sync.#normalizeBase(this.#guidesBase)}/orkestrel/${short}/refs/heads/${this.#branch}/guides/src/${short}.md`;
1212
+ }
1213
+ #registryUrl(name) {
1214
+ return `${Sync.#normalizeBase(this.#registryBase)}/${name.replace("/", "%2F")}`;
1215
+ }
1216
+ #orgUrl() {
1217
+ return `${Sync.#normalizeBase(this.#registryBase)}/-/org/orkestrel/package`;
1218
+ }
1219
+ static #normalizeBase(base) {
1220
+ return /^https?:\/\//.test(base) ? base : `https://${base}`;
1221
+ }
1222
+ static #shortName(name) {
1223
+ return name.startsWith("@orkestrel/") ? name.slice(11) : name;
1224
+ }
1225
+ static #isRecord(value) {
1226
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1227
+ }
1228
+ static async #runPool(items, concurrency, worker) {
1229
+ const results = new Array(items.length);
1230
+ const state = {
1231
+ cursor: 0,
1232
+ stopped: false,
1233
+ firstError: void 0
1234
+ };
1235
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => Sync.#drain({
1236
+ items,
1237
+ worker,
1238
+ results,
1239
+ state
1240
+ }));
1241
+ await Promise.allSettled(workers);
1242
+ if (state.firstError !== void 0) throw state.firstError.error;
1243
+ return results;
1244
+ }
1245
+ static async #drain(pool) {
1246
+ for (;;) {
1247
+ if (pool.state.stopped) return;
1248
+ const index = pool.state.cursor;
1249
+ pool.state.cursor += 1;
1250
+ if (index >= pool.items.length) return;
1251
+ try {
1252
+ pool.results[index] = await pool.worker(pool.items[index]);
1253
+ } catch (error) {
1254
+ if (pool.state.firstError === void 0) pool.state.firstError = { error };
1255
+ pool.state.stopped = true;
1256
+ return;
1257
+ }
1258
+ }
1259
+ }
1260
+ static async #fetchText(url, timeout, retries, limit) {
1261
+ let lastError;
1262
+ let lastNote = "";
1263
+ for (let attempt = 0; attempt <= retries; attempt += 1) try {
1264
+ const response = await fetch(url, {
1265
+ signal: AbortSignal.timeout(timeout),
1266
+ redirect: "manual"
1267
+ });
1268
+ if (response.status === 404) return { kind: "missing" };
1269
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
1270
+ lastNote = "redirected (redirect following is disabled)";
1271
+ lastError = /* @__PURE__ */ new Error(`Redirect blocked for ${url}`);
1272
+ continue;
1273
+ }
1274
+ if (!response.ok) {
1275
+ lastNote = `HTTP ${String(response.status)}`;
1276
+ lastError = /* @__PURE__ */ new Error(`Unexpected HTTP status ${response.status} for ${url}`);
1277
+ continue;
1278
+ }
1279
+ const read = await Sync.#readBounded(response, url, limit);
1280
+ if (!read.ok) {
1281
+ lastError = read.error;
1282
+ lastNote = read.note;
1283
+ continue;
1284
+ }
1285
+ return {
1286
+ kind: "ok",
1287
+ text: read.text
1288
+ };
1289
+ } catch (error) {
1290
+ lastError = error;
1291
+ lastNote = Sync.#transportNote(error);
1292
+ }
1293
+ return {
1294
+ kind: "failed",
1295
+ error: lastError,
1296
+ note: lastNote
1297
+ };
1298
+ }
1299
+ static #transportNote(error) {
1300
+ if (!(error instanceof Error)) return String(error);
1301
+ const code = Sync.#causeCode(error);
1302
+ return code !== void 0 ? `${error.message}: ${code}` : error.message;
1303
+ }
1304
+ static #causeCode(error) {
1305
+ const cause = error.cause;
1306
+ if (typeof cause !== "object" || cause === null) return void 0;
1307
+ if (!("code" in cause)) return void 0;
1308
+ return typeof cause.code === "string" ? cause.code : void 0;
1309
+ }
1310
+ static async #readBounded(response, url, limit) {
1311
+ const declared = response.headers.get("content-length");
1312
+ if (declared !== null) {
1313
+ const declaredBytes = Number(declared);
1314
+ if (Number.isFinite(declaredBytes) && declaredBytes > limit) return {
1315
+ ok: false,
1316
+ error: /* @__PURE__ */ new Error(`Response body for ${url} declares ${String(declaredBytes)} bytes, exceeding the ${String(limit)}-byte limit`),
1317
+ note: `response exceeded limit (${String(limit)} bytes)`
1318
+ };
1319
+ }
1320
+ const body = response.body;
1321
+ if (body === null) return {
1322
+ ok: true,
1323
+ text: ""
1324
+ };
1325
+ const reader = body.getReader();
1326
+ const decoder = new TextDecoder();
1327
+ const chunks = [];
1328
+ let total = 0;
1329
+ try {
1330
+ for (;;) {
1331
+ const { done, value } = await reader.read();
1332
+ if (done) break;
1333
+ total += value.byteLength;
1334
+ if (total > limit) return {
1335
+ ok: false,
1336
+ error: /* @__PURE__ */ new Error(`Response body for ${url} exceeded the ${String(limit)}-byte limit`),
1337
+ note: `response exceeded limit (${String(limit)} bytes)`
1338
+ };
1339
+ chunks.push(decoder.decode(value, { stream: true }));
1340
+ }
1341
+ chunks.push(decoder.decode());
1342
+ } finally {
1343
+ reader.releaseLock();
1344
+ }
1345
+ return {
1346
+ ok: true,
1347
+ text: chunks.join("")
1348
+ };
1349
+ }
1350
+ static #toGuideSync(name, short, outcome, current) {
1351
+ const path = `guides/src/${short}.md`;
1352
+ if (outcome.kind === "missing") return {
1353
+ name,
1354
+ path,
1355
+ content: "",
1356
+ freshness: "missing",
1357
+ note: "HTTP 404"
1358
+ };
1359
+ if (outcome.kind === "failed") return {
1360
+ name,
1361
+ path,
1362
+ content: "",
1363
+ freshness: "failed",
1364
+ note: outcome.note
1365
+ };
1366
+ const freshness = current?.[name] === outcome.text ? "current" : "behind";
1367
+ return {
1368
+ name,
1369
+ path,
1370
+ content: outcome.text,
1371
+ freshness
1372
+ };
1373
+ }
1374
+ static #toVersionSync(dep, outcome) {
1375
+ if (outcome.kind === "missing") return {
1376
+ name: dep.name,
1377
+ range: dep.range,
1378
+ latest: "",
1379
+ freshness: "missing",
1380
+ note: "HTTP 404"
1381
+ };
1382
+ if (outcome.kind === "failed") return {
1383
+ name: dep.name,
1384
+ range: dep.range,
1385
+ latest: "",
1386
+ freshness: "failed",
1387
+ note: outcome.note
1388
+ };
1389
+ const latest = Sync.#parseLatest(outcome.text);
1390
+ if (latest === void 0) return {
1391
+ name: dep.name,
1392
+ range: dep.range,
1393
+ latest: "",
1394
+ freshness: "failed",
1395
+ note: "malformed registry response (missing dist-tags.latest)"
1396
+ };
1397
+ return {
1398
+ name: dep.name,
1399
+ range: dep.range,
1400
+ latest,
1401
+ freshness: rangeToFreshness(dep.range, latest)
1402
+ };
1403
+ }
1404
+ static #toOrgPackages(outcome, url) {
1405
+ if (outcome.kind === "missing" || outcome.kind === "failed") {
1406
+ const note = outcome.kind === "missing" ? "HTTP 404" : outcome.note;
1407
+ throw new ScaffoldError("FETCH", `Failed to fetch the package list at ${url}`, {
1408
+ url,
1409
+ note
1410
+ });
1411
+ }
1412
+ let parsed;
1413
+ try {
1414
+ parsed = JSON.parse(outcome.text);
1415
+ } catch {
1416
+ throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
1417
+ }
1418
+ if (!Sync.#isRecord(parsed)) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
1419
+ const names = Object.keys(parsed).filter((name) => name.startsWith("@orkestrel/"));
1420
+ if (names.length === 0) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
1421
+ return names;
1422
+ }
1423
+ static #toPackument(outcome) {
1424
+ if (outcome.kind === "missing") return {
1425
+ version: "",
1426
+ description: "",
1427
+ note: "HTTP 404"
1428
+ };
1429
+ if (outcome.kind === "failed") return {
1430
+ version: "",
1431
+ description: "",
1432
+ note: outcome.note
1433
+ };
1434
+ let parsed;
1435
+ try {
1436
+ parsed = JSON.parse(outcome.text);
1437
+ } catch {
1438
+ return {
1439
+ version: "",
1440
+ description: "",
1441
+ note: "malformed registry response (invalid JSON)"
1442
+ };
1443
+ }
1444
+ if (!Sync.#isRecord(parsed)) return {
1445
+ version: "",
1446
+ description: "",
1447
+ note: "malformed registry response (not an object)"
1448
+ };
1449
+ const distTags = parsed["dist-tags"];
1450
+ const version = Sync.#isRecord(distTags) && typeof distTags.latest === "string" ? distTags.latest : "";
1451
+ return {
1452
+ version,
1453
+ description: typeof parsed.description === "string" ? parsed.description : "",
1454
+ note: version === "" ? "malformed registry response (missing dist-tags.latest)" : ""
1455
+ };
1456
+ }
1457
+ static #toGuideDescription(outcome) {
1458
+ if (outcome.kind === "missing") return {
1459
+ description: void 0,
1460
+ note: "guide unreachable (HTTP 404 — repo private or guide missing?)"
1461
+ };
1462
+ if (outcome.kind === "failed") return {
1463
+ description: void 0,
1464
+ note: outcome.note
1465
+ };
1466
+ const description = Sync.#firstBlockquoteParagraph(outcome.text);
1467
+ return description !== void 0 ? {
1468
+ description,
1469
+ note: ""
1470
+ } : {
1471
+ description: void 0,
1472
+ note: "guide has no blockquote description"
1473
+ };
1474
+ }
1475
+ static #firstBlockquoteParagraph(text) {
1476
+ let document;
1477
+ try {
1478
+ document = parseDocument(text);
1479
+ } catch {
1480
+ return;
1481
+ }
1482
+ let quote;
1483
+ for (const node of walkNodes(document)) if (isBlockquoteNode(node)) {
1484
+ quote = node;
1485
+ break;
1486
+ }
1487
+ if (quote === void 0) return void 0;
1488
+ const paragraph = quote.children.find((child) => isParagraphNode(child));
1489
+ if (paragraph === void 0) return void 0;
1490
+ const flattened = flattenText(paragraph).replace(/\s+/g, " ").trim();
1491
+ return flattened.length > 0 ? flattened : void 0;
1492
+ }
1493
+ static #combineNote(guideNote, packumentNote) {
1494
+ const parts = [];
1495
+ if (packumentNote !== "") parts.push(`version unavailable — ${packumentNote}`);
1496
+ if (guideNote !== "") parts.push(`description from registry — ${guideNote}`);
1497
+ return parts.join("; ");
1498
+ }
1499
+ static #parseLatest(text) {
1500
+ let parsed;
1501
+ try {
1502
+ parsed = JSON.parse(text);
1503
+ } catch {
1504
+ return;
1505
+ }
1506
+ if (!Sync.#isRecord(parsed)) return void 0;
1507
+ const distTags = parsed["dist-tags"];
1508
+ if (!Sync.#isRecord(distTags)) return void 0;
1509
+ const latest = distTags.latest;
1510
+ return typeof latest === "string" ? latest : void 0;
1511
+ }
1512
+ static #assertContained(target, relative) {
1513
+ const resolvedRoot = Sync.#resolveReal(resolve(target));
1514
+ const resolvedCandidate = Sync.#resolveReal(resolve(target, relative));
1515
+ if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + sep)) throw new ScaffoldError("WRITE", `Guide path "${relative}" escapes the target root`, {
1516
+ path: relative,
1517
+ target
1518
+ });
1519
+ return resolve(target, relative);
1520
+ }
1521
+ static #resolveReal(path) {
1522
+ if (existsSync(path)) return realpathSync(path);
1523
+ const parent = dirname(path);
1524
+ if (parent === path) return path;
1525
+ return join(Sync.#resolveReal(parent), relative(parent, path));
1526
+ }
1527
+ #ensureAlive() {
1528
+ if (this.#destroyed) throw new ScaffoldError("DESTROYED", "Sync has been destroyed");
1529
+ }
1530
+ };
1531
+ //#endregion
1532
+ //#region src/server/factories.ts
1533
+ /**
1534
+ * Create a `MaterializerInterface` (server) — the materialization entity,
1535
+ * seeded from `MaterializerOptions`.
1536
+ *
1537
+ * @param options - Optional `host` root override, emitter hooks, and error handler
1538
+ * @returns A {@link MaterializerInterface}
1539
+ *
1540
+ * @example
1541
+ * ```ts
1542
+ * import { createMaterializer } from '@orkestrel/scaffold/server'
1543
+ *
1544
+ * const materializer = createMaterializer()
1545
+ * materializer.destroy()
1546
+ * ```
1547
+ */
1548
+ function createMaterializer(options) {
1549
+ return new Materializer(options);
1550
+ }
1551
+ /**
1552
+ * Create a `SyncInterface` (server) — the upstream-synchronization entity,
1553
+ * seeded from `SyncOptions`.
1554
+ *
1555
+ * @param options - Optional endpoint bases/branch, concurrency, retries, strict, emitter hooks, and error handler
1556
+ * @returns A {@link SyncInterface}
1557
+ *
1558
+ * @example
1559
+ * ```ts
1560
+ * import { createSync } from '@orkestrel/scaffold/server'
1561
+ *
1562
+ * const sync = createSync()
1563
+ * sync.destroy()
1564
+ * ```
1565
+ */
1566
+ function createSync(options) {
1567
+ return new Sync(options);
1568
+ }
1569
+ //#endregion
1570
+ export { Materializer, PRUNE_DIRECTORIES, Sync, catalogPackages, createMaterializer, createSync, deriveBlueprint, discoverPackages, hostRoot, hydratePlan, isManifestEntry, isRecord, isVacant, listFiles, locateHostSource, pruneTargets, readHostManifest, readManifest, readTarget, selectOrkestrelEntries, stageHost, storagePath, vendoredPruneSet };
1571
+
1572
+ //# sourceMappingURL=index.js.map