@skaile/forge-settings 0.0.0-stage → 1.0.0

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +45 -0
  8. package/dist/runtime/ai-sdk/index.js +39 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +83 -0
  14. package/dist/runtime/model/errors.d.ts +24 -0
  15. package/dist/runtime/model/errors.js +10 -0
  16. package/dist/runtime/server/credential-policy.d.ts +60 -0
  17. package/dist/runtime/server/credential-policy.js +64 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +185 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +37 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +223 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  43. package/dist/runtime/shared/harness-providers.js +38 -0
  44. package/dist/runtime/shared/harnesses.d.ts +19 -0
  45. package/dist/runtime/shared/harnesses.js +15 -0
  46. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  47. package/dist/runtime/shared/provider-registry.js +112 -0
  48. package/dist/runtime/shared/settings-types.d.ts +84 -0
  49. package/dist/runtime/shared/settings-types.js +0 -0
  50. package/dist/runtime/shims-vue.d.ts +8 -0
  51. package/dist/runtime/vue/index.d.ts +14 -0
  52. package/dist/runtime/vue/index.js +6 -0
  53. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  54. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  55. package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
  56. package/dist/runtime/vue/useProvidersPanel.js +655 -0
  57. package/dist/types.d.mts +9 -0
  58. package/package.json +117 -4
  59. package/src/runtime/ai-sdk/index.ts +91 -0
  60. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  61. package/src/runtime/editor/index.ts +157 -0
  62. package/src/runtime/model/errors.ts +31 -0
  63. package/src/runtime/server/credential-policy.ts +137 -0
  64. package/src/runtime/server/credential-store.ts +473 -0
  65. package/src/runtime/server/env-layers.ts +290 -0
  66. package/src/runtime/server/env.ts +46 -0
  67. package/src/runtime/server/harness-settings.ts +373 -0
  68. package/src/runtime/server/index.ts +24 -0
  69. package/src/runtime/server/provider-discovery.ts +164 -0
  70. package/src/runtime/server/provider-layers.ts +70 -0
  71. package/src/runtime/server/provider-service.ts +244 -0
  72. package/src/runtime/server/provider-settings.ts +192 -0
  73. package/src/runtime/server/provider-write.ts +438 -0
  74. package/src/runtime/server/settings-defaults.ts +87 -0
  75. package/src/runtime/shared/env-vars.ts +82 -0
  76. package/src/runtime/shared/harness-providers.ts +118 -0
  77. package/src/runtime/shared/harnesses.ts +34 -0
  78. package/src/runtime/shared/provider-registry.ts +391 -0
  79. package/src/runtime/shared/settings-types.ts +87 -0
  80. package/src/runtime/shims-vue.d.ts +8 -0
  81. package/src/runtime/vue/index.ts +15 -0
  82. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  83. package/src/runtime/vue/useProvidersPanel.ts +1087 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Editing the provider registry's on-disk layers over HTTP: the instance
