@stackstackstack/dsh-app-boot 0.1.5

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/lib/index.js ADDED
@@ -0,0 +1,1215 @@
1
+ import { createRequire } from "node:module";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { parseEnv } from "node:util";
5
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
+ import * as yaml from "js-yaml";
7
+ import { Context, Service } from "@deepseek-ai/cordis";
8
+ import Loader, { EntryGroup, EntryTree, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
9
+ import { access, constants, readFile, rename, writeFile } from "node:fs/promises";
10
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
+ import Group from "@deepseek-ai/cordis-plugin-group";
12
+ import { dshHomePath, resolveDshHome } from "@stackstackstack/dsh-home-paths";
13
+ import { createLaunchEnvironmentSnapshot } from "@stackstackstack/dsh-launch-environment";
14
+ //#region ../../../vendor/include/src/index.ts
15
+ const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
16
+ kind: "scalar",
17
+ resolve: (data) => typeof data === "string",
18
+ construct: (data) => ({ __jsExpr: data }),
19
+ predicate: isJsExpr,
20
+ represent: (data) => data["__jsExpr"]
21
+ });
22
+ /**
23
+ * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
24
+ * the Loader evaluates at entry activation. Exported so config tooling
25
+ * (`dsh --dump-config`) parses and prints exactly the dialect this include
26
+ * mounts.
27
+ */
28
+ const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
29
+ const schema = entryListSchema;
30
+ const writable = {
31
+ ".json": "application/json",
32
+ ".yaml": "application/yaml",
33
+ ".yml": "application/yaml"
34
+ };
35
+ const supported = new Set(Object.keys(writable));
36
+ const WRITE_RETRY_LIMIT = 10;
37
+ const WRITE_RETRY_DELAY_MS = 50;
38
+ function retryableWriteError(error) {
39
+ const code = error?.code;
40
+ return code === "EACCES" || code === "EBUSY" || code === "EPERM";
41
+ }
42
+ /**
43
+ * Apply patch lists to an entry list — THE patch semantics of this include,
44
+ * shared by mounting (`applyPatches`) and offline config tooling
45
+ * (`dsh --dump-config`) so a dump can never drift from what boots. The input
46
+ * is never mutated and the result is always detached from it (even with no
47
+ * patches): patching or mounting shared entry objects would bake earlier
48
+ * values into the cached parse, so repeated application (config hot-reloads)
49
+ * could never revert a removed or changed patch. Inserted entries are indexed
50
+ * as they are added, so a later patch in the same list can target a row an
51
+ * earlier patch inserted. A patch that matches nothing warns and is skipped.
52
+ * @param data - the parsed entry list (JSON-safe plain data).
53
+ * @param patches - the patch list to apply, in order.
54
+ * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
55
+ * @returns a detached entry list with every applicable patch applied.
56
+ */
57
+ function applyEntryPatches(data, patches, warn) {
58
+ data = structuredClone(data);
59
+ if (!patches?.length) return data;
60
+ const entryMap = /* @__PURE__ */ new Map();
61
+ const buildMap = (entries) => {
62
+ for (const entry of entries) {
63
+ if (entry.id) entryMap.set(entry.id, entry);
64
+ if (entry.group && Array.isArray(entry.config)) buildMap(entry.config);
65
+ }
66
+ };
67
+ buildMap(data);
68
+ for (const patch of patches) {
69
+ const { id, insert, name, ...overrides } = patch;
70
+ if (insert) {
71
+ if (id) {
72
+ const target = entryMap.get(id);
73
+ if (!target) {
74
+ warn("patch insert: entry %C not found", id);
75
+ continue;
76
+ }
77
+ if (!target.group) {
78
+ warn("patch insert: entry %C is not a group", id);
79
+ continue;
80
+ }
81
+ if (!Array.isArray(target.config)) target.config = [];
82
+ target.config.push(...insert);
83
+ } else data.push(...insert);
84
+ buildMap(insert);
85
+ continue;
86
+ }
87
+ if (!id) {
88
+ warn("patch: id is required for non-insert patches");
89
+ continue;
90
+ }
91
+ const target = entryMap.get(id);
92
+ if (!target) {
93
+ warn("patch: entry %C not found", id);
94
+ continue;
95
+ }
96
+ if (name && name !== target.name) {
97
+ warn("patch: name mismatch for %C (expected %C, got %C), skipping", id, target.name, name);
98
+ continue;
99
+ }
100
+ for (const [key, value] of Object.entries(overrides)) {
101
+ if (key === "id") continue;
102
+ target[key] = value;
103
+ }
104
+ }
105
+ return data;
106
+ }
107
+ var ConfigFileError = class extends Error {
108
+ stage;
109
+ constructor(stage, path, cause) {
110
+ super(`failed to ${stage} config file ${path}`, { cause });
111
+ this.stage = stage;
112
+ this.name = "ConfigFileError";
113
+ }
114
+ };
115
+ /** Loader entry tree backed by a YAML or JSON file. */
116
+ var Include = class extends EntryTree {
117
+ config;
118
+ static inject = ["loader"];
119
+ static [EntryGroup.key] = true;
120
+ filename;
121
+ type;
122
+ readonly;
123
+ content;
124
+ data;
125
+ writeTask;
126
+ pendingWrite;
127
+ writeQueue = Promise.resolve();
128
+ applyQueue = Promise.resolve();
129
+ constructor(ctx, config) {
130
+ super(ctx);
131
+ this.config = config;
132
+ this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false;
133
+ this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl));
134
+ const ext = extname(this.filename);
135
+ if (!supported.has(ext)) throw new Error(`extension "${ext}" not supported`);
136
+ this.type = writable[ext];
137
+ this.readonly = !this.type;
138
+ this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
139
+ ctx.on("internal/update", async (config, _, next) => {
140
+ if (config.path !== this.config.path) return next();
141
+ await this.enqueue(async () => {
142
+ const data = this.applyPatches(this.data, config.patches);
143
+ await this.root.update(data);
144
+ this.config = config;
145
+ });
146
+ });
147
+ }
148
+ /**
149
+ * Serialize one child-tree mutation behind every earlier one. The group's
150
+ * transactional `update` is not reentrant: two concurrent applies (the init
151
+ * apply racing an HMR-triggered refresh from the watcher's initial scan)
152
+ * interleave create and rollback on the same entries and strand the include
153
+ * fiber without settling, so every apply path funnels through this queue.
154
+ * A predecessor's failure is its own caller's outcome and never gates the
155
+ * next task.
156
+ */
157
+ enqueue(task) {
158
+ const run = this.applyQueue.then(task, task);
159
+ this.applyQueue = run.then(() => {}, () => {});
160
+ return run;
161
+ }
162
+ async checkAccess() {
163
+ if (!this.type) return;
164
+ try {
165
+ await access(this.filename, constants.W_OK);
166
+ } catch {
167
+ this.readonly = true;
168
+ }
169
+ }
170
+ async read(forced = false) {
171
+ let content;
172
+ try {
173
+ content = await readFile(this.filename, "utf8");
174
+ } catch (error) {
175
+ throw new ConfigFileError("read", this.filename, error);
176
+ }
177
+ if (!forced && this.content === content) return;
178
+ let data;
179
+ try {
180
+ if (this.type === "application/yaml") data = yaml.load(content, { schema });
181
+ else if (this.type === "application/json") data = JSON.parse(content);
182
+ else {
183
+ const module = await import(
184
+ /* @vite-ignore */
185
+ this.filename
186
+ );
187
+ data = module.default || module;
188
+ }
189
+ } catch (error) {
190
+ throw new ConfigFileError("parse", this.filename, error);
191
+ }
192
+ if (!Array.isArray(data)) throw new ConfigFileError("validate", this.filename, /* @__PURE__ */ new TypeError("config file must be a top-level array"));
193
+ return {
194
+ content,
195
+ data
196
+ };
197
+ }
198
+ applyPatches(data, patches) {
199
+ return applyEntryPatches(data, patches, (message, ...args) => {
200
+ this.ctx.root.logger?.("loader").warn(message, ...args);
201
+ });
202
+ }
203
+ async *[Service.init]() {
204
+ let candidate;
205
+ try {
206
+ candidate = await this.read(true);
207
+ } catch (error) {
208
+ if (!(error instanceof ConfigFileError) || error.stage !== "read" || error.cause?.code !== "ENOENT") throw error;
209
+ if (this.config.initial) {
210
+ await this._writeFile(this.config.initial);
211
+ candidate = await this.read(true);
212
+ } else throw new Error(`config file not found: ${this.filename}`);
213
+ }
214
+ yield () => this.stop();
215
+ await this.apply(candidate);
216
+ }
217
+ async stop() {
218
+ await this.root.stop();
219
+ await this.flushWrite();
220
+ }
221
+ /**
222
+ * Re-read the file and transactionally refresh child entries when content changed.
223
+ * @returns a promise resolving after the new tree commits, or immediately when unchanged.
224
+ * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
225
+ */
226
+ async refresh() {
227
+ await this.enqueue(async () => {
228
+ const candidate = await this.read();
229
+ if (!candidate) return;
230
+ await this._apply(candidate);
231
+ });
232
+ }
233
+ apply(candidate) {
234
+ return this.enqueue(() => this._apply(candidate));
235
+ }
236
+ async _apply(candidate) {
237
+ const data = this.applyPatches(candidate.data, this.config.patches);
238
+ await this.root.update(data);
239
+ this.content = candidate.content;
240
+ this.data = candidate.data;
241
+ await this.checkAccess();
242
+ }
243
+ async _writeFile(config) {
244
+ if (this.readonly) throw new Error(`cannot overwrite readonly config`);
245
+ if (this.type === "application/yaml") this.content = yaml.dump(config, { schema });
246
+ else if (this.type === "application/json") this.content = JSON.stringify(config, null, 2);
247
+ await writeFile(this.filename + ".tmp", this.content);
248
+ for (let retry = 0;; retry++) try {
249
+ await rename(this.filename + ".tmp", this.filename);
250
+ return;
251
+ } catch (error) {
252
+ if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error;
253
+ await setTimeout$1((retry + 1) * WRITE_RETRY_DELAY_MS);
254
+ }
255
+ }
256
+ writeFile(config) {
257
+ clearTimeout(this.writeTask);
258
+ this.pendingWrite = config;
259
+ this.writeTask = setTimeout(() => {
260
+ this.flushWrite();
261
+ }, 0);
262
+ }
263
+ flushWrite() {
264
+ clearTimeout(this.writeTask);
265
+ this.writeTask = void 0;
266
+ const config = this.pendingWrite;
267
+ this.pendingWrite = void 0;
268
+ if (config === void 0) return this.writeQueue;
269
+ const run = this.writeQueue.then(() => this._writeFile(config), () => this._writeFile(config));
270
+ this.writeQueue = run;
271
+ run.catch((error) => {
272
+ this.ctx.root.logger?.("loader").warn("failed to write config file %C", this.filename);
273
+ this.ctx.root.logger?.("loader").warn(error);
274
+ });
275
+ return run;
276
+ }
277
+ /** Schedule a write of the current root entry data. */
278
+ write() {
279
+ this.context.emit("loader/config-update");
280
+ return this.writeFile(this.root.data);
281
+ }
282
+ };
283
+ //#endregion
284
+ //#region lib/types/profile.js
285
+ /**
286
+ * Profile discovery, initialization, and patch-layer composition for the
287
+ * `dsh --profile` launcher family.
288
+ *
289
+ * A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
290
+ * `package.json` (out-of-tree plugin dependencies plus the profile manifest
291
+ * `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
292
+ * (the user's own patch layer, applied after every bundle layer). Bundles are
293
+ * npm packages whose manifest declares
294
+ * `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is
295
+ * composed by applying each bundle's patch list in `dsh.profile.bundles` order over
296
+ * an empty entry list, then the profile's own patches, then any launcher
297
+ * layers (`--patch` files and flag-derived patches).
298
+ *
299
+ * Module resolution is two-anchor by construction: a bundle name resolves
300
+ * first from the dsh installation (the launcher's own package), then from the
301
+ * profile directory. The Loader's `baseUrl` is the profile directory, whose
302
+ * `node_modules` pnpm manages for out-of-tree plugins, while the maintained
303
+ * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per
304
+ * package the installation's app and bundles depend on) makes every in-box
305
+ * plugin Node-resolvable from any profile through the ordinary parent-walk.
306
+ * @module @stackstackstack/dsh-app-boot/profile
307
+ */
308
+ /** Directory under the Harness home holding every profile. */
309
+ const PROFILES_DIR = "profiles";
310
+ /** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
311
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
312
+ /**
313
+ * Resolve a profile's directory under the Harness home.
314
+ * @param name - the profile name (`dsh --profile <name>`).
315
+ * @param home - the Harness home; defaults to {@link resolveDshHome}.
316
+ * @returns the absolute profile directory (which may not exist yet).
317
+ */
318
+ function resolveProfileDir(name, home = resolveDshHome()) {
319
+ if (name === "" || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name === "node_modules") throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`);
320
+ return join(home, PROFILES_DIR, name);
321
+ }
322
+ /** The shipped profile templates auto-initialized on first use, by name. */
323
+ const PROFILE_TEMPLATES = {
324
+ web: ["@stackstackstack/dsh-base", "@stackstackstack/dsh-web-app"],
325
+ headless: ["@stackstackstack/dsh-base", "@stackstackstack/dsh-headless"]
326
+ };
327
+ /** Installation-owned bundle tuples normalized to the shipped template. */
328
+ const INSTALLATION_OWNED_PROFILE_TUPLES = { headless: [
329
+ "@stackstackstack/dsh-base",
330
+ "@stackstackstack/dsh-web-app",
331
+ "@stackstackstack/dsh-headless"
332
+ ] };
333
+ /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
334
+ const DEFAULT_PROFILE_BUNDLES = ["@stackstackstack/dsh-base"];
335
+ const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
336
+ # a top-level YAML array of loader patch entries (id-targeted config
337
+ # overrides, disables, and insert lists; \`!!js\` expressions allowed).
338
+ []
339
+ `;
340
+ const PROFILE_PNPM_WORKSPACE = `packages:
341
+ - .
342
+
343
+ nodeLinker: hoisted
344
+ autoInstallPeers: false
345
+ `;
346
+ /**
347
+ * Initialize a profile directory: manifest, empty user patch layer, and the
348
+ * pnpm settings out-of-tree plugins need. Existing files are never touched,
349
+ * so re-running is a no-op on an initialized profile.
350
+ * @param dir - the profile directory from {@link resolveProfileDir}.
351
+ * @param bundles - the initial `dsh.profile.bundles` layer list.
352
+ */
353
+ function initProfile(dir, bundles) {
354
+ mkdirSync(dir, { recursive: true });
355
+ const manifestPath = join(dir, "package.json");
356
+ if (!existsSync(manifestPath)) {
357
+ const manifest = {
358
+ name: `dsh-profile-${basename(dir)}`,
359
+ private: true,
360
+ dependencies: {},
361
+ dsh: { profile: { bundles: [...bundles] } }
362
+ };
363
+ writeFileSync(manifestPath, JSON.stringify(manifest, void 0, 2) + "\n");
364
+ }
365
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME);
366
+ if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE);
367
+ const workspacePath = join(dir, "pnpm-workspace.yaml");
368
+ if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE);
369
+ }
370
+ /** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
371
+ function ensureSymlink(link, target) {
372
+ let stat;
373
+ try {
374
+ stat = lstatSync(link);
375
+ } catch {
376
+ stat = void 0;
377
+ }
378
+ if (stat !== void 0) {
379
+ if (!stat.isSymbolicLink()) throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`);
380
+ if (readlinkSync(link) === target) return;
381
+ unlinkSync(link);
382
+ }
383
+ try {
384
+ symlinkSync(target, link, "junction");
385
+ } catch (error) {
386
+ /* v8 ignore next 4 */
387
+ if (error.code !== "EEXIST" || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) throw error;
388
+ }
389
+ }
390
+ /**
391
+ * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
392
+ * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS
393
+ * over `dependencies` from the app manifest), each resolved from its own
394
+ * real location. Node's parent-directory walk from any profile finds this
395
+ * directory after the profile's own `node_modules`, so every in-box plugin
396
+ * resolves without pnpm ever managing it — the exact "bundles come from the
397
+ * installation" contract. The closure (not just direct dependencies) is
398
+ * required for out-of-tree plugins: their peer dependencies name Service
399
+ * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app
400
+ * reaches only through its Service Provider packages. Symlinked packages
401
+ * resolve their own dependencies from their real directories (Node's default
402
+ * symlink-following), so each package needs only its one flat link.
403
+ * Idempotent: correct links are kept and moved installations are
404
+ * re-pointed; a stale link to a vanished package stays until its name is
405
+ * reused (dangling links are invisible to resolution).
406
+ * @param installAnchor - absolute path of the dsh app's package.json.
407
+ * @param home - the Harness home; defaults to {@link resolveDshHome}.
408
+ */
409
+ function healProfilesModuleFallback(installAnchor, home = resolveDshHome()) {
410
+ const modulesDir = join(join(home, PROFILES_DIR), "node_modules");
411
+ mkdirSync(modulesDir, { recursive: true });
412
+ const appManifest = JSON.parse(readFileSync(installAnchor, "utf8"));
413
+ const links = /* @__PURE__ */ new Map();
414
+ /* v8 ignore next -- a real app manifest always declares its name */
415
+ if (appManifest.name !== void 0) links.set(appManifest.name, dirname(installAnchor));
416
+ const queue = [{
417
+ anchor: installAnchor,
418
+ manifest: appManifest
419
+ }];
420
+ for (let next = queue.shift(); next !== void 0; next = queue.shift())
421
+ /* v8 ignore next -- a real app manifest always declares dependencies */
422
+ for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
423
+ if (links.has(dep)) continue;
424
+ const dir = packageDirFromAnchor(next.anchor, dep);
425
+ if (dir === void 0) continue;
426
+ links.set(dep, dir);
427
+ const manifestPath = join(dir, "package.json");
428
+ queue.push({
429
+ anchor: manifestPath,
430
+ manifest: JSON.parse(readFileSync(manifestPath, "utf8"))
431
+ });
432
+ }
433
+ for (const [packageName, target] of links) {
434
+ const link = join(modulesDir, packageName);
435
+ mkdirSync(dirname(link), { recursive: true });
436
+ ensureSymlink(link, target);
437
+ }
438
+ }
439
+ /**
440
+ * Read a profile's manifest.
441
+ * @param binName - the diagnostic prefix on the thrown error.
442
+ * @param dir - the profile directory.
443
+ * @returns the parsed manifest.
444
+ */
445
+ function readProfileManifest(binName, dir) {
446
+ const path = join(dir, "package.json");
447
+ let raw;
448
+ try {
449
+ raw = readFileSync(path, "utf8");
450
+ } catch (error) {
451
+ throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`);
452
+ }
453
+ const parsed = JSON.parse(raw);
454
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`);
455
+ return parsed;
456
+ }
457
+ /**
458
+ * Write a profile's manifest back (2-space JSON, trailing newline).
459
+ * @param dir - the profile directory.
460
+ * @param manifest - the manifest value to persist.
461
+ */
462
+ function writeProfileManifest(dir, manifest) {
463
+ writeFileSync(join(dir, "package.json"), JSON.stringify(manifest, void 0, 2) + "\n");
464
+ }
465
+ /** Return whether two bundle lists have the same values in the same order. */
466
+ function sameBundles(left, right) {
467
+ return left.length === right.length && left.every((value, index) => value === right[index]);
468
+ }
469
+ /**
470
+ * Normalize an exact installation-owned bundle tuple to its shipped template
471
+ * while preserving every other manifest field. Any other list is user-owned.
472
+ */
473
+ function normalizeShippedProfile(name, dir, manifest) {
474
+ const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name];
475
+ const current = PROFILE_TEMPLATES[name];
476
+ const bundles = manifest.dsh?.profile?.bundles;
477
+ if (installationOwned === void 0 || current === void 0 || bundles === void 0 || !sameBundles(bundles, installationOwned)) return manifest;
478
+ const normalized = {
479
+ ...manifest,
480
+ dsh: {
481
+ ...manifest.dsh,
482
+ profile: {
483
+ ...manifest.dsh?.profile,
484
+ bundles: [...current]
485
+ }
486
+ }
487
+ };
488
+ writeProfileManifest(dir, normalized);
489
+ return normalized;
490
+ }
491
+ /**
492
+ * Resolve a package's root directory from one anchor without depending on the
493
+ * package exporting `./package.json` (`require.resolve` would need that):
494
+ * probe the require resolution paths for a directory holding the named
495
+ * manifest. This is Node's own node_modules lookup order, so the result
496
+ * matches what the Loader would import from the same anchor, and
497
+ * `existsSync` follows the symlinks pnpm's isolated layout uses.
498
+ */
499
+ function packageDirFromAnchor(anchor, packageName) {
500
+ /* v8 ignore next */
501
+ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
502
+ const candidate = join(searchPath, packageName);
503
+ if (existsSync(join(candidate, "package.json"))) return candidate;
504
+ }
505
+ }
506
+ /**
507
+ * Resolve one bundle package's directory: installation anchor first, then the
508
+ * profile directory. The installation-first order is the contract that
509
+ * `@stackstackstack/dsh-base` (and every other in-box bundle) always comes from
510
+ * the same installation as the running dsh, never from a profile-local copy.
511
+ * Resolution does not require the package to export `./package.json`.
512
+ * @param binName - the diagnostic prefix on the thrown error.
513
+ * @param packageName - the bundle's package name from `dsh.profile.bundles`.
514
+ * @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
515
+ * @param profileDir - the profile directory (second anchor).
516
+ * @returns the bundle package's absolute directory.
517
+ */
518
+ function resolveBundleDir(binName, packageName, installAnchor, profileDir) {
519
+ for (const anchor of [installAnchor, join(profileDir, "package.json")]) {
520
+ const dir = packageDirFromAnchor(anchor, packageName);
521
+ if (dir !== void 0) return dir;
522
+ }
523
+ throw new Error(`${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`);
524
+ }
525
+ /**
526
+ * Load a profile: resolve every `dsh.profile.bundles` entry to its patch
527
+ * layer and parse the profile's own patch file. A listed bundle without a
528
+ * `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
529
+ * is a misconfiguration, not "no patches".
530
+ * @param binName - the diagnostic prefix on thrown errors.
531
+ * @param name - the profile name.
532
+ * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
533
+ * @param home - the Harness home; defaults to {@link resolveDshHome}.
534
+ * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
535
+ * bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
536
+ * cannot fail on a broken user layer.
537
+ * @returns the loaded profile (empty `patches` when the user layer is skipped).
538
+ */
539
+ function loadProfile(binName, name, installAnchor, home = resolveDshHome(), options = {}) {
540
+ const dir = resolveProfileDir(name, home);
541
+ if (!existsSync(join(dir, "package.json"))) {
542
+ const template = PROFILE_TEMPLATES[name];
543
+ if (template === void 0) throw new Error(`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`);
544
+ initProfile(dir, template);
545
+ }
546
+ const layers = (normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)).dsh?.profile?.bundles ?? []).map((packageName) => {
547
+ const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir);
548
+ const declared = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).dsh?.bundle?.patch;
549
+ if (declared === void 0) throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`);
550
+ const patchPath = join(packageDir, declared);
551
+ return {
552
+ packageName,
553
+ packageDir,
554
+ patchPath,
555
+ patches: loadOverlayPatches(binName, patchPath)
556
+ };
557
+ });
558
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME);
559
+ return {
560
+ name,
561
+ dir,
562
+ layers,
563
+ patchPath,
564
+ patches: options.userLayer !== false && existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : []
565
+ };
566
+ }
567
+ /**
568
+ * Compose patch layers into the effective entry list over an empty root —
569
+ * the same single `applyEntryPatches` call the boot include makes, so flag
570
+ * derivation and config dumps see exactly what mounts.
571
+ * @param layers - patch lists in application order.
572
+ * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
573
+ * @returns the composed entry list.
574
+ */
575
+ function composeEntries(layers, warn = () => {}) {
576
+ return applyEntryPatches([], structuredClone(layers.flat()), (message, ...args) => {
577
+ let index = 0;
578
+ warn(message.replace(/%C/g, () => JSON.stringify(args[index++])));
579
+ });
580
+ }
581
+ //#endregion
582
+ //#region lib/types/index.js
583
+ /**
584
+ * Shared boot glue for the app bins (`dsh`, `dsh-acp-demo`): load the gitignored
585
+ * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
586
+ * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
587
+ * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
588
+ * @module @stackstackstack/dsh-app-boot
589
+ */
590
+ /**
591
+ * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
592
+ * `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
593
+ * @param configPath - the requested config path (absolute, or relative to `cwd`).
594
+ * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
595
+ * basename.
596
+ * @param cwd - the base a relative `configPath` resolves against.
597
+ * @returns the absolute path of the config to boot.
598
+ */
599
+ function resolveConfigPath(configPath, snapshotMode, cwd = process.cwd()) {
600
+ const absolute = resolve(cwd, configPath);
601
+ if (snapshotMode !== "replay") return absolute;
602
+ return resolve(dirname(absolute), basename(absolute).replace(/cordis\.ya?ml$/, "cordis.snapshot.yml"));
603
+ }
604
+ /**
605
+ * Load the optional gitignored `.env` from `dir`. Missing files fall back to the
606
+ * ambient environment; other read failures are reported through `warn`.
607
+ * @param binName - the diagnostic prefix on the warn line.
608
+ * @param dir - the directory whose `.env` to load.
609
+ * @param warn - sink for the one-line misconfiguration diagnostic.
610
+ */
611
+ function loadEnv(binName, dir = process.cwd(), warn = (line) => void process.stderr.write(line)) {
612
+ try {
613
+ process.loadEnvFile(resolve(dir, ".env"));
614
+ } catch (error) {
615
+ if (error?.code !== "ENOENT") warn(`${binName}: failed to load .env: ${String(error)}\n`);
616
+ }
617
+ }
618
+ /** Exact names no discovered file may set. */
619
+ const BOOTSTRAP_NAMES = new Set([
620
+ "PATH",
621
+ "HOME",
622
+ "USERPROFILE",
623
+ "SHELL",
624
+ "NODE_OPTIONS",
625
+ "NODE_PATH",
626
+ "NODE_EXTRA_CA_CERTS",
627
+ "LD_PRELOAD",
628
+ "LD_LIBRARY_PATH",
629
+ "LD_AUDIT",
630
+ "BASH_ENV",
631
+ "ENV",
632
+ "SHELLOPTS",
633
+ "BASHOPTS",
634
+ "PERL5OPT",
635
+ "PERL5LIB",
636
+ "PYTHONSTARTUP",
637
+ "PYTHONPATH",
638
+ "RUBYOPT",
639
+ "RUBYLIB",
640
+ "JAVA_TOOL_OPTIONS",
641
+ "_JAVA_OPTIONS",
642
+ "JDK_JAVA_OPTIONS",
643
+ "PYTHONHOME",
644
+ "GIT_SSH",
645
+ "GIT_SSH_COMMAND",
646
+ "GIT_EXTERNAL_DIFF",
647
+ "GIT_PAGER",
648
+ "GIT_EDITOR",
649
+ "GIT_ASKPASS",
650
+ "SSH_ASKPASS",
651
+ "GIT_CONFIG_GLOBAL",
652
+ "GIT_CONFIG_SYSTEM",
653
+ "GIT_CONFIG_COUNT",
654
+ "EDITOR",
655
+ "VISUAL",
656
+ "PAGER",
657
+ "DEEPSEEK_BASE_URL",
658
+ "DEEPSEEK_SEARCH_BASE_URL",
659
+ "SSL_CERT_FILE",
660
+ "SSL_CERT_DIR",
661
+ "HTTP_PROXY",
662
+ "HTTPS_PROXY",
663
+ "ALL_PROXY",
664
+ "NO_PROXY",
665
+ "REQUESTS_CA_BUNDLE",
666
+ "CURL_CA_BUNDLE",
667
+ "NODE_TLS_REJECT_UNAUTHORIZED"
668
+ ]);
669
+ /** Name prefixes no discovered file may set. */
670
+ const BOOTSTRAP_PREFIXES = [
671
+ "DSH_",
672
+ "XDG_",
673
+ "DYLD_",
674
+ "BASH_FUNC_"
675
+ ];
676
+ /**
677
+ * Whether a variable may come only from the inherited process environment
678
+ * because it changes process, runtime, VCS, or network bootstrap.
679
+ * @param name - the variable name.
680
+ * @returns true when only the inherited environment may supply it.
681
+ */
682
+ function isBootstrapOnly(name) {
683
+ const upper = name.toUpperCase();
684
+ return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some((prefix) => upper.startsWith(prefix));
685
+ }
686
+ /**
687
+ * Parse one directory's `.env` without applying it, rejecting bootstrap-only
688
+ * names before any value is materialized.
689
+ * @param binName - the diagnostic prefix on the thrown error.
690
+ * @param dir - the directory whose `.env` to read.
691
+ * @param warn - sink for the one-line unreadable-file diagnostic.
692
+ * @returns the parsed entries, or `undefined` when the file is absent or unreadable.
693
+ * @throws when the file declares a name {@link isBootstrapOnly} rejects.
694
+ */
695
+ function readEnvLayer(binName, dir, warn) {
696
+ const path = resolve(dir, ".env");
697
+ let content;
698
+ try {
699
+ content = readFileSync(path, "utf8");
700
+ } catch (error) {
701
+ if (error?.code !== "ENOENT") warn(`${binName}: failed to load .env: ${String(error)}\n`);
702
+ return;
703
+ }
704
+ const values = parseEnv(content);
705
+ for (const name of Object.keys(values)) {
706
+ if (!isBootstrapOnly(name)) continue;
707
+ throw new Error(`${binName}: ${path} sets "${name}", which only the launching environment may set (it decides how this process starts, where its code and instructions load from, or how it reaches the network); export ${name} instead of putting it in a .env file`);
708
+ }
709
+ return {
710
+ path,
711
+ values
712
+ };
713
+ }
714
+ /**
715
+ * Load the product CLI's inherited > invoking-directory `.env` > Harness-home
716
+ * `.env` snapshot. The Harness home resolves before either file; both files
717
+ * are checked before either is applied, and accepted values are materialized
718
+ * without replacing inherited ones. The snapshot preserves which layer supplied each value.
719
+ * @param binName - the diagnostic prefix on the diagnostics.
720
+ * @param cwd - the invoking directory whose `.env` is the project layer.
721
+ * @param warn - sink for the one-line misconfiguration diagnostics.
722
+ * @returns this run's frozen environment snapshot.
723
+ * @throws when either file declares a bootstrap-only variable.
724
+ */
725
+ function loadLayeredEnv(binName, cwd = process.cwd(), warn = (line) => void process.stderr.write(line)) {
726
+ const home = resolveDshHome();
727
+ const inherited = { ...process.env };
728
+ const project = readEnvLayer(binName, cwd, warn);
729
+ const user = home === resolve(cwd) ? void 0 : readEnvLayer(binName, home, warn);
730
+ for (const layer of [project, user]) {
731
+ if (layer === void 0) continue;
732
+ for (const [name, value] of Object.entries(layer.values)) if (process.env[name] === void 0) process.env[name] = value;
733
+ }
734
+ return createLaunchEnvironmentSnapshot([
735
+ {
736
+ source: "process",
737
+ values: inherited
738
+ },
739
+ ...project === void 0 ? [] : [{
740
+ source: "project-env",
741
+ path: project.path,
742
+ values: project.values
743
+ }],
744
+ ...user === void 0 ? [] : [{
745
+ source: "user-env",
746
+ path: user.path,
747
+ values: user.values
748
+ }]
749
+ ]);
750
+ }
751
+ const bootstrapIncludes = /* @__PURE__ */ new WeakMap();
752
+ const userPatchesSchema = entryListSchema;
753
+ /**
754
+ * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
755
+ * @param ctx - settled app context containing the root Include and an active HMR service.
756
+ * @param options - diagnostic, file, and patch-composition inputs.
757
+ * @returns an asynchronous disposer after the exact-path watcher is ready.
758
+ * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
759
+ */
760
+ async function watchUserPatches(ctx, options) {
761
+ const { binName, filename, compose = (patches) => patches } = options;
762
+ const hmr = ctx.get("hmr");
763
+ if (hmr === void 0) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`);
764
+ const entry = bootstrapIncludes.get(ctx);
765
+ if (entry === void 0) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`);
766
+ const register = hmr.registerConfig(filename, async () => {
767
+ const { patches: _previousPatches, ...includeConfig } = entry.options.config;
768
+ const patches = compose(loadOptionalPatches(binName, filename) ?? []);
769
+ await entry.update({ config: {
770
+ ...includeConfig,
771
+ patches
772
+ } });
773
+ });
774
+ try {
775
+ return await register;
776
+ } catch (error) {
777
+ if (error?.code === "INACTIVE_EFFECT") return async () => {};
778
+ throw error;
779
+ }
780
+ }
781
+ /**
782
+ * Load an optional patch-list file: a top-level YAML array of loader patch
783
+ * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config
784
+ * overrides and `insert` lists, with `!!js` expressions allowed. A missing
785
+ * file means "no layer"; an unreadable, unparsable, or non-array file throws —
786
+ * a present patch file that cannot apply is a misconfiguration and must fail
787
+ * loud at boot, never be silently skipped.
788
+ * @param binName - the diagnostic prefix on the thrown error.
789
+ * @param file - absolute path of the patch file.
790
+ * @returns the parsed patches, or `undefined` when the file does not exist.
791
+ */
792
+ function loadOptionalPatches(binName, file) {
793
+ let content;
794
+ try {
795
+ content = readFileSync(file, "utf8");
796
+ } catch (error) {
797
+ if (error?.code === "ENOENT") return void 0;
798
+ throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`);
799
+ }
800
+ return parsePatchList(binName, file, content, "patches");
801
+ }
802
+ /**
803
+ * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
804
+ * `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
805
+ * but a missing file throws, because the caller named this file — its absence
806
+ * is a misconfiguration, not "no overlay".
807
+ * @param binName - the diagnostic prefix on the thrown error.
808
+ * @param file - absolute path of the overlay file.
809
+ * @returns the parsed patch list.
810
+ */
811
+ function loadOverlayPatches(binName, file) {
812
+ let content;
813
+ try {
814
+ content = readFileSync(file, "utf8");
815
+ } catch (error) {
816
+ throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`);
817
+ }
818
+ return parsePatchList(binName, file, content, "overlay");
819
+ }
820
+ /**
821
+ * Parse one loader patch list: a top-level YAML array of
822
+ * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
823
+ * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
824
+ * because a patch file that cannot be applied at all is a misconfiguration; a
825
+ * single patch whose target row is absent stays a per-entry Loader warning, so
826
+ * one overlay shared across surfaces does not have to match every tree.
827
+ * @param binName - the diagnostic prefix on the thrown error.
828
+ * @param file - the source path, quoted in errors.
829
+ * @param content - the file's text.
830
+ * @param label - what to call this list in errors (`patches`, `overlay`).
831
+ * @returns the parsed patch list.
832
+ */
833
+ function parsePatchList(binName, file, content, label) {
834
+ let parsed;
835
+ try {
836
+ parsed = yaml.load(content, { schema: userPatchesSchema });
837
+ } catch (error) {
838
+ throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`);
839
+ }
840
+ if (!Array.isArray(parsed)) throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`);
841
+ parsed.forEach((entry, index) => {
842
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`);
843
+ });
844
+ return parsed;
845
+ }
846
+ /**
847
+ * Compose the effective entry list exactly as `boot()` would mount it: parse
848
+ * the base config file with the include's entry-list dialect, apply every
849
+ * layer's patches as ONE flattened list through the include's own patch
850
+ * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
851
+ * even patch-visibility corner cases (a later layer targeting a group child a
852
+ * plain config replacement introduced, which the single-pass id index never
853
+ * sees) compose identically — then render the result as YAML in the same
854
+ * dialect (`!!js` expressions print verbatim, unevaluated).
855
+ *
856
+ * Every run of rows from the same file and patch layers is preceded by a `# ==` comment
857
+ * naming the file that contributed the rows and any layers that patched them,
858
+ * so the output stays a loadable YAML document while showing which section
859
+ * comes from which file. The file and patch labels are derived from single-call prefix
860
+ * snapshots (base + layers 1..k), diffed positionally: the patch algorithm
861
+ * only rewrites rows in place or appends, so a top-level index identifies one
862
+ * row across snapshots, and a layer whose addition changes the row (config
863
+ * replacement, disable, group insert) is listed as having patched it.
864
+ *
865
+ * A patch that matches no row is reported through `warn` with its layer
866
+ * label, mirroring the Loader's boot-time warning. Earlier layers' patches
867
+ * see an identical preceding state in every snapshot that includes them, so
868
+ * each snapshot's warning list extends the previous one and the new tail
869
+ * belongs to the added layer.
870
+ * @param binName - the diagnostic prefix on read/parse errors.
871
+ * @param absoluteConfigPath - the base config file `boot()` would include.
872
+ * @param layers - overlay layers in application order (later wins).
873
+ * @param warn - sink for skipped-patch diagnostics; defaults to stderr.
874
+ * @returns the composed entry list rendered as a YAML document with
875
+ * source comment separators.
876
+ */
877
+ function renderConfigDump(binName, absoluteConfigPath, layers, warn = (line) => void process.stderr.write(`${line}\n`)) {
878
+ let content;
879
+ try {
880
+ content = readFileSync(absoluteConfigPath, "utf8");
881
+ } catch (error) {
882
+ throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`);
883
+ }
884
+ let parsed;
885
+ try {
886
+ parsed = yaml.load(content, { schema: entryListSchema });
887
+ } catch (error) {
888
+ throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`);
889
+ }
890
+ if (!Array.isArray(parsed)) throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`);
891
+ const baseLabel = basename(absoluteConfigPath);
892
+ const base = parsed;
893
+ const snapshot = (count, warnings) => {
894
+ return applyEntryPatches(base, structuredClone(layers.slice(0, count).flatMap((layer) => layer.patches)), (message, ...args) => {
895
+ let index = 0;
896
+ warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])));
897
+ });
898
+ };
899
+ let previous = base;
900
+ let previousWarnings = [];
901
+ const provenance = base.map(() => ({
902
+ origin: baseLabel,
903
+ patchedBy: []
904
+ }));
905
+ let composed = base;
906
+ for (let count = 1; count <= layers.length; count += 1) {
907
+ const layer = layers[count - 1];
908
+ /* v8 ignore next -- count iterates 1..length, so the slot exists */
909
+ if (layer === void 0) continue;
910
+ const warnings = [];
911
+ composed = snapshot(count, warnings);
912
+ for (const line of warnings.slice(previousWarnings.length)) warn(`${binName}: [${layer.label}] ${line}`);
913
+ const before = previous.map((entry) => JSON.stringify(entry));
914
+ for (let index = 0; index < composed.length; index += 1) if (index >= before.length) provenance.push({
915
+ origin: layer.label,
916
+ patchedBy: []
917
+ });
918
+ else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label);
919
+ previous = composed;
920
+ previousWarnings = warnings;
921
+ }
922
+ return groupedDump(composed, provenance);
923
+ }
924
+ /** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
925
+ function groupedDump(composed, provenance) {
926
+ const lines = [];
927
+ let currentLabel;
928
+ let group = [];
929
+ const flush = () => {
930
+ if (currentLabel === void 0 || group.length === 0) return;
931
+ lines.push(`# == ${currentLabel}`);
932
+ lines.push(yaml.dump(group, {
933
+ schema: entryListSchema,
934
+ noRefs: true
935
+ }).trimEnd());
936
+ group = [];
937
+ };
938
+ for (let index = 0; index < composed.length; index += 1) {
939
+ const record = provenance[index];
940
+ /* v8 ignore next -- this array is index-aligned with composed by construction */
941
+ if (record === void 0) continue;
942
+ const label = record.patchedBy.length === 0 ? record.origin : `${record.origin}, patched by ${record.patchedBy.join(", ")}`;
943
+ if (label !== currentLabel) {
944
+ flush();
945
+ currentLabel = label;
946
+ }
947
+ group.push(composed[index]);
948
+ }
949
+ flush();
950
+ return lines.join("\n") + "\n";
951
+ }
952
+ /**
953
+ * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
954
+ * @param ctx - context carrying an initialized Loader service.
955
+ * @param absoluteConfigPath - absolute YAML or JSON configuration path.
956
+ * @param patches - initial app and user patches, applied in order.
957
+ * @param bareModuleBaseUrl - optional installed-host base for bare package
958
+ * names; relative names continue to resolve beside the configuration file.
959
+ * @returns the created root Include entry, or `undefined` when a surface
960
+ * disposed the whole tree (taking the Loader service with it) while the
961
+ * transactional create was still settling entry lifecycle.
962
+ */
963
+ async function mountRootInclude(ctx, absoluteConfigPath, patches = [], bareModuleBaseUrl) {
964
+ ctx.loader.builtins.include = bareModuleBaseUrl === void 0 ? Include : class HostResolvedRootInclude extends Include {
965
+ import(name, getOuterStack) {
966
+ const specifier = isAbsolute(name) ? pathToFileURL(name).href : name;
967
+ if (name.startsWith(".") || name.startsWith("cordis:")) return super.import(specifier, getOuterStack);
968
+ const internal = this.ctx.loader.internal;
969
+ /* v8 ignore next -- Node supplies the internal loader; this preserves the
970
+ original diagnostic for hypothetical embedders without it. */
971
+ if (internal === void 0) return super.import(specifier, getOuterStack);
972
+ return internal.import(specifier, bareModuleBaseUrl, {});
973
+ }
974
+ };
975
+ ctx.loader.builtins.group = Group;
976
+ const rootInclude = {
977
+ id: "include",
978
+ name: "cordis:include",
979
+ config: {
980
+ path: pathToFileURL(absoluteConfigPath).href,
981
+ ...patches.length > 0 ? { patches: [...patches] } : {}
982
+ }
983
+ };
984
+ const includeId = await ctx.loader.create(rootInclude);
985
+ const loader = ctx.get("loader");
986
+ if (loader === void 0) return void 0;
987
+ const entry = loader.resolve(includeId);
988
+ bootstrapIncludes.set(ctx, entry);
989
+ return entry;
990
+ }
991
+ const assembledActivationRejections = /* @__PURE__ */ new Map();
992
+ function retainAssembledRejection(reason) {
993
+ assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1);
994
+ }
995
+ function releaseAssembledRejection(reason) {
996
+ const count = assembledActivationRejections.get(reason);
997
+ if (count === void 0 || count === 1) assembledActivationRejections.delete(reason);
998
+ else assembledActivationRejections.set(reason, count - 1);
999
+ }
1000
+ async function observeLoaderRejectionCheckpoint(reasons) {
1001
+ for (const reason of reasons) retainAssembledRejection(reason);
1002
+ try {
1003
+ await new Promise((resolve) => setImmediate(resolve));
1004
+ } finally {
1005
+ for (const reason of reasons) releaseAssembledRejection(reason);
1006
+ }
1007
+ }
1008
+ /**
1009
+ * How long {@link installFailLoud} waits for its `release` hook before exiting
1010
+ * anyway. A wedged disposer must delay the fatal exit, never cancel it.
1011
+ */
1012
+ const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2e3;
1013
+ /**
1014
+ * Install before boot to turn a late unhandled plugin-init rejection into one
1015
+ * labelled stderr diagnostic and `exit(1)`. A rejection already included by
1016
+ * {@link assertEntriesActivated} is ignored during its process checkpoint;
1017
+ * every other rejection remains fatal. Stdout remains untouched for ACP; the
1018
+ * returned function removes the handler.
1019
+ *
1020
+ * The Loader mounts entries concurrently, so a surface that owns the terminal
1021
+ * can already hold it when a sibling entry rejects. Exiting straight from the
1022
+ * handler would strand raw mode, bracketed paste, and the keyboard protocol on
1023
+ * the user's shell, and leave an in-flight terminal query's reply to land as
1024
+ * literal text at the next prompt. `release` is the terminal owner's chance to
1025
+ * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
1026
+ * timer stays referenced so a never-settling disposer cannot let Node reach an
1027
+ * empty event loop and exit 0 instead of failing.
1028
+ *
1029
+ * The diagnostic is written before the release so a hanging or failing disposer
1030
+ * cannot swallow the reason. The handler stays installed while the release runs
1031
+ * — removing it would let a second concurrent rejection become uncaught and kill
1032
+ * the process mid-teardown, stranding exactly the terminal state this restores —
1033
+ * so a latch keeps the first rejection the reported one and lets later
1034
+ * rejections (including the release's own) fall through to the pending exit.
1035
+ * @param binName - the diagnostic prefix on the fatal-failure line.
1036
+ * @param proc - the process slice to register on; tests inject a fake.
1037
+ * @param release - optional teardown awaited before exit, used by a
1038
+ * terminal-owning surface to restore the terminal. Its own failure is
1039
+ * swallowed because the pending fatal exit already owns the outcome.
1040
+ * @returns the uninstaller that removes the rejection handler.
1041
+ */
1042
+ function installFailLoud(binName, proc = process, release) {
1043
+ let exiting = false;
1044
+ const handler = (err) => {
1045
+ if (assembledActivationRejections.has(err)) return;
1046
+ if (exiting) return;
1047
+ exiting = true;
1048
+ proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1049
+ if (release === void 0) {
1050
+ proc.exit(1);
1051
+ return;
1052
+ }
1053
+ (async () => {
1054
+ let timer;
1055
+ try {
1056
+ await Promise.race([(async () => release())(), new Promise((resolve) => {
1057
+ timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS);
1058
+ })]);
1059
+ } catch {}
1060
+ clearTimeout(timer);
1061
+ proc.exit(1);
1062
+ })();
1063
+ };
1064
+ const uninstall = () => void proc.off("unhandledRejection", handler);
1065
+ proc.on("unhandledRejection", handler);
1066
+ return uninstall;
1067
+ }
1068
+ /**
1069
+ * After the tree settles, reject entries with no fiber and name every plugin
1070
+ * whose module failed to resolve. Disabled entries are the only valid
1071
+ * fiber-less state.
1072
+ * @param ctx - the settled context whose loader entries to audit.
1073
+ * @param binName - the diagnostic prefix on the thrown error.
1074
+ */
1075
+ function assertEntriesLoaded(ctx, binName) {
1076
+ const failed = [...ctx.loader.entries()].filter((entry) => entry.fiber === void 0 && !entry.disabled);
1077
+ if (failed.length > 0) {
1078
+ const names = failed.map((entry) => entry.options.name).join(", ");
1079
+ throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`);
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Value mirrors used because Cordis's const enum has no runtime object to import.
1084
+ * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
1085
+ * `packages/client/web/src/loader-status.ts`.
1086
+ */
1087
+ const FIBER_PENDING = 0;
1088
+ const FIBER_ACTIVE = 2;
1089
+ const FIBER_FAILED = 3;
1090
+ /** Render a thrown plugin value without discarding an Error's original stack. */
1091
+ function formatActivationError(error) {
1092
+ return error instanceof Error ? error.stack ?? error.message : String(error);
1093
+ }
1094
+ /**
1095
+ * Reject a settled Loader tree when an enabled entry failed or remains inactive.
1096
+ * Plugin failures include the original thrown stack; pending entries name their
1097
+ * unresolved services because no plugin error exists for that state. Active
1098
+ * entries require no further wait; only failed fibers are awaited to recover
1099
+ * their private rejection reason.
1100
+ * @param ctx - the settled context whose Loader entries to audit.
1101
+ * @param binName - the diagnostic prefix on the thrown error.
1102
+ * @returns nothing when every enabled entry is active.
1103
+ * @throws after one process rejection checkpoint when an entry failed to
1104
+ * import, rejected during activation, or did not become active.
1105
+ */
1106
+ async function assertEntriesActivated(ctx, binName) {
1107
+ assertEntriesLoaded(ctx, binName);
1108
+ const failures = [];
1109
+ const rejectionReasons = [];
1110
+ for (const entry of ctx.loader.entries()) {
1111
+ const fiber = entry.fiber;
1112
+ if (fiber === void 0 || entry.disabled) continue;
1113
+ const state = fiber.state;
1114
+ if (state === FIBER_ACTIVE) continue;
1115
+ if (state === FIBER_FAILED) {
1116
+ try {
1117
+ await fiber.await();
1118
+ } catch (error) {
1119
+ rejectionReasons.push(error);
1120
+ failures.push(`${entry.options.name}: ${formatActivationError(error)}`);
1121
+ }
1122
+ continue;
1123
+ }
1124
+ if (state === FIBER_PENDING) {
1125
+ const missing = Object.keys(fiber.inject).filter((service) => fiber.ctx.get(service) === void 0);
1126
+ const subject = missing.length === 1 ? "service" : "services";
1127
+ failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(", ") || "unknown"})`);
1128
+ } else failures.push(`${entry.options.name}: fiber state ${String(state)}`);
1129
+ }
1130
+ if (failures.length > 0) {
1131
+ if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons);
1132
+ const noun = failures.length === 1 ? "entry" : "entries";
1133
+ throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join("\n")}`);
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Boot the Loader against `absoluteConfigPath` and return only after the whole
1138
+ * tree settles. Relative entry names resolve against the config directory;
1139
+ * bare package names resolve there by default or against an explicit
1140
+ * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include
1141
+ * is statically imported and mounted as the `cordis:include` builtin, loading
1142
+ * through the ambient module pipeline (vite/tsx/plain ESM). The package build
1143
+ * embeds Include while leaving Loader external, so the built include tree and
1144
+ * host share one Loader peer. Loader
1145
+ * settlement rejects startup failures, which `boot` wraps after disposing the
1146
+ * partial context; a missing fiber or never-activating entry is rejected by
1147
+ * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
1148
+ * init rejection with its original stack; later unhandled rejections remain
1149
+ * covered by {@link installFailLoud}. Built bins need the Loader's native
1150
+ * helper for bare plugin specifiers; relative specifiers do not.
1151
+ * @param binName - the diagnostic prefix for load-failure errors.
1152
+ * @param absoluteConfigPath - the config to include; must already be absolute
1153
+ * (see {@link resolveConfigPath}).
1154
+ * @param patches - optional overlay patches applied over the included tree
1155
+ * (see {@link loadOptionalPatches}); an empty list mounts none.
1156
+ * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
1157
+ * @param bareModuleBaseUrl - optional installed-host base for bare package
1158
+ * names; use it when the host, rather than the configuration project, owns the
1159
+ * complete plugin set.
1160
+ * @returns the root context once every entry has started, or as soon as a
1161
+ * surface disposed the tree while startup was still in flight.
1162
+ * @throws a labelled error after disposing the partial context — `host
1163
+ * preparation failed` when `prepare` threw before any config-tree entry
1164
+ * mounted, `plugin tree failed to load` afterwards.
1165
+ */
1166
+ async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBaseUrl) {
1167
+ const ctx = new Context();
1168
+ let stage = "host preparation failed";
1169
+ try {
1170
+ ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/";
1171
+ ctx.provide("dshHomePath", dshHomePath);
1172
+ await ctx.plugin(Loader);
1173
+ await prepare?.(ctx);
1174
+ stage = "plugin tree failed to load";
1175
+ await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl);
1176
+ await ctx.get("loader")?.await();
1177
+ if (ctx.get("loader") === void 0) return ctx;
1178
+ await assertEntriesActivated(ctx, binName);
1179
+ return ctx;
1180
+ } catch (cause) {
1181
+ await ctx.fiber.dispose();
1182
+ const detail = cause instanceof Error ? cause.message : String(cause);
1183
+ let deepest = cause;
1184
+ while (deepest instanceof Error && deepest.cause !== void 0) deepest = deepest.cause;
1185
+ const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : "";
1186
+ throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause });
1187
+ }
1188
+ }
1189
+ /** Prompt-section name for the harness-source location line an app bin adds after boot. */
1190
+ const HARNESS_SOURCE_SECTION = "harness:source";
1191
+ /**
1192
+ * Add a global prompt section naming the on-disk harness source checkout while
1193
+ * explicitly distinguishing it from the task workspace and current working
1194
+ * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
1195
+ * checkout. Call once on the settled boot context ({@link boot}); the section
1196
+ * orders just after the harness identity opener (`-100`) and before the deployment
1197
+ * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
1198
+ * augment, so this is then a no-op that returns `undefined`. The section is
1199
+ * registered against the `systemPrompt` service's fiber, so a dev HMR reload of
1200
+ * that plugin drops it until the next boot.
1201
+ * @param ctx - the settled boot context whose global system prompt to augment.
1202
+ * @param sourceRoot - the absolute path to the harness checkout root.
1203
+ * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
1204
+ */
1205
+ function addHarnessSourceSection(ctx, sourceRoot) {
1206
+ const systemPrompt = ctx.get("systemPrompt");
1207
+ if (systemPrompt === void 0) return void 0;
1208
+ return systemPrompt.section({
1209
+ name: HARNESS_SOURCE_SECTION,
1210
+ order: -99,
1211
+ text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
1212
+ });
1213
+ }
1214
+ //#endregion
1215
+ export { DEFAULT_PROFILE_BUNDLES, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, PROFILES_DIR, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, composeEntries, healProfilesModuleFallback, initProfile, installFailLoud, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, mountRootInclude, readProfileManifest, renderConfigDump, resolveBundleDir, resolveConfigPath, resolveProfileDir, watchUserPatches, writeProfileManifest };