@openchambery/web 2.0.0-beta.8 → 2.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.
- package/README.md +14 -0
- package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-C1MGZ7qC.js} +1 -1
- package/dist/assets/AssistantView-vqCFvVo0.js +3 -0
- package/dist/assets/{DiagramView-BBPsLqLq.js → DiagramView-CYO5iVVm.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-BMSk_tY-.js} +1 -1
- package/dist/assets/Icon-BdzPRCSy.js +1 -0
- package/dist/assets/MarkdownRenderer-FMynjkGR.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-CD0RYNWX.js → MarkdownRendererImpl-DoWvw5jj.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-CECVYTH6.js → MarkstreamRendererImpl-DoeYajmw.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DOwW-F0i.js → MobileDetailNavigation-BzBz-co_.js} +1 -1
- package/dist/assets/{MobileShareBridge-d0hQ2FSQ.js → MobileShareBridge-D1Uoj6l_.js} +3 -3
- package/dist/assets/MobileSurface-8YpZ51Qo.js +2 -0
- package/dist/assets/{MultiRunWindow-DOdFEgMg.js → MultiRunWindow-BxcYUPta.js} +1 -1
- package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-yRdQTR1M.js} +1 -1
- package/dist/assets/{OverlayScrollbar-S-7EePsU.js → OverlayScrollbar-BUjtqnxn.js} +1 -1
- package/dist/assets/SettingsView-D7KrvqvH.js +25 -0
- package/dist/assets/{SettingsWindow-vKn-MWGU.js → SettingsWindow-DX2w-cDc.js} +1 -1
- package/dist/assets/{TerminalView-BgPjY0CO.js → TerminalView-CiUcCQPm.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DtT3wbie.js → ToolOutputDialog-DQvXtK71.js} +1 -1
- package/dist/assets/appThemeRegistry-BXC6cIDE.js +1 -0
- package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
- package/dist/assets/{button-DIIrsMjW.js → button-CUwjpv-i.js} +1 -1
- package/dist/assets/{collapsible-CnvUeYst.js → collapsible-paItOH3A.js} +1 -1
- package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-K6zNumqN.js} +1 -1
- package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-DuftEj8B.js} +1 -1
- package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
- package/dist/assets/{diagram-rvn7_qBM.js → diagram-D6nY5hbD.js} +1 -1
- package/dist/assets/dialog-PjKWayIU.js +1 -0
- package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
- package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
- package/dist/assets/fileMentionTouchSelection-78v5mNTE.js +2 -0
- package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-DuF89nFO.js} +1 -1
- package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
- package/dist/assets/{gitApi-D9ygh31C.js → gitApi-eoKSJcvW.js} +4 -4
- package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
- package/dist/assets/{input-BVu-iykl.js → input-BeVw4D0W.js} +1 -1
- package/dist/assets/insertionBoundaries-BI3RHOpf.js +1 -0
- package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
- package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
- package/dist/assets/main-CQpUPjPK.js +169 -0
- package/dist/assets/{main-B97oNWoh.js → main-MN0YKJ0_.js} +2 -2
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-CGCWmHgq.js} +1 -1
- package/dist/assets/{mcpQueries-zC4-V5b8.js → mcpQueries-X424Iq2z.js} +1 -1
- package/dist/assets/miniChat-DFIl76ku.js +2 -0
- package/dist/assets/mobile-C3fWXGQJ.js +2 -0
- package/dist/assets/multirun-DuSX3YRs.js +3 -0
- package/dist/assets/persistence-Dnj1QOdX.js +3 -0
- package/dist/assets/pl-B714sdql.js +15 -0
- package/dist/assets/platform-jCZ3KfMQ.js +6 -0
- package/dist/assets/projectMeta-Cn08pTNi.js +1 -0
- package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
- package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CRPdctIn.js +2 -0
- package/dist/assets/renderMobileApp-CVURQbeh.js +28 -0
- package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
- package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
- package/dist/assets/runtimeConfig-xivE1wxC.js +2 -0
- package/dist/assets/runtimeEndpointReset-B8DzKzwg.js +148 -0
- package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
- package/dist/assets/sessionChangeSummary-yBsR2pyd.js +2 -0
- package/dist/assets/{sessionLookup-DcyGP3xg.js → sessionLookup-1Z1FWZRB.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-BsObV7Pl.js → sortable-tabs-strip-D611LPyd.js} +1 -1
- package/dist/assets/src-TBM7scxy.css +2 -0
- package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
- package/dist/assets/{terminalApi-Dhqqp4xi.js → terminalApi-BZW_rAc3.js} +1 -1
- package/dist/assets/{textarea-BoXpPjTd.js → textarea-QtZjf4L8.js} +1 -1
- package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-DDNnmcXI.js} +1 -1
- package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
- package/dist/assets/{url-CGbJbiJq.js → url-DNRMq_0R.js} +1 -1
- package/dist/assets/{useAppFontEffects-BauzQXF0.js → useAppFontEffects-DJy8P5By.js} +51 -51
- package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-DlwSomVK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-qDoROBC_.js → useEffectiveDirectory-eJorVTT9.js} +1 -1
- package/dist/assets/{useFontPreferences-Xw1vSkmC.js → useFontPreferences-Bjgt_lzl.js} +1 -1
- package/dist/assets/useMobileNavigationStore-A_ZNTfpl.js +415 -0
- package/dist/assets/useSessionAutoCleanup-CqC3dEou.js +2 -0
- package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-Dfx6TKvi.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-D8luxgem.js → useWorkerHighlightedLines-B0vSL6jr.js} +1 -1
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
- package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
- package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +3 -3
- package/server/index.js +8 -20
- package/server/lib/client-auth/remote-clients.js +15 -0
- package/server/lib/client-auth/remote-clients.test.js +42 -1
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/message-queue/service.js +1 -1
- package/server/lib/opencode/DOCUMENTATION.md +33 -22
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +10 -123
- package/server/lib/opencode/config-entity-routes.test.js +23 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +17 -1
- package/server/lib/opencode/core-routes.test.js +43 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +18 -1
- package/server/lib/opencode/feature-routes-runtime.js +9 -7
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugin-routes.test.js +4 -2
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/plugins.test.js +5 -3
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +7 -0
- package/server/lib/opencode/proxy.test.js +13 -0
- package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
- package/server/lib/opencode/routes.js +10 -8
- package/server/lib/opencode/routes.test.js +51 -10
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/settings-normalization-runtime.js +3 -1
- package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
- package/server/lib/opencode/settings-runtime.test.js +58 -1
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/update-discovery.js +39 -0
- package/server/lib/opencode/update-discovery.test.js +39 -0
- package/server/lib/opencode/upgrade-screen.js +4 -11
- package/server/lib/opencode/upgrade-screen.test.js +25 -25
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
- package/server/lib/question-auto-delegate/core.d.ts +5 -1
- package/server/lib/question-auto-delegate/core.js +21 -13
- package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
- package/server/lib/question-auto-delegate/form-io.js +39 -0
- package/server/lib/question-auto-delegate/runtime.js +4 -24
- package/server/lib/question-auto-delegate/runtime.test.js +138 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +3 -1
- package/server/lib/relay/service.js +1 -0
- package/server/lib/relay/service.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
- package/server/lib/scheduled-tasks/runtime.js +12 -4
- package/server/lib/scheduled-tasks/runtime.test.js +81 -6
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/session-goal/runtime.js +4 -4
- package/server/lib/session-goal/runtime.test.js +32 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
- package/server/lib/session-metadata/session-projection.d.ts +1 -0
- package/server/lib/session-metadata/session-projection.js +23 -0
- package/server/lib/session-metadata/session-projection.test.js +35 -0
- package/server/lib/session-title/DOCUMENTATION.md +52 -14
- package/server/lib/session-title/native-runtime.test.js +59 -5
- package/server/lib/session-title/runtime.js +63 -17
- package/server/lib/session-title/session-access.js +108 -3
- package/server/lib/session-title/session-access.test.js +125 -7
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/AssistantView-CxAzKd78.js +0 -3
- package/dist/assets/Icon-B8fSTLdP.js +0 -1
- package/dist/assets/MarkdownRenderer-yq2DPZUG.js +0 -2
- package/dist/assets/MobileSurface-qalyCN7X.js +0 -2
- package/dist/assets/SettingsView-DVxMbeKC.js +0 -25
- package/dist/assets/appThemeRegistry-DC5NRV_i.js +0 -1
- package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
- package/dist/assets/dialog-CQ63sc9E.js +0 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
- package/dist/assets/fileMentionTouchSelection-B8Blr6EI.js +0 -3
- package/dist/assets/insertionBoundaries-DYu8VdgE.js +0 -1
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/main-SXMSe99Y.js +0 -169
- package/dist/assets/miniChat-CIi4PmQO.js +0 -2
- package/dist/assets/mobile-BK0Ov-k5.js +0 -2
- package/dist/assets/multirun-UIu_EbC2.js +0 -4
- package/dist/assets/persistence-D0y7226-.js +0 -3
- package/dist/assets/pl-BCf5uLlK.js +0 -15
- package/dist/assets/platform-D_c-T5nO.js +0 -6
- package/dist/assets/projectMeta-BPi16qGY.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-CnTCJpBd.js +0 -2
- package/dist/assets/renderMobileApp-BFDrOvfv.js +0 -28
- package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
- package/dist/assets/runtimeConfig-023C-il6.js +0 -2
- package/dist/assets/runtimeEndpointReset-Bdx3CutT.js +0 -149
- package/dist/assets/sessionChangeSummary-B8xrgW5-.js +0 -2
- package/dist/assets/src-lS1keDVV.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-kZ4wtPnO.js +0 -1
- package/dist/assets/useMobileNavigationStore-BDXu5FSG.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BHloMhRp.js +0 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readConfigFile } from './config-files.js';
|
|
6
|
+
|
|
7
|
+
let root;
|
|
8
|
+
let directory;
|
|
9
|
+
let mcp;
|
|
10
|
+
let agents;
|
|
11
|
+
let providers;
|
|
12
|
+
let plugins;
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-entity-layers-'));
|
|
15
|
+
directory = path.join(root, 'xdg', 'opencode');
|
|
16
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
17
|
+
vi.stubEnv('XDG_CONFIG_HOME', path.dirname(directory));
|
|
18
|
+
vi.stubEnv('OPENCODE_CONFIG', '');
|
|
19
|
+
vi.stubEnv('OPENCODE_CONFIG_DIR', '');
|
|
20
|
+
vi.spyOn(os, 'homedir').mockReturnValue(root);
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
[mcp, agents, providers, plugins] = await Promise.all([import('./mcp.js'), import('./agents.js'), import('./providers.js'), import('./plugins.js')]);
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('Web/Electron entity physical provenance', () => {
|
|
31
|
+
it('MCP CRUD edits only the source document and preserves comments', () => {
|
|
32
|
+
const json = path.join(directory, 'opencode.json');
|
|
33
|
+
const jsonc = path.join(directory, 'opencode.jsonc');
|
|
34
|
+
fs.writeFileSync(json, '{"mcp":{"lower":{"type":"local","command":["lower"]}}}');
|
|
35
|
+
fs.writeFileSync(jsonc, '{ // retained\n"mcp":{"higher":{"type":"local","command":["higher"]}}}');
|
|
36
|
+
expect(mcp.listMcpConfigs().map((entry) => entry.name).sort()).toEqual(['higher', 'lower']);
|
|
37
|
+
mcp.updateMcpConfig('lower', { enabled: false });
|
|
38
|
+
expect(readConfigFile(json)).toMatchObject({ mcp: { lower: { enabled: false } } });
|
|
39
|
+
mcp.createMcpConfig('new', { type: 'local', command: ['new'] });
|
|
40
|
+
expect(readConfigFile(jsonc).mcp.lower).toBeUndefined();
|
|
41
|
+
mcp.deleteMcpConfig('higher');
|
|
42
|
+
expect(readConfigFile(jsonc).mcp.higher).toBeUndefined();
|
|
43
|
+
expect(fs.readFileSync(jsonc, 'utf8')).toContain('// retained');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('agent and provider metadata point to physical layers, including scoped deletion', () => {
|
|
47
|
+
const json = path.join(directory, 'opencode.json');
|
|
48
|
+
const jsonc = path.join(directory, 'opencode.jsonc');
|
|
49
|
+
fs.writeFileSync(json, '{"agent":{"lower":{"description":"lower"}},"provider":{"lower":{}}}');
|
|
50
|
+
fs.writeFileSync(jsonc, '{ // retained\n"agent":{"higher":{"description":"higher"}},"provider":{"higher":{}}}');
|
|
51
|
+
expect(agents.getAgentSources('lower').json.path).toBe(json);
|
|
52
|
+
expect(agents.getAgentSources('higher').json.path).toBe(jsonc);
|
|
53
|
+
agents.deleteAgent('lower', undefined, 'user');
|
|
54
|
+
expect(readConfigFile(json).agent).toBeUndefined();
|
|
55
|
+
expect(readConfigFile(jsonc).agent.higher).toBeDefined();
|
|
56
|
+
expect(providers.getProviderSources('lower').sources.user.path).toBe(json);
|
|
57
|
+
providers.removeProviderConfig('lower');
|
|
58
|
+
expect(readConfigFile(json).provider).toBeUndefined();
|
|
59
|
+
expect(readConfigFile(jsonc).provider.higher).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('plugin edits locate the source containing the requested spec', () => {
|
|
63
|
+
const json = path.join(directory, 'opencode.json');
|
|
64
|
+
const jsonc = path.join(directory, 'opencode.jsonc');
|
|
65
|
+
fs.writeFileSync(json, '{"plugin":["lower-plugin"]}');
|
|
66
|
+
fs.writeFileSync(jsonc, '{ // retained\n"plugin":["higher-plugin"]}');
|
|
67
|
+
const entry = plugins.listPluginEntries().find((item) => item.spec === 'lower-plugin');
|
|
68
|
+
plugins.updatePluginEntry(entry.id, { spec: 'updated-plugin' });
|
|
69
|
+
expect(readConfigFile(json).plugin).toEqual(['updated-plugin']);
|
|
70
|
+
expect(readConfigFile(jsonc).plugin).toEqual(['higher-plugin']);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { parse as parseJsonc } from 'jsonc-parser';
|
|
5
4
|
import { OpenCode } from '@opencode/client';
|
|
6
5
|
import { projectProviderCatalog } from './provider-catalog.js';
|
|
6
|
+
import { getGlobalConfigDirectory, writeConfigText } from './config-files.js';
|
|
7
7
|
|
|
8
8
|
// Compose official v2 provider.list + model.list (+ optional default) into the
|
|
9
9
|
// catalog shape consumed by projectProviderCatalog. Missing arrays are failure.
|
|
@@ -60,7 +60,7 @@ const createOpenCodeClient = ({ baseUrl, headers, fetch: fetchImpl }) =>
|
|
|
60
60
|
|
|
61
61
|
const MAX_GLOBAL_CONFIG_SIZE = 2 * 1024 * 1024;
|
|
62
62
|
const GLOBAL_CONFIG_FILES = {
|
|
63
|
-
opencode: ['opencode.
|
|
63
|
+
opencode: ['opencode.jsonc', 'opencode.json'],
|
|
64
64
|
'oh-my-opencode-slim': ['oh-my-opencode-slim.json', 'oh-my-opencode-slim.jsonc'],
|
|
65
65
|
'oh-my-openagent': ['oh-my-openagent.json', 'oh-my-openagent.jsonc'],
|
|
66
66
|
};
|
|
@@ -84,6 +84,10 @@ function resolveGlobalConfigPath(target, configDirectory) {
|
|
|
84
84
|
if (!fileNames) {
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
|
+
if (target === 'opencode' && process.env.OPENCODE_CONFIG) {
|
|
88
|
+
const filePath = path.resolve(process.env.OPENCODE_CONFIG);
|
|
89
|
+
return { target, fileNames: [path.basename(filePath)], fileName: path.basename(filePath), filePath };
|
|
90
|
+
}
|
|
87
91
|
return {
|
|
88
92
|
target,
|
|
89
93
|
fileNames,
|
|
@@ -99,7 +103,7 @@ async function findGlobalConfigPath(target, configDirectory) {
|
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
for (const fileName of configPath.fileNames) {
|
|
102
|
-
const filePath = path.join(
|
|
106
|
+
const filePath = path.join(path.dirname(configPath.filePath), fileName);
|
|
103
107
|
try {
|
|
104
108
|
if ((await fs.stat(filePath)).isFile()) {
|
|
105
109
|
return { ...configPath, fileName, filePath };
|
|
@@ -153,13 +157,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
153
157
|
createMcpConfig,
|
|
154
158
|
updateMcpConfig,
|
|
155
159
|
deleteMcpConfig,
|
|
156
|
-
|
|
157
|
-
getSnippet,
|
|
158
|
-
createSnippet,
|
|
159
|
-
updateSnippet,
|
|
160
|
-
deleteSnippet,
|
|
161
|
-
expandSnippets,
|
|
162
|
-
configDirectory = path.join(os.homedir(), '.config', 'opencode'),
|
|
160
|
+
configDirectory = getGlobalConfigDirectory(),
|
|
163
161
|
} = dependencies;
|
|
164
162
|
|
|
165
163
|
app.get('/api/config/catalog/providers', async (req, res) => {
|
|
@@ -186,7 +184,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
186
184
|
const [providersResult, modelsResult, defaultResult] = await Promise.all([
|
|
187
185
|
client.provider.list({ location }),
|
|
188
186
|
client.model.list({ location }),
|
|
189
|
-
client.model.default({ location }),
|
|
187
|
+
client.model.default({ location }).catch(() => undefined),
|
|
190
188
|
]);
|
|
191
189
|
const source = composeV2ProviderCatalogSource(
|
|
192
190
|
providersResult?.data,
|
|
@@ -254,10 +252,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
254
252
|
}
|
|
255
253
|
|
|
256
254
|
try {
|
|
257
|
-
|
|
258
|
-
const temporaryPath = `${target.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
259
|
-
await fs.writeFile(temporaryPath, content, 'utf8');
|
|
260
|
-
await fs.rename(temporaryPath, target.filePath);
|
|
255
|
+
writeConfigText(content, target.filePath);
|
|
261
256
|
return res.json({
|
|
262
257
|
target: req.params.target,
|
|
263
258
|
fileName: target.fileName,
|
|
@@ -679,112 +674,4 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
679
674
|
}
|
|
680
675
|
});
|
|
681
676
|
|
|
682
|
-
app.get('/api/config/snippets', async (req, res) => {
|
|
683
|
-
try {
|
|
684
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
685
|
-
if (error) {
|
|
686
|
-
return res.status(400).json({ error });
|
|
687
|
-
}
|
|
688
|
-
res.json(listSnippets(directory));
|
|
689
|
-
} catch (error) {
|
|
690
|
-
console.error('[API:GET /api/config/snippets] Failed:', error);
|
|
691
|
-
res.status(500).json({ error: error.message || 'Failed to list snippets' });
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
app.post('/api/config/snippets/expand', async (req, res) => {
|
|
696
|
-
try {
|
|
697
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
698
|
-
if (error) {
|
|
699
|
-
return res.status(400).json({ error });
|
|
700
|
-
}
|
|
701
|
-
res.json({ text: expandSnippets(req.body?.text ?? '', directory) });
|
|
702
|
-
} catch (error) {
|
|
703
|
-
console.error('[API:POST /api/config/snippets/expand] Failed:', error);
|
|
704
|
-
res.status(500).json({ error: error.message || 'Failed to expand snippets' });
|
|
705
|
-
}
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
app.get('/api/config/snippets/:name', async (req, res) => {
|
|
709
|
-
try {
|
|
710
|
-
const name = req.params.name;
|
|
711
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
712
|
-
if (error) {
|
|
713
|
-
return res.status(400).json({ error });
|
|
714
|
-
}
|
|
715
|
-
const snippet = getSnippet(name, directory);
|
|
716
|
-
if (!snippet) {
|
|
717
|
-
return res.status(404).json({ error: `Snippet "${name}" not found` });
|
|
718
|
-
}
|
|
719
|
-
res.json(snippet);
|
|
720
|
-
} catch (error) {
|
|
721
|
-
console.error('[API:GET /api/config/snippets/:name] Failed:', error);
|
|
722
|
-
if (error.message?.includes('Snippet name')) {
|
|
723
|
-
return res.status(400).json({ error: error.message });
|
|
724
|
-
}
|
|
725
|
-
res.status(500).json({ error: error.message || 'Failed to get snippet' });
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
app.post('/api/config/snippets/:name', async (req, res) => {
|
|
730
|
-
try {
|
|
731
|
-
const name = req.params.name;
|
|
732
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
733
|
-
if (error) {
|
|
734
|
-
return res.status(400).json({ error });
|
|
735
|
-
}
|
|
736
|
-
const snippet = createSnippet(name, req.body || {}, directory, req.body?.scope || 'global');
|
|
737
|
-
res.json({ success: true, snippet });
|
|
738
|
-
} catch (error) {
|
|
739
|
-
console.error('[API:POST /api/config/snippets/:name] Failed:', error);
|
|
740
|
-
if (error.message?.includes('already exists')) {
|
|
741
|
-
return res.status(409).json({ error: error.message });
|
|
742
|
-
}
|
|
743
|
-
if (error.message?.includes('Snippet name') || error.message?.includes('Project directory')) {
|
|
744
|
-
return res.status(400).json({ error: error.message });
|
|
745
|
-
}
|
|
746
|
-
res.status(500).json({ error: error.message || 'Failed to create snippet' });
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
app.patch('/api/config/snippets/:name', async (req, res) => {
|
|
751
|
-
try {
|
|
752
|
-
const name = req.params.name;
|
|
753
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
754
|
-
if (error) {
|
|
755
|
-
return res.status(400).json({ error });
|
|
756
|
-
}
|
|
757
|
-
res.json({ success: true, snippet: updateSnippet(name, req.body || {}, directory) });
|
|
758
|
-
} catch (error) {
|
|
759
|
-
console.error('[API:PATCH /api/config/snippets/:name] Failed:', error);
|
|
760
|
-
if (error.message?.includes('not found')) {
|
|
761
|
-
return res.status(404).json({ error: error.message });
|
|
762
|
-
}
|
|
763
|
-
if (error.message?.includes('Snippet name')) {
|
|
764
|
-
return res.status(400).json({ error: error.message });
|
|
765
|
-
}
|
|
766
|
-
res.status(500).json({ error: error.message || 'Failed to update snippet' });
|
|
767
|
-
}
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
app.delete('/api/config/snippets/:name', async (req, res) => {
|
|
771
|
-
try {
|
|
772
|
-
const name = req.params.name;
|
|
773
|
-
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
774
|
-
if (error) {
|
|
775
|
-
return res.status(400).json({ error });
|
|
776
|
-
}
|
|
777
|
-
deleteSnippet(name, directory);
|
|
778
|
-
res.json({ success: true });
|
|
779
|
-
} catch (error) {
|
|
780
|
-
console.error('[API:DELETE /api/config/snippets/:name] Failed:', error);
|
|
781
|
-
if (error.message?.includes('not found')) {
|
|
782
|
-
return res.status(404).json({ error: error.message });
|
|
783
|
-
}
|
|
784
|
-
if (error.message?.includes('Snippet name')) {
|
|
785
|
-
return res.status(400).json({ error: error.message });
|
|
786
|
-
}
|
|
787
|
-
res.status(500).json({ error: error.message || 'Failed to delete snippet' });
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
677
|
};
|
|
@@ -38,12 +38,6 @@ const createDependencies = (getCommandSources, configDirectory, getAgentSources
|
|
|
38
38
|
createMcpConfig: vi.fn(),
|
|
39
39
|
updateMcpConfig: vi.fn(),
|
|
40
40
|
deleteMcpConfig: vi.fn(),
|
|
41
|
-
listSnippets: vi.fn(),
|
|
42
|
-
getSnippet: vi.fn(),
|
|
43
|
-
createSnippet: vi.fn(),
|
|
44
|
-
updateSnippet: vi.fn(),
|
|
45
|
-
deleteSnippet: vi.fn(),
|
|
46
|
-
expandSnippets: vi.fn(),
|
|
47
41
|
configDirectory,
|
|
48
42
|
buildOpenCodeUrl: () => 'http://opencode-upstream:4096/',
|
|
49
43
|
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic example' }),
|
|
@@ -211,6 +205,22 @@ describe('config entity command metadata route', () => {
|
|
|
211
205
|
});
|
|
212
206
|
|
|
213
207
|
describe('provider catalog route', () => {
|
|
208
|
+
it('keeps the model catalog available when the optional default request fails', async () => {
|
|
209
|
+
const app = express();
|
|
210
|
+
createOpencodeClient.mockReturnValue({
|
|
211
|
+
provider: { list: vi.fn(async () => ({ data: [{ id: 'openai', name: 'OpenAI' }] })) },
|
|
212
|
+
model: {
|
|
213
|
+
list: vi.fn(async () => ({ data: [{ id: 'model', providerID: 'openai', name: 'Model' }] })),
|
|
214
|
+
default: vi.fn(async () => { throw new Error('default unavailable'); }),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
registerConfigEntityRoutes(app, createDependencies(vi.fn()));
|
|
218
|
+
const response = await request(app).get('/api/config/catalog/providers').expect(200);
|
|
219
|
+
expect(response.body.providers[0].models.model).toEqual({ id: 'model', name: 'Model' });
|
|
220
|
+
expect(response.body.default).toEqual({});
|
|
221
|
+
expect(response.body.partial).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
214
224
|
it('uses the resolved directory, OpenCode auth, SDK providers call, and explicit catalog route', async () => {
|
|
215
225
|
const app = express();
|
|
216
226
|
const resolveProjectDirectory = vi.fn(async () => ({ directory: '/project/from-header' }));
|
|
@@ -443,6 +453,8 @@ describe('config entity agent metadata route', () => {
|
|
|
443
453
|
});
|
|
444
454
|
|
|
445
455
|
describe('global raw configuration routes', () => {
|
|
456
|
+
beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
|
|
457
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
446
458
|
it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
|
|
447
459
|
const app = express();
|
|
448
460
|
app.use(express.json());
|
|
@@ -487,7 +499,7 @@ describe('global raw configuration routes', () => {
|
|
|
487
499
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
488
500
|
});
|
|
489
501
|
|
|
490
|
-
it('discovers only existing
|
|
502
|
+
it('discovers only existing targets and edits the winning global JSONC document', async () => {
|
|
491
503
|
const app = express();
|
|
492
504
|
app.use(express.json());
|
|
493
505
|
const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
|
|
@@ -500,20 +512,20 @@ describe('global raw configuration routes', () => {
|
|
|
500
512
|
const available = await request(app).get('/api/config/global').expect(200);
|
|
501
513
|
expect(available.body).toEqual({
|
|
502
514
|
targets: [
|
|
503
|
-
{ target: 'opencode', fileName: 'opencode.
|
|
515
|
+
{ target: 'opencode', fileName: 'opencode.jsonc' },
|
|
504
516
|
{ target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
|
|
505
517
|
],
|
|
506
518
|
});
|
|
507
519
|
|
|
508
520
|
const openCode = await request(app).get('/api/config/global/opencode').expect(200);
|
|
509
|
-
expect(openCode.body).toMatchObject({ fileName: 'opencode.
|
|
521
|
+
expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
|
|
510
522
|
|
|
511
523
|
await request(app)
|
|
512
524
|
.put('/api/config/global/opencode')
|
|
513
525
|
.send({ content: '{ "model": "updated-json-model" }' })
|
|
514
526
|
.expect(200);
|
|
515
|
-
expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('
|
|
516
|
-
expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('
|
|
527
|
+
expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('json-model');
|
|
528
|
+
expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('updated-json-model');
|
|
517
529
|
|
|
518
530
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
519
531
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ConfigObject = Record<string, unknown>;
|
|
2
|
+
export type ConfigDocument = { path: string; scope: 'user' | 'project' | 'custom'; config: ConfigObject };
|
|
3
|
+
export type ConfigLayers = {
|
|
4
|
+
userConfig: ConfigObject; projectConfig: ConfigObject; customConfig: ConfigObject; mergedConfig: ConfigObject;
|
|
5
|
+
userLayers: ConfigDocument[]; projectLayers: ConfigDocument[]; documents: ConfigDocument[];
|
|
6
|
+
paths: { userPath: string; projectPath: string | null; customPath: string | null };
|
|
7
|
+
};
|
|
8
|
+
export function getGlobalConfigDirectory(): string;
|
|
9
|
+
export function isPlainObject(value: unknown): value is ConfigObject;
|
|
10
|
+
export function readConfigFile(filePath?: string | null): ConfigObject;
|
|
11
|
+
export function readConfigLayers(workingDirectory?: string): ConfigLayers;
|
|
12
|
+
export function readConfig(workingDirectory?: string): ConfigObject;
|
|
13
|
+
export function getConfigForPath(layers: ConfigLayers, targetPath?: string | null): ConfigObject;
|
|
14
|
+
export function getJsonEntrySource(layers: ConfigLayers, sectionKey: string, entryName: string): { section: unknown; config: ConfigObject | null; path: string | null; exists: boolean };
|
|
15
|
+
export function getJsonWriteTarget(layers: ConfigLayers, preferredScope: string): { config: ConfigObject; path: string };
|
|
16
|
+
export function writeConfig(config: ConfigObject, filePath?: string): void;
|
|
17
|
+
export function writeConfigText(content: string, filePath: string): void;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { parse, parseTree, findNodeAtLocation, createScanner, SyntaxKind, modify, applyEdits } from 'jsonc-parser';
|
|
7
|
+
|
|
8
|
+
export const getGlobalConfigDirectory = () => process.env.OPENCODE_CONFIG_DIR
|
|
9
|
+
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
|
10
|
+
: path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode');
|
|
11
|
+
export const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
|
|
13
|
+
function parseConfigObject(content, filePath) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const value = parse(content.replace(/^\uFEFF/, ''), errors, { allowTrailingComma: true, allowEmptyContent: true });
|
|
16
|
+
if (errors.length || (value !== undefined && !isPlainObject(value))) {
|
|
17
|
+
throw Object.assign(new Error(`Invalid JSONC configuration: ${filePath}`), { code: 'INVALID_JSONC' });
|
|
18
|
+
}
|
|
19
|
+
return value ?? {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readText(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') return null;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readConfigFile(filePath) {
|
|
32
|
+
if (!filePath) return {};
|
|
33
|
+
const text = readText(filePath);
|
|
34
|
+
return text === null ? {} : parseConfigObject(text, filePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mergeConfigs(base, override) {
|
|
38
|
+
const result = { ...base };
|
|
39
|
+
for (const [key, value] of Object.entries(override)) {
|
|
40
|
+
Object.defineProperty(result, key, {
|
|
41
|
+
value: isPlainObject(result[key]) && isPlainObject(value) ? mergeConfigs(result[key], value) : value,
|
|
42
|
+
enumerable: true, configurable: true, writable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const names = ['opencode.json', 'opencode.jsonc'];
|
|
49
|
+
|
|
50
|
+
export function readConfigLayers(workingDirectory) {
|
|
51
|
+
const directory = getGlobalConfigDirectory();
|
|
52
|
+
const userPaths = names.map((name) => path.join(directory, name));
|
|
53
|
+
const customPath = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
|
|
54
|
+
const ancestors = [];
|
|
55
|
+
if (workingDirectory && path.resolve(workingDirectory) !== path.resolve(directory)) {
|
|
56
|
+
let current = path.resolve(workingDirectory);
|
|
57
|
+
while (true) {
|
|
58
|
+
ancestors.unshift(current);
|
|
59
|
+
const parent = path.dirname(current);
|
|
60
|
+
if (parent === current) break;
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const projectPaths = [
|
|
65
|
+
...ancestors.flatMap((dir) => names.map((name) => path.join(dir, name))),
|
|
66
|
+
...ancestors.flatMap((dir) => names.map((name) => path.join(dir, '.opencode', name))),
|
|
67
|
+
].filter((filePath) => !userPaths.includes(filePath));
|
|
68
|
+
const load = (paths, scope) => paths.flatMap((filePath) => {
|
|
69
|
+
const text = readText(filePath);
|
|
70
|
+
return text === null ? [] : [{ path: filePath, scope, config: parseConfigObject(text, filePath) }];
|
|
71
|
+
});
|
|
72
|
+
const userLayers = load(userPaths, 'user');
|
|
73
|
+
const projectLayers = load(projectPaths, 'project');
|
|
74
|
+
const customConfig = readConfigFile(customPath);
|
|
75
|
+
const documents = [...userLayers, ...(customPath ? [{ path: customPath, scope: 'custom', config: customConfig }] : []), ...projectLayers];
|
|
76
|
+
const merge = (layers) => layers.reduce((config, layer) => mergeConfigs(config, layer.config), {});
|
|
77
|
+
return {
|
|
78
|
+
userConfig: merge(userLayers), projectConfig: merge(projectLayers), customConfig,
|
|
79
|
+
mergedConfig: merge(documents), userLayers, projectLayers, documents,
|
|
80
|
+
paths: {
|
|
81
|
+
userPath: userLayers.at(-1)?.path ?? path.join(directory, 'opencode.jsonc'),
|
|
82
|
+
projectPath: projectLayers.at(-1)?.path ?? (workingDirectory ? path.join(workingDirectory, '.opencode', 'opencode.jsonc') : null),
|
|
83
|
+
customPath,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const readConfig = (workingDirectory) => readConfigLayers(workingDirectory).mergedConfig;
|
|
89
|
+
|
|
90
|
+
export function getConfigForPath(layers, targetPath) {
|
|
91
|
+
const filePath = targetPath || layers.paths.userPath;
|
|
92
|
+
return layers.documents.findLast((layer) => layer.path === filePath)?.config ?? readConfigFile(filePath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getJsonEntrySource(layers, sectionKey, entryName) {
|
|
96
|
+
for (const layer of layers.documents.toReversed()) {
|
|
97
|
+
const section = layer.config?.[sectionKey]?.[entryName];
|
|
98
|
+
if (section !== undefined) return { section, config: layer.config, path: layer.path, exists: true };
|
|
99
|
+
}
|
|
100
|
+
return { section: null, config: null, path: null, exists: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getJsonWriteTarget(layers, preferredScope) {
|
|
104
|
+
const filePath = preferredScope === 'project' && layers.paths.projectPath
|
|
105
|
+
? layers.paths.projectPath : layers.paths.customPath || layers.paths.userPath;
|
|
106
|
+
return { config: getConfigForPath(layers, filePath), path: filePath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removalEdits(text, propertyPath) {
|
|
110
|
+
const node = findNodeAtLocation(parseTree(text), propertyPath)?.parent;
|
|
111
|
+
const parent = node?.parent;
|
|
112
|
+
if (!node || parent?.type !== 'object') throw new Error('Cannot locate configuration property');
|
|
113
|
+
const siblings = parent.children;
|
|
114
|
+
const index = siblings.indexOf(node);
|
|
115
|
+
const end = node.offset + node.length;
|
|
116
|
+
const scanner = createScanner(text, true);
|
|
117
|
+
const comma = (start, stop) => {
|
|
118
|
+
scanner.setPosition(start);
|
|
119
|
+
return scanner.scan() === SyntaxKind.CommaToken && scanner.getTokenOffset() < stop ? scanner.getTokenOffset() : -1;
|
|
120
|
+
};
|
|
121
|
+
let separator = comma(end, siblings[index + 1]?.offset ?? parent.offset + parent.length - 1);
|
|
122
|
+
if (separator < 0) {
|
|
123
|
+
const previous = siblings[index - 1];
|
|
124
|
+
separator = comma(previous ? previous.offset + previous.length : parent.offset + 1, node.offset);
|
|
125
|
+
}
|
|
126
|
+
return [{ offset: node.offset, length: node.length, content: '' }, ...(separator < 0 ? [] : [{ offset: separator, length: 1, content: '' }])];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function editConfig(existing, desired, filePath) {
|
|
130
|
+
const current = parseConfigObject(existing, filePath);
|
|
131
|
+
const bom = existing.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
132
|
+
let text = existing.slice(bom.length);
|
|
133
|
+
if (!parseTree(text)) return `${existing}${existing && !existing.endsWith('\n') ? '\n' : ''}${JSON.stringify(desired, null, 2)}\n`;
|
|
134
|
+
const indentation = text.match(/\n([\t ]+)"/)?.[1] ?? ' ';
|
|
135
|
+
const formattingOptions = { insertSpaces: !indentation.includes('\t'), tabSize: indentation.length, eol: text.includes('\r\n') ? '\r\n' : '\n' };
|
|
136
|
+
const visit = (before, after, keys) => {
|
|
137
|
+
if (isDeepStrictEqual(before, after)) return;
|
|
138
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
139
|
+
for (const key of Object.keys(before)) {
|
|
140
|
+
if (!Object.hasOwn(after, key)) text = applyEdits(text, removalEdits(text, [...keys, key]));
|
|
141
|
+
}
|
|
142
|
+
for (const key of Object.keys(after)) visit(before[key], after[key], [...keys, key]);
|
|
143
|
+
} else {
|
|
144
|
+
text = applyEdits(text, modify(text, keys, after, { formattingOptions }));
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
visit(current, desired, []);
|
|
148
|
+
if (!isDeepStrictEqual(parseConfigObject(text, filePath), desired)) throw new Error('Configuration edit did not round-trip');
|
|
149
|
+
return bom + text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Shared by structured entity edits and the raw editor; failure never truncates the original.
|
|
153
|
+
export function writeConfigText(content, filePath) {
|
|
154
|
+
parseConfigObject(content, filePath);
|
|
155
|
+
let exists = false;
|
|
156
|
+
try {
|
|
157
|
+
fs.lstatSync(filePath);
|
|
158
|
+
exists = true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code !== 'ENOENT') throw error;
|
|
161
|
+
}
|
|
162
|
+
// Resolve before staging: rename must replace the dotfile target, not its link.
|
|
163
|
+
// lstat distinguishes new files from dangling links, whose realpath must fail.
|
|
164
|
+
const targetPath = exists ? fs.realpathSync(filePath) : filePath;
|
|
165
|
+
const original = readText(targetPath);
|
|
166
|
+
if (original !== null) parseConfigObject(original, filePath);
|
|
167
|
+
if (content === original) return;
|
|
168
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
169
|
+
const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
const mode = original === null ? 0o600 : fs.statSync(targetPath).mode & 0o777;
|
|
172
|
+
fs.writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
173
|
+
if (original !== null) {
|
|
174
|
+
fs.chmodSync(temporaryPath, mode);
|
|
175
|
+
fs.copyFileSync(targetPath, `${filePath}.openchamber.backup`);
|
|
176
|
+
}
|
|
177
|
+
fs.renameSync(temporaryPath, targetPath);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function writeConfig(config, filePath = path.join(getGlobalConfigDirectory(), 'opencode.jsonc')) {
|
|
184
|
+
const desired = JSON.parse(JSON.stringify(config));
|
|
185
|
+
if (!isPlainObject(desired)) throw new Error('Configuration must be an object');
|
|
186
|
+
writeConfigText(editConfig(readText(filePath) ?? '', desired, filePath), filePath);
|
|
187
|
+
}
|