@moxxy/config 0.27.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/dist/config-writer.d.ts +30 -0
  3. package/dist/config-writer.d.ts.map +1 -0
  4. package/dist/config-writer.js +57 -0
  5. package/dist/config-writer.js.map +1 -0
  6. package/dist/define.d.ts +17 -0
  7. package/dist/define.d.ts.map +1 -0
  8. package/dist/define.js +18 -0
  9. package/dist/define.js.map +1 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +11 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/loader.d.ts +28 -0
  15. package/dist/loader.d.ts.map +1 -0
  16. package/dist/loader.js +210 -0
  17. package/dist/loader.js.map +1 -0
  18. package/dist/merge.d.ts +9 -0
  19. package/dist/merge.d.ts.map +1 -0
  20. package/dist/merge.js +45 -0
  21. package/dist/merge.js.map +1 -0
  22. package/dist/plugin-settings-schema.d.ts +24 -0
  23. package/dist/plugin-settings-schema.d.ts.map +1 -0
  24. package/dist/plugin-settings-schema.js +17 -0
  25. package/dist/plugin-settings-schema.js.map +1 -0
  26. package/dist/plugin.d.ts +21 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/plugin.js +329 -0
  29. package/dist/plugin.js.map +1 -0
  30. package/dist/plugins-tree-schema.d.ts +444 -0
  31. package/dist/plugins-tree-schema.d.ts.map +1 -0
  32. package/dist/plugins-tree-schema.js +93 -0
  33. package/dist/plugins-tree-schema.js.map +1 -0
  34. package/dist/schema.d.ts +1200 -0
  35. package/dist/schema.d.ts.map +1 -0
  36. package/dist/schema.js +198 -0
  37. package/dist/schema.js.map +1 -0
  38. package/dist/user-config.d.ts +77 -0
  39. package/dist/user-config.d.ts.map +1 -0
  40. package/dist/user-config.js +265 -0
  41. package/dist/user-config.js.map +1 -0
  42. package/package.json +56 -0
  43. package/src/config-writer.test.ts +52 -0
  44. package/src/config-writer.ts +88 -0
  45. package/src/define.ts +19 -0
  46. package/src/index.ts +64 -0
  47. package/src/loader.test.ts +122 -0
  48. package/src/loader.ts +232 -0
  49. package/src/loader.yaml.test.ts +149 -0
  50. package/src/merge.test.ts +78 -0
  51. package/src/merge.ts +44 -0
  52. package/src/plugin-settings-schema.ts +18 -0
  53. package/src/plugin.test.ts +310 -0
  54. package/src/plugin.ts +360 -0
  55. package/src/plugins-tree-schema.test.ts +25 -0
  56. package/src/plugins-tree-schema.ts +103 -0
  57. package/src/schema.ts +218 -0
  58. package/src/security-config.test.ts +30 -0
  59. package/src/user-config.test.ts +113 -0
  60. package/src/user-config.ts +359 -0
