@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.
Files changed (195) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/multirun-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/src-BzX5UI-c.css +2 -0
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/textarea-3XR8sUfy.js +1 -0
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. 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 { createOpencodeClient } from '@opencode-ai/sdk/v2';
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
- const client = createOpencodeClient({
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
- fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
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 response = await client.config.providers({ directory });
125
- if (response?.error || response?.data === undefined) {
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(response?.data);
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 = createOpencodeClient({
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
- const commands = Array.isArray(response?.data) ? response.data : [];
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-ai/sdk/v2', () => ({ createOpencodeClient: vi.fn() }));
8
+ vi.mock('@opencode/client', () => ({ OpenCode: { make: vi.fn() } }));
9
9
 
10
- const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
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 providers = vi.fn(async () => ({ data: { providers: [], default: {} } }));
159
- createOpencodeClient.mockReturnValue({ config: { providers } });
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(providers).toHaveBeenCalledWith({ directory: '/project/from-header' });
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({ config: { providers: vi.fn(async () => ({ data: { providers: {} } })) } });
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({ config: { providers: vi.fn(async () => { throw new Error('upstream unavailable'); }) } });
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
- config: { providers: vi.fn(async () => ({ error: { message: 'upstream sentinel' }, data: { providers: [], default: {} } })) },
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
+ });