3
+ * layer (admins) and a workspace's `<root>/.skaile/providers.yml` (write
4
+ * access, unless the admin locked providers).
5
+ *
6
+ * A layer file holds references, never secrets: a body that carries a key
7
+ * value is refused before anything else, and `credential.ref` must be one of
8
+ * the four reference forms. Every write is gated on the registry it would
9
+ * produce — the scope's layers merged over the catalog presets the entries
10
+ * name, as the spawned child merges them — so an entry the child would drop is
11
+ * refused with the upstream diagnostics instead of being written.
12
+ *
13
+ * An entry a lower document defines can be **patched** from a higher scope:
14
+ * a body holding only {@link PATCH_KEYS} is stored under that id in the
15
+ * scope's own document, where the registry merge applies it field by field
16
+ * over the inherited entry. Removing it again (`removeProviderEntry`) is the
17
+ * patch's reset.
18
+ *
19
+ * Writes never touch live sessions: a running chat keeps its endpoint until
20
+ * its model or harness changes.
21
+ *
22
+ * Auth stays in the app: it builds the {@link ProviderWriteScope} (paths, env,
23
+ * lock state), asks {@link providerScopeWritable}, and answers 403 itself.
24
+ */
25
+
26
+ import {
27
+ type AIProviderDiagnostic,
28
+ type AIProviderRegistryLayer,
29
+ type AIProviderRegistryPatch,
30
+ isSafeProviderId,
31
+ loadProviderRegistryFile,
32
+ mergeRegistries,
33
+ parseCredentialRef,
34
+ validateMergedRegistry,
35
+ validateRegistry,
36
+ } from "@skaile/workspaces/core";
37
+ import {
38
+ AI_PROVIDER_PRESETS,
39
+ type AIProviderPreset,
40
+ expandPreset,
41
+ } from "@skaile/workspaces/sdk/bridge";
42
+ import type { AIProviderEntry, AIProviderRegistry } from "../shared/provider-registry";
43
+ import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
44
+ import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers";
45
+
46
+ /**
47
+ * A refused provider write. Framework-free: a Nitro / h3 host answers a thrown
48
+ * error carrying `statusCode`, `statusMessage` and `data` with exactly that
49
+ * status, message and body, the same as one made by `createError`.
50
+ */
51
+ export class ProviderWriteError extends Error {
52
+ readonly statusCode: number;
53
+ readonly statusMessage: string;
54
+ readonly data?: unknown;
55
+ constructor(input: { statusCode: number; statusMessage: string; data?: unknown }) {
56
+ super(input.statusMessage);
57
+ this.name = "ProviderWriteError";
58
+ this.statusCode = input.statusCode;
59
+ this.statusMessage = input.statusMessage;
60
+ if (input.data !== undefined) this.data = input.data;
61
+ }
62
+ }
63
+
64
+ function createError(input: { statusCode: number; statusMessage: string; data?: unknown }) {
65
+ return new ProviderWriteError(input);
66
+ }
67
+
68
+ /** The refusal for a body that carries a secret instead of a reference. */
69
+ export const PROVIDER_SECRET_REFUSAL =
70
+ "Secrets go into the scope's .env; reference them as env:NAME";
71
+
72
+ const CREDENTIAL_REF_REFUSAL =
73
+ "credential.ref must be a reference, not a secret: env:NAME, cmd:<shell>, platform:<configId> or keyring:<service>/<account>";
74
+
75
+ const PATCH_REFUSAL =
76
+ "A workspace may only patch credential and enabled of an inherited entry; add it as a workspace entry to change anything else.";
77
+
78
+ /**
79
+ * Entry keys a `PUT` body may set, and that an omitting body removes. `kind`
80
+ * may be set too — an entry without a `preset` (the settings modal's
81
+ * "Custom") has nothing else to supply it — but, omitted, it keeps its
82
+ * written value like every other key of a stored entry.
83
+ */
84
+ const BODY_KEYS: Record<string, true> = {
85
+ preset: true,
86
+ label: true,
87
+ surface: true,
88
+ base_url: true,
89
+ headers: true,
90
+ credential: true,
91
+ model_map: true,
92
+ enabled: true,
93
+ };
94
+
95
+ /** Entry fields a higher scope may set on an entry a lower document defines. */
96
+ export const PATCH_KEYS = ["credential", "enabled"] as const;
97
+
98
+ /** Keys that only ever hold a secret value. */
99
+ const SECRET_KEYS: Record<string, true> = { apiKey: true, api_key: true, token: true };
100
+
101
+ /** One registry layer file a write goes to, and the layers merged beneath it. */
102
+ export interface ProviderWriteScope {
103
+ /** Which registry the caller edits. */
104
+ name: "instance" | "workspace";
105
+ /** The scope's own document (its `.yaml` spelling when only that one exists). */
106
+ file: string;
107
+ /** Documents merged beneath it, lowest first; empty for the lowest layer. */
108
+ below: string[];
109
+ /** The scope's effective environment, as `env:` credential references resolve against it. */
110
+ env: Record<string, string | undefined>;
111
+ /** The control plane's provider lock is in force: only an admin may write. */
112
+ lock: boolean;
113
+ /**
114
+ * The host's credential policy. A write whose resulting entry names a
115
+ * reference or environment variable the policy refuses is a 400 and nothing
116
+ * is written. Absent: no restriction.
117
+ */
118
+ policy?: CredentialPolicy;
119
+ }
120
+
121
+ /**
122
+ * Whether the caller may write the scope's document: an admin always; anyone
123
+ * else only to a workspace document while providers are not locked.
124
+ */
125
+ export function providerScopeWritable(
126
+ scope: ProviderWriteScope,
127
+ caller: { isAdmin: boolean },
128
+ ): boolean {
129
+ if (caller.isAdmin) return true;
130
+ return scope.name !== "instance" && !scope.lock;
131
+ }
132
+
133
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }
136
+
137
+ /** The merge layer a scope's own document feeds. */
138
+ function mergeLayerOf(scope: ProviderWriteScope): "user" | "workspace" {
139
+ return scope.name === "instance" ? "user" : "workspace";
140
+ }
141
+
142
+ /** A layer document's entries; a document that is not a registry reads as empty, as the loader skips it. */
143
+ function readLayerLeniently(file: string): Record<string, unknown> {
144
+ try {
145
+ return readProviderLayer(file);
146
+ } catch {
147
+ return {};
148
+ }
149
+ }
150
+
151
+ /** The scope's document for an edit; 409 when it cannot be read back safely. */
152
+ function editableLayer(scope: ProviderWriteScope): Record<string, unknown> {
153
+ try {
154
+ return readProviderLayer(scope.file);
155
+ } catch (err) {
156
+ throw createError({
157
+ statusCode: 409,
158
+ statusMessage: `${(err as Error).message} - fix it by hand first`,
159
+ });
160
+ }
161
+ }
162
+
163
+ /** The entry fields a `PUT` body sets, or the refusal. */
164
+ function readEntryBody(body: unknown): Record<string, unknown> | string {
165
+ if (!isPlainObject(body)) return "Body must be a provider entry object";
166
+ const credential = body.credential;
167
+ const credentialKeys = isPlainObject(credential) ? Object.keys(credential) : [];
168
+ if (
169
+ Object.keys(body).some((key) => Object.hasOwn(SECRET_KEYS, key)) ||
170
+ credentialKeys.some((key) => key === "value" || Object.hasOwn(SECRET_KEYS, key))
171
+ ) {
172
+ return PROVIDER_SECRET_REFUSAL;
173
+ }
174
+ const unknown = Object.keys(body).find((key) => !Object.hasOwn(BODY_KEYS, key) && key !== "kind");
175
+ if (unknown) return `'${unknown}' is not a provider entry field`;
176
+ if (body.preset !== undefined && !catalogPreset(body.preset)) {
177
+ return `Unknown preset '${String(body.preset)}'`;
178
+ }
179
+ if (credential !== undefined) {
180
+ if (!isPlainObject(credential)) return "credential must be { kind, ref }";
181
+ const ref = credential.ref;
182
+ if (ref !== undefined && (typeof ref !== "string" || !parseCredentialRef(ref)))
183
+ return CREDENTIAL_REF_REFUSAL;
184
+ }
185
+ return body;
186
+ }
187
+
188
+ /**
189
+ * Error diagnostics for entry `id` in the registry the scope would hold with
190
+ * `providers` as its own layer: the layer check (types, enums, literal
191
+ * secrets) plus the merged gate over the lower layers and the catalog presets
192
+ * the merged entries name. Only the edited entry's errors count — a broken
193
+ * entry elsewhere is not this write's doing.
194
+ */
195
+ function entryErrors(
196
+ scope: ProviderWriteScope,
197
+ providers: Record<string, unknown>,
198
+ id: string,
199
+ ): AIProviderDiagnostic[] {
200
+ const layer = mergeLayerOf(scope);
201
+ const lower = scope.below.map((file) => loadProviderRegistryFile(file, "user"));
202
+ const own = validateRegistry({ version: 1, providers }, { source: scope.file, layer });
203
+ const fileLayers: AIProviderRegistryLayer[] = [
204
+ ...lower.map((file) => ({ layer: file.layer, providers: file.document.providers })),
205
+ { layer, providers: own.document.providers },
206
+ ];
207
+ const catalog: AIProviderRegistryPatch = {};
208
+ for (const [entryId, patch] of Object.entries(mergeRegistries(fileLayers).providers)) {
209
+ const preset = catalogPreset(patch.preset);
210
+ if (preset) catalog[entryId] = expandPreset(preset);
211
+ }
212
+ const merged = validateMergedRegistry(
213
+ mergeRegistries([...fileLayers, { layer: "catalog", providers: catalog }]),
214
+ {
215
+ sources: [
216
+ ...lower.map((file) => ({ label: file.path, providers: file.document.providers })),
217
+ { label: scope.file, providers: own.document.providers },
218
+ ],
219
+ },
220
+ );
221
+ const path = `providers.${id}`;
222
+ return [...own.diagnostics, ...merged.diagnostics].filter(
223
+ (d) => d.severity === "error" && (d.path === path || d.path.startsWith(`${path}.`)),
224
+ );
225
+ }
226
+
227
+ /** Whether a stored entry holds only patch fields (or is absent) — nothing a workspace could not have inherited. */
228
+ function isPatchOrAbsent(entry: unknown): boolean {
229
+ if (entry === undefined) return true;
230
+ return (
231
+ isPlainObject(entry) &&
232
+ Object.keys(entry).every((key) => (PATCH_KEYS as readonly string[]).includes(key))
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Upsert entry `id` in the scope's document.
238
+ *
239
+ * - **Own entry** — the body sets the editable fields (`BODY_KEYS`) — one it
240
+ * omits is removed — and optionally `kind`; fields it does not set (`kind`,
241
+ * `transport`, `params`, …) keep their written value.
242
+ * - **Patch** — when a lower document defines `id` and the body holds only
243
+ * {@link PATCH_KEYS}, the body is merged into the scope's stored entry for
244
+ * `id` (created when absent), leaving every other stored field alone.
245
+ *
246
+ * The file and its directory are created when absent; other entries are kept
247
+ * as written.
248
+ *
249
+ * 400: invalid id, secret-bearing or malformed body, a body for an inherited
250
+ * entry that is neither a patch nor over an own full entry, or any error
251
+ * diagnostic for the entry (as `data.diagnostics`; nothing is written). 403:
252
+ * the entry is `locked: true` in some document. 409: the document on disk is
253
+ * not a registry.
254
+ */
255
+ export function upsertProviderEntry(
256
+ scope: ProviderWriteScope,
257
+ id: string,
258
+ body: unknown,
259
+ ): { id: string; entry: Record<string, unknown> } {
260
+ if (!isSafeProviderId(id)) {
261
+ throw createError({
262
+ statusCode: 400,
263
+ statusMessage: `'${id}' is not a valid provider id - use [a-z0-9][a-z0-9_-]*`,
264
+ });
265
+ }
266
+ const fields = readEntryBody(body);
267
+ if (typeof fields === "string") throw createError({ statusCode: 400, statusMessage: fields });
268
+
269
+ const providers = editableLayer(scope);
270
+ const existing = providers[id];
271
+ const lowerEntries = scope.below.map((file) => readLayerLeniently(file)[id]);
272
+ const inherited = lowerEntries.some((entry) => entry !== undefined);
273
+
274
+ if ([...lowerEntries, existing].some((entry) => isPlainObject(entry) && entry.locked === true)) {
275
+ throw createError({
276
+ statusCode: 403,
277
+ statusMessage: `Provider '${id}' is locked by the control plane`,
278
+ });
279
+ }
280
+
281
+ const fieldKeys = Object.keys(fields);
282
+ const isPatch =
283
+ inherited &&
284
+ fieldKeys.length > 0 &&
285
+ fieldKeys.every((key) => (PATCH_KEYS as readonly string[]).includes(key));
286
+ if (inherited && !isPatch && isPatchOrAbsent(existing)) {
287
+ throw createError({ statusCode: 400, statusMessage: PATCH_REFUSAL });
288
+ }
289
+
290
+ let entry: Record<string, unknown>;
291
+ if (isPatch) {
292
+ entry = { ...(isPlainObject(existing) ? existing : {}), ...fields };
293
+ } else {
294
+ const kept = isPlainObject(existing)
295
+ ? Object.fromEntries(
296
+ Object.entries(existing).filter(([key]) => !Object.hasOwn(BODY_KEYS, key)),
297
+ )
298
+ : {};
299
+ entry = { ...kept, ...fields };
300
+ }
301
+ const next = { ...providers, [id]: entry };
302
+
303
+ const refusal = scope.policy
304
+ ? entryPolicyRefusal(entry as unknown as AIProviderEntry, scope.policy)
305
+ : undefined;
306
+ if (refusal) throw createError({ statusCode: 400, statusMessage: refusal });
307
+
308
+ const diagnostics = entryErrors(scope, next, id);
309
+ if (diagnostics.length > 0) {
310
+ throw createError({
311
+ statusCode: 400,
312
+ statusMessage: `Invalid provider entry '${id}': ${diagnostics.map((d) => d.path).join(", ")}`,
313
+ data: { diagnostics },
314
+ });
315
+ }
316
+ writeProviderLayer(scope.file, next);
317
+ return { id, entry };
318
+ }
319
+
320
+ /**
321
+ * Remove entry `id` from the scope's document; 404 when that document does
322
+ * not define it. On a patched entry that deletes the patch and the inherited
323
+ * entry shows through again.
324
+ */
325
+ export function removeProviderEntry(scope: ProviderWriteScope, id: string): { id: string } {
326
+ const providers = editableLayer(scope);
327
+ if (!Object.hasOwn(providers, id)) {
328
+ throw createError({
329
+ statusCode: 404,
330
+ statusMessage: `No provider '${id}' in the ${scope.name} registry`,
331
+ });
332
+ }
333
+ const { [id]: _removed, ...rest } = providers;
334
+ writeProviderLayer(scope.file, rest);
335
+ return { id };
336
+ }
337
+
338
+ /** A catalog preset as the settings modal lists it. */
339
+ export interface ProviderPresetSummary {
340
+ id: string;
341
+ label: string;
342
+ kind: AIProviderPreset["kind"];
343
+ surface?: AIProviderPreset["surface"];
344
+ base_url?: string;
345
+ base_url_env?: string;
346
+ credential: { kind: AIProviderPreset["credential"]["kind"]; env?: string };
347
+ }
348
+
349
+ /** Catalog presets (all of them by default), in catalog order. */
350
+ export function providerPresetSummaries(
351
+ presets: AIProviderPreset[] = Object.values(AI_PROVIDER_PRESETS),
352
+ ): ProviderPresetSummary[] {
353
+ return presets.map((preset) => ({
354
+ id: preset.id,
355
+ label: preset.label,
356
+ kind: preset.kind,
357
+ surface: preset.surface,
358
+ base_url: preset.base_url,
359
+ base_url_env: preset.base_url_env,
360
+ credential: { kind: preset.credential.kind, env: preset.credential.env },
361
+ }));
362
+ }
363
+
364
+ /**
365
+ * Presets a scope could configure right now: their `credential.env` is set
366
+ * in `env`, and no entry of `registry` is the preset's id or names it as its
367
+ * `preset`.
368
+ *
369
+ * @param env - The scope's layered environment.
370
+ * @param registry - The scope's merged registry.
371
+ */
372
+ export function detectedProviderPresets(
373
+ env: Record<string, string | undefined>,
374
+ registry: AIProviderRegistry,
375
+ ): ProviderPresetSummary[] {
376
+ const used = new Set(Object.keys(registry));
377
+ for (const entry of Object.values(registry)) {
378
+ if (entry.preset !== undefined) used.add(entry.preset);
379
+ }
380
+ return providerPresetSummaries(
381
+ Object.values(AI_PROVIDER_PRESETS).filter((preset) => {
382
+ const name = preset.credential.env;
383
+ return name !== undefined && Boolean(env[name]?.trim()) && !used.has(preset.id);
384
+ }),
385
+ );
386
+ }
387
+
388
+ /** Which registry document holds a provider entry the scope lists. */
389
+ export type ProviderLayerName = ProviderWriteScope["name"];
390
+
391
+ /**
392
+ * The defining document's values for the `PUT`-editable fields a settings UI
393
+ * edit dialog does not show. A `PUT` removes every editable field it omits,
394
+ * so an edit sends these back unchanged instead of dropping them.
395
+ */
396
+ export interface ProviderLayerFields {
397
+ label?: unknown;
398
+ headers?: unknown;
399
+ model_map?: unknown;
400
+ enabled?: unknown;
401
+ }
402
+
403
+ const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled"] as const;
404
+
405
+ /**
406
+ * Tag each provider view of a scope with every registry document that holds
407
+ * its id — `layers`, lowest first: `"instance"` for each lower document, then
408
+ * the scope's own name when its own document holds it. An id no document
409
+ * holds (not expected) is attributed to the lowest layer of the scope.
410
+ * `layerFields` are the {@link ProviderLayerFields} of the scope's own entry,
411
+ * or of the lowest document holding the id when the scope has none.
412
+ */
413
+ export function withProviderLayers<T extends { id: string }>(
414
+ views: T[],
415
+ scope: ProviderWriteScope,
416
+ ): Array<T & { layers: ProviderLayerName[]; layerFields: ProviderLayerFields }> {
417
+ const own = readLayerLeniently(scope.file);
418
+ const lower = scope.below.map((file) => readLayerLeniently(file));
419
+ return views.map((view) => {
420
+ const layers = lower
421
+ .filter((doc) => Object.hasOwn(doc, view.id))
422
+ .map((): ProviderLayerName => "instance");
423
+ if (Object.hasOwn(own, view.id)) layers.push(scope.name);
424
+ const deduped = [...new Set(layers)];
425
+ if (deduped.length === 0) deduped.push(scope.below.length > 0 ? "instance" : scope.name);
426
+
427
+ const entry = Object.hasOwn(own, view.id)
428
+ ? own[view.id]
429
+ : lower.find((doc) => Object.hasOwn(doc, view.id))?.[view.id];
430
+ const layerFields: ProviderLayerFields = {};
431
+ if (isPlainObject(entry)) {
432
+ for (const key of LAYER_FIELD_KEYS) {
433
+ if (entry[key] !== undefined) layerFields[key] = entry[key];
434
+ }
435
+ }
436
+ return { ...view, layers: deduped, layerFields };
437
+ });
438
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Settings defaults and the legacy harness-key migration.
3
+ *
4
+ * Storage stays with the host: this module reads and writes nothing except the
5
+ * one `settings.json` {@link migrateLegacyHarnessSettings} is pointed at.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { SETTINGS_DEFAULTS as CORE_DEFAULTS } from "@skaile/workspaces/sdk/core";
10
+ import type { SkaileSettings } from "../shared/settings-types";
11
+
12
+ /**
13
+ * App-level defaults merged on top of core defaults.
14
+ *
15
+ * No voice defaults live here. `sttProvider`/`ttsProvider` default to the first
16
+ * enabled entry of that kind in registry order (see `resolveVoiceProvider`),
17
+ * and the language and voice id default to the chosen entry's `params`.
18
+ */
19
+ const APP_DEFAULTS: Partial<SkaileSettings> = {
20
+ domains: {
21
+ enabled: [
22
+ "skaileup-conceptualization",
23
+ "skaileup-implementation",
24
+ "skaileup-evaluate",
25
+ "dev-security",
26
+ ],
27
+ default: "skaileup-conceptualization",
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Merged default settings for forge apps.
33
+ *
34
+ * Combines core defaults with the app-specific domain defaults. Used as the
35
+ * lowest-priority layer during settings resolution.
36
+ */
37
+ export const SETTINGS_DEFAULTS: SkaileSettings = { ...CORE_DEFAULTS, ...APP_DEFAULTS };
38
+
39
+ /**
40
+ * Harness settings keys renamed, old name → new name. `driver` → `harness` is
41
+ * the `@skaile/workspaces` 6 rename (read as an alias for one major there too);
42
+ * the rest were renamed in forge-common 6.0.0.
43
+ */
44
+ const LEGACY_HARNESS_KEYS: Readonly<Record<string, string>> = {
45
+ driver: "harness",
46
+ driverModels: "harnessModels",
47
+ driverProviders: "harnessProviders",
48
+ enabledBackends: "enabledHarnesses",
49
+ lockBackend: "lockHarness",
50
+ };
51
+
52
+ /**
53
+ * Rewrite one `settings.json` from the legacy harness keys to the current
54
+ * ones. Each legacy key is moved to its new name and removed; when the new key
55
+ * already exists it wins and the legacy value is dropped. A file without legacy
56
+ * keys (or one that is missing / not a JSON object) is left byte-identical.
57
+ *
58
+ * @param settingsPath - Absolute path to a `settings.json`.
59
+ * @returns The legacy keys that were rewritten (empty when nothing changed).
60
+ */
61
+ export function migrateLegacyHarnessSettings(settingsPath: string): { mapped: string[] } {
62
+ const mapped: string[] = [];
63
+ if (!existsSync(settingsPath)) return { mapped };
64
+
65
+ let settings: unknown;
66
+ try {
67
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
68
+ } catch {
69
+ return { mapped };
70
+ }
71
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
72
+ return { mapped };
73
+ }
74
+
75
+ const doc = settings as Record<string, unknown>;
76
+ for (const [legacy, current] of Object.entries(LEGACY_HARNESS_KEYS)) {
77
+ if (!(legacy in doc)) continue;
78
+ if (!(current in doc)) doc[current] = doc[legacy];
79
+ delete doc[legacy];
80
+ mapped.push(legacy);
81
+ }
82
+
83
+ if (mapped.length > 0) {
84
+ writeFileSync(settingsPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
85
+ }
86
+ return { mapped };
87
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Environment-layer vocabulary shared by the server helpers
3
+ * (`server/utils/env-layers.ts`) and the settings modal's Providers tab.
4
+ *
5
+ * Browser-safe on purpose — no `node:` imports — so the client refuses the
6
+ * same names the server refuses, from one source.
7
+ *
8
+ * Layers, lowest to highest precedence: `process` (boot env), `instance`
9
+ * (`$FORGE_HOME/.env`), `workspace` (`<workspace root>/.env`). The nearest
10
+ * layer defining a non-empty value wins.
11
+ */
12
+
13
+ /** One source of environment variables. */
14
+ export type EnvLayerId = "process" | "instance" | "workspace";
15
+
16
+ /** Layer precedence, lowest first. */
17
+ export const ENV_LAYER_ORDER: readonly EnvLayerId[] = ["process", "instance", "workspace"];
18
+
19
+ /** Names an editable `.env` layer accepts. */
20
+ export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
21
+
22
+ /** Names no settings UI may write: they steer the process or forge itself. */
23
+ export const ENV_VAR_DENYLIST: readonly string[] = [
24
+ "PATH",
25
+ "HOME",
26
+ "NODE_OPTIONS",
27
+ "LD_PRELOAD",
28
+ "DYLD_INSERT_LIBRARIES",
29
+ "FORGE_HOME",
30
+ "FORGE_CREDENTIAL_KEY",
31
+ "FORGE_CREDENTIAL_STORE",
32
+ "SKAILE_HOME",
33
+ ];
34
+
35
+ /** Refusal text for a name an editable layer must not hold, or `null` when it is fine. */
36
+ export function validateEnvVarName(name: string): string | null {
37
+ if (!ENV_VAR_NAME_PATTERN.test(name)) {
38
+ return `Invalid variable name "${name}": use upper-case letters, digits and underscores, not starting with a digit`;
39
+ }
40
+ if (ENV_VAR_DENYLIST.includes(name)) {
41
+ return `"${name}" cannot be set from settings`;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** One variable as the browser sees it — never the value. */
47
+ export interface EnvVarView {
48
+ name: string;
49
+ /** Last 4 characters when the value has at least 8, else `""`. */
50
+ hint: string;
51
+ length: number;
52
+ /** Layer whose value wins. */
53
+ source: EnvLayerId;
54
+ /** Lower layers that also define the name (their value is shadowed). */
55
+ shadows: EnvLayerId[];
56
+ /** Registry entry ids whose credential references `env:<name>`. */
57
+ referencedBy: string[];
58
+ }
59
+
60
+ /** A registry `env:` reference no layer defines. */
61
+ export interface EnvSettingsMissing {
62
+ name: string;
63
+ referencedBy: string[];
64
+ }
65
+
66
+ /** Response of `GET`/`PUT <environmentUrl>`. */
67
+ export interface EnvSettingsDocument {
68
+ scope: "instance" | "workspace";
69
+ /** Whether the caller may `PUT` to this document's layer. */
70
+ editable: boolean;
71
+ /** The layer this document edits. */
72
+ layer: "instance" | "workspace";
73
+ /** Sorted by name. */
74
+ vars: EnvVarView[];
75
+ missing: EnvSettingsMissing[];
76
+ }
77
+
78
+ /** Request body of `PUT <environmentUrl>`. */
79
+ export interface EnvSettingsUpdate {
80
+ set: Record<string, string>;
81
+ unset: string[];
82
+ }