@@ -0,0 +1,359 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createMutex } from '@moxxy/sdk';
3
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
4
+ import { type Document, isMap, parse as parseYaml, parseDocument } from 'yaml';
5
+ import { PLUGIN_CATEGORY_KEYS, type PluginCategoryKey } from './plugins-tree-schema.js';
6
+ import { type PluginSettings, pluginSettingsSchema } from './plugin-settings-schema.js';
7
+
8
+ /**
9
+ * Comment-preserving writers for the user's `~/.moxxy/config.yaml` — the single
10
+ * source of truth for the unified `plugins:` manifest. Runtime quick-switches
11
+ * (the TUI `/model` `/mode` pickers, the runner's provider switch, channel
12
+ * onboarding) persist here through these helpers, which edit the parsed YAML
13
+ * Document in place so user comments and unmodeled keys survive the round-trip.
14
+ *
15
+ * This REPLACES the old `~/.moxxy/preferences.json`: the persisted provider /
16
+ * mode / model / disabled-set now live in the same tree the rest of the config
17
+ * uses (`plugins.provider.default`, `plugins.mode.default`,
18
+ * `plugins.provider.items.<name>.{model,enabled}`), so there is no second store
19
+ * to reconcile.
20
+ */
21
+
22
+ /**
23
+ * Per-instance mutex serializing the read-modify-write of `config.yaml`. The
24
+ * atomic write prevents torn files, but two concurrent writers would otherwise
25
+ * read the same baseline and the second would clobber the first. Cross-process
26
+ * races (CLI vs running session) remain best-effort behind the atomic rename.
27
+ */
28
+ const configMutex = createMutex();
29
+
30
+ export interface UserConfigOptions {
31
+ readonly configPath?: string;
32
+ }
33
+
34
+ export function defaultUserConfigPath(): string {
35
+ return moxxyPath('config.yaml');
36
+ }
37
+
38
+ // --- package enable/disable ledger (plugins.packages.<pkg>) ---------------
39
+
40
+ export async function loadDisabledPackageNames(
41
+ opts: UserConfigOptions = {},
42
+ ): Promise<ReadonlySet<string>> {
43
+ const packages = await readPackagesMap(opts.configPath ?? defaultUserConfigPath());
44
+ const disabled = new Set<string>();
45
+ for (const [packageName, settings] of Object.entries(packages)) {
46
+ if (settings?.enabled === false) disabled.add(packageName);
47
+ }
48
+ return disabled;
49
+ }
50
+
51
+ export async function isPluginDisabled(
52
+ packageName: string,
53
+ opts: UserConfigOptions = {},
54
+ ): Promise<boolean> {
55
+ return (await loadDisabledPackageNames(opts)).has(packageName);
56
+ }
57
+
58
+ export async function setPluginEnabled(
59
+ packageName: string,
60
+ enabled: boolean,
61
+ opts: UserConfigOptions = {},
62
+ ): Promise<void> {
63
+ const configPath = opts.configPath ?? defaultUserConfigPath();
64
+ await configMutex.run(async () => {
65
+ const doc = await readUserConfigDoc(configPath);
66
+ pluginSettingsSchema.pick({ enabled: true }).parse({ enabled });
67
+ const existing = readRawEntry(doc, ['plugins', 'packages', packageName]);
68
+ doc.setIn(['plugins', 'packages', packageName], { ...existing, enabled });
69
+ await writeUserConfigDoc(configPath, doc);
70
+ });
71
+ }
72
+
73
+ export async function clearPluginState(
74
+ packageName: string,
75
+ opts: UserConfigOptions = {},
76
+ ): Promise<void> {
77
+ const configPath = opts.configPath ?? defaultUserConfigPath();
78
+ await configMutex.run(async () => {
79
+ const doc = await readUserConfigDoc(configPath);
80
+ if (!doc.hasIn(['plugins', 'packages', packageName])) return;
81
+ doc.deleteIn(['plugins', 'packages', packageName]);
82
+ pruneEmpty(doc, ['plugins', 'packages']);
83
+ pruneEmpty(doc, ['plugins']);
84
+ await writeUserConfigDoc(configPath, doc);
85
+ });
86
+ }
87
+
88
+ // --- category default swap (plugins.<category>.default) -------------------
89
+
90
+ export async function setCategoryDefault(
91
+ category: string,
92
+ name: string,
93
+ opts: UserConfigOptions = {},
94
+ ): Promise<void> {
95
+ if (!(PLUGIN_CATEGORY_KEYS as ReadonlyArray<string>).includes(category)) {
96
+ throw new Error(
97
+ `unknown plugin category '${category}' (expected one of: ${PLUGIN_CATEGORY_KEYS.join(', ')})`,
98
+ );
99
+ }
100
+ const configPath = opts.configPath ?? defaultUserConfigPath();
101
+ await configMutex.run(async () => {
102
+ const doc = await readUserConfigDoc(configPath);
103
+ doc.setIn(['plugins', category as PluginCategoryKey, 'default'], name);
104
+ await writeUserConfigDoc(configPath, doc);
105
+ });
106
+ }
107
+
108
+ // --- provider item options (plugins.provider.items.<name>.{model,enabled}) -
109
+
110
+ /** Persist the active model for a provider (`plugins.provider.items.<name>.model`). */
111
+ export async function setProviderModel(
112
+ providerName: string,
113
+ model: string,
114
+ opts: UserConfigOptions = {},
115
+ ): Promise<void> {
116
+ const configPath = opts.configPath ?? defaultUserConfigPath();
117
+ await configMutex.run(async () => {
118
+ const doc = await readUserConfigDoc(configPath);
119
+ doc.setIn(['plugins', 'provider', 'items', providerName, 'model'], model);
120
+ await writeUserConfigDoc(configPath, doc);
121
+ });
122
+ }
123
+
124
+ /** Enable/disable a provider (`plugins.provider.items.<name>.enabled`). */
125
+ export async function setProviderEnabled(
126
+ providerName: string,
127
+ enabled: boolean,
128
+ opts: UserConfigOptions = {},
129
+ ): Promise<void> {
130
+ const configPath = opts.configPath ?? defaultUserConfigPath();
131
+ await configMutex.run(async () => {
132
+ const doc = await readUserConfigDoc(configPath);
133
+ doc.setIn(['plugins', 'provider', 'items', providerName, 'enabled'], enabled);
134
+ await writeUserConfigDoc(configPath, doc);
135
+ });
136
+ }
137
+
138
+ /**
139
+ * One stored provider item's persisted shape at
140
+ * `plugins.provider.items.<name>` — the unified-tree home of the runtime-
141
+ * registered (OpenAI-compatible) vendors that used to live in
142
+ * `~/.moxxy/providers.json`. `config` carries the vendor payload
143
+ * (`kind: 'openai-compat'`, baseURL, models, envVar, …); `model` is the
144
+ * default model the activation walk reads.
145
+ */
146
+ export interface ProviderItemState {
147
+ readonly model?: string;
148
+ readonly enabled?: boolean;
149
+ readonly config?: Record<string, unknown>;
150
+ }
151
+
152
+ /** All provider items from the USER config (tolerant: {} on missing file). */
153
+ export async function loadProviderItems(
154
+ opts: UserConfigOptions = {},
155
+ ): Promise<Record<string, ProviderItemState>> {
156
+ const configPath = opts.configPath ?? defaultUserConfigPath();
157
+ try {
158
+ const raw = await fs.readFile(configPath, 'utf8');
159
+ const parsed = parseYaml(raw) as {
160
+ plugins?: { provider?: { items?: Record<string, ProviderItemState> } };
161
+ } | null;
162
+ return parsed?.plugins?.provider?.items ?? {};
163
+ } catch {
164
+ return {};
165
+ }
166
+ }
167
+
168
+ /** Upsert a provider item's `config` payload (and optionally its model). */
169
+ export async function setProviderItemConfig(
170
+ providerName: string,
171
+ config: Record<string, unknown>,
172
+ opts: UserConfigOptions & { readonly model?: string } = {},
173
+ ): Promise<void> {
174
+ const configPath = opts.configPath ?? defaultUserConfigPath();
175
+ await configMutex.run(async () => {
176
+ const doc = await readUserConfigDoc(configPath);
177
+ doc.setIn(['plugins', 'provider', 'items', providerName, 'config'], config);
178
+ if (opts.model) {
179
+ doc.setIn(['plugins', 'provider', 'items', providerName, 'model'], opts.model);
180
+ }
181
+ await writeUserConfigDoc(configPath, doc);
182
+ });
183
+ }
184
+
185
+ /** Remove a provider item entirely. Returns false when it wasn't present. */
186
+ export async function removeProviderItem(
187
+ providerName: string,
188
+ opts: UserConfigOptions = {},
189
+ ): Promise<boolean> {
190
+ const configPath = opts.configPath ?? defaultUserConfigPath();
191
+ return configMutex.run(async () => {
192
+ const doc = await readUserConfigDoc(configPath);
193
+ const path = ['plugins', 'provider', 'items', providerName];
194
+ if (!doc.hasIn(path)) return false;
195
+ doc.deleteIn(path);
196
+ await writeUserConfigDoc(configPath, doc);
197
+ return true;
198
+ });
199
+ }
200
+
201
+ // --- first-run wizard (moxxy init) ----------------------------------------
202
+
203
+ /** The subset of `moxxy init` selections that map onto the unified config tree. */
204
+ export interface InitConfigSelections {
205
+ /** Active provider contribution name (`plugins.provider.default`). */
206
+ readonly provider: string;
207
+ /** Default model for the provider (`plugins.provider.items.<name>.model`). */
208
+ readonly model?: string | null;
209
+ /** Ordered fallback provider names (`plugins.provider.fallbacks`); the primary is dropped. */
210
+ readonly fallbacks?: ReadonlyArray<string>;
211
+ /** Loop strategy (`plugins.mode.default`). */
212
+ readonly mode: string;
213
+ /** Memory embedder (`plugins.embedder.default`); the `tfidf` floor is left unwritten. */
214
+ readonly embedder: string;
215
+ /** Opt-in plugin-security toggle (top-level `security.enabled`). */
216
+ readonly security?: { readonly enabled: boolean };
217
+ }
218
+
219
+ /**
220
+ * Persist the interactive `moxxy init` wizard's selections into
221
+ * `~/.moxxy/config.yaml` — the same store `moxxy provision` and the runtime
222
+ * quick-switches write, so init no longer drops a legacy-shaped file in the
223
+ * project cwd that the clean-slate schema silently ignores.
224
+ *
225
+ * One atomic read-modify-write over the parsed Document, so the package ledger
226
+ * the wizard already wrote (enabling the provider + extra packages via
227
+ * {@link setPluginEnabled}) and any user comments survive the merge. Like
228
+ * `provision`, the provider's API key lives in the vault under its canonical
229
+ * name and the credential resolver finds it there — so no `${vault:...}` ref is
230
+ * written here.
231
+ */
232
+ export async function applyInitConfig(
233
+ sel: InitConfigSelections,
234
+ opts: UserConfigOptions = {},
235
+ ): Promise<string> {
236
+ const configPath = opts.configPath ?? defaultUserConfigPath();
237
+ await configMutex.run(async () => {
238
+ const doc = await readUserConfigDoc(configPath);
239
+ doc.setIn(['plugins', 'provider', 'default'], sel.provider);
240
+ if (sel.model) {
241
+ doc.setIn(['plugins', 'provider', 'items', sel.provider, 'model'], sel.model);
242
+ }
243
+ const fallbacks = (sel.fallbacks ?? []).filter((f) => f !== sel.provider);
244
+ if (fallbacks.length > 0) {
245
+ doc.setIn(['plugins', 'provider', 'fallbacks'], doc.createNode(fallbacks));
246
+ }
247
+ doc.setIn(['plugins', 'mode', 'default'], sel.mode);
248
+ // tfidf is the built-in floor — only persist a non-default embedder.
249
+ if (sel.embedder && sel.embedder !== 'tfidf') {
250
+ doc.setIn(['plugins', 'embedder', 'default'], sel.embedder);
251
+ }
252
+ if (sel.security?.enabled) {
253
+ doc.setIn(['security', 'enabled'], true);
254
+ }
255
+ await writeUserConfigDoc(configPath, doc);
256
+ });
257
+ return configPath;
258
+ }
259
+
260
+ /**
261
+ * The persisted active model — the default provider's `model` item option
262
+ * (`plugins.provider.items.<default>.model`). The user-level equivalent of the
263
+ * old `preferences.model`; used by the TUI as the effective model when no
264
+ * `--model` flag is passed. Returns undefined when unset.
265
+ */
266
+ export async function loadActiveModel(opts: UserConfigOptions = {}): Promise<string | undefined> {
267
+ const doc = await readUserConfigDoc(opts.configPath ?? defaultUserConfigPath());
268
+ const provider = doc.getIn(['plugins', 'provider', 'default']);
269
+ if (typeof provider !== 'string') return undefined;
270
+ const model = doc.getIn(['plugins', 'provider', 'items', provider, 'model']);
271
+ return typeof model === 'string' ? model : undefined;
272
+ }
273
+
274
+ /** The active provider contribution name (`plugins.provider.default`), or null. */
275
+ export async function loadActiveProvider(opts: UserConfigOptions = {}): Promise<string | null> {
276
+ const doc = await readUserConfigDoc(opts.configPath ?? defaultUserConfigPath());
277
+ const provider = doc.getIn(['plugins', 'provider', 'default']);
278
+ return typeof provider === 'string' ? provider : null;
279
+ }
280
+
281
+ /** Provider names disabled via `plugins.provider.items.<name>.enabled: false`. */
282
+ export async function loadDisabledProviders(
283
+ opts: UserConfigOptions = {},
284
+ ): Promise<ReadonlyArray<string>> {
285
+ const doc = await readUserConfigDoc(opts.configPath ?? defaultUserConfigPath());
286
+ const items = doc.getIn(['plugins', 'provider', 'items']);
287
+ if (!isMap(items)) return [];
288
+ const out: string[] = [];
289
+ for (const [name, entry] of Object.entries(items.toJSON() as Record<string, unknown>)) {
290
+ if (entry && typeof entry === 'object' && (entry as { enabled?: unknown }).enabled === false) {
291
+ out.push(name);
292
+ }
293
+ }
294
+ return out;
295
+ }
296
+
297
+ // --- shared YAML round-trip helpers ---------------------------------------
298
+
299
+ /**
300
+ * Parse `config.yaml` into a yaml Document, preserving comments and untouched
301
+ * keys for the write path. A malformed file degrades to an empty document
302
+ * rather than throwing, so a single hand-edit typo can't strand every writer.
303
+ * The bad file is left in place for the user to inspect.
304
+ */
305
+ async function readUserConfigDoc(configPath: string): Promise<Document> {
306
+ let raw = '';
307
+ try {
308
+ raw = await fs.readFile(configPath, 'utf8');
309
+ } catch (err) {
310
+ if (isNotFound(err)) return parseDocument('');
311
+ throw err;
312
+ }
313
+ const doc = parseDocument(raw);
314
+ if (doc.errors.length > 0) {
315
+ console.warn(
316
+ `moxxy: ignoring unparseable user config at ${configPath} (${doc.errors[0]?.message}); ` +
317
+ 'treating as empty',
318
+ );
319
+ return parseDocument('');
320
+ }
321
+ return doc;
322
+ }
323
+
324
+ async function writeUserConfigDoc(configPath: string, doc: Document): Promise<void> {
325
+ await writeFileAtomic(configPath, doc.toString());
326
+ }
327
+
328
+ /** Read the validated `plugins.packages` map for the read-only paths. */
329
+ async function readPackagesMap(configPath: string): Promise<Record<string, PluginSettings>> {
330
+ const doc = await readUserConfigDoc(configPath);
331
+ const packages = doc.getIn(['plugins', 'packages']);
332
+ if (!isMap(packages)) return {};
333
+ const out: Record<string, PluginSettings> = {};
334
+ for (const [key, entry] of Object.entries(packages.toJSON() as Record<string, unknown>)) {
335
+ const parsed = pluginSettingsSchema.safeParse(entry);
336
+ if (parsed.success) out[key] = parsed.data;
337
+ }
338
+ return out;
339
+ }
340
+
341
+ /** A node as plain JS (unvalidated), or {} when absent/non-object. */
342
+ function readRawEntry(doc: Document, path: ReadonlyArray<string>): Record<string, unknown> {
343
+ const node = doc.getIn(path, false);
344
+ if (!isMap(node)) return {};
345
+ const raw = node.toJSON() as unknown;
346
+ return typeof raw === 'object' && raw !== null && !Array.isArray(raw)
347
+ ? (raw as Record<string, unknown>)
348
+ : {};
349
+ }
350
+
351
+ /** Delete an emptied map node so we don't leave a bare `plugins: { packages: {} }`. */
352
+ function pruneEmpty(doc: Document, path: ReadonlyArray<string>): void {
353
+ const node = doc.getIn(path);
354
+ if (isMap(node) && node.items.length === 0) doc.deleteIn(path);
355
+ }
356
+
357
+ function isNotFound(err: unknown): boolean {
358
+ return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT';
359
+ }