@deepseek-ai/dsh-settings 0.1.6-alpha.2 → 0.1.7-alpha.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/lib/index.js CHANGED
@@ -1,5 +1,11 @@
1
- import { Service } from "@deepseek-ai/cordis";
2
- import { deepEqualJson, deepFreeze } from "@deepseek-ai/dsh-util-values";
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 follows `object`, `dict`, and `array` containers; a secret must be
58
- * declared directly on a field reachable through those containers (a secret
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: walk(schema, value, [], secrets),
70
- secrets
86
+ value: stripped,
87
+ secrets: [...positions.values()]
71
88
  };
72
89
  }
73
90
  //#endregion
74
- //#region lib/types/index.js
75
- /**
76
- * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
77
- * per-namespace sections; plugins register a namespace schema and read the
78
- * resolved value, which layers schema defaults, the registrant's composition
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
- const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
83
- function parseSettingsNamespace(value) {
84
- if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
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
- * A write refused because the namespace moved since the caller read it. The
89
- * Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
90
- * from one holding a stale snapshot, which is what this reports.
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 [head, ...rest] = op.path;
120
- if (head === void 0) {
121
- if (op.op === "unset") return {};
122
- if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
123
- return { ...op.value };
124
- }
125
- if (rest.length === 0) {
126
- if (op.op === "set") return {
127
- ...section,
128
- [head]: op.value
129
- };
130
- const { [head]: _removed, ...kept } = section;
131
- return kept;
132
- }
133
- const child = section[head];
134
- if (!isPlainObject(child)) {
135
- if (op.op === "unset") return section;
136
- return {
137
- ...section,
138
- [head]: applyPathOp({}, {
139
- ...op,
140
- path: rest
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 - plain-object write input (caller-checked).
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, reject) {
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[key] = clone(entry, `${path}.${key}`);
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[key] = key in merged ? mergeLayers(merged[key], value) : value;
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
- * Abstract settings service. Providers implement raw-document storage
219
- * (`load`/`persist`) and push external changes through {@link Settings.publish};
220
- * the base class owns namespace registration, resolution, validation, change
221
- * detection, and the `settings/updated` commit event.
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
- var SettingsProvider = class extends Service {
224
- registrations = /* @__PURE__ */ new Map();
225
- /** Latest published raw document; empty until the provider's first publish. */
226
- document = {};
227
- /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
228
- writeQueues = /* @__PURE__ */ new Map();
229
- /** In-flight watcher invocation segments, drained by the dispose teardown. */
230
- pendingTails = /* @__PURE__ */ new Set();
231
- /** Set at service dispose: refuse new writes while queued ones drain. */
232
- stopped = false;
233
- /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
234
- isStopped() {
235
- return this.stopped;
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
- constructor(ctx) {
238
- super(ctx, "settings");
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
- * Load the provider's document once and publish it before the service
242
- * becomes injectable, and register the write-drain teardown. Providers with
243
- * their own init (watchers, connections) delegate here first via
244
- * `yield* super[Service.init]()`; their disposers then run before the drain.
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
- async *[Service.init]() {
247
- yield async () => {
248
- this.stopped = true;
249
- await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]);
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
- * Absolute path of the provider's user-editable document, when its storage
255
- * is one local file. Configuration surfaces use this only as availability
256
- * metadata; the guarded open operation resolves the path again Host-side.
257
- * Non-file providers leave it undefined and expose no open-document affordance.
258
- * @returns the absolute local document path, or undefined for non-file storage.
259
- */
260
- get documentPath() {}
261
- /**
262
- * Prepare the provider's user-editable document for a native editor. File
263
- * providers may materialize an absent document before returning its path;
264
- * non-file providers return undefined.
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
- * Register a namespace schema and receive its owner scope. The registration
272
- * is an effect on the calling plugin's fiber: disposing that fiber removes
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
- * Attach one optional-settings consumer to this provider. The consumer
318
- * registers its composition entry as the base layer while this provider is
319
- * present, then falls back to that entry if the provider detaches.
320
- * @param owner - consumer context whose unload suppresses fallback work.
321
- * @param ns - consumer-owned settings namespace.
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
- installSection(owner, ns, schema, entry, hooks) {
328
- const scope = this.register(ns, schema, {
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
- * Describe every registered namespace for configuration surfaces, including
346
- * the composition `base` and raw user layers so a form can mark which fields
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
- return [...this.registrations.values()].map((registration) => {
353
- let user;
354
- try {
355
- user = this.section(registration.ns);
356
- } catch {
357
- user = void 0;
358
- }
359
- const base = registration.base === void 0 ? void 0 : structuredClone(registration.base);
360
- const detachedUser = user === void 0 ? void 0 : structuredClone(user);
361
- const descriptor = {
362
- ns: registration.ns,
363
- schema: registration.schema.toJSON(),
364
- value: registration.resolved,
365
- revision: registration.revision,
366
- ...base === void 0 ? {} : { base },
367
- ...detachedUser === void 0 ? {} : { user: detachedUser },
368
- applies: registration.applies
369
- };
370
- if (options?.redactSecrets !== true) return descriptor;
371
- const schema = registration.schema;
372
- const redacted = redactSecrets(schema, registration.resolved);
373
- return {
374
- ...descriptor,
375
- value: redacted.value,
376
- ...base === void 0 ? {} : { base: redactSecrets(schema, base).value },
377
- ...detachedUser === void 0 ? {} : { user: redactSecrets(schema, detachedUser).value },
378
- secrets: redacted.secrets
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
- * Read one registered namespace's resolved value.
384
- * @param ns - the namespace to read.
385
- * @returns the resolved value, or `undefined` while unregistered.
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
- return this.write(parseSettingsNamespace(ns), patch, "merge", expectedRevision);
471
+ const input = cloneJsonShaped(patch);
472
+ await this.write(ns, (current) => mergeLayers(current, input), expectedRevision);
405
473
  }
406
- /**
407
- * Replace one registered namespace's user section wholesale, validate,
408
- * persist, then commit and emit. Keys absent from `section` fall back to the
409
- * composition `base` and schema defaults — this is the removal/reset path a
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
- return this.write(parseSettingsNamespace(ns), section, "replace", expectedRevision);
480
+ const input = cloneJsonShaped(section);
481
+ await this.write(ns, (_current, base) => mergeLayers(base, input), expectedRevision);
419
482
  }
420
- /**
421
- * Apply path-addressed edits to one registered namespace's user section,
422
- * validate, persist, then commit and emit. The ops are applied to the
423
- * section as it stands when the write reaches the front of the queue, so a
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
- const parsedNs = parseSettingsNamespace(ns);
435
- if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`);
436
- for (const op of ops) {
437
- if (!isPlainObject(op) || op["op"] !== "set" && op["op"] !== "unset") throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`);
438
- if (!Array.isArray(op["path"]) || op["path"].some((part) => typeof part !== "string")) throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`);
439
- }
440
- return this.write(parsedNs, ops, "mutate", expectedRevision);
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
- /** Validate a write, then queue it on the namespace's serialized write chain. */
443
- write(ns, input, mode, expectedRevision) {
444
- const verb = mode === "merge" ? "update" : mode === "replace" ? "replace" : "mutate";
445
- const registration = this.registrations.get(ns);
446
- if (registration === void 0) throw new Error(`settings namespace "${ns}" is not registered`);
447
- if (this.isStopped()) throw new Error(`settings service is disposed: "${ns}" cannot be written`);
448
- if (!this.writable) throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`);
449
- let payload;
450
- if (mode === "mutate") payload = { ops: input };
451
- else {
452
- if (!isPlainObject(input)) throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`);
453
- payload = input;
454
- }
455
- const snapshot = cloneJsonShaped(payload, (label, path) => /* @__PURE__ */ new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`));
456
- const run = (this.writeQueues.get(ns) ?? Promise.resolve()).catch(() => void 0).then(async () => {
457
- if (this.isStopped()) throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`);
458
- if (this.registrations.get(ns) !== registration) throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`);
459
- const current = this.section(ns) ?? {};
460
- if (expectedRevision !== void 0 && expectedRevision !== registration.revision) throw new SettingsConflictError(ns, expectedRevision, registration.revision);
461
- const section = mode === "merge" ? mergeLayers(current, snapshot) : mode === "replace" ? snapshot : snapshot["ops"].reduce(applyPathOp, current);
462
- const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate));
463
- await this.persist(ns, section);
464
- this.document[ns] = section;
465
- if (this.registrations.get(ns) === registration && !this.isStopped()) {
466
- this.bumpRevision(registration, current, section);
467
- this.commit(registration, next, "update");
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.writeQueues.set(ns, run);
471
- return run;
536
+ this.describe();
472
537
  }
473
- /**
474
- * Provider hook: commit a complete raw document observed in storage. Each
475
- * registered namespace re-resolves; an invalid section keeps that
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, SettingsProvider, SettingsProvider as default, redactSecrets };
544
+ export { SettingsConflictError, SettingsForms, SettingsForms as default, redactSecrets };