@deepseek-ai/cordis-plugin-include 1.0.5-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-present Shigma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @cordisjs/plugin-include
2
+
3
+ File-backed loader tree for Cordis. The include plugin reads a YAML or JSON
4
+ file, turns it into loader entries, and writes updates back when the file is
5
+ writable.
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { Context } from 'cordis'
11
+ import Loader from '@cordisjs/plugin-loader'
12
+ import Include from '@cordisjs/plugin-include'
13
+
14
+ const root = new Context()
15
+ await root.plugin(Loader, { baseUrl: import.meta.url })
16
+ await root.plugin(Include, {
17
+ path: './cordis.yml',
18
+ initial: [],
19
+ enableLogs: true,
20
+ })
21
+ ```
22
+
23
+ Example `cordis.yml`:
24
+
25
+ ```yaml
26
+ - id: timer
27
+ name: '@cordisjs/plugin-timer'
28
+ - id: app
29
+ name: ./plugins/app
30
+ config:
31
+ message: hello
32
+ ```
33
+
34
+ ## Config
35
+
36
+ | Field | Description |
37
+ | --- | --- |
38
+ | `path` | YAML or JSON file path resolved from `ctx.baseUrl`. |
39
+ | `initial` | Entry list written when the file is missing. |
40
+ | `patches` | Runtime patches applied after reading the file. |
41
+ | `enableLogs` | Enables loader apply, reload, and unload logs. |
42
+
43
+ Patches can insert entries or override fields on entries with a matching `id`.
package/lib/index.js ADDED
@@ -0,0 +1,297 @@
1
+ import { EntryConfigResolver, EntryTree, interpolate, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { extname } from "node:path";
4
+ import { access, constants, readFile, rename, writeFile } from "node:fs/promises";
5
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import * as yaml from "js-yaml";
8
+ //#region lib/types/index.js
9
+ var __rewriteRelativeImportExtension = function(path, preserveJsx) {
10
+ if (typeof path === "string" && /^\.\.?\//.test(path)) return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) {
11
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js";
12
+ });
13
+ return path;
14
+ };
15
+ const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
16
+ kind: "scalar",
17
+ resolve: (data) => typeof data === "string",
18
+ construct: (data) => ({ __jsExpr: data }),
19
+ predicate: isJsExpr,
20
+ represent: (data) => data["__jsExpr"]
21
+ });
22
+ /**
23
+ * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
24
+ * the Loader evaluates at entry activation. Exported so config tooling
25
+ * (`dsh --dump-config`) parses and prints exactly the dialect this include
26
+ * mounts.
27
+ */
28
+ const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
29
+ const schema = entryListSchema;
30
+ const writable = {
31
+ ".json": "application/json",
32
+ ".yaml": "application/yaml",
33
+ ".yml": "application/yaml"
34
+ };
35
+ const supported = new Set(Object.keys(writable));
36
+ const WRITE_RETRY_LIMIT = 10;
37
+ const WRITE_RETRY_DELAY_MS = 50;
38
+ function retryableWriteError(error) {
39
+ const code = error?.code;
40
+ return code === "EACCES" || code === "EBUSY" || code === "EPERM";
41
+ }
42
+ /**
43
+ * Apply patch lists to an entry list — THE patch semantics of this include,
44
+ * shared by mounting (`applyPatches`) and offline config tooling
45
+ * (`dsh --dump-config`) so a dump can never drift from what boots. The input
46
+ * is never mutated and the result is always detached from it (even with no
47
+ * patches): patching or mounting shared entry objects would bake earlier
48
+ * values into the cached parse, so repeated application (config hot-reloads)
49
+ * could never revert a removed or changed patch. Inserted entries are indexed
50
+ * as they are added, so a later patch in the same list can target a row an
51
+ * earlier patch inserted. A patch that matches nothing warns and is skipped.
52
+ * @param data - the parsed entry list (JSON-safe plain data).
53
+ * @param patches - the patch list to apply, in order.
54
+ * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
55
+ * @returns a detached entry list with every applicable patch applied.
56
+ */
57
+ function applyEntryPatches(data, patches, warn) {
58
+ data = structuredClone(data);
59
+ if (!patches?.length) return data;
60
+ const entryMap = /* @__PURE__ */ new Map();
61
+ const buildMap = (entries) => {
62
+ for (const entry of entries) {
63
+ if (entry.id) entryMap.set(entry.id, entry);
64
+ if (entry.group && Array.isArray(entry.config)) buildMap(entry.config);
65
+ }
66
+ };
67
+ buildMap(data);
68
+ for (const patch of patches) {
69
+ const { id, insert, name, ...overrides } = patch;
70
+ if (insert) {
71
+ if (id) {
72
+ const target = entryMap.get(id);
73
+ if (!target) {
74
+ warn("patch insert: entry %C not found", id);
75
+ continue;
76
+ }
77
+ if (!target.group) {
78
+ warn("patch insert: entry %C is not a group", id);
79
+ continue;
80
+ }
81
+ if (!Array.isArray(target.config)) target.config = [];
82
+ target.config.push(...insert);
83
+ } else data.push(...insert);
84
+ buildMap(insert);
85
+ continue;
86
+ }
87
+ if (!id) {
88
+ warn("patch: id is required for non-insert patches");
89
+ continue;
90
+ }
91
+ const target = entryMap.get(id);
92
+ if (!target) {
93
+ warn("patch: entry %C not found", id);
94
+ continue;
95
+ }
96
+ if (name && name !== target.name) {
97
+ warn("patch: name mismatch for %C (expected %C, got %C), skipping", id, target.name, name);
98
+ continue;
99
+ }
100
+ for (const [key, value] of Object.entries(overrides)) {
101
+ if (key === "id") continue;
102
+ target[key] = value;
103
+ }
104
+ }
105
+ return data;
106
+ }
107
+ var ConfigFileError = class extends Error {
108
+ stage;
109
+ constructor(stage, path, cause) {
110
+ super(`failed to ${stage} config file ${path}`, { cause });
111
+ this.stage = stage;
112
+ this.name = "ConfigFileError";
113
+ }
114
+ };
115
+ /** Loader entry tree backed by a YAML or JSON file. */
116
+ var Include = class extends EntryTree {
117
+ config;
118
+ static inject = ["loader"];
119
+ /**
120
+ * Resolve Include's own options while preserving nested entry expressions.
121
+ * @param ctx - the Include plugin context.
122
+ * @param config - the raw Include config.
123
+ * @returns resolved Include options with `initial` and `patches` untouched.
124
+ */
125
+ static [EntryConfigResolver](ctx, config) {
126
+ const { initial, patches, ...own } = config;
127
+ return {
128
+ ...interpolate(ctx, own),
129
+ ...initial === void 0 ? {} : { initial },
130
+ ...patches === void 0 ? {} : { patches }
131
+ };
132
+ }
133
+ filename;
134
+ type;
135
+ readonly;
136
+ content;
137
+ data;
138
+ writeTask;
139
+ pendingWrite;
140
+ writeQueue = Promise.resolve();
141
+ applyQueue = Promise.resolve();
142
+ constructor(ctx, config) {
143
+ super(ctx);
144
+ this.config = config;
145
+ this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false;
146
+ this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl));
147
+ const ext = extname(this.filename);
148
+ if (!supported.has(ext)) throw new Error(`extension "${ext}" not supported`);
149
+ this.type = writable[ext];
150
+ this.readonly = !this.type;
151
+ this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
152
+ ctx.on("internal/update", async (config, _, next) => {
153
+ if (config.path !== this.config.path) return next();
154
+ await this.enqueue(async () => {
155
+ const data = this.applyPatches(this.data, config.patches);
156
+ await this.root.update(data);
157
+ this.config = config;
158
+ });
159
+ });
160
+ }
161
+ /**
162
+ * Serialize one child-tree mutation behind every earlier one. The group's
163
+ * transactional `update` is not reentrant: two concurrent applies (the init
164
+ * apply racing an HMR-triggered refresh from the watcher's initial scan)
165
+ * interleave create and rollback on the same entries and strand the include
166
+ * fiber without settling, so every apply path funnels through this queue.
167
+ * A predecessor's failure is its own caller's outcome and never gates the
168
+ * next task.
169
+ */
170
+ enqueue(task) {
171
+ const run = this.applyQueue.then(task, task);
172
+ this.applyQueue = run.then(() => {}, () => {});
173
+ return run;
174
+ }
175
+ async checkAccess() {
176
+ if (!this.type) return;
177
+ try {
178
+ await access(this.filename, constants.W_OK);
179
+ } catch {
180
+ this.readonly = true;
181
+ }
182
+ }
183
+ async read(forced = false) {
184
+ let content;
185
+ try {
186
+ content = await readFile(this.filename, "utf8");
187
+ } catch (error) {
188
+ throw new ConfigFileError("read", this.filename, error);
189
+ }
190
+ if (!forced && this.content === content) return;
191
+ let data;
192
+ try {
193
+ if (this.type === "application/yaml") data = yaml.load(content, { schema });
194
+ else if (this.type === "application/json") data = JSON.parse(content);
195
+ else {
196
+ const module = await import(__rewriteRelativeImportExtension(
197
+ /* @vite-ignore */
198
+ this.filename
199
+ ));
200
+ data = module.default || module;
201
+ }
202
+ } catch (error) {
203
+ throw new ConfigFileError("parse", this.filename, error);
204
+ }
205
+ if (!Array.isArray(data)) throw new ConfigFileError("validate", this.filename, /* @__PURE__ */ new TypeError("config file must be a top-level array"));
206
+ return {
207
+ content,
208
+ data
209
+ };
210
+ }
211
+ applyPatches(data, patches) {
212
+ return applyEntryPatches(data, patches, (message, ...args) => {
213
+ this.ctx.root.logger?.("loader").warn(message, ...args);
214
+ });
215
+ }
216
+ async *[Service.init]() {
217
+ let candidate;
218
+ try {
219
+ candidate = await this.read(true);
220
+ } catch (error) {
221
+ if (!(error instanceof ConfigFileError) || error.stage !== "read" || error.cause?.code !== "ENOENT") throw error;
222
+ if (this.config.initial) {
223
+ await this._writeFile(this.config.initial);
224
+ candidate = await this.read(true);
225
+ } else throw new Error(`config file not found: ${this.filename}`);
226
+ }
227
+ yield () => this.stop();
228
+ await this.apply(candidate);
229
+ }
230
+ async stop() {
231
+ await this.root.stop();
232
+ await this.flushWrite();
233
+ }
234
+ /**
235
+ * Re-read the file and transactionally refresh child entries when content changed.
236
+ * @returns a promise resolving after the new tree commits, or immediately when unchanged.
237
+ * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
238
+ */
239
+ async refresh() {
240
+ await this.enqueue(async () => {
241
+ const candidate = await this.read();
242
+ if (!candidate) return;
243
+ await this._apply(candidate);
244
+ });
245
+ }
246
+ apply(candidate) {
247
+ return this.enqueue(() => this._apply(candidate));
248
+ }
249
+ async _apply(candidate) {
250
+ const data = this.applyPatches(candidate.data, this.config.patches);
251
+ await this.root.update(data);
252
+ this.content = candidate.content;
253
+ this.data = candidate.data;
254
+ await this.checkAccess();
255
+ }
256
+ async _writeFile(config) {
257
+ if (this.readonly) throw new Error(`cannot overwrite readonly config`);
258
+ if (this.type === "application/yaml") this.content = yaml.dump(config, { schema });
259
+ else if (this.type === "application/json") this.content = JSON.stringify(config, null, 2);
260
+ await writeFile(this.filename + ".tmp", this.content);
261
+ for (let retry = 0;; retry++) try {
262
+ await rename(this.filename + ".tmp", this.filename);
263
+ return;
264
+ } catch (error) {
265
+ if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error;
266
+ await setTimeout$1((retry + 1) * WRITE_RETRY_DELAY_MS);
267
+ }
268
+ }
269
+ writeFile(config) {
270
+ clearTimeout(this.writeTask);
271
+ this.pendingWrite = config;
272
+ this.writeTask = setTimeout(() => {
273
+ this.flushWrite();
274
+ }, 0);
275
+ }
276
+ flushWrite() {
277
+ clearTimeout(this.writeTask);
278
+ this.writeTask = void 0;
279
+ const config = this.pendingWrite;
280
+ this.pendingWrite = void 0;
281
+ if (config === void 0) return this.writeQueue;
282
+ const run = this.writeQueue.then(() => this._writeFile(config), () => this._writeFile(config));
283
+ this.writeQueue = run;
284
+ run.catch((error) => {
285
+ this.ctx.root.logger?.("loader").warn("failed to write config file %C", this.filename);
286
+ this.ctx.root.logger?.("loader").warn(error);
287
+ });
288
+ return run;
289
+ }
290
+ /** Schedule a write of the current root entry data. */
291
+ write() {
292
+ this.context.emit("loader/config-update");
293
+ return this.writeFile(this.root.data);
294
+ }
295
+ };
296
+ //#endregion
297
+ export { Include, Include as default, applyEntryPatches, entryListSchema };
@@ -0,0 +1,105 @@
1
+ import { EntryConfigResolver, EntryTree, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader';
2
+ import { Context, Service } from '@deepseek-ai/cordis';
3
+ import * as yaml from 'js-yaml';
4
+ /**
5
+ * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
6
+ * the Loader evaluates at entry activation. Exported so config tooling
7
+ * (`dsh --dump-config`) parses and prints exactly the dialect this include
8
+ * mounts.
9
+ */
10
+ export declare const entryListSchema: yaml.Schema;
11
+ /**
12
+ * Apply patch lists to an entry list — THE patch semantics of this include,
13
+ * shared by mounting (`applyPatches`) and offline config tooling
14
+ * (`dsh --dump-config`) so a dump can never drift from what boots. The input
15
+ * is never mutated and the result is always detached from it (even with no
16
+ * patches): patching or mounting shared entry objects would bake earlier
17
+ * values into the cached parse, so repeated application (config hot-reloads)
18
+ * could never revert a removed or changed patch. Inserted entries are indexed
19
+ * as they are added, so a later patch in the same list can target a row an
20
+ * earlier patch inserted. A patch that matches nothing warns and is skipped.
21
+ * @param data - the parsed entry list (JSON-safe plain data).
22
+ * @param patches - the patch list to apply, in order.
23
+ * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
24
+ * @returns a detached entry list with every applicable patch applied.
25
+ */
26
+ export declare function applyEntryPatches(data: EntryOptions[], patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void): EntryOptions[];
27
+ /** Runtime patch applied to entries loaded from an included config file. */
28
+ export interface PatchOptions {
29
+ id?: string;
30
+ insert?: EntryOptions[];
31
+ name?: string;
32
+ config?: any;
33
+ group?: boolean | null;
34
+ disabled?: boolean | null;
35
+ inject?: any;
36
+ intercept?: any;
37
+ isolate?: any;
38
+ [key: string]: any;
39
+ }
40
+ /** Config namespace for the file-backed include loader. */
41
+ export declare namespace Include {
42
+ /** Config for a file-backed loader subtree. */
43
+ interface Config {
44
+ /** YAML or JSON path resolved from `ctx.baseUrl`. */
45
+ path: string;
46
+ /** Entry list written when the file does not already exist. */
47
+ initial?: any[];
48
+ /** Runtime patches applied after reading the file. */
49
+ patches?: PatchOptions[];
50
+ /** Enables loader apply/reload/unload logs for this subtree. */
51
+ enableLogs?: boolean;
52
+ }
53
+ }
54
+ /** Loader entry tree backed by a YAML or JSON file. */
55
+ export declare class Include extends EntryTree {
56
+ config: Include.Config;
57
+ static inject: string[];
58
+ /**
59
+ * Resolve Include's own options while preserving nested entry expressions.
60
+ * @param ctx - the Include plugin context.
61
+ * @param config - the raw Include config.
62
+ * @returns resolved Include options with `initial` and `patches` untouched.
63
+ */
64
+ static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config;
65
+ filename: string;
66
+ private type?;
67
+ private readonly;
68
+ private content?;
69
+ private data?;
70
+ private writeTask?;
71
+ private pendingWrite?;
72
+ private writeQueue;
73
+ private applyQueue;
74
+ constructor(ctx: Context, config: Include.Config);
75
+ /**
76
+ * Serialize one child-tree mutation behind every earlier one. The group's
77
+ * transactional `update` is not reentrant: two concurrent applies (the init
78
+ * apply racing an HMR-triggered refresh from the watcher's initial scan)
79
+ * interleave create and rollback on the same entries and strand the include
80
+ * fiber without settling, so every apply path funnels through this queue.
81
+ * A predecessor's failure is its own caller's outcome and never gates the
82
+ * next task.
83
+ */
84
+ private enqueue;
85
+ private checkAccess;
86
+ private read;
87
+ private applyPatches;
88
+ [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
89
+ stop(): Promise<void>;
90
+ /**
91
+ * Re-read the file and transactionally refresh child entries when content changed.
92
+ * @returns a promise resolving after the new tree commits, or immediately when unchanged.
93
+ * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
94
+ */
95
+ refresh(): Promise<void>;
96
+ private apply;
97
+ private _apply;
98
+ private _writeFile;
99
+ private writeFile;
100
+ private flushWrite;
101
+ /** Schedule a write of the current root entry data. */
102
+ write(): void;
103
+ }
104
+ export default Include;
105
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAyB,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAA;AAC5H,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKtD,OAAO,KAAK,IAAI,MAAM,SAAS,CAAA;AAU/B;;;;;GAKG;AACH,eAAO,MAAM,eAAe,aAAkC,CAAA;AAoB9D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,YAAY,EAAE,EACpB,OAAO,EAAE,YAAY,EAAE,GAAG,SAAS,EACnC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAC9C,YAAY,EAAE,CAkEhB;AAgBD,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACzB,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,SAAS,CAAC,EAAE,GAAG,CAAA;IACf,OAAO,CAAC,EAAE,GAAG,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED,2DAA2D;AAC3D,yBAAiB,OAAO,CAAC;IACvB,+CAA+C;IAC/C,UAAiB,MAAM;QACrB,qDAAqD;QACrD,IAAI,EAAE,MAAM,CAAA;QACZ,+DAA+D;QAC/D,OAAO,CAAC,EAAE,GAAG,EAAE,CAAA;QACf,sDAAsD;QACtD,OAAO,CAAC,EAAE,YAAY,EAAE,CAAA;QACxB,gEAAgE;QAChE,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB;CACF;AAED,uDAAuD;AACvD,qBAAa,OAAQ,SAAQ,SAAS;IA4BH,MAAM,EAAE,OAAO,CAAC,MAAM;IA3BvD,MAAM,CAAC,MAAM,WAAa;IAE1B;;;;;OAKG;IACH,MAAM,CAAC,CAAC,mBAAmB,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM;IAS3E,QAAQ,EAAE,MAAM,CAAA;IACvB,OAAO,CAAC,IAAI,CAAC,CAAQ;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAC,CAAQ;IACxB,OAAO,CAAC,IAAI,CAAC,CAAgB;IAC7B,OAAO,CAAC,SAAS,CAAC,CAA4B;IAC9C,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,UAAU,CAAsC;gBAE5C,GAAG,EAAE,OAAO,EAAS,MAAM,EAAE,OAAO,CAAC,MAAM;IAsBvD;;;;;;;;OAQG;IACH,OAAO,CAAC,OAAO;YAMD,WAAW;YASX,IAAI;IA2BlB,OAAO,CAAC,YAAY;IAMb,CAAC,OAAO,CAAC,IAAI,CAAC;IAkBf,IAAI;IAKV;;;;OAIG;IACG,OAAO;IAUb,OAAO,CAAC,KAAK;YAIC,MAAM;YAQN,UAAU;IAqBxB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,UAAU;IAkBlB,uDAAuD;IACvD,KAAK;CAIN;AAED,eAAe,OAAO,CAAA"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@deepseek-ai/cordis-plugin-include",
3
+ "description": "Include files in cordis configurations",
4
+ "version": "1.0.5-rc.1",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "vendor/include"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts",
27
+ "lib/types/**/*.d.ts.map",
28
+ "src"
29
+ ],
30
+ "author": "Shigma <shigma10826@gmail.com>",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.1-rc.1",
34
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
35
+ },
36
+ "dependencies": {
37
+ "js-yaml": "^4.1.0",
38
+ "@deepseek-ai/cosmokit": "^1.8.2-rc.1"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
2
+ import { Context, Service } from '@deepseek-ai/cordis'
3
+ import { extname } from 'node:path'
4
+ import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
5
+ import { setTimeout as delay } from 'node:timers/promises'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import * as yaml from 'js-yaml'
8
+
9
+ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
10
+ kind: 'scalar',
11
+ resolve: (data) => typeof data === 'string',
12
+ construct: (data) => ({ __jsExpr: data }),
13
+ predicate: isJsExpr,
14
+ represent: (data) => data['__jsExpr'],
15
+ })
16
+
17
+ /**
18
+ * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
19
+ * the Loader evaluates at entry activation. Exported so config tooling
20
+ * (`dsh --dump-config`) parses and prints exactly the dialect this include
21
+ * mounts.
22
+ */
23
+ export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
24
+
25
+ const schema = entryListSchema
26
+
27
+ const writable: Record<string, string> = {
28
+ '.json': 'application/json',
29
+ '.yaml': 'application/yaml',
30
+ '.yml': 'application/yaml',
31
+ }
32
+
33
+ const supported = new Set(Object.keys(writable))
34
+
35
+ const WRITE_RETRY_LIMIT = 10
36
+ const WRITE_RETRY_DELAY_MS = 50
37
+
38
+ function retryableWriteError(error: unknown): boolean {
39
+ const code = (error as NodeJS.ErrnoException | null)?.code
40
+ return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM'
41
+ }
42
+
43
+ /**
44
+ * Apply patch lists to an entry list — THE patch semantics of this include,
45
+ * shared by mounting (`applyPatches`) and offline config tooling
46
+ * (`dsh --dump-config`) so a dump can never drift from what boots. The input
47
+ * is never mutated and the result is always detached from it (even with no
48
+ * patches): patching or mounting shared entry objects would bake earlier
49
+ * values into the cached parse, so repeated application (config hot-reloads)
50
+ * could never revert a removed or changed patch. Inserted entries are indexed
51
+ * as they are added, so a later patch in the same list can target a row an
52
+ * earlier patch inserted. A patch that matches nothing warns and is skipped.
53
+ * @param data - the parsed entry list (JSON-safe plain data).
54
+ * @param patches - the patch list to apply, in order.
55
+ * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
56
+ * @returns a detached entry list with every applicable patch applied.
57
+ */
58
+ export function applyEntryPatches(
59
+ data: EntryOptions[],
60
+ patches: PatchOptions[] | undefined,
61
+ warn: (message: string, ...args: any[]) => void,
62
+ ): EntryOptions[] {
63
+ data = structuredClone(data)
64
+ if (!patches?.length) return data
65
+
66
+ const entryMap = new Map<string, EntryOptions>()
67
+ const buildMap = (entries: EntryOptions[]) => {
68
+ for (const entry of entries) {
69
+ if (entry.id) entryMap.set(entry.id, entry)
70
+ if (entry.group && Array.isArray(entry.config)) {
71
+ buildMap(entry.config)
72
+ }
73
+ }
74
+ }
75
+ buildMap(data)
76
+
77
+ for (const patch of patches) {
78
+ const { id, insert, name, ...overrides } = patch
79
+
80
+ if (insert) {
81
+ if (id) {
82
+ const target = entryMap.get(id)
83
+ if (!target) {
84
+ warn('patch insert: entry %C not found', id)
85
+ continue
86
+ }
87
+ if (!target.group) {
88
+ warn('patch insert: entry %C is not a group', id)
89
+ continue
90
+ }
91
+ if (!Array.isArray(target.config)) target.config = []
92
+ target.config.push(...insert)
93
+ } else {
94
+ data.push(...insert)
95
+ }
96
+ // Index what this patch added so a LATER patch in the same list can
97
+ // target it. Patch lists compose one layer per source (each bundle
98
+ // layer, then the user's, then `--patch` overlays), and a layer must be
99
+ // able to configure or disable a row an earlier layer inserted; without
100
+ // this, inserted rows were silently unpatchable.
101
+ buildMap(insert)
102
+ continue
103
+ }
104
+
105
+ if (!id) {
106
+ warn('patch: id is required for non-insert patches')
107
+ continue
108
+ }
109
+
110
+ const target = entryMap.get(id)
111
+ if (!target) {
112
+ warn('patch: entry %C not found', id)
113
+ continue
114
+ }
115
+
116
+ if (name && name !== target.name) {
117
+ warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)
118
+ continue
119
+ }
120
+
121
+ for (const [key, value] of Object.entries(overrides)) {
122
+ if (key === 'id') continue
123
+ target[key] = value
124
+ }
125
+ }
126
+
127
+ return data
128
+ }
129
+
130
+ type ConfigUpdateStage = 'read' | 'parse' | 'validate'
131
+
132
+ interface ReadCandidate {
133
+ content: string
134
+ data: EntryOptions[]
135
+ }
136
+
137
+ class ConfigFileError extends Error {
138
+ constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
139
+ super(`failed to ${stage} config file ${path}`, { cause })
140
+ this.name = 'ConfigFileError'
141
+ }
142
+ }
143
+
144
+ /** Runtime patch applied to entries loaded from an included config file. */
145
+ export interface PatchOptions {
146
+ id?: string
147
+ insert?: EntryOptions[]
148
+ name?: string
149
+ config?: any
150
+ group?: boolean | null
151
+ disabled?: boolean | null
152
+ inject?: any
153
+ intercept?: any
154
+ isolate?: any
155
+ [key: string]: any
156
+ }
157
+
158
+ /** Config namespace for the file-backed include loader. */
159
+ export namespace Include {
160
+ /** Config for a file-backed loader subtree. */
161
+ export interface Config {
162
+ /** YAML or JSON path resolved from `ctx.baseUrl`. */
163
+ path: string
164
+ /** Entry list written when the file does not already exist. */
165
+ initial?: any[]
166
+ /** Runtime patches applied after reading the file. */
167
+ patches?: PatchOptions[]
168
+ /** Enables loader apply/reload/unload logs for this subtree. */
169
+ enableLogs?: boolean
170
+ }
171
+ }
172
+
173
+ /** Loader entry tree backed by a YAML or JSON file. */
174
+ export class Include extends EntryTree {
175
+ static inject = ['loader']
176
+
177
+ /**
178
+ * Resolve Include's own options while preserving nested entry expressions.
179
+ * @param ctx - the Include plugin context.
180
+ * @param config - the raw Include config.
181
+ * @returns resolved Include options with `initial` and `patches` untouched.
182
+ */
183
+ static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config {
184
+ const { initial, patches, ...own } = config
185
+ return {
186
+ ...interpolate(ctx, own),
187
+ ...(initial === undefined ? {} : { initial }),
188
+ ...(patches === undefined ? {} : { patches }),
189
+ }
190
+ }
191
+
192
+ public filename: string
193
+ private type?: string
194
+ private readonly: boolean
195
+ private content?: string
196
+ private data?: EntryOptions[]
197
+ private writeTask?: NodeJS.Timeout | undefined
198
+ private pendingWrite?: EntryOptions[]
199
+ private writeQueue: Promise<void> = Promise.resolve()
200
+ private applyQueue: Promise<unknown> = Promise.resolve()
201
+
202
+ constructor(ctx: Context, public config: Include.Config) {
203
+ super(ctx)
204
+ this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
205
+ this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
206
+ const ext = extname(this.filename)
207
+ if (!supported.has(ext)) {
208
+ throw new Error(`extension "${ext}" not supported`)
209
+ }
210
+ this.type = writable[ext]
211
+ this.readonly = !this.type
212
+ this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
213
+
214
+ ctx.on('internal/update', async (config, _, next) => {
215
+ if (config.path !== this.config.path) return next()
216
+ await this.enqueue(async () => {
217
+ const data = this.applyPatches(this.data!, config.patches)
218
+ await this.root.update(data)
219
+ this.config = config
220
+ })
221
+ })
222
+ }
223
+
224
+ /**
225
+ * Serialize one child-tree mutation behind every earlier one. The group's
226
+ * transactional `update` is not reentrant: two concurrent applies (the init
227
+ * apply racing an HMR-triggered refresh from the watcher's initial scan)
228
+ * interleave create and rollback on the same entries and strand the include
229
+ * fiber without settling, so every apply path funnels through this queue.
230
+ * A predecessor's failure is its own caller's outcome and never gates the
231
+ * next task.
232
+ */
233
+ private enqueue<T>(task: () => Promise<T>): Promise<T> {
234
+ const run = this.applyQueue.then(task, task)
235
+ this.applyQueue = run.then(() => {}, () => {})
236
+ return run
237
+ }
238
+
239
+ private async checkAccess() {
240
+ if (!this.type) return
241
+ try {
242
+ await access(this.filename, constants.W_OK)
243
+ } catch {
244
+ this.readonly = true
245
+ }
246
+ }
247
+
248
+ private async read(forced = false): Promise<ReadCandidate | undefined> {
249
+ let content: string
250
+ try {
251
+ content = await readFile(this.filename, 'utf8')
252
+ } catch (error) {
253
+ throw new ConfigFileError('read', this.filename, error)
254
+ }
255
+ if (!forced && this.content === content) return
256
+ let data: any
257
+ try {
258
+ if (this.type === 'application/yaml') {
259
+ data = yaml.load(content, { schema })
260
+ } else if (this.type === 'application/json') {
261
+ data = JSON.parse(content)
262
+ } else {
263
+ const module = await import(/* @vite-ignore */ this.filename)
264
+ data = module.default || module
265
+ }
266
+ } catch (error) {
267
+ throw new ConfigFileError('parse', this.filename, error)
268
+ }
269
+ if (!Array.isArray(data)) {
270
+ throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
271
+ }
272
+ return { content, data }
273
+ }
274
+
275
+ private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] {
276
+ return applyEntryPatches(data, patches, (message, ...args) => {
277
+ this.ctx.root.logger?.('loader').warn(message, ...args)
278
+ })
279
+ }
280
+
281
+ async* [Service.init]() {
282
+ let candidate: ReadCandidate
283
+ try {
284
+ candidate = (await this.read(true))!
285
+ } catch (error) {
286
+ if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
287
+ if (this.config.initial) {
288
+ await this._writeFile(this.config.initial as any)
289
+ candidate = (await this.read(true))!
290
+ } else {
291
+ throw new Error(`config file not found: ${this.filename}`)
292
+ }
293
+ }
294
+
295
+ yield () => this.stop()
296
+ await this.apply(candidate)
297
+ }
298
+
299
+ async stop() {
300
+ await this.root.stop()
301
+ await this.flushWrite()
302
+ }
303
+
304
+ /**
305
+ * Re-read the file and transactionally refresh child entries when content changed.
306
+ * @returns a promise resolving after the new tree commits, or immediately when unchanged.
307
+ * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
308
+ */
309
+ async refresh() {
310
+ // Read inside the queue so the changed-content check compares against the
311
+ // predecessor's committed state, not a mid-apply snapshot.
312
+ await this.enqueue(async () => {
313
+ const candidate = await this.read()
314
+ if (!candidate) return
315
+ await this._apply(candidate)
316
+ })
317
+ }
318
+
319
+ private apply(candidate: ReadCandidate) {
320
+ return this.enqueue(() => this._apply(candidate))
321
+ }
322
+
323
+ private async _apply(candidate: ReadCandidate) {
324
+ const data = this.applyPatches(candidate.data, this.config.patches)
325
+ await this.root.update(data)
326
+ this.content = candidate.content
327
+ this.data = candidate.data
328
+ await this.checkAccess()
329
+ }
330
+
331
+ private async _writeFile(config: EntryOptions[]) {
332
+ if (this.readonly) {
333
+ throw new Error(`cannot overwrite readonly config`)
334
+ }
335
+ if (this.type === 'application/yaml') {
336
+ this.content = yaml.dump(config, { schema })
337
+ } else if (this.type === 'application/json') {
338
+ this.content = JSON.stringify(config, null, 2)
339
+ }
340
+ await writeFile(this.filename + '.tmp', this.content!)
341
+ for (let retry = 0; ; retry++) {
342
+ try {
343
+ await rename(this.filename + '.tmp', this.filename)
344
+ return
345
+ } catch (error) {
346
+ if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error
347
+ await delay((retry + 1) * WRITE_RETRY_DELAY_MS)
348
+ }
349
+ }
350
+ }
351
+
352
+ private writeFile(config: EntryOptions[]) {
353
+ clearTimeout(this.writeTask)
354
+ this.pendingWrite = config
355
+ this.writeTask = setTimeout(() => {
356
+ void this.flushWrite()
357
+ }, 0)
358
+ }
359
+
360
+ private flushWrite(): Promise<void> {
361
+ clearTimeout(this.writeTask)
362
+ this.writeTask = undefined
363
+ const config = this.pendingWrite
364
+ this.pendingWrite = undefined
365
+ if (config === undefined) return this.writeQueue
366
+ const run = this.writeQueue.then(
367
+ () => this._writeFile(config),
368
+ () => this._writeFile(config),
369
+ )
370
+ this.writeQueue = run
371
+ void run.catch((error) => {
372
+ this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename)
373
+ this.ctx.root.logger?.('loader').warn(error)
374
+ })
375
+ return run
376
+ }
377
+
378
+ /** Schedule a write of the current root entry data. */
379
+ write() {
380
+ this.context.emit('loader/config-update')
381
+ return this.writeFile(this.root.data)
382
+ }
383
+ }
384
+
385
+ export default Include