@openchambery/web 2.0.0-beta.25 → 2.0.0-beta.29
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-FyTdEOMr.js → AgentAvatar-O2x_1cFU.js} +1 -1
- package/dist/assets/{AssistantView-CUqIFFyH.js → AssistantView-Bphg-QIn.js} +3 -3
- package/dist/assets/{DiagramView-DhEXKsIH.js → DiagramView-CRD_GFch.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
- package/dist/assets/{Icon-D7NdMmbi.js → Icon-BN87CyS4.js} +1 -1
- package/dist/assets/{MarkdownRenderer-CyacU8IE.js → MarkdownRenderer-D5KEGdBn.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-DgO-73sa.js → MarkdownRendererImpl-C143YQsV.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-CZPmsOrD.js → MarkstreamRendererImpl-C4rgOuOP.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BGKiZT4X.js → MobileDetailNavigation-D5tcF2Kg.js} +1 -1
- package/dist/assets/{MobileShareBridge-BFkZ-if6.js → MobileShareBridge-Bm0JZKHc.js} +3 -3
- package/dist/assets/MobileSurface-BzzBCVkG.js +2 -0
- package/dist/assets/{MultiRunWindow-BZLr1op3.js → MultiRunWindow-B6Sb3aEG.js} +1 -1
- package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-BLjZIBOs.js} +1 -1
- package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BOqRklf0.js} +1 -1
- package/dist/assets/SettingsView-Ds6x2z0F.js +25 -0
- package/dist/assets/{SettingsWindow-BMISHpTN.js → SettingsWindow-D6CN5ahJ.js} +1 -1
- package/dist/assets/{TerminalView-DKCcoHzi.js → TerminalView-DkzaJJfh.js} +1 -1
- package/dist/assets/{ToolOutputDialog-ByptQgFf.js → ToolOutputDialog-BegFpg1h.js} +1 -1
- package/dist/assets/{appThemeRegistry-b59_2rLn.js → appThemeRegistry-BMzuqD8_.js} +1 -1
- package/dist/assets/{button-e8fYWIMM.js → button-Ck8ipzBm.js} +1 -1
- package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-nU99prkD.js} +1 -1
- package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DMzZN1oi.js} +1 -1
- package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-DuftEj8B.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-g378BdZf.js} +1 -1
- package/dist/assets/{diagram-BBL-LfsC.js → diagram-CvO4P8ds.js} +1 -1
- package/dist/assets/{dialog-MRsJpHzu.js → dialog-ByGUkS3c.js} +1 -1
- package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
- package/dist/assets/fileMentionTouchSelection-Cuk9C_jN.js +2 -0
- package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-DuF89nFO.js} +1 -1
- package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
- package/dist/assets/{gitApi-CYFnq-nn.js → gitApi-BlWYyRdx.js} +3 -3
- package/dist/assets/{input-DbOe1mdT.js → input-CXhFmHcq.js} +1 -1
- package/dist/assets/insertionBoundaries-lAwXo-R-.js +1 -0
- package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
- package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
- package/dist/assets/{main-DOpUCN5f.js → main-BmiKRvkB.js} +2 -2
- package/dist/assets/main-D5SdJE5d.js +169 -0
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-g_UsWzbA.js} +1 -1
- package/dist/assets/{mcpQueries-Bxq_6P-n.js → mcpQueries-0Oe-8vHC.js} +1 -1
- package/dist/assets/{miniChat-CBpEIoJn.js → miniChat-CSwFHa9K.js} +2 -2
- package/dist/assets/mobile-DzJO7usb.js +2 -0
- package/dist/assets/multirun-Bdd0yE3M.js +3 -0
- package/dist/assets/persistence-Dnj1QOdX.js +3 -0
- package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
- package/dist/assets/platform-pfYy32vr.js +6 -0
- package/dist/assets/projectMeta-2dbON46t.js +1 -0
- package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
- package/dist/assets/renderElectronMiniChatApp-Bjf5yS-2.js +2 -0
- package/dist/assets/renderMobileApp-Bv0VxQo1.js +28 -0
- package/dist/assets/{runtimeConfig-CgFmzQWA.js → runtimeConfig-BOk04YEP.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
- package/dist/assets/runtimeEndpointReset-Oo4f7T5P.js +148 -0
- package/dist/assets/{sessionChangeSummary-Bg9UCEjA.js → sessionChangeSummary-Bf5wJLSv.js} +2 -2
- package/dist/assets/{sessionLookup-4MZgvAtH.js → sessionLookup-pga3-Etf.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-CHqMkTI3.js} +1 -1
- package/dist/assets/{store-D8fmTWK3.js → store-Cuktref-.js} +3 -3
- package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-BgGraKxs.js} +1 -1
- package/dist/assets/{textarea-B5GMntHk.js → textarea-B0Od84ZM.js} +1 -1
- package/dist/assets/{tooltip-C64SdRvR.js → tooltip-BKL7J5hx.js} +1 -1
- package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
- package/dist/assets/{url-DF1NFwt_.js → url-DNRMq_0R.js} +1 -1
- package/dist/assets/{useAppFontEffects-Ykf_fh2W.js → useAppFontEffects-gMl7z7An.js} +53 -53
- package/dist/assets/{useAssistantUIStore-B75cQm_z.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-GOgauAay.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CpsCUPDZ.js → useEffectiveDirectory-DgAB7HWq.js} +1 -1
- package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-Djxu_uRy.js} +1 -1
- package/dist/assets/useMobileNavigationStore-D7uAoBXe.js +415 -0
- package/dist/assets/useSessionAutoCleanup-Cg7KJFGa.js +2 -0
- package/dist/assets/{useUIStore-Brsa2mKs.js → useUIStore-Dfx6TKvi.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-N119bHfO.js → useWorkerHighlightedLines-DKa2EWBH.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-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
- package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
- package/dist/index.html +10 -10
- package/dist/mini-chat.html +10 -10
- package/dist/mobile.html +10 -10
- package/package.json +3 -3
- package/server/index.js +8 -20
- 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/opencode/DOCUMENTATION.md +24 -20
- 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 +9 -122
- package/server/lib/opencode/config-entity-routes.test.js +7 -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 +0 -1
- package/server/lib/opencode/core-routes.test.js +0 -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 +2 -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/routes.js +5 -5
- package/server/lib/opencode/routes.test.js +18 -18
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/shared.js +6 -175
- 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/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/scheduled-tasks/DOCUMENTATION.md +1 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -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 +36 -8
- package/server/lib/session-title/native-runtime.test.js +42 -0
- package/server/lib/session-title/runtime.js +18 -8
- 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/MobileSurface-CiQ9yAML.js +0 -2
- package/dist/assets/SettingsView-Bo1Xj4mZ.js +0 -25
- package/dist/assets/fileMentionTouchSelection-CMG5yrcJ.js +0 -3
- package/dist/assets/insertionBoundaries-C5b2f_mO.js +0 -1
- package/dist/assets/main-Bz3MB9dk.js +0 -169
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/mobile-CiFeGY2g.js +0 -2
- package/dist/assets/multirun-DnD1iWqj.js +0 -4
- package/dist/assets/persistence-CMWO3jFo.js +0 -3
- package/dist/assets/platform-B0p43squ.js +0 -6
- package/dist/assets/projectMeta-B1ShmM7W.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-DePrSFtn.js +0 -2
- package/dist/assets/renderMobileApp-CiGHeia2.js +0 -28
- package/dist/assets/runtimeEndpointReset-Dw46SzSQ.js +0 -149
- package/dist/assets/useDesktopWindowControlsLayout-kH_CrZfG.js +0 -1
- package/dist/assets/useMobileNavigationStore-CBo6pohX.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BuFi27vR.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) => {
|
|
@@ -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' }),
|
|
@@ -459,6 +453,8 @@ describe('config entity agent metadata route', () => {
|
|
|
459
453
|
});
|
|
460
454
|
|
|
461
455
|
describe('global raw configuration routes', () => {
|
|
456
|
+
beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
|
|
457
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
462
458
|
it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
|
|
463
459
|
const app = express();
|
|
464
460
|
app.use(express.json());
|
|
@@ -503,7 +499,7 @@ describe('global raw configuration routes', () => {
|
|
|
503
499
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
504
500
|
});
|
|
505
501
|
|
|
506
|
-
it('discovers only existing
|
|
502
|
+
it('discovers only existing targets and edits the winning global JSONC document', async () => {
|
|
507
503
|
const app = express();
|
|
508
504
|
app.use(express.json());
|
|
509
505
|
const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
|
|
@@ -516,20 +512,20 @@ describe('global raw configuration routes', () => {
|
|
|
516
512
|
const available = await request(app).get('/api/config/global').expect(200);
|
|
517
513
|
expect(available.body).toEqual({
|
|
518
514
|
targets: [
|
|
519
|
-
{ target: 'opencode', fileName: 'opencode.
|
|
515
|
+
{ target: 'opencode', fileName: 'opencode.jsonc' },
|
|
520
516
|
{ target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
|
|
521
517
|
],
|
|
522
518
|
});
|
|
523
519
|
|
|
524
520
|
const openCode = await request(app).get('/api/config/global/opencode').expect(200);
|
|
525
|
-
expect(openCode.body).toMatchObject({ fileName: 'opencode.
|
|
521
|
+
expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
|
|
526
522
|
|
|
527
523
|
await request(app)
|
|
528
524
|
.put('/api/config/global/opencode')
|
|
529
525
|
.send({ content: '{ "model": "updated-json-model" }' })
|
|
530
526
|
.expect(200);
|
|
531
|
-
expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('
|
|
532
|
-
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');
|
|
533
529
|
|
|
534
530
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
535
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
|
+
}
|