@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,52 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { setConfigValue } from './config-writer.js';
|
|
6
|
+
|
|
7
|
+
let tmp: string;
|
|
8
|
+
let prevHome: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-cfgw-'));
|
|
12
|
+
prevHome = process.env.MOXXY_HOME;
|
|
13
|
+
process.env.MOXXY_HOME = path.join(tmp, 'home');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
if (prevHome === undefined) delete process.env.MOXXY_HOME;
|
|
18
|
+
else process.env.MOXXY_HOME = prevHome;
|
|
19
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('setConfigValue', () => {
|
|
23
|
+
it('creates the user config and sets a nested path', async () => {
|
|
24
|
+
const res = await setConfigValue({
|
|
25
|
+
scope: 'user',
|
|
26
|
+
cwd: tmp,
|
|
27
|
+
path: 'context.reasoning',
|
|
28
|
+
value: true,
|
|
29
|
+
});
|
|
30
|
+
expect(res.config.context?.reasoning).toBe(true);
|
|
31
|
+
const text = await fs.readFile(res.path, 'utf8');
|
|
32
|
+
expect(text).toContain('reasoning: true');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('preserves comments on existing YAML', async () => {
|
|
36
|
+
const home = path.join(tmp, 'home');
|
|
37
|
+
await fs.mkdir(home, { recursive: true });
|
|
38
|
+
const file = path.join(home, 'config.yaml');
|
|
39
|
+
await fs.writeFile(file, '# keep me\ncontext:\n caching: true # inline note\n');
|
|
40
|
+
await setConfigValue({ scope: 'user', cwd: tmp, path: 'tui.hints', value: false });
|
|
41
|
+
const text = await fs.readFile(file, 'utf8');
|
|
42
|
+
expect(text).toContain('# keep me');
|
|
43
|
+
expect(text).toContain('# inline note');
|
|
44
|
+
expect(text).toContain('hints: false');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects writes that would produce a schema-invalid config', async () => {
|
|
48
|
+
await expect(
|
|
49
|
+
setConfigValue({ scope: 'user', cwd: tmp, path: 'tui.theme', value: 'neon' }),
|
|
50
|
+
).rejects.toThrow(/invalid config/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createMutex } from '@moxxy/sdk';
|
|
4
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
5
|
+
import { findUpward } from './loader.js';
|
|
6
|
+
import { moxxyConfigSchema, type MoxxyConfig } from './schema.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ONE schema-validated, comment-preserving dot-path config writer. Both
|
|
10
|
+
* the model-facing `config_set` tool (plugin.ts) and UI surfaces (the TUI's
|
|
11
|
+
* /settings panel) write through here, sharing a single mutex, so concurrent
|
|
12
|
+
* writers can't interleave and no surface can persist a structurally-invalid
|
|
13
|
+
* config.
|
|
14
|
+
*/
|
|
15
|
+
export type ConfigScope = 'user' | 'project';
|
|
16
|
+
|
|
17
|
+
const USER_YAML = (): string => moxxyPath('config.yaml');
|
|
18
|
+
|
|
19
|
+
// YAML names only — the writer round-trips documents with `setIn`, which it
|
|
20
|
+
// can't do for the .ts/.js configs loadConfig also honors.
|
|
21
|
+
const PROJECT_YAML_NAMES = ['moxxy.config.yaml', 'moxxy.config.yml'] as const;
|
|
22
|
+
|
|
23
|
+
/** Serializes every config write in this process (tool + UI surfaces). */
|
|
24
|
+
export const configWriteMutex = createMutex();
|
|
25
|
+
|
|
26
|
+
export async function findScopePath(scope: ConfigScope, cwd: string): Promise<string | null> {
|
|
27
|
+
if (scope === 'user') {
|
|
28
|
+
const yaml = USER_YAML();
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(yaml);
|
|
31
|
+
return yaml;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findUpward(cwd, PROJECT_YAML_NAMES);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function scopeDefaultPath(scope: ConfigScope, cwd: string): string {
|
|
40
|
+
return scope === 'user' ? USER_YAML() : path.join(cwd, 'moxxy.config.yaml');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseDotPath(p: string): Array<string | number> {
|
|
44
|
+
return p.split('.').map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SetConfigValueOptions {
|
|
48
|
+
readonly scope: ConfigScope;
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
/** Dot path into the config (e.g. `context.reasoning`, `tui.hints`). */
|
|
51
|
+
readonly path: string;
|
|
52
|
+
/** The ALREADY-PARSED value to set (callers own string→value parsing). */
|
|
53
|
+
readonly value: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SetConfigValueResult {
|
|
57
|
+
/** The file that was written. */
|
|
58
|
+
readonly path: string;
|
|
59
|
+
/** The full validated config snapshot after the write. */
|
|
60
|
+
readonly config: MoxxyConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function setConfigValue(opts: SetConfigValueOptions): Promise<SetConfigValueResult> {
|
|
64
|
+
return configWriteMutex.run(async () => {
|
|
65
|
+
const target = (await findScopePath(opts.scope, opts.cwd)) ?? scopeDefaultPath(opts.scope, opts.cwd);
|
|
66
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
67
|
+
const yamlMod = (await import('yaml')) as typeof import('yaml');
|
|
68
|
+
let text = '';
|
|
69
|
+
try {
|
|
70
|
+
text = await fs.readFile(target, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
/* new file */
|
|
73
|
+
}
|
|
74
|
+
const doc = yamlMod.parseDocument(text);
|
|
75
|
+
doc.setIn(parseDotPath(opts.path), opts.value);
|
|
76
|
+
const candidate = String(doc);
|
|
77
|
+
const parsed = yamlMod.parse(candidate);
|
|
78
|
+
const validated = moxxyConfigSchema.safeParse(parsed ?? {});
|
|
79
|
+
if (!validated.success) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`config write to '${opts.path}' would produce an invalid config:\n` +
|
|
82
|
+
JSON.stringify(validated.error.issues, null, 2),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
await writeFileAtomic(target, candidate);
|
|
86
|
+
return { path: target, config: validated.data };
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MoxxyConfig } from './schema.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed configuration factory. Use in `moxxy.config.ts`:
|
|
5
|
+
*
|
|
6
|
+
* import { defineConfig } from '@moxxy/config';
|
|
7
|
+
*
|
|
8
|
+
* export default defineConfig({
|
|
9
|
+
* provider: { name: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
10
|
+
* plugins: {
|
|
11
|
+
* '@moxxy/plugin-mcp': { enabled: true, options: { servers: [...] } },
|
|
12
|
+
* '@acme/moxxy-plugin-shell': { enabled: false },
|
|
13
|
+
* },
|
|
14
|
+
* watcher: 'auto',
|
|
15
|
+
* });
|
|
16
|
+
*/
|
|
17
|
+
export function defineConfig(config: MoxxyConfig): MoxxyConfig {
|
|
18
|
+
return config;
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export { defineConfig } from './define.js';
|
|
2
|
+
export { loadConfig, type LoadConfigOptions, type LoadedConfig } from './loader.js';
|
|
3
|
+
export { mergeConfigs } from './merge.js';
|
|
4
|
+
export {
|
|
5
|
+
buildConfigPlugin,
|
|
6
|
+
type ConfigApplier,
|
|
7
|
+
type ConfigApplyResult,
|
|
8
|
+
} from './plugin.js';
|
|
9
|
+
export {
|
|
10
|
+
moxxyConfigSchema,
|
|
11
|
+
pluginSettingsSchema,
|
|
12
|
+
permissionsConfigSchema,
|
|
13
|
+
embeddingsConfigSchema,
|
|
14
|
+
securityConfigSchema,
|
|
15
|
+
watcherModeSchema,
|
|
16
|
+
type MoxxyConfig,
|
|
17
|
+
type PluginSettings,
|
|
18
|
+
type PermissionsConfig,
|
|
19
|
+
type EmbeddingsConfig,
|
|
20
|
+
type SecurityConfig,
|
|
21
|
+
type WatcherMode,
|
|
22
|
+
} from './schema.js';
|
|
23
|
+
export {
|
|
24
|
+
pluginsTreeSchema,
|
|
25
|
+
providerSlotSchema,
|
|
26
|
+
providerItemSchema,
|
|
27
|
+
categorySlotSchema,
|
|
28
|
+
PLUGIN_CATEGORY_KEYS,
|
|
29
|
+
type PluginsTree,
|
|
30
|
+
type ProviderSlot,
|
|
31
|
+
type ProviderItem,
|
|
32
|
+
type CategorySlot,
|
|
33
|
+
type PluginCategoryKey,
|
|
34
|
+
} from './plugins-tree-schema.js';
|
|
35
|
+
// Comment-preserving writers for ~/.moxxy/config.yaml — the single store that
|
|
36
|
+
// replaced ~/.moxxy/preferences.json (runtime provider/mode/model/disabled).
|
|
37
|
+
export {
|
|
38
|
+
applyInitConfig,
|
|
39
|
+
clearPluginState,
|
|
40
|
+
defaultUserConfigPath,
|
|
41
|
+
isPluginDisabled,
|
|
42
|
+
loadActiveModel,
|
|
43
|
+
loadActiveProvider,
|
|
44
|
+
loadDisabledPackageNames,
|
|
45
|
+
loadDisabledProviders,
|
|
46
|
+
loadProviderItems,
|
|
47
|
+
removeProviderItem,
|
|
48
|
+
setCategoryDefault,
|
|
49
|
+
setPluginEnabled,
|
|
50
|
+
setProviderEnabled,
|
|
51
|
+
setProviderItemConfig,
|
|
52
|
+
setProviderModel,
|
|
53
|
+
type ProviderItemState,
|
|
54
|
+
type InitConfigSelections,
|
|
55
|
+
type UserConfigOptions,
|
|
56
|
+
} from './user-config.js';
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
configWriteMutex,
|
|
60
|
+
setConfigValue,
|
|
61
|
+
type ConfigScope,
|
|
62
|
+
type SetConfigValueOptions,
|
|
63
|
+
type SetConfigValueResult,
|
|
64
|
+
} from './config-writer.js';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { loadConfig } from './loader.js';
|
|
6
|
+
|
|
7
|
+
let tmp: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-config-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('loadConfig', () => {
|
|
17
|
+
it('returns empty config when no file is found', async () => {
|
|
18
|
+
const result = await loadConfig({ cwd: tmp, skipUser: true });
|
|
19
|
+
expect(result.config).toEqual({});
|
|
20
|
+
expect(result.sources).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('loads a moxxy.config.js from cwd', async () => {
|
|
24
|
+
await fs.writeFile(
|
|
25
|
+
path.join(tmp, 'moxxy.config.js'),
|
|
26
|
+
`export default { plugins: { provider: { default: 'anthropic', items: { anthropic: { model: 'sonnet' } } } } };`,
|
|
27
|
+
);
|
|
28
|
+
const result = await loadConfig({ cwd: tmp, skipUser: true });
|
|
29
|
+
expect(result.config.plugins?.provider?.default).toBe('anthropic');
|
|
30
|
+
expect(result.config.plugins?.provider?.items?.anthropic?.model).toBe('sonnet');
|
|
31
|
+
expect(result.sources[0]?.scope).toBe('project');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('walks upward to find moxxy.config.js', async () => {
|
|
35
|
+
const nested = path.join(tmp, 'a/b/c');
|
|
36
|
+
await fs.mkdir(nested, { recursive: true });
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(tmp, 'moxxy.config.js'),
|
|
39
|
+
`export default { plugins: { mode: { default: 'default' } } };`,
|
|
40
|
+
);
|
|
41
|
+
const result = await loadConfig({ cwd: nested, skipUser: true });
|
|
42
|
+
expect(result.config.plugins?.mode?.default).toBe('default');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('honors explicitPath over upward search', async () => {
|
|
46
|
+
await fs.writeFile(
|
|
47
|
+
path.join(tmp, 'moxxy.config.js'),
|
|
48
|
+
`export default { plugins: { mode: { default: 'default' } } };`,
|
|
49
|
+
);
|
|
50
|
+
const custom = path.join(tmp, 'custom.config.js');
|
|
51
|
+
await fs.writeFile(custom, `export default { plugins: { mode: { default: 'research' } } };`);
|
|
52
|
+
const result = await loadConfig({ cwd: tmp, explicitPath: custom, skipUser: true });
|
|
53
|
+
expect(result.config.plugins?.mode?.default).toBe('research');
|
|
54
|
+
expect(result.sources[0]?.scope).toBe('explicit');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects a config whose schema is invalid', async () => {
|
|
58
|
+
await fs.writeFile(
|
|
59
|
+
path.join(tmp, 'moxxy.config.js'),
|
|
60
|
+
`export default { plugins: { provider: { default: 42 } } };`,
|
|
61
|
+
);
|
|
62
|
+
await expect(loadConfig({ cwd: tmp, skipUser: true })).rejects.toThrow(/Invalid moxxy config/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects a config with no default export', async () => {
|
|
66
|
+
await fs.writeFile(
|
|
67
|
+
path.join(tmp, 'moxxy.config.js'),
|
|
68
|
+
`export const config = {};`,
|
|
69
|
+
);
|
|
70
|
+
await expect(loadConfig({ cwd: tmp, skipUser: true })).rejects.toThrow(/default-export/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reloads a rewritten .mjs config freshly even on rapid successive loads', async () => {
|
|
74
|
+
// The first import is plain (single module-registry entry, no per-load
|
|
75
|
+
// cache-buster → no leak); subsequent reloads append a monotonic-counter
|
|
76
|
+
// buster so back-to-back reloads in the same millisecond can't return the
|
|
77
|
+
// stale cached module. Use .mjs so it goes through importJsConfig, not jiti.
|
|
78
|
+
const file = path.join(tmp, 'moxxy.config.mjs');
|
|
79
|
+
await fs.writeFile(file, `export default { plugins: { mode: { default: 'default' } } };`);
|
|
80
|
+
const first = await loadConfig({ cwd: tmp, skipUser: true });
|
|
81
|
+
expect(first.config.plugins?.mode?.default).toBe('default');
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(file, `export default { plugins: { mode: { default: 'goal' } } };`);
|
|
84
|
+
// Two reloads with no delay between them (same-ms risk).
|
|
85
|
+
const [a, b] = await Promise.all([
|
|
86
|
+
loadConfig({ cwd: tmp, skipUser: true }),
|
|
87
|
+
loadConfig({ cwd: tmp, skipUser: true }),
|
|
88
|
+
]);
|
|
89
|
+
expect(a.config.plugins?.mode?.default).toBe('goal');
|
|
90
|
+
expect(b.config.plugins?.mode?.default).toBe('goal');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('resolves each .ts config\'s relative imports against ITS OWN dir (jiti cache keyed by cwd)', async () => {
|
|
94
|
+
// Two projects in two dirs, each with a .ts config that imports a sibling
|
|
95
|
+
// module. A jiti instance binds its resolution base to the dir it was
|
|
96
|
+
// created with; a single shared instance would resolve the SECOND config's
|
|
97
|
+
// `./marker` against the FIRST dir, picking up the wrong value.
|
|
98
|
+
const dirA = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-jiti-a-'));
|
|
99
|
+
const dirB = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-jiti-b-'));
|
|
100
|
+
try {
|
|
101
|
+
await fs.writeFile(path.join(dirA, 'marker.ts'), `export const marker = 'from-A';`);
|
|
102
|
+
await fs.writeFile(
|
|
103
|
+
path.join(dirA, 'moxxy.config.ts'),
|
|
104
|
+
`import { marker } from './marker';\nexport default { plugins: { provider: { default: 'x', items: { x: { model: marker } } } } };`,
|
|
105
|
+
);
|
|
106
|
+
await fs.writeFile(path.join(dirB, 'marker.ts'), `export const marker = 'from-B';`);
|
|
107
|
+
await fs.writeFile(
|
|
108
|
+
path.join(dirB, 'moxxy.config.ts'),
|
|
109
|
+
`import { marker } from './marker';\nexport default { plugins: { provider: { default: 'x', items: { x: { model: marker } } } } };`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const a = await loadConfig({ cwd: dirA, skipUser: true });
|
|
113
|
+
const b = await loadConfig({ cwd: dirB, skipUser: true });
|
|
114
|
+
|
|
115
|
+
expect(a.config.plugins?.provider?.items?.x?.model).toBe('from-A');
|
|
116
|
+
expect(b.config.plugins?.provider?.items?.x?.model).toBe('from-B');
|
|
117
|
+
} finally {
|
|
118
|
+
await fs.rm(dirA, { recursive: true, force: true });
|
|
119
|
+
await fs.rm(dirB, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { moxxyHome } from '@moxxy/sdk/server';
|
|
5
|
+
import { mergeConfigs } from './merge.js';
|
|
6
|
+
import { moxxyConfigSchema, type MoxxyConfig } from './schema.js';
|
|
7
|
+
|
|
8
|
+
export interface LoadConfigOptions {
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly explicitPath?: string;
|
|
11
|
+
readonly skipUser?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LoadedConfig {
|
|
15
|
+
readonly config: MoxxyConfig;
|
|
16
|
+
readonly sources: ReadonlyArray<{ scope: 'project' | 'user' | 'explicit'; path: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CONFIG_NAMES = [
|
|
20
|
+
'moxxy.config.yaml',
|
|
21
|
+
'moxxy.config.yml',
|
|
22
|
+
'moxxy.config.ts',
|
|
23
|
+
'moxxy.config.js',
|
|
24
|
+
'moxxy.config.mjs',
|
|
25
|
+
'moxxy.config.cjs',
|
|
26
|
+
];
|
|
27
|
+
const USER_CONFIG_NAMES = [
|
|
28
|
+
'config.yaml',
|
|
29
|
+
'config.yml',
|
|
30
|
+
'config.ts',
|
|
31
|
+
'config.js',
|
|
32
|
+
'config.mjs',
|
|
33
|
+
'config.cjs',
|
|
34
|
+
];
|
|
35
|
+
/** Cap upward filesystem traversal when searching for a project config.
|
|
36
|
+
* Shared with the config plugin's scope-resolution walk so the bound can't
|
|
37
|
+
* drift between load time (here) and edit time (plugin.ts). */
|
|
38
|
+
export const MAX_CONFIG_SEARCH_DEPTH = 12;
|
|
39
|
+
|
|
40
|
+
export async function loadConfig(opts: LoadConfigOptions): Promise<LoadedConfig> {
|
|
41
|
+
const sources: Array<{ scope: 'project' | 'user' | 'explicit'; path: string }> = [];
|
|
42
|
+
const configs: MoxxyConfig[] = [];
|
|
43
|
+
|
|
44
|
+
if (!opts.skipUser) {
|
|
45
|
+
const userPath = await findFile(moxxyHome(), USER_CONFIG_NAMES);
|
|
46
|
+
if (userPath) {
|
|
47
|
+
const cfg = await loadOne(userPath);
|
|
48
|
+
configs.push(cfg);
|
|
49
|
+
sources.push({ scope: 'user', path: userPath });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.explicitPath) {
|
|
54
|
+
const cfg = await loadOne(opts.explicitPath);
|
|
55
|
+
configs.push(cfg);
|
|
56
|
+
sources.push({ scope: 'explicit', path: opts.explicitPath });
|
|
57
|
+
} else {
|
|
58
|
+
const projectPath = await findUpward(opts.cwd, CONFIG_NAMES);
|
|
59
|
+
if (projectPath) {
|
|
60
|
+
warnIfAncestorExecutableConfig(projectPath, opts.cwd);
|
|
61
|
+
const cfg = await loadOne(projectPath);
|
|
62
|
+
configs.push(cfg);
|
|
63
|
+
sources.push({ scope: 'project', path: projectPath });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { config: mergeConfigs(...configs), sources };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const EXECUTABLE_CONFIG_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs']);
|
|
71
|
+
|
|
72
|
+
function isUnderDir(filePath: string, dir: string): boolean {
|
|
73
|
+
const rel = path.relative(path.resolve(dir), path.resolve(filePath));
|
|
74
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The project-config upward walk resolves and then EXECUTES the first matching
|
|
79
|
+
* config above cwd. An ancestor (e.g. a shared home/temp parent or an untrusted
|
|
80
|
+
* outer repo) can therefore plant a config whose code runs with full process
|
|
81
|
+
* privileges. We keep the documented upward-walk behavior but surface the exact
|
|
82
|
+
* absolute path on stderr before running an ancestor *executable* config, so the
|
|
83
|
+
* operator can see what is about to execute. Non-executable YAML and at/under-cwd
|
|
84
|
+
* configs are silent (no new trust boundary widened).
|
|
85
|
+
*/
|
|
86
|
+
function warnIfAncestorExecutableConfig(filePath: string, cwd: string): void {
|
|
87
|
+
if (!EXECUTABLE_CONFIG_EXTS.has(path.extname(filePath))) return;
|
|
88
|
+
if (isUnderDir(filePath, cwd)) return;
|
|
89
|
+
console.warn(
|
|
90
|
+
`[moxxy] executing project config from an ancestor directory: ${path.resolve(filePath)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadOne(filePath: string): Promise<MoxxyConfig> {
|
|
95
|
+
const ext = path.extname(filePath);
|
|
96
|
+
let raw: unknown;
|
|
97
|
+
|
|
98
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
99
|
+
const yamlText = await fs.readFile(filePath, 'utf8');
|
|
100
|
+
const yamlMod = (await import('yaml')) as { parse: (text: string) => unknown };
|
|
101
|
+
raw = yamlMod.parse(yamlText);
|
|
102
|
+
if (raw === null || raw === undefined) raw = {};
|
|
103
|
+
} else {
|
|
104
|
+
let mod: unknown;
|
|
105
|
+
if (ext === '.ts' || ext === '.tsx') {
|
|
106
|
+
const jiti = await getJiti(path.dirname(filePath));
|
|
107
|
+
if (!jiti) throw new Error(`Cannot load ${filePath}: jiti is required for .ts configs.`);
|
|
108
|
+
mod = jiti(filePath);
|
|
109
|
+
} else {
|
|
110
|
+
mod = await importJsConfig(filePath);
|
|
111
|
+
}
|
|
112
|
+
raw = extractDefault(mod);
|
|
113
|
+
if (!raw) {
|
|
114
|
+
throw new Error(`Config file ${filePath} must default-export the result of defineConfig().`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parsed = moxxyConfigSchema.safeParse(raw);
|
|
119
|
+
if (!parsed.success) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Invalid moxxy config at ${filePath}:\n` + JSON.stringify(parsed.error.issues, null, 2),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return parsed.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Key the cache by cwd: a jiti instance binds its module-resolution /
|
|
128
|
+
// interopDefault base to the dir it was created with, so a single shared
|
|
129
|
+
// instance would resolve a SECOND project's `.ts` config relative imports
|
|
130
|
+
// against the FIRST project's dir. Long-lived hosts (desktop/runner) load
|
|
131
|
+
// configs for multiple workspaces in one process, so each cwd needs its own.
|
|
132
|
+
//
|
|
133
|
+
// Each entry carries its own module-resolution + compiled-module store, so an
|
|
134
|
+
// unbounded map would grow one jiti runtime per workspace dir forever. Cap it
|
|
135
|
+
// with a small LRU (re-insert on hit, drop the oldest on overflow) so a
|
|
136
|
+
// long-running host that opens many projects keeps only a bounded working set.
|
|
137
|
+
const MAX_CACHED_JITI = 16;
|
|
138
|
+
const cachedJiti = new Map<string, (id: string) => unknown>();
|
|
139
|
+
|
|
140
|
+
type JitiFactory = (cwd: string, opts?: unknown) => (id: string) => unknown;
|
|
141
|
+
|
|
142
|
+
async function getJiti(cwd: string): Promise<((id: string) => unknown) | null> {
|
|
143
|
+
const existing = cachedJiti.get(cwd);
|
|
144
|
+
if (existing) {
|
|
145
|
+
// Mark as most-recently-used.
|
|
146
|
+
cachedJiti.delete(cwd);
|
|
147
|
+
cachedJiti.set(cwd, existing);
|
|
148
|
+
return existing;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const mod = await import('jiti');
|
|
152
|
+
const factory =
|
|
153
|
+
(mod as { createJiti?: JitiFactory; default?: JitiFactory }).createJiti ??
|
|
154
|
+
(mod as { default?: JitiFactory }).default;
|
|
155
|
+
if (!factory) return null;
|
|
156
|
+
const instance = factory(cwd, { interopDefault: true });
|
|
157
|
+
cachedJiti.set(cwd, instance);
|
|
158
|
+
while (cachedJiti.size > MAX_CACHED_JITI) {
|
|
159
|
+
const oldest = cachedJiti.keys().next().value;
|
|
160
|
+
if (oldest === undefined) break;
|
|
161
|
+
cachedJiti.delete(oldest);
|
|
162
|
+
}
|
|
163
|
+
return instance;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The ESM module registry never evicts an entry once imported and offers no
|
|
170
|
+
// eviction API: every DISTINCT specifier is retained for the process lifetime
|
|
171
|
+
// along with its whole module graph. A naive `?v=${Date.now()}` cache-buster on
|
|
172
|
+
// every load therefore leaks one module per reload in the exact long-lived
|
|
173
|
+
// hosts (desktop/runner) this loader targets. Instead: import each JS config by
|
|
174
|
+
// its plain file URL the FIRST time (one registry entry, GC-stable), and only
|
|
175
|
+
// append a cache-buster when re-loading a path we've already imported — using a
|
|
176
|
+
// monotonic counter so same-millisecond reloads are still guaranteed unique
|
|
177
|
+
// (Date.now()'s 1ms resolution would otherwise return the stale cached module).
|
|
178
|
+
const importedJsConfigs = new Set<string>();
|
|
179
|
+
let importReloadCounter = 0;
|
|
180
|
+
|
|
181
|
+
async function importJsConfig(filePath: string): Promise<unknown> {
|
|
182
|
+
const base = pathToFileURL(filePath).href;
|
|
183
|
+
if (importedJsConfigs.has(base)) {
|
|
184
|
+
return import(`${base}?v=${Date.now()}-${++importReloadCounter}`);
|
|
185
|
+
}
|
|
186
|
+
importedJsConfigs.add(base);
|
|
187
|
+
return import(base);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractDefault(mod: unknown): unknown {
|
|
191
|
+
if (!mod) return undefined;
|
|
192
|
+
if (typeof mod !== 'object') return undefined;
|
|
193
|
+
const m = mod as Record<string, unknown>;
|
|
194
|
+
if (m.default && typeof m.default === 'object') return m.default;
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Walk upward from `startDir` (bounded by {@link MAX_CONFIG_SEARCH_DEPTH})
|
|
200
|
+
* returning the first directory that holds one of `names`. The `names` list is
|
|
201
|
+
* a deliberate parameter: `loadConfig` searches every config extension while the
|
|
202
|
+
* config plugin's editor searches only the YAML names it can safely mutate — so
|
|
203
|
+
* the shared traversal invariant lives here, the divergent name set stays with
|
|
204
|
+
* the caller. Returns the full path, or null if none found within the bound.
|
|
205
|
+
*/
|
|
206
|
+
export async function findUpward(
|
|
207
|
+
startDir: string,
|
|
208
|
+
names: ReadonlyArray<string>,
|
|
209
|
+
): Promise<string | null> {
|
|
210
|
+
let cursor = path.resolve(startDir);
|
|
211
|
+
for (let i = 0; i < MAX_CONFIG_SEARCH_DEPTH; i++) {
|
|
212
|
+
const found = await findFile(cursor, names);
|
|
213
|
+
if (found) return found;
|
|
214
|
+
const parent = path.dirname(cursor);
|
|
215
|
+
if (parent === cursor) break;
|
|
216
|
+
cursor = parent;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function findFile(dir: string, names: ReadonlyArray<string>): Promise<string | null> {
|
|
222
|
+
for (const name of names) {
|
|
223
|
+
const candidate = path.join(dir, name);
|
|
224
|
+
try {
|
|
225
|
+
await fs.access(candidate);
|
|
226
|
+
return candidate;
|
|
227
|
+
} catch {
|
|
228
|
+
// continue
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|