@deepseek-ai/dsh-settings 0.1.6-alpha.2 → 0.1.7-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/README.i18n.yaml +2 -2
- package/README.md +19 -96
- package/README.zh.md +25 -102
- package/lib/index.js +360 -426
- package/lib/types/index.d.ts +66 -283
- package/lib/types/index.js +271 -512
- package/lib/types/redact.d.ts +2 -4
- package/lib/types/redact.js +14 -8
- package/lib/types/schema.d.ts +24 -0
- package/lib/types/schema.js +85 -0
- package/lib/types/types.d.ts +16 -44
- package/lib/types/types.js +1 -9
- package/package.json +21 -17
- package/lib/invariant.js +0 -35
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -41
package/lib/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rename } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { Service, resolveConfig } from "@deepseek-ai/cordis";
|
|
6
|
+
import { interpolate } from "@deepseek-ai/cordis-plugin-loader";
|
|
7
|
+
import z from "@deepseek-ai/schemastery";
|
|
8
|
+
import { isVolatile } from "@deepseek-ai/cosmokit";
|
|
3
9
|
//#region lib/types/redact.js
|
|
4
10
|
/**
|
|
5
11
|
* Structural secret redaction for settings values. `role('secret')` fields are
|
|
@@ -49,15 +55,16 @@ function walk(node, value, path, secrets) {
|
|
|
49
55
|
case "array":
|
|
50
56
|
if (!Array.isArray(value)) return value;
|
|
51
57
|
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
|
|
58
|
+
case "union":
|
|
59
|
+
case "intersect": return (node.list ?? []).reduce((current, child) => walk(child, current, path, secrets), value);
|
|
60
|
+
case "transform": return walk(node.inner, value, path, secrets);
|
|
52
61
|
default: return value;
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
64
|
/**
|
|
56
65
|
* Remove every `role('secret')` field a schema declares from a value. The
|
|
57
|
-
* walker
|
|
58
|
-
*
|
|
59
|
-
* buried inside a union branch or transform is not reachable and must not be
|
|
60
|
-
* modeled that way). The input is never mutated.
|
|
66
|
+
* walker visits every union branch, conservatively removing any field declared
|
|
67
|
+
* secret by a branch. The input is never mutated.
|
|
61
68
|
* @param schema - live schemastery schema describing the value.
|
|
62
69
|
* @param value - the value to strip; `undefined` yields an empty record with
|
|
63
70
|
* object-property secret slots still enumerated.
|
|
@@ -65,30 +72,94 @@ function walk(node, value, path, secrets) {
|
|
|
65
72
|
*/
|
|
66
73
|
function redactSecrets(schema, value) {
|
|
67
74
|
const secrets = [];
|
|
75
|
+
const stripped = walk(schema, value, [], secrets);
|
|
76
|
+
const positions = /* @__PURE__ */ new Map();
|
|
77
|
+
for (const secret of secrets) {
|
|
78
|
+
const key = JSON.stringify(secret.path);
|
|
79
|
+
const previous = positions.get(key);
|
|
80
|
+
positions.set(key, {
|
|
81
|
+
...secret,
|
|
82
|
+
set: secret.set || previous?.set === true
|
|
83
|
+
});
|
|
84
|
+
}
|
|
68
85
|
return {
|
|
69
|
-
value:
|
|
70
|
-
secrets
|
|
86
|
+
value: stripped,
|
|
87
|
+
secrets: [...positions.values()]
|
|
71
88
|
};
|
|
72
89
|
}
|
|
73
90
|
//#endregion
|
|
74
|
-
//#region lib/types/
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* `base`, and the user document section, in that order.
|
|
80
|
-
* @module @deepseek-ai/dsh-settings
|
|
91
|
+
//#region lib/types/schema.js
|
|
92
|
+
/** Derive editable forms and plain values from plugin Config schemas. */
|
|
93
|
+
/** Remove runtime references from a configuration snapshot.
|
|
94
|
+
* @param value Parsed Config output.
|
|
95
|
+
* @returns Detached ordinary values suitable for redaction and forms.
|
|
81
96
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
97
|
+
function plainConfig(value) {
|
|
98
|
+
if (isVolatile(value)) return plainConfig(value.get());
|
|
99
|
+
if (Array.isArray(value)) return value.map(plainConfig);
|
|
100
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plainConfig(child)]));
|
|
85
101
|
return value;
|
|
86
102
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
function plainSchema(schema) {
|
|
104
|
+
const result = new z(schema.toJSON());
|
|
105
|
+
const walk = (node) => {
|
|
106
|
+
delete node.meta.volatile;
|
|
107
|
+
if (node.meta.role === "secret") {
|
|
108
|
+
delete node.meta.default;
|
|
109
|
+
delete node.meta.required;
|
|
110
|
+
} else if (node.meta.default !== void 0) node.meta.default = redactSecrets(node, node.meta.default).value;
|
|
111
|
+
for (const child of Object.values(node.dict ?? {})) walk(child);
|
|
112
|
+
if (node.inner) walk(node.inner);
|
|
113
|
+
for (const child of node.list ?? []) walk(child);
|
|
114
|
+
};
|
|
115
|
+
walk(result);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/** Select fields whose nearest volatile ancestor makes them editable without remounting.
|
|
119
|
+
* @param schema The plugin's Config schema.
|
|
120
|
+
* @returns A plain form schema, or undefined when no field is live.
|
|
121
|
+
*/
|
|
122
|
+
function volatileForm(schema) {
|
|
123
|
+
if (schema.meta.volatile) return plainSchema(schema);
|
|
124
|
+
if (schema.type === "object") {
|
|
125
|
+
const dict = Object.fromEntries(Object.entries(schema.dict ?? {}).flatMap(([key, child]) => {
|
|
126
|
+
const field = volatileForm(child);
|
|
127
|
+
return field === void 0 ? [] : [[key, field]];
|
|
128
|
+
}));
|
|
129
|
+
return Object.keys(dict).length === 0 ? void 0 : z.object(dict);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Whether a raw config node is an unevaluated `!!js` expression, kept whole rather than projected field by field. */
|
|
133
|
+
function isExpression(value) {
|
|
134
|
+
return Object.keys(value).length === 1 && typeof Reflect.get(value, "__jsExpr") === "string";
|
|
135
|
+
}
|
|
136
|
+
/** Project only schema-declared fields, excluding ordinary configuration.
|
|
137
|
+
* @param schema The filtered form schema.
|
|
138
|
+
* @param value Plain raw or resolved config.
|
|
139
|
+
* @returns The fields visible to this form.
|
|
140
|
+
*/
|
|
141
|
+
function projectForm(schema, value) {
|
|
142
|
+
if (schema.type === "object" && value !== null && typeof value === "object" && !isExpression(value)) return Object.fromEntries(Object.entries(schema.dict ?? {}).flatMap(([key, child]) => {
|
|
143
|
+
const field = Reflect.get(value, key);
|
|
144
|
+
return field === void 0 ? [] : [[key, projectForm(child, field)]];
|
|
145
|
+
}));
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
/** Check that a field path lies beneath a declared volatile node.
|
|
149
|
+
* @param schema Complete plugin Config schema.
|
|
150
|
+
* @param path Field path addressed by a form edit.
|
|
151
|
+
* @returns Whether the path can be edited live.
|
|
91
152
|
*/
|
|
153
|
+
function isVolatilePath(schema, path) {
|
|
154
|
+
if (schema.meta.volatile) return true;
|
|
155
|
+
const [key, ...rest] = path;
|
|
156
|
+
const child = key === void 0 ? void 0 : schema.dict?.[key];
|
|
157
|
+
return child !== void 0 && isVolatilePath(child, rest);
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region lib/types/index.js
|
|
161
|
+
/** Config-schema projection and form edits over Cordis profile patches. */
|
|
162
|
+
/** Refusal to overwrite configuration changed since the form was read. */
|
|
92
163
|
var SettingsConflictError = class extends Error {
|
|
93
164
|
/** Stable machine code for wire layers mapping this to their own taxonomy. */
|
|
94
165
|
code = "SETTINGS_CONFLICT";
|
|
@@ -115,39 +186,33 @@ function isPlainObject(value) {
|
|
|
115
186
|
return proto === Object.prototype || proto === null;
|
|
116
187
|
}
|
|
117
188
|
/** Apply one path op to a detached section, returning the next section. */
|
|
118
|
-
function applyPathOp(section, op) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
if (op.op === "
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
[
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
})
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
return {
|
|
145
|
-
...section,
|
|
146
|
-
[head]: applyPathOp(child, {
|
|
147
|
-
...op,
|
|
148
|
-
path: rest
|
|
149
|
-
})
|
|
189
|
+
function applyPathOp(section, op, schema) {
|
|
190
|
+
const edit = (input, path, node) => {
|
|
191
|
+
const [head, ...rest] = path;
|
|
192
|
+
if (head === void 0) return op.op === "set" ? op.value : void 0;
|
|
193
|
+
const value = input === void 0 ? node?.meta.default : input;
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
if (!/^(0|[1-9][0-9]*)$/.test(head) || Number(head) > value.length || Number(head) === value.length && (rest.length > 0 || op.op === "unset")) throw new TypeError(`Config array index "${head}" is out of range`);
|
|
196
|
+
const result = [...value];
|
|
197
|
+
const index = Number(head);
|
|
198
|
+
if (rest.length === 0 && op.op === "unset") result.splice(index, 1);
|
|
199
|
+
else result[index] = edit(value[index], rest, node?.inner);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
const result = isPlainObject(value) ? { ...value } : {};
|
|
203
|
+
const child = edit(Object.hasOwn(result, head) ? result[head] : void 0, rest, node?.dict?.[head] ?? node?.inner);
|
|
204
|
+
if (child === void 0) Reflect.deleteProperty(result, head);
|
|
205
|
+
else Object.defineProperty(result, head, {
|
|
206
|
+
value: child,
|
|
207
|
+
enumerable: true,
|
|
208
|
+
writable: true,
|
|
209
|
+
configurable: true
|
|
210
|
+
});
|
|
211
|
+
return result;
|
|
150
212
|
};
|
|
213
|
+
const result = edit(section, op.path, schema);
|
|
214
|
+
if (!isPlainObject(result)) throw new TypeError("Config root must be a plain object");
|
|
215
|
+
return result;
|
|
151
216
|
}
|
|
152
217
|
/** Human label for a value that lossless JSON cannot represent (numbers reject inline). */
|
|
153
218
|
function describeRejected(value) {
|
|
@@ -166,11 +231,12 @@ function describeRejected(value) {
|
|
|
166
231
|
* silently distorts on the reload round-trip. `undefined` entries in objects
|
|
167
232
|
* are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
|
|
168
233
|
* an `undefined` array entry is rejected rather than coerced.
|
|
169
|
-
* @param root -
|
|
170
|
-
* @param reject - builds the validation error from a value label and its `$`-rooted path.
|
|
234
|
+
* @param root - write input to validate before merging.
|
|
171
235
|
* @returns the detached JSON-compatible clone.
|
|
172
236
|
*/
|
|
173
|
-
function cloneJsonShaped(root
|
|
237
|
+
function cloneJsonShaped(root) {
|
|
238
|
+
const reject = (label, path) => /* @__PURE__ */ new TypeError(`Config ${path} contains ${label}`);
|
|
239
|
+
if (!isPlainObject(root)) throw reject("a non-plain root", "$");
|
|
174
240
|
const visiting = /* @__PURE__ */ new WeakSet();
|
|
175
241
|
const clone = (value, path) => {
|
|
176
242
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
@@ -191,7 +257,12 @@ function cloneJsonShaped(root, reject) {
|
|
|
191
257
|
const out = {};
|
|
192
258
|
for (const [key, entry] of Object.entries(value)) {
|
|
193
259
|
if (entry === void 0) continue;
|
|
194
|
-
out
|
|
260
|
+
Object.defineProperty(out, key, {
|
|
261
|
+
value: clone(entry, `${path}.${key}`),
|
|
262
|
+
enumerable: true,
|
|
263
|
+
configurable: true,
|
|
264
|
+
writable: true
|
|
265
|
+
});
|
|
195
266
|
}
|
|
196
267
|
visiting.delete(value);
|
|
197
268
|
return out;
|
|
@@ -208,403 +279,266 @@ function cloneJsonShaped(root, reject) {
|
|
|
208
279
|
* cannot erase lower keys.
|
|
209
280
|
*/
|
|
210
281
|
function mergeLayers(under, over) {
|
|
211
|
-
if (over === void 0) return under;
|
|
212
282
|
if (!isPlainObject(under) || !isPlainObject(over)) return over;
|
|
213
283
|
const merged = { ...under };
|
|
214
|
-
for (const [key, value] of Object.entries(over)) merged
|
|
284
|
+
for (const [key, value] of Object.entries(over)) Object.defineProperty(merged, key, {
|
|
285
|
+
value: Object.hasOwn(merged, key) ? mergeLayers(merged[key], value) : value,
|
|
286
|
+
enumerable: true,
|
|
287
|
+
configurable: true,
|
|
288
|
+
writable: true
|
|
289
|
+
});
|
|
215
290
|
return merged;
|
|
216
291
|
}
|
|
217
|
-
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
292
|
+
/** Read one member of a plain object or array; `own` limits the read to own properties.
|
|
293
|
+
* @param node Candidate container.
|
|
294
|
+
* @param key Member name.
|
|
295
|
+
* @param own Whether inherited members count as absent.
|
|
296
|
+
* @returns The member, or undefined when the node is not a container or lacks the member.
|
|
222
297
|
*/
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
298
|
+
function member(node, key, own = false) {
|
|
299
|
+
if (!(isPlainObject(node) || Array.isArray(node)) || own && !Object.hasOwn(node, key)) return void 0;
|
|
300
|
+
return Reflect.get(node, key);
|
|
301
|
+
}
|
|
302
|
+
/** Entry ids of the removed `settings.yaml` sections whose owning entry carries another id. */
|
|
303
|
+
const LEGACY_SECTION_ENTRIES = {
|
|
304
|
+
"ui-developer-tools": "ui-settings",
|
|
305
|
+
"ui-onboarding": "ui-settings-general",
|
|
306
|
+
/* v8 ignore next -- the base bundle composes one shell executor per platform */
|
|
307
|
+
shell: process.platform === "win32" ? "pwsh-sandbox" : "bash-sandbox"
|
|
308
|
+
};
|
|
309
|
+
/** Resolve the inherited layers alone, or keep their raw values when required fields arrive only through the profile.
|
|
310
|
+
* @param runtime Plugin runtime owning the Config schema.
|
|
311
|
+
* @param inherited Interpolated config beneath the profile override.
|
|
312
|
+
* @returns Values the profile override sits on.
|
|
313
|
+
*/
|
|
314
|
+
function inheritedConfig(runtime, inherited) {
|
|
315
|
+
try {
|
|
316
|
+
return resolveConfig(runtime, inherited);
|
|
317
|
+
} catch (_error) {
|
|
318
|
+
return inherited;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Project Config schemas into forms and own optional instance-level UI policy. */
|
|
322
|
+
var SettingsForms = class extends Service {
|
|
323
|
+
ownerContext;
|
|
324
|
+
static inject = ["configEditor", "profileContext"];
|
|
325
|
+
revisions = /* @__PURE__ */ new Map();
|
|
326
|
+
closed = false;
|
|
327
|
+
scheduled = false;
|
|
328
|
+
presentations = /* @__PURE__ */ new Map();
|
|
329
|
+
constructor(ownerContext) {
|
|
330
|
+
super(ownerContext, "settings");
|
|
331
|
+
this.ownerContext = ownerContext;
|
|
332
|
+
const ctx = ownerContext;
|
|
333
|
+
ctx.effect(() => () => {
|
|
334
|
+
this.closed = true;
|
|
335
|
+
});
|
|
336
|
+
ctx.on("app-boot/config-reload", () => {
|
|
337
|
+
this.invalidate();
|
|
338
|
+
});
|
|
339
|
+
ctx.root.loader.await().then(() => this.importLegacyDocument()).catch((error) => {
|
|
340
|
+
ctx.logger.error(error);
|
|
341
|
+
});
|
|
236
342
|
}
|
|
237
|
-
|
|
238
|
-
|
|
343
|
+
/** Move the sections of the removed `settings.yaml` into the active profile once the Loader has settled every entry.
|
|
344
|
+
* The document is renamed before the first write, so a partial import never repeats; a section the running
|
|
345
|
+
* composition rejects is logged and remains only in the renamed file. */
|
|
346
|
+
async importLegacyDocument() {
|
|
347
|
+
const profile = this.ownerContext.profileContext;
|
|
348
|
+
const path = join(profile.home, "settings.yaml");
|
|
349
|
+
if (!existsSync(path)) return;
|
|
350
|
+
const imported = `${path}.imported`;
|
|
351
|
+
await rename(path, imported);
|
|
352
|
+
const sections = parse(await readFile(imported, "utf8"));
|
|
353
|
+
for (const [section, values] of Object.entries(sections ?? {})) {
|
|
354
|
+
const ns = LEGACY_SECTION_ENTRIES[section] ?? section;
|
|
355
|
+
try {
|
|
356
|
+
await this.update(ns, values);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
this.ownerContext.logger.warn("settings: section %s of %s was not imported into entry %s", section, imported, ns);
|
|
359
|
+
this.ownerContext.logger.warn(error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
this.ownerContext.logger.info("settings: imported %s into profile %s", imported, profile.name);
|
|
239
363
|
}
|
|
240
|
-
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
364
|
+
/** Register the calling plugin instance's page policy without changing its Config.
|
|
365
|
+
* @param presentation Automatic-page policy for this instance; `auto` defaults to true.
|
|
366
|
+
* @param owner Plugin instance the policy belongs to; defaults to the calling fiber.
|
|
367
|
+
* @returns Disposer; register it with the calling plugin's effects.
|
|
368
|
+
* @throws If this instance already has a registered policy.
|
|
245
369
|
*/
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
370
|
+
configure(presentation, owner = this.ctx.fiber) {
|
|
371
|
+
const fiber = owner;
|
|
372
|
+
if (this.presentations.has(fiber)) throw new Error("Settings presentation is already configured for this plugin instance");
|
|
373
|
+
const policy = { ...presentation };
|
|
374
|
+
this.presentations.set(fiber, policy);
|
|
375
|
+
this.invalidate();
|
|
376
|
+
return () => {
|
|
377
|
+
if (this.presentations.get(fiber) !== policy) return;
|
|
378
|
+
this.presentations.delete(fiber);
|
|
379
|
+
this.invalidate();
|
|
250
380
|
};
|
|
251
|
-
this.publish(await this.load());
|
|
252
381
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
* @returns the absolute local document path, or undefined for non-file storage.
|
|
266
|
-
*/
|
|
267
|
-
prepareDocument() {
|
|
268
|
-
return Promise.resolve(this.documentPath);
|
|
382
|
+
invalidate() {
|
|
383
|
+
if (this.scheduled || this.closed) return;
|
|
384
|
+
this.scheduled = true;
|
|
385
|
+
queueMicrotask(() => {
|
|
386
|
+
this.scheduled = false;
|
|
387
|
+
if (this.closed || this.ownerContext.fiber.state !== 2) return;
|
|
388
|
+
try {
|
|
389
|
+
this.describe();
|
|
390
|
+
} catch (error) {
|
|
391
|
+
this.ownerContext.logger.error(error);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
269
394
|
}
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
* the namespace and its observers. An invalid stored section fails the
|
|
274
|
-
* registration itself — the earliest point where the schema can judge it.
|
|
275
|
-
* @param ns - unique namespace; duplicate registration fails loud.
|
|
276
|
-
* @param schema - schemastery schema resolving this namespace's value.
|
|
277
|
-
* @param options - composition `base` layer and effect timing.
|
|
278
|
-
* @returns the owner scope for reads, observation, and updates.
|
|
279
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
280
|
-
*/
|
|
281
|
-
register(ns, schema, options) {
|
|
282
|
-
const parsedNs = parseSettingsNamespace(ns);
|
|
283
|
-
if (this.registrations.has(parsedNs)) throw new Error(`settings namespace "${parsedNs}" is already registered`);
|
|
284
|
-
const registration = {
|
|
285
|
-
ns: parsedNs,
|
|
286
|
-
schema,
|
|
287
|
-
base: options?.base,
|
|
288
|
-
applies: options?.applies ?? "live",
|
|
289
|
-
...options?.validate === void 0 ? {} : { validate: options.validate },
|
|
290
|
-
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
|
|
291
|
-
revision: 0,
|
|
292
|
-
watchers: /* @__PURE__ */ new Set()
|
|
293
|
-
};
|
|
294
|
-
this.ctx.effect(() => {
|
|
295
|
-
this.registrations.set(parsedNs, registration);
|
|
296
|
-
return () => this.registrations.delete(parsedNs);
|
|
297
|
-
}, `settings.register(${JSON.stringify(String(parsedNs))})`);
|
|
298
|
-
return {
|
|
299
|
-
get: () => registration.resolved,
|
|
300
|
-
watch: (callback) => {
|
|
301
|
-
const watcher = {
|
|
302
|
-
callback,
|
|
303
|
-
tail: Promise.resolve(),
|
|
304
|
-
active: true
|
|
305
|
-
};
|
|
306
|
-
registration.watchers.add(watcher);
|
|
307
|
-
return () => {
|
|
308
|
-
watcher.active = false;
|
|
309
|
-
registration.watchers.delete(watcher);
|
|
310
|
-
};
|
|
311
|
-
},
|
|
312
|
-
update: (patch) => this.update(parsedNs, patch),
|
|
313
|
-
replace: (section) => this.replace(parsedNs, section)
|
|
314
|
-
};
|
|
395
|
+
/** Whether the active profile accepts form edits. */
|
|
396
|
+
get writable() {
|
|
397
|
+
return true;
|
|
315
398
|
}
|
|
316
|
-
/**
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
* @
|
|
322
|
-
* @param schema - schema resolving the namespace.
|
|
323
|
-
* @param entry - composition entry used as the base and fallback value.
|
|
324
|
-
* @param hooks - source sink, change notification, and optional validation.
|
|
325
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
399
|
+
/** Current profile patch shown by the native configuration editor. */
|
|
400
|
+
get documentPath() {
|
|
401
|
+
return this.ownerContext.configEditor.documentPath;
|
|
402
|
+
}
|
|
403
|
+
/** Locate the profile patch for native editing.
|
|
404
|
+
* @returns The existing profile patch path.
|
|
326
405
|
*/
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
base: entry,
|
|
330
|
-
...hooks.validate === void 0 ? {} : { validate: hooks.validate }
|
|
331
|
-
});
|
|
332
|
-
hooks.setSource(() => scope.get());
|
|
333
|
-
this.ctx.effect(() => () => {
|
|
334
|
-
if (isUnloading(owner)) return;
|
|
335
|
-
hooks.setSource(() => entry);
|
|
336
|
-
hooks.onChange();
|
|
337
|
-
});
|
|
338
|
-
hooks.onChange();
|
|
339
|
-
scope.watch(() => {
|
|
340
|
-
if (isUnloading(owner)) return;
|
|
341
|
-
hooks.onChange();
|
|
342
|
-
});
|
|
406
|
+
prepareDocument() {
|
|
407
|
+
return Promise.resolve(this.documentPath);
|
|
343
408
|
}
|
|
344
|
-
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* the user overrode (presence in `user`) and what a reset returns to.
|
|
348
|
-
* @param options - redaction switch; wire surfaces must redact.
|
|
349
|
-
* @returns one descriptor per registered namespace, in registration order.
|
|
409
|
+
/** Read active plugin schemas and their live values.
|
|
410
|
+
* @param options Redaction required for remote callers.
|
|
411
|
+
* @returns Forms keyed by unique profile entry ids.
|
|
350
412
|
*/
|
|
351
413
|
describe(options) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
414
|
+
const active = /* @__PURE__ */ new Set();
|
|
415
|
+
const descriptors = this.ownerContext.configEditor.configuration().flatMap(({ entry, inherited, override }) => {
|
|
416
|
+
const schema = this.schema(entry);
|
|
417
|
+
if (schema === void 0 || entry.fiber === void 0 || entry.fiber.runtime === null || entry.fiber.state !== 2) return [];
|
|
418
|
+
const form = volatileForm(schema);
|
|
419
|
+
if (form === void 0) return [];
|
|
420
|
+
active.add(entry.id);
|
|
421
|
+
const raw = JSON.stringify([
|
|
422
|
+
entry.fiber.uid,
|
|
423
|
+
schema.toJSON(),
|
|
424
|
+
entry.options.config ?? {}
|
|
425
|
+
]);
|
|
426
|
+
const autoGenerate = this.presentations.get(entry.fiber)?.auto ?? true;
|
|
427
|
+
const previous = this.revisions.get(entry.id);
|
|
428
|
+
const revision = previous === void 0 ? 0 : previous.revision + Number(previous.raw !== raw);
|
|
429
|
+
this.revisions.set(entry.id, {
|
|
430
|
+
raw,
|
|
431
|
+
revision,
|
|
432
|
+
ns: entry.options.id,
|
|
433
|
+
autoGenerate
|
|
434
|
+
});
|
|
435
|
+
if (previous?.raw !== raw || previous.autoGenerate !== autoGenerate) this.ownerContext.emit("settings/document-updated", entry.options.id, revision);
|
|
436
|
+
const value = projectForm(form, plainConfig(entry.fiber.config));
|
|
437
|
+
const resolved = interpolate(entry.fiber.ctx, inherited);
|
|
438
|
+
const base = projectForm(form, plainConfig(inheritedConfig(entry.fiber.runtime, resolved)));
|
|
439
|
+
const user = projectForm(form, override);
|
|
440
|
+
const redacted = redactSecrets(form, value);
|
|
441
|
+
return [{
|
|
442
|
+
autoGenerate,
|
|
443
|
+
ns: entry.options.id,
|
|
444
|
+
schema: form.toJSON(),
|
|
445
|
+
revision,
|
|
446
|
+
applies: "live",
|
|
447
|
+
value: options?.redactSecrets ? redacted.value : value,
|
|
448
|
+
base: options?.redactSecrets ? redactSecrets(form, base).value : base,
|
|
449
|
+
user: options?.redactSecrets ? redactSecrets(form, user).value : user,
|
|
450
|
+
...options?.redactSecrets ? { secrets: redacted.secrets } : {}
|
|
451
|
+
}];
|
|
380
452
|
});
|
|
453
|
+
for (const [id, previous] of this.revisions) {
|
|
454
|
+
if (active.has(id) || previous.raw === void 0) continue;
|
|
455
|
+
const revision = previous.revision + 1;
|
|
456
|
+
this.revisions.set(id, {
|
|
457
|
+
...previous,
|
|
458
|
+
raw: void 0,
|
|
459
|
+
revision
|
|
460
|
+
});
|
|
461
|
+
this.ownerContext.emit("settings/document-updated", previous.ns, revision);
|
|
462
|
+
}
|
|
463
|
+
return descriptors;
|
|
381
464
|
}
|
|
382
|
-
/**
|
|
383
|
-
*
|
|
384
|
-
* @param
|
|
385
|
-
* @
|
|
386
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
387
|
-
*/
|
|
388
|
-
get(ns) {
|
|
389
|
-
return this.registrations.get(parseSettingsNamespace(ns))?.resolved;
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* Merge a patch into one registered namespace's user layer, validate the
|
|
393
|
-
* resolved candidate, persist through the provider, then commit and emit.
|
|
394
|
-
* A validation failure rejects before anything is persisted. Writes to one
|
|
395
|
-
* namespace are serialized: concurrent updates apply in call order, each
|
|
396
|
-
* merging over the previous write's committed section.
|
|
397
|
-
* @param ns - the registered namespace to update.
|
|
398
|
-
* @param patch - plain-object patch over the user section.
|
|
399
|
-
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
400
|
-
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
401
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
465
|
+
/** Merge editable fields into an entry's config.
|
|
466
|
+
* @param ns Profile entry id.
|
|
467
|
+
* @param patch Fields to merge.
|
|
468
|
+
* @param expectedRevision Revision returned by describe.
|
|
402
469
|
*/
|
|
403
470
|
async update(ns, patch, expectedRevision) {
|
|
404
|
-
|
|
471
|
+
const input = cloneJsonShaped(patch);
|
|
472
|
+
await this.write(ns, (current) => mergeLayers(current, input), expectedRevision);
|
|
405
473
|
}
|
|
406
|
-
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
|
411
|
-
* @param ns - the registered namespace to replace.
|
|
412
|
-
* @param section - the complete next user section.
|
|
413
|
-
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
414
|
-
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
415
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
474
|
+
/** Reset all live fields, then set the supplied fields; ordinary config is preserved.
|
|
475
|
+
* @param ns Profile entry id.
|
|
476
|
+
* @param section Complete form values.
|
|
477
|
+
* @param expectedRevision Revision returned by describe.
|
|
416
478
|
*/
|
|
417
479
|
async replace(ns, section, expectedRevision) {
|
|
418
|
-
|
|
480
|
+
const input = cloneJsonShaped(section);
|
|
481
|
+
await this.write(ns, (_current, base) => mergeLayers(base, input), expectedRevision);
|
|
419
482
|
}
|
|
420
|
-
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* caller never has to restate fields it did not touch — and, crucially,
|
|
425
|
-
* cannot delete fields it never saw. This is the write path for any caller
|
|
426
|
-
* holding a redacted view; `replace` remains the wholesale reset.
|
|
427
|
-
* @param ns - the registered namespace to edit.
|
|
428
|
-
* @param ops - ordered path edits; later ops observe earlier ones.
|
|
429
|
-
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
430
|
-
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
431
|
-
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
483
|
+
/** Apply field edits without restating redacted secrets; unsetting an array index removes its element.
|
|
484
|
+
* @param ns Profile entry id.
|
|
485
|
+
* @param ops Ordered form edits.
|
|
486
|
+
* @param expectedRevision Revision returned by describe.
|
|
432
487
|
*/
|
|
433
488
|
async mutate(ns, ops, expectedRevision) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
489
|
+
await this.write(ns, (current, base, schema) => ops.reduce((value, op) => {
|
|
490
|
+
if (op.op === "set") return applyPathOp(value, op, schema);
|
|
491
|
+
const parent = op.path.slice(0, -1).reduce((node, key) => member(node, key), value);
|
|
492
|
+
if (Array.isArray(parent)) return applyPathOp(value, op, schema);
|
|
493
|
+
const inherited = op.path.reduce((node, key) => member(node, key, true), base);
|
|
494
|
+
return applyPathOp(value, inherited === void 0 ? op : {
|
|
495
|
+
op: "set",
|
|
496
|
+
path: op.path,
|
|
497
|
+
value: inherited
|
|
498
|
+
}, schema);
|
|
499
|
+
}, current), expectedRevision, ops.map((op) => op.path));
|
|
441
500
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
if (!
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
501
|
+
async write(ns, change, expected, paths = []) {
|
|
502
|
+
const entry = this.ownerContext.configEditor.entries().find((row) => row.options.id === ns);
|
|
503
|
+
const schema = entry === void 0 ? void 0 : this.schema(entry);
|
|
504
|
+
if (entry === void 0 || schema === void 0) throw new Error(`No configurable plugin entry "${ns}"`);
|
|
505
|
+
const form = volatileForm(schema);
|
|
506
|
+
if (form === void 0) throw new Error(`Plugin entry "${ns}" has no volatile fields`);
|
|
507
|
+
for (const path of paths) if (path.length && !isVolatilePath(schema, path)) throw new Error(`Config field "${path.join(".")}" is not volatile`);
|
|
508
|
+
await this.ownerContext.configEditor.edit(entry, (raw, inherited) => {
|
|
509
|
+
const descriptor = this.describe().find((row) => row.ns === ns);
|
|
510
|
+
if (descriptor === void 0) throw new Error(`Plugin entry "${ns}" is no longer configurable`);
|
|
511
|
+
if (expected !== void 0 && descriptor.revision !== expected) throw new SettingsConflictError(ns, expected, descriptor.revision);
|
|
512
|
+
const next = cloneJsonShaped(change(projectForm(form, raw), projectForm(form, inherited), schema));
|
|
513
|
+
const validatePaths = (value, node, path = []) => {
|
|
514
|
+
for (const [key, child] of Object.entries(value)) {
|
|
515
|
+
const target = [...path, key];
|
|
516
|
+
if (isVolatilePath(schema, target)) continue;
|
|
517
|
+
const fields = node.dict;
|
|
518
|
+
const field = Object.hasOwn(fields, key) ? fields[key] : void 0;
|
|
519
|
+
if (isPlainObject(child) && field !== void 0) validatePaths(child, field, target);
|
|
520
|
+
else throw new Error(`Config field "${target.join(".")}" is not volatile`);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
validatePaths(next, form);
|
|
524
|
+
const strip = (value, node, path = []) => {
|
|
525
|
+
if (isVolatilePath(schema, path)) return {};
|
|
526
|
+
const result = { ...value };
|
|
527
|
+
for (const [key, field] of Object.entries(node.dict)) {
|
|
528
|
+
const target = [...path, key];
|
|
529
|
+
if (isVolatilePath(schema, target)) Reflect.deleteProperty(result, key);
|
|
530
|
+
else if (isPlainObject(result[key])) result[key] = strip(result[key], field, target);
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
};
|
|
534
|
+
return mergeLayers(strip(raw, form), next);
|
|
469
535
|
});
|
|
470
|
-
this.
|
|
471
|
-
return run;
|
|
536
|
+
this.describe();
|
|
472
537
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
* namespace's last good value and warns, other namespaces still commit.
|
|
477
|
-
* @param doc - the detached raw document (unregistered sections preserved).
|
|
478
|
-
* @param source - change origin; defaults to `provider`.
|
|
479
|
-
*/
|
|
480
|
-
publish(doc, source = "provider") {
|
|
481
|
-
const before = /* @__PURE__ */ new Map();
|
|
482
|
-
for (const registration of this.registrations.values()) try {
|
|
483
|
-
before.set(registration.ns, this.section(registration.ns));
|
|
484
|
-
} catch {
|
|
485
|
-
before.set(registration.ns, void 0);
|
|
486
|
-
}
|
|
487
|
-
this.document = doc;
|
|
488
|
-
for (const registration of this.registrations.values()) {
|
|
489
|
-
let next;
|
|
490
|
-
try {
|
|
491
|
-
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate));
|
|
492
|
-
} catch (error) {
|
|
493
|
-
this.ctx.logger.warn("settings: keeping last good \"%s\" after invalid stored section", registration.ns);
|
|
494
|
-
this.ctx.logger.warn(error);
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns));
|
|
498
|
-
this.commit(registration, next, source);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
/** Read one namespace's raw user section, rejecting non-object sections. */
|
|
502
|
-
section(ns) {
|
|
503
|
-
const section = this.document[ns];
|
|
504
|
-
if (section === void 0) return void 0;
|
|
505
|
-
if (!isPlainObject(section)) throw new TypeError(`settings section "${ns}" must be an object of keys`);
|
|
506
|
-
return section;
|
|
507
|
-
}
|
|
508
|
-
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
|
|
509
|
-
resolve(schema, base, section, validate) {
|
|
510
|
-
const value = schema(mergeLayers(base, section));
|
|
511
|
-
validate?.(value);
|
|
512
|
-
return value;
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* Advance a namespace's revision when its RAW section changed, and announce
|
|
516
|
-
* it. Deliberately independent of {@link commit}'s resolved-value equality:
|
|
517
|
-
* storing an override equal to the composition base leaves the resolved
|
|
518
|
-
* value alone but changes what the document says, which is exactly what a
|
|
519
|
-
* configuration surface must re-read.
|
|
520
|
-
*/
|
|
521
|
-
bumpRevision(registration, before, after) {
|
|
522
|
-
if (deepEqualJson(before, after)) return;
|
|
523
|
-
registration.revision += 1;
|
|
524
|
-
this.emitDocumentUpdated(registration.ns, registration.revision);
|
|
525
|
-
}
|
|
526
|
-
/** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
|
|
527
|
-
emitDocumentUpdated(ns, revision) {
|
|
528
|
-
let invariantFailure;
|
|
529
|
-
const args = [
|
|
530
|
-
"settings/document-updated",
|
|
531
|
-
ns,
|
|
532
|
-
revision
|
|
533
|
-
];
|
|
534
|
-
for (const listener of this.ctx.events.dispatch("emit", args)) try {
|
|
535
|
-
const returned = listener(ns, revision);
|
|
536
|
-
if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
|
|
537
|
-
this.warnListenerFailure(ns, error);
|
|
538
|
-
});
|
|
539
|
-
} catch (error) {
|
|
540
|
-
if (error?.code === "INVARIANT") {
|
|
541
|
-
invariantFailure ??= error;
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
this.warnListenerFailure(ns, error);
|
|
545
|
-
}
|
|
546
|
-
if (invariantFailure !== void 0) throw invariantFailure;
|
|
547
|
-
}
|
|
548
|
-
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
|
549
|
-
commit(registration, next, source) {
|
|
550
|
-
const prev = registration.resolved;
|
|
551
|
-
if (deepEqualJson(next, prev)) return;
|
|
552
|
-
registration.resolved = next;
|
|
553
|
-
for (const watcher of [...registration.watchers]) {
|
|
554
|
-
const segment = watcher.tail.then(() => {
|
|
555
|
-
if (!watcher.active || this.isStopped()) return;
|
|
556
|
-
return watcher.callback(next, prev);
|
|
557
|
-
}).then(() => void 0, (error) => {
|
|
558
|
-
this.warnWatcherFailure(registration.ns, error);
|
|
559
|
-
});
|
|
560
|
-
watcher.tail = segment;
|
|
561
|
-
this.pendingTails.add(segment);
|
|
562
|
-
segment.then(() => this.pendingTails.delete(segment));
|
|
563
|
-
}
|
|
564
|
-
let invariantFailure;
|
|
565
|
-
const args = [
|
|
566
|
-
"settings/updated",
|
|
567
|
-
registration.ns,
|
|
568
|
-
next,
|
|
569
|
-
prev,
|
|
570
|
-
source
|
|
571
|
-
];
|
|
572
|
-
for (const listener of this.ctx.events.dispatch("emit", args)) try {
|
|
573
|
-
const returned = listener(registration.ns, next, prev, source);
|
|
574
|
-
if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
|
|
575
|
-
this.warnListenerFailure(registration.ns, error);
|
|
576
|
-
});
|
|
577
|
-
} catch (error) {
|
|
578
|
-
if (error?.code === "INVARIANT") {
|
|
579
|
-
invariantFailure ??= error;
|
|
580
|
-
continue;
|
|
581
|
-
}
|
|
582
|
-
this.warnListenerFailure(registration.ns, error);
|
|
583
|
-
}
|
|
584
|
-
if (invariantFailure !== void 0) throw invariantFailure;
|
|
585
|
-
}
|
|
586
|
-
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
|
587
|
-
warnWatcherFailure(ns, error) {
|
|
588
|
-
this.ctx.logger.warn("settings: watcher for \"%s\" failed", ns);
|
|
589
|
-
this.ctx.logger.warn(error);
|
|
590
|
-
}
|
|
591
|
-
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
592
|
-
warnListenerFailure(ns, error) {
|
|
593
|
-
this.ctx.logger.warn("settings: a settings/updated listener for \"%s\" failed", ns);
|
|
594
|
-
this.ctx.logger.warn(error);
|
|
538
|
+
schema(entry) {
|
|
539
|
+
const schema = entry.fiber?.runtime?.Config;
|
|
540
|
+
return schema !== void 0 && "toJSON" in schema ? schema : void 0;
|
|
595
541
|
}
|
|
596
542
|
};
|
|
597
|
-
/**
|
|
598
|
-
* Value mirror of the `FiberState` members {@link isUnloading} compares
|
|
599
|
-
* against: a const enum has no runtime object to import, and the value is
|
|
600
|
-
* needed at runtime (same rationale as the CLI boot driver's mirror).
|
|
601
|
-
*/
|
|
602
|
-
const FIBER_DISPOSED = 4;
|
|
603
|
-
const FIBER_UNLOADING = 5;
|
|
604
|
-
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
|
|
605
|
-
function isUnloading(ctx) {
|
|
606
|
-
const state = ctx.fiber.state;
|
|
607
|
-
return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
|
|
608
|
-
}
|
|
609
543
|
//#endregion
|
|
610
|
-
export { SettingsConflictError,
|
|
544
|
+
export { SettingsConflictError, SettingsForms, SettingsForms as default, redactSecrets };
|