@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.1
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 +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/multirun-BvSG94mW.js +4 -0
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/projectMeta-DOe49dM_.js +3 -0
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
- package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/src-BzX5UI-c.css +2 -0
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/textarea-3XR8sUfy.js +1 -0
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
- package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
- package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
- package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
- package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CcIuekQ3.js +0 -7
- package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-CFLmpNC5.js +0 -169
- package/dist/assets/main-Ckj_IpUy.js +0 -2
- package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
- package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
- package/dist/assets/multirun-BGUKIJys.js +0 -4
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
- package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
- package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
- package/dist/assets/src-rS3zCcsC.css +0 -2
- package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
- package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -2,9 +2,58 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parse as parseJsonc } from 'jsonc-parser';
|
|
5
|
-
import {
|
|
5
|
+
import { OpenCode } from '@opencode/client';
|
|
6
6
|
import { projectProviderCatalog } from './provider-catalog.js';
|
|
7
7
|
|
|
8
|
+
// Compose official v2 provider.list + model.list (+ optional default) into the
|
|
9
|
+
// catalog shape consumed by projectProviderCatalog. Missing arrays are failure.
|
|
10
|
+
const composeV2ProviderCatalogSource = (providers, models, defaultModel) => {
|
|
11
|
+
if (!Array.isArray(providers) || !Array.isArray(models)) return null;
|
|
12
|
+
|
|
13
|
+
const modelsByProvider = new Map();
|
|
14
|
+
for (const model of models) {
|
|
15
|
+
if (!model || typeof model !== 'object') continue;
|
|
16
|
+
const providerID = typeof model.providerID === 'string' ? model.providerID : '';
|
|
17
|
+
const modelID = typeof model.modelID === 'string' && model.modelID
|
|
18
|
+
? model.modelID
|
|
19
|
+
: (typeof model.id === 'string' ? model.id : '');
|
|
20
|
+
if (!providerID || !modelID) continue;
|
|
21
|
+
if (!modelsByProvider.has(providerID)) modelsByProvider.set(providerID, Object.create(null));
|
|
22
|
+
const entry = { id: modelID };
|
|
23
|
+
if (typeof model.name === 'string' && model.name) entry.name = model.name;
|
|
24
|
+
if (typeof model.family === 'string' && model.family) entry.family = model.family;
|
|
25
|
+
if (model.limit && typeof model.limit === 'object') entry.limit = model.limit;
|
|
26
|
+
if (model.capabilities) entry.capabilities = model.capabilities;
|
|
27
|
+
if (model.cost && typeof model.cost === 'object' && !Array.isArray(model.cost)) entry.cost = model.cost;
|
|
28
|
+
if (model.variants && typeof model.variants === 'object' && !Array.isArray(model.variants)) {
|
|
29
|
+
entry.variants = model.variants;
|
|
30
|
+
}
|
|
31
|
+
if (typeof model.time?.released === 'number' && Number.isFinite(model.time.released)) {
|
|
32
|
+
entry.release_date = new Date(model.time.released).toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
modelsByProvider.get(providerID)[modelID] = entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const list = [];
|
|
38
|
+
for (const provider of providers) {
|
|
39
|
+
if (!provider || typeof provider.id !== 'string' || !provider.id) continue;
|
|
40
|
+
list.push({
|
|
41
|
+
id: provider.id,
|
|
42
|
+
name: typeof provider.name === 'string' && provider.name ? provider.name : provider.id,
|
|
43
|
+
models: modelsByProvider.get(provider.id) || Object.create(null),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const defaults = Object.create(null);
|
|
48
|
+
if (defaultModel && typeof defaultModel.providerID === 'string' && typeof defaultModel.modelID === 'string') {
|
|
49
|
+
defaults[defaultModel.providerID] = defaultModel.modelID;
|
|
50
|
+
}
|
|
51
|
+
return { providers: list, default: defaults };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const createOpenCodeClient = ({ baseUrl, headers, fetch: fetchImpl }) =>
|
|
55
|
+
OpenCode.make({ baseUrl, headers, fetch: fetchImpl });
|
|
56
|
+
|
|
8
57
|
const MAX_GLOBAL_CONFIG_SIZE = 2 * 1024 * 1024;
|
|
9
58
|
const GLOBAL_CONFIG_FILES = {
|
|
10
59
|
opencode: ['opencode.json', 'opencode.jsonc'],
|
|
@@ -83,6 +132,7 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
83
132
|
resolveOptionalProjectDirectory,
|
|
84
133
|
refreshOpenCodeAfterConfigChange,
|
|
85
134
|
clientReloadDelayMs,
|
|
135
|
+
waitForOpenCodeReady,
|
|
86
136
|
getAgentSources,
|
|
87
137
|
getAgentConfig,
|
|
88
138
|
createAgent,
|
|
@@ -115,25 +165,43 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
115
165
|
if (!directory) {
|
|
116
166
|
return res.status(400).json({ error });
|
|
117
167
|
}
|
|
118
|
-
|
|
168
|
+
// Gate on managed-OpenCode readiness like the generic proxy: without this
|
|
169
|
+
// the SDK hits an upstream that is still starting (or a stale orphan from
|
|
170
|
+
// a nodemon restart) and gets HTML/401 back, surfacing as a spurious 502.
|
|
171
|
+
if (typeof waitForOpenCodeReady === 'function') {
|
|
172
|
+
await waitForOpenCodeReady(20_000, 200);
|
|
173
|
+
}
|
|
174
|
+
const client = createOpenCodeClient({
|
|
119
175
|
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
120
|
-
directory,
|
|
121
176
|
headers: getOpenCodeAuthHeaders(),
|
|
122
|
-
|
|
177
|
+
// Forward the full init: the SDK carries Authorization in init.headers.
|
|
178
|
+
// Dropping init (the old `(request) => fetch(request, { signal })` shape)
|
|
179
|
+
// silently stripped auth and produced spurious upstream 401s.
|
|
180
|
+
fetch: (request, init) => fetch(request, { ...init, signal: AbortSignal.timeout(8_000) }),
|
|
123
181
|
});
|
|
124
|
-
const
|
|
125
|
-
|
|
182
|
+
const location = { directory };
|
|
183
|
+
const [providersResult, modelsResult, defaultResult] = await Promise.all([
|
|
184
|
+
client.provider.list({ location }),
|
|
185
|
+
client.model.list({ location }),
|
|
186
|
+
client.model.default({ location }),
|
|
187
|
+
]);
|
|
188
|
+
const source = composeV2ProviderCatalogSource(
|
|
189
|
+
providersResult?.data,
|
|
190
|
+
modelsResult?.data,
|
|
191
|
+
defaultResult?.data,
|
|
192
|
+
);
|
|
193
|
+
if (!source) {
|
|
126
194
|
console.error('Provider catalog upstream response failed');
|
|
127
195
|
return res.status(502).json({ error: 'Provider catalog is unavailable' });
|
|
128
196
|
}
|
|
129
|
-
const catalog = projectProviderCatalog(
|
|
197
|
+
const catalog = projectProviderCatalog(source);
|
|
130
198
|
if (!catalog.ok) {
|
|
131
199
|
console.error('Provider catalog upstream response is malformed');
|
|
132
200
|
return res.status(502).json({ error: 'Provider catalog is unavailable' });
|
|
133
201
|
}
|
|
134
202
|
return res.json(catalog.value);
|
|
135
|
-
} catch {
|
|
136
|
-
console.error('Provider catalog request failed');
|
|
203
|
+
} catch (catalogError) {
|
|
204
|
+
console.error('Provider catalog request failed', catalogError?.reason ? `(${catalogError.reason})` : catalogError?.message ?? catalogError);
|
|
137
205
|
return res.status(502).json({ error: 'Provider catalog is unavailable' });
|
|
138
206
|
}
|
|
139
207
|
});
|
|
@@ -500,14 +568,17 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
|
500
568
|
if (!getOpenCodePort()) {
|
|
501
569
|
return res.json({ commands: [] });
|
|
502
570
|
}
|
|
503
|
-
const client =
|
|
571
|
+
const client = createOpenCodeClient({
|
|
504
572
|
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
505
|
-
directory,
|
|
506
573
|
headers: getOpenCodeAuthHeaders(),
|
|
507
574
|
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
|
|
508
575
|
});
|
|
509
|
-
const response = await client.command.list({ directory });
|
|
510
|
-
|
|
576
|
+
const response = await client.command.list({ location: { directory } });
|
|
577
|
+
if (!Array.isArray(response?.data)) {
|
|
578
|
+
console.error('Command catalog upstream response failed');
|
|
579
|
+
return res.status(502).json({ error: 'Command catalog is unavailable' });
|
|
580
|
+
}
|
|
581
|
+
const commands = response.data;
|
|
511
582
|
return res.json({
|
|
512
583
|
commands: commands
|
|
513
584
|
.filter((command) => command?.source !== 'skill')
|
|
@@ -5,10 +5,11 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
vi.mock('@opencode
|
|
8
|
+
vi.mock('@opencode/client', () => ({ OpenCode: { make: vi.fn() } }));
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const { OpenCode } = await import('@opencode/client');
|
|
11
11
|
const { registerConfigEntityRoutes } = await import('./config-entity-routes.js');
|
|
12
|
+
const createOpencodeClient = OpenCode.make;
|
|
12
13
|
|
|
13
14
|
beforeEach(() => {
|
|
14
15
|
createOpencodeClient.mockReset();
|
|
@@ -100,9 +101,9 @@ describe('config entity command metadata route', () => {
|
|
|
100
101
|
|
|
101
102
|
expect(createOpencodeClient).toHaveBeenCalledWith(expect.objectContaining({
|
|
102
103
|
baseUrl: 'http://opencode-upstream:4096',
|
|
103
|
-
directory: '/repo',
|
|
104
104
|
headers: { Authorization: 'Basic example' },
|
|
105
105
|
}));
|
|
106
|
+
expect(createOpencodeClient.mock.calls[0][0].directory).toBeUndefined();
|
|
106
107
|
expect(response.body.commands).toEqual([
|
|
107
108
|
expect.objectContaining({ name: 'markdown', reference: markdownPath, description: `${'😀'.repeat(160)}…`, scope: 'project', isBuiltIn: false }),
|
|
108
109
|
expect.objectContaining({ name: 'json', reference: 'json', description: 'JSON command', scope: 'project', isBuiltIn: false }),
|
|
@@ -149,14 +150,35 @@ describe('config entity command metadata route', () => {
|
|
|
149
150
|
{ name: 'long-path', reference: 'long-path' },
|
|
150
151
|
]);
|
|
151
152
|
});
|
|
153
|
+
|
|
154
|
+
it('returns 502 when the command catalog is not an array instead of an empty success', async () => {
|
|
155
|
+
const app = express();
|
|
156
|
+
app.use(express.json());
|
|
157
|
+
createOpencodeClient.mockReturnValue({
|
|
158
|
+
command: { list: vi.fn(async () => ({ data: { commands: [] } })) },
|
|
159
|
+
});
|
|
160
|
+
registerConfigEntityRoutes(app, createDependencies(vi.fn()));
|
|
161
|
+
|
|
162
|
+
const response = await request(app)
|
|
163
|
+
.post('/api/config/commands/metadata?directory=%2Frepo')
|
|
164
|
+
.send({ catalog: true })
|
|
165
|
+
.expect(502);
|
|
166
|
+
expect(response.body).toEqual({ error: 'Command catalog is unavailable' });
|
|
167
|
+
});
|
|
152
168
|
});
|
|
153
169
|
|
|
154
170
|
describe('provider catalog route', () => {
|
|
155
171
|
it('uses the resolved directory, OpenCode auth, SDK providers call, and explicit catalog route', async () => {
|
|
156
172
|
const app = express();
|
|
157
173
|
const resolveProjectDirectory = vi.fn(async () => ({ directory: '/project/from-header' }));
|
|
158
|
-
const
|
|
159
|
-
|
|
174
|
+
const location = { directory: '/project/from-header' };
|
|
175
|
+
const providers = vi.fn(async () => ({ data: [] }));
|
|
176
|
+
const models = vi.fn(async () => ({ data: [] }));
|
|
177
|
+
const modelDefault = vi.fn(async () => ({ data: null }));
|
|
178
|
+
createOpencodeClient.mockReturnValue({
|
|
179
|
+
provider: { list: providers },
|
|
180
|
+
model: { list: models, default: modelDefault },
|
|
181
|
+
});
|
|
160
182
|
registerConfigEntityRoutes(app, {
|
|
161
183
|
...createDependencies(vi.fn()),
|
|
162
184
|
resolveProjectDirectory,
|
|
@@ -171,22 +193,30 @@ describe('provider catalog route', () => {
|
|
|
171
193
|
expect(resolveProjectDirectory).toHaveBeenCalledWith(expect.objectContaining({ query: { directory: '/repo' } }));
|
|
172
194
|
expect(createOpencodeClient).toHaveBeenCalledWith(expect.objectContaining({
|
|
173
195
|
baseUrl: 'http://opencode-upstream:4096',
|
|
174
|
-
directory: '/project/from-header',
|
|
175
196
|
headers: { Authorization: 'Basic example' },
|
|
176
197
|
fetch: expect.any(Function),
|
|
177
198
|
}));
|
|
178
|
-
expect(
|
|
199
|
+
expect(createOpencodeClient.mock.calls[0][0].directory).toBeUndefined();
|
|
200
|
+
expect(providers).toHaveBeenCalledWith({ location });
|
|
201
|
+
expect(models).toHaveBeenCalledWith({ location });
|
|
202
|
+
expect(modelDefault).toHaveBeenCalledWith({ location });
|
|
179
203
|
expect(response.body).toEqual({ schemaVersion: 1, providers: [], default: {}, partial: false });
|
|
180
204
|
});
|
|
181
205
|
|
|
182
206
|
it('returns 502 for SDK failure and malformed catalog responses', async () => {
|
|
183
207
|
const app = express();
|
|
184
|
-
createOpencodeClient.mockReturnValue({
|
|
208
|
+
createOpencodeClient.mockReturnValue({
|
|
209
|
+
provider: { list: vi.fn(async () => ({ data: {} })) },
|
|
210
|
+
model: { list: vi.fn(async () => ({ data: [] })), default: vi.fn(async () => ({ data: null })) },
|
|
211
|
+
});
|
|
185
212
|
registerConfigEntityRoutes(app, createDependencies(vi.fn()));
|
|
186
213
|
await request(app).get('/api/config/catalog/providers').expect(502);
|
|
187
214
|
|
|
188
215
|
const failingApp = express();
|
|
189
|
-
createOpencodeClient.mockReturnValue({
|
|
216
|
+
createOpencodeClient.mockReturnValue({
|
|
217
|
+
provider: { list: vi.fn(async () => { throw new Error('upstream unavailable'); }) },
|
|
218
|
+
model: { list: vi.fn(async () => ({ data: [] })), default: vi.fn(async () => ({ data: null })) },
|
|
219
|
+
});
|
|
190
220
|
registerConfigEntityRoutes(failingApp, createDependencies(vi.fn()));
|
|
191
221
|
await request(failingApp).get('/api/config/catalog/providers').expect(502);
|
|
192
222
|
});
|
|
@@ -194,7 +224,8 @@ describe('provider catalog route', () => {
|
|
|
194
224
|
it('returns 502 for SDK error envelopes that include data', async () => {
|
|
195
225
|
const app = express();
|
|
196
226
|
createOpencodeClient.mockReturnValue({
|
|
197
|
-
|
|
227
|
+
provider: { list: vi.fn(async () => { throw new Error('upstream sentinel'); }) },
|
|
228
|
+
model: { list: vi.fn(async () => ({ data: [] })), default: vi.fn(async () => ({ data: null })) },
|
|
198
229
|
});
|
|
199
230
|
registerConfigEntityRoutes(app, createDependencies(vi.fn()));
|
|
200
231
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
PINNED_OPENCODE2_VERSION,
|
|
7
|
+
isAcceptableOpenCode2HealthVersion,
|
|
8
|
+
isOpenCode2VersionAtLeast,
|
|
9
|
+
npmPackageForOpenCode2,
|
|
10
|
+
openCodeBinaryName,
|
|
11
|
+
parseOpenCode2VersionOutput,
|
|
12
|
+
} from './opencode2-pin.js';
|
|
13
|
+
|
|
14
|
+
const INSTALL_FETCH_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export function isOpenCode2AutoInstallEnabled(env = process.env) {
|
|
17
|
+
const value = typeof env.OPENCHAMBER_OPENCODE2_AUTO_INSTALL === 'string'
|
|
18
|
+
? env.OPENCHAMBER_OPENCODE2_AUTO_INSTALL.trim().toLowerCase()
|
|
19
|
+
: '';
|
|
20
|
+
return value !== '0' && value !== 'false' && value !== 'no';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveOpenChamberDataDir(env = process.env, homedir = os.homedir) {
|
|
24
|
+
if (typeof env.OPENCHAMBER_DATA_DIR === 'string' && env.OPENCHAMBER_DATA_DIR.trim()) {
|
|
25
|
+
return path.resolve(env.OPENCHAMBER_DATA_DIR.trim());
|
|
26
|
+
}
|
|
27
|
+
const home = typeof homedir === 'function' ? homedir() : homedir;
|
|
28
|
+
return path.join(home, '.config', 'openchamber');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function installedOpenCode2BinaryPath(version = PINNED_OPENCODE2_VERSION, options = {}) {
|
|
32
|
+
const dataDir = options.dataDir || resolveOpenChamberDataDir(options.env, options.homedir);
|
|
33
|
+
return path.join(dataDir, 'opencode-cli', version, openCodeBinaryName(options.platform));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readOpenCode2BinaryVersion(binaryPath, options = {}) {
|
|
37
|
+
if (typeof binaryPath !== 'string' || !binaryPath.trim()) return '';
|
|
38
|
+
const run = typeof options.spawnSync === 'function' ? options.spawnSync : spawnSync;
|
|
39
|
+
try {
|
|
40
|
+
const result = run(binaryPath, ['--version'], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
timeout: 8_000,
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
});
|
|
46
|
+
if (result.status !== 0) return '';
|
|
47
|
+
return parseOpenCode2VersionOutput(`${result.stdout || ''}\n${result.stderr || ''}`);
|
|
48
|
+
} catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolveNpmRegistry = (options = {}) => {
|
|
54
|
+
const env = options.env || process.env;
|
|
55
|
+
const electronRoot = options.electronRoot;
|
|
56
|
+
const candidates = [
|
|
57
|
+
env.NPM_CONFIG_REGISTRY,
|
|
58
|
+
options.cwd ? path.join(options.cwd, '.npmrc') : null,
|
|
59
|
+
electronRoot ? path.join(electronRoot, '.npmrc') : null,
|
|
60
|
+
path.join(os.homedir(), '.npmrc'),
|
|
61
|
+
].filter(Boolean);
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
try {
|
|
64
|
+
if (!String(candidate).endsWith('.npmrc')) {
|
|
65
|
+
if (/^https?:\/\//.test(candidate)) return String(candidate).replace(/\/+$/, '');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!fs.existsSync(candidate)) continue;
|
|
69
|
+
const match = fs.readFileSync(candidate, 'utf8').match(/^\s*registry\s*=\s*(\S+)\s*$/m);
|
|
70
|
+
if (match) return match[1].replace(/\/+$/, '');
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore unreadable npmrc candidates and fall back to the default registry.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return 'https://registry.npmjs.org';
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const downloadBuffer = async (url, fetchImpl, timeoutMs) => {
|
|
79
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.from(await response.arrayBuffer());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const extractArchive = (archivePath, destination, run) => {
|
|
87
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
88
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
89
|
+
if (archivePath.endsWith('.zip')) {
|
|
90
|
+
if (process.platform === 'win32') {
|
|
91
|
+
run('powershell.exe', [
|
|
92
|
+
'-NoLogo',
|
|
93
|
+
'-NoProfile',
|
|
94
|
+
'-NonInteractive',
|
|
95
|
+
'-ExecutionPolicy',
|
|
96
|
+
'Bypass',
|
|
97
|
+
'-Command',
|
|
98
|
+
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`,
|
|
99
|
+
]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
run('unzip', ['-q', archivePath, '-d', destination]);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) {
|
|
106
|
+
run('tar', ['-xzf', archivePath, '-C', destination]);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Unsupported opencode2 CLI archive: ${archivePath}`);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const findBinary = (root, binaryName) => {
|
|
113
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const fullPath = path.join(root, entry.name);
|
|
116
|
+
if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) {
|
|
117
|
+
return fullPath;
|
|
118
|
+
}
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
const found = findBinary(fullPath, binaryName);
|
|
121
|
+
if (found) return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export async function installPinnedOpenCode2Cli(options = {}) {
|
|
128
|
+
const version = options.version || PINNED_OPENCODE2_VERSION;
|
|
129
|
+
const platform = options.platform || process.platform;
|
|
130
|
+
const arch = options.arch || process.arch;
|
|
131
|
+
const dataDir = options.dataDir || resolveOpenChamberDataDir(options.env, options.homedir);
|
|
132
|
+
const binaryName = openCodeBinaryName(platform);
|
|
133
|
+
const outputBinary = installedOpenCode2BinaryPath(version, { dataDir, platform });
|
|
134
|
+
const run = typeof options.spawnSync === 'function' ? options.spawnSync : spawnSync;
|
|
135
|
+
const fetchImpl = typeof options.fetch === 'function' ? options.fetch : fetch;
|
|
136
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : INSTALL_FETCH_TIMEOUT_MS;
|
|
137
|
+
|
|
138
|
+
const cacheDir = path.join(dataDir, 'opencode-cli', '.cache', version, `${platform}-${arch}`);
|
|
139
|
+
const archivePath = path.join(cacheDir, 'opencode2-cli.tgz');
|
|
140
|
+
const packageName = npmPackageForOpenCode2(platform, arch);
|
|
141
|
+
|
|
142
|
+
if (!fs.existsSync(archivePath)) {
|
|
143
|
+
const registry = resolveNpmRegistry(options);
|
|
144
|
+
const metadataResponse = await fetchImpl(`${registry}/${encodeURIComponent(packageName)}/${version}`, {
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
if (!metadataResponse.ok) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Failed to resolve ${packageName}@${version} on ${registry}: ${metadataResponse.status} ${metadataResponse.statusText}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const metadata = await metadataResponse.json();
|
|
153
|
+
const tarball = metadata?.dist?.tarball;
|
|
154
|
+
if (typeof tarball !== 'string' || !/^https?:\/\//.test(tarball)) {
|
|
155
|
+
throw new Error(`No tarball found for ${packageName}@${version} on ${registry}`);
|
|
156
|
+
}
|
|
157
|
+
const bytes = await downloadBuffer(tarball, fetchImpl, timeoutMs);
|
|
158
|
+
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
|
159
|
+
const temp = `${archivePath}.tmp`;
|
|
160
|
+
fs.writeFileSync(temp, bytes);
|
|
161
|
+
fs.renameSync(temp, archivePath);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const extractDir = options.extractDir || path.join(cacheDir, 'extract');
|
|
165
|
+
if (typeof options.extract === 'function') {
|
|
166
|
+
await options.extract(archivePath, extractDir);
|
|
167
|
+
} else {
|
|
168
|
+
extractArchive(archivePath, extractDir, (command, args) => {
|
|
169
|
+
const result = run(command, args, {
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
stdio: 'pipe',
|
|
172
|
+
windowsHide: true,
|
|
173
|
+
});
|
|
174
|
+
if (result.status !== 0) {
|
|
175
|
+
const stderr = result.stderr ? `\n${result.stderr.trim()}` : '';
|
|
176
|
+
const stdout = result.stdout ? `\n${result.stdout.trim()}` : '';
|
|
177
|
+
throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`);
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const extractedBinary = findBinary(extractDir, binaryName)
|
|
184
|
+
|| findBinary(extractDir, platform === 'win32' ? 'opencode2.exe' : 'opencode2');
|
|
185
|
+
if (!extractedBinary) {
|
|
186
|
+
throw new Error(`Archive ${archivePath} did not contain ${binaryName} or opencode`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fs.mkdirSync(path.dirname(outputBinary), { recursive: true });
|
|
190
|
+
fs.copyFileSync(extractedBinary, outputBinary);
|
|
191
|
+
if (platform !== 'win32') {
|
|
192
|
+
fs.chmodSync(outputBinary, 0o755);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const preparedVersion = readOpenCode2BinaryVersion(outputBinary, options);
|
|
196
|
+
if (preparedVersion !== version) {
|
|
197
|
+
throw new Error(`Installed opencode2 version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`);
|
|
198
|
+
}
|
|
199
|
+
return outputBinary;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Reuse any discovered acceptable 2.x CLI. Install the pin only when nothing
|
|
204
|
+
* usable is already installed (global PATH, explicit override, or cache).
|
|
205
|
+
*/
|
|
206
|
+
export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
207
|
+
const pin = input.pin || PINNED_OPENCODE2_VERSION;
|
|
208
|
+
const discoveredPath = typeof input.discoveredPath === 'string' ? input.discoveredPath.trim() : '';
|
|
209
|
+
const autoInstall = input.autoInstall ?? isOpenCode2AutoInstallEnabled(input.env);
|
|
210
|
+
const readVersion = typeof input.readVersion === 'function'
|
|
211
|
+
? input.readVersion
|
|
212
|
+
: (binaryPath) => readOpenCode2BinaryVersion(binaryPath, input);
|
|
213
|
+
const install = typeof input.install === 'function' ? input.install : installPinnedOpenCode2Cli;
|
|
214
|
+
|
|
215
|
+
if (discoveredPath) {
|
|
216
|
+
const version = readVersion(discoveredPath);
|
|
217
|
+
if (isAcceptableOpenCode2HealthVersion(version)) {
|
|
218
|
+
return { path: discoveredPath, version, source: 'discovered', installed: false };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const cached = installedOpenCode2BinaryPath(pin, input);
|
|
223
|
+
const cachedVersion = readVersion(cached);
|
|
224
|
+
if (cachedVersion === pin || isOpenCode2VersionAtLeast(cachedVersion, pin)) {
|
|
225
|
+
return { path: cached, version: cachedVersion, source: 'installed', installed: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!autoInstall) {
|
|
229
|
+
const error = new Error(
|
|
230
|
+
`Unable to locate opencode ${pin}. Set OPENCODE_BINARY or install @opencode/cli@${pin}.`,
|
|
231
|
+
);
|
|
232
|
+
error.code = 'OPENCODE_CLI_MISSING';
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const installedPath = await install({ ...input, version: pin });
|
|
237
|
+
const version = readVersion(installedPath) || pin;
|
|
238
|
+
return { path: installedPath, version, source: 'installed', installed: true };
|
|
239
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
|
|
6
|
+
import {
|
|
7
|
+
ensurePinnedOpenCode2Cli,
|
|
8
|
+
installedOpenCode2BinaryPath,
|
|
9
|
+
isOpenCode2AutoInstallEnabled,
|
|
10
|
+
resolveOpenChamberDataDir,
|
|
11
|
+
} from './ensure-cli.js';
|
|
12
|
+
|
|
13
|
+
const tempDirs = [];
|
|
14
|
+
|
|
15
|
+
const createTempDir = (prefix) => {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
17
|
+
tempDirs.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tempDirs.splice(0)) {
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('ensurePinnedOpenCode2Cli', () => {
|
|
28
|
+
it('keeps a discovered binary that already meets the pin', async () => {
|
|
29
|
+
const result = await ensurePinnedOpenCode2Cli({
|
|
30
|
+
discoveredPath: '/opt/opencode2',
|
|
31
|
+
pin: '2.0.12',
|
|
32
|
+
readVersion: () => '2.0.12',
|
|
33
|
+
install: async () => {
|
|
34
|
+
throw new Error('should not install');
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
path: '/opt/opencode2',
|
|
39
|
+
version: '2.0.12',
|
|
40
|
+
source: 'discovered',
|
|
41
|
+
installed: false,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reuses an older global 2.x binary instead of reinstalling the pin', async () => {
|
|
46
|
+
const result = await ensurePinnedOpenCode2Cli({
|
|
47
|
+
discoveredPath: '/opt/old-opencode',
|
|
48
|
+
pin: '2.0.12',
|
|
49
|
+
readVersion: () => '2.0.5',
|
|
50
|
+
install: async () => {
|
|
51
|
+
throw new Error('should not install');
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(result.source).toBe('discovered');
|
|
55
|
+
expect(result.path).toBe('/opt/old-opencode');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('installs the pin only when no usable 2.x binary is present', async () => {
|
|
59
|
+
const dataDir = createTempDir('openchamber-ensure-cli-');
|
|
60
|
+
const installed = path.join(dataDir, 'opencode-cli', PINNED_OPENCODE2_VERSION, 'opencode');
|
|
61
|
+
let installedOnce = false;
|
|
62
|
+
const result = await ensurePinnedOpenCode2Cli({
|
|
63
|
+
discoveredPath: '',
|
|
64
|
+
pin: PINNED_OPENCODE2_VERSION,
|
|
65
|
+
dataDir,
|
|
66
|
+
platform: 'linux',
|
|
67
|
+
readVersion: (binaryPath) => {
|
|
68
|
+
if (binaryPath === installed && installedOnce) return PINNED_OPENCODE2_VERSION;
|
|
69
|
+
return '';
|
|
70
|
+
},
|
|
71
|
+
install: async () => {
|
|
72
|
+
installedOnce = true;
|
|
73
|
+
return installed;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
path: installed,
|
|
78
|
+
version: PINNED_OPENCODE2_VERSION,
|
|
79
|
+
source: 'installed',
|
|
80
|
+
installed: true,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reuses a cached pin without downloading', async () => {
|
|
85
|
+
const dataDir = createTempDir('openchamber-ensure-cached-');
|
|
86
|
+
const cached = installedOpenCode2BinaryPath(PINNED_OPENCODE2_VERSION, { dataDir, platform: 'linux' });
|
|
87
|
+
const result = await ensurePinnedOpenCode2Cli({
|
|
88
|
+
pin: PINNED_OPENCODE2_VERSION,
|
|
89
|
+
dataDir,
|
|
90
|
+
platform: 'linux',
|
|
91
|
+
readVersion: (binaryPath) => (binaryPath === cached ? PINNED_OPENCODE2_VERSION : ''),
|
|
92
|
+
install: async () => {
|
|
93
|
+
throw new Error('should not install');
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(result.source).toBe('installed');
|
|
97
|
+
expect(result.installed).toBe(false);
|
|
98
|
+
expect(result.path).toBe(cached);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('fails closed when auto-install is disabled and nothing usable is present', async () => {
|
|
102
|
+
await expect(ensurePinnedOpenCode2Cli({
|
|
103
|
+
pin: PINNED_OPENCODE2_VERSION,
|
|
104
|
+
autoInstall: false,
|
|
105
|
+
dataDir: createTempDir('openchamber-ensure-disabled-'),
|
|
106
|
+
readVersion: () => '',
|
|
107
|
+
})).rejects.toMatchObject({ code: 'OPENCODE_CLI_MISSING' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats OPENCHAMBER_OPENCODE2_AUTO_INSTALL=0 as disabled', () => {
|
|
111
|
+
expect(isOpenCode2AutoInstallEnabled({ OPENCHAMBER_OPENCODE2_AUTO_INSTALL: '0' })).toBe(false);
|
|
112
|
+
expect(isOpenCode2AutoInstallEnabled({})).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('resolves the OpenChamber data dir from OPENCHAMBER_DATA_DIR', () => {
|
|
116
|
+
const dataDir = createTempDir('openchamber-data-dir-');
|
|
117
|
+
expect(resolveOpenChamberDataDir({ OPENCHAMBER_DATA_DIR: dataDir })).toBe(path.resolve(dataDir));
|
|
118
|
+
});
|
|
119
|
+
});
|