@qlzt/dsh-app-boot 0.0.0-stage → 0.2.1-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +73 -0
- package/README.md +221 -2
- package/README.zh.md +222 -0
- package/lib/index.js +4293 -0
- package/lib/types/compatibility-preflight.d.ts +26 -0
- package/lib/types/config-schema/collect.d.ts +16 -0
- package/lib/types/config-schema/document.d.ts +13 -0
- package/lib/types/config-schema/index.d.ts +19 -0
- package/lib/types/config-schema/native.d.ts +9 -0
- package/lib/types/config-schema/pattern.d.ts +8 -0
- package/lib/types/config-schema/projector.d.ts +20 -0
- package/lib/types/config-schema/types.d.ts +95 -0
- package/lib/types/index.d.ts +327 -0
- package/lib/types/official-bundle-packages.d.ts +4 -0
- package/lib/types/official-bundles.d.ts +12 -0
- package/lib/types/official-bundles.generated.d.ts +5 -0
- package/lib/types/package-meta.d.ts +29 -0
- package/lib/types/plugin-compatibility.d.ts +35 -0
- package/lib/types/profile-compatibility.d.ts +50 -0
- package/lib/types/profile-context.d.ts +51 -0
- package/lib/types/profile-plugins.d.ts +60 -0
- package/lib/types/profile-resolution/legacy-links.d.ts +15 -0
- package/lib/types/profile-resolution/resolver.d.ts +45 -0
- package/lib/types/profile-resolution/service.d.ts +66 -0
- package/lib/types/profile-resolution/worker-bootstrap.d.ts +3 -0
- package/lib/types/profile-sanitize.d.ts +13 -0
- package/lib/types/profile.d.ts +277 -0
- package/lib/worker/profile-resolution-bootstrap.js +728 -0
- package/package.json +65 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,4293 @@
|
|
|
1
|
+
import { createRequire, isBuiltin } from "node:module";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { inspect, parseEnv } from "node:util";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
6
|
+
import * as yaml from "js-yaml";
|
|
7
|
+
import { load } from "js-yaml";
|
|
8
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
9
|
+
import Loader, { EntryGroup, EntryTree, ModuleLoader, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
|
|
10
|
+
import { access, constants, mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
12
|
+
import Group from "@deepseek-ai/cordis-plugin-group";
|
|
13
|
+
import { dshHomePath, resolveDshHome } from "@qlzt/dsh-home-paths";
|
|
14
|
+
import { createLaunchEnvironmentSnapshot } from "@qlzt/dsh-launch-environment";
|
|
15
|
+
import semver, { parse } from "semver";
|
|
16
|
+
import { withFileLock, writeFileAtomic } from "@qlzt/dsh-atomic-write";
|
|
17
|
+
import { getEnvironmentData, setEnvironmentData } from "node:worker_threads";
|
|
18
|
+
import { imports } from "resolve.exports";
|
|
19
|
+
//#region ../../../vendor/include/src/index.ts
|
|
20
|
+
const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
|
|
21
|
+
kind: "scalar",
|
|
22
|
+
resolve: (data) => typeof data === "string",
|
|
23
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
24
|
+
predicate: isJsExpr,
|
|
25
|
+
represent: (data) => data["__jsExpr"]
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
|
|
29
|
+
* the Loader evaluates at entry activation. Exported so config tooling
|
|
30
|
+
* (`dsh --dump-config`) parses and prints exactly the dialect this include
|
|
31
|
+
* mounts.
|
|
32
|
+
*/
|
|
33
|
+
const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
|
|
34
|
+
const schema = entryListSchema;
|
|
35
|
+
const writable = {
|
|
36
|
+
".json": "application/json",
|
|
37
|
+
".yaml": "application/yaml",
|
|
38
|
+
".yml": "application/yaml"
|
|
39
|
+
};
|
|
40
|
+
const supported = new Set(Object.keys(writable));
|
|
41
|
+
const WRITE_RETRY_LIMIT = 10;
|
|
42
|
+
const WRITE_RETRY_DELAY_MS = 50;
|
|
43
|
+
function retryableWriteError(error) {
|
|
44
|
+
const code = error?.code;
|
|
45
|
+
return code === "EACCES" || code === "EBUSY" || code === "EPERM";
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Apply patch lists to an entry list — THE patch semantics of this include,
|
|
49
|
+
* shared by mounting (`applyPatches`) and offline config tooling
|
|
50
|
+
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
|
51
|
+
* is never mutated: patching shared entry objects would bake earlier patch
|
|
52
|
+
* values into the cached parse, so repeated application (config hot-reloads)
|
|
53
|
+
* could never revert a removed or changed patch. Inserted entries are indexed
|
|
54
|
+
* as they are added, so a later patch in the same list can target a row an
|
|
55
|
+
* earlier patch inserted. A patch that matches nothing warns and is skipped.
|
|
56
|
+
* @param data - the parsed entry list (JSON-safe plain data).
|
|
57
|
+
* @param patches - the patch list to apply, in order.
|
|
58
|
+
* @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
|
|
59
|
+
* @returns a detached entry list with every applicable patch applied.
|
|
60
|
+
*/
|
|
61
|
+
function applyEntryPatches(data, patches, warn) {
|
|
62
|
+
if (!patches?.length) return [...data];
|
|
63
|
+
data = structuredClone(data);
|
|
64
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
65
|
+
const buildMap = (entries) => {
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.id) entryMap.set(entry.id, entry);
|
|
68
|
+
if (entry.group && Array.isArray(entry.config)) buildMap(entry.config);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
buildMap(data);
|
|
72
|
+
for (const patch of patches) {
|
|
73
|
+
const { id, insert, name, ...overrides } = patch;
|
|
74
|
+
if (insert) {
|
|
75
|
+
if (id) {
|
|
76
|
+
const target = entryMap.get(id);
|
|
77
|
+
if (!target) {
|
|
78
|
+
warn("patch insert: entry %C not found", id);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!target.group) {
|
|
82
|
+
warn("patch insert: entry %C is not a group", id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(target.config)) target.config = [];
|
|
86
|
+
target.config.push(...insert);
|
|
87
|
+
} else data.push(...insert);
|
|
88
|
+
buildMap(insert);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!id) {
|
|
92
|
+
warn("patch: id is required for non-insert patches");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const target = entryMap.get(id);
|
|
96
|
+
if (!target) {
|
|
97
|
+
warn("patch: entry %C not found", id);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (name && name !== target.name) {
|
|
101
|
+
warn("patch: name mismatch for %C (expected %C, got %C), skipping", id, target.name, name);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
105
|
+
if (key === "id") continue;
|
|
106
|
+
target[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
/** Loader entry tree backed by a YAML or JSON file. */
|
|
112
|
+
var Include = class extends EntryTree {
|
|
113
|
+
config;
|
|
114
|
+
static inject = ["loader"];
|
|
115
|
+
static [EntryGroup.key] = true;
|
|
116
|
+
filename;
|
|
117
|
+
type;
|
|
118
|
+
readonly;
|
|
119
|
+
content;
|
|
120
|
+
data;
|
|
121
|
+
writeTask;
|
|
122
|
+
pendingWrite;
|
|
123
|
+
writeQueue = Promise.resolve();
|
|
124
|
+
constructor(ctx, config) {
|
|
125
|
+
super(ctx);
|
|
126
|
+
this.config = config;
|
|
127
|
+
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false;
|
|
128
|
+
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl));
|
|
129
|
+
const ext = extname(this.filename);
|
|
130
|
+
if (!supported.has(ext)) throw new Error(`extension "${ext}" not supported`);
|
|
131
|
+
this.type = writable[ext];
|
|
132
|
+
this.readonly = !this.type;
|
|
133
|
+
this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
|
|
134
|
+
ctx.on("internal/update", (config, _, next) => {
|
|
135
|
+
if (config.path !== this.config.path) return next();
|
|
136
|
+
this.config = config;
|
|
137
|
+
this.root.update(this.applyPatches(this.data, config.patches)).catch((error) => {
|
|
138
|
+
this.ctx.logger.warn("config update at %C failed", this.filename);
|
|
139
|
+
this.ctx.logger.warn(error);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async checkAccess() {
|
|
144
|
+
if (!this.type) return;
|
|
145
|
+
try {
|
|
146
|
+
await access(this.filename, constants.W_OK);
|
|
147
|
+
} catch {
|
|
148
|
+
this.readonly = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async read(forced = false) {
|
|
152
|
+
const content = await readFile(this.filename, "utf8");
|
|
153
|
+
if (!forced && this.content === content) return false;
|
|
154
|
+
let data;
|
|
155
|
+
if (this.type === "application/yaml") data = yaml.load(content, { schema: entryListSchema });
|
|
156
|
+
else if (this.type === "application/json") data = JSON.parse(content);
|
|
157
|
+
else {
|
|
158
|
+
const module = await import(
|
|
159
|
+
/* @vite-ignore */
|
|
160
|
+
this.filename
|
|
161
|
+
);
|
|
162
|
+
data = module.default || module;
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(data)) throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`);
|
|
165
|
+
this.content = content;
|
|
166
|
+
this.data = data;
|
|
167
|
+
await this.checkAccess();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
applyPatches(data, patches = this.config.patches) {
|
|
171
|
+
return applyEntryPatches(data, patches, (message, ...args) => {
|
|
172
|
+
this.ctx.root.logger?.("loader").warn(message, ...args);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async *[Service.init]() {
|
|
176
|
+
try {
|
|
177
|
+
await this.read();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error?.code !== "ENOENT") throw error;
|
|
180
|
+
if (this.config.initial) {
|
|
181
|
+
await this._writeFile(this.config.initial);
|
|
182
|
+
await this.read(true);
|
|
183
|
+
} else throw new Error(`config file not found: ${this.filename}`);
|
|
184
|
+
}
|
|
185
|
+
yield () => this.stop();
|
|
186
|
+
await this.root.update(this.applyPatches(this.data));
|
|
187
|
+
}
|
|
188
|
+
async stop() {
|
|
189
|
+
try {
|
|
190
|
+
await this.flushWrite();
|
|
191
|
+
} finally {
|
|
192
|
+
this.root.stop();
|
|
193
|
+
await this.flushWrite();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Re-read the file and refresh child entries when content changed. An
|
|
198
|
+
* unreadable or unparsable file logs a warning and keeps the last good
|
|
199
|
+
* tree: a hot-reload of a live app must never take the process down.
|
|
200
|
+
*/
|
|
201
|
+
async refresh() {
|
|
202
|
+
try {
|
|
203
|
+
if (!await this.read()) return;
|
|
204
|
+
await this.root.update(this.applyPatches(this.data));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
this.ctx.logger.warn("config reload at %C failed; keeping the running tree", this.filename);
|
|
207
|
+
this.ctx.logger.warn(error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async _writeFile(config) {
|
|
211
|
+
if (this.readonly) throw new Error(`cannot overwrite readonly config`);
|
|
212
|
+
if (this.type === "application/yaml") this.content = yaml.dump(config, { schema });
|
|
213
|
+
else if (this.type === "application/json") this.content = JSON.stringify(config, null, 2);
|
|
214
|
+
await writeFile(this.filename + ".tmp", this.content);
|
|
215
|
+
for (let retry = 0;; retry++) try {
|
|
216
|
+
await rename(this.filename + ".tmp", this.filename);
|
|
217
|
+
return;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error;
|
|
220
|
+
await setTimeout$1((retry + 1) * WRITE_RETRY_DELAY_MS);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
writeFile(config) {
|
|
224
|
+
clearTimeout(this.writeTask);
|
|
225
|
+
this.pendingWrite = config;
|
|
226
|
+
this.writeTask = setTimeout(() => {
|
|
227
|
+
this.flushWrite();
|
|
228
|
+
}, 0);
|
|
229
|
+
}
|
|
230
|
+
flushWrite() {
|
|
231
|
+
clearTimeout(this.writeTask);
|
|
232
|
+
this.writeTask = void 0;
|
|
233
|
+
const config = this.pendingWrite;
|
|
234
|
+
this.pendingWrite = void 0;
|
|
235
|
+
if (config === void 0) return this.writeQueue;
|
|
236
|
+
const run = this.writeQueue.then(() => this._writeFile(config), () => this._writeFile(config));
|
|
237
|
+
this.writeQueue = run;
|
|
238
|
+
run.catch((error) => {
|
|
239
|
+
this.ctx.root.logger?.("loader").warn("failed to write config file %C", this.filename);
|
|
240
|
+
this.ctx.root.logger?.("loader").warn(error);
|
|
241
|
+
});
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
/** Schedule a write of the current root entry data. */
|
|
245
|
+
write() {
|
|
246
|
+
this.context.emit("loader/config-update");
|
|
247
|
+
return this.writeFile(this.root.data);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region lib/types/plugin-compatibility.js
|
|
252
|
+
/** Evaluate plugin dsh peer requirements without importing plugin code. */
|
|
253
|
+
function objectOf$1(value, field) {
|
|
254
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${field} must be an object`);
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
function runtimeVersionOf(value) {
|
|
258
|
+
if (typeof value !== "string" || semver.valid(value) === null) throw new Error(`Invalid dsh runtime version: ${JSON.stringify(value)}; expected a semantic version`);
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
function identityField(manifest, field) {
|
|
262
|
+
const value = Object.hasOwn(manifest, field) ? manifest[field] : void 0;
|
|
263
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`Plugin manifest ${field} must be a non-empty string when dsh peers are incompatible`);
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Read this app-boot package's version in both source and bundled installations.
|
|
268
|
+
* @returns the validated runtime semantic version, preserving its exact spelling.
|
|
269
|
+
* @throws if package.json cannot be read or its version is missing or invalid.
|
|
270
|
+
*/
|
|
271
|
+
function getDshRuntimeVersion() {
|
|
272
|
+
const filename = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
273
|
+
const manifest = objectOf$1(JSON.parse(fs.readFileSync(filename, "utf8")), "app-boot package.json");
|
|
274
|
+
return runtimeVersionOf(Object.hasOwn(manifest, "version") ? manifest.version : void 0);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Check every @qlzt/dsh or @deepseek-ai/dsh-* peer against the runtime.
|
|
278
|
+
* Prereleases participate in ranges. workspace:^, workspace:~, and workspace:*
|
|
279
|
+
* refer to the current runtime; other invalid ranges are incompatible.
|
|
280
|
+
* @param manifest - parsed plugin package.json; inherited fields are ignored.
|
|
281
|
+
* @param exemptions - exact plugin name@version keys mapped to exact runtime versions.
|
|
282
|
+
* @param runtimeVersion - running dsh version, defaulting to this app-boot package.
|
|
283
|
+
* @returns incompatible peers and exemption status, or undefined when none are incompatible.
|
|
284
|
+
* @throws for malformed manifest peer fields, invalid runtime versions, or missing identity on a mismatch.
|
|
285
|
+
*/
|
|
286
|
+
function evaluatePluginCompatibility(manifest, exemptions = {}, runtimeVersion = getDshRuntimeVersion()) {
|
|
287
|
+
runtimeVersionOf(runtimeVersion);
|
|
288
|
+
const fields = objectOf$1(manifest, "Plugin manifest");
|
|
289
|
+
if (!Object.hasOwn(fields, "peerDependencies")) return void 0;
|
|
290
|
+
const dependencies = objectOf$1(fields.peerDependencies, "Plugin manifest peerDependencies");
|
|
291
|
+
const peers = {};
|
|
292
|
+
for (const [name, range] of Object.entries(dependencies)) {
|
|
293
|
+
if (typeof range !== "string") throw new Error(`Plugin manifest peerDependencies[${JSON.stringify(name)}] must be a string`);
|
|
294
|
+
if (name !== "@qlzt/dsh" && !name.startsWith("@deepseek-ai/dsh-")) continue;
|
|
295
|
+
const requirement = [
|
|
296
|
+
"workspace:^",
|
|
297
|
+
"workspace:~",
|
|
298
|
+
"workspace:*"
|
|
299
|
+
].includes(range) ? runtimeVersion : range;
|
|
300
|
+
if (requirement.trim() === "" || !semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) peers[name] = range;
|
|
301
|
+
}
|
|
302
|
+
if (Object.keys(peers).length === 0) return void 0;
|
|
303
|
+
const name = identityField(fields, "name");
|
|
304
|
+
const version = identityField(fields, "version");
|
|
305
|
+
const key = `${name}@${version}`;
|
|
306
|
+
return {
|
|
307
|
+
name,
|
|
308
|
+
version,
|
|
309
|
+
runtimeVersion,
|
|
310
|
+
peers,
|
|
311
|
+
exempted: (Object.hasOwn(exemptions, key) ? exemptions[key] : void 0)?.includes(runtimeVersion) === true
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Describe incompatible peers, their risk, and the exact-version remedy.
|
|
316
|
+
* Surfaces with their own grant mechanism or locale render the structured result themselves.
|
|
317
|
+
* @param issue - incompatible plugin/runtime result, including exempted mismatches.
|
|
318
|
+
* @returns an English diagnostic for logs and stderr.
|
|
319
|
+
*/
|
|
320
|
+
function pluginCompatibilityWarning(issue) {
|
|
321
|
+
const key = `${issue.name}@${issue.version}`;
|
|
322
|
+
return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. Running it may cause crashes or data loss. Update the plugin or install a plugin version compatible with this dsh runtime. To accept this risk explicitly, grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} with \`dsh plugin allow-version\` or the plugin manager, then retry the installation or restart dsh. Exact-version exemption: ${issue.exempted ? "active" : "not active"}.`;
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region lib/types/profile-compatibility.js
|
|
326
|
+
/** Profile-local compatibility permissions readable before any Cordis plugins load. */
|
|
327
|
+
/** Independent profile metadata; neither package manifests nor Cordis patches carry grants. */
|
|
328
|
+
const PROFILE_COMPATIBILITY_FILENAME = "compatibility.json";
|
|
329
|
+
/** Published and scoped npm package names, as npm accepts them in a manifest dependency key. */
|
|
330
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
331
|
+
/** Whether a decoded record accepts only exact DSH versions. */
|
|
332
|
+
function isVersionList(value) {
|
|
333
|
+
return Array.isArray(value) && value.every((version) => typeof version === "string" && isExactPluginVersion(version));
|
|
334
|
+
}
|
|
335
|
+
/** Test whether an exemption names a canonical exact SemVer, including build metadata.
|
|
336
|
+
* @param value Version supplied by a manifest or user.
|
|
337
|
+
* @returns False for ranges, prefixes, whitespace, and malformed versions.
|
|
338
|
+
*/
|
|
339
|
+
function isExactPluginVersion(value) {
|
|
340
|
+
const parsed = parse(value);
|
|
341
|
+
return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? "" : `+${parsed.build.join(".")}`}`;
|
|
342
|
+
}
|
|
343
|
+
/** Validate one explicit exemption without granting it.
|
|
344
|
+
* @param packageVersion Exact npm package-name@version, never an installation spec or range.
|
|
345
|
+
* @param runtimeVersion Exact DSH version, including prerelease and build metadata.
|
|
346
|
+
* @throws When either identity is not canonical.
|
|
347
|
+
*/
|
|
348
|
+
function validatePluginVersionExemption(packageVersion, runtimeVersion) {
|
|
349
|
+
const separator = packageVersion.lastIndexOf("@");
|
|
350
|
+
if (separator <= 0 || !PACKAGE_NAME.test(packageVersion.slice(0, separator)) || !isExactPluginVersion(packageVersion.slice(separator + 1)) || !isExactPluginVersion(runtimeVersion)) throw new Error("Version exemptions require an exact npm package-name@version and an exact DSH runtime version");
|
|
351
|
+
}
|
|
352
|
+
/** Read the profile's independent compatibility file without loading plugins.
|
|
353
|
+
* A missing file authorizes nothing. An unreadable or unparsable file authorizes nothing and is
|
|
354
|
+
* reported instead of failing, so a bad file can never make the profile unusable; rejected records
|
|
355
|
+
* are skipped while the remaining valid ones still apply.
|
|
356
|
+
* @param profileDir Absolute profile directory.
|
|
357
|
+
* @returns Accepted exemptions plus every problem found; no manifest fallback is used.
|
|
358
|
+
*/
|
|
359
|
+
function readProfileCompatibility(profileDir) {
|
|
360
|
+
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME);
|
|
361
|
+
const unreadable = (reason) => ({
|
|
362
|
+
exemptions: {},
|
|
363
|
+
warnings: [`${filename} ${reason}; treating the profile as having no exemptions`],
|
|
364
|
+
rewritable: false
|
|
365
|
+
});
|
|
366
|
+
let text;
|
|
367
|
+
try {
|
|
368
|
+
text = readFileSync(filename, "utf8");
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code === "ENOENT") return {
|
|
371
|
+
exemptions: {},
|
|
372
|
+
warnings: [],
|
|
373
|
+
rewritable: true
|
|
374
|
+
};
|
|
375
|
+
return unreadable(`cannot be read (${String(error)})`);
|
|
376
|
+
}
|
|
377
|
+
let value;
|
|
378
|
+
try {
|
|
379
|
+
value = JSON.parse(text);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return unreadable(`is not valid JSON (${String(error)})`);
|
|
382
|
+
}
|
|
383
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return unreadable("must map exact package@version keys to DSH version lists");
|
|
384
|
+
const exemptions = {};
|
|
385
|
+
const warnings = [];
|
|
386
|
+
for (const [key, versions] of Object.entries(value)) {
|
|
387
|
+
const separator = key.lastIndexOf("@");
|
|
388
|
+
if (separator <= 0 || !PACKAGE_NAME.test(key.slice(0, separator)) || !isExactPluginVersion(key.slice(separator + 1))) {
|
|
389
|
+
warnings.push(`${filename}: ${JSON.stringify(key)} is not an exact package-name@version key; the record is ignored`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!isVersionList(versions)) {
|
|
393
|
+
warnings.push(`${filename}: ${key} must contain a list of exact DSH versions; the record is ignored`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
exemptions[key] = versions;
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
exemptions,
|
|
400
|
+
warnings,
|
|
401
|
+
rewritable: warnings.length === 0
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** Read only the accepted exemptions of a profile.
|
|
405
|
+
* @param profileDir Absolute profile directory.
|
|
406
|
+
* @returns Exact package-name@version keys mapped to their allowed DSH versions.
|
|
407
|
+
*/
|
|
408
|
+
function readProfileVersionExemptions(profileDir) {
|
|
409
|
+
return readProfileCompatibility(profileDir).exemptions;
|
|
410
|
+
}
|
|
411
|
+
/** Persist one informed grant or revocation under the compatibility file's own lock.
|
|
412
|
+
* @param profileDir Profile directory; no package manifest is created or modified.
|
|
413
|
+
* @param packageVersion Exact manifest package-name@version.
|
|
414
|
+
* @param runtimeVersion Exact DSH version; grants must name the current runtime, revocations may name historical ones.
|
|
415
|
+
* @param enabled Whether to grant rather than revoke.
|
|
416
|
+
* @param acceptRisk Required true for grants after explicit acknowledgement of possible crashes or data loss.
|
|
417
|
+
* @returns After the atomic write. Existing plugin instances are not reloaded by this operation.
|
|
418
|
+
* @throws For invalid identities, missing consent, a stale runtime, or a file the reader rejected,
|
|
419
|
+
* which the user must repair by hand because rewriting it would discard their content.
|
|
420
|
+
*/
|
|
421
|
+
async function setProfileVersionExemption(profileDir, packageVersion, runtimeVersion, enabled, acceptRisk) {
|
|
422
|
+
validatePluginVersionExemption(packageVersion, runtimeVersion);
|
|
423
|
+
if (enabled && !acceptRisk) throw new Error("Incompatible plugins may cause crashes or data loss. To grant this exact-version exemption, explicitly acknowledge the risk with --accept-risk (acceptRisk: true).");
|
|
424
|
+
const current = getDshRuntimeVersion();
|
|
425
|
+
if (enabled && runtimeVersion !== current) throw new Error(`Cannot approve DSH ${runtimeVersion}: this application runs DSH ${current}. Use --dsh-version ${current}.`);
|
|
426
|
+
await mkdir(profileDir, { recursive: true });
|
|
427
|
+
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME);
|
|
428
|
+
await withFileLock(filename, async () => {
|
|
429
|
+
const current = readProfileCompatibility(profileDir);
|
|
430
|
+
if (!current.rewritable) throw new Error(`${PROFILE_COMPATIBILITY_FILENAME} must be repaired before exemptions change:\n${current.warnings.join("\n")}`);
|
|
431
|
+
const exemptions = current.exemptions;
|
|
432
|
+
const versions = exemptions[packageVersion] ?? [];
|
|
433
|
+
if (enabled) exemptions[packageVersion] = [...new Set([...versions, runtimeVersion])];
|
|
434
|
+
else {
|
|
435
|
+
const retained = versions.filter((version) => version !== runtimeVersion);
|
|
436
|
+
if (retained.length) exemptions[packageVersion] = retained;
|
|
437
|
+
else Reflect.deleteProperty(exemptions, packageVersion);
|
|
438
|
+
}
|
|
439
|
+
await writeFileAtomic(filename, JSON.stringify(exemptions, void 0, 2) + "\n", { mode: 384 });
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region lib/types/profile-resolution/legacy-links.js
|
|
444
|
+
/** Package directory and resource canonicalization through the active runtime carrier's filesystem. */
|
|
445
|
+
/**
|
|
446
|
+
* Return whether the process reads application modules from pkg's virtual filesystem.
|
|
447
|
+
* @returns whether pkg owns the module filesystem.
|
|
448
|
+
*/
|
|
449
|
+
function isPackagedExecutable() {
|
|
450
|
+
return process.pkg !== void 0;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Resolve a directory through the active carrier's filesystem implementation.
|
|
454
|
+
* @param path - directory path to canonicalize.
|
|
455
|
+
* @returns the canonical directory path.
|
|
456
|
+
*/
|
|
457
|
+
function realModuleDirectory(path) {
|
|
458
|
+
return isPackagedExecutable() ? realpathSync(path) : realpathSync.native(path);
|
|
459
|
+
}
|
|
460
|
+
/** Canonicalize a module resource, preserving physical file aliases.
|
|
461
|
+
* SEA archive files are symlink-free and support stat and reads, while their containing directories support realpath.
|
|
462
|
+
* @param path Existing module resource path.
|
|
463
|
+
* @returns Canonical resource path under the active carrier's filesystem.
|
|
464
|
+
* @throws When the resource or its directory cannot be inspected or resolved.
|
|
465
|
+
*/
|
|
466
|
+
function realModuleFile(path) {
|
|
467
|
+
return !isPackagedExecutable() || lstatSync(path).isSymbolicLink() ? realpathSync(path) : join(realModuleDirectory(dirname(path)), basename(path));
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region lib/types/profile.js
|
|
471
|
+
/**
|
|
472
|
+
* Profile discovery, initialization, and patch-layer composition for the
|
|
473
|
+
* `dsh --profile` launcher family.
|
|
474
|
+
*
|
|
475
|
+
* A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
|
|
476
|
+
* `package.json` (out-of-tree plugin dependencies plus the profile manifest
|
|
477
|
+
* `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
|
|
478
|
+
* (the user's own patch layer, applied after every bundle layer). Bundles are
|
|
479
|
+
* npm packages whose manifest declares
|
|
480
|
+
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (one file, or an
|
|
481
|
+
* ordered list of files); the tree is composed by applying each bundle's patch
|
|
482
|
+
* lists in `dsh.profile.bundles` order over an empty entry list, then the
|
|
483
|
+
* profile's own patches, then any launcher layers (`--patch` files and
|
|
484
|
+
* flag-derived patches).
|
|
485
|
+
*
|
|
486
|
+
* Module resolution is two-anchor by construction: a bundle name resolves
|
|
487
|
+
* first from the dsh installation (the launcher's own package), then from the
|
|
488
|
+
* profile directory. Pnpm-managed entries in the profile's `node_modules`
|
|
489
|
+
* resolve first. The runtime resolution supplies packages carried by the
|
|
490
|
+
* installation and selected bundles to Node's ESM and CommonJS resolvers.
|
|
491
|
+
* @module @qlzt/dsh-app-boot/profile
|
|
492
|
+
*/
|
|
493
|
+
/** Directory under the Harness home holding every profile. */
|
|
494
|
+
const PROFILES_DIR = "profiles";
|
|
495
|
+
/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
|
|
496
|
+
const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
|
|
497
|
+
/**
|
|
498
|
+
* The patch files a bundle declares, as written: one file for a string
|
|
499
|
+
* `patch`, the listed files in order for an array.
|
|
500
|
+
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
|
|
501
|
+
* @returns the package-relative patch file paths in application order.
|
|
502
|
+
* @throws {Error} when `patch` is neither a string nor a list of strings.
|
|
503
|
+
*/
|
|
504
|
+
function bundlePatchFiles(bundle) {
|
|
505
|
+
const declared = typeof bundle.patch === "string" ? [bundle.patch] : bundle.patch;
|
|
506
|
+
if (!Array.isArray(declared) || !declared.every((file) => typeof file === "string")) throw new Error("dsh.bundle.patch must be a file path or a list of file paths");
|
|
507
|
+
return declared;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Resolve a bundle declaration to its ordered absolute patch files.
|
|
511
|
+
* @param packageDir - absolute directory of the bundle package.
|
|
512
|
+
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
|
|
513
|
+
* @returns the absolute patch file paths in application order.
|
|
514
|
+
* @throws {Error} when `patch` is neither a string nor a list of strings.
|
|
515
|
+
*/
|
|
516
|
+
function bundlePatchPaths(packageDir, bundle) {
|
|
517
|
+
return bundlePatchFiles(bundle).map((file) => join(packageDir, file));
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Print each skipped bundle once; loading never prints, so launchers call this once per start.
|
|
521
|
+
* @param binName - the diagnostic prefix.
|
|
522
|
+
* @param profile - the loaded profile.
|
|
523
|
+
*/
|
|
524
|
+
function reportSkippedBundles(binName, profile) {
|
|
525
|
+
for (const { packageName, reason } of profile.skippedBundles) process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Resolve a profile's directory under the Harness home.
|
|
529
|
+
* @param name - the profile name (`dsh --profile <name>`).
|
|
530
|
+
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
|
531
|
+
* @returns the absolute profile directory (which may not exist yet).
|
|
532
|
+
*/
|
|
533
|
+
function resolveProfileDir(name, home = resolveDshHome()) {
|
|
534
|
+
if (name === "" || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name === "node_modules") throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`);
|
|
535
|
+
return join(home, PROFILES_DIR, name);
|
|
536
|
+
}
|
|
537
|
+
/** The shipped profile templates auto-initialized on first use, by name. */
|
|
538
|
+
const PROFILE_TEMPLATES = {
|
|
539
|
+
acp: { bundles: ["@qlzt/dsh-base", "@qlzt/dsh-acp-app"] },
|
|
540
|
+
web: { bundles: ["@qlzt/dsh-base", "@qlzt/dsh-web-app"] },
|
|
541
|
+
headless: { bundles: ["@qlzt/dsh-base", "@qlzt/dsh-headless"] },
|
|
542
|
+
sdk: { bundles: ["@qlzt/dsh-base", "@qlzt/dsh-sdk-app"] },
|
|
543
|
+
"sdk-minimal": { bundles: ["@qlzt/dsh-sdk-minimal"] }
|
|
544
|
+
};
|
|
545
|
+
/** Installation-owned bundle tuples normalized to the shipped template. */
|
|
546
|
+
const INSTALLATION_OWNED_PROFILE_TUPLES = { headless: [
|
|
547
|
+
"@qlzt/dsh-base",
|
|
548
|
+
"@qlzt/dsh-web-app",
|
|
549
|
+
"@qlzt/dsh-headless"
|
|
550
|
+
] };
|
|
551
|
+
/**
|
|
552
|
+
* Bundles an earlier release shipped and the installation no longer carries;
|
|
553
|
+
* {@link loadProfileDirectory} removes them from the profile's bundle list.
|
|
554
|
+
*/
|
|
555
|
+
const RETIRED_BUNDLES = new Set(["@deepseek-ai/dsh-experimental-schedule-bundle"]);
|
|
556
|
+
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
|
|
557
|
+
const DEFAULT_PROFILE_BUNDLES = ["@qlzt/dsh-base"];
|
|
558
|
+
/**
|
|
559
|
+
* The bundles the dsh installation ships for a person to switch on: each a
|
|
560
|
+
* runtime dependency of the installation that declares `dsh.bundle.patch`,
|
|
561
|
+
* an `icon`, and `./locale/*.json` display metadata, selected by no shipped
|
|
562
|
+
* template, and offered switched off by the plugin manager
|
|
563
|
+
* ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md),
|
|
564
|
+
* [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)).
|
|
565
|
+
*/
|
|
566
|
+
const OPTIONAL_BUNDLES = [
|
|
567
|
+
"@qlzt/dsh-experimental-session-search",
|
|
568
|
+
"@qlzt/dsh-experimental-ralph-bundle",
|
|
569
|
+
"@qlzt/dsh-experimental-terminal-bundle",
|
|
570
|
+
"@qlzt/dsh-experimental-badge-skill-bundle",
|
|
571
|
+
"@qlzt/dsh-experimental-session-titles-bundle",
|
|
572
|
+
"@qlzt/dsh-experimental-agent-team-profile",
|
|
573
|
+
"@qlzt/dsh-experimental-voice-input-bundle",
|
|
574
|
+
"@qlzt/dsh-experimental-cot-translation-bundle",
|
|
575
|
+
"@qlzt/dsh-experimental-auto-review",
|
|
576
|
+
"@qlzt/dsh-experimental-inspector-profile",
|
|
577
|
+
"@qlzt/dsh-experimental-tool-worktree"
|
|
578
|
+
];
|
|
579
|
+
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
|
580
|
+
# a top-level YAML array of loader patch entries (id-targeted config
|
|
581
|
+
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
|
|
582
|
+
[]
|
|
583
|
+
`;
|
|
584
|
+
const PROFILE_PNPM_WORKSPACE = `packages:
|
|
585
|
+
- .
|
|
586
|
+
|
|
587
|
+
nodeLinker: hoisted
|
|
588
|
+
autoInstallPeers: false
|
|
589
|
+
`;
|
|
590
|
+
/**
|
|
591
|
+
* Initialize a profile directory: manifest, empty user patch layer, and the
|
|
592
|
+
* pnpm settings out-of-tree plugins need. Existing files are never touched,
|
|
593
|
+
* so re-running is a no-op on an initialized profile.
|
|
594
|
+
* @param dir - the profile directory from {@link resolveProfileDir}.
|
|
595
|
+
* @param bundles - the initial `dsh.profile.bundles` layer list.
|
|
596
|
+
*/
|
|
597
|
+
function initProfile(dir, bundles) {
|
|
598
|
+
mkdirSync(dir, { recursive: true });
|
|
599
|
+
const manifestPath = join(dir, "package.json");
|
|
600
|
+
if (!existsSync(manifestPath)) {
|
|
601
|
+
const manifest = {
|
|
602
|
+
name: `dsh-profile-${basename(dir)}`,
|
|
603
|
+
private: true,
|
|
604
|
+
dependencies: {},
|
|
605
|
+
dsh: { profile: { bundles: [...bundles] } }
|
|
606
|
+
};
|
|
607
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, void 0, 2) + "\n");
|
|
608
|
+
}
|
|
609
|
+
const patchPath = join(dir, PROFILE_PATCH_FILENAME);
|
|
610
|
+
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE);
|
|
611
|
+
const workspacePath = join(dir, "pnpm-workspace.yaml");
|
|
612
|
+
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE);
|
|
613
|
+
}
|
|
614
|
+
/** Directory where the link backend of the dsh 0.1.5 releases projected bundle-carried packages into a profile. */
|
|
615
|
+
const LINK_PROJECTION_DIR = ".dsh-module-fallback";
|
|
616
|
+
/**
|
|
617
|
+
* Remove the package projections a link-backend launch left in a profile.
|
|
618
|
+
* Only symlinks under the profile's `node_modules` whose target lies inside
|
|
619
|
+
* `<profile>/.dsh-module-fallback/node_modules` are unlinked, then that directory is removed;
|
|
620
|
+
* pnpm-installed packages and every other symlink stay. A profile without the directory is untouched.
|
|
621
|
+
* @param dir - the profile directory.
|
|
622
|
+
*/
|
|
623
|
+
function removeLinkProjections(dir) {
|
|
624
|
+
const owned = join(dir, LINK_PROJECTION_DIR);
|
|
625
|
+
if (!existsSync(owned)) return;
|
|
626
|
+
const ownedModules = join(owned, "node_modules");
|
|
627
|
+
for (const link of symlinksUnder(join(dir, "node_modules"))) if (pointsInto(link, ownedModules)) unlinkSync(link);
|
|
628
|
+
rmSync(owned, {
|
|
629
|
+
recursive: true,
|
|
630
|
+
force: true
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** Top-level and scoped entries under a node_modules directory that are symlinks or junctions. */
|
|
634
|
+
function symlinksUnder(modules) {
|
|
635
|
+
const links = [];
|
|
636
|
+
if (!existsSync(modules)) return links;
|
|
637
|
+
for (const entry of readdirSync(modules, { withFileTypes: true })) {
|
|
638
|
+
const path = join(modules, entry.name);
|
|
639
|
+
if (entry.isSymbolicLink()) links.push(path);
|
|
640
|
+
else if (entry.name.startsWith("@") && entry.isDirectory()) {
|
|
641
|
+
for (const child of readdirSync(path, { withFileTypes: true })) if (child.isSymbolicLink()) links.push(join(path, child.name));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return links;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Active profile `node_modules` entries linked outside the shared profiles tree and the active profile.
|
|
648
|
+
* Missing targets and files are not linked roots; invalid link chains retain Node's diagnostic.
|
|
649
|
+
*/
|
|
650
|
+
function linkedProfileRoots(profile, profilesDir) {
|
|
651
|
+
const modules = join(profile.dir, "node_modules");
|
|
652
|
+
const links = symlinksUnder(modules);
|
|
653
|
+
if (links.length === 0) return [];
|
|
654
|
+
let tree;
|
|
655
|
+
try {
|
|
656
|
+
tree = realModuleDirectory(profilesDir) + sep;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error.code !== "ENOENT") throw error;
|
|
659
|
+
tree = resolve(profilesDir) + sep;
|
|
660
|
+
}
|
|
661
|
+
const excludedTrees = [tree, realModuleDirectory(profile.dir) + sep];
|
|
662
|
+
const roots = [];
|
|
663
|
+
for (const linkPath of links) {
|
|
664
|
+
let realPath;
|
|
665
|
+
try {
|
|
666
|
+
realPath = realModuleDirectory(linkPath);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (error.code !== "ENOENT") throw error;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (excludedTrees.some((prefix) => realPath + sep === prefix || realPath.startsWith(prefix)) || !statSync(realPath).isDirectory()) continue;
|
|
672
|
+
roots.push({
|
|
673
|
+
name: relative(modules, linkPath).split(sep).join("/"),
|
|
674
|
+
realPath
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return roots.sort((left, right) => left.name.localeCompare(right.name));
|
|
678
|
+
}
|
|
679
|
+
/** Whether a symlink's target directory is `root` or lies below it. */
|
|
680
|
+
function pointsInto(link, root) {
|
|
681
|
+
try {
|
|
682
|
+
const target = resolve(dirname(link), readlinkSync(link));
|
|
683
|
+
const parent = realpathSync.native(dirname(target));
|
|
684
|
+
const rootPath = realpathSync.native(root);
|
|
685
|
+
return parent === rootPath || parent.startsWith(rootPath + sep);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
/* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */
|
|
688
|
+
if (error.code === "ENOENT") return false;
|
|
689
|
+
/* v8 ignore next -- see the host-filesystem exception above */
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Read one package manifest while traversing a dependency graph. */
|
|
694
|
+
function readPackageManifest(anchor) {
|
|
695
|
+
return JSON.parse(readFileSync(anchor, "utf8"));
|
|
696
|
+
}
|
|
697
|
+
/** Return dependency names that may be imported by a loader-visible plugin. */
|
|
698
|
+
function profileDependencyNames(manifest) {
|
|
699
|
+
return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})];
|
|
700
|
+
}
|
|
701
|
+
/** Resolve the installation packages that the runtime resolver supplies to every profile. */
|
|
702
|
+
function collectInstallationScopePackages(installAnchor, skippedBundles) {
|
|
703
|
+
const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor));
|
|
704
|
+
const appManifest = readPackageManifest(canonicalAnchor);
|
|
705
|
+
const links = /* @__PURE__ */ new Map();
|
|
706
|
+
const declarers = /* @__PURE__ */ new Map();
|
|
707
|
+
const versions = /* @__PURE__ */ new Map();
|
|
708
|
+
/* v8 ignore next -- a real app manifest always declares its name */
|
|
709
|
+
if (appManifest.name !== void 0) {
|
|
710
|
+
links.set(appManifest.name, dirname(installAnchor));
|
|
711
|
+
declarers.set(appManifest.name, canonicalAnchor);
|
|
712
|
+
versions.set(appManifest.name, appManifest.version);
|
|
713
|
+
}
|
|
714
|
+
const queue = [{
|
|
715
|
+
anchor: canonicalAnchor,
|
|
716
|
+
manifest: appManifest
|
|
717
|
+
}];
|
|
718
|
+
for (let next = queue.shift(); next !== void 0; next = queue.shift())
|
|
719
|
+
/* v8 ignore next -- a real app manifest always declares dependencies */
|
|
720
|
+
for (const dep of profileDependencyNames(next.manifest)) {
|
|
721
|
+
if (links.has(dep)) continue;
|
|
722
|
+
const dir = packageDirFromAnchor(next.anchor, dep);
|
|
723
|
+
if (dir === void 0) continue;
|
|
724
|
+
const manifestPath = join(realModuleDirectory(dir), "package.json");
|
|
725
|
+
let manifest;
|
|
726
|
+
try {
|
|
727
|
+
manifest = skippedBundles.has(dep) ? readProfileManifest("dsh", dir) : readPackageManifest(manifestPath);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (!skippedBundles.has(dep)) throw error;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
links.set(dep, dir);
|
|
733
|
+
declarers.set(dep, next.anchor);
|
|
734
|
+
versions.set(dep, manifest.version);
|
|
735
|
+
queue.push({
|
|
736
|
+
anchor: manifestPath,
|
|
737
|
+
manifest
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
packageNames: new Set(links.keys()),
|
|
742
|
+
packageDirs: links,
|
|
743
|
+
declarers,
|
|
744
|
+
versions
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Compute the runtime resolution without writing module-resolution files.
|
|
749
|
+
* @param options - installation anchor, optional loaded profile, and Harness home.
|
|
750
|
+
* @returns the complete immutable runtime resolution.
|
|
751
|
+
*/
|
|
752
|
+
async function createRuntimeResolution(options) {
|
|
753
|
+
const { installAnchor, profile, home = resolveDshHome() } = options;
|
|
754
|
+
const profilesDir = join(home, PROFILES_DIR);
|
|
755
|
+
const manifest = readOptionalProfileManifest(profile);
|
|
756
|
+
const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(installAnchor, new Set(profile?.skippedBundles.map((skipped) => skipped.packageName)));
|
|
757
|
+
const profileDeclarers = /* @__PURE__ */ new Map();
|
|
758
|
+
const profileVersions = /* @__PURE__ */ new Map();
|
|
759
|
+
const localPackageNames = profile === void 0 ? [] : installedProfilePackageNames(profile, manifest);
|
|
760
|
+
const profilePackages = profile === void 0 ? /* @__PURE__ */ new Map() : collectProfileScopePackages(profile, packageNames, profileDeclarers, profileVersions);
|
|
761
|
+
const linkedRoots = profile === void 0 ? [] : linkedProfileRoots(profile, profilesDir);
|
|
762
|
+
return await Promise.resolve(new ProfileRuntimeResolution({
|
|
763
|
+
installAnchor,
|
|
764
|
+
profileDir: profile?.dir,
|
|
765
|
+
home
|
|
766
|
+
}, {
|
|
767
|
+
profilesDir,
|
|
768
|
+
profileDir: profile?.dir,
|
|
769
|
+
localPackageNames: Object.freeze(localPackageNames),
|
|
770
|
+
linkedRoots: Object.freeze(linkedRoots.map((root) => Object.freeze(root))),
|
|
771
|
+
entries: Object.freeze([...[...packageDirs].map(([name, packageDir]) => Object.freeze({
|
|
772
|
+
name,
|
|
773
|
+
packageDir,
|
|
774
|
+
version: versions.get(name),
|
|
775
|
+
declarer: declarers.get(name),
|
|
776
|
+
scope: "installation"
|
|
777
|
+
})), ...[...profilePackages].map(([name, packageDir]) => Object.freeze({
|
|
778
|
+
name,
|
|
779
|
+
packageDir,
|
|
780
|
+
version: profileVersions.get(name),
|
|
781
|
+
declarer: profileDeclarers.get(name),
|
|
782
|
+
scope: "profile"
|
|
783
|
+
}))])
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* A runtime resolution that remembers the inputs it was computed from. Worker environment data carries only its
|
|
788
|
+
* fields; the inputs stay private to the thread that computed it.
|
|
789
|
+
*/
|
|
790
|
+
var ProfileRuntimeResolution = class {
|
|
791
|
+
profilesDir;
|
|
792
|
+
profileDir;
|
|
793
|
+
localPackageNames;
|
|
794
|
+
entries;
|
|
795
|
+
linkedRoots;
|
|
796
|
+
#source;
|
|
797
|
+
/**
|
|
798
|
+
* @param source - inputs of {@link createRuntimeResolution}, reused by {@link computeLatestResolution}.
|
|
799
|
+
* @param table - the computed package table.
|
|
800
|
+
*/
|
|
801
|
+
constructor(source, table) {
|
|
802
|
+
this.#source = source;
|
|
803
|
+
this.profilesDir = table.profilesDir;
|
|
804
|
+
this.profileDir = table.profileDir;
|
|
805
|
+
this.localPackageNames = table.localPackageNames;
|
|
806
|
+
this.entries = table.entries;
|
|
807
|
+
this.linkedRoots = table.linkedRoots;
|
|
808
|
+
Object.freeze(this);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Compute the latest generation from the same installation, profile directory, and Harness home, rereading the
|
|
812
|
+
* profile's manifest, bundle selection, and installed packages from disk, without retaining synthetic layers.
|
|
813
|
+
* With no profile directory, only installation packages are recomputed. This instance is unchanged.
|
|
814
|
+
* @returns a new resolution for the latest generation.
|
|
815
|
+
*/
|
|
816
|
+
computeLatestResolution() {
|
|
817
|
+
const { installAnchor, home, profileDir } = this.#source;
|
|
818
|
+
return createRuntimeResolution({
|
|
819
|
+
installAnchor,
|
|
820
|
+
home,
|
|
821
|
+
...profileDir === void 0 ? {} : { profile: loadProfileDirectory("dsh", profileDir, installAnchor) }
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
|
|
826
|
+
function readOptionalProfileManifest(profile) {
|
|
827
|
+
if (profile === void 0) return void 0;
|
|
828
|
+
try {
|
|
829
|
+
return readPackageManifest(join(profile.dir, "package.json"));
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error.code === "ENOENT") return void 0;
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** Return installed direct dependencies that Node resolves before profile fallback. */
|
|
836
|
+
function installedProfilePackageNames(profile, manifest) {
|
|
837
|
+
if (manifest === void 0) return [];
|
|
838
|
+
return profileDependencyNames(manifest).filter((name) => existsSync(join(profile.dir, "node_modules", name, "package.json")));
|
|
839
|
+
}
|
|
840
|
+
/** Collect the first resolvable package directory for each dependency name. */
|
|
841
|
+
function dependencyClosure(anchors, reserved, declarers, versions) {
|
|
842
|
+
const links = /* @__PURE__ */ new Map();
|
|
843
|
+
const visited = new Set(reserved);
|
|
844
|
+
for (const anchor of anchors) {
|
|
845
|
+
const canonicalAnchor = join(realModuleDirectory(dirname(anchor)), basename(anchor));
|
|
846
|
+
const manifest = readPackageManifest(canonicalAnchor);
|
|
847
|
+
/* v8 ignore next -- an installable package manifest always declares its name */
|
|
848
|
+
if (manifest.name === void 0) continue;
|
|
849
|
+
if (!visited.has(manifest.name)) {
|
|
850
|
+
visited.add(manifest.name);
|
|
851
|
+
links.set(manifest.name, dirname(canonicalAnchor));
|
|
852
|
+
declarers?.set(manifest.name, canonicalAnchor);
|
|
853
|
+
versions?.set(manifest.name, manifest.version);
|
|
854
|
+
}
|
|
855
|
+
const queue = [{
|
|
856
|
+
anchor: canonicalAnchor,
|
|
857
|
+
manifest
|
|
858
|
+
}];
|
|
859
|
+
for (let next = queue.shift(); next !== void 0; next = queue.shift())
|
|
860
|
+
/* v8 ignore next -- an installable package manifest always declares dependencies or peers */
|
|
861
|
+
for (const dep of profileDependencyNames(next.manifest)) {
|
|
862
|
+
if (visited.has(dep)) continue;
|
|
863
|
+
const dir = packageDirFromAnchor(next.anchor, dep);
|
|
864
|
+
if (dir === void 0) continue;
|
|
865
|
+
visited.add(dep);
|
|
866
|
+
links.set(dep, dir);
|
|
867
|
+
declarers?.set(dep, next.anchor);
|
|
868
|
+
const manifestPath = join(realModuleDirectory(dir), "package.json");
|
|
869
|
+
const dependencyManifest = readPackageManifest(manifestPath);
|
|
870
|
+
versions?.set(dep, dependencyManifest.version);
|
|
871
|
+
queue.push({
|
|
872
|
+
anchor: manifestPath,
|
|
873
|
+
manifest: dependencyManifest
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return links;
|
|
878
|
+
}
|
|
879
|
+
/** Collect packages carried by the profile's selected bundles that the installation does not supply. */
|
|
880
|
+
function collectProfileScopePackages(profile, installationPackageNames, declarers, versions) {
|
|
881
|
+
const bundleLinks = dependencyClosure(profile.layers.filter((layer) => !installationPackageNames.has(layer.packageName)).map((layer) => join(layer.packageDir, "package.json")), installationPackageNames, declarers, versions);
|
|
882
|
+
for (const layer of profile.layers) bundleLinks.delete(layer.packageName);
|
|
883
|
+
return bundleLinks;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Read a profile's manifest.
|
|
887
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
888
|
+
* @param dir - the profile directory.
|
|
889
|
+
* @returns the parsed manifest.
|
|
890
|
+
*/
|
|
891
|
+
function readProfileManifest(binName, dir) {
|
|
892
|
+
const path = join(dir, "package.json");
|
|
893
|
+
let raw;
|
|
894
|
+
try {
|
|
895
|
+
raw = readFileSync(path, "utf8");
|
|
896
|
+
} catch (error) {
|
|
897
|
+
throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`);
|
|
898
|
+
}
|
|
899
|
+
const parsed = JSON.parse(raw);
|
|
900
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`);
|
|
901
|
+
return parsed;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Write a profile's manifest back (2-space JSON, trailing newline).
|
|
905
|
+
* @param dir - the profile directory.
|
|
906
|
+
* @param manifest - the manifest value to persist.
|
|
907
|
+
*/
|
|
908
|
+
function writeProfileManifest(dir, manifest) {
|
|
909
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify(manifest, void 0, 2) + "\n");
|
|
910
|
+
}
|
|
911
|
+
/** Return whether two bundle lists have the same values in the same order. */
|
|
912
|
+
function sameBundles(left, right) {
|
|
913
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
914
|
+
}
|
|
915
|
+
/** Return `manifest` with `dsh.profile.bundles` replaced, preserving all other fields. */
|
|
916
|
+
function withBundles(manifest, bundles) {
|
|
917
|
+
return {
|
|
918
|
+
...manifest,
|
|
919
|
+
dsh: {
|
|
920
|
+
...manifest.dsh,
|
|
921
|
+
profile: {
|
|
922
|
+
...manifest.dsh?.profile,
|
|
923
|
+
bundles: [...bundles]
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Normalize an exact installation-owned bundle tuple to its shipped template,
|
|
930
|
+
* preserving all other manifest fields. Other bundle lists remain untouched.
|
|
931
|
+
*/
|
|
932
|
+
function normalizeShippedProfile(name, dir, manifest) {
|
|
933
|
+
const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name];
|
|
934
|
+
const template = PROFILE_TEMPLATES[name];
|
|
935
|
+
const bundles = manifest.dsh?.profile?.bundles;
|
|
936
|
+
if (template === void 0 || bundles === void 0) return manifest;
|
|
937
|
+
if (!(installationOwned !== void 0 && sameBundles(bundles, installationOwned))) return manifest;
|
|
938
|
+
const normalized = withBundles(manifest, template.bundles);
|
|
939
|
+
writeProfileManifest(dir, normalized);
|
|
940
|
+
return normalized;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Remove {@link RETIRED_BUNDLES} from a profile's bundle list, writing the
|
|
944
|
+
* manifest back only when it listed one.
|
|
945
|
+
*/
|
|
946
|
+
function dropRetiredBundles(dir, manifest) {
|
|
947
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
948
|
+
const kept = bundles.filter((bundle) => !RETIRED_BUNDLES.has(bundle));
|
|
949
|
+
if (kept.length === bundles.length) return manifest;
|
|
950
|
+
const normalized = withBundles(manifest, kept);
|
|
951
|
+
writeProfileManifest(dir, normalized);
|
|
952
|
+
return normalized;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Resolve a package's root directory from one anchor without depending on the
|
|
956
|
+
* package exporting `./package.json` (`require.resolve` would need that):
|
|
957
|
+
* probe the require resolution paths for a directory holding the named
|
|
958
|
+
* manifest. This is Node's own node_modules lookup order, so the result
|
|
959
|
+
* matches what the Loader would import from the same anchor, and
|
|
960
|
+
* `existsSync` follows the symlinks pnpm's isolated layout uses.
|
|
961
|
+
*/
|
|
962
|
+
function packageDirFromAnchor(anchor, packageName) {
|
|
963
|
+
/* v8 ignore next */
|
|
964
|
+
for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
|
|
965
|
+
const candidate = join(searchPath, packageName);
|
|
966
|
+
if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Resolve one bundle package's directory: installation anchor first, then the
|
|
971
|
+
* profile directory. The installation-first order is the contract that
|
|
972
|
+
* `@qlzt/dsh-base` (and every other in-box bundle) always comes from
|
|
973
|
+
* the same installation as the running dsh, never from a profile-local copy.
|
|
974
|
+
* Resolution does not require the package to export `./package.json`.
|
|
975
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
976
|
+
* @param packageName - the bundle's package name from `dsh.profile.bundles`.
|
|
977
|
+
* @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
|
|
978
|
+
* @param profileDir - the profile directory (second anchor).
|
|
979
|
+
* @returns the bundle package's absolute directory.
|
|
980
|
+
*/
|
|
981
|
+
function resolveBundleDir(binName, packageName, installAnchor, profileDir) {
|
|
982
|
+
for (const anchor of [installAnchor, join(profileDir, "package.json")]) {
|
|
983
|
+
const dir = packageDirFromAnchor(anchor, packageName);
|
|
984
|
+
if (dir !== void 0) return dir;
|
|
985
|
+
}
|
|
986
|
+
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`);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Load an already initialized profile directory without resolving it through
|
|
990
|
+
* the shared Harness home. This is used by application-owned profiles whose
|
|
991
|
+
* package project and lifecycle belong to that application.
|
|
992
|
+
* Retired bundles are removed from the stored bundle list first, rewriting the
|
|
993
|
+
* manifest when it listed one. Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped
|
|
994
|
+
* without changing the manifest and listed in `skippedBundles`; nothing is printed.
|
|
995
|
+
* @param binName - the diagnostic prefix on thrown errors.
|
|
996
|
+
* @param dir - absolute profile package directory.
|
|
997
|
+
* @param installAnchor - absolute path of the owning dsh app's package.json.
|
|
998
|
+
* @param options - `userLayer: false` skips reading `cordis.patch.yml`.
|
|
999
|
+
* @returns the successfully loaded bundle layers and optional user patch layer.
|
|
1000
|
+
*/
|
|
1001
|
+
function loadProfileDirectory(binName, dir, installAnchor, options = {}) {
|
|
1002
|
+
const bundles = dropRetiredBundles(dir, readProfileManifest(binName, dir)).dsh?.profile?.bundles ?? [];
|
|
1003
|
+
const layers = [];
|
|
1004
|
+
const skippedBundles = [];
|
|
1005
|
+
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir);
|
|
1006
|
+
for (const packageName of bundles) try {
|
|
1007
|
+
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir);
|
|
1008
|
+
const bundleManifest = readProfileManifest(binName, packageDir);
|
|
1009
|
+
const bundle = bundleManifest.dsh?.bundle;
|
|
1010
|
+
if (bundle === void 0) throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`);
|
|
1011
|
+
const issue = evaluatePluginCompatibility(bundleManifest, exemptions);
|
|
1012
|
+
if (issue !== void 0 && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue));
|
|
1013
|
+
const patchPaths = bundlePatchPaths(packageDir, bundle);
|
|
1014
|
+
const patches = patchPaths.flatMap((patchPath) => loadOverlayPatches(binName, patchPath));
|
|
1015
|
+
layers.push({
|
|
1016
|
+
packageName,
|
|
1017
|
+
packageDir,
|
|
1018
|
+
patchPaths,
|
|
1019
|
+
patches
|
|
1020
|
+
});
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
skippedBundles.push({
|
|
1023
|
+
packageName,
|
|
1024
|
+
reason: String(error)
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
const patchPath = join(dir, PROFILE_PATCH_FILENAME);
|
|
1028
|
+
const patches = options.userLayer !== false && existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : [];
|
|
1029
|
+
return {
|
|
1030
|
+
name: basename(dir),
|
|
1031
|
+
dir,
|
|
1032
|
+
layers,
|
|
1033
|
+
patchPath,
|
|
1034
|
+
patches,
|
|
1035
|
+
skippedBundles
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
|
1040
|
+
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
|
|
1041
|
+
* are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
|
|
1042
|
+
* @param binName - the diagnostic prefix on thrown errors.
|
|
1043
|
+
* @param name - the profile name.
|
|
1044
|
+
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
|
1045
|
+
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
|
1046
|
+
* @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
|
|
1047
|
+
* bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
|
|
1048
|
+
* cannot fail on a broken user layer.
|
|
1049
|
+
* @returns the loaded profile (empty `patches` when the user layer is skipped).
|
|
1050
|
+
*/
|
|
1051
|
+
function loadProfile(binName, name, installAnchor, home = resolveDshHome(), options = {}) {
|
|
1052
|
+
const dir = resolveProfileDir(name, home);
|
|
1053
|
+
if (!existsSync(join(dir, "package.json"))) {
|
|
1054
|
+
const template = PROFILE_TEMPLATES[name];
|
|
1055
|
+
if (template === void 0) throw new Error(`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`);
|
|
1056
|
+
initProfile(dir, template.bundles);
|
|
1057
|
+
}
|
|
1058
|
+
removeLinkProjections(dir);
|
|
1059
|
+
normalizeShippedProfile(name, dir, readProfileManifest(binName, dir));
|
|
1060
|
+
return loadProfileDirectory(binName, dir, installAnchor, options);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Compose patch layers into the effective entry list over an empty root —
|
|
1064
|
+
* the same single `applyEntryPatches` call the boot include makes, so flag
|
|
1065
|
+
* derivation and config dumps see exactly what mounts.
|
|
1066
|
+
* @param layers - patch lists in application order.
|
|
1067
|
+
* @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
|
|
1068
|
+
* @returns the composed entry list.
|
|
1069
|
+
*/
|
|
1070
|
+
function composeEntries(layers, warn = () => {}) {
|
|
1071
|
+
return applyEntryPatches([], structuredClone(layers.flat()), (message, ...args) => {
|
|
1072
|
+
let index = 0;
|
|
1073
|
+
warn(message.replace(/%C/g, () => JSON.stringify(args[index++])));
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region lib/types/profile-context.js
|
|
1078
|
+
/** Launcher-owned profile locations and composition inputs. */
|
|
1079
|
+
const TELEMETRY_ROW_ID = "session-telemetry-otel";
|
|
1080
|
+
/**
|
|
1081
|
+
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
|
|
1082
|
+
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
|
|
1083
|
+
* off-by-mistake over on-by-mistake. A composition without the telemetry row
|
|
1084
|
+
* exports nothing, so the switch is then trivially satisfied and no patch is
|
|
1085
|
+
* generated — custom profiles need not mount telemetry to run with the
|
|
1086
|
+
* switch set.
|
|
1087
|
+
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
|
|
1088
|
+
* @param hasRow - whether the composition carries the telemetry row.
|
|
1089
|
+
* @returns the disable patch, or `undefined` when no hard-disable patch is required.
|
|
1090
|
+
*/
|
|
1091
|
+
function resolveTelemetryPatch(disabledEnv, hasRow) {
|
|
1092
|
+
if ((disabledEnv ?? "") === "" || !hasRow) return void 0;
|
|
1093
|
+
return {
|
|
1094
|
+
id: TELEMETRY_ROW_ID,
|
|
1095
|
+
disabled: true
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
/** Read current bundle and user layers with the launch-time overlays.
|
|
1099
|
+
* @param binName Diagnostic prefix for malformed or missing configuration.
|
|
1100
|
+
* @param context Data supplied by the profile launcher.
|
|
1101
|
+
* @param initialProfile Already loaded startup profile; omitted reads the current files.
|
|
1102
|
+
* @returns Detached ordered patches; this function does not update the Loader.
|
|
1103
|
+
*/
|
|
1104
|
+
function readProfilePatches(binName, context, initialProfile) {
|
|
1105
|
+
const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false });
|
|
1106
|
+
const patches = structuredClone([
|
|
1107
|
+
...profile.layers.flatMap((layer) => layer.patches),
|
|
1108
|
+
...initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? [],
|
|
1109
|
+
...loadOptionalPatches(binName, join(context.home, "cordis.patch.yml")) ?? [],
|
|
1110
|
+
...context.overlays
|
|
1111
|
+
]);
|
|
1112
|
+
const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv, composeEntries([patches]).some((row) => row.id === TELEMETRY_ROW_ID));
|
|
1113
|
+
if (telemetryPatch !== void 0) patches.push(telemetryPatch);
|
|
1114
|
+
return patches;
|
|
1115
|
+
}
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region lib/types/profile-plugins.js
|
|
1118
|
+
/** Installed profile dependencies and their bundle activation after package-manager operations. */
|
|
1119
|
+
/** Unavailable installed metadata must not prevent listing or removing dependencies. */
|
|
1120
|
+
function optionalManifest(binName, packageDir) {
|
|
1121
|
+
try {
|
|
1122
|
+
return readProfileManifest(binName, packageDir);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** Resolve activation metadata with the same installation precedence as profile loading. */
|
|
1128
|
+
function bundleManifest(location, name) {
|
|
1129
|
+
let packageDir;
|
|
1130
|
+
try {
|
|
1131
|
+
packageDir = resolveBundleDir(location.binName, name, location.installAnchor, location.profileDir);
|
|
1132
|
+
} catch {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
return optionalManifest(location.binName, packageDir);
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Read installed versions and bundle declarations without requiring loadable plugin code.
|
|
1139
|
+
* Missing or unreadable installed metadata leaves the dependency visible for repair or removal.
|
|
1140
|
+
* @param location - profile and installation resolution inputs.
|
|
1141
|
+
* @returns dependency records in package.json order and the profile manifest.
|
|
1142
|
+
*/
|
|
1143
|
+
function readProfilePlugins(location) {
|
|
1144
|
+
const manifest = readProfileManifest(location.binName, location.profileDir);
|
|
1145
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
1146
|
+
return {
|
|
1147
|
+
manifest,
|
|
1148
|
+
dependencies: Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => {
|
|
1149
|
+
const installed = optionalManifest(location.binName, join(location.profileDir, "node_modules", name));
|
|
1150
|
+
return {
|
|
1151
|
+
name,
|
|
1152
|
+
version: typeof installed?.version === "string" ? installed.version : spec,
|
|
1153
|
+
bundle: bundleManifest(location, name)?.dsh?.bundle?.patch !== void 0,
|
|
1154
|
+
enabled: bundles.includes(name)
|
|
1155
|
+
};
|
|
1156
|
+
})
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Write a bundle list while preserving the supplied profile's other metadata.
|
|
1161
|
+
* @param profileDir - directory whose package.json is updated.
|
|
1162
|
+
* @param manifest - current profile manifest, read after the package operation when applicable.
|
|
1163
|
+
* @param bundles - ordered active bundle names, including any template entries.
|
|
1164
|
+
* @returns the written manifest.
|
|
1165
|
+
*/
|
|
1166
|
+
function writeProfileBundles(profileDir, manifest, bundles) {
|
|
1167
|
+
const updated = {
|
|
1168
|
+
...manifest,
|
|
1169
|
+
dsh: {
|
|
1170
|
+
...manifest.dsh,
|
|
1171
|
+
profile: {
|
|
1172
|
+
...manifest.dsh?.profile,
|
|
1173
|
+
bundles: [...bundles]
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
writeProfileManifest(profileDir, updated);
|
|
1178
|
+
return updated;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Reconcile installed bundle declarations after a successful package-manager operation.
|
|
1182
|
+
* Dependency-managed entries disappear when removed or when their package loses its declaration;
|
|
1183
|
+
* template entries retain their order and duplicates. New bundle dependencies activate automatically.
|
|
1184
|
+
* @param options - location, inventory captured before pnpm, and whether explicitly disabled bundles remain disabled.
|
|
1185
|
+
* @returns updated inventory and newly added ordinary dependencies for caller-owned warnings.
|
|
1186
|
+
*/
|
|
1187
|
+
function reconcileProfilePlugins(options) {
|
|
1188
|
+
const after = readProfilePlugins(options);
|
|
1189
|
+
const beforeNames = new Set(options.before.dependencies.map((dependency) => dependency.name));
|
|
1190
|
+
const afterNames = new Set(after.dependencies.map((dependency) => dependency.name));
|
|
1191
|
+
const bundleNames = new Set(after.dependencies.filter((dependency) => dependency.bundle).map((dependency) => dependency.name));
|
|
1192
|
+
const disabled = new Set(options.preserveDisabled ? options.before.dependencies.filter((dependency) => dependency.bundle && !dependency.enabled).map((dependency) => dependency.name) : []);
|
|
1193
|
+
const previous = after.manifest.dsh?.profile?.bundles ?? [];
|
|
1194
|
+
const bundles = previous.filter((name) => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name));
|
|
1195
|
+
for (const dependency of after.dependencies) if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name);
|
|
1196
|
+
return {
|
|
1197
|
+
plugins: {
|
|
1198
|
+
manifest: bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index]) ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest,
|
|
1199
|
+
dependencies: after.dependencies.map((dependency) => ({
|
|
1200
|
+
...dependency,
|
|
1201
|
+
enabled: bundles.includes(dependency.name)
|
|
1202
|
+
}))
|
|
1203
|
+
},
|
|
1204
|
+
addedPlainDependencies: after.dependencies.filter((dependency) => !dependency.bundle && !beforeNames.has(dependency.name)).map((dependency) => dependency.name)
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region lib/types/profile-sanitize.js
|
|
1209
|
+
/** Filesystem recovery for callers that own profile shutdown and write exclusion. */
|
|
1210
|
+
/**
|
|
1211
|
+
* Back up the profile patch and retain only the caller's recovery bundles.
|
|
1212
|
+
* The caller must stop the profile and exclude concurrent profile writes.
|
|
1213
|
+
* Installed packages and other manifest fields are preserved; patches are never parsed.
|
|
1214
|
+
* Failures propagate and may leave completed changes in place for a retry.
|
|
1215
|
+
* @param binName - Diagnostic prefix for invalid profile manifests.
|
|
1216
|
+
* @param profileDir - Profile directory to recover without loading its plugins.
|
|
1217
|
+
* @param bundles - Ordered bundles to enable after recovery.
|
|
1218
|
+
* @returns Backup path with a Unix millisecond timestamp and optional collision ordinal, or undefined if absent.
|
|
1219
|
+
*/
|
|
1220
|
+
function sanitizeProfile(binName, profileDir, bundles) {
|
|
1221
|
+
const manifest = existsSync(join(profileDir, "package.json")) ? readProfileManifest(binName, profileDir) : void 0;
|
|
1222
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
1223
|
+
const backupBase = `${patchPath}.bak-${Date.now()}`;
|
|
1224
|
+
let backupPath = backupBase;
|
|
1225
|
+
let ordinal = 0;
|
|
1226
|
+
while (existsSync(backupPath)) backupPath = `${backupBase}-${++ordinal}`;
|
|
1227
|
+
try {
|
|
1228
|
+
renameSync(patchPath, backupPath);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
1231
|
+
backupPath = void 0;
|
|
1232
|
+
}
|
|
1233
|
+
if (manifest !== void 0) writeProfileBundles(profileDir, manifest, bundles);
|
|
1234
|
+
return backupPath;
|
|
1235
|
+
}
|
|
1236
|
+
//#endregion
|
|
1237
|
+
//#region lib/types/profile-resolution/resolver.js
|
|
1238
|
+
/** In-memory profile package routing for Node's default ESM and CommonJS loaders. */
|
|
1239
|
+
const WORKER_RESOLUTION_KEY = "@qlzt/dsh-app-boot/profile-resolution";
|
|
1240
|
+
const EMPTY_ATTRIBUTES = Object.freeze({});
|
|
1241
|
+
/**
|
|
1242
|
+
* Split a bare request into its package name without allocating path segments.
|
|
1243
|
+
* @param request - module specifier to classify.
|
|
1244
|
+
* @returns the bare package name, or undefined for non-package requests.
|
|
1245
|
+
*/
|
|
1246
|
+
function barePackageName(request) {
|
|
1247
|
+
if (!request || request[0] === "." || request[0] === "/" || request[0] === "\\" || request[0] === "#" || request.includes(":") || isBuiltin(request)) return;
|
|
1248
|
+
const first = request.indexOf("/");
|
|
1249
|
+
if (request[0] !== "@") return first < 0 ? request : request.slice(0, first);
|
|
1250
|
+
if (first < 0) return;
|
|
1251
|
+
const second = request.indexOf("/", first + 1);
|
|
1252
|
+
return second < 0 ? request : request.slice(0, second);
|
|
1253
|
+
}
|
|
1254
|
+
function canonicalPath(path) {
|
|
1255
|
+
try {
|
|
1256
|
+
return realpathSync(path);
|
|
1257
|
+
} catch {
|
|
1258
|
+
return resolve(path);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
function prefixes(path) {
|
|
1262
|
+
const configured = resolve(path) + sep;
|
|
1263
|
+
const canonical = canonicalPath(path) + sep;
|
|
1264
|
+
return canonical === configured ? [configured] : [configured, canonical];
|
|
1265
|
+
}
|
|
1266
|
+
function compileResolution(resolution) {
|
|
1267
|
+
return {
|
|
1268
|
+
entries: new Map(resolution.entries.map((entry) => [entry.name, entry])),
|
|
1269
|
+
profilesDir: resolution.profilesDir,
|
|
1270
|
+
profileDir: resolution.profileDir,
|
|
1271
|
+
profilePaths: prefixes(resolution.profilesDir),
|
|
1272
|
+
profile: resolution.profileDir === void 0 ? [] : prefixes(resolution.profileDir),
|
|
1273
|
+
installationPaths: [...new Set(resolution.entries.filter((entry) => entry.scope === "installation").flatMap((entry) => prefixes(entry.packageDir)))],
|
|
1274
|
+
linkedPaths: resolution.linkedRoots.flatMap((root) => prefixes(root.realPath)),
|
|
1275
|
+
localPackageNames: new Set(resolution.localPackageNames),
|
|
1276
|
+
esmRoutes: /* @__PURE__ */ new Map(),
|
|
1277
|
+
cjsRoutes: /* @__PURE__ */ new Map()
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* The interception layer of a profile is `<profileParent>/node_modules`: an entry occupies its name there, so its
|
|
1282
|
+
* subpath misses continue above it, and a name without an entry continues at it.
|
|
1283
|
+
*/
|
|
1284
|
+
function computeProfileLayer(dir, active) {
|
|
1285
|
+
const profileParent = dirname(dir);
|
|
1286
|
+
return {
|
|
1287
|
+
kind: "profile",
|
|
1288
|
+
active,
|
|
1289
|
+
localPrefix: dir + sep,
|
|
1290
|
+
nativeAfter: join(profileParent, "package.json"),
|
|
1291
|
+
after: join(dirname(profileParent), "package.json")
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/** The interception layer of a module path: from its profile directory inside a profiles tree, or its linked root. */
|
|
1295
|
+
function findInterceptionLayer(path, resolution) {
|
|
1296
|
+
const treeRoot = resolution.profilePaths.find((prefix) => path.startsWith(prefix));
|
|
1297
|
+
const activeProfile = resolution.profile.find((prefix) => path.startsWith(prefix));
|
|
1298
|
+
const dir = treeRoot !== void 0 ? profileChild(path, treeRoot) : activeProfile?.slice(0, -1);
|
|
1299
|
+
if (dir !== void 0) return computeProfileLayer(dir, activeProfile !== void 0);
|
|
1300
|
+
if (!startsWithin(path, resolution.linkedPaths)) return void 0;
|
|
1301
|
+
if (startsWithin(path, resolution.installationPaths)) return void 0;
|
|
1302
|
+
return { kind: "linked" };
|
|
1303
|
+
}
|
|
1304
|
+
/** Package names a directory's current manifest lists as peers; an unreadable manifest lists none. */
|
|
1305
|
+
function readPeerNames(directory) {
|
|
1306
|
+
try {
|
|
1307
|
+
const peers = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).peerDependencies;
|
|
1308
|
+
return new Set(peers !== null && typeof peers === "object" ? Object.keys(peers) : []);
|
|
1309
|
+
} catch (_error) {
|
|
1310
|
+
return /* @__PURE__ */ new Set();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
function startsWithin(path, roots) {
|
|
1314
|
+
for (const root of roots) if (path.startsWith(root)) return true;
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The profile directory owning a module inside the profiles tree: the first path segment below the tree root.
|
|
1319
|
+
* @param path - module path below `treePrefix`.
|
|
1320
|
+
* @param treePrefix - profiles directory with a trailing separator.
|
|
1321
|
+
* @returns the profile directory without a trailing separator.
|
|
1322
|
+
*/
|
|
1323
|
+
function profileChild(path, treePrefix) {
|
|
1324
|
+
const rest = path.slice(treePrefix.length);
|
|
1325
|
+
const end = rest.indexOf(sep);
|
|
1326
|
+
return treePrefix + (end < 0 ? rest : rest.slice(0, end));
|
|
1327
|
+
}
|
|
1328
|
+
function nativePackageDir(parent, name) {
|
|
1329
|
+
for (const searchPath of createRequire(parent).resolve.paths(name)) {
|
|
1330
|
+
const candidate = join(searchPath, name);
|
|
1331
|
+
if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
function localPackageCandidate(searchPath, name, flavor) {
|
|
1335
|
+
const candidate = join(searchPath, name);
|
|
1336
|
+
const stat = statSync(candidate, { throwIfNoEntry: false });
|
|
1337
|
+
return (flavor === "esm" ? stat?.isDirectory() === true : stat !== void 0 || [
|
|
1338
|
+
".js",
|
|
1339
|
+
".json",
|
|
1340
|
+
".node"
|
|
1341
|
+
].some((extension) => existsSync(candidate + extension))) ? candidate : void 0;
|
|
1342
|
+
}
|
|
1343
|
+
function selfReferenceName(parent) {
|
|
1344
|
+
let current = dirname(parent);
|
|
1345
|
+
while (true) {
|
|
1346
|
+
const manifestPath = join(current, "package.json");
|
|
1347
|
+
if (existsSync(manifestPath)) {
|
|
1348
|
+
let manifest;
|
|
1349
|
+
try {
|
|
1350
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1351
|
+
} catch (_error) {
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
return typeof manifest.name === "string" && manifest.exports != null ? manifest.name : false;
|
|
1355
|
+
}
|
|
1356
|
+
/* v8 ignore next -- scoped module requests normally find an owning manifest before node_modules. */
|
|
1357
|
+
if (basename(current) === "node_modules") return false;
|
|
1358
|
+
const next = dirname(current);
|
|
1359
|
+
if (next === current) return false;
|
|
1360
|
+
current = next;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function packageImportsTarget(parent, request, conditions) {
|
|
1364
|
+
let current = dirname(parent);
|
|
1365
|
+
while (true) {
|
|
1366
|
+
const manifestPath = join(current, "package.json");
|
|
1367
|
+
if (existsSync(manifestPath)) {
|
|
1368
|
+
let manifest;
|
|
1369
|
+
try {
|
|
1370
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1371
|
+
} catch (_error) {
|
|
1372
|
+
/* v8 ignore next -- native resolution cannot report MODULE_NOT_FOUND after an invalid scope manifest */
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
try {
|
|
1376
|
+
const target = imports(manifest, request, {
|
|
1377
|
+
conditions: [...conditions],
|
|
1378
|
+
unsafe: true
|
|
1379
|
+
})?.[0];
|
|
1380
|
+
return target !== void 0 && barePackageName(target) !== void 0 ? {
|
|
1381
|
+
specifier: target,
|
|
1382
|
+
parentURL: pathToFileURL(manifestPath).href
|
|
1383
|
+
} : void 0;
|
|
1384
|
+
} catch (_error) {
|
|
1385
|
+
/* v8 ignore next -- native resolution cannot report MODULE_NOT_FOUND before selecting a valid mapping */
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (basename(current) === "node_modules") return void 0;
|
|
1390
|
+
const next = dirname(current);
|
|
1391
|
+
/* v8 ignore next -- a MODULE_NOT_FOUND package-import target always has an owning package scope */
|
|
1392
|
+
if (next === current) return void 0;
|
|
1393
|
+
current = next;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
function packageSearchPaths(entry, request, cjs) {
|
|
1397
|
+
const name = barePackageName(request);
|
|
1398
|
+
/* v8 ignore next -- interception routes are created only for bare package requests */
|
|
1399
|
+
if (name === void 0) return cjs._nodeModulePaths(dirname(entry.declarer));
|
|
1400
|
+
const suffix = sep + name.split("/").join(sep);
|
|
1401
|
+
return entry.packageDir.endsWith(suffix) ? [entry.packageDir.slice(0, -suffix.length)] : cjs._nodeModulePaths(dirname(entry.declarer));
|
|
1402
|
+
}
|
|
1403
|
+
function localCandidateOwnsResolution(candidate, resolved, request, name) {
|
|
1404
|
+
if (startsWithin(resolved, prefixes(candidate))) return true;
|
|
1405
|
+
if (sameResolution(candidate, resolved)) return true;
|
|
1406
|
+
if ([
|
|
1407
|
+
".js",
|
|
1408
|
+
".json",
|
|
1409
|
+
".node"
|
|
1410
|
+
].some((extension) => sameResolution(candidate + extension, resolved))) return true;
|
|
1411
|
+
/* v8 ignore next -- a bounded native lookup can escape a candidate only through its root legacy main */
|
|
1412
|
+
if (request !== name) return false;
|
|
1413
|
+
try {
|
|
1414
|
+
const manifest = JSON.parse(readFileSync(join(candidate, "package.json"), "utf8"));
|
|
1415
|
+
/* v8 ignore next -- a bounded native lookup outside the package directory requires a legacy main */
|
|
1416
|
+
if (typeof manifest.main !== "string") return false;
|
|
1417
|
+
return sameResolution(createRequire(join(candidate, "package.json")).resolve(resolve(candidate, manifest.main)), resolved);
|
|
1418
|
+
} catch (_error) {
|
|
1419
|
+
/* v8 ignore next -- this helper runs only after the same native resolution succeeded */
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function isUnselectedPackageMiss(error) {
|
|
1424
|
+
const failure = error;
|
|
1425
|
+
return failure.code === "MODULE_NOT_FOUND" && failure.path === void 0;
|
|
1426
|
+
}
|
|
1427
|
+
function sameResolution(left, right) {
|
|
1428
|
+
if (left === right) return true;
|
|
1429
|
+
return canonicalPath(left) === canonicalPath(right);
|
|
1430
|
+
}
|
|
1431
|
+
/** One mutable pointer to the immutable runtime resolution. */
|
|
1432
|
+
var ResolutionRouter = class {
|
|
1433
|
+
nodeModulePaths;
|
|
1434
|
+
current;
|
|
1435
|
+
linkedTargets;
|
|
1436
|
+
constructor(resolution, nodeModulePaths) {
|
|
1437
|
+
this.nodeModulePaths = nodeModulePaths;
|
|
1438
|
+
this.current = compileResolution(resolution);
|
|
1439
|
+
this.linkedTargets = new Map(resolution.linkedRoots.map((root) => [root.name, root.realPath]));
|
|
1440
|
+
}
|
|
1441
|
+
replace(successor) {
|
|
1442
|
+
const entries = new Map(successor.entries.map((entry) => [entry.name, entry]));
|
|
1443
|
+
if (successor.profilesDir !== this.current.profilesDir || successor.profileDir !== this.current.profileDir) throw new Error("profile resolution: a successor cannot change its profile scope");
|
|
1444
|
+
for (const [name, current] of this.current.entries) {
|
|
1445
|
+
const next = entries.get(name);
|
|
1446
|
+
if (next === void 0 && current.scope === "profile") continue;
|
|
1447
|
+
if (next === void 0 || !sameResolution(current.packageDir, next.packageDir) || current.scope === "installation" && !sameResolution(current.declarer, next.declarer) || current.version !== next.version || current.scope !== next.scope) throw new Error(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`);
|
|
1448
|
+
}
|
|
1449
|
+
const localPackageNames = new Set(successor.localPackageNames);
|
|
1450
|
+
for (const name of localPackageNames) if (!this.current.localPackageNames.has(name) && this.current.entries.has(name)) throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`);
|
|
1451
|
+
for (const root of successor.linkedRoots) {
|
|
1452
|
+
const previous = this.linkedTargets.get(root.name);
|
|
1453
|
+
if (previous !== void 0 && !sameResolution(previous, root.realPath)) throw new Error(`profile resolution: relinking ${JSON.stringify(root.name)} requires a process restart`);
|
|
1454
|
+
}
|
|
1455
|
+
const next = compileResolution(successor);
|
|
1456
|
+
for (const { name, realPath } of successor.linkedRoots) if (!this.linkedTargets.has(name)) this.linkedTargets.set(name, realPath);
|
|
1457
|
+
this.current = next;
|
|
1458
|
+
}
|
|
1459
|
+
/** Whether a module path has an interception layer: it lies in the profiles tree, the active profile, or a linked root. */
|
|
1460
|
+
hasInterceptionLayerForPath(path) {
|
|
1461
|
+
return findInterceptionLayer(path, this.current) !== void 0;
|
|
1462
|
+
}
|
|
1463
|
+
/** Whether an importer URL is scoped; the answer is memoized with the importer's routes. */
|
|
1464
|
+
hasInterceptionLayerForUrl(parentURL) {
|
|
1465
|
+
return this.getOrCreateParentRoutesForUrl(parentURL) !== false;
|
|
1466
|
+
}
|
|
1467
|
+
createParentRoutes(parent, resolution) {
|
|
1468
|
+
const layer = findInterceptionLayer(parent, resolution);
|
|
1469
|
+
return layer === void 0 ? void 0 : {
|
|
1470
|
+
parent,
|
|
1471
|
+
layer,
|
|
1472
|
+
requests: /* @__PURE__ */ new Map()
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
getOrCreateParentRoutesForUrl(parentURL) {
|
|
1476
|
+
const resolution = this.current;
|
|
1477
|
+
let parentRoutes = resolution.esmRoutes.get(parentURL);
|
|
1478
|
+
if (parentRoutes !== void 0) return parentRoutes;
|
|
1479
|
+
let parent;
|
|
1480
|
+
try {
|
|
1481
|
+
parent = fileURLToPath(parentURL);
|
|
1482
|
+
} catch {
|
|
1483
|
+
parent = void 0;
|
|
1484
|
+
}
|
|
1485
|
+
parentRoutes = parent === void 0 ? false : this.createParentRoutes(parent, resolution) ?? false;
|
|
1486
|
+
resolution.esmRoutes.set(parentURL, parentRoutes);
|
|
1487
|
+
return parentRoutes;
|
|
1488
|
+
}
|
|
1489
|
+
routeScoped(request, parentRoutes, resolution, flavor, cjs) {
|
|
1490
|
+
const { parent, layer, requests } = parentRoutes;
|
|
1491
|
+
const memo = layer.kind === "profile" && (cjs === void 0 || cjs.cacheable);
|
|
1492
|
+
const name = barePackageName(request);
|
|
1493
|
+
if (name === void 0) return void 0;
|
|
1494
|
+
if (parentRoutes.selfReferenceName === void 0 || !memo) parentRoutes.selfReferenceName = selfReferenceName(parent);
|
|
1495
|
+
if (parentRoutes.selfReferenceName === name || parentRoutes.selfReferenceName === null) {
|
|
1496
|
+
const state = { route: { kind: "native" } };
|
|
1497
|
+
if (memo) requests.set(request, state);
|
|
1498
|
+
return state;
|
|
1499
|
+
}
|
|
1500
|
+
if (layer.kind === "linked") return this.routeLinked(request, name, parent, resolution, flavor, cjs);
|
|
1501
|
+
const target = resolution.entries.get(name);
|
|
1502
|
+
const candidates = [];
|
|
1503
|
+
const localSearchPaths = [];
|
|
1504
|
+
for (const searchPath of createRequire(parent).resolve.paths(name)) {
|
|
1505
|
+
if (!searchPath.startsWith(layer.localPrefix)) break;
|
|
1506
|
+
localSearchPaths.push(searchPath);
|
|
1507
|
+
const candidate = localPackageCandidate(searchPath, name, flavor);
|
|
1508
|
+
if (candidate !== void 0) candidates.push(candidate);
|
|
1509
|
+
}
|
|
1510
|
+
if (candidates.length > 0) if (cjs !== void 0) try {
|
|
1511
|
+
const resolved = cjs.resolveNative(localSearchPaths);
|
|
1512
|
+
const selected = candidates.find((candidate) => localCandidateOwnsResolution(candidate, resolved, request, name));
|
|
1513
|
+
if (selected !== void 0) {
|
|
1514
|
+
const state = {
|
|
1515
|
+
route: {
|
|
1516
|
+
kind: "native",
|
|
1517
|
+
packageDir: selected
|
|
1518
|
+
},
|
|
1519
|
+
packageDir: selected,
|
|
1520
|
+
cjs: resolved
|
|
1521
|
+
};
|
|
1522
|
+
if (memo) requests.set(request, state);
|
|
1523
|
+
return state;
|
|
1524
|
+
}
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
if (!isUnselectedPackageMiss(error)) throw error;
|
|
1527
|
+
}
|
|
1528
|
+
else {
|
|
1529
|
+
const selected = candidates[0];
|
|
1530
|
+
const state = {
|
|
1531
|
+
route: {
|
|
1532
|
+
kind: "native",
|
|
1533
|
+
packageDir: selected
|
|
1534
|
+
},
|
|
1535
|
+
packageDir: selected
|
|
1536
|
+
};
|
|
1537
|
+
requests.set(request, state);
|
|
1538
|
+
return state;
|
|
1539
|
+
}
|
|
1540
|
+
const route = target !== void 0 && (target.scope === "installation" || layer.active) ? {
|
|
1541
|
+
kind: "interception",
|
|
1542
|
+
entry: target,
|
|
1543
|
+
after: layer.after
|
|
1544
|
+
} : {
|
|
1545
|
+
kind: "native-after-interception",
|
|
1546
|
+
parent: layer.nativeAfter
|
|
1547
|
+
};
|
|
1548
|
+
const state = { route };
|
|
1549
|
+
if (route.kind === "interception" && memo) requests.set(request, state);
|
|
1550
|
+
return state;
|
|
1551
|
+
}
|
|
1552
|
+
routeLinked(request, name, parent, resolution, flavor, cjs) {
|
|
1553
|
+
const target = resolution.entries.get(name);
|
|
1554
|
+
if (target === void 0) return { route: { kind: "native" } };
|
|
1555
|
+
const ancestors = this.nodeModulePaths(dirname(parent));
|
|
1556
|
+
const ancestorSet = new Set(ancestors);
|
|
1557
|
+
const searchPaths = flavor === "esm" ? ancestors : createRequire(parent).resolve.paths(name);
|
|
1558
|
+
for (const searchPath of searchPaths) {
|
|
1559
|
+
const directory = dirname(searchPath);
|
|
1560
|
+
if (ancestorSet.has(searchPath) && readPeerNames(directory).has(name)) {
|
|
1561
|
+
const route = {
|
|
1562
|
+
kind: "interception",
|
|
1563
|
+
entry: target,
|
|
1564
|
+
after: join(dirname(directory), "package.json")
|
|
1565
|
+
};
|
|
1566
|
+
if (cjs === void 0) return { route };
|
|
1567
|
+
try {
|
|
1568
|
+
const resolved = cjs.resolveEntry(target, route.after);
|
|
1569
|
+
if (localCandidateOwnsResolution(target.packageDir, resolved, request, name)) return {
|
|
1570
|
+
route,
|
|
1571
|
+
packageDir: target.packageDir,
|
|
1572
|
+
cjs: resolved
|
|
1573
|
+
};
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
if (!isUnselectedPackageMiss(error)) throw error;
|
|
1576
|
+
}
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const candidate = localPackageCandidate(searchPath, name, flavor);
|
|
1580
|
+
if (candidate === void 0) continue;
|
|
1581
|
+
if (cjs === void 0) return {
|
|
1582
|
+
route: {
|
|
1583
|
+
kind: "native",
|
|
1584
|
+
packageDir: candidate
|
|
1585
|
+
},
|
|
1586
|
+
packageDir: candidate
|
|
1587
|
+
};
|
|
1588
|
+
try {
|
|
1589
|
+
const resolved = cjs.resolveNative([searchPath]);
|
|
1590
|
+
if (localCandidateOwnsResolution(candidate, resolved, request, name)) return {
|
|
1591
|
+
route: {
|
|
1592
|
+
kind: "native",
|
|
1593
|
+
packageDir: candidate
|
|
1594
|
+
},
|
|
1595
|
+
packageDir: candidate,
|
|
1596
|
+
cjs: resolved
|
|
1597
|
+
};
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
if (!isUnselectedPackageMiss(error)) throw error;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return cjs === void 0 ? { route: { kind: "native" } } : {
|
|
1603
|
+
route: { kind: "native" },
|
|
1604
|
+
cjs: cjs.resolveNative([])
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
routeLocalPackage(request, parentRoutes, resolution) {
|
|
1608
|
+
const name = barePackageName(request);
|
|
1609
|
+
const { layer } = parentRoutes;
|
|
1610
|
+
if (name === void 0 || layer.kind !== "profile" || !layer.active || !resolution.localPackageNames.has(name)) return void 0;
|
|
1611
|
+
const state = { route: { kind: "native" } };
|
|
1612
|
+
parentRoutes.requests.set(request, state);
|
|
1613
|
+
return state;
|
|
1614
|
+
}
|
|
1615
|
+
routeUrl(request, parentURL) {
|
|
1616
|
+
const resolution = this.current;
|
|
1617
|
+
const parentRoutes = this.getOrCreateParentRoutesForUrl(parentURL);
|
|
1618
|
+
if (parentRoutes === false) return void 0;
|
|
1619
|
+
const cached = parentRoutes.requests.get(request);
|
|
1620
|
+
if (cached !== void 0) return cached;
|
|
1621
|
+
const local = this.routeLocalPackage(request, parentRoutes, resolution);
|
|
1622
|
+
if (local !== void 0) return local;
|
|
1623
|
+
return this.routeScoped(request, parentRoutes, resolution, "esm");
|
|
1624
|
+
}
|
|
1625
|
+
routePath(request, parent, cjs) {
|
|
1626
|
+
const resolution = this.current;
|
|
1627
|
+
let parentRoutes = resolution.cjsRoutes.get(parent);
|
|
1628
|
+
if (parentRoutes === false) return void 0;
|
|
1629
|
+
const cached = cjs.cacheable ? parentRoutes?.requests.get(request) : void 0;
|
|
1630
|
+
if (cached !== void 0) return cached;
|
|
1631
|
+
if (parentRoutes === void 0) {
|
|
1632
|
+
parentRoutes = this.createParentRoutes(parent, resolution) ?? false;
|
|
1633
|
+
resolution.cjsRoutes.set(parent, parentRoutes);
|
|
1634
|
+
if (parentRoutes === false) return void 0;
|
|
1635
|
+
}
|
|
1636
|
+
return this.routeScoped(request, parentRoutes, resolution, "cjs", cjs);
|
|
1637
|
+
}
|
|
1638
|
+
packageDir(specifier, parentURL) {
|
|
1639
|
+
const name = barePackageName(specifier);
|
|
1640
|
+
if (name === void 0) return void 0;
|
|
1641
|
+
const state = this.routeUrl(name, parentURL);
|
|
1642
|
+
if (state?.route.kind === "interception") return state.route.entry.packageDir;
|
|
1643
|
+
if (state?.packageDir !== void 0) return state.packageDir;
|
|
1644
|
+
let parent;
|
|
1645
|
+
try {
|
|
1646
|
+
parent = state?.route.kind === "native-after-interception" ? state.route.parent : fileURLToPath(parentURL);
|
|
1647
|
+
} catch {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
const found = nativePackageDir(parent, name);
|
|
1651
|
+
if (state !== void 0 && found !== void 0) state.packageDir = found;
|
|
1652
|
+
return found;
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
function internalModules() {
|
|
1656
|
+
const addon = createRequire(import.meta.url)("node-addon-require-builtin");
|
|
1657
|
+
const esmModule = addon.requireBuiltin("internal/modules/esm/loader");
|
|
1658
|
+
const cjsModule = addon.requireBuiltin("internal/modules/cjs/loader");
|
|
1659
|
+
const cjsHelpers = addon.requireBuiltin("internal/modules/helpers");
|
|
1660
|
+
const esmUtils = addon.requireBuiltin("internal/modules/esm/utils");
|
|
1661
|
+
const esmResolve = addon.requireBuiltin("internal/modules/esm/resolve");
|
|
1662
|
+
const esm = esmModule.getOrInitializeCascadedLoader();
|
|
1663
|
+
const modern = "getOrCreateModuleJob" in esm;
|
|
1664
|
+
/* v8 ignore start -- the supported Node 22/24/26 matrix validates each available Internal interface */
|
|
1665
|
+
if (typeof esm.resolveSync !== "function" || typeof Reflect.get(esm, modern ? "getOrCreateModuleJob" : "getModuleJobForImport") !== "function" || !modern && typeof Reflect.get(esm, "resolve") !== "function" || typeof cjsModule.Module._resolveFilename !== "function" || typeof cjsHelpers.getCjsConditions !== "function" || typeof esmUtils.getDefaultConditions !== "function" || typeof esmResolve.defaultResolve !== "function") throw new Error("profile resolution: unsupported Node module loader");
|
|
1666
|
+
/* v8 ignore stop */
|
|
1667
|
+
return {
|
|
1668
|
+
esm,
|
|
1669
|
+
esmDefaultResolve: (specifier, context) => esmResolve.defaultResolve(specifier, context),
|
|
1670
|
+
esmConditions: esmUtils.getDefaultConditions(),
|
|
1671
|
+
cjs: cjsModule.Module,
|
|
1672
|
+
cjsConditions: cjsHelpers.getCjsConditions(),
|
|
1673
|
+
modern
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Replace a resolver error's message and the same text in its stack.
|
|
1678
|
+
* Node's module-hooks thread serializes errors and returns the stack accessor as a read-only configurable
|
|
1679
|
+
* data property, so a rejected stack assignment redefines the property value.
|
|
1680
|
+
* @param error - resolver error to update in place.
|
|
1681
|
+
* @param message - replacement message.
|
|
1682
|
+
*/
|
|
1683
|
+
function replaceErrorMessage(error, message) {
|
|
1684
|
+
const { message: originalMessage, stack } = error;
|
|
1685
|
+
error.message = message;
|
|
1686
|
+
/* v8 ignore next -- Node's resolver errors always carry a stack */
|
|
1687
|
+
if (stack === void 0) return;
|
|
1688
|
+
const replaced = stack.replace(originalMessage, message);
|
|
1689
|
+
if (!Reflect.set(error, "stack", replaced)) Object.defineProperty(error, "stack", { value: replaced });
|
|
1690
|
+
}
|
|
1691
|
+
function throwWithImporter(error, routedParent, parent) {
|
|
1692
|
+
const code = error.code;
|
|
1693
|
+
if (error instanceof Error && (code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED")) {
|
|
1694
|
+
const routedPath = fileURLToPath(routedParent);
|
|
1695
|
+
const parentPath = fileURLToPath(parent);
|
|
1696
|
+
replaceErrorMessage(error, error.message.replaceAll(routedParent, parent).replaceAll(routedPath, parentPath));
|
|
1697
|
+
}
|
|
1698
|
+
throw error;
|
|
1699
|
+
}
|
|
1700
|
+
function throwWithoutCjsAnchor(error, anchor) {
|
|
1701
|
+
const resolved = error;
|
|
1702
|
+
const requireStack = resolved.requireStack;
|
|
1703
|
+
if (error instanceof Error && resolved.code === "MODULE_NOT_FOUND" && requireStack?.[0] !== void 0 && sameResolution(requireStack[0], anchor)) {
|
|
1704
|
+
const originalMessage = error.message;
|
|
1705
|
+
const originalBlock = `\nRequire stack:\n${requireStack.map((path) => `- ${path}`).join("\n")}`;
|
|
1706
|
+
const remaining = requireStack.slice(1);
|
|
1707
|
+
/* v8 ignore next -- routed calls always retain the original importing module */
|
|
1708
|
+
const replacement = remaining.length === 0 ? "" : `\nRequire stack:\n${remaining.map((path) => `- ${path}`).join("\n")}`;
|
|
1709
|
+
error.message = originalMessage.replace(originalBlock, replacement);
|
|
1710
|
+
resolved.requireStack = remaining;
|
|
1711
|
+
const stack = error.stack;
|
|
1712
|
+
/* v8 ignore next -- Node's resolver errors always carry a stack */
|
|
1713
|
+
if (stack !== void 0) error.stack = stack.replace(originalMessage, error.message);
|
|
1714
|
+
}
|
|
1715
|
+
throw error;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Install one runtime resolution as the interception on Node's default ESM and CommonJS resolvers.
|
|
1719
|
+
* @param resolution - complete package table and profile scope.
|
|
1720
|
+
* @returns an interception that publishes a successor or restores the native methods.
|
|
1721
|
+
*/
|
|
1722
|
+
function installRuntimeInterception(resolution) {
|
|
1723
|
+
const { esm, esmDefaultResolve, esmConditions, cjs, cjsConditions, modern } = internalModules();
|
|
1724
|
+
const router = new ResolutionRouter(resolution, (directory) => cjs._nodeModulePaths(directory));
|
|
1725
|
+
let delegatedEsm;
|
|
1726
|
+
const adaptEsm = (native) => {
|
|
1727
|
+
const adapted = (request, parent, attributes) => {
|
|
1728
|
+
const delegated = delegatedEsm;
|
|
1729
|
+
/* v8 ignore next -- reentry requires a separate synchronous Node hook; supported launches install none */
|
|
1730
|
+
if (delegated !== void 0 && delegated.parent === parent && delegated.request === request) return native(request, parent, attributes);
|
|
1731
|
+
if (parent === void 0 || !router.hasInterceptionLayerForUrl(parent)) return native(request, parent, attributes);
|
|
1732
|
+
const state = router.routeUrl(request, parent);
|
|
1733
|
+
if (state === void 0) {
|
|
1734
|
+
const target = request[0] === "#" ? packageImportsTarget(fileURLToPath(parent), request, esmConditions) : void 0;
|
|
1735
|
+
if (target === void 0) return native(request, parent, attributes);
|
|
1736
|
+
const restoreImporter = (error) => throwWithImporter(error, target.parentURL, parent);
|
|
1737
|
+
let expected;
|
|
1738
|
+
try {
|
|
1739
|
+
expected = adapted(target.specifier, target.parentURL, attributes);
|
|
1740
|
+
/* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */
|
|
1741
|
+
if (expected instanceof Promise) expected = expected.catch(restoreImporter);
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
return restoreImporter(error);
|
|
1744
|
+
}
|
|
1745
|
+
return expected;
|
|
1746
|
+
}
|
|
1747
|
+
const cacheable = attributes === EMPTY_ATTRIBUTES || Object.keys(attributes).length === 0;
|
|
1748
|
+
if (cacheable && state.esm !== void 0) return state.esm;
|
|
1749
|
+
const route = state.route;
|
|
1750
|
+
if (route.kind === "native") {
|
|
1751
|
+
const result = native(request, parent, attributes);
|
|
1752
|
+
if (cacheable && !(result instanceof Promise)) state.esm = result;
|
|
1753
|
+
return result;
|
|
1754
|
+
}
|
|
1755
|
+
const routedParent = pathToFileURL(route.kind === "interception" ? route.entry.declarer : route.parent).href;
|
|
1756
|
+
const previous = delegatedEsm;
|
|
1757
|
+
delegatedEsm = {
|
|
1758
|
+
parent: routedParent,
|
|
1759
|
+
request
|
|
1760
|
+
};
|
|
1761
|
+
const restoreImporter = (error) => throwWithImporter(error, routedParent, parent);
|
|
1762
|
+
try {
|
|
1763
|
+
let result;
|
|
1764
|
+
try {
|
|
1765
|
+
result = native(request, routedParent, attributes);
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
return restoreImporter(error);
|
|
1768
|
+
}
|
|
1769
|
+
/* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */
|
|
1770
|
+
if (result instanceof Promise) return result.catch(restoreImporter);
|
|
1771
|
+
if (cacheable) state.esm = result;
|
|
1772
|
+
return result;
|
|
1773
|
+
} finally {
|
|
1774
|
+
delegatedEsm = previous;
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
return adapted;
|
|
1778
|
+
};
|
|
1779
|
+
let restoreEsm;
|
|
1780
|
+
/* v8 ignore else -- CI coverage runs Node 24 v2; the Node 22 matrix exercises the v1 adapter */
|
|
1781
|
+
if (modern) {
|
|
1782
|
+
const loader = esm;
|
|
1783
|
+
const original = Reflect.get(loader, "resolveSync");
|
|
1784
|
+
const resolveRequest = adaptEsm((request, parent, attributes) => original.call(loader, parent, {
|
|
1785
|
+
specifier: request,
|
|
1786
|
+
attributes
|
|
1787
|
+
}));
|
|
1788
|
+
const wrapped = (parent, request, ...rest) => rest.length ? Reflect.apply(original, loader, [
|
|
1789
|
+
parent,
|
|
1790
|
+
request,
|
|
1791
|
+
...rest
|
|
1792
|
+
]) : resolveRequest(request.specifier, parent, request.attributes ?? EMPTY_ATTRIBUTES);
|
|
1793
|
+
loader.resolveSync = wrapped;
|
|
1794
|
+
restoreEsm = () => {
|
|
1795
|
+
/* v8 ignore else -- interceptions are disposed in reverse installation order */
|
|
1796
|
+
if (loader.resolveSync === wrapped) loader.resolveSync = original;
|
|
1797
|
+
};
|
|
1798
|
+
} else {
|
|
1799
|
+
const loader = esm;
|
|
1800
|
+
const original = Reflect.get(loader, "resolve");
|
|
1801
|
+
const originalSync = Reflect.get(loader, "resolveSync");
|
|
1802
|
+
const resolveRequest = adaptEsm((request, parent, attributes) => original.call(loader, request, parent, attributes));
|
|
1803
|
+
const resolveRequestSync = adaptEsm((request, parent, attributes) => originalSync.call(loader, request, parent, attributes));
|
|
1804
|
+
const wrapped = (request, parent, attributes = EMPTY_ATTRIBUTES) => resolveRequest(request, parent, attributes);
|
|
1805
|
+
const wrappedSync = (request, parent, attributes = EMPTY_ATTRIBUTES) => resolveRequestSync(request, parent, attributes);
|
|
1806
|
+
loader.resolve = wrapped;
|
|
1807
|
+
loader.resolveSync = wrappedSync;
|
|
1808
|
+
restoreEsm = () => {
|
|
1809
|
+
if (loader.resolve === wrapped) loader.resolve = original;
|
|
1810
|
+
if (loader.resolveSync === wrappedSync) loader.resolveSync = originalSync;
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
const originalFilename = Reflect.get(cjs, "_resolveFilename");
|
|
1814
|
+
let delegatedCjs = 0;
|
|
1815
|
+
const resolveRoutedCjs = (request, routed, parent, main, options) => {
|
|
1816
|
+
const anchor = routed.kind === "interception" ? routed.entry.declarer : routed.parent;
|
|
1817
|
+
const synthetic = new cjs(anchor);
|
|
1818
|
+
synthetic.parent = parent;
|
|
1819
|
+
synthetic.filename = anchor;
|
|
1820
|
+
synthetic.paths = routed.kind === "interception" ? packageSearchPaths(routed.entry, request, cjs) : cjs._nodeModulePaths(dirname(anchor));
|
|
1821
|
+
try {
|
|
1822
|
+
return originalFilename.call(cjs, request, synthetic, main, options);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
return throwWithoutCjsAnchor(error, anchor);
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
const resolveNativeCjs = (request, searchPaths, parent, parentFilename, main, conditions) => {
|
|
1828
|
+
const synthetic = new cjs(parentFilename);
|
|
1829
|
+
if (parent.parent !== void 0) synthetic.parent = parent.parent;
|
|
1830
|
+
synthetic.filename = parentFilename;
|
|
1831
|
+
synthetic.paths = [...searchPaths];
|
|
1832
|
+
const options = conditions === void 0 ? void 0 : { conditions };
|
|
1833
|
+
return originalFilename.call(cjs, request, synthetic, main, options);
|
|
1834
|
+
};
|
|
1835
|
+
const resolvePackageImportCjs = (target, conditions) => {
|
|
1836
|
+
const state = router.routeUrl(target.specifier, target.parentURL);
|
|
1837
|
+
const resolveFrom = (parentURL) => fileURLToPath(esmDefaultResolve(target.specifier, {
|
|
1838
|
+
parentURL,
|
|
1839
|
+
conditions: [...conditions]
|
|
1840
|
+
}).url);
|
|
1841
|
+
/* v8 ignore next -- the target manifest was found inside the established profile scope */
|
|
1842
|
+
if (state === void 0) return resolveFrom(target.parentURL);
|
|
1843
|
+
if (state.route.kind === "native") return resolveFrom(target.parentURL);
|
|
1844
|
+
const route = state.route;
|
|
1845
|
+
if (route.kind === "native-after-interception") return resolveFrom(pathToFileURL(route.parent).href);
|
|
1846
|
+
return resolveFrom(pathToFileURL(route.entry.declarer).href);
|
|
1847
|
+
};
|
|
1848
|
+
const wrappedFilename = (request, parent, main, options) => {
|
|
1849
|
+
if (delegatedCjs || !parent?.filename || options?.paths !== void 0) return originalFilename.call(cjs, request, parent, main, options);
|
|
1850
|
+
const parentFilename = parent.filename;
|
|
1851
|
+
const cacheable = options?.conditions === void 0;
|
|
1852
|
+
const routedOptions = options?.conditions === void 0 ? void 0 : { conditions: options.conditions };
|
|
1853
|
+
const state = router.routePath(request, parentFilename, {
|
|
1854
|
+
cacheable,
|
|
1855
|
+
resolveNative: (searchPaths) => resolveNativeCjs(request, searchPaths, parent, parentFilename, main, options?.conditions),
|
|
1856
|
+
resolveEntry: (entry, after) => {
|
|
1857
|
+
delegatedCjs++;
|
|
1858
|
+
try {
|
|
1859
|
+
return resolveRoutedCjs(request, {
|
|
1860
|
+
kind: "interception",
|
|
1861
|
+
entry,
|
|
1862
|
+
after
|
|
1863
|
+
}, parent, main, routedOptions);
|
|
1864
|
+
} finally {
|
|
1865
|
+
delegatedCjs--;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
if (state === void 0) {
|
|
1870
|
+
const scoped = router.hasInterceptionLayerForPath(parentFilename);
|
|
1871
|
+
const conditions = options?.conditions ?? cjsConditions;
|
|
1872
|
+
const target = request[0] === "#" && scoped ? packageImportsTarget(parentFilename, request, conditions) : void 0;
|
|
1873
|
+
if (target === void 0) return originalFilename.call(cjs, request, parent, main, options);
|
|
1874
|
+
return resolvePackageImportCjs(target, conditions);
|
|
1875
|
+
}
|
|
1876
|
+
if (state.cjs !== void 0) return state.cjs;
|
|
1877
|
+
const route = state.route;
|
|
1878
|
+
if (route.kind === "native") {
|
|
1879
|
+
const result = originalFilename.call(cjs, request, parent, main, options);
|
|
1880
|
+
if (cacheable) state.cjs = result;
|
|
1881
|
+
return result;
|
|
1882
|
+
}
|
|
1883
|
+
delegatedCjs++;
|
|
1884
|
+
try {
|
|
1885
|
+
let expected;
|
|
1886
|
+
try {
|
|
1887
|
+
expected = resolveRoutedCjs(request, route, parent, main, routedOptions);
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
if (route.kind !== "interception" || !isUnselectedPackageMiss(error)) throw error;
|
|
1890
|
+
expected = resolveRoutedCjs(request, {
|
|
1891
|
+
kind: "native-after-interception",
|
|
1892
|
+
parent: route.after
|
|
1893
|
+
}, parent, main, routedOptions);
|
|
1894
|
+
}
|
|
1895
|
+
if (cacheable) state.cjs = expected;
|
|
1896
|
+
return expected;
|
|
1897
|
+
} finally {
|
|
1898
|
+
delegatedCjs--;
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
cjs._resolveFilename = wrappedFilename;
|
|
1902
|
+
return {
|
|
1903
|
+
packageDir(specifier, parentURL) {
|
|
1904
|
+
return router.packageDir(specifier, parentURL);
|
|
1905
|
+
},
|
|
1906
|
+
replace(next) {
|
|
1907
|
+
router.replace(next);
|
|
1908
|
+
},
|
|
1909
|
+
dispose() {
|
|
1910
|
+
/* v8 ignore else -- interceptions are disposed in reverse installation order */
|
|
1911
|
+
if (cjs._resolveFilename === wrappedFilename) cjs._resolveFilename = originalFilename;
|
|
1912
|
+
restoreEsm();
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Publish one runtime resolution for Harness-owned Workers.
|
|
1918
|
+
* @param resolution - complete package table and profile scope.
|
|
1919
|
+
* @returns a disposer restoring the previous thread environment data.
|
|
1920
|
+
*/
|
|
1921
|
+
function registerWorkerResolution(resolution) {
|
|
1922
|
+
const previous = getEnvironmentData(WORKER_RESOLUTION_KEY);
|
|
1923
|
+
setEnvironmentData(WORKER_RESOLUTION_KEY, { resolution });
|
|
1924
|
+
return () => {
|
|
1925
|
+
setEnvironmentData(WORKER_RESOLUTION_KEY, previous);
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
//#endregion
|
|
1929
|
+
//#region lib/types/package-meta.js
|
|
1930
|
+
/** Read plugin display text and icons through exported resources without evaluating plugin code. */
|
|
1931
|
+
const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
|
|
1932
|
+
/** Maximum raw icon bytes admitted to plugin metadata. */
|
|
1933
|
+
const MAX_ICON_BYTES = 256 * 1024;
|
|
1934
|
+
const ICON_MEDIA_TYPES = new Map([
|
|
1935
|
+
[".svg", "image/svg+xml"],
|
|
1936
|
+
[".png", "image/png"],
|
|
1937
|
+
[".jpg", "image/jpeg"],
|
|
1938
|
+
[".jpeg", "image/jpeg"],
|
|
1939
|
+
[".webp", "image/webp"]
|
|
1940
|
+
]);
|
|
1941
|
+
function iconOf(specifier, packageName, parentURL, manifestPath, value) {
|
|
1942
|
+
const icon = manifestPath === void 0 ? void 0 : textOf(value, `${manifestPath}: icon`);
|
|
1943
|
+
let target;
|
|
1944
|
+
let label;
|
|
1945
|
+
if (manifestPath !== void 0 && icon !== void 0) {
|
|
1946
|
+
if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) throw new Error(`${manifestPath}: icon must be a relative file path`);
|
|
1947
|
+
label = manifestPath;
|
|
1948
|
+
const root = realpathSync(dirname(manifestPath));
|
|
1949
|
+
target = resolve(root, icon);
|
|
1950
|
+
const local = relative(root, realModuleFile(target));
|
|
1951
|
+
if (local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) throw new Error(`${label}: icon must remain inside its manifest directory`);
|
|
1952
|
+
} else {
|
|
1953
|
+
const exported = optionalResourcePath(`${specifier}/icon`, parentURL);
|
|
1954
|
+
if (exported === void 0) return void 0;
|
|
1955
|
+
target = label = exported;
|
|
1956
|
+
assertPackageOwned(realModuleFile(target), packageName);
|
|
1957
|
+
}
|
|
1958
|
+
const mediaType = ICON_MEDIA_TYPES.get(extname(target).toLowerCase());
|
|
1959
|
+
if (mediaType === void 0) throw new Error(`${label}: icon must be SVG, PNG, JPEG, or WebP`);
|
|
1960
|
+
const file = realModuleFile(target);
|
|
1961
|
+
const stat = statSync(file);
|
|
1962
|
+
if (!stat.isFile()) throw new Error(`${label}: icon must be a regular file`);
|
|
1963
|
+
if (stat.size > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`);
|
|
1964
|
+
const bytes = readFileSync(file);
|
|
1965
|
+
if (bytes.length > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`);
|
|
1966
|
+
return `data:${mediaType};base64,${bytes.toString("base64")}`;
|
|
1967
|
+
}
|
|
1968
|
+
/** Require an ancestor of the real file whose package.json names the owning package. */
|
|
1969
|
+
function assertPackageOwned(file, name) {
|
|
1970
|
+
for (let directory = dirname(file);; directory = dirname(directory)) {
|
|
1971
|
+
const manifest = join(directory, "package.json");
|
|
1972
|
+
if (existsSync(manifest) && readObject(manifest).name === name) return;
|
|
1973
|
+
if (dirname(directory) === directory) throw new Error(`${file}: icon must remain inside its package directory`);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Resolve a plugin resource through the active Node ESM resolver without evaluating it.
|
|
1978
|
+
* @param specifier - plugin module or exported resource specifier.
|
|
1979
|
+
* @param parentURL - owning module-resolution base.
|
|
1980
|
+
* @returns the local filesystem path selected by Node and the active profile.
|
|
1981
|
+
* @throws when the resolver is unavailable or the resource cannot resolve to a local file.
|
|
1982
|
+
*/
|
|
1983
|
+
function resolvePluginResource(specifier, parentURL) {
|
|
1984
|
+
const loader = ModuleLoader.fromInternal();
|
|
1985
|
+
if (loader === void 0) throw new Error("Plugin metadata requires the Node module resolver");
|
|
1986
|
+
return fileURLToPath((loader.version === "v2" ? loader.resolveSync(parentURL, {
|
|
1987
|
+
specifier,
|
|
1988
|
+
attributes: {}
|
|
1989
|
+
}) : loader.resolveSync(specifier, parentURL, {})).url);
|
|
1990
|
+
}
|
|
1991
|
+
function missingResource(error) {
|
|
1992
|
+
const code = error?.code;
|
|
1993
|
+
return code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || code === "ENOENT" || code === "ENOTDIR";
|
|
1994
|
+
}
|
|
1995
|
+
function optionalResourcePath(specifier, parentURL) {
|
|
1996
|
+
try {
|
|
1997
|
+
return resolvePluginResource(specifier, parentURL);
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
if (missingResource(error)) return void 0;
|
|
2000
|
+
throw error;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
function objectOf(value, path) {
|
|
2004
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
2005
|
+
return value;
|
|
2006
|
+
}
|
|
2007
|
+
function textOf(value, path) {
|
|
2008
|
+
if (value === void 0) return void 0;
|
|
2009
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string`);
|
|
2010
|
+
return value;
|
|
2011
|
+
}
|
|
2012
|
+
function readObject(file) {
|
|
2013
|
+
let contents;
|
|
2014
|
+
try {
|
|
2015
|
+
contents = JSON.parse(readFileSync(file, "utf8"));
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
throw new Error(`${file}: ${String(error)}`);
|
|
2018
|
+
}
|
|
2019
|
+
return objectOf(contents, file);
|
|
2020
|
+
}
|
|
2021
|
+
function fallbackText(value) {
|
|
2022
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2023
|
+
}
|
|
2024
|
+
function dictionariesOf(englishPath, specifier, parentURL) {
|
|
2025
|
+
const dictionaries = /* @__PURE__ */ new Map();
|
|
2026
|
+
for (const entry of readdirSync(dirname(englishPath), { withFileTypes: true })) {
|
|
2027
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
2028
|
+
const resource = `${specifier}/locale/${entry.name}`;
|
|
2029
|
+
const language = entry.name.slice(0, -5);
|
|
2030
|
+
if (!LANGUAGE_ID.test(language)) throw new Error(`${resource} must use a language id as its filename`);
|
|
2031
|
+
const id = language.toLowerCase();
|
|
2032
|
+
if (dictionaries.has(id)) throw new Error(`${resource} duplicates locale ${id}`);
|
|
2033
|
+
const file = resolvePluginResource(resource, parentURL);
|
|
2034
|
+
if (dirname(file) !== dirname(englishPath)) throw new Error(`${resource}: ${file} must share the English locale directory ${dirname(englishPath)}`);
|
|
2035
|
+
const parsed = readObject(file);
|
|
2036
|
+
const meta = parsed.meta === void 0 ? void 0 : objectOf(parsed.meta, `${file}: meta`);
|
|
2037
|
+
dictionaries.set(id, {
|
|
2038
|
+
title: textOf(meta?.title, `${file}: meta.title`),
|
|
2039
|
+
description: textOf(meta?.description, `${file}: meta.description`)
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
return dictionaries;
|
|
2043
|
+
}
|
|
2044
|
+
function localizedText(field, dictionaries, fallback, finalFallback) {
|
|
2045
|
+
const entries = [...dictionaries].flatMap(([language, fields]) => {
|
|
2046
|
+
const value = fields[field];
|
|
2047
|
+
return value === void 0 ? [] : [[language, value]];
|
|
2048
|
+
});
|
|
2049
|
+
if (entries.length === 0) return fallback;
|
|
2050
|
+
return {
|
|
2051
|
+
en: fallback ?? finalFallback,
|
|
2052
|
+
...Object.fromEntries(entries)
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Read plugin locale JSON display text and icon without evaluating JavaScript entries.
|
|
2057
|
+
* Only a package-root specifier reads its exported package.json: `name`/`description` fill missing
|
|
2058
|
+
* locale fields, and a declared `icon` takes priority over the `<specifier>/icon` resource.
|
|
2059
|
+
* A subpath specifier never reads package.json; its icon comes only from `<specifier>/icon`.
|
|
2060
|
+
* Manifest icons remain inside their declaring directory; exported icons remain inside their
|
|
2061
|
+
* owning package after realpath resolution. Both accept SVG, PNG, JPEG, or WebP up to 256 KiB.
|
|
2062
|
+
* Icon failures retain display text without falling back.
|
|
2063
|
+
* Non-package specifiers are skipped without invoking the resource resolver.
|
|
2064
|
+
* Language files share the directory containing the resolved English resource;
|
|
2065
|
+
* each file is resolved through the complete plugin specifier before reading.
|
|
2066
|
+
* Translation maps retain an English fallback, ultimately the full module specifier
|
|
2067
|
+
* for titles and empty for descriptions.
|
|
2068
|
+
* @param specifier - configured plugin module name, including any package subpath.
|
|
2069
|
+
* @param parentURL - owning Loader tree's module-resolution base.
|
|
2070
|
+
* @returns display fields and any resource diagnostic, or undefined for non-package specifiers or absent metadata.
|
|
2071
|
+
*/
|
|
2072
|
+
function readPluginMeta(specifier, parentURL) {
|
|
2073
|
+
const packageName = barePackageName(specifier);
|
|
2074
|
+
if (packageName === void 0) return void 0;
|
|
2075
|
+
try {
|
|
2076
|
+
const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL);
|
|
2077
|
+
const dictionaries = englishPath === void 0 ? /* @__PURE__ */ new Map() : dictionariesOf(englishPath, specifier, parentURL);
|
|
2078
|
+
const manifestPath = packageName === specifier ? optionalResourcePath(`${specifier}/package.json`, parentURL) : void 0;
|
|
2079
|
+
const manifest = manifestPath === void 0 ? void 0 : readObject(manifestPath);
|
|
2080
|
+
const title = localizedText("title", dictionaries, fallbackText(manifest?.name), specifier);
|
|
2081
|
+
const description = localizedText("description", dictionaries, fallbackText(manifest?.description), "");
|
|
2082
|
+
const text = {
|
|
2083
|
+
...title === void 0 ? {} : { title },
|
|
2084
|
+
...description === void 0 ? {} : { description }
|
|
2085
|
+
};
|
|
2086
|
+
let icon;
|
|
2087
|
+
try {
|
|
2088
|
+
icon = iconOf(specifier, packageName, parentURL, manifestPath, manifest?.icon);
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
return {
|
|
2091
|
+
...text,
|
|
2092
|
+
error: `Plugin metadata for ${specifier}: ${String(error)}`
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
if (title === void 0 && description === void 0 && icon === void 0) return void 0;
|
|
2096
|
+
return {
|
|
2097
|
+
...text,
|
|
2098
|
+
...icon === void 0 ? {} : { icon }
|
|
2099
|
+
};
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
return { error: `Plugin metadata for ${specifier}: ${String(error)}` };
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
//#endregion
|
|
2105
|
+
//#region lib/types/compatibility-preflight.js
|
|
2106
|
+
/** Compatibility checks at the composition entry points DSH owns; no Loader instrumentation. */
|
|
2107
|
+
function readManifest(filename) {
|
|
2108
|
+
return JSON.parse(readFileSync(filename, "utf8"));
|
|
2109
|
+
}
|
|
2110
|
+
/** Render the patch wording an Include would report, through the profile's logger. */
|
|
2111
|
+
function patchWarning(ctx) {
|
|
2112
|
+
return (message, ...args) => {
|
|
2113
|
+
let index = 0;
|
|
2114
|
+
ctx.logger.warn(message.replace(/%C/g, () => JSON.stringify(args[index++])));
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
function manifestOf(ctx, name, parentURL) {
|
|
2118
|
+
if (name.startsWith("cordis:")) return void 0;
|
|
2119
|
+
const specifier = name.startsWith("#") ? resolvePluginResource(name, parentURL) : name;
|
|
2120
|
+
const packages = ctx.get("pluginPackages");
|
|
2121
|
+
const pkg = packages?.packageOf(specifier, parentURL);
|
|
2122
|
+
if (pkg !== void 0) return readManifest(pkg.manifestPath);
|
|
2123
|
+
const bare = barePackageName(specifier);
|
|
2124
|
+
if (bare !== void 0 && packages === void 0) for (const path of createRequire(parentURL).resolve.paths(bare)) {
|
|
2125
|
+
const filename = join(path, bare, "package.json");
|
|
2126
|
+
if (existsSync(filename)) return readManifest(filename);
|
|
2127
|
+
}
|
|
2128
|
+
if (!isAbsolute(specifier) && !specifier.startsWith(".") && !specifier.startsWith("file:")) return void 0;
|
|
2129
|
+
const url = isAbsolute(specifier) ? pathToFileURL(specifier) : new URL(specifier, parentURL);
|
|
2130
|
+
let dir;
|
|
2131
|
+
try {
|
|
2132
|
+
dir = dirname(realpathSync(fileURLToPath(url)));
|
|
2133
|
+
} catch (error) {
|
|
2134
|
+
if ([
|
|
2135
|
+
"ENOENT",
|
|
2136
|
+
"ENOTDIR",
|
|
2137
|
+
"EISDIR"
|
|
2138
|
+
].includes(String(error.code))) return void 0;
|
|
2139
|
+
throw error;
|
|
2140
|
+
}
|
|
2141
|
+
while (true) try {
|
|
2142
|
+
return readManifest(join(dir, "package.json"));
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
if (error.code !== "ENOENT") throw error;
|
|
2145
|
+
const parent = dirname(dir);
|
|
2146
|
+
if (parent === dir) return void 0;
|
|
2147
|
+
dir = parent;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
/** Prepare profile or preset rows before the owning DSH caller passes them to Loader.
|
|
2151
|
+
* Only a compatibility conflict denies a row: an entry whose manifest cannot be resolved keeps
|
|
2152
|
+
* the Loader's own import failure. A denied ordinary row gains `disabled`; a native Include that
|
|
2153
|
+
* reaches a denied plugin is denied as a whole, because its file is never rewritten.
|
|
2154
|
+
* @param ctx Context carrying launcher-owned profile facts; non-profile contexts retain their rows.
|
|
2155
|
+
* @param entries Complete effective entry list, after patch composition.
|
|
2156
|
+
* @param parentURL Resolution base of the tree that will import these rows.
|
|
2157
|
+
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
|
|
2158
|
+
* @returns Detached rows with incompatible entries denied; reads the profile compatibility file once.
|
|
2159
|
+
* @throws For malformed compatibility permissions or a profile composition without a resolution base.
|
|
2160
|
+
*/
|
|
2161
|
+
function prepareProfileEntries(ctx, entries, parentURL, binName = "dsh") {
|
|
2162
|
+
return preflight(ctx, entries, parentURL, (row, reason) => {
|
|
2163
|
+
const label = typeof row.id === "string" ? `row ${JSON.stringify(row.id)}` : row.name;
|
|
2164
|
+
process.stderr.write(`${binName}: disabling profile plugin ${label}: ${reason}\n`);
|
|
2165
|
+
}).rows;
|
|
2166
|
+
}
|
|
2167
|
+
function preflight(ctx, entries, parentURL, report) {
|
|
2168
|
+
const rows = structuredClone(entries);
|
|
2169
|
+
const profile = ctx.get("profileContext");
|
|
2170
|
+
if (profile === void 0) return {
|
|
2171
|
+
rows,
|
|
2172
|
+
blocked: false
|
|
2173
|
+
};
|
|
2174
|
+
if (parentURL === void 0) throw new Error("Profile compatibility preflight requires a resolution base");
|
|
2175
|
+
const { exemptions, warnings } = readProfileCompatibility(profile.dir);
|
|
2176
|
+
for (const warning of warnings) process.stderr.write(`${warning}\n`);
|
|
2177
|
+
const includes = /* @__PURE__ */ new Set();
|
|
2178
|
+
/** Only a compatibility conflict denies a row; every other failure keeps the Loader's own diagnosis. */
|
|
2179
|
+
const denial = (row, base) => {
|
|
2180
|
+
try {
|
|
2181
|
+
const manifest = manifestOf(ctx, row.name, base);
|
|
2182
|
+
if (manifest === void 0) return void 0;
|
|
2183
|
+
const issue = evaluatePluginCompatibility(manifest, exemptions);
|
|
2184
|
+
return issue === void 0 || issue.exempted ? void 0 : pluginCompatibilityWarning(issue);
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
return `its declared peer dependencies cannot be validated: ${error instanceof Error ? error.message : String(error)}`;
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
const deny = (row, reason) => {
|
|
2190
|
+
row.disabled = true;
|
|
2191
|
+
if (row.group) row.group = false;
|
|
2192
|
+
report(row, reason);
|
|
2193
|
+
};
|
|
2194
|
+
const check = (rows, base) => {
|
|
2195
|
+
let blocked = false;
|
|
2196
|
+
for (const row of rows) {
|
|
2197
|
+
if (row.disabled === true && !row.group) continue;
|
|
2198
|
+
const conflict = denial(row, base);
|
|
2199
|
+
if (conflict !== void 0) {
|
|
2200
|
+
deny(row, conflict);
|
|
2201
|
+
blocked = true;
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
if ((row.group === true || row.name === "cordis:group" || row.name === "@deepseek-ai/cordis-plugin-group") && Array.isArray(row.config) && check(row.config, base)) blocked = true;
|
|
2205
|
+
if (row.name !== "cordis:include" && row.name !== "@deepseek-ai/cordis-plugin-include") continue;
|
|
2206
|
+
const reached = includedConflicts(row, base);
|
|
2207
|
+
if (reached !== void 0) {
|
|
2208
|
+
deny(row, reached);
|
|
2209
|
+
blocked = true;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return blocked;
|
|
2213
|
+
};
|
|
2214
|
+
/** Whether a native Include reaches a denied plugin; an unreadable one has no compatibility opinion. */
|
|
2215
|
+
const includedConflicts = (row, base) => {
|
|
2216
|
+
const config = row.config;
|
|
2217
|
+
if (typeof config?.path !== "string" || ![
|
|
2218
|
+
".yml",
|
|
2219
|
+
".yaml",
|
|
2220
|
+
".json"
|
|
2221
|
+
].includes(extname(config.path))) return;
|
|
2222
|
+
const requested = isAbsolute(config.path) ? config.path : fileURLToPath(new URL(config.path, base));
|
|
2223
|
+
const filename = existsSync(requested) ? realpathSync(requested) : requested;
|
|
2224
|
+
if (includes.has(filename)) return void 0;
|
|
2225
|
+
includes.add(filename);
|
|
2226
|
+
try {
|
|
2227
|
+
let data;
|
|
2228
|
+
try {
|
|
2229
|
+
data = load(readFileSync(filename, "utf8"), { schema: entryListSchema });
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
if (error.code !== "ENOENT" || config.initial === void 0) return void 0;
|
|
2232
|
+
data = config.initial;
|
|
2233
|
+
}
|
|
2234
|
+
if (!Array.isArray(data)) return void 0;
|
|
2235
|
+
return check(applyEntryPatches(data, config.patches, patchWarning(ctx)), pathToFileURL(filename).href) ? `its included file ${filename} reaches an incompatible plugin, and that file is never rewritten` : void 0;
|
|
2236
|
+
} finally {
|
|
2237
|
+
includes.delete(filename);
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
return {
|
|
2241
|
+
rows,
|
|
2242
|
+
blocked: check(rows, parentURL)
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
/** Apply compatibility policy to the complete patch composition over a profile's empty root.
|
|
2246
|
+
* The caller passes every patch layer of the profile, because this returns one insertion patch for
|
|
2247
|
+
* that empty root: a non-empty root config would lose the caller's override patches by id.
|
|
2248
|
+
* @param ctx Profile context prepared by the launcher.
|
|
2249
|
+
* @param patches Original ordered profile patches; these remain unchanged.
|
|
2250
|
+
* @param parentURL Root Include's resolution base.
|
|
2251
|
+
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
|
|
2252
|
+
* @returns One prepared insertion patch for profiles, or the original patches for non-profile callers.
|
|
2253
|
+
*/
|
|
2254
|
+
function prepareProfilePatches(ctx, patches, parentURL, binName = "dsh") {
|
|
2255
|
+
if (ctx.get("profileContext") === void 0) return patches;
|
|
2256
|
+
const rows = prepareProfileEntries(ctx, applyEntryPatches([], patches, patchWarning(ctx)), parentURL, binName);
|
|
2257
|
+
return rows.length === 0 ? [] : [{ insert: rows }];
|
|
2258
|
+
}
|
|
2259
|
+
//#endregion
|
|
2260
|
+
//#region lib/types/official-bundle-packages.js
|
|
2261
|
+
/** Package admission shared by offline catalog generation and release ordering. */
|
|
2262
|
+
/** Public DSH release members available through the ordinary on-demand bundle installer. */
|
|
2263
|
+
const ON_DEMAND_BUNDLES = ["@qlzt/dsh-subagent-claude-code", "@qlzt/dsh-subagent-codex"];
|
|
2264
|
+
//#endregion
|
|
2265
|
+
//#region lib/types/official-bundles.generated.js
|
|
2266
|
+
/** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */
|
|
2267
|
+
/** Official on-demand entries, independent of installed packages and registry availability. */
|
|
2268
|
+
const OFFICIAL_ON_DEMAND_CATALOG = [{
|
|
2269
|
+
packageName: "@qlzt/dsh-subagent-claude-code",
|
|
2270
|
+
meta: {
|
|
2271
|
+
title: {
|
|
2272
|
+
"en": "Claude Code subagent",
|
|
2273
|
+
"zh": "Claude Code 子智能体"
|
|
2274
|
+
},
|
|
2275
|
+
description: {
|
|
2276
|
+
"en": "Delegate one task to Claude Code using its native authentication and settings.",
|
|
2277
|
+
"zh": "使用 Claude Code 原生认证和设置委派单个任务。"
|
|
2278
|
+
},
|
|
2279
|
+
icon: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K"
|
|
2280
|
+
}
|
|
2281
|
+
}, {
|
|
2282
|
+
packageName: "@qlzt/dsh-subagent-codex",
|
|
2283
|
+
meta: {
|
|
2284
|
+
title: {
|
|
2285
|
+
"en": "Codex subagent",
|
|
2286
|
+
"zh": "Codex 子智能体"
|
|
2287
|
+
},
|
|
2288
|
+
description: {
|
|
2289
|
+
"en": "Delegate one task to Codex using its native authentication and settings.",
|
|
2290
|
+
"zh": "使用 Codex 原生认证和设置委派单个任务。"
|
|
2291
|
+
},
|
|
2292
|
+
icon: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K"
|
|
2293
|
+
}
|
|
2294
|
+
}];
|
|
2295
|
+
//#endregion
|
|
2296
|
+
//#region lib/types/config-schema/native.js
|
|
2297
|
+
/** Identity checks at the plugin-export and lazy-builder boundaries. */
|
|
2298
|
+
/**
|
|
2299
|
+
* Recognize the native Schemastery graph protocol without invoking a validator or serialization hook.
|
|
2300
|
+
* @param value - plugin Config export or lazy-builder result.
|
|
2301
|
+
* @returns whether native schema fields can be inspected.
|
|
2302
|
+
*/
|
|
2303
|
+
function isNativeConfigSchema(value) {
|
|
2304
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return false;
|
|
2305
|
+
const meta = Reflect.get(value, "meta");
|
|
2306
|
+
return Reflect.get(value, Symbol.for("schemastery")) === true && typeof Reflect.get(value, "type") === "string" && meta !== null && typeof meta === "object";
|
|
2307
|
+
}
|
|
2308
|
+
//#endregion
|
|
2309
|
+
//#region lib/types/config-schema/pattern.js
|
|
2310
|
+
/** Conservative compatibility checks between native RegExp validation and Unicode JSON Schema patterns. */
|
|
2311
|
+
function bmpOnly(node) {
|
|
2312
|
+
switch (node.type) {
|
|
2313
|
+
case "Character": return node.value <= 65535 && (node.value < 55296 || node.value > 57343);
|
|
2314
|
+
case "CharacterClassRange": return bmpOnly(node.min) && bmpOnly(node.max) && (node.max.value < 55296 || node.min.value > 57343);
|
|
2315
|
+
case "CharacterClass": return !node.negate && node.elements.every(bmpOnly);
|
|
2316
|
+
case "CharacterSet": return (node.kind === "digit" || node.kind === "space" || node.kind === "word") && !node.negate;
|
|
2317
|
+
case "Quantifier": return bmpOnly(node.element);
|
|
2318
|
+
case "Assertion": return [
|
|
2319
|
+
"start",
|
|
2320
|
+
"end",
|
|
2321
|
+
"word"
|
|
2322
|
+
].includes(node.kind);
|
|
2323
|
+
default: return false;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
function broadSet(node) {
|
|
2327
|
+
return node.type === "CharacterSet" && (node.kind === "any" || (node.kind === "digit" || node.kind === "space" || node.kind === "word") && node.negate);
|
|
2328
|
+
}
|
|
2329
|
+
function portableAlternative(alternative) {
|
|
2330
|
+
if (alternative.elements.length === 1 && alternative.elements.some(broadSet)) return true;
|
|
2331
|
+
let broadRepetitions = 0;
|
|
2332
|
+
const wordAssertion = alternative.elements.some((element) => element.type === "Assertion" && element.kind === "word");
|
|
2333
|
+
return alternative.elements.every((element) => {
|
|
2334
|
+
if (element.type === "Quantifier" && element.max === Infinity && element.min <= 1 && broadSet(element.element)) return !wordAssertion && ++broadRepetitions === 1;
|
|
2335
|
+
return bmpOnly(element);
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
function signature(pattern) {
|
|
2339
|
+
const incidental = new Set([
|
|
2340
|
+
"parent",
|
|
2341
|
+
"start",
|
|
2342
|
+
"end",
|
|
2343
|
+
"raw",
|
|
2344
|
+
"references",
|
|
2345
|
+
"resolved"
|
|
2346
|
+
]);
|
|
2347
|
+
return JSON.stringify(pattern, (key, value) => incidental.has(key) ? void 0 : value);
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Create a checker for patterns that can retain native acceptance under Unicode regex semantics.
|
|
2351
|
+
* Unrecognized flagless syntax stays explicitly partial rather than becoming a misleading constraint.
|
|
2352
|
+
* @returns the compatibility predicate; parsing never executes the regular expression against config values.
|
|
2353
|
+
*/
|
|
2354
|
+
async function createPatternCheck() {
|
|
2355
|
+
const { RegExpParser } = await import("@eslint-community/regexpp");
|
|
2356
|
+
const parser = new RegExpParser();
|
|
2357
|
+
return (source, flags = "") => {
|
|
2358
|
+
if (flags !== "" && flags !== "u") return false;
|
|
2359
|
+
try {
|
|
2360
|
+
const unicode = parser.parsePattern(source, 0, source.length, { unicode: true });
|
|
2361
|
+
if (flags === "u") return true;
|
|
2362
|
+
const native = parser.parsePattern(source, 0, source.length, { unicode: false });
|
|
2363
|
+
return signature(native) === signature(unicode) && native.alternatives.every(portableAlternative);
|
|
2364
|
+
} catch (error) {
|
|
2365
|
+
if (error instanceof SyntaxError) return false;
|
|
2366
|
+
throw error;
|
|
2367
|
+
}
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
//#endregion
|
|
2371
|
+
//#region lib/types/config-schema/projector.js
|
|
2372
|
+
/** Project native Config input constraints without executing their validators or transform callbacks. */
|
|
2373
|
+
/** Definition that projected value positions reference as `#/$defs/loaderExpression`; an enclosing document must define it. */
|
|
2374
|
+
const LOADER_EXPRESSION_SCHEMA = {
|
|
2375
|
+
type: "object",
|
|
2376
|
+
properties: { __jsExpr: { type: "string" } },
|
|
2377
|
+
required: ["__jsExpr"],
|
|
2378
|
+
description: "Inert representation of a YAML !!js scalar from the Cordis entry-list parser. Its result is evaluated and validated only at runtime. Extra marker-object fields are ignored by interpolation."
|
|
2379
|
+
};
|
|
2380
|
+
/** Copy JSON-compatible schema annotations without silently converting non-JSON defaults. */
|
|
2381
|
+
function jsonValue(value, nullifyUndefined = false) {
|
|
2382
|
+
const visit = (item, active) => {
|
|
2383
|
+
if (nullifyUndefined && item === void 0) return;
|
|
2384
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
2385
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
2386
|
+
if (typeof item !== "object") throw new Error("schema annotation is not JSON-compatible");
|
|
2387
|
+
if (active.has(item)) throw new Error("schema annotation contains a cycle");
|
|
2388
|
+
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("schema annotation contains a non-JSON object");
|
|
2389
|
+
active.add(item);
|
|
2390
|
+
for (const child of Object.values(item)) visit(child, active);
|
|
2391
|
+
active.delete(item);
|
|
2392
|
+
};
|
|
2393
|
+
visit(value, /* @__PURE__ */ new Set());
|
|
2394
|
+
return JSON.parse(JSON.stringify(value, (_key, item) => nullifyUndefined && item === void 0 ? null : item));
|
|
2395
|
+
}
|
|
2396
|
+
/** Native constant equality treats omitted and null-valued object members alike. */
|
|
2397
|
+
function constant(value, objectComparison) {
|
|
2398
|
+
if (Array.isArray(value)) return {
|
|
2399
|
+
type: "array",
|
|
2400
|
+
minItems: value.length,
|
|
2401
|
+
maxItems: value.length,
|
|
2402
|
+
items: false,
|
|
2403
|
+
...value.length ? { prefixItems: Array.from(value, (item) => constant(item, objectComparison)) } : {}
|
|
2404
|
+
};
|
|
2405
|
+
if (value !== null && typeof value === "object") {
|
|
2406
|
+
objectComparison();
|
|
2407
|
+
const properties = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, constant(child, objectComparison)]));
|
|
2408
|
+
const inherited = Object.getOwnPropertyNames(Object.prototype);
|
|
2409
|
+
const required = Object.keys(properties).filter((key) => !inherited.includes(key) && Reflect.get(value, key) != null);
|
|
2410
|
+
return {
|
|
2411
|
+
type: "object",
|
|
2412
|
+
properties,
|
|
2413
|
+
additionalProperties: { type: "null" },
|
|
2414
|
+
patternProperties: { [`^(?:${inherited.map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$`]: {} },
|
|
2415
|
+
...required.length ? { required } : {}
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
return { const: value };
|
|
2419
|
+
}
|
|
2420
|
+
/** Add or remove null acceptance; a type list stays deduplicated because a wrapped projection may already accept null. */
|
|
2421
|
+
function nullable(core, accepts) {
|
|
2422
|
+
if (typeof core === "boolean") return accepts ? core || { type: "null" } : core && { not: { type: "null" } };
|
|
2423
|
+
const types = typeof core.type === "string" ? [core.type] : core.type;
|
|
2424
|
+
if (types === void 0) return accepts ? { anyOf: [core, { type: "null" }] } : {
|
|
2425
|
+
...core,
|
|
2426
|
+
not: { type: "null" }
|
|
2427
|
+
};
|
|
2428
|
+
const [single, ...rest] = accepts ? [...new Set([...types, "null"])] : types.filter((type) => type !== "null");
|
|
2429
|
+
if (single === void 0) return false;
|
|
2430
|
+
return {
|
|
2431
|
+
...core,
|
|
2432
|
+
type: rest.length ? [single, ...rest] : single
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
/** Add expression alternatives only at config VALUE positions, not property names or applicator branches. */
|
|
2436
|
+
function expressions(schema, valuePosition = true, enabled = true) {
|
|
2437
|
+
const result = { ...schema };
|
|
2438
|
+
if (schema.properties) result.properties = Object.fromEntries(Object.entries(schema.properties).map(([key, child]) => [key, expressions(child, true, enabled)]));
|
|
2439
|
+
for (const key of ["items", "additionalProperties"]) if (schema[key] !== void 0) result[key] = schema[key] === false ? false : expressions(schema[key], true, enabled);
|
|
2440
|
+
if (schema.propertyNames) result.propertyNames = expressions(schema.propertyNames, false, false);
|
|
2441
|
+
if (schema.prefixItems) result.prefixItems = schema.prefixItems.map((child) => expressions(child, true, enabled));
|
|
2442
|
+
if (schema.anyOf) result.anyOf = schema.anyOf.map((child) => expressions(child, false, enabled));
|
|
2443
|
+
if (schema.not !== void 0) result.not = expressions(schema.not, false, false);
|
|
2444
|
+
if (!valuePosition || !enabled) return result;
|
|
2445
|
+
const annotations = {};
|
|
2446
|
+
for (const key of [
|
|
2447
|
+
"title",
|
|
2448
|
+
"description",
|
|
2449
|
+
"default",
|
|
2450
|
+
"$comment",
|
|
2451
|
+
"x-cordis"
|
|
2452
|
+
]) if (Object.hasOwn(result, key)) {
|
|
2453
|
+
annotations[key] = result[key];
|
|
2454
|
+
Reflect.deleteProperty(result, key);
|
|
2455
|
+
}
|
|
2456
|
+
return {
|
|
2457
|
+
...annotations,
|
|
2458
|
+
anyOf: [result, { $ref: "#/$defs/loaderExpression" }]
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
/** Check native volatile containment without evaluating values or invoking unresolved lazy builders. */
|
|
2462
|
+
function validateVolatilePlacement(node, path, blocked = false, seen = /* @__PURE__ */ new Map()) {
|
|
2463
|
+
const states = seen.get(node) ?? /* @__PURE__ */ new Set();
|
|
2464
|
+
if (states.has(blocked)) return;
|
|
2465
|
+
states.add(blocked);
|
|
2466
|
+
seen.set(node, states);
|
|
2467
|
+
if (node.meta.volatile && blocked) throw new Error(`${path}: volatile fields require a fixed object path without an enclosing volatile field`);
|
|
2468
|
+
const nested = blocked || !!node.meta.volatile;
|
|
2469
|
+
for (const [key, child] of Object.entries(node.dict ?? {})) validateVolatilePlacement(child, `${path}/${key}`, nested, seen);
|
|
2470
|
+
if (node.sKey) validateVolatilePlacement(node.sKey, `${path}/keys`, true, seen);
|
|
2471
|
+
if (node.inner && (node.type !== "lazy" || isNativeConfigSchema(node.inner))) validateVolatilePlacement(node.inner, `${path}/inner`, true, seen);
|
|
2472
|
+
for (const [index, child] of (node.list ?? []).entries()) validateVolatilePlacement(child, `${path}/${index}`, true, seen);
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Create an invocation-local projector. Ajv checks only literal defaults against generated schemas;
|
|
2476
|
+
* it does not fill defaults, coerce input, or call native plugin validators. Opaque input or metadata effects
|
|
2477
|
+
* widen validation with explicit limitations instead of simulating native mutation.
|
|
2478
|
+
* @returns a function projecting one trusted native Config graph into the enclosing document's definitions.
|
|
2479
|
+
*/
|
|
2480
|
+
async function createConfigProjector() {
|
|
2481
|
+
const { Ajv2020 } = await import("ajv/dist/2020.js");
|
|
2482
|
+
const validator = new Ajv2020({
|
|
2483
|
+
strict: false,
|
|
2484
|
+
logger: false,
|
|
2485
|
+
validateFormats: false,
|
|
2486
|
+
addUsedSchema: false
|
|
2487
|
+
});
|
|
2488
|
+
const portablePattern = await createPatternCheck();
|
|
2489
|
+
const builderResults = /* @__PURE__ */ new Map();
|
|
2490
|
+
return (root, prefix) => {
|
|
2491
|
+
validateVolatilePlacement(root, "config");
|
|
2492
|
+
const completed = /* @__PURE__ */ new Map();
|
|
2493
|
+
const strictNodes = /* @__PURE__ */ new Map();
|
|
2494
|
+
const active = /* @__PURE__ */ new Set();
|
|
2495
|
+
const recursiveNames = /* @__PURE__ */ new Map();
|
|
2496
|
+
const definitions = {};
|
|
2497
|
+
const limitations = [];
|
|
2498
|
+
const metadataEffects = [];
|
|
2499
|
+
const limitation = (path, message) => {
|
|
2500
|
+
const text = `${path}: ${message}`;
|
|
2501
|
+
limitations.push(text);
|
|
2502
|
+
return text;
|
|
2503
|
+
};
|
|
2504
|
+
const visit = (node, path, strict = false) => {
|
|
2505
|
+
let key = node;
|
|
2506
|
+
if (strict) {
|
|
2507
|
+
key = strictNodes.get(node) ?? { ...node };
|
|
2508
|
+
strictNodes.set(node, key);
|
|
2509
|
+
}
|
|
2510
|
+
const cached = completed.get(key);
|
|
2511
|
+
if (cached) return cached;
|
|
2512
|
+
if (active.has(key)) {
|
|
2513
|
+
let name = recursiveNames.get(key);
|
|
2514
|
+
if (name === void 0) {
|
|
2515
|
+
name = `${prefix}Recursive${recursiveNames.size}`;
|
|
2516
|
+
recursiveNames.set(key, name);
|
|
2517
|
+
}
|
|
2518
|
+
return {
|
|
2519
|
+
schema: { $ref: `#/$defs/${name}` },
|
|
2520
|
+
acceptsMissing: node.type !== "lazy" && node.meta.required ? false : "unknown",
|
|
2521
|
+
exact: true,
|
|
2522
|
+
recursive: true,
|
|
2523
|
+
effectful: true,
|
|
2524
|
+
mutating: true
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
active.add(key);
|
|
2528
|
+
let projection;
|
|
2529
|
+
if (node.type === "lazy") {
|
|
2530
|
+
let inner = node;
|
|
2531
|
+
let meta = node.meta.volatile ? {
|
|
2532
|
+
...node.meta,
|
|
2533
|
+
volatile: false
|
|
2534
|
+
} : node.meta;
|
|
2535
|
+
const chain = /* @__PURE__ */ new Set();
|
|
2536
|
+
let looseProjection;
|
|
2537
|
+
while (inner.type === "lazy") {
|
|
2538
|
+
const cachedInner = isNativeConfigSchema(inner.inner);
|
|
2539
|
+
if (meta.loose) {
|
|
2540
|
+
const message = limitation(path, "lazy loose fallback can accept inputs rejected by its inner schema");
|
|
2541
|
+
if (!cachedInner) metadataEffects.push(limitation(path, "unresolved loose lazy metadata propagation may affect shared schemas"));
|
|
2542
|
+
looseProjection = {
|
|
2543
|
+
schema: { "x-cordis": {
|
|
2544
|
+
loose: true,
|
|
2545
|
+
limitations: [message]
|
|
2546
|
+
} },
|
|
2547
|
+
acceptsMissing: true,
|
|
2548
|
+
exact: false,
|
|
2549
|
+
recursive: false,
|
|
2550
|
+
effectful: true,
|
|
2551
|
+
mutating: false
|
|
2552
|
+
};
|
|
2553
|
+
break;
|
|
2554
|
+
}
|
|
2555
|
+
if (chain.has(inner)) throw new Error(`${path}: lazy cycle has no concrete schema`);
|
|
2556
|
+
chain.add(inner);
|
|
2557
|
+
let next = cachedInner ? inner.inner : builderResults.get(inner);
|
|
2558
|
+
if (next === void 0 && typeof inner.builder === "function") {
|
|
2559
|
+
next = Reflect.apply(inner.builder, inner, []);
|
|
2560
|
+
builderResults.set(inner, next);
|
|
2561
|
+
}
|
|
2562
|
+
if (!isNativeConfigSchema(next)) throw new Error(`${path}: lazy schema has no native builder result or resolved inner schema`);
|
|
2563
|
+
if (!cachedInner && [
|
|
2564
|
+
"required",
|
|
2565
|
+
"default",
|
|
2566
|
+
"min",
|
|
2567
|
+
"max",
|
|
2568
|
+
"step",
|
|
2569
|
+
"pattern",
|
|
2570
|
+
"loose",
|
|
2571
|
+
"volatile"
|
|
2572
|
+
].some((key) => Object.hasOwn(meta, key) && !Object.hasOwn(next.meta, key) && ([
|
|
2573
|
+
"required",
|
|
2574
|
+
"loose",
|
|
2575
|
+
"volatile"
|
|
2576
|
+
].includes(key) ? !!Reflect.get(meta, key) : Reflect.get(meta, key) != null))) metadataEffects.push(limitation(path, "lazy metadata propagation may affect shared schemas; validation requires native execution"));
|
|
2577
|
+
meta = cachedInner ? next.meta : {
|
|
2578
|
+
...meta,
|
|
2579
|
+
...next.meta
|
|
2580
|
+
};
|
|
2581
|
+
validateVolatilePlacement({
|
|
2582
|
+
...next,
|
|
2583
|
+
meta
|
|
2584
|
+
}, `${path}/lazy`, true);
|
|
2585
|
+
inner = next;
|
|
2586
|
+
}
|
|
2587
|
+
projection = looseProjection ?? visit({
|
|
2588
|
+
...inner,
|
|
2589
|
+
meta
|
|
2590
|
+
}, `${path}/lazy`, strict);
|
|
2591
|
+
if (node.meta.volatile) projection = {
|
|
2592
|
+
...projection,
|
|
2593
|
+
schema: {
|
|
2594
|
+
...projection.schema,
|
|
2595
|
+
"x-cordis": {
|
|
2596
|
+
...projection.schema["x-cordis"],
|
|
2597
|
+
volatile: true
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2601
|
+
} else {
|
|
2602
|
+
const ownLimitations = [];
|
|
2603
|
+
const inputLimitations = [];
|
|
2604
|
+
let unsupported = false;
|
|
2605
|
+
const inputLimitation = (message) => {
|
|
2606
|
+
inputLimitations.push(message);
|
|
2607
|
+
ownLimitations.push(limitation(path, message));
|
|
2608
|
+
};
|
|
2609
|
+
const annotation = (value, name) => {
|
|
2610
|
+
try {
|
|
2611
|
+
return {
|
|
2612
|
+
ok: true,
|
|
2613
|
+
value: jsonValue(value)
|
|
2614
|
+
};
|
|
2615
|
+
} catch (error) {
|
|
2616
|
+
ownLimitations.push(limitation(`${path}/${name}`, `annotation omitted: ${error instanceof Error ? error.message : String(error)}`));
|
|
2617
|
+
return { ok: false };
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
const children = [];
|
|
2621
|
+
const child = (value, suffix, childStrict = false) => {
|
|
2622
|
+
if (value === void 0) throw new Error(`${path}: ${node.type} schema is missing ${suffix}`);
|
|
2623
|
+
const result = visit(value, `${path}/${suffix}`, childStrict);
|
|
2624
|
+
children.push(result);
|
|
2625
|
+
return result;
|
|
2626
|
+
};
|
|
2627
|
+
const meta = { ...node.meta };
|
|
2628
|
+
for (const key of [
|
|
2629
|
+
"min",
|
|
2630
|
+
"max",
|
|
2631
|
+
"step"
|
|
2632
|
+
]) {
|
|
2633
|
+
if (meta[key] === void 0 || Number.isFinite(meta[key])) continue;
|
|
2634
|
+
if (!(key === "max" ? meta.max === Infinity : key === "min" && meta.min === -Infinity && !meta.step)) inputLimitation(`non-finite ${key} constraint is omitted; native validation is required`);
|
|
2635
|
+
Reflect.deleteProperty(meta, key);
|
|
2636
|
+
}
|
|
2637
|
+
let core;
|
|
2638
|
+
let dictionaryValidation;
|
|
2639
|
+
switch (node.type) {
|
|
2640
|
+
case "any":
|
|
2641
|
+
core = true;
|
|
2642
|
+
break;
|
|
2643
|
+
case "never":
|
|
2644
|
+
core = false;
|
|
2645
|
+
break;
|
|
2646
|
+
case "const":
|
|
2647
|
+
try {
|
|
2648
|
+
core = node.value == null ? false : constant(jsonValue(node.value, true), () => {
|
|
2649
|
+
inputLimitation("object constant inherited-member comparison requires native validation");
|
|
2650
|
+
});
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
core = true;
|
|
2653
|
+
inputLimitation(`constant constraint requires native validation: ${error instanceof Error ? error.message : String(error)}`);
|
|
2654
|
+
}
|
|
2655
|
+
break;
|
|
2656
|
+
case "boolean":
|
|
2657
|
+
core = { type: "boolean" };
|
|
2658
|
+
break;
|
|
2659
|
+
case "string":
|
|
2660
|
+
core = { type: "string" };
|
|
2661
|
+
if (meta.min !== void 0) if (meta.min <= 1) core.minLength = Math.max(0, Math.ceil(meta.min));
|
|
2662
|
+
else inputLimitation("UTF-16 minimum length requires native validation");
|
|
2663
|
+
if (meta.max !== void 0) if (meta.max < 0) core = false;
|
|
2664
|
+
else {
|
|
2665
|
+
const maximum = Math.floor(meta.max);
|
|
2666
|
+
core.maxLength = maximum;
|
|
2667
|
+
if (maximum > 0) inputLimitation("UTF-16 maximum length requires native validation");
|
|
2668
|
+
}
|
|
2669
|
+
if (meta.pattern && core !== false) if (portablePattern(meta.pattern.source, meta.pattern.flags)) core.pattern = meta.pattern.source;
|
|
2670
|
+
else inputLimitation("regular-expression syntax or Unicode semantics require native validation");
|
|
2671
|
+
break;
|
|
2672
|
+
case "number":
|
|
2673
|
+
core = { type: "number" };
|
|
2674
|
+
if (meta.min !== void 0) core.minimum = meta.min;
|
|
2675
|
+
if (meta.max !== void 0) core.maximum = meta.max;
|
|
2676
|
+
if (meta.step) {
|
|
2677
|
+
const step = Math.abs(meta.step);
|
|
2678
|
+
const origin = meta.min ?? 0;
|
|
2679
|
+
if (!Number.isInteger(step) || origin % step !== 0) inputLimitation("fractional or offset numeric step requires native validation");
|
|
2680
|
+
else if (step === 1) core.type = "integer";
|
|
2681
|
+
else core.multipleOf = step;
|
|
2682
|
+
}
|
|
2683
|
+
break;
|
|
2684
|
+
case "object": {
|
|
2685
|
+
const required = [];
|
|
2686
|
+
core = {
|
|
2687
|
+
type: "object",
|
|
2688
|
+
properties: Object.fromEntries(Object.entries(node.dict ?? {}).map(([key, field]) => {
|
|
2689
|
+
const value = child(field, key);
|
|
2690
|
+
if (value.acceptsMissing === false) required.push(key);
|
|
2691
|
+
return [key, value.schema];
|
|
2692
|
+
}))
|
|
2693
|
+
};
|
|
2694
|
+
if (required.length) core.required = required;
|
|
2695
|
+
break;
|
|
2696
|
+
}
|
|
2697
|
+
case "array":
|
|
2698
|
+
core = {
|
|
2699
|
+
type: "array",
|
|
2700
|
+
items: child(node.inner, "items").schema
|
|
2701
|
+
};
|
|
2702
|
+
if (meta.min !== void 0 && node.inner?.meta.default == null) core.minItems = Math.max(0, Math.ceil(meta.min));
|
|
2703
|
+
if (meta.max !== void 0) if (meta.max < 0) core = false;
|
|
2704
|
+
else core.maxItems = Math.floor(meta.max);
|
|
2705
|
+
break;
|
|
2706
|
+
case "dict": {
|
|
2707
|
+
const valueSchema = child(node.inner, "values").schema;
|
|
2708
|
+
core = {
|
|
2709
|
+
type: "object",
|
|
2710
|
+
additionalProperties: valueSchema
|
|
2711
|
+
};
|
|
2712
|
+
if (node.sKey) {
|
|
2713
|
+
const key = child(node.sKey, "keys");
|
|
2714
|
+
if (!(node.sKey.type === "string" && node.sKey.meta.min === void 0 && node.sKey.meta.max === void 0 && node.sKey.meta.pattern === void 0) && (strict || key.effectful)) {
|
|
2715
|
+
dictionaryValidation = {
|
|
2716
|
+
keySchema: key.schema,
|
|
2717
|
+
valueSchema
|
|
2718
|
+
};
|
|
2719
|
+
core = { type: "object" };
|
|
2720
|
+
inputLimitation(key.effectful ? "dictionary key normalization can overwrite unvalidated values; native validation is required" : "strict dictionary filtering and value validation require native validation");
|
|
2721
|
+
} else core.propertyNames = key.schema;
|
|
2722
|
+
}
|
|
2723
|
+
break;
|
|
2724
|
+
}
|
|
2725
|
+
case "tuple": {
|
|
2726
|
+
if (node.list === void 0) throw new Error(`${path}: tuple schema is missing its list`);
|
|
2727
|
+
const values = node.list.map((item, index) => child(item, String(index)));
|
|
2728
|
+
core = {
|
|
2729
|
+
type: "array",
|
|
2730
|
+
...values.length ? { prefixItems: values.map((item) => item.schema) } : {}
|
|
2731
|
+
};
|
|
2732
|
+
const minimum = values.reduce((length, value, index) => value.acceptsMissing === false ? index + 1 : length, 0);
|
|
2733
|
+
if (minimum) core.minItems = minimum;
|
|
2734
|
+
break;
|
|
2735
|
+
}
|
|
2736
|
+
case "union": {
|
|
2737
|
+
if (node.list === void 0) throw new Error(`${path}: union schema is missing its list`);
|
|
2738
|
+
const variants = node.list.map((item, index) => child(item, String(index), strict));
|
|
2739
|
+
if (variants.slice(0, -1).some((variant) => variant.mutating)) {
|
|
2740
|
+
core = true;
|
|
2741
|
+
inputLimitation("earlier union branches may alter later validation inputs; native validation is required");
|
|
2742
|
+
} else core = variants.length ? { anyOf: variants.map((variant) => variant.schema) } : false;
|
|
2743
|
+
break;
|
|
2744
|
+
}
|
|
2745
|
+
case "transform":
|
|
2746
|
+
core = child(node.inner, "input", true).schema;
|
|
2747
|
+
inputLimitation("transform callback validation and normalization are not executed or projected");
|
|
2748
|
+
break;
|
|
2749
|
+
default:
|
|
2750
|
+
core = true;
|
|
2751
|
+
unsupported = true;
|
|
2752
|
+
inputLimitation(`native schema type ${node.type} is not statically projected`);
|
|
2753
|
+
}
|
|
2754
|
+
if (meta.loose) {
|
|
2755
|
+
core = true;
|
|
2756
|
+
inputLimitation("loose validation can replace invalid values with defaults");
|
|
2757
|
+
}
|
|
2758
|
+
const recursive = children.some((value) => value.recursive);
|
|
2759
|
+
const container = [
|
|
2760
|
+
"object",
|
|
2761
|
+
"array",
|
|
2762
|
+
"dict",
|
|
2763
|
+
"tuple"
|
|
2764
|
+
].includes(node.type);
|
|
2765
|
+
const mutating = unsupported || children.some((value) => value.mutating || container && value.effectful);
|
|
2766
|
+
const exact = inputLimitations.length === 0 && children.every((value) => value.exact);
|
|
2767
|
+
const fallback = meta.default === void 0 ? void 0 : annotation(meta.default, "default");
|
|
2768
|
+
let acceptsMissing;
|
|
2769
|
+
if (meta.required) acceptsMissing = false;
|
|
2770
|
+
else if (unsupported) acceptsMissing = "unknown";
|
|
2771
|
+
else if (meta.default == null) acceptsMissing = true;
|
|
2772
|
+
else if (!fallback?.ok) acceptsMissing = "unknown";
|
|
2773
|
+
else if (recursive) {
|
|
2774
|
+
acceptsMissing = "unknown";
|
|
2775
|
+
inputLimitation("recursive default validation cannot be decided statically");
|
|
2776
|
+
} else if (!exact) acceptsMissing = "unknown";
|
|
2777
|
+
else acceptsMissing = validator.validate(core, fallback.value);
|
|
2778
|
+
const annotations = {};
|
|
2779
|
+
if (fallback?.ok) annotations.default = fallback.value;
|
|
2780
|
+
const native = {};
|
|
2781
|
+
if (typeof meta.description === "string") annotations.description = meta.description;
|
|
2782
|
+
else if (meta.description) {
|
|
2783
|
+
const descriptions = annotation(meta.description, "description");
|
|
2784
|
+
if (descriptions.ok) {
|
|
2785
|
+
native.descriptions = descriptions.value;
|
|
2786
|
+
const description = meta.description.en ?? meta.description[""] ?? Object.values(meta.description)[0];
|
|
2787
|
+
if (typeof description === "string") annotations.description = description;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
if (dictionaryValidation) native.dictionaryValidation = dictionaryValidation;
|
|
2791
|
+
const copy = (name, value) => {
|
|
2792
|
+
if (value === void 0) return;
|
|
2793
|
+
const result = annotation(value, name);
|
|
2794
|
+
if (result.ok) native[name] = result.value;
|
|
2795
|
+
};
|
|
2796
|
+
for (const key of [
|
|
2797
|
+
"role",
|
|
2798
|
+
"extra",
|
|
2799
|
+
"hidden",
|
|
2800
|
+
"disabled",
|
|
2801
|
+
"collapse",
|
|
2802
|
+
"link",
|
|
2803
|
+
"comment",
|
|
2804
|
+
"badges",
|
|
2805
|
+
"loose",
|
|
2806
|
+
"volatile"
|
|
2807
|
+
]) copy(key, meta[key]);
|
|
2808
|
+
if (meta.pattern?.flags) copy("patternFlags", meta.pattern.flags);
|
|
2809
|
+
if (node.type === "union") native.branchSelection = "first-success";
|
|
2810
|
+
if (acceptsMissing === "unknown") native.omissionValidation = "runtime";
|
|
2811
|
+
if (ownLimitations.length) {
|
|
2812
|
+
native.type = node.type;
|
|
2813
|
+
for (const key of [
|
|
2814
|
+
"min",
|
|
2815
|
+
"max",
|
|
2816
|
+
"step",
|
|
2817
|
+
"pattern"
|
|
2818
|
+
]) copy(key, meta[key]);
|
|
2819
|
+
native.limitations = ownLimitations;
|
|
2820
|
+
}
|
|
2821
|
+
if (Object.keys(native).length) annotations["x-cordis"] = native;
|
|
2822
|
+
const input = nullable(core, acceptsMissing !== false);
|
|
2823
|
+
projection = {
|
|
2824
|
+
schema: {
|
|
2825
|
+
...typeof input === "boolean" ? input ? {} : { not: {} } : input,
|
|
2826
|
+
...annotations
|
|
2827
|
+
},
|
|
2828
|
+
acceptsMissing,
|
|
2829
|
+
exact: exact && acceptsMissing !== "unknown",
|
|
2830
|
+
recursive,
|
|
2831
|
+
effectful: unsupported || node.type === "transform" || !!meta.loose || children.some((child) => child.effectful),
|
|
2832
|
+
mutating
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
active.delete(key);
|
|
2836
|
+
const name = recursiveNames.get(key);
|
|
2837
|
+
if (name !== void 0) {
|
|
2838
|
+
definitions[name] = projection.schema;
|
|
2839
|
+
projection = {
|
|
2840
|
+
...projection,
|
|
2841
|
+
schema: { $ref: `#/$defs/${name}` }
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
completed.set(key, projection);
|
|
2845
|
+
return projection;
|
|
2846
|
+
};
|
|
2847
|
+
const result = visit(root, "config");
|
|
2848
|
+
const schema = expressions(result.schema);
|
|
2849
|
+
return {
|
|
2850
|
+
schema: metadataEffects.length ? { anyOf: [schema, {}] } : schema,
|
|
2851
|
+
definitions: Object.fromEntries(Object.entries(definitions).map(([key, value]) => [key, expressions(value, false)])),
|
|
2852
|
+
acceptsMissing: metadataEffects.length ? "unknown" : result.acceptsMissing,
|
|
2853
|
+
limitations: [...new Set(limitations)]
|
|
2854
|
+
};
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
//#endregion
|
|
2858
|
+
//#region lib/types/config-schema/document.js
|
|
2859
|
+
/** Compose Loader entry/patch structure and discovered plugin input schemas into one JSON Schema document. */
|
|
2860
|
+
const ref = (name) => ({ $ref: `#/$defs/${name}` });
|
|
2861
|
+
function metadata() {
|
|
2862
|
+
return {
|
|
2863
|
+
id: {
|
|
2864
|
+
type: "string",
|
|
2865
|
+
description: "Entry id. Loader generates an id when an entry omits it; patches use the configured id."
|
|
2866
|
+
},
|
|
2867
|
+
name: {
|
|
2868
|
+
type: "string",
|
|
2869
|
+
description: "Plugin module specifier. Inserted relative plugin paths are anchored beside their patch file."
|
|
2870
|
+
},
|
|
2871
|
+
config: {},
|
|
2872
|
+
group: {
|
|
2873
|
+
type: ["boolean", "null"],
|
|
2874
|
+
description: "Allows patch indexing and insertion into an entry-list config; does not select the plugin implementation."
|
|
2875
|
+
},
|
|
2876
|
+
disabled: {
|
|
2877
|
+
anyOf: [{ type: ["boolean", "null"] }, ref("loaderExpression")],
|
|
2878
|
+
description: "Boolean or !!js expression. The Loader coerces other truthy values as disabled; this schema rejects them."
|
|
2879
|
+
},
|
|
2880
|
+
inject: { anyOf: [
|
|
2881
|
+
{
|
|
2882
|
+
type: "array",
|
|
2883
|
+
items: { type: "string" }
|
|
2884
|
+
},
|
|
2885
|
+
{ type: "object" },
|
|
2886
|
+
{ type: "null" }
|
|
2887
|
+
] },
|
|
2888
|
+
intercept: { type: ["object", "null"] },
|
|
2889
|
+
isolate: {
|
|
2890
|
+
type: ["object", "null"],
|
|
2891
|
+
additionalProperties: { anyOf: [{ const: true }, { type: "string" }] }
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
function patchStructure() {
|
|
2896
|
+
return {
|
|
2897
|
+
type: "object",
|
|
2898
|
+
allOf: [ref("entryMetadata")],
|
|
2899
|
+
properties: { insert: ref("entryList") },
|
|
2900
|
+
description: "An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped."
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Build a schema for the composed entry list and a separately addressable root-tree patch list.
|
|
2905
|
+
* Unknown plugin names remain open; only collected schemas supply plugin-specific constraints.
|
|
2906
|
+
* @param profile - selected profile name.
|
|
2907
|
+
* @param collected - declarations discovered without mounting plugins, including include descendants.
|
|
2908
|
+
* @param targets - last-id-wins patch targets from the root tree's patch index, excluding include descendants.
|
|
2909
|
+
* @param initialDiagnostics - composition and import diagnostics already collected.
|
|
2910
|
+
* @returns one JSON Schema document with explicit collection and projection annotations.
|
|
2911
|
+
*/
|
|
2912
|
+
async function buildConfigSchemaDocument(profile, collected, targets, initialDiagnostics) {
|
|
2913
|
+
const project = await createConfigProjector();
|
|
2914
|
+
const diagnostics = [...initialDiagnostics];
|
|
2915
|
+
const definitions = {
|
|
2916
|
+
loaderExpression: LOADER_EXPRESSION_SCHEMA,
|
|
2917
|
+
entryMetadata: {
|
|
2918
|
+
type: "object",
|
|
2919
|
+
properties: metadata()
|
|
2920
|
+
},
|
|
2921
|
+
entryList: {
|
|
2922
|
+
type: "array",
|
|
2923
|
+
items: ref("entry")
|
|
2924
|
+
},
|
|
2925
|
+
patchList: {
|
|
2926
|
+
type: "array",
|
|
2927
|
+
items: ref("patch")
|
|
2928
|
+
},
|
|
2929
|
+
unknownConfig: { $comment: "No projected Config schema is available; this is unknown configuration, not a prohibition on fields." },
|
|
2930
|
+
includeConfig: {
|
|
2931
|
+
type: "object",
|
|
2932
|
+
required: ["path"],
|
|
2933
|
+
properties: {
|
|
2934
|
+
path: {
|
|
2935
|
+
type: "string",
|
|
2936
|
+
description: "YAML/JSON filename resolved relative to the owning Loader tree. This field is literal, not a !!js expression."
|
|
2937
|
+
},
|
|
2938
|
+
initial: ref("entryList"),
|
|
2939
|
+
patches: {
|
|
2940
|
+
type: "array",
|
|
2941
|
+
items: ref("includePatch")
|
|
2942
|
+
},
|
|
2943
|
+
enableLogs: { type: "boolean" }
|
|
2944
|
+
},
|
|
2945
|
+
description: "Native Include configuration stays literal. initial is used only when the file is absent. Included files have their own module-resolution base and patch target index."
|
|
2946
|
+
},
|
|
2947
|
+
includePatch: patchStructure()
|
|
2948
|
+
};
|
|
2949
|
+
const entries = [];
|
|
2950
|
+
const outputByEntry = /* @__PURE__ */ new Map();
|
|
2951
|
+
const projections = /* @__PURE__ */ new Map();
|
|
2952
|
+
const trees = new Set(["#/$defs/entryList", "#/$defs/includeConfig"]);
|
|
2953
|
+
const requiredConfigs = new Set(trees);
|
|
2954
|
+
const names = new Map([["cordis:group", new Set(["#/$defs/entryList"])], ["cordis:include", new Set(["#/$defs/includeConfig"])]]);
|
|
2955
|
+
for (const entry of collected) {
|
|
2956
|
+
const { native, ...output } = entry;
|
|
2957
|
+
if (entry.tree) output.configRef = entry.tree === "group" ? "#/$defs/entryList" : "#/$defs/includeConfig";
|
|
2958
|
+
else if (native) {
|
|
2959
|
+
let projection = projections.get(native);
|
|
2960
|
+
if (projection === void 0) {
|
|
2961
|
+
const name = `config${projections.size}`;
|
|
2962
|
+
try {
|
|
2963
|
+
const result = project(native, name);
|
|
2964
|
+
definitions[name] = result.schema;
|
|
2965
|
+
Object.assign(definitions, result.definitions);
|
|
2966
|
+
projection = {
|
|
2967
|
+
reference: `#/$defs/${name}`,
|
|
2968
|
+
required: result.acceptsMissing === false,
|
|
2969
|
+
limitations: result.limitations
|
|
2970
|
+
};
|
|
2971
|
+
projections.set(native, projection);
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
output.status = "error";
|
|
2974
|
+
diagnostics.push({
|
|
2975
|
+
level: "error",
|
|
2976
|
+
path: entry.path,
|
|
2977
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
if (projection) {
|
|
2982
|
+
output.configRef = projection.reference;
|
|
2983
|
+
output.status = projection.limitations.length ? "partial" : "schema";
|
|
2984
|
+
if (projection.required) requiredConfigs.add(projection.reference);
|
|
2985
|
+
for (const message of projection.limitations) diagnostics.push({
|
|
2986
|
+
level: "warning",
|
|
2987
|
+
path: entry.path,
|
|
2988
|
+
message
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
output.configRef ??= "#/$defs/unknownConfig";
|
|
2993
|
+
entries.push(output);
|
|
2994
|
+
outputByEntry.set(entry, output);
|
|
2995
|
+
if (entry.name !== void 0) {
|
|
2996
|
+
const choices = names.get(entry.name) ?? /* @__PURE__ */ new Set();
|
|
2997
|
+
choices.add(output.configRef);
|
|
2998
|
+
names.set(entry.name, choices);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
const dormant = {
|
|
3002
|
+
properties: {
|
|
3003
|
+
disabled: { anyOf: [{ const: true }, ref("loaderExpression")] },
|
|
3004
|
+
group: { not: { const: true } }
|
|
3005
|
+
},
|
|
3006
|
+
required: ["disabled"]
|
|
3007
|
+
};
|
|
3008
|
+
const entryRules = [];
|
|
3009
|
+
for (const [name, choices] of names) {
|
|
3010
|
+
const validated = { properties: { config: { anyOf: [...choices].map((reference) => ({ $ref: reference })) } } };
|
|
3011
|
+
let then = validated;
|
|
3012
|
+
if ([...choices].every((reference) => requiredConfigs.has(reference))) {
|
|
3013
|
+
then = {
|
|
3014
|
+
if: dormant,
|
|
3015
|
+
else: {
|
|
3016
|
+
...validated,
|
|
3017
|
+
required: ["config"]
|
|
3018
|
+
}
|
|
3019
|
+
};
|
|
3020
|
+
if (![...choices].every((reference) => trees.has(reference))) then.then = validated;
|
|
3021
|
+
}
|
|
3022
|
+
entryRules.push({
|
|
3023
|
+
if: {
|
|
3024
|
+
properties: { name: { const: name } },
|
|
3025
|
+
required: ["name"]
|
|
3026
|
+
},
|
|
3027
|
+
then
|
|
3028
|
+
});
|
|
3029
|
+
if (choices.size > 1) diagnostics.push({
|
|
3030
|
+
level: "warning",
|
|
3031
|
+
message: `Plugin ${JSON.stringify(name)} has multiple collected schemas; entry validation accepts their union because module resolution depends on the owning tree.`
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
definitions.entry = {
|
|
3035
|
+
type: "object",
|
|
3036
|
+
required: ["name"],
|
|
3037
|
+
allOf: [ref("entryMetadata"), ...entryRules],
|
|
3038
|
+
description: "Loader entry. Unknown metadata and plugin names are accepted; plugin-specific validation is available only for the names collected in this profile. Distinct resolutions of one name use a union of their schemas."
|
|
3039
|
+
};
|
|
3040
|
+
const patchRules = [];
|
|
3041
|
+
for (const [id, target] of targets) {
|
|
3042
|
+
if (target.name === void 0) continue;
|
|
3043
|
+
const configRef = outputByEntry.get(target)?.configRef ?? "#/$defs/unknownConfig";
|
|
3044
|
+
patchRules.push({
|
|
3045
|
+
if: {
|
|
3046
|
+
required: ["id"],
|
|
3047
|
+
properties: { id: { const: id } },
|
|
3048
|
+
allOf: [{ not: { required: ["insert"] } }, { anyOf: [{ not: { required: ["name"] } }, { properties: { name: { enum: ["", target.name] } } }] }]
|
|
3049
|
+
},
|
|
3050
|
+
then: { properties: { config: { $ref: configRef } } }
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
definitions.patch = {
|
|
3054
|
+
...patchStructure(),
|
|
3055
|
+
allOf: [ref("entryMetadata"), ...patchRules]
|
|
3056
|
+
};
|
|
3057
|
+
const positions = new Map(entries.map((entry, index) => [entry.path, index]));
|
|
3058
|
+
diagnostics.sort((left, right) => (positions.get(left.path ?? "") ?? -1) - (positions.get(right.path ?? "") ?? -1));
|
|
3059
|
+
return {
|
|
3060
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
3061
|
+
title: `Cordis configuration for profile ${profile}`,
|
|
3062
|
+
description: "Describes the parsed entry-list YAML printed by --dump-config. Use $defs.patchList for a profile/home/CLI overlay. Parse !!js with the Cordis entry-list YAML dialect. Expression results, service dependencies, plugin startup checks, and sequence-dependent patch targets still require runtime validation.",
|
|
3063
|
+
$comment: "Bundle, profile, home, and CLI layers apply in that order. A patch config replaces the whole config. JSON Schema defaults are annotations; Schemastery validates a fallback on null/omission unless required rejects it first. Schemastery unions select the first successful branch. Root patch id constraints describe the current index, not ids introduced or changed by earlier patches. Include-local patches have a separate index. Relative plugin insertions require their source patch directory; this document does not infer future overlay locations.",
|
|
3064
|
+
type: "array",
|
|
3065
|
+
items: ref("entry"),
|
|
3066
|
+
$defs: definitions,
|
|
3067
|
+
"x-cordis": {
|
|
3068
|
+
profile,
|
|
3069
|
+
complete: !diagnostics.some((item) => item.level === "error") && !entries.some((entry) => [
|
|
3070
|
+
"partial",
|
|
3071
|
+
"unsupported",
|
|
3072
|
+
"error"
|
|
3073
|
+
].includes(entry.status)) && ![...names.values()].some((choices) => choices.size > 1),
|
|
3074
|
+
entries,
|
|
3075
|
+
diagnostics,
|
|
3076
|
+
patchSchema: "#/$defs/patchList"
|
|
3077
|
+
}
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
//#endregion
|
|
3081
|
+
//#region lib/types/config-schema/collect.js
|
|
3082
|
+
/** Boot-free inspection of declared plugin Config schemas using profile module resolution. */
|
|
3083
|
+
function objectLike(value) {
|
|
3084
|
+
return value !== null && (typeof value === "object" || typeof value === "function");
|
|
3085
|
+
}
|
|
3086
|
+
function record(value) {
|
|
3087
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3088
|
+
}
|
|
3089
|
+
function configOf(plugin) {
|
|
3090
|
+
return objectLike(plugin) ? Reflect.get(plugin, "Config") : void 0;
|
|
3091
|
+
}
|
|
3092
|
+
function validateMetadata(row) {
|
|
3093
|
+
for (const key of ["id", "name"]) {
|
|
3094
|
+
const value = Reflect.get(row, key);
|
|
3095
|
+
if (value !== void 0 && typeof value !== "string") throw new Error(`${key} must be a literal string`);
|
|
3096
|
+
}
|
|
3097
|
+
const group = Reflect.get(row, "group");
|
|
3098
|
+
if (group !== void 0 && group !== null && typeof group !== "boolean") throw new Error("group must be a literal boolean or null");
|
|
3099
|
+
}
|
|
3100
|
+
function validateEntry(value) {
|
|
3101
|
+
if (!record(value) || typeof value.name !== "string") throw new Error("each entry must be a mapping with a literal plugin name");
|
|
3102
|
+
validateMetadata(value);
|
|
3103
|
+
}
|
|
3104
|
+
function entryList(value) {
|
|
3105
|
+
if (value === void 0) throw new Error("entry list config is missing");
|
|
3106
|
+
if (!Array.isArray(value)) throw new Error("expected a literal entry list; config expressions are not evaluated");
|
|
3107
|
+
return value;
|
|
3108
|
+
}
|
|
3109
|
+
function includePatches(value) {
|
|
3110
|
+
if (value === void 0) return void 0;
|
|
3111
|
+
if (!Array.isArray(value)) throw new Error("include patches must be a literal patch list; config expressions are not evaluated");
|
|
3112
|
+
for (const patch of value) {
|
|
3113
|
+
if (!record(patch) || isJsExpr(patch)) throw new Error("include patches must be literal mappings; config expressions are not evaluated");
|
|
3114
|
+
validateMetadata(patch);
|
|
3115
|
+
if (patch.insert !== void 0) entryList(patch.insert);
|
|
3116
|
+
}
|
|
3117
|
+
return value;
|
|
3118
|
+
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Generate JSON Schema from parsed entry rows and root-tree patches without applying plugins or evaluating expressions.
|
|
3121
|
+
* Imports, Config getters, and lazy schema builders execute trusted plugin code; transform callbacks do not. Calls must not overlap
|
|
3122
|
+
* another profile-resolution interception; module imports remain cached after the interception is released.
|
|
3123
|
+
* @param profile - prepared profile whose directory anchors root module and include resolution.
|
|
3124
|
+
* @param entries - unvalidated rows from profile composition; malformed rows become positioned diagnostics without losing siblings.
|
|
3125
|
+
* @param resolution - the same immutable package resolution used for profile boot.
|
|
3126
|
+
* @param diagnostics - existing composition diagnostics; copied into the returned catalog.
|
|
3127
|
+
* @returns a JSON Schema document with partial-result diagnostics and Config references under `x-cordis`.
|
|
3128
|
+
* @throws when Node's profile module resolution cannot be installed.
|
|
3129
|
+
*/
|
|
3130
|
+
async function collectConfigSchemas(profile, entries, resolution, diagnostics = []) {
|
|
3131
|
+
const result = {
|
|
3132
|
+
entries: [],
|
|
3133
|
+
diagnostics: [...diagnostics]
|
|
3134
|
+
};
|
|
3135
|
+
const byOptions = /* @__PURE__ */ new Map();
|
|
3136
|
+
const interception = installRuntimeInterception(resolution);
|
|
3137
|
+
try {
|
|
3138
|
+
const loader = ModuleLoader.fromInternal();
|
|
3139
|
+
if (loader === void 0) throw new Error("config schema dump requires the Node module loader used by profile resolution");
|
|
3140
|
+
const builtins = {
|
|
3141
|
+
group: Group,
|
|
3142
|
+
include: Include
|
|
3143
|
+
};
|
|
3144
|
+
const nativeCarriers = /* @__PURE__ */ new Map();
|
|
3145
|
+
const carrier = async (plugin, baseUrl) => {
|
|
3146
|
+
if (plugin === Group) return "group";
|
|
3147
|
+
if (plugin === Include) return "include";
|
|
3148
|
+
if (!objectLike(plugin) || !Reflect.get(plugin, EntryGroup.key)) return void 0;
|
|
3149
|
+
let resolved = nativeCarriers.get(baseUrl);
|
|
3150
|
+
if (resolved === void 0) {
|
|
3151
|
+
resolved = Promise.allSettled([loader.import("@deepseek-ai/cordis-plugin-group", baseUrl, {}), loader.import("@deepseek-ai/cordis-plugin-include", baseUrl, {})]).then(([group, include]) => {
|
|
3152
|
+
return {
|
|
3153
|
+
group: group.status === "fulfilled" ? Loader.prototype.unwrapExports(group.value) : void 0,
|
|
3154
|
+
include: include.status === "fulfilled" ? Loader.prototype.unwrapExports(include.value) : void 0
|
|
3155
|
+
};
|
|
3156
|
+
});
|
|
3157
|
+
nativeCarriers.set(baseUrl, resolved);
|
|
3158
|
+
}
|
|
3159
|
+
const native = await resolved;
|
|
3160
|
+
if (plugin === native.group) return "group";
|
|
3161
|
+
if (plugin === native.include) return "include";
|
|
3162
|
+
throw new Error("unrecognized Loader tree carrier; use cordis:group or cordis:include for native child collection");
|
|
3163
|
+
};
|
|
3164
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
3165
|
+
const report = (path, error) => {
|
|
3166
|
+
result.diagnostics.push({
|
|
3167
|
+
level: "error",
|
|
3168
|
+
path,
|
|
3169
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3170
|
+
});
|
|
3171
|
+
};
|
|
3172
|
+
const warn = (path) => (message, ...args) => {
|
|
3173
|
+
let index = 0;
|
|
3174
|
+
result.diagnostics.push({
|
|
3175
|
+
level: "warning",
|
|
3176
|
+
path,
|
|
3177
|
+
message: message.replace(/%C/g, () => JSON.stringify(args[index++]))
|
|
3178
|
+
});
|
|
3179
|
+
};
|
|
3180
|
+
const walkInclude = async (config, baseUrl, path) => {
|
|
3181
|
+
if (config === void 0) throw new Error("include config is missing");
|
|
3182
|
+
if (!record(config) || isJsExpr(config)) throw new Error("include config must be literal; config expressions are not evaluated");
|
|
3183
|
+
if (typeof config.path !== "string") throw new Error("include path must be literal; config expressions are not evaluated");
|
|
3184
|
+
const filename = fileURLToPath(new URL(config.path, baseUrl));
|
|
3185
|
+
const extension = extname(filename);
|
|
3186
|
+
if (![
|
|
3187
|
+
".json",
|
|
3188
|
+
".yaml",
|
|
3189
|
+
".yml"
|
|
3190
|
+
].includes(extension)) throw new Error(`include extension ${JSON.stringify(extension)} is not supported`);
|
|
3191
|
+
let source;
|
|
3192
|
+
let canonical;
|
|
3193
|
+
try {
|
|
3194
|
+
canonical = await realpath(filename);
|
|
3195
|
+
} catch (error) {
|
|
3196
|
+
if (error.code !== "ENOENT") throw error;
|
|
3197
|
+
canonical = filename;
|
|
3198
|
+
}
|
|
3199
|
+
if (ancestors.has(canonical)) throw new Error(`include cycle at ${filename}`);
|
|
3200
|
+
let content;
|
|
3201
|
+
try {
|
|
3202
|
+
content = await readFile(filename, "utf8");
|
|
3203
|
+
} catch (error) {
|
|
3204
|
+
if (error.code !== "ENOENT") throw error;
|
|
3205
|
+
source = config.initial;
|
|
3206
|
+
if (source === void 0) throw new Error(`include file not found: ${filename}`);
|
|
3207
|
+
}
|
|
3208
|
+
if (content !== void 0) try {
|
|
3209
|
+
source = extension === ".json" ? JSON.parse(content) : yaml.load(content, { schema: entryListSchema });
|
|
3210
|
+
} catch (error) {
|
|
3211
|
+
const at = error instanceof yaml.YAMLException ? ` at line ${error.mark.line + 1}, column ${error.mark.column + 1}` : "";
|
|
3212
|
+
throw new Error(`invalid ${extension === ".json" ? "JSON" : "YAML"} include ${filename}${at}`);
|
|
3213
|
+
}
|
|
3214
|
+
const patches = includePatches(config.patches);
|
|
3215
|
+
const rows = entryList(source);
|
|
3216
|
+
let children;
|
|
3217
|
+
try {
|
|
3218
|
+
children = applyEntryPatches(rows, patches, warn(path));
|
|
3219
|
+
} catch (error) {
|
|
3220
|
+
throw new Error(`include patches could not be composed for ${filename}; child declarations are unavailable`, { cause: error });
|
|
3221
|
+
}
|
|
3222
|
+
ancestors.add(canonical);
|
|
3223
|
+
try {
|
|
3224
|
+
await walk(children, new URL(".", pathToFileURL(filename)).href, `${path}/include`);
|
|
3225
|
+
} finally {
|
|
3226
|
+
ancestors.delete(canonical);
|
|
3227
|
+
}
|
|
3228
|
+
};
|
|
3229
|
+
const walk = async (rows, baseUrl, prefix) => {
|
|
3230
|
+
for (const [index, value] of rows.entries()) {
|
|
3231
|
+
const path = `${prefix}/${index}`;
|
|
3232
|
+
const identity = record(value) ? value : void 0;
|
|
3233
|
+
const entry = {
|
|
3234
|
+
path,
|
|
3235
|
+
...typeof identity?.id === "string" ? { id: identity.id } : {},
|
|
3236
|
+
...typeof identity?.name === "string" ? { name: identity.name } : {},
|
|
3237
|
+
status: "error"
|
|
3238
|
+
};
|
|
3239
|
+
result.entries.push(entry);
|
|
3240
|
+
const row = value;
|
|
3241
|
+
try {
|
|
3242
|
+
validateEntry(row);
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
report(path, error);
|
|
3245
|
+
continue;
|
|
3246
|
+
}
|
|
3247
|
+
entry.status = "absent";
|
|
3248
|
+
byOptions.set(row, entry);
|
|
3249
|
+
let plugin;
|
|
3250
|
+
try {
|
|
3251
|
+
let exports;
|
|
3252
|
+
if (row.name.startsWith("cordis:")) {
|
|
3253
|
+
const name = row.name.slice(7);
|
|
3254
|
+
if (!Object.hasOwn(builtins, name)) throw new Error(`unknown Cordis builtin ${JSON.stringify(row.name)}`);
|
|
3255
|
+
exports = builtins[name];
|
|
3256
|
+
} else exports = await loader.import(row.name, baseUrl, {});
|
|
3257
|
+
plugin = Loader.prototype.unwrapExports(exports);
|
|
3258
|
+
const schema = configOf(plugin);
|
|
3259
|
+
if (schema !== void 0 && schema !== null) if (!isNativeConfigSchema(schema)) {
|
|
3260
|
+
entry.status = "unsupported";
|
|
3261
|
+
report(path, "Config is not a native Schemastery schema");
|
|
3262
|
+
} else {
|
|
3263
|
+
entry.status = "schema";
|
|
3264
|
+
entry.native = schema;
|
|
3265
|
+
}
|
|
3266
|
+
} catch (error) {
|
|
3267
|
+
entry.status = "error";
|
|
3268
|
+
report(path, error);
|
|
3269
|
+
}
|
|
3270
|
+
try {
|
|
3271
|
+
const kind = await carrier(plugin, baseUrl);
|
|
3272
|
+
if (kind === void 0) continue;
|
|
3273
|
+
entry.tree = kind;
|
|
3274
|
+
if (row.config === void 0 && row.group !== true && (row.disabled === true || isJsExpr(row.disabled))) continue;
|
|
3275
|
+
if (kind === "group") await walk(entryList(row.config), baseUrl, `${path}/config`);
|
|
3276
|
+
else await walkInclude(row.config, baseUrl, path);
|
|
3277
|
+
} catch (error) {
|
|
3278
|
+
report(path, error);
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
await walk(entries, pathToFileURL(join(profile.dir, "cordis.yml")).href, "");
|
|
3283
|
+
const targets = /* @__PURE__ */ new Map();
|
|
3284
|
+
const indexTargets = (rows) => {
|
|
3285
|
+
for (const row of rows) {
|
|
3286
|
+
if (!record(row)) continue;
|
|
3287
|
+
if (typeof row.id === "string" && row.id) {
|
|
3288
|
+
const entry = byOptions.get(row);
|
|
3289
|
+
if (entry) targets.set(row.id, entry);
|
|
3290
|
+
else targets.delete(row.id);
|
|
3291
|
+
}
|
|
3292
|
+
if (row.group && Array.isArray(row.config)) indexTargets(row.config);
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
3295
|
+
indexTargets(entries);
|
|
3296
|
+
return await buildConfigSchemaDocument(profile.name, result.entries, targets, result.diagnostics);
|
|
3297
|
+
} finally {
|
|
3298
|
+
interception.dispose();
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
//#endregion
|
|
3302
|
+
//#region lib/types/config-schema/index.js
|
|
3303
|
+
/** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
|
|
3304
|
+
/**
|
|
3305
|
+
* Generate JSON Schema for a prepared profile's ordered patch layers without mounting plugins or evaluating expressions.
|
|
3306
|
+
* Imports, Config getters, and lazy builders execute trusted code. Native validators and
|
|
3307
|
+
* transform callbacks are not executed. Calls must not overlap another profile-resolution interception; collection
|
|
3308
|
+
* releases its interception on success or rejection, while Node retains imported modules. Supplied layers are not mutated.
|
|
3309
|
+
* Profile preparation, layer selection, process streams, and exit policy belong to the caller.
|
|
3310
|
+
* @param profile - prepared on-disk profile whose directory anchors root module and include resolution.
|
|
3311
|
+
* @param layers - already parsed patch lists in application order, including caller-selected home and argv overlays.
|
|
3312
|
+
* @param installAnchor - package manifest anchoring the installation's runtime dependencies.
|
|
3313
|
+
* @returns a JSON Schema document with declaration references, partial results, and diagnostics under `x-cordis`.
|
|
3314
|
+
* @throws when composition or runtime resolution cannot be prepared.
|
|
3315
|
+
*/
|
|
3316
|
+
async function generateConfigSchema(profile, layers, installAnchor) {
|
|
3317
|
+
const diagnostics = [];
|
|
3318
|
+
for (const { packageName } of profile.skippedBundles) diagnostics.push({
|
|
3319
|
+
level: "error",
|
|
3320
|
+
message: `Selected profile bundle ${JSON.stringify(packageName)} could not be loaded; repair or remove its bundle selection.`
|
|
3321
|
+
});
|
|
3322
|
+
return collectConfigSchemas(profile, composeEntries(layers, (message) => diagnostics.push({
|
|
3323
|
+
level: "warning",
|
|
3324
|
+
message
|
|
3325
|
+
})), await createRuntimeResolution({
|
|
3326
|
+
installAnchor,
|
|
3327
|
+
profile
|
|
3328
|
+
}), diagnostics);
|
|
3329
|
+
}
|
|
3330
|
+
//#endregion
|
|
3331
|
+
//#region lib/types/profile-resolution/service.js
|
|
3332
|
+
/** Package metadata resolved through one runtime interception. */
|
|
3333
|
+
function readPackage(dir, fallbackName) {
|
|
3334
|
+
const manifestPath = join(dir, "package.json");
|
|
3335
|
+
if (!existsSync(manifestPath)) return void 0;
|
|
3336
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
3337
|
+
const name = manifest.name;
|
|
3338
|
+
const version = manifest.version;
|
|
3339
|
+
return {
|
|
3340
|
+
name: typeof name === "string" ? name : fallbackName,
|
|
3341
|
+
version: typeof version === "string" ? version : void 0,
|
|
3342
|
+
dir,
|
|
3343
|
+
manifestPath,
|
|
3344
|
+
manifest
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
/** Package lookup shared by metadata consumers in one profile process. */
|
|
3348
|
+
var PluginPackages = class extends Service {
|
|
3349
|
+
packages = /* @__PURE__ */ new Map();
|
|
3350
|
+
interception;
|
|
3351
|
+
disposeWorkerResolution;
|
|
3352
|
+
current;
|
|
3353
|
+
constructor(ctx, config = {}) {
|
|
3354
|
+
super(ctx, "pluginPackages");
|
|
3355
|
+
if (config.resolution === void 0) return;
|
|
3356
|
+
this.current = config.resolution;
|
|
3357
|
+
const interception = installRuntimeInterception(config.resolution);
|
|
3358
|
+
this.disposeWorkerResolution = registerWorkerResolution(config.resolution);
|
|
3359
|
+
this.interception = interception;
|
|
3360
|
+
ctx.effect(() => () => {
|
|
3361
|
+
this.disposeWorkerResolution?.();
|
|
3362
|
+
interception.dispose();
|
|
3363
|
+
}, "profile package resolution");
|
|
3364
|
+
}
|
|
3365
|
+
/**
|
|
3366
|
+
* Publish a complete successor generation for this process and subsequently created Workers.
|
|
3367
|
+
* Profile mappings and local package names may be removed after their plugins stop; linked roots may also be removed.
|
|
3368
|
+
* Retained profile mappings may change their declarer, but not their normalized directory, version, or scope;
|
|
3369
|
+
* installation mappings must remain unchanged. Publication does not unload modules or clear Node caches.
|
|
3370
|
+
* @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}.
|
|
3371
|
+
*/
|
|
3372
|
+
replace(successor) {
|
|
3373
|
+
if (this.interception === void 0) throw new Error("plugin-packages: runtime resolution is not installed");
|
|
3374
|
+
this.interception.replace(successor);
|
|
3375
|
+
this.current = successor;
|
|
3376
|
+
this.packages = /* @__PURE__ */ new Map();
|
|
3377
|
+
this.disposeWorkerResolution?.();
|
|
3378
|
+
this.disposeWorkerResolution = registerWorkerResolution(successor);
|
|
3379
|
+
}
|
|
3380
|
+
/**
|
|
3381
|
+
* Publish the latest generation computed by the installed resolution through {@link replace}. Package contents
|
|
3382
|
+
* and loaded modules are not reloaded.
|
|
3383
|
+
* @throws when no resolution is installed, it is plain data rather than a {@link ProfileRuntimeResolution},
|
|
3384
|
+
* reading the latest files fails, or the successor is rejected. A computed resolution without a profile can refresh.
|
|
3385
|
+
*/
|
|
3386
|
+
async refresh() {
|
|
3387
|
+
if (!(this.current instanceof ProfileRuntimeResolution)) throw new Error("plugin-packages: the installed runtime resolution cannot be recomputed");
|
|
3388
|
+
this.replace(await this.current.computeLatestResolution());
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Locate the package named by a specifier without requiring a package export.
|
|
3392
|
+
* @param specifier - module specifier whose package owns the requested module.
|
|
3393
|
+
* @param parentURL - URL whose Node lookup order applies.
|
|
3394
|
+
* @returns the parsed package, or undefined when no package owns the request.
|
|
3395
|
+
*/
|
|
3396
|
+
packageOf(specifier, parentURL) {
|
|
3397
|
+
const name = barePackageName(specifier);
|
|
3398
|
+
if (name === void 0) return void 0;
|
|
3399
|
+
const dir = this.interception === void 0 ? packageDirFromParent(name, parentURL) : this.interception.packageDir(name, parentURL);
|
|
3400
|
+
if (dir === void 0) return void 0;
|
|
3401
|
+
const key = JSON.stringify({
|
|
3402
|
+
dir,
|
|
3403
|
+
name
|
|
3404
|
+
});
|
|
3405
|
+
if (!this.packages.has(key)) this.packages.set(key, readPackage(dir, name));
|
|
3406
|
+
return this.packages.get(key);
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* Read display metadata without loading or activating the target plugin.
|
|
3410
|
+
* @param specifier - configured package module, including package subpaths.
|
|
3411
|
+
* @param parentURL - owning Loader tree's resolution base.
|
|
3412
|
+
* @returns local display metadata or its diagnostic; undefined for non-package requests or absent metadata.
|
|
3413
|
+
*/
|
|
3414
|
+
metaOf(specifier, parentURL) {
|
|
3415
|
+
return readPluginMeta(specifier, parentURL);
|
|
3416
|
+
}
|
|
3417
|
+
};
|
|
3418
|
+
function packageDirFromParent(name, parentURL) {
|
|
3419
|
+
for (const searchPath of createRequire(parentURL).resolve.paths(name)) {
|
|
3420
|
+
const candidate = join(searchPath, name);
|
|
3421
|
+
if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
//#endregion
|
|
3425
|
+
//#region lib/types/index.js
|
|
3426
|
+
/**
|
|
3427
|
+
* Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored
|
|
3428
|
+
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
|
3429
|
+
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
|
|
3430
|
+
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
|
|
3431
|
+
* @module @qlzt/dsh-app-boot
|
|
3432
|
+
*/
|
|
3433
|
+
/**
|
|
3434
|
+
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
|
3435
|
+
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
|
3436
|
+
* @param configPath - the requested config path (absolute, or relative to `cwd`).
|
|
3437
|
+
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
|
|
3438
|
+
* basename.
|
|
3439
|
+
* @param cwd - the base a relative `configPath` resolves against.
|
|
3440
|
+
* @returns the absolute path of the config to boot.
|
|
3441
|
+
*/
|
|
3442
|
+
function resolveConfigPath(configPath, snapshotMode, cwd = process.cwd()) {
|
|
3443
|
+
const absolute = resolve(cwd, configPath);
|
|
3444
|
+
if (snapshotMode !== "replay") return absolute;
|
|
3445
|
+
return resolve(dirname(absolute), basename(absolute).replace(/cordis\.ya?ml$/, "cordis.snapshot.yml"));
|
|
3446
|
+
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
|
3449
|
+
* ambient environment; other read failures are reported through `warn`.
|
|
3450
|
+
* @param binName - the diagnostic prefix on the warn line.
|
|
3451
|
+
* @param dir - the directory whose `.env` to load.
|
|
3452
|
+
* @param warn - sink for the one-line misconfiguration diagnostic.
|
|
3453
|
+
*/
|
|
3454
|
+
function loadEnv(binName, dir = process.cwd(), warn = (line) => void process.stderr.write(line)) {
|
|
3455
|
+
try {
|
|
3456
|
+
process.loadEnvFile(resolve(dir, ".env"));
|
|
3457
|
+
} catch (error) {
|
|
3458
|
+
if (error?.code !== "ENOENT") warn(`${binName}: failed to load .env: ${String(error)}\n`);
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
/** Exact names no discovered file may set. */
|
|
3462
|
+
const BOOTSTRAP_NAMES = new Set([
|
|
3463
|
+
"PATH",
|
|
3464
|
+
"HOME",
|
|
3465
|
+
"USERPROFILE",
|
|
3466
|
+
"SHELL",
|
|
3467
|
+
"NODE_OPTIONS",
|
|
3468
|
+
"NODE_PATH",
|
|
3469
|
+
"NODE_EXTRA_CA_CERTS",
|
|
3470
|
+
"LD_PRELOAD",
|
|
3471
|
+
"LD_LIBRARY_PATH",
|
|
3472
|
+
"LD_AUDIT",
|
|
3473
|
+
"BASH_ENV",
|
|
3474
|
+
"ENV",
|
|
3475
|
+
"SHELLOPTS",
|
|
3476
|
+
"BASHOPTS",
|
|
3477
|
+
"PERL5OPT",
|
|
3478
|
+
"PERL5LIB",
|
|
3479
|
+
"PYTHONSTARTUP",
|
|
3480
|
+
"PYTHONPATH",
|
|
3481
|
+
"RUBYOPT",
|
|
3482
|
+
"RUBYLIB",
|
|
3483
|
+
"JAVA_TOOL_OPTIONS",
|
|
3484
|
+
"_JAVA_OPTIONS",
|
|
3485
|
+
"JDK_JAVA_OPTIONS",
|
|
3486
|
+
"PYTHONHOME",
|
|
3487
|
+
"GIT_SSH",
|
|
3488
|
+
"GIT_SSH_COMMAND",
|
|
3489
|
+
"GIT_EXTERNAL_DIFF",
|
|
3490
|
+
"GIT_PAGER",
|
|
3491
|
+
"GIT_EDITOR",
|
|
3492
|
+
"GIT_ASKPASS",
|
|
3493
|
+
"SSH_ASKPASS",
|
|
3494
|
+
"GIT_CONFIG_GLOBAL",
|
|
3495
|
+
"GIT_CONFIG_SYSTEM",
|
|
3496
|
+
"GIT_CONFIG_COUNT",
|
|
3497
|
+
"EDITOR",
|
|
3498
|
+
"VISUAL",
|
|
3499
|
+
"PAGER",
|
|
3500
|
+
"BROWSER",
|
|
3501
|
+
"DEEPSEEK_BASE_URL",
|
|
3502
|
+
"DEEPSEEK_SEARCH_BASE_URL",
|
|
3503
|
+
"SSL_CERT_FILE",
|
|
3504
|
+
"SSL_CERT_DIR",
|
|
3505
|
+
"HTTP_PROXY",
|
|
3506
|
+
"HTTPS_PROXY",
|
|
3507
|
+
"ALL_PROXY",
|
|
3508
|
+
"NO_PROXY",
|
|
3509
|
+
"REQUESTS_CA_BUNDLE",
|
|
3510
|
+
"CURL_CA_BUNDLE",
|
|
3511
|
+
"NODE_TLS_REJECT_UNAUTHORIZED"
|
|
3512
|
+
]);
|
|
3513
|
+
/** Name prefixes no discovered file may set. */
|
|
3514
|
+
const BOOTSTRAP_PREFIXES = [
|
|
3515
|
+
"DSH_",
|
|
3516
|
+
"XDG_",
|
|
3517
|
+
"DYLD_",
|
|
3518
|
+
"BASH_FUNC_"
|
|
3519
|
+
];
|
|
3520
|
+
/**
|
|
3521
|
+
* The bootstrap names the Harness-home `.env` alone may set. A proxy chooses the route every
|
|
3522
|
+
* request takes, so the invoking directory's file — which arrives with a clone — keeps refusing
|
|
3523
|
+
* them; the home file is the user's own, and `DSH_HOME` is itself bootstrap-only, so no `.env` can
|
|
3524
|
+
* relocate this exemption. The CA and TLS names in the same group stay refused everywhere: they
|
|
3525
|
+
* change what is trusted, not where traffic goes.
|
|
3526
|
+
*/
|
|
3527
|
+
const HOME_LAYER_PROXY_NAMES = new Set([
|
|
3528
|
+
"HTTP_PROXY",
|
|
3529
|
+
"HTTPS_PROXY",
|
|
3530
|
+
"ALL_PROXY",
|
|
3531
|
+
"NO_PROXY"
|
|
3532
|
+
]);
|
|
3533
|
+
/**
|
|
3534
|
+
* Whether a variable may come only from the inherited process environment
|
|
3535
|
+
* because it changes process, runtime, VCS, or network bootstrap. The Harness-home
|
|
3536
|
+
* file is additionally allowed {@link HOME_LAYER_PROXY_NAMES}.
|
|
3537
|
+
* @param name - the variable name.
|
|
3538
|
+
* @returns true when only the inherited environment may supply it.
|
|
3539
|
+
*/
|
|
3540
|
+
function isBootstrapOnly(name) {
|
|
3541
|
+
const upper = name.toUpperCase();
|
|
3542
|
+
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
|
3543
|
+
}
|
|
3544
|
+
/**
|
|
3545
|
+
* Parse one directory's `.env` without applying it, rejecting bootstrap-only
|
|
3546
|
+
* names before any value is materialized.
|
|
3547
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
3548
|
+
* @param dir - the directory whose `.env` to read.
|
|
3549
|
+
* @param warn - sink for the one-line unreadable-file diagnostic.
|
|
3550
|
+
* @param home - the resolved Harness home; when `dir` is it, {@link HOME_LAYER_PROXY_NAMES} are accepted.
|
|
3551
|
+
* @returns the parsed entries, or `undefined` when the file is absent or unreadable.
|
|
3552
|
+
* @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set.
|
|
3553
|
+
*/
|
|
3554
|
+
function readEnvLayer(binName, dir, warn, home) {
|
|
3555
|
+
const path = resolve(dir, ".env");
|
|
3556
|
+
const isHome = resolve(dir) === home;
|
|
3557
|
+
let content;
|
|
3558
|
+
try {
|
|
3559
|
+
content = readFileSync(path, "utf8");
|
|
3560
|
+
} catch (error) {
|
|
3561
|
+
if (error?.code !== "ENOENT") warn(`${binName}: failed to load .env: ${String(error)}\n`);
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3564
|
+
const values = parseEnv(content);
|
|
3565
|
+
for (const name of Object.keys(values)) {
|
|
3566
|
+
if (!isBootstrapOnly(name)) continue;
|
|
3567
|
+
const proxyName = HOME_LAYER_PROXY_NAMES.has(name.toUpperCase());
|
|
3568
|
+
if (isHome && proxyName) continue;
|
|
3569
|
+
const remedy = proxyName ? `export ${name}, or put it in ${resolve(home, ".env")}, which does not travel with a repository` : `export ${name} instead of putting it in a .env file`;
|
|
3570
|
+
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); ${remedy}`);
|
|
3571
|
+
}
|
|
3572
|
+
return {
|
|
3573
|
+
path,
|
|
3574
|
+
values
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
/**
|
|
3578
|
+
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
|
|
3579
|
+
* `.env` snapshot. The Harness home resolves before either file; both files
|
|
3580
|
+
* are checked before either is applied, and accepted values are materialized
|
|
3581
|
+
* without replacing inherited ones. The snapshot preserves which layer supplied each value.
|
|
3582
|
+
* @param binName - the diagnostic prefix on the diagnostics.
|
|
3583
|
+
* @param cwd - the invoking directory whose `.env` is the project layer.
|
|
3584
|
+
* @param warn - sink for the one-line misconfiguration diagnostics.
|
|
3585
|
+
* @returns this run's frozen environment snapshot.
|
|
3586
|
+
* @throws when either file declares a bootstrap-only variable, except {@link HOME_LAYER_PROXY_NAMES} in the Harness-home file.
|
|
3587
|
+
*/
|
|
3588
|
+
function loadLayeredEnv(binName, cwd = process.cwd(), warn = (line) => void process.stderr.write(line)) {
|
|
3589
|
+
const home = resolveDshHome();
|
|
3590
|
+
const inherited = { ...process.env };
|
|
3591
|
+
const project = readEnvLayer(binName, cwd, warn, home);
|
|
3592
|
+
const user = home === resolve(cwd) ? void 0 : readEnvLayer(binName, home, warn, home);
|
|
3593
|
+
for (const layer of [project, user]) {
|
|
3594
|
+
if (layer === void 0) continue;
|
|
3595
|
+
for (const [name, value] of Object.entries(layer.values)) if (process.env[name] === void 0) process.env[name] = value;
|
|
3596
|
+
}
|
|
3597
|
+
return createLaunchEnvironmentSnapshot([
|
|
3598
|
+
{
|
|
3599
|
+
source: "process",
|
|
3600
|
+
values: inherited
|
|
3601
|
+
},
|
|
3602
|
+
...project === void 0 ? [] : [{
|
|
3603
|
+
source: "project-env",
|
|
3604
|
+
path: project.path,
|
|
3605
|
+
values: project.values
|
|
3606
|
+
}],
|
|
3607
|
+
...user === void 0 ? [] : [{
|
|
3608
|
+
source: "user-env",
|
|
3609
|
+
path: user.path,
|
|
3610
|
+
values: user.values
|
|
3611
|
+
}]
|
|
3612
|
+
]);
|
|
3613
|
+
}
|
|
3614
|
+
const bootstrapIncludes = /* @__PURE__ */ new WeakMap();
|
|
3615
|
+
const userPatchesSchema = entryListSchema;
|
|
3616
|
+
/** Apply one complete patch generation and wait for Loader activation diagnostics.
|
|
3617
|
+
* @param ctx Booted root context.
|
|
3618
|
+
* @param patches Complete ordered patch list.
|
|
3619
|
+
* @param binName Diagnostic prefix.
|
|
3620
|
+
* @param requiredIds Explicit enablement targets whose existing failures also reject reconciliation.
|
|
3621
|
+
* @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject.
|
|
3622
|
+
*/
|
|
3623
|
+
async function reconcileProfilePatches(ctx, patches, binName, requiredIds = []) {
|
|
3624
|
+
const entry = bootstrapIncludes.get(ctx);
|
|
3625
|
+
if (entry === void 0) throw new Error(`${binName}: profile reload requires the root Include entry`);
|
|
3626
|
+
const previousFailures = (await inactiveEntries(ctx)).map((failure) => ({
|
|
3627
|
+
...failure,
|
|
3628
|
+
diagnostic: inactiveDiagnostic(failure),
|
|
3629
|
+
fiber: failure.entry.fiber,
|
|
3630
|
+
options: JSON.stringify(failure.entry.options)
|
|
3631
|
+
}));
|
|
3632
|
+
const previousFibers = [...ctx.loader.entries()].flatMap((row) => row.fiber === void 0 ? [] : [{
|
|
3633
|
+
fiber: row.fiber,
|
|
3634
|
+
failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED
|
|
3635
|
+
}]);
|
|
3636
|
+
const { patches: _previous, ...includeConfig } = entry.options.config;
|
|
3637
|
+
const parentURL = new URL(".", new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href;
|
|
3638
|
+
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName);
|
|
3639
|
+
await entry.update({ config: {
|
|
3640
|
+
...includeConfig,
|
|
3641
|
+
patches: prepared
|
|
3642
|
+
} });
|
|
3643
|
+
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()));
|
|
3644
|
+
await ctx.loader.await();
|
|
3645
|
+
const failures = await inactiveEntries(ctx);
|
|
3646
|
+
const introduced = failures.filter((failure) => requiredIds.includes(failure.entry.options.id) || !previousFailures.some((previous) => previous.entry === failure.entry && previous.fiber === failure.entry.fiber && previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === inactiveDiagnostic(failure)));
|
|
3647
|
+
if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced).trimEnd());
|
|
3648
|
+
for (const [index, result] of results.entries()) if (result.status === "rejected" && !previousFibers[index]?.failed) throw result.reason;
|
|
3649
|
+
ctx.emit("app-boot/config-reload");
|
|
3650
|
+
return failures.map(inactiveDiagnostic);
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* Load an optional patch-list file: a top-level YAML array of loader patch
|
|
3654
|
+
* entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config
|
|
3655
|
+
* overrides and `insert` lists, with `!!js` expressions allowed. A missing
|
|
3656
|
+
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
|
|
3657
|
+
* a present patch file that cannot apply is a misconfiguration and must fail
|
|
3658
|
+
* loud at boot, never be silently skipped.
|
|
3659
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
3660
|
+
* @param file - absolute path of the patch file.
|
|
3661
|
+
* @returns the parsed patches, or `undefined` when the file does not exist.
|
|
3662
|
+
*/
|
|
3663
|
+
function loadOptionalPatches(binName, file) {
|
|
3664
|
+
let content;
|
|
3665
|
+
try {
|
|
3666
|
+
content = readFileSync(file, "utf8");
|
|
3667
|
+
} catch (error) {
|
|
3668
|
+
if (error?.code === "ENOENT") return void 0;
|
|
3669
|
+
throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`);
|
|
3670
|
+
}
|
|
3671
|
+
return parsePatchList(binName, file, content, "patches");
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
|
|
3675
|
+
* `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
|
|
3676
|
+
* but a missing file throws, because the caller named this file — its absence
|
|
3677
|
+
* is a misconfiguration, not "no overlay".
|
|
3678
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
3679
|
+
* @param file - absolute path of the overlay file.
|
|
3680
|
+
* @returns the parsed patch list.
|
|
3681
|
+
*/
|
|
3682
|
+
function loadOverlayPatches(binName, file) {
|
|
3683
|
+
let content;
|
|
3684
|
+
try {
|
|
3685
|
+
content = readFileSync(file, "utf8");
|
|
3686
|
+
} catch (error) {
|
|
3687
|
+
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`);
|
|
3688
|
+
}
|
|
3689
|
+
return parsePatchList(binName, file, content, "overlay");
|
|
3690
|
+
}
|
|
3691
|
+
/** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
|
|
3692
|
+
function anchorInsertedPluginNames(patches, file) {
|
|
3693
|
+
const base = dirname(resolve(file));
|
|
3694
|
+
const visit = (entry) => {
|
|
3695
|
+
if (typeof entry.name === "string" && (isAbsolute(entry.name) || entry.name.startsWith("./") || entry.name.startsWith("../"))) entry.name = pathToFileURL(resolve(base, entry.name)).href;
|
|
3696
|
+
if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit);
|
|
3697
|
+
};
|
|
3698
|
+
for (const patch of patches) patch.insert?.forEach(visit);
|
|
3699
|
+
return patches;
|
|
3700
|
+
}
|
|
3701
|
+
/**
|
|
3702
|
+
* Parse one loader patch list: a top-level YAML array of
|
|
3703
|
+
* `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
|
|
3704
|
+
* `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
|
|
3705
|
+
* because a patch file that cannot be applied at all is a misconfiguration; a
|
|
3706
|
+
* single patch whose target row is absent stays a per-entry Loader warning, so
|
|
3707
|
+
* one overlay shared across surfaces does not have to match every tree.
|
|
3708
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
3709
|
+
* @param file - the source path, quoted in errors.
|
|
3710
|
+
* @param content - the file's text.
|
|
3711
|
+
* @param label - what to call this list in errors (`patches`, `overlay`).
|
|
3712
|
+
* @returns the parsed patch list.
|
|
3713
|
+
*/
|
|
3714
|
+
function parsePatchList(binName, file, content, label) {
|
|
3715
|
+
let parsed;
|
|
3716
|
+
try {
|
|
3717
|
+
parsed = yaml.load(content, { schema: userPatchesSchema });
|
|
3718
|
+
} catch (error) {
|
|
3719
|
+
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`);
|
|
3720
|
+
}
|
|
3721
|
+
if (!Array.isArray(parsed)) throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`);
|
|
3722
|
+
parsed.forEach((entry, index) => {
|
|
3723
|
+
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)`);
|
|
3724
|
+
});
|
|
3725
|
+
return anchorInsertedPluginNames(parsed, file);
|
|
3726
|
+
}
|
|
3727
|
+
/**
|
|
3728
|
+
* Compose the configured entry list: parse the base config file with the
|
|
3729
|
+
* include's entry-list dialect, apply every layer's patches as ONE flattened
|
|
3730
|
+
* list through the include's own patch algorithm (`applyEntryPatches`) — the
|
|
3731
|
+
* same call `boot()` makes, so even patch-visibility corner cases (a later
|
|
3732
|
+
* layer targeting a group child a plain config replacement introduced, which
|
|
3733
|
+
* the single-pass id index never sees) compose identically — then render the
|
|
3734
|
+
* result as YAML in the same dialect (`!!js` expressions print verbatim,
|
|
3735
|
+
* unevaluated). Row admission is a later stage: a plugin row the compatibility
|
|
3736
|
+
* policy denies still appears here, while a denied bundle contributes no layer.
|
|
3737
|
+
*
|
|
3738
|
+
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
|
|
3739
|
+
* naming the file that contributed the rows and any layers that patched them,
|
|
3740
|
+
* so the output stays a loadable YAML document while showing which section
|
|
3741
|
+
* comes from which file. The file and patch labels are derived from single-call prefix
|
|
3742
|
+
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
|
|
3743
|
+
* only rewrites rows in place or appends, so a top-level index identifies one
|
|
3744
|
+
* row across snapshots, and a layer whose addition changes the row (config
|
|
3745
|
+
* replacement, disable, group insert) is listed as having patched it.
|
|
3746
|
+
*
|
|
3747
|
+
* A patch that matches no row is reported through `warn` with its layer
|
|
3748
|
+
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
|
|
3749
|
+
* see an identical preceding state in every snapshot that includes them, so
|
|
3750
|
+
* each snapshot's warning list extends the previous one and the new tail
|
|
3751
|
+
* belongs to the added layer.
|
|
3752
|
+
* @param binName - the diagnostic prefix on read/parse errors.
|
|
3753
|
+
* @param absoluteConfigPath - the base config file `boot()` would include.
|
|
3754
|
+
* @param layers - overlay layers in application order (later wins).
|
|
3755
|
+
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
|
|
3756
|
+
* @returns the composed entry list rendered as a YAML document with
|
|
3757
|
+
* source comment separators.
|
|
3758
|
+
*/
|
|
3759
|
+
function renderConfigDump(binName, absoluteConfigPath, layers, warn = (line) => void process.stderr.write(`${line}\n`)) {
|
|
3760
|
+
let content;
|
|
3761
|
+
try {
|
|
3762
|
+
content = readFileSync(absoluteConfigPath, "utf8");
|
|
3763
|
+
} catch (error) {
|
|
3764
|
+
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`);
|
|
3765
|
+
}
|
|
3766
|
+
let parsed;
|
|
3767
|
+
try {
|
|
3768
|
+
parsed = yaml.load(content, { schema: entryListSchema });
|
|
3769
|
+
} catch (error) {
|
|
3770
|
+
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`);
|
|
3771
|
+
}
|
|
3772
|
+
if (!Array.isArray(parsed)) throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`);
|
|
3773
|
+
const baseLabel = basename(absoluteConfigPath);
|
|
3774
|
+
const base = parsed;
|
|
3775
|
+
const snapshot = (count, warnings) => {
|
|
3776
|
+
return applyEntryPatches(base, structuredClone(layers.slice(0, count).flatMap((layer) => layer.patches)), (message, ...args) => {
|
|
3777
|
+
let index = 0;
|
|
3778
|
+
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])));
|
|
3779
|
+
});
|
|
3780
|
+
};
|
|
3781
|
+
let previous = base;
|
|
3782
|
+
let previousWarnings = [];
|
|
3783
|
+
const entryOrigins = base.map(() => ({
|
|
3784
|
+
origin: baseLabel,
|
|
3785
|
+
patchedBy: []
|
|
3786
|
+
}));
|
|
3787
|
+
let composed = base;
|
|
3788
|
+
for (let count = 1; count <= layers.length; count += 1) {
|
|
3789
|
+
const layer = layers[count - 1];
|
|
3790
|
+
/* v8 ignore next -- count iterates 1..length, so the slot exists */
|
|
3791
|
+
if (layer === void 0) continue;
|
|
3792
|
+
const warnings = [];
|
|
3793
|
+
composed = snapshot(count, warnings);
|
|
3794
|
+
for (const line of warnings.slice(previousWarnings.length)) warn(`${binName}: [${layer.label}] ${line}`);
|
|
3795
|
+
const before = previous.map((entry) => JSON.stringify(entry));
|
|
3796
|
+
for (let index = 0; index < composed.length; index += 1) if (index >= before.length) entryOrigins.push({
|
|
3797
|
+
origin: layer.label,
|
|
3798
|
+
patchedBy: []
|
|
3799
|
+
});
|
|
3800
|
+
else if (JSON.stringify(composed[index]) !== before[index]) entryOrigins[index]?.patchedBy.push(layer.label);
|
|
3801
|
+
previous = composed;
|
|
3802
|
+
previousWarnings = warnings;
|
|
3803
|
+
}
|
|
3804
|
+
return groupedDump(composed, entryOrigins);
|
|
3805
|
+
}
|
|
3806
|
+
/** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
|
|
3807
|
+
function groupedDump(composed, entryOrigins) {
|
|
3808
|
+
const lines = [];
|
|
3809
|
+
let currentLabel;
|
|
3810
|
+
let group = [];
|
|
3811
|
+
const flush = () => {
|
|
3812
|
+
if (currentLabel === void 0 || group.length === 0) return;
|
|
3813
|
+
lines.push(`# == ${currentLabel}`);
|
|
3814
|
+
lines.push(yaml.dump(group, {
|
|
3815
|
+
schema: entryListSchema,
|
|
3816
|
+
noRefs: true
|
|
3817
|
+
}).trimEnd());
|
|
3818
|
+
group = [];
|
|
3819
|
+
};
|
|
3820
|
+
for (let index = 0; index < composed.length; index += 1) {
|
|
3821
|
+
const record = entryOrigins[index];
|
|
3822
|
+
/* v8 ignore next -- this array is index-aligned with composed by construction */
|
|
3823
|
+
if (record === void 0) continue;
|
|
3824
|
+
const label = record.patchedBy.length === 0 ? record.origin : `${record.origin}, patched by ${record.patchedBy.join(", ")}`;
|
|
3825
|
+
if (label !== currentLabel) {
|
|
3826
|
+
flush();
|
|
3827
|
+
currentLabel = label;
|
|
3828
|
+
}
|
|
3829
|
+
group.push(composed[index]);
|
|
3830
|
+
}
|
|
3831
|
+
flush();
|
|
3832
|
+
return lines.join("\n") + "\n";
|
|
3833
|
+
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
|
|
3836
|
+
* @param ctx - context carrying an initialized Loader service.
|
|
3837
|
+
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
|
3838
|
+
* @param patches - initial app and user patches, applied in order.
|
|
3839
|
+
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
|
3840
|
+
* names; relative names continue to resolve beside the configuration file.
|
|
3841
|
+
* @param binName - diagnostic prefix for a profile plugin denied by compatibility policy; defaults to `dsh`.
|
|
3842
|
+
* @returns the created root Include entry, or `undefined` when a surface
|
|
3843
|
+
* disposed the whole tree (taking the Loader service with it) while the
|
|
3844
|
+
* entry creation was in flight.
|
|
3845
|
+
*/
|
|
3846
|
+
async function mountRootInclude(ctx, absoluteConfigPath, patches = [], bareModuleBaseUrl, binName = "dsh") {
|
|
3847
|
+
ctx.loader.builtins.include = bareModuleBaseUrl === void 0 ? Include : class HostResolvedRootInclude extends Include {
|
|
3848
|
+
import(name, getOuterStack) {
|
|
3849
|
+
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name;
|
|
3850
|
+
if (name.startsWith(".") || name.startsWith("cordis:")) return super.import(specifier, getOuterStack);
|
|
3851
|
+
const internal = this.ctx.loader.internal;
|
|
3852
|
+
/* v8 ignore next -- Node supplies the internal loader; this preserves the
|
|
3853
|
+
original diagnostic for hypothetical embedders without it. */
|
|
3854
|
+
if (internal === void 0) return super.import(specifier, getOuterStack);
|
|
3855
|
+
return internal.import(specifier, bareModuleBaseUrl, {});
|
|
3856
|
+
}
|
|
3857
|
+
};
|
|
3858
|
+
ctx.loader.builtins.group = Group;
|
|
3859
|
+
const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + "/", binName);
|
|
3860
|
+
const rootInclude = {
|
|
3861
|
+
id: "include",
|
|
3862
|
+
name: "cordis:include",
|
|
3863
|
+
config: {
|
|
3864
|
+
path: pathToFileURL(absoluteConfigPath).href,
|
|
3865
|
+
...prepared.length > 0 ? { patches: prepared } : {}
|
|
3866
|
+
}
|
|
3867
|
+
};
|
|
3868
|
+
const includeId = await ctx.loader.create(rootInclude);
|
|
3869
|
+
const loader = ctx.get("loader");
|
|
3870
|
+
if (loader === void 0) return void 0;
|
|
3871
|
+
const entry = loader.resolve(includeId);
|
|
3872
|
+
bootstrapIncludes.set(ctx, entry);
|
|
3873
|
+
return entry;
|
|
3874
|
+
}
|
|
3875
|
+
const assembledActivationRejections = /* @__PURE__ */ new Map();
|
|
3876
|
+
function retainAssembledRejection(reason) {
|
|
3877
|
+
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1);
|
|
3878
|
+
}
|
|
3879
|
+
function releaseAssembledRejection(reason) {
|
|
3880
|
+
const count = assembledActivationRejections.get(reason);
|
|
3881
|
+
if (count === void 0 || count === 1) assembledActivationRejections.delete(reason);
|
|
3882
|
+
else assembledActivationRejections.set(reason, count - 1);
|
|
3883
|
+
}
|
|
3884
|
+
async function observeLoaderRejectionCheckpoint(reasons) {
|
|
3885
|
+
for (const reason of reasons) retainAssembledRejection(reason);
|
|
3886
|
+
try {
|
|
3887
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
3888
|
+
} finally {
|
|
3889
|
+
for (const reason of reasons) releaseAssembledRejection(reason);
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
/**
|
|
3893
|
+
* How long {@link installFailLoud} waits for its `release` hook before exiting
|
|
3894
|
+
* anyway. A wedged disposer must delay the fatal exit, never cancel it.
|
|
3895
|
+
*/
|
|
3896
|
+
const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2e3;
|
|
3897
|
+
/**
|
|
3898
|
+
* Install before boot to turn an unhandled rejection or an uncaught exception,
|
|
3899
|
+
* at any point in the process lifetime, into one labelled stderr diagnostic and
|
|
3900
|
+
* `exit(1)`. A rejection already included by {@link auditStartupEntries} is
|
|
3901
|
+
* ignored during its process checkpoint; every other rejection and every
|
|
3902
|
+
* uncaught exception remains fatal. Control never returns to the failed
|
|
3903
|
+
* operation after either: only the throw site knows which state is intact, and
|
|
3904
|
+
* a listener that threw mid-update (a stream `'data'` handler, a half-applied
|
|
3905
|
+
* registry write) leaves silently wrong results behind if it were resumed. The
|
|
3906
|
+
* event loop keeps running only until the release hook settles or times out.
|
|
3907
|
+
* Stdout remains untouched for ACP; the returned function removes both handlers.
|
|
3908
|
+
*
|
|
3909
|
+
* The diagnostic is `util.inspect(err)`, not `err.stack`: a `node:fs` error's
|
|
3910
|
+
* `code`, `syscall`, and `path` and any `cause` chain are enumerable properties
|
|
3911
|
+
* that the stack line omits, and they are what a crash report needs. Once a
|
|
3912
|
+
* handler is installed Node prints nothing of its own, so this line is the
|
|
3913
|
+
* only record of the failure.
|
|
3914
|
+
*
|
|
3915
|
+
* The Loader mounts entries concurrently, so a surface that owns the terminal
|
|
3916
|
+
* can already hold it when a sibling entry rejects. Exiting straight from the
|
|
3917
|
+
* handler would strand raw mode, bracketed paste, and the keyboard protocol on
|
|
3918
|
+
* the user's shell, and leave an in-flight terminal query's reply to land as
|
|
3919
|
+
* literal text at the next prompt. `release` is the terminal owner's chance to
|
|
3920
|
+
* hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
|
|
3921
|
+
* timer stays referenced so a never-settling disposer cannot let Node reach an
|
|
3922
|
+
* empty event loop and exit 0 instead of failing.
|
|
3923
|
+
*
|
|
3924
|
+
* The diagnostic is written before the release so a hanging or failing disposer
|
|
3925
|
+
* cannot swallow the reason. The handler stays installed while the release runs
|
|
3926
|
+
* — removing it would let a second concurrent rejection become uncaught and kill
|
|
3927
|
+
* the process mid-teardown, stranding exactly the terminal state this restores —
|
|
3928
|
+
* so a latch keeps the first rejection the reported one and lets later
|
|
3929
|
+
* rejections (including the release's own) fall through to the pending exit.
|
|
3930
|
+
* @param binName - the diagnostic prefix on the fatal-failure line.
|
|
3931
|
+
* @param proc - the process slice to register on; tests inject a fake.
|
|
3932
|
+
* @param release - optional teardown awaited before exit, used by a
|
|
3933
|
+
* terminal-owning surface to restore the terminal. Its own failure is
|
|
3934
|
+
* swallowed because the pending fatal exit already owns the outcome.
|
|
3935
|
+
* @returns the uninstaller that removes both handlers.
|
|
3936
|
+
*/
|
|
3937
|
+
function installFailLoud(binName, proc = process, release) {
|
|
3938
|
+
let exiting = false;
|
|
3939
|
+
const report = (err, label) => {
|
|
3940
|
+
if (exiting) return;
|
|
3941
|
+
exiting = true;
|
|
3942
|
+
proc.stderr.write(`${binName}: ${label}: ${inspect(err, {
|
|
3943
|
+
depth: 4,
|
|
3944
|
+
maxArrayLength: 50
|
|
3945
|
+
})}\n`);
|
|
3946
|
+
if (release === void 0) {
|
|
3947
|
+
proc.exit(1);
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
(async () => {
|
|
3951
|
+
let timer;
|
|
3952
|
+
try {
|
|
3953
|
+
await Promise.race([(async () => release())(), new Promise((resolve) => {
|
|
3954
|
+
timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS);
|
|
3955
|
+
})]);
|
|
3956
|
+
} catch {}
|
|
3957
|
+
clearTimeout(timer);
|
|
3958
|
+
proc.exit(1);
|
|
3959
|
+
})();
|
|
3960
|
+
};
|
|
3961
|
+
const onRejection = (err) => {
|
|
3962
|
+
if (assembledActivationRejections.has(err)) return;
|
|
3963
|
+
report(err, "fatal load failure");
|
|
3964
|
+
};
|
|
3965
|
+
const onException = (err) => {
|
|
3966
|
+
report(err, "fatal uncaught exception");
|
|
3967
|
+
};
|
|
3968
|
+
const uninstall = () => {
|
|
3969
|
+
proc.off("unhandledRejection", onRejection);
|
|
3970
|
+
proc.off("uncaughtException", onException);
|
|
3971
|
+
};
|
|
3972
|
+
proc.on("unhandledRejection", onRejection);
|
|
3973
|
+
proc.on("uncaughtException", onException);
|
|
3974
|
+
return uninstall;
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
|
3978
|
+
* Keep aligned with `packages/client/web/src/loader-status.ts`.
|
|
3979
|
+
*/
|
|
3980
|
+
const FIBER_PENDING = 0;
|
|
3981
|
+
const FIBER_ACTIVE = 2;
|
|
3982
|
+
const FIBER_FAILED = 3;
|
|
3983
|
+
const FIBER_DISPOSED = 4;
|
|
3984
|
+
/**
|
|
3985
|
+
* Entry ids whose presence defines a usable DSH application.
|
|
3986
|
+
*
|
|
3987
|
+
* The list is global rather than profile metadata. Missing or disabled ids do
|
|
3988
|
+
* not affect startup; an enabled listed entry must activate. The list covers
|
|
3989
|
+
* shared Agent execution, application endpoints, and Web bootstrap/transport.
|
|
3990
|
+
*/
|
|
3991
|
+
const requiredStartupEntryIds = new Set([
|
|
3992
|
+
"agent-loop",
|
|
3993
|
+
"webserver",
|
|
3994
|
+
"web-runtime",
|
|
3995
|
+
"modules",
|
|
3996
|
+
"connection",
|
|
3997
|
+
"headless-runner",
|
|
3998
|
+
"acp",
|
|
3999
|
+
"sdk-jsonrpc-server"
|
|
4000
|
+
]);
|
|
4001
|
+
/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
|
|
4002
|
+
function formatActivationError(error) {
|
|
4003
|
+
const details = [];
|
|
4004
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4005
|
+
function visit(value) {
|
|
4006
|
+
if (!(value instanceof Error)) {
|
|
4007
|
+
details.push(String(value));
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
4010
|
+
if (seen.has(value)) return;
|
|
4011
|
+
seen.add(value);
|
|
4012
|
+
details.push(value.stack ?? value.message);
|
|
4013
|
+
if (value.cause !== void 0) visit(value.cause);
|
|
4014
|
+
if (value instanceof AggregateError) value.errors.forEach(visit);
|
|
4015
|
+
}
|
|
4016
|
+
visit(error);
|
|
4017
|
+
return details.join("\n");
|
|
4018
|
+
}
|
|
4019
|
+
/** Startup audit failure with non-enumerable metadata and original failures as its cause. */
|
|
4020
|
+
var StartupError = class extends Error {
|
|
4021
|
+
entries;
|
|
4022
|
+
/** Root configuration and startup logs, attached by boot after disposal. */
|
|
4023
|
+
startup;
|
|
4024
|
+
/**
|
|
4025
|
+
* @param message - concise terminal diagnostic.
|
|
4026
|
+
* @param entries - inactive plugin metadata and original failure values.
|
|
4027
|
+
*/
|
|
4028
|
+
constructor(message, entries) {
|
|
4029
|
+
const failures = entries.flatMap(({ outcome }) => outcome.kind === "failed" ? [outcome.error] : []);
|
|
4030
|
+
super(message, failures.length > 0 ? { cause: new AggregateError(failures, "Plugin activation failures") } : void 0);
|
|
4031
|
+
this.entries = entries;
|
|
4032
|
+
Object.defineProperties(this, {
|
|
4033
|
+
entries: { enumerable: false },
|
|
4034
|
+
startup: { enumerable: false }
|
|
4035
|
+
});
|
|
4036
|
+
}
|
|
4037
|
+
};
|
|
4038
|
+
/**
|
|
4039
|
+
* Collect Loader activation failures and disabled-expression errors. Failed
|
|
4040
|
+
* fibers are awaited to recover their recorded rejection reason and coalesce
|
|
4041
|
+
* duplicate Loader notifications through the next process rejection checkpoint.
|
|
4042
|
+
*/
|
|
4043
|
+
async function inactiveEntries(ctx) {
|
|
4044
|
+
const failures = [];
|
|
4045
|
+
const rejectionReasons = [];
|
|
4046
|
+
for (const entry of ctx.loader.entries()) {
|
|
4047
|
+
try {
|
|
4048
|
+
if (entry.disabled) continue;
|
|
4049
|
+
} catch (error) {
|
|
4050
|
+
failures.push({
|
|
4051
|
+
entry,
|
|
4052
|
+
outcome: {
|
|
4053
|
+
kind: "failed",
|
|
4054
|
+
error,
|
|
4055
|
+
phase: "disabled expression failed"
|
|
4056
|
+
}
|
|
4057
|
+
});
|
|
4058
|
+
continue;
|
|
4059
|
+
}
|
|
4060
|
+
const fiber = entry.fiber;
|
|
4061
|
+
if (fiber === void 0) {
|
|
4062
|
+
failures.push({
|
|
4063
|
+
entry,
|
|
4064
|
+
outcome: {
|
|
4065
|
+
kind: "failed",
|
|
4066
|
+
error: "failed to import"
|
|
4067
|
+
}
|
|
4068
|
+
});
|
|
4069
|
+
continue;
|
|
4070
|
+
}
|
|
4071
|
+
const state = fiber.state;
|
|
4072
|
+
if (state === FIBER_ACTIVE) continue;
|
|
4073
|
+
if (state === FIBER_FAILED) {
|
|
4074
|
+
try {
|
|
4075
|
+
await fiber.await();
|
|
4076
|
+
} catch (error) {
|
|
4077
|
+
rejectionReasons.push(error);
|
|
4078
|
+
failures.push({
|
|
4079
|
+
entry,
|
|
4080
|
+
outcome: {
|
|
4081
|
+
kind: "failed",
|
|
4082
|
+
error
|
|
4083
|
+
}
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
continue;
|
|
4087
|
+
}
|
|
4088
|
+
if (state === FIBER_PENDING) {
|
|
4089
|
+
const missing = Object.keys(fiber.inject).filter((service) => fiber.ctx.get(service) === void 0);
|
|
4090
|
+
failures.push({
|
|
4091
|
+
entry,
|
|
4092
|
+
outcome: {
|
|
4093
|
+
kind: "pending",
|
|
4094
|
+
missing
|
|
4095
|
+
}
|
|
4096
|
+
});
|
|
4097
|
+
} else failures.push({
|
|
4098
|
+
entry,
|
|
4099
|
+
outcome: {
|
|
4100
|
+
kind: "failed",
|
|
4101
|
+
error: `fiber state ${String(state)}`
|
|
4102
|
+
}
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons);
|
|
4106
|
+
return failures;
|
|
4107
|
+
}
|
|
4108
|
+
/** Render one failed plugin's original error and activation phase. */
|
|
4109
|
+
function failureDetail(outcome) {
|
|
4110
|
+
return `${outcome.phase === void 0 ? "" : `${outcome.phase}: `}${formatActivationError(outcome.error)}`;
|
|
4111
|
+
}
|
|
4112
|
+
/** Render optional-only warnings without changing startup policy. */
|
|
4113
|
+
function activationDiagnostic(binName, failures) {
|
|
4114
|
+
const noun = failures.length === 1 ? "entry" : "entries";
|
|
4115
|
+
return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(inactiveDiagnostic).join("\n")}\n`;
|
|
4116
|
+
}
|
|
4117
|
+
/** Stable per-entry text for reload comparisons and optional warnings. */
|
|
4118
|
+
function inactiveDiagnostic({ entry, outcome }) {
|
|
4119
|
+
const detail = outcome.kind === "failed" ? failureDetail(outcome) : `pending (waiting for ${outcome.missing.length === 1 ? "service" : "services"}: ${outcome.missing.join(", ") || "unknown"})`;
|
|
4120
|
+
return `${entry.options.id} (${entry.options.name}): ${detail}`;
|
|
4121
|
+
}
|
|
4122
|
+
/** Group startup failures and pending services, marking every required entry. */
|
|
4123
|
+
function startupDiagnostic(binName, failures, required) {
|
|
4124
|
+
const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? "plugin" : "plugins"} did not activate`];
|
|
4125
|
+
const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === "failed" ? [{
|
|
4126
|
+
entry,
|
|
4127
|
+
outcome
|
|
4128
|
+
}] : []);
|
|
4129
|
+
const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === "pending" ? [{
|
|
4130
|
+
entry,
|
|
4131
|
+
outcome
|
|
4132
|
+
}] : []);
|
|
4133
|
+
pending.sort((left, right) => Number(required.has(right.entry)) - Number(required.has(left.entry)));
|
|
4134
|
+
const label = (entry) => `${entry.options.id}${required.has(entry) ? " (required)" : ""}`;
|
|
4135
|
+
if (failed.length > 0) {
|
|
4136
|
+
lines.push("", `Failed plugins (${String(failed.length)}):`);
|
|
4137
|
+
for (const { entry, outcome } of failed) {
|
|
4138
|
+
lines.push(` ${label(entry)}`, ` Package: ${entry.options.name}`);
|
|
4139
|
+
lines.push(...failureDetail(outcome).split("\n").map((line) => ` ${line}`));
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
if (pending.length > 0) {
|
|
4143
|
+
const width = Math.max(6, ...pending.map(({ entry }) => label(entry).length)) + 2;
|
|
4144
|
+
lines.push("", `Plugins waiting for services (${String(pending.length)}):`, ` ${"Plugin".padEnd(width)}Missing services`);
|
|
4145
|
+
for (const { entry, outcome } of pending) lines.push(` ${label(entry).padEnd(width)}${outcome.missing.join(", ") || "unknown"}`);
|
|
4146
|
+
}
|
|
4147
|
+
return lines.join("\n");
|
|
4148
|
+
}
|
|
4149
|
+
/**
|
|
4150
|
+
* Apply DSH startup policy to a settled Loader tree.
|
|
4151
|
+
*
|
|
4152
|
+
* Inactive entries from the global required list reject startup. Other
|
|
4153
|
+
* inactive entries join that failure diagnostic, or produce one warning when
|
|
4154
|
+
* no required entry failed and leave successful siblings running.
|
|
4155
|
+
* Required ids absent from the tree, and disabled required entries, are ignored.
|
|
4156
|
+
* A throwing disabled expression is an entry failure, not a disabled entry.
|
|
4157
|
+
* The bootstrap Include must activate so unreadable or invalid root config is fatal.
|
|
4158
|
+
* @param ctx - the settled context whose Loader entries to audit.
|
|
4159
|
+
* @param binName - the prefix on startup diagnostics.
|
|
4160
|
+
* @param warn - sink for optional-entry warnings.
|
|
4161
|
+
* @returns after optional warnings if required startup checks pass.
|
|
4162
|
+
* @throws {@link StartupError} when the bootstrap Include or a required entry is inactive or its disabled expression throws;
|
|
4163
|
+
* its message includes optional failures too.
|
|
4164
|
+
*/
|
|
4165
|
+
async function auditStartupEntries(ctx, binName, warn = (line) => void process.stderr.write(line)) {
|
|
4166
|
+
const failures = await inactiveEntries(ctx);
|
|
4167
|
+
const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx) || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry));
|
|
4168
|
+
if (required.size > 0) throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
|
|
4169
|
+
id: entry.options.id,
|
|
4170
|
+
module: entry.options.name,
|
|
4171
|
+
required: required.has(entry),
|
|
4172
|
+
fiberState: entry.fiber?.state,
|
|
4173
|
+
outcome
|
|
4174
|
+
})));
|
|
4175
|
+
if (failures.length > 0) warn(activationDiagnostic(binName, failures));
|
|
4176
|
+
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
|
4179
|
+
* tree settles. Relative entry names resolve against the config directory;
|
|
4180
|
+
* bare package names resolve there by default or against an explicit
|
|
4181
|
+
* `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include
|
|
4182
|
+
* is statically imported and mounted as the `cordis:include` builtin, loading
|
|
4183
|
+
* through the ambient module pipeline (vite/tsx/plain ESM). The package build
|
|
4184
|
+
* embeds Include while leaving Loader external, so the built include tree and
|
|
4185
|
+
* host share one Loader peer. Loader settlement drains entry work without
|
|
4186
|
+
* rejecting the whole tree. The final {@link auditStartupEntries} call rejects
|
|
4187
|
+
* failures in the global required list and warns about other failed, missing,
|
|
4188
|
+
* and pending entries while successful siblings remain active. Later unhandled
|
|
4189
|
+
* rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
|
|
4190
|
+
* helper for bare plugin specifiers; relative specifiers do not.
|
|
4191
|
+
* @param binName - the diagnostic prefix for load-failure errors.
|
|
4192
|
+
* @param absoluteConfigPath - the config to include; must already be absolute
|
|
4193
|
+
* (see {@link resolveConfigPath}).
|
|
4194
|
+
* @param patches - optional overlay patches applied over the included tree
|
|
4195
|
+
* (see {@link loadOptionalPatches}); an empty list mounts none.
|
|
4196
|
+
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
|
4197
|
+
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
|
4198
|
+
* names; use it when the host, rather than the configuration project, owns the
|
|
4199
|
+
* complete plugin set.
|
|
4200
|
+
* @returns the root context after the initial startup audit, or as soon as a
|
|
4201
|
+
* surface disposed the tree while startup was still in flight.
|
|
4202
|
+
* @throws {@link StartupError} for an inactive required entry, including all inactive plugins in its message;
|
|
4203
|
+
* otherwise a labelled error after disposing the partial context — `host
|
|
4204
|
+
* preparation failed` when `prepare` threw before any config-tree entry
|
|
4205
|
+
* mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
|
|
4206
|
+
* diagnostic traversal without replacing the original cause.
|
|
4207
|
+
*/
|
|
4208
|
+
async function boot(binName, absoluteConfigPath, patches, prepare, bareModuleBaseUrl) {
|
|
4209
|
+
const ctx = new Context();
|
|
4210
|
+
const startupLogs = [];
|
|
4211
|
+
const diagnostics = new Context();
|
|
4212
|
+
diagnostics.logger = ctx.logger;
|
|
4213
|
+
diagnostics.logger.exporter({
|
|
4214
|
+
levels: { default: 2 },
|
|
4215
|
+
export: ({ ts, name, type, args }) => {
|
|
4216
|
+
if (type === "warn" || type === "error") startupLogs.push({
|
|
4217
|
+
ts,
|
|
4218
|
+
name,
|
|
4219
|
+
type,
|
|
4220
|
+
args
|
|
4221
|
+
});
|
|
4222
|
+
}
|
|
4223
|
+
});
|
|
4224
|
+
let stage = "host preparation failed";
|
|
4225
|
+
try {
|
|
4226
|
+
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + "/";
|
|
4227
|
+
ctx.provide("dshHomePath", dshHomePath);
|
|
4228
|
+
ctx.on("internal/update", (_config, _noSave, next) => {
|
|
4229
|
+
Promise.resolve(next()).catch((error) => {
|
|
4230
|
+
ctx.logger.error(error);
|
|
4231
|
+
});
|
|
4232
|
+
}, {
|
|
4233
|
+
global: true,
|
|
4234
|
+
prepend: true
|
|
4235
|
+
});
|
|
4236
|
+
await ctx.plugin(Loader);
|
|
4237
|
+
await prepare?.(ctx);
|
|
4238
|
+
stage = "plugin tree failed to load";
|
|
4239
|
+
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName);
|
|
4240
|
+
await ctx.get("loader")?.await();
|
|
4241
|
+
if (ctx.get("loader") === void 0) return ctx;
|
|
4242
|
+
await auditStartupEntries(ctx, binName);
|
|
4243
|
+
return ctx;
|
|
4244
|
+
} catch (cause) {
|
|
4245
|
+
await ctx.fiber.dispose();
|
|
4246
|
+
if (cause instanceof StartupError) {
|
|
4247
|
+
cause.startup = {
|
|
4248
|
+
configurationPath: absoluteConfigPath,
|
|
4249
|
+
messages: startupLogs
|
|
4250
|
+
};
|
|
4251
|
+
throw cause;
|
|
4252
|
+
}
|
|
4253
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
4254
|
+
let deepest = cause;
|
|
4255
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4256
|
+
while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== void 0) {
|
|
4257
|
+
seen.add(deepest);
|
|
4258
|
+
deepest = deepest.cause;
|
|
4259
|
+
}
|
|
4260
|
+
const stack = deepest instanceof AggregateError ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join("\n")}` : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : "";
|
|
4261
|
+
throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause });
|
|
4262
|
+
} finally {
|
|
4263
|
+
await diagnostics.fiber.dispose();
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
/** Prompt-section name for the harness-source location line an app bin adds after boot. */
|
|
4267
|
+
const HARNESS_SOURCE_SECTION = "harness:source";
|
|
4268
|
+
/**
|
|
4269
|
+
* Add a global prompt section naming the on-disk harness source checkout while
|
|
4270
|
+
* explicitly distinguishing it from the task workspace and current working
|
|
4271
|
+
* directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
|
|
4272
|
+
* checkout. Call once on the settled boot context ({@link boot}); the section
|
|
4273
|
+
* uses the shared first-party placement after reusable instructions
|
|
4274
|
+
* and before the Web surface and persona suffix. A booted tree with no
|
|
4275
|
+
* `systemPrompt` service has no prompt to augment, so this is then a no-op
|
|
4276
|
+
* that returns `undefined`. The section is
|
|
4277
|
+
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
|
|
4278
|
+
* that plugin drops it until the next boot.
|
|
4279
|
+
* @param ctx - the settled boot context whose global system prompt to augment.
|
|
4280
|
+
* @param sourceRoot - the absolute path to the harness checkout root.
|
|
4281
|
+
* @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
|
|
4282
|
+
*/
|
|
4283
|
+
function addHarnessSourceSection(ctx, sourceRoot) {
|
|
4284
|
+
const systemPrompt = ctx.get("systemPrompt");
|
|
4285
|
+
if (systemPrompt === void 0) return void 0;
|
|
4286
|
+
return systemPrompt.section({
|
|
4287
|
+
name: HARNESS_SOURCE_SECTION,
|
|
4288
|
+
order: systemPrompt.getSectionOrder("HARNESS_SOURCE"),
|
|
4289
|
+
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.`
|
|
4290
|
+
});
|
|
4291
|
+
}
|
|
4292
|
+
//#endregion
|
|
4293
|
+
export { DEFAULT_PROFILE_BUNDLES, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, LOADER_EXPRESSION_SCHEMA, OFFICIAL_ON_DEMAND_CATALOG, ON_DEMAND_BUNDLES, OPTIONAL_BUNDLES, PROFILES_DIR, PROFILE_COMPATIBILITY_FILENAME, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PluginPackages, ProfileRuntimeResolution, StartupError, addHarnessSourceSection, auditStartupEntries, boot, bundlePatchFiles, bundlePatchPaths, composeEntries, createConfigProjector, createRuntimeResolution, evaluatePluginCompatibility, generateConfigSchema, getDshRuntimeVersion, initProfile, installFailLoud, isNativeConfigSchema, loadEnv, loadLayeredEnv, loadOptionalPatches, loadOverlayPatches, loadProfile, loadProfileDirectory, mountRootInclude, pluginCompatibilityWarning, prepareProfileEntries, prepareProfilePatches, readPluginMeta, readProfileCompatibility, readProfileManifest, readProfilePatches, readProfilePlugins, readProfileVersionExemptions, reconcileProfilePatches, reconcileProfilePlugins, removeLinkProjections, renderConfigDump, reportSkippedBundles, resolveBundleDir, resolveConfigPath, resolvePluginResource, resolveProfileDir, resolveTelemetryPatch, sanitizeProfile, setProfileVersionExemption, writeProfileBundles, writeProfileManifest };
|