@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.
- package/LICENSE +21 -0
- package/dist/config-writer.d.ts +30 -0
- package/dist/config-writer.d.ts.map +1 -0
- package/dist/config-writer.js +57 -0
- package/dist/config-writer.js.map +1 -0
- package/dist/define.d.ts +17 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +18 -0
- package/dist/define.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +28 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +210 -0
- package/dist/loader.js.map +1 -0
- package/dist/merge.d.ts +9 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +45 -0
- package/dist/merge.js.map +1 -0
- package/dist/plugin-settings-schema.d.ts +24 -0
- package/dist/plugin-settings-schema.d.ts.map +1 -0
- package/dist/plugin-settings-schema.js +17 -0
- package/dist/plugin-settings-schema.js.map +1 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +329 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugins-tree-schema.d.ts +444 -0
- package/dist/plugins-tree-schema.d.ts.map +1 -0
- package/dist/plugins-tree-schema.js +93 -0
- package/dist/plugins-tree-schema.js.map +1 -0
- package/dist/schema.d.ts +1200 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +198 -0
- package/dist/schema.js.map +1 -0
- package/dist/user-config.d.ts +77 -0
- package/dist/user-config.d.ts.map +1 -0
- package/dist/user-config.js +265 -0
- package/dist/user-config.js.map +1 -0
- package/package.json +56 -0
- package/src/config-writer.test.ts +52 -0
- package/src/config-writer.ts +88 -0
- package/src/define.ts +19 -0
- package/src/index.ts +64 -0
- package/src/loader.test.ts +122 -0
- package/src/loader.ts +232 -0
- package/src/loader.yaml.test.ts +149 -0
- package/src/merge.test.ts +78 -0
- package/src/merge.ts +44 -0
- package/src/plugin-settings-schema.ts +18 -0
- package/src/plugin.test.ts +310 -0
- package/src/plugin.ts +360 -0
- package/src/plugins-tree-schema.test.ts +25 -0
- package/src/plugins-tree-schema.ts +103 -0
- package/src/schema.ts +218 -0
- package/src/security-config.test.ts +30 -0
- package/src/user-config.test.ts +113 -0
- 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
|
+
}
|