@vantran-se/openchamber-web 1.22.1 → 1.22.2
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 +2 -3
- package/bin/cli.js +4 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-CV1HNV5l.js} +1 -1
- package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
- package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
- package/dist/assets/FilesView-DDu8zDy5.js +17 -0
- package/dist/assets/GitView-1Ixo8z-M.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
- package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
- package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
- package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
- package/dist/assets/PlanView-trDaszfq.js +5 -0
- package/dist/assets/SettingsView-DStMfBmy.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
- package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
- package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
- package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
- package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
- package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
- package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
- package/dist/assets/index-D42fPvt4.css +1 -0
- package/dist/assets/index-DlFbCkEy.js +39 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
- package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
- package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
- package/dist/assets/main-B7qTsd_w.js +930 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
- package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
- package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
- package/dist/assets/pl-B2zmkOpw.js +16 -0
- package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
- package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
- package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
- package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
- package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
- package/dist/assets/tr-MJmRIzEY.js +7 -0
- package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
- package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
- package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
- package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
- package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
- package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -4
- package/server/index.js +38 -5
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/fs/DOCUMENTATION.md +2 -0
- package/server/lib/fs/routes.js +32 -21
- package/server/lib/fs/routes.test.js +77 -0
- package/server/lib/git/DOCUMENTATION.md +3 -1
- package/server/lib/git/routes.js +16 -0
- package/server/lib/git/service.js +107 -17
- package/server/lib/git/service.test.js +171 -0
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +158 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +13 -5
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.test.js +2 -2
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +4 -4
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-helpers.js +28 -0
- package/server/lib/opencode/settings-helpers.test.js +177 -0
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/opencode/settings-runtime.test.js +29 -0
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +2 -1
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/opencode-go.js +1 -0
- package/server/lib/quota/providers/opencode-go.test.js +5 -1
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-host.js +3 -1
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +1 -0
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +77 -11
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +4 -0
- package/server/lib/small-model/call.js +8 -2
- package/server/lib/small-model/call.test.js +23 -0
- package/server/lib/small-model/index.js +2 -1
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +11 -7
- package/server/lib/terminal/runtime.js +180 -26
- package/server/lib/terminal/runtime.test.js +553 -26
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
|
@@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
|
|
|
17
17
|
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
|
|
18
18
|
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
|
19
19
|
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
|
20
|
+
const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
|
20
21
|
|
|
21
22
|
let existingSessionMessages = [];
|
|
22
23
|
let dispatchedUserMessageSeq = 0;
|
|
@@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
|
|
|
78
79
|
fork: sessionForkMock,
|
|
79
80
|
messages: sessionMessagesMock,
|
|
80
81
|
command: sessionCommandMock,
|
|
82
|
+
update: sessionUpdateMock,
|
|
81
83
|
},
|
|
82
84
|
command: {
|
|
83
85
|
list: commandListMock,
|
|
@@ -132,6 +134,92 @@ describe('openchamber session routes', () => {
|
|
|
132
134
|
sessionCommandMock.mockResolvedValue({ data: {} });
|
|
133
135
|
commandListMock.mockReset();
|
|
134
136
|
commandListMock.mockResolvedValue({ data: [] });
|
|
137
|
+
sessionUpdateMock.mockReset();
|
|
138
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('archiving a batch of sessions', () => {
|
|
142
|
+
it('archives every id against the resolved directory and returns the archived sessions', async () => {
|
|
143
|
+
const { app } = createApp();
|
|
144
|
+
const response = await request(app)
|
|
145
|
+
.post('/api/openchamber/sessions/archive')
|
|
146
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
|
|
147
|
+
.expect(200);
|
|
148
|
+
|
|
149
|
+
expect(response.body.directory).toBe('/repo/app');
|
|
150
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
|
|
151
|
+
expect(response.body.failedIds).toEqual([]);
|
|
152
|
+
expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(sessionUpdateMock).toHaveBeenCalledWith({
|
|
154
|
+
sessionID: 'ses_a',
|
|
155
|
+
directory: '/repo/app',
|
|
156
|
+
time: { archived: 1700 },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('keeps archiving after a failed session and reports it as failed', async () => {
|
|
161
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
|
|
162
|
+
if (sessionID === 'ses_b') throw new Error('session.update failed');
|
|
163
|
+
return { data: { id: sessionID } };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const { app } = createApp();
|
|
167
|
+
const response = await request(app)
|
|
168
|
+
.post('/api/openchamber/sessions/archive')
|
|
169
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
|
|
170
|
+
.expect(200);
|
|
171
|
+
|
|
172
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
|
|
173
|
+
expect(response.body.failedIds).toEqual(['ses_b']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('reports a session the server did not confirm as failed instead of archived', async () => {
|
|
177
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
|
|
178
|
+
sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
|
|
179
|
+
));
|
|
180
|
+
|
|
181
|
+
const { app } = createApp();
|
|
182
|
+
const response = await request(app)
|
|
183
|
+
.post('/api/openchamber/sessions/archive')
|
|
184
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
|
|
185
|
+
.expect(200);
|
|
186
|
+
|
|
187
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
|
|
188
|
+
expect(response.body.failedIds).toEqual(['ses_b']);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
|
|
192
|
+
const { app } = createApp();
|
|
193
|
+
|
|
194
|
+
await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
|
|
195
|
+
await request(app)
|
|
196
|
+
.post('/api/openchamber/sessions/archive')
|
|
197
|
+
.send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
|
|
198
|
+
.expect(400);
|
|
199
|
+
await request(app)
|
|
200
|
+
.post('/api/openchamber/sessions/archive')
|
|
201
|
+
.send({ directory: '/repo/app', ids: ['ses_a', ''] })
|
|
202
|
+
.expect(400);
|
|
203
|
+
await request(app)
|
|
204
|
+
.post('/api/openchamber/sessions/archive')
|
|
205
|
+
.send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
|
|
206
|
+
.expect(400);
|
|
207
|
+
|
|
208
|
+
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects a directory the runtime does not accept', async () => {
|
|
212
|
+
const { app } = createApp({
|
|
213
|
+
validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await request(app)
|
|
217
|
+
.post('/api/openchamber/sessions/archive')
|
|
218
|
+
.send({ directory: '/elsewhere', ids: ['ses_a'] })
|
|
219
|
+
.expect(400);
|
|
220
|
+
|
|
221
|
+
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
135
223
|
});
|
|
136
224
|
|
|
137
225
|
it('creates a session for a directory', async () => {
|
|
@@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
34
34
|
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
|
|
35
35
|
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
|
36
36
|
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
|
|
37
|
+
- `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
|
|
38
|
+
- `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
|
|
37
39
|
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
|
38
40
|
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
|
39
41
|
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
|
@@ -61,12 +63,12 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
61
63
|
|
|
62
64
|
## Public exports (providers.js)
|
|
63
65
|
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
|
|
64
|
-
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom
|
|
65
|
-
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
|
66
|
+
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. The adapter may be OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages. Existing provider, option, and retained-model fields not managed by the form are preserved; omitted models, headers, and env credentials remain explicit removals. Updating a legacy `providers` entry migrates it to the canonical `provider` key. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
|
|
67
|
+
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, adapter allowlist `@ai-sdk/openai-compatible`/`@ai-sdk/openai`/`@ai-sdk/anthropic`, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
|
66
68
|
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
|
67
69
|
|
|
68
70
|
## Public exports (shared.js)
|
|
69
|
-
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer
|
|
71
|
+
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants rooted at `$XDG_CONFIG_HOME/opencode` when `XDG_CONFIG_HOME` is non-empty, otherwise `~/.config/opencode`. These constants are evaluated when the module loads; no files are migrated. `OPENCODE_CONFIG` remains a separate explicit config-file path and is resolved at call time for the custom config layer; it does not replace the global config directory.
|
|
70
72
|
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
|
71
73
|
- `ensureDirs()`: Creates required OpenCode directories.
|
|
72
74
|
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
|
@@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit
|
|
|
130
132
|
- `killProcessOnPort(port)`
|
|
131
133
|
|
|
132
134
|
Managed OpenCode launch also merges the environment returned by the agent-tool
|
|
133
|
-
runtime
|
|
135
|
+
runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
|
|
136
|
+
plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
|
134
137
|
be replaced by injected values. External OpenCode processes receive no
|
|
135
138
|
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
|
136
139
|
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
|
@@ -208,6 +211,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
|
|
|
208
211
|
- `writeSettingsToDisk(settings)`
|
|
209
212
|
- `persistSettings(changes)`
|
|
210
213
|
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
|
214
|
+
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
|
|
211
215
|
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
|
|
212
216
|
|
|
213
217
|
## Public exports (settings-helpers.js)
|
|
@@ -417,7 +421,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|
|
417
421
|
|
|
418
422
|
## Storage and configuration
|
|
419
423
|
- Provider auth: `~/.local/share/opencode/auth.json`.
|
|
420
|
-
- User config: `~/.config/opencode/opencode.json
|
|
424
|
+
- User config: `$XDG_CONFIG_HOME/opencode/opencode.json`, falling back to `~/.config/opencode/opencode.json` when unset or blank.
|
|
421
425
|
- Project config: `<workingDirectory>/.opencode/opencode.json` or `opencode.json`.
|
|
422
426
|
- Custom config: `OPENCODE_CONFIG` env var path.
|
|
423
427
|
- Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars.
|
|
@@ -429,3 +433,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|
|
429
433
|
- Config merging follows priority: custom > project > user.
|
|
430
434
|
- UI auth uses scrypt for password hashing with constant-time comparison.
|
|
431
435
|
- Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback.
|
|
436
|
+
|
|
437
|
+
The behavior `GET /api/behavior/agents-md` response includes `path`, the effective
|
|
438
|
+
server-side filename, whether or not the file exists. Settings displays this
|
|
439
|
+
path without deriving a directory from the browser environment.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
7
|
+
const originalOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
11
|
+
else process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
|
12
|
+
if (originalOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
|
13
|
+
else process.env.OPENCODE_CONFIG = originalOpenCodeConfig;
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function loadOpenCodeModules() {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
return Promise.all([
|
|
20
|
+
import('./shared.js'),
|
|
21
|
+
import('./agents.js'),
|
|
22
|
+
import('./commands.js'),
|
|
23
|
+
import('./skills.js'),
|
|
24
|
+
import('./snippets.js'),
|
|
25
|
+
import('./plugins.js'),
|
|
26
|
+
import('./routes.js'),
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('OpenCode global config paths', () => {
|
|
31
|
+
it('derives all shared constants from a non-empty XDG_CONFIG_HOME', async () => {
|
|
32
|
+
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-xdg-'));
|
|
33
|
+
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
34
|
+
|
|
35
|
+
const [{ OPENCODE_CONFIG_DIR, AGENT_DIR, COMMAND_DIR, SKILL_DIR, CONFIG_FILE }] = await loadOpenCodeModules();
|
|
36
|
+
const configDir = path.join(xdgConfigHome, 'opencode');
|
|
37
|
+
expect(OPENCODE_CONFIG_DIR).toBe(configDir);
|
|
38
|
+
expect(AGENT_DIR).toBe(path.join(configDir, 'agents'));
|
|
39
|
+
expect(COMMAND_DIR).toBe(path.join(configDir, 'commands'));
|
|
40
|
+
expect(SKILL_DIR).toBe(path.join(configDir, 'skills'));
|
|
41
|
+
expect(CONFIG_FILE).toBe(path.join(configDir, 'config.json'));
|
|
42
|
+
|
|
43
|
+
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each([undefined, ' '])('falls back to ~/.config/opencode when XDG_CONFIG_HOME is %s', async (xdgConfigHome) => {
|
|
47
|
+
if (xdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
48
|
+
else process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
49
|
+
|
|
50
|
+
const [{ OPENCODE_CONFIG_DIR }] = await loadOpenCodeModules();
|
|
51
|
+
expect(OPENCODE_CONFIG_DIR).toBe(path.join(os.homedir(), '.config', 'opencode'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps global CRUD below XDG_CONFIG_HOME while project files stay in the project', async () => {
|
|
55
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-paths-'));
|
|
56
|
+
const xdgConfigHome = path.join(root, 'xdg');
|
|
57
|
+
const projectDir = path.join(root, 'project');
|
|
58
|
+
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
59
|
+
delete process.env.OPENCODE_CONFIG;
|
|
60
|
+
|
|
61
|
+
const [, agents, commands, skills, snippets, plugins] = await loadOpenCodeModules();
|
|
62
|
+
agents.createAgent('global-agent', { description: 'Global', prompt: 'Global prompt' }, projectDir, 'user');
|
|
63
|
+
commands.createCommand('global-command', { description: 'Global', template: 'Global template' }, projectDir, 'user');
|
|
64
|
+
skills.createSkill('global-skill', { description: 'Global', instructions: 'Global instructions' }, projectDir, 'user');
|
|
65
|
+
snippets.createSnippet('global-snippet', { content: 'Global snippet' }, projectDir, 'global');
|
|
66
|
+
plugins.createPluginEntry({ spec: 'global-plugin', scope: 'user' }, projectDir);
|
|
67
|
+
|
|
68
|
+
const configDir = path.join(xdgConfigHome, 'opencode');
|
|
69
|
+
expect(fs.existsSync(path.join(configDir, 'agents', 'global-agent.md'))).toBe(true);
|
|
70
|
+
expect(fs.existsSync(path.join(configDir, 'commands', 'global-command.md'))).toBe(true);
|
|
71
|
+
expect(fs.existsSync(path.join(configDir, 'skills', 'global-skill', 'SKILL.md'))).toBe(true);
|
|
72
|
+
expect(fs.existsSync(path.join(configDir, 'snippet', 'global-snippet.md'))).toBe(true);
|
|
73
|
+
expect(JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')).plugin).toEqual(['global-plugin']);
|
|
74
|
+
|
|
75
|
+
agents.createAgent('project-agent', { description: 'Project', prompt: 'Project prompt' }, projectDir, 'project');
|
|
76
|
+
commands.createCommand('project-command', { description: 'Project', template: 'Project template' }, projectDir, 'project');
|
|
77
|
+
skills.createSkill('project-skill', { description: 'Project', instructions: 'Project instructions' }, projectDir, 'project');
|
|
78
|
+
|
|
79
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'agents', 'project-agent.md'))).toBe(true);
|
|
80
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'commands', 'project-command.md'))).toBe(true);
|
|
81
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'skills', 'project-skill', 'SKILL.md'))).toBe(true);
|
|
82
|
+
|
|
83
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('writes global AGENTS.md below XDG_CONFIG_HOME', async () => {
|
|
87
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-agents-md-'));
|
|
88
|
+
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
|
|
89
|
+
delete process.env.OPENCODE_CONFIG;
|
|
90
|
+
|
|
91
|
+
const [, , , , , , routes] = await loadOpenCodeModules();
|
|
92
|
+
const handlers = new Map();
|
|
93
|
+
const app = {
|
|
94
|
+
get(route, ...callbacks) { handlers.set(`GET ${route}`, callbacks.at(-1)); },
|
|
95
|
+
put(route, ...callbacks) { handlers.set(`PUT ${route}`, callbacks.at(-1)); },
|
|
96
|
+
post() {},
|
|
97
|
+
delete() {},
|
|
98
|
+
};
|
|
99
|
+
routes.registerOpenCodeRoutes(app, {});
|
|
100
|
+
|
|
101
|
+
const response = { json: vi.fn(), status: vi.fn(() => response) };
|
|
102
|
+
await handlers.get('GET /api/behavior/agents-md')({}, response);
|
|
103
|
+
expect(response.json).toHaveBeenLastCalledWith({
|
|
104
|
+
content: '', exists: false, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await handlers.get('PUT /api/behavior/agents-md')({ body: { content: 'Global behavior' } }, response);
|
|
108
|
+
|
|
109
|
+
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
|
|
110
|
+
await handlers.get('GET /api/behavior/agents-md')({}, response);
|
|
111
|
+
expect(response.json).toHaveBeenLastCalledWith({
|
|
112
|
+
content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
|
|
113
|
+
});
|
|
114
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1065,6 +1065,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1065
1065
|
req.path.startsWith('/api/push') ||
|
|
1066
1066
|
req.path.startsWith('/api/notifications') ||
|
|
1067
1067
|
req.path.startsWith('/api/permission-auto-accept') ||
|
|
1068
|
+
req.path.startsWith('/api/message-queue') ||
|
|
1068
1069
|
req.path.startsWith('/api/provider') ||
|
|
1069
1070
|
req.path.startsWith('/api/session-folders') ||
|
|
1070
1071
|
req.path.startsWith('/api/small-model') ||
|
|
@@ -13,6 +13,7 @@ import { registerProjectContextRoutes } from '../project-context/routes.js';
|
|
|
13
13
|
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
|
|
14
14
|
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
|
|
15
15
|
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
|
16
|
+
import { registerMessageQueueRoutes } from '../message-queue/runtime.js';
|
|
16
17
|
import { registerConfigEntityRoutes } from './config-entity-routes.js';
|
|
17
18
|
import { registerSettingsUtilityRoutes } from './core-routes.js';
|
|
18
19
|
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
|
@@ -97,6 +98,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
97
98
|
createFsSearchRuntime,
|
|
98
99
|
openchamberDataDir,
|
|
99
100
|
openchamberUserConfigRoot,
|
|
101
|
+
managedChatsRoot,
|
|
100
102
|
normalizeDirectoryPath,
|
|
101
103
|
resolveProjectDirectory,
|
|
102
104
|
resolveOptionalProjectDirectory,
|
|
@@ -132,6 +134,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
132
134
|
writeSseEvent,
|
|
133
135
|
emitSessionCreatedEvent,
|
|
134
136
|
permissionAutoAcceptRuntime,
|
|
137
|
+
messageQueueRuntime,
|
|
135
138
|
} = routeDependencies;
|
|
136
139
|
|
|
137
140
|
registerSettingsUtilityRoutes(app, {
|
|
@@ -141,6 +144,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime);
|
|
147
|
+
registerMessageQueueRoutes(app, messageQueueRuntime);
|
|
144
148
|
|
|
145
149
|
registerOpenCodeRoutes(app, {
|
|
146
150
|
crypto,
|
|
@@ -329,6 +333,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
329
333
|
buildAugmentedPath,
|
|
330
334
|
resolveGitBinaryForSpawn,
|
|
331
335
|
openchamberUserConfigRoot,
|
|
336
|
+
managedChatsRoot,
|
|
332
337
|
});
|
|
333
338
|
};
|
|
334
339
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const DEFAULT_INPUT_HISTORY_SCOPE = 'session';
|
|
2
|
+
export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
|
|
3
|
+
const MIN_INPUT_HISTORY_LIMIT = 1;
|
|
4
|
+
const MAX_INPUT_HISTORY_LIMIT = 100;
|
|
5
|
+
|
|
6
|
+
export const isInputHistoryScope = (value) => (
|
|
7
|
+
value === 'global' || value === 'session'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const isInputHistoryLimit = (value) => (
|
|
11
|
+
Number.isInteger(value)
|
|
12
|
+
&& value >= MIN_INPUT_HISTORY_LIMIT
|
|
13
|
+
&& value <= MAX_INPUT_HISTORY_LIMIT
|
|
14
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
2
|
+
|
|
3
|
+
const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Append a managed OpenChamber plugin to the `plugin` list of an
|
|
7
|
+
* `OPENCODE_CONFIG_CONTENT` value.
|
|
8
|
+
*
|
|
9
|
+
* Existing entries are preserved; an earlier entry for the same URL is dropped
|
|
10
|
+
* so a restart never registers the same plugin twice. Every managed plugin
|
|
11
|
+
* (agent tools, system prompt optimizer, MCP reconnect) goes through this one
|
|
12
|
+
* merge so they compose in any order.
|
|
13
|
+
*
|
|
14
|
+
* @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
|
|
15
|
+
* @param {string} pluginUrl `file://` URL of the materialized plugin
|
|
16
|
+
* @param {string} purpose short noun phrase for the error message, e.g. "managed tool"
|
|
17
|
+
* @returns {string} the merged config as JSON
|
|
18
|
+
*/
|
|
19
|
+
export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => {
|
|
20
|
+
const errors = [];
|
|
21
|
+
const text = (rawConfig ?? '').trim();
|
|
22
|
+
const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {};
|
|
23
|
+
if (errors.length > 0 || !isJsonObject(parsed)) {
|
|
24
|
+
throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`);
|
|
25
|
+
}
|
|
26
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
|
27
|
+
throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`);
|
|
28
|
+
}
|
|
29
|
+
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
|
30
|
+
parsed.plugin = [
|
|
31
|
+
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
|
32
|
+
pluginUrl,
|
|
33
|
+
];
|
|
34
|
+
return JSON.stringify(parsed);
|
|
35
|
+
};
|
|
@@ -62,7 +62,7 @@ beforeEach(() => {
|
|
|
62
62
|
packageManager: 'npm',
|
|
63
63
|
});
|
|
64
64
|
packageManager.getUpdateCommand.mockReturnValue(
|
|
65
|
-
'npm install -g @vantran-se/openchamber-web@latest --registry=https://
|
|
65
|
+
'npm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org',
|
|
66
66
|
);
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -133,7 +133,7 @@ describe('OpenChamber foreground update route', () => {
|
|
|
133
133
|
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
|
|
134
134
|
'/bin/sh',
|
|
135
135
|
'-c',
|
|
136
|
-
"set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://
|
|
136
|
+
"set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org\nsystemctl --user restart 'openchamber@wsl.service'",
|
|
137
137
|
], {
|
|
138
138
|
encoding: 'utf8',
|
|
139
139
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import {
|
|
5
4
|
AGENT_SCOPE,
|
|
5
|
+
OPENCODE_CONFIG_DIR,
|
|
6
6
|
readConfigFile,
|
|
7
7
|
readConfigLayer,
|
|
8
8
|
writeConfig,
|
|
@@ -71,7 +71,7 @@ function getActiveOpencodeConfigDir() {
|
|
|
71
71
|
if (customConfigPath) {
|
|
72
72
|
return path.dirname(path.resolve(customConfigPath));
|
|
73
73
|
}
|
|
74
|
-
return
|
|
74
|
+
return OPENCODE_CONFIG_DIR;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function getActiveUserConfigPaths() {
|
|
@@ -152,6 +152,37 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
|
|
|
152
152
|
return { ok: true, value: { providerId, config: normalized } };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function mergeCustomProviderConfig(existingValue, normalizedConfig) {
|
|
156
|
+
const existing = isPlainObject(existingValue) ? existingValue : {};
|
|
157
|
+
const existingOptions = isPlainObject(existing.options) ? existing.options : {};
|
|
158
|
+
const normalizedOptions = isPlainObject(normalizedConfig.options) ? normalizedConfig.options : {};
|
|
159
|
+
const mergedOptions = { ...existingOptions, ...normalizedOptions };
|
|
160
|
+
if (!Object.prototype.hasOwnProperty.call(normalizedOptions, 'headers')) {
|
|
161
|
+
delete mergedOptions.headers;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const existingModels = isPlainObject(existing.models) ? existing.models : {};
|
|
165
|
+
const normalizedModels = isPlainObject(normalizedConfig.models) ? normalizedConfig.models : {};
|
|
166
|
+
const mergedModels = Object.fromEntries(
|
|
167
|
+
Object.entries(normalizedModels).map(([modelId, normalizedModel]) => {
|
|
168
|
+
const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {};
|
|
169
|
+
const nextModel = isPlainObject(normalizedModel) ? normalizedModel : {};
|
|
170
|
+
return [modelId, { ...existingModel, ...nextModel }];
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const merged = {
|
|
175
|
+
...existing,
|
|
176
|
+
...normalizedConfig,
|
|
177
|
+
options: mergedOptions,
|
|
178
|
+
models: mergedModels,
|
|
179
|
+
};
|
|
180
|
+
if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) {
|
|
181
|
+
delete merged.env;
|
|
182
|
+
}
|
|
183
|
+
return merged;
|
|
184
|
+
}
|
|
185
|
+
|
|
155
186
|
/**
|
|
156
187
|
* Persist (create or update) a custom provider block in OpenCode user/project/custom config.
|
|
157
188
|
* Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
|
|
@@ -183,8 +214,19 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
|
|
|
183
214
|
|
|
184
215
|
const targetConfig = getConfigForPath(layers, targetPath);
|
|
185
216
|
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
|
|
186
|
-
|
|
217
|
+
const providersAlias = isPlainObject(targetConfig.providers) ? { ...targetConfig.providers } : {};
|
|
218
|
+
const existingProvider = providerConfig[validated.value.providerId] ?? providersAlias[validated.value.providerId];
|
|
219
|
+
const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config);
|
|
220
|
+
providerConfig[validated.value.providerId] = mergedConfig;
|
|
187
221
|
targetConfig.provider = providerConfig;
|
|
222
|
+
if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) {
|
|
223
|
+
delete providersAlias[validated.value.providerId];
|
|
224
|
+
if (Object.keys(providersAlias).length === 0) {
|
|
225
|
+
delete targetConfig.providers;
|
|
226
|
+
} else {
|
|
227
|
+
targetConfig.providers = providersAlias;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
188
230
|
|
|
189
231
|
if (Array.isArray(targetConfig.disabled_providers)) {
|
|
190
232
|
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
|
@@ -198,7 +240,7 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
|
|
|
198
240
|
return {
|
|
199
241
|
providerId: validated.value.providerId,
|
|
200
242
|
path: writePath,
|
|
201
|
-
config:
|
|
243
|
+
config: mergedConfig,
|
|
202
244
|
};
|
|
203
245
|
}
|
|
204
246
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getProviderSources,
|
|
10
10
|
removeProviderConfig,
|
|
11
11
|
} from './providers.js';
|
|
12
|
+
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
|
12
13
|
|
|
13
14
|
let projectDir;
|
|
14
15
|
|
|
@@ -162,6 +163,132 @@ describe('custom provider config persistence', () => {
|
|
|
162
163
|
expect(written.disabled_providers).toEqual(['other']);
|
|
163
164
|
});
|
|
164
165
|
|
|
166
|
+
test('upsertProviderConfig preserves unmanaged provider and model metadata', () => {
|
|
167
|
+
const configPath = path.join(projectDir, 'opencode.json');
|
|
168
|
+
writeJson(configPath, {
|
|
169
|
+
provider: {
|
|
170
|
+
'campus-llm': {
|
|
171
|
+
npm: '@ai-sdk/openai-compatible',
|
|
172
|
+
name: 'Old',
|
|
173
|
+
customProviderField: { owner: 'user' },
|
|
174
|
+
env: ['OLD_KEY'],
|
|
175
|
+
options: {
|
|
176
|
+
baseURL: 'https://old.example.edu/v1',
|
|
177
|
+
headers: { 'X-Old': '1' },
|
|
178
|
+
timeout: 45_000,
|
|
179
|
+
},
|
|
180
|
+
models: {
|
|
181
|
+
retained: {
|
|
182
|
+
name: 'Old retained name',
|
|
183
|
+
reasoning: true,
|
|
184
|
+
attachment: true,
|
|
185
|
+
tool_call: true,
|
|
186
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
187
|
+
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
|
188
|
+
options: { instructions: 'Keep this instruction' },
|
|
189
|
+
variants: {
|
|
190
|
+
low: { reasoningEffort: 'low' },
|
|
191
|
+
high: { reasoningEffort: 'high' },
|
|
192
|
+
},
|
|
193
|
+
customModelField: { source: 'manual' },
|
|
194
|
+
},
|
|
195
|
+
removed: {
|
|
196
|
+
name: 'Remove me',
|
|
197
|
+
reasoning: true,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
upsertProviderConfig('campus-llm', {
|
|
205
|
+
name: 'Campus LLM',
|
|
206
|
+
options: { baseURL: 'https://new.example.edu/v1' },
|
|
207
|
+
models: {
|
|
208
|
+
retained: { name: 'Retained model' },
|
|
209
|
+
added: { name: 'Added model' },
|
|
210
|
+
},
|
|
211
|
+
}, projectDir, 'project', { hasStoredAuth: true });
|
|
212
|
+
|
|
213
|
+
const written = readJson(configPath).provider['campus-llm'];
|
|
214
|
+
expect(written).toEqual({
|
|
215
|
+
npm: '@ai-sdk/openai-compatible',
|
|
216
|
+
name: 'Campus LLM',
|
|
217
|
+
customProviderField: { owner: 'user' },
|
|
218
|
+
options: {
|
|
219
|
+
baseURL: 'https://new.example.edu/v1',
|
|
220
|
+
timeout: 45_000,
|
|
221
|
+
},
|
|
222
|
+
models: {
|
|
223
|
+
retained: {
|
|
224
|
+
name: 'Retained model',
|
|
225
|
+
reasoning: true,
|
|
226
|
+
attachment: true,
|
|
227
|
+
tool_call: true,
|
|
228
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
229
|
+
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
|
230
|
+
options: { instructions: 'Keep this instruction' },
|
|
231
|
+
variants: {
|
|
232
|
+
low: { reasoningEffort: 'low' },
|
|
233
|
+
high: { reasoningEffort: 'high' },
|
|
234
|
+
},
|
|
235
|
+
customModelField: { source: 'manual' },
|
|
236
|
+
},
|
|
237
|
+
added: { name: 'Added model' },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => {
|
|
243
|
+
const configPath = path.join(projectDir, 'opencode.json');
|
|
244
|
+
writeJson(configPath, {
|
|
245
|
+
providers: {
|
|
246
|
+
legacy: {
|
|
247
|
+
name: 'Legacy provider',
|
|
248
|
+
options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 },
|
|
249
|
+
models: { model: { name: 'Old model', reasoning: true } },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
upsertProviderConfig('legacy', {
|
|
255
|
+
name: 'Updated provider',
|
|
256
|
+
options: { baseURL: 'https://new.example.com/v1' },
|
|
257
|
+
models: { model: { name: 'Updated model' } },
|
|
258
|
+
}, projectDir, 'project', { hasStoredAuth: true });
|
|
259
|
+
|
|
260
|
+
const written = readJson(configPath);
|
|
261
|
+
expect(written.providers).toBeUndefined();
|
|
262
|
+
expect(written.provider.legacy).toEqual({
|
|
263
|
+
npm: '@ai-sdk/openai-compatible',
|
|
264
|
+
name: 'Updated provider',
|
|
265
|
+
options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 },
|
|
266
|
+
models: { model: { name: 'Updated model', reasoning: true } },
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('migrating one legacy providers entry keeps the other legacy entries', () => {
|
|
271
|
+
const configPath = path.join(projectDir, 'opencode.json');
|
|
272
|
+
writeJson(configPath, {
|
|
273
|
+
providers: {
|
|
274
|
+
legacy: { name: 'Legacy provider', options: { baseURL: 'https://old.example.com/v1' }, models: { model: { name: 'Old model' } } },
|
|
275
|
+
untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
upsertProviderConfig('legacy', {
|
|
280
|
+
name: 'Updated provider',
|
|
281
|
+
options: { baseURL: 'https://new.example.com/v1' },
|
|
282
|
+
models: { model: { name: 'Updated model' } },
|
|
283
|
+
}, projectDir, 'project', { hasStoredAuth: true });
|
|
284
|
+
|
|
285
|
+
const written = readJson(configPath);
|
|
286
|
+
expect(written.providers).toEqual({
|
|
287
|
+
untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
|
|
288
|
+
});
|
|
289
|
+
expect(written.provider.legacy.name).toBe('Updated provider');
|
|
290
|
+
});
|
|
291
|
+
|
|
165
292
|
test('upsert then remove restores absence', () => {
|
|
166
293
|
upsertProviderConfig('temp-provider', {
|
|
167
294
|
name: 'Temp',
|
|
@@ -230,8 +357,8 @@ describe('custom provider config persistence', () => {
|
|
|
230
357
|
expect(sources.sources.custom.exists).toBe(false);
|
|
231
358
|
|
|
232
359
|
for (const userPath of [
|
|
233
|
-
path.join(
|
|
234
|
-
path.join(
|
|
360
|
+
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
|
|
361
|
+
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
|
|
235
362
|
]) {
|
|
236
363
|
if (!fs.existsSync(userPath)) continue;
|
|
237
364
|
const userConfig = readJson(userPath);
|
|
@@ -269,8 +396,8 @@ describe('custom provider config persistence', () => {
|
|
|
269
396
|
expect(sources.sources.project.exists).toBe(false);
|
|
270
397
|
|
|
271
398
|
for (const userPath of [
|
|
272
|
-
path.join(
|
|
273
|
-
path.join(
|
|
399
|
+
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
|
|
400
|
+
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
|
|
274
401
|
]) {
|
|
275
402
|
if (!fs.existsSync(userPath)) continue;
|
|
276
403
|
const userConfig = readJson(userPath);
|