@openchambery/web 2.0.0-beta.9 → 2.0.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 (238) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-DGyF86jy.js} +1 -1
  3. package/dist/assets/AssistantView-ByGdaGiF.js +3 -0
  4. package/dist/assets/{DiagramView-oeXj4YHn.js → DiagramView-PB0pBILb.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-B_a083XJ.js} +1 -1
  6. package/dist/assets/Icon-BE2hkaYc.js +1 -0
  7. package/dist/assets/MarkdownRenderer-H1MIHXzt.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-CahUctxC.js → MarkdownRendererImpl-CZ8GeckU.js} +3 -3
  9. package/dist/assets/{MarkstreamRendererImpl-5JUaTSdl.js → MarkstreamRendererImpl-BKE6YZ2N.js} +1 -1
  10. package/dist/assets/MobileDetailNavigation-D9KkIrWy.js +1 -0
  11. package/dist/assets/{MobileShareBridge-yCN95dYg.js → MobileShareBridge-1yCjq-60.js} +3 -3
  12. package/dist/assets/MobileSurface-Dqn3OPVX.js +2 -0
  13. package/dist/assets/{MultiRunWindow-C0HqMFpp.js → MultiRunWindow-YCFM6or1.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-CTmsNR65.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-CRfGtcmG.js → OverlayScrollbar-CVgW_Z9B.js} +1 -1
  16. package/dist/assets/SettingsView-CbiuXtYG.js +25 -0
  17. package/dist/assets/{SettingsWindow-B_d5Aqq3.js → SettingsWindow-jOZ7L6K8.js} +1 -1
  18. package/dist/assets/{TerminalView-DdNQQb8W.js → TerminalView-CI-KsDdH.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-4DC5OE1J.js → ToolOutputDialog-izZjgkkg.js} +1 -1
  20. package/dist/assets/appThemeRegistry-MiWlVk4e.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DIIrsMjW.js → button-CcOoDLE1.js} +1 -1
  23. package/dist/assets/{collapsible-CnvUeYst.js → collapsible-CSa0Ndgm.js} +1 -1
  24. package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-DoTwmMb5.js} +1 -1
  25. package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-BsbquPqS.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-yyvjztgj.js +1 -0
  27. package/dist/assets/{diagram-rvn7_qBM.js → diagram-CZKDkADZ.js} +1 -1
  28. package/dist/assets/dialog-Csvrld51.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
  31. package/dist/assets/fileMentionTouchSelection-Bn0f83D3.js +2 -0
  32. package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-B0hhXD86.js} +1 -1
  33. package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
  34. package/dist/assets/{gitApi-da8etTTe.js → gitApi-DpwH7xTg.js} +4 -4
  35. package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-BVu-iykl.js → input-C6Y7mytw.js} +1 -1
  37. package/dist/assets/insertionBoundaries-Cst5Z6Cd.js +1 -0
  38. package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
  39. package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
  40. package/dist/assets/{main-KwlAomA8.js → main-CEs8ipuu.js} +2 -2
  41. package/dist/assets/main-Dkzuy7EG.js +169 -0
  42. package/dist/assets/main-qSeeJRW1.css +1 -0
  43. package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-DhLhFBWX.js} +1 -1
  44. package/dist/assets/{mcpQueries-3GTWeMU-.js → mcpQueries-BBpCJgvK.js} +1 -1
  45. package/dist/assets/miniChat-CR7k42bZ.js +2 -0
  46. package/dist/assets/mobile-rvypjUpd.js +2 -0
  47. package/dist/assets/multirun-DqmJk0mh.js +3 -0
  48. package/dist/assets/persistence-CwHY3fDg.js +3 -0
  49. package/dist/assets/pl-B714sdql.js +15 -0
  50. package/dist/assets/platform-Bxaalnc8.js +6 -0
  51. package/dist/assets/projectMeta-BhKfv5cj.js +1 -0
  52. package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
  53. package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BLay6f9j.js +2 -0
  55. package/dist/assets/renderMobileApp-BrikirqO.js +28 -0
  56. package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-DjKsteH0.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
  60. package/dist/assets/runtimeEndpointReset-DbfaE3dL.js +148 -0
  61. package/dist/assets/sessionChangeSummary-Cgq9RPUq.js +2 -0
  62. package/dist/assets/{sessionLookup-0Z4e-J1g.js → sessionLookup-BiE36BTy.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-CE7W7lJ9.js → sortable-tabs-strip-CKKp4p3Y.js} +1 -1
  64. package/dist/assets/src-BZ0tISrf.css +2 -0
  65. package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
  66. package/dist/assets/{terminalApi-mDR2jXTo.js → terminalApi-DSJOXNRQ.js} +1 -1
  67. package/dist/assets/{textarea-XD2oX_Ee.js → textarea-DUjc3iDJ.js} +1 -1
  68. package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-CBHR50fA.js} +1 -1
  69. package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
  70. package/dist/assets/{url-CGbJbiJq.js → url-CEnO45_n.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-y_GTVXhJ.js → useAppFontEffects-CWzjhjXr.js} +55 -55
  72. package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-CXj8c_tt.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-Dtqco-Um.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-BW9jNyuK.js → useEffectiveDirectory-DNbDaaNV.js} +1 -1
  75. package/dist/assets/{useFontPreferences-Bjx8zkGM.js → useFontPreferences-DGbTdGy3.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-DYKC2swj.js +415 -0
  77. package/dist/assets/useSessionAutoCleanup-BI0xQcio.js +2 -0
  78. package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-DNkuudYO.js} +1 -1
  79. package/dist/assets/{useWorkerHighlightedLines-BUcSLKlL.js → useWorkerHighlightedLines-BoyYALWC.js} +1 -1
  80. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  81. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  82. package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
  83. package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
  84. package/dist/index.html +13 -13
  85. package/dist/mini-chat.html +13 -13
  86. package/dist/mobile.html +13 -13
  87. package/package.json +3 -3
  88. package/server/index.js +8 -20
  89. package/server/lib/client-auth/remote-clients.js +15 -0
  90. package/server/lib/client-auth/remote-clients.test.js +42 -1
  91. package/server/lib/git/DOCUMENTATION.md +1 -0
  92. package/server/lib/git/routes.js +7 -4
  93. package/server/lib/git/routes.test.js +34 -0
  94. package/server/lib/git/service.js +3 -1
  95. package/server/lib/llm/DOCUMENTATION.md +6 -4
  96. package/server/lib/llm/generate.js +8 -9
  97. package/server/lib/llm/generate.test.js +33 -6
  98. package/server/lib/message-queue/service.js +1 -1
  99. package/server/lib/opencode/DOCUMENTATION.md +33 -22
  100. package/server/lib/opencode/agents.js +13 -22
  101. package/server/lib/opencode/auth-state-runtime.js +3 -3
  102. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  103. package/server/lib/opencode/auth.d.ts +13 -0
  104. package/server/lib/opencode/auth.js +74 -64
  105. package/server/lib/opencode/auth.test.js +57 -0
  106. package/server/lib/opencode/commands.js +1 -1
  107. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  108. package/server/lib/opencode/config-entity-routes.js +10 -123
  109. package/server/lib/opencode/config-entity-routes.test.js +23 -11
  110. package/server/lib/opencode/config-files.d.ts +17 -0
  111. package/server/lib/opencode/config-files.js +187 -0
  112. package/server/lib/opencode/config-files.test.js +201 -0
  113. package/server/lib/opencode/core-routes.js +17 -1
  114. package/server/lib/opencode/core-routes.test.js +43 -15
  115. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  116. package/server/lib/opencode/credential-boundary.js +11 -0
  117. package/server/lib/opencode/ensure-cli.js +4 -1
  118. package/server/lib/opencode/env-config.js +4 -1
  119. package/server/lib/opencode/env-config.test.js +28 -0
  120. package/server/lib/opencode/env-runtime.test.js +18 -1
  121. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  122. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  123. package/server/lib/opencode/lifecycle.js +1 -49
  124. package/server/lib/opencode/lifecycle.test.js +30 -4
  125. package/server/lib/opencode/location-release.d.ts +8 -0
  126. package/server/lib/opencode/location-release.js +32 -0
  127. package/server/lib/opencode/location-release.test.js +43 -0
  128. package/server/lib/opencode/mcp.js +1 -1
  129. package/server/lib/opencode/opencode2-pin.js +2 -1
  130. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  131. package/server/lib/opencode/plugin-routes.test.js +4 -2
  132. package/server/lib/opencode/plugins.js +12 -60
  133. package/server/lib/opencode/plugins.test.js +5 -3
  134. package/server/lib/opencode/providers.js +6 -3
  135. package/server/lib/opencode/proxy.js +7 -0
  136. package/server/lib/opencode/proxy.test.js +13 -0
  137. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  138. package/server/lib/opencode/routes.js +10 -8
  139. package/server/lib/opencode/routes.test.js +51 -10
  140. package/server/lib/opencode/runtime-contract.js +3 -2
  141. package/server/lib/opencode/runtime-contract.test.js +12 -12
  142. package/server/lib/opencode/settings-normalization-runtime.js +3 -1
  143. package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
  144. package/server/lib/opencode/settings-runtime.test.js +58 -1
  145. package/server/lib/opencode/shared.js +6 -175
  146. package/server/lib/opencode/update-discovery.js +39 -0
  147. package/server/lib/opencode/update-discovery.test.js +39 -0
  148. package/server/lib/opencode/upgrade-screen.js +4 -11
  149. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  150. package/server/lib/opencode/version-alignment.test.js +40 -0
  151. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  152. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  153. package/server/lib/question-auto-delegate/core.js +21 -13
  154. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  155. package/server/lib/question-auto-delegate/form-io.js +39 -0
  156. package/server/lib/question-auto-delegate/runtime.js +4 -24
  157. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  158. package/server/lib/quota/DOCUMENTATION.md +9 -0
  159. package/server/lib/quota/provider-credentials.test.js +29 -0
  160. package/server/lib/quota/providers/claude.js +4 -4
  161. package/server/lib/quota/providers/codex.js +4 -4
  162. package/server/lib/quota/providers/codex.test.js +1 -1
  163. package/server/lib/quota/providers/copilot.js +5 -5
  164. package/server/lib/quota/providers/google/auth.js +3 -3
  165. package/server/lib/quota/providers/google/index.js +2 -2
  166. package/server/lib/quota/providers/index.js +3 -7
  167. package/server/lib/quota/providers/index.test.js +2 -2
  168. package/server/lib/quota/providers/kimi.js +4 -4
  169. package/server/lib/quota/providers/minimax-shared.js +4 -4
  170. package/server/lib/quota/providers/nanogpt.js +4 -4
  171. package/server/lib/quota/providers/openai.js +4 -4
  172. package/server/lib/quota/providers/openrouter.js +4 -4
  173. package/server/lib/quota/providers/wafer.js +4 -4
  174. package/server/lib/quota/providers/zai.js +4 -4
  175. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  176. package/server/lib/quota/routes.js +1 -1
  177. package/server/lib/relay/DOCUMENTATION.md +3 -1
  178. package/server/lib/relay/service.js +1 -0
  179. package/server/lib/relay/service.test.js +13 -0
  180. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  181. package/server/lib/scheduled-tasks/runtime.js +12 -4
  182. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  183. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  184. package/server/lib/session-goal/runtime.js +4 -4
  185. package/server/lib/session-goal/runtime.test.js +32 -3
  186. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  187. package/server/lib/session-index/event-ingest.js +13 -0
  188. package/server/lib/session-index/event-ingest.test.js +7 -0
  189. package/server/lib/session-index/service.js +45 -0
  190. package/server/lib/session-index/service.test.js +47 -0
  191. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  192. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  193. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  194. package/server/lib/session-metadata/session-projection.js +23 -0
  195. package/server/lib/session-metadata/session-projection.test.js +35 -0
  196. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  197. package/server/lib/session-title/native-runtime.test.js +59 -5
  198. package/server/lib/session-title/runtime.js +63 -17
  199. package/server/lib/session-title/session-access.js +108 -3
  200. package/server/lib/session-title/session-access.test.js +125 -7
  201. package/server/lib/tts/DOCUMENTATION.md +7 -5
  202. package/server/lib/tts/credential-source.test.js +23 -0
  203. package/server/lib/tts/routes.js +2 -2
  204. package/server/lib/tts/service.js +16 -37
  205. package/dist/assets/AssistantView-CLFPj5Ki.js +0 -3
  206. package/dist/assets/Icon-B8fSTLdP.js +0 -1
  207. package/dist/assets/MarkdownRenderer-BK69MEHQ.js +0 -2
  208. package/dist/assets/MobileDetailNavigation-agQ-Yhpd.js +0 -1
  209. package/dist/assets/MobileSurface-D0HP_lTS.js +0 -2
  210. package/dist/assets/SettingsView-xLYb22wV.js +0 -25
  211. package/dist/assets/appThemeRegistry-LkVH62uF.js +0 -1
  212. package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
  213. package/dist/assets/dialog-CQ63sc9E.js +0 -1
  214. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  215. package/dist/assets/fileMentionTouchSelection-CBzDuQxP.js +0 -3
  216. package/dist/assets/insertionBoundaries-DQrE5qG2.js +0 -1
  217. package/dist/assets/main-D2BzpnUt.css +0 -1
  218. package/dist/assets/main-DYISNvWY.js +0 -169
  219. package/dist/assets/miniChat-DYIqdWi7.js +0 -2
  220. package/dist/assets/mobile-CwiCcmWt.js +0 -2
  221. package/dist/assets/multirun-C3snjTFs.js +0 -4
  222. package/dist/assets/persistence-D0y7226-.js +0 -3
  223. package/dist/assets/pl-BCf5uLlK.js +0 -15
  224. package/dist/assets/platform-CurqO28D.js +0 -6
  225. package/dist/assets/projectMeta-BkhYFMaM.js +0 -3
  226. package/dist/assets/renderElectronMiniChatApp-7iyh_419.js +0 -2
  227. package/dist/assets/renderMobileApp-y0_NeckR.js +0 -28
  228. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  229. package/dist/assets/runtimeConfig-12C5j02f.js +0 -2
  230. package/dist/assets/runtimeEndpointReset-DTY2ZXXb.js +0 -149
  231. package/dist/assets/sessionChangeSummary-CwPBIb3Z.js +0 -2
  232. package/dist/assets/src-lS1keDVV.css +0 -2
  233. package/dist/assets/useDesktopWindowControlsLayout-CR_DcY77.js +0 -1
  234. package/dist/assets/useMobileNavigationStore-DwupvLSA.js +0 -414
  235. package/dist/assets/useSessionAutoCleanup-DkGO_usj.js +0 -2
  236. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  237. package/server/lib/opencode/snippets.js +0 -242
  238. package/server/lib/opencode/snippets.test.js +0 -97
@@ -15,8 +15,10 @@ vi.mock('./ensure-cli.js', async (importOriginal) => {
15
15
 
16
16
  import { registerOpenCodeRoutes } from './routes.js';
17
17
  import { isOpenCode1xVersion } from './opencode2-pin.js';
18
+ import { createOpenCodeUpdateDiscovery } from './update-discovery.js';
18
19
 
19
20
  const createDependencies = ({ formatSettingsResponse }) => ({
21
+ discoverOpenCodeUpdate: async () => '2.0.12',
20
22
  crypto: {},
21
23
  clientReloadDelayMs: 0,
22
24
  getOpenCodeResolutionSnapshot: vi.fn(),
@@ -231,26 +233,65 @@ describe('opencode2 upgrade pin (ticket 12)', () => {
231
233
  });
232
234
 
233
235
  it('upgrades a shared service using the owned cache and verifies the running version', async () => {
234
- const binary = '/cache/opencode-cli/2.0.14/opencode';
236
+ const binary = '/cache/opencode-cli/2.0.23/opencode';
235
237
  vi.mocked(installPinnedOpenCode2Cli).mockResolvedValueOnce(binary);
236
- vi.mocked(readOpenCode2BinaryVersion).mockReturnValueOnce('2.0.14');
237
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.14', pid: 42 }))));
238
+ vi.mocked(readOpenCode2BinaryVersion).mockReturnValueOnce('2.0.23');
239
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.23', pid: 42 }))));
238
240
  let resolvedBinary = '/usr/local/bin/opencode';
239
241
  const restartOpenCode = vi.fn(async () => {});
240
242
  const { app } = createUpgradeApp({
241
243
  getIsExternalOpenCode: () => true,
242
244
  getIsSharedOpenCodeService: () => true,
243
245
  getResolvedOpenCodeBinary: () => resolvedBinary,
244
- getOpenCodeCliVersion: () => '2.0.14',
246
+ getOpenCodeCliVersion: () => '2.0.23',
245
247
  forceResolvedOpenCodeBinary: (value) => { resolvedBinary = value; },
246
248
  restartOpenCode,
247
249
  waitForOpenCodeReady: vi.fn(),
248
250
  openchamberDataDir: '/cache',
249
251
  });
250
252
 
251
- const response = await request(app).post('/api/opencode/upgrade').send({ target: '2.0.14' }).expect(200);
253
+ const response = await request(app).post('/api/opencode/upgrade').send({ target: '2.0.23' }).expect(200);
252
254
  expect(restartOpenCode).toHaveBeenCalledWith({ binaryPath: binary });
253
- expect(response.body).toMatchObject({ success: true, version: '2.0.14', ownership: 'shared-service' });
255
+ expect(response.body).toMatchObject({ success: true, version: '2.0.23', ownership: 'shared-service' });
256
+ });
257
+
258
+ it('discovers the remote release and updates a shared 2.0.16 service to 2.0.23', async () => {
259
+ let serveVersion = '2.0.16';
260
+ let binary = '/usr/local/bin/opencode';
261
+ const installed = '/cache/opencode-cli/2.0.23/opencode';
262
+ const fetchMock = vi.fn(async (url) => new Response(JSON.stringify({
263
+ version: String(url).includes('registry.npmjs.org') ? '2.0.23' : serveVersion,
264
+ })));
265
+ vi.stubGlobal('fetch', fetchMock);
266
+ vi.mocked(readOpenCode2BinaryVersion).mockReturnValue('2.0.23');
267
+ vi.mocked(installPinnedOpenCode2Cli).mockResolvedValueOnce(installed);
268
+ const restartOpenCode = vi.fn(async () => { serveVersion = '2.0.23'; });
269
+ const { app } = createUpgradeApp({
270
+ discoverOpenCodeUpdate: createOpenCodeUpdateDiscovery(),
271
+ getIsExternalOpenCode: () => true,
272
+ getIsSharedOpenCodeService: () => true,
273
+ getResolvedOpenCodeBinary: () => binary,
274
+ forceResolvedOpenCodeBinary: (value) => { binary = value; },
275
+ restartOpenCode,
276
+ waitForOpenCodeReady: vi.fn(),
277
+ openchamberDataDir: '/cache',
278
+ });
279
+ const before = await request(app).get('/api/opencode/upgrade-status').expect(200);
280
+ expect(before.body).toMatchObject({ available: true, serveVersion: '2.0.16', targetVersion: '2.0.23', canManage: true });
281
+ const result = await request(app).post('/api/opencode/upgrade').send({}).expect(200);
282
+ expect(result.body).toMatchObject({ success: true, restarted: true, version: '2.0.23' });
283
+ expect(restartOpenCode).toHaveBeenCalledWith({ binaryPath: installed });
284
+ const after = await request(app).get('/api/opencode/upgrade-status').expect(200);
285
+ expect(after.body).toMatchObject({ available: false, serveVersion: '2.0.23' });
286
+ expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('registry.npmjs.org'))).toHaveLength(1);
287
+ vi.mocked(readOpenCode2BinaryVersion).mockReset();
288
+ });
289
+
290
+ it('reports registry failure as unknown rather than up-to-date', async () => {
291
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.16' }))));
292
+ const { app } = createUpgradeApp({ discoverOpenCodeUpdate: async () => { throw new Error('registry unavailable'); } });
293
+ const result = await request(app).get('/api/opencode/upgrade-status').expect(500);
294
+ expect(result.body).toMatchObject({ available: null, error: 'registry unavailable' });
254
295
  });
255
296
 
256
297
  it('does not report a failed shared replacement as success and restores binary selection', async () => {
@@ -344,7 +385,7 @@ describe('runtime contract routes (ticket 11)', () => {
344
385
  ok: true,
345
386
  status: 200,
346
387
  statusText: 'OK',
347
- json: async () => ({ version: '2.0.12', pid: 1 }),
388
+ json: async () => ({ version: '2.0.20', pid: 1 }),
348
389
  })));
349
390
  const { app } = createContractApp();
350
391
 
@@ -354,7 +395,7 @@ describe('runtime contract routes (ticket 11)', () => {
354
395
  expect(health.body.executionAllowed).toBe(true);
355
396
 
356
397
  const contract = await request(app).get('/api/opencode/contract').expect(200);
357
- expect(contract.body.serveVersion).toBe('2.0.12');
398
+ expect(contract.body.serveVersion).toBe('2.0.20');
358
399
  expect(contract.body.capabilities['core.protocol'].available).toBe(true);
359
400
  });
360
401
 
@@ -398,7 +439,7 @@ describe('GET /api/opencode/health', () => {
398
439
  status: 200,
399
440
  statusText: 'OK',
400
441
  json: async () => ({
401
- version: '2.0.12',
442
+ version: '2.0.20',
402
443
  pid: 4242,
403
444
  urls: { api: 'http://127.0.0.1:4096' },
404
445
  paths: { config: '/tmp/config', data: '/tmp/data' },
@@ -412,7 +453,7 @@ describe('GET /api/opencode/health', () => {
412
453
 
413
454
  expect(response.body).toMatchObject({
414
455
  healthy: true,
415
- version: '2.0.12',
456
+ version: '2.0.20',
416
457
  executionAllowed: true,
417
458
  protocolCompatible: true,
418
459
  });
@@ -7,20 +7,21 @@
7
7
  * execution semantics.
8
8
  *
9
9
  * Evidence bounds (ticket 11):
10
- * - MIN_VERIFIED: pin / installed verification (`2.0.12`)
10
+ * - MIN_VERIFIED: credential API availability (`2.0.20`)
11
11
  * Any acceptable OpenCode 2.x at or above MIN_VERIFIED may execute. Below-min
12
12
  * 2.x and 1.x are refused with upgrade guidance; there is no upper version cap.
13
13
  */
14
14
 
15
15
  import {
16
16
  PINNED_OPENCODE2_VERSION,
17
+ MINIMUM_OPENCODE2_VERSION,
17
18
  compareOpenCode2Versions,
18
19
  isAcceptableOpenCode2HealthVersion,
19
20
  isOpenCode1xVersion,
20
21
  } from './opencode2-pin.js';
21
22
 
22
23
  /** Lowest version with documented OpenChamber + OpenCode 2 contract tests. */
23
- export const RUNTIME_CONTRACT_MIN_VERIFIED = PINNED_OPENCODE2_VERSION;
24
+ export const RUNTIME_CONTRACT_MIN_VERIFIED = MINIMUM_OPENCODE2_VERSION;
24
25
 
25
26
  /**
26
27
  * Optional / core capabilities and the minimum serve version that admits them.
@@ -13,14 +13,14 @@ import {
13
13
  } from './runtime-contract.js';
14
14
 
15
15
  describe('runtime contract admission (ticket 11)', () => {
16
- it('records minimum verified bound from pin', () => {
17
- expect(RUNTIME_CONTRACT_MIN_VERIFIED).toBe('2.0.12');
16
+ it('records the credential API floor independently of the install pin', () => {
17
+ expect(RUNTIME_CONTRACT_MIN_VERIFIED).toBe('2.0.20');
18
18
  });
19
19
 
20
20
  it('classifies version bands with a 2.x floor only', () => {
21
- expect(classifyRuntimeVersionBand('2.0.12')).toBe('verified');
22
- expect(classifyRuntimeVersionBand('2.0.14')).toBe('verified');
23
- expect(classifyRuntimeVersionBand('2.0.15')).toBe('verified');
21
+ expect(classifyRuntimeVersionBand('2.0.19')).toBe('below-min');
22
+ expect(classifyRuntimeVersionBand('2.0.20')).toBe('verified');
23
+ expect(classifyRuntimeVersionBand('2.0.23')).toBe('verified');
24
24
  expect(classifyRuntimeVersionBand('2.1.0')).toBe('verified');
25
25
  expect(classifyRuntimeVersionBand('2.0.5')).toBe('below-min');
26
26
  expect(classifyRuntimeVersionBand('1.18.18')).toBe('1x');
@@ -30,8 +30,8 @@ describe('runtime contract admission (ticket 11)', () => {
30
30
 
31
31
  it('admits verified serve for core protocol and optional caps', () => {
32
32
  const result = evaluateRuntimeContract({
33
- serveVersion: '2.0.12',
34
- cliVersion: '2.0.12',
33
+ serveVersion: '2.0.20',
34
+ cliVersion: '2.0.20',
35
35
  reachable: true,
36
36
  authenticated: true,
37
37
  healthOk: true,
@@ -65,7 +65,7 @@ describe('runtime contract admission (ticket 11)', () => {
65
65
 
66
66
  it('admits newer 2.x above the documented floor for execution', () => {
67
67
  const result = evaluateRuntimeContract({
68
- serveVersion: '2.0.15',
68
+ serveVersion: '2.0.23',
69
69
  reachable: true,
70
70
  authenticated: true,
71
71
  healthOk: true,
@@ -105,12 +105,12 @@ describe('runtime contract admission (ticket 11)', () => {
105
105
  expect(evaluateRuntimeContract({
106
106
  reachable: true,
107
107
  authenticated: false,
108
- serveVersion: '2.0.12',
108
+ serveVersion: '2.0.20',
109
109
  healthOk: true,
110
110
  }).phase).toBe('unauthenticated');
111
111
 
112
112
  const migration = evaluateRuntimeContract({
113
- serveVersion: '2.0.12',
113
+ serveVersion: '2.0.20',
114
114
  reachable: true,
115
115
  authenticated: true,
116
116
  healthOk: true,
@@ -122,7 +122,7 @@ describe('runtime contract admission (ticket 11)', () => {
122
122
  expect(migration.migrationExecutable).toBe(false);
123
123
 
124
124
  const mismatch = evaluateRuntimeContract({
125
- serveVersion: '2.0.12',
125
+ serveVersion: '2.0.20',
126
126
  cliVersion: '2.0.14',
127
127
  reachable: true,
128
128
  authenticated: true,
@@ -179,7 +179,7 @@ describe('runtime contract admission (ticket 11)', () => {
179
179
  migrationAdmitTranscript: true,
180
180
  });
181
181
  const body = runtimeContractExecutionBlockedBody(contract);
182
- expect(body.error).toContain('2.0.12');
182
+ expect(body.error).toContain('2.0.20');
183
183
  expect(body.error).toMatch(/Settings → About/i);
184
184
  expect(formatRuntimeContractBlockedError(contract)).toBe(body.error);
185
185
  });
@@ -44,7 +44,9 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
44
44
  return value;
45
45
  }
46
46
  try {
47
- return realpathSync(value);
47
+ // The JS implementation preserves input casing on case-insensitive volumes.
48
+ // Native realpath supplies the filesystem spelling used by Git and OpenCode.
49
+ return (realpathSync.native ?? realpathSync)(value);
48
50
  } catch {
49
51
  return value;
50
52
  }
@@ -22,6 +22,42 @@ const createTestRuntime = (overrides = {}) => {
22
22
 
23
23
  describe('settings normalization runtime - symlink resolution', () => {
24
24
  describe('normalizePathForPersistence', () => {
25
+ it('uses native realpath to recover filesystem casing instead of preserving the input spelling', () => {
26
+ const realpathSync = Object.assign((p) => p, {
27
+ native: (p) => p.replace('/code/', '/Code/'),
28
+ });
29
+ const runtime = createTestRuntime({ realpathSync, processLike: { platform: 'darwin' } });
30
+
31
+ expect(runtime.normalizePathForPersistence('/workspace/code/project')).toBe('/workspace/Code/project');
32
+ const result = runtime.normalizeSettingsPaths({
33
+ projects: [{ id: 'project', path: '/workspace/code/project' }],
34
+ lastDirectory: '/workspace/code/project',
35
+ pinnedDirectories: ['/workspace/code/project'],
36
+ });
37
+ expect(result.changed).toBe(true);
38
+ expect(result.settings.projects[0].path).toBe('/workspace/Code/project');
39
+ expect(result.settings.lastDirectory).toBe('/workspace/Code/project');
40
+ expect(result.settings.pinnedDirectories).toEqual(['/workspace/Code/project']);
41
+ expect(runtime.normalizeSettingsPaths(result.settings).changed).toBe(false);
42
+ });
43
+
44
+ it('preserves distinct directory casing when native realpath reports distinct locations', () => {
45
+ const runtime = createTestRuntime({ realpathSync: Object.assign((p) => p, { native: (p) => p }) });
46
+ expect(runtime.sanitizeProjects([
47
+ { id: 'lower', path: '/workspace/code/project' },
48
+ { id: 'upper', path: '/workspace/Code/project' },
49
+ ])).toHaveLength(2);
50
+ });
51
+
52
+ it('keeps unavailable paths when native realpath fails', () => {
53
+ const runtime = createTestRuntime({
54
+ realpathSync: Object.assign(() => '/incorrect-fallback', {
55
+ native: () => { throw new Error('EACCES'); },
56
+ }),
57
+ });
58
+ expect(runtime.normalizePathForPersistence('/unavailable/project')).toBe('/unavailable/project');
59
+ });
60
+
25
61
  it('resolves symlinks via realpathSync', () => {
26
62
  const runtime = createTestRuntime({
27
63
  realpathSync: (p) =>
@@ -5,8 +5,9 @@ import os from 'os';
5
5
  import path from 'path';
6
6
  import { createProjectIdFromPath } from '../projects/project-id.js';
7
7
  import { createSettingsRuntime } from './settings-runtime.js';
8
+ import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
8
9
 
9
- const createRuntime = async () => {
10
+ const createRuntime = async (overrides = {}) => {
10
11
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
11
12
  const settingsFilePath = path.join(tempRoot, 'settings.json');
12
13
  const runtime = createSettingsRuntime({
@@ -21,6 +22,7 @@ const createRuntime = async () => {
21
22
  normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
22
23
  formatSettingsResponse: (settings) => settings,
23
24
  resolveDirectoryCandidate: (value) => value,
25
+ ...overrides,
24
26
  });
25
27
 
26
28
  return {
@@ -34,6 +36,61 @@ const createRuntime = async () => {
34
36
  };
35
37
 
36
38
  describe('settings runtime', () => {
39
+ it('repairs existing project casing, migrates project data, and persists new paths canonically', async () => {
40
+ const normalization = createSettingsNormalizationRuntime({
41
+ os, path, processLike: process,
42
+ realpathSync: Object.assign((value) => value, {
43
+ native: (value) => value.replace(`${path.sep}code${path.sep}`, `${path.sep}Code${path.sep}`),
44
+ }),
45
+ });
46
+ let failSettingsWrite = true;
47
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime({
48
+ ...normalization,
49
+ fsPromises: {
50
+ ...fsPromises,
51
+ rename: async (source, target) => {
52
+ if (failSettingsWrite && path.basename(target) === 'settings.json') {
53
+ throw Object.assign(new Error('Simulated settings write failure'), { code: 'EIO' });
54
+ }
55
+ return fsPromises.rename(source, target);
56
+ },
57
+ },
58
+ });
59
+ try {
60
+ const legacyPath = path.join(tempRoot, 'code', 'project');
61
+ const canonicalPath = path.join(tempRoot, 'Code', 'project');
62
+ const oldId = createProjectIdFromPath(legacyPath);
63
+ const newId = createProjectIdFromPath(canonicalPath);
64
+ const projectsRoot = path.join(tempRoot, 'projects');
65
+ await fsPromises.mkdir(canonicalPath, { recursive: true });
66
+ await fsPromises.mkdir(projectsRoot, { recursive: true });
67
+ await fsPromises.writeFile(path.join(projectsRoot, `${oldId}.json`), JSON.stringify({ projectNotes: 'Keep these notes' }));
68
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify({
69
+ projects: [{ id: oldId, path: legacyPath, label: 'My project' }],
70
+ activeProjectId: oldId,
71
+ lastDirectory: legacyPath,
72
+ }));
73
+
74
+ const original = await fsPromises.readFile(settingsFilePath, 'utf8');
75
+ await expect(runtime.readSettingsFromDiskMigrated()).rejects.toThrow('Simulated settings write failure');
76
+ expect(await fsPromises.readFile(settingsFilePath, 'utf8')).toBe(original);
77
+ failSettingsWrite = false;
78
+ const settings = await runtime.readSettingsFromDiskMigrated();
79
+ expect(settings.projects).toMatchObject([{ id: newId, path: canonicalPath, label: 'My project' }]);
80
+ expect(settings.activeProjectId).toBe(newId);
81
+ expect(settings.lastDirectory).toBe(canonicalPath);
82
+ expect(JSON.parse(await fsPromises.readFile(path.join(projectsRoot, `${newId}.json`), 'utf8')).projectNotes).toBe('Keep these notes');
83
+ expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(settings);
84
+ expect(await runtime.readSettingsFromDiskMigrated()).toEqual(settings);
85
+
86
+ const saved = await runtime.persistSettings({ ...settings, projects: [{ id: oldId, path: legacyPath }] });
87
+ expect(saved.projects).toMatchObject([{ id: newId, path: canonicalPath }]);
88
+ expect(saved.activeProjectId).toBe(newId);
89
+ } finally {
90
+ await cleanup();
91
+ }
92
+ });
93
+
37
94
  it('migrates absent notification toggles to their defaults', async () => {
38
95
  const { runtime, settingsFilePath, cleanup } = await createRuntime();
39
96
  try {
@@ -2,18 +2,18 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import yaml from 'yaml';
5
- import { parse as parseJsonc } from 'jsonc-parser';
5
+ import {
6
+ getGlobalConfigDirectory, readConfigFile, isPlainObject, readConfigLayers,
7
+ readConfig, getConfigForPath, writeConfig, getJsonEntrySource, getJsonWriteTarget,
8
+ } from './config-files.js';
6
9
 
7
10
  // ============== PATH CONSTANTS ==============
8
11
 
9
- const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
12
+ const OPENCODE_CONFIG_DIR = getGlobalConfigDirectory();
10
13
  const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
11
14
  const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
12
15
  const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
13
- const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
14
- const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
15
- ? path.resolve(process.env.OPENCODE_CONFIG)
16
- : null;
16
+ const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc');
17
17
  const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
18
18
 
19
19
  // ============== SCOPE TYPE CONSTANTS ==============
@@ -89,175 +89,6 @@ function writeMdFile(filePath, frontmatter, body) {
89
89
 
90
90
  // ============== CONFIG FILE OPERATIONS ==============
91
91
 
92
- function getProjectConfigCandidates(workingDirectory) {
93
- if (!workingDirectory) return [];
94
- return [
95
- path.join(workingDirectory, 'opencode.json'),
96
- path.join(workingDirectory, 'opencode.jsonc'),
97
- path.join(workingDirectory, '.opencode', 'opencode.json'),
98
- path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
99
- ];
100
- }
101
-
102
- function getProjectConfigPath(workingDirectory) {
103
- if (!workingDirectory) return null;
104
-
105
- const candidates = getProjectConfigCandidates(workingDirectory);
106
-
107
- for (const candidate of candidates) {
108
- if (fs.existsSync(candidate)) {
109
- return candidate;
110
- }
111
- }
112
-
113
- return candidates[0];
114
- }
115
-
116
- function getConfigPaths(workingDirectory) {
117
- return {
118
- userPaths: [
119
- path.join(OPENCODE_CONFIG_DIR, 'config.json'),
120
- path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
121
- path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
122
- ],
123
- projectPath: getProjectConfigPath(workingDirectory),
124
- customPath: CUSTOM_CONFIG_FILE
125
- };
126
- }
127
-
128
- function getPrimaryUserConfigPath(userPaths) {
129
- for (const userPath of userPaths) {
130
- if (fs.existsSync(userPath)) {
131
- return userPath;
132
- }
133
- }
134
-
135
- return CONFIG_FILE;
136
- }
137
-
138
- function readConfigFile(filePath) {
139
- if (!filePath || !fs.existsSync(filePath)) {
140
- return {};
141
- }
142
- try {
143
- const content = fs.readFileSync(filePath, 'utf8');
144
- const normalized = content.trim();
145
- if (!normalized) {
146
- return {};
147
- }
148
- return parseJsonc(normalized, [], { allowTrailingComma: true });
149
- } catch (error) {
150
- console.error(`Failed to read config file: ${filePath}`, error);
151
- throw new Error('Failed to read OpenCode configuration');
152
- }
153
- }
154
-
155
- function isPlainObject(value) {
156
- return value && typeof value === 'object' && !Array.isArray(value);
157
- }
158
-
159
- function mergeConfigs(base, override) {
160
- if (!isPlainObject(base) || !isPlainObject(override)) {
161
- return override;
162
- }
163
- const result = { ...base };
164
- for (const [key, value] of Object.entries(override)) {
165
- if (key in result) {
166
- const baseValue = result[key];
167
- if (isPlainObject(baseValue) && isPlainObject(value)) {
168
- result[key] = mergeConfigs(baseValue, value);
169
- } else {
170
- result[key] = value;
171
- }
172
- } else {
173
- result[key] = value;
174
- }
175
- }
176
- return result;
177
- }
178
-
179
- function readConfigLayers(workingDirectory) {
180
- const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
181
- const userPath = getPrimaryUserConfigPath(userPaths);
182
- const userConfig = readConfigFile(userPath);
183
- const projectConfig = readConfigFile(projectPath);
184
- const customConfig = readConfigFile(customPath);
185
- const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
186
-
187
- return {
188
- userConfig,
189
- projectConfig,
190
- customConfig,
191
- mergedConfig,
192
- paths: { userPath, projectPath, customPath }
193
- };
194
- }
195
-
196
- function readConfig(workingDirectory) {
197
- return readConfigLayers(workingDirectory).mergedConfig;
198
- }
199
-
200
- function getConfigForPath(layers, targetPath) {
201
- if (!targetPath) {
202
- return layers.userConfig;
203
- }
204
- if (layers.paths.customPath && targetPath === layers.paths.customPath) {
205
- return layers.customConfig;
206
- }
207
- if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
208
- return layers.projectConfig;
209
- }
210
- return layers.userConfig;
211
- }
212
-
213
- function writeConfig(config, filePath = CONFIG_FILE) {
214
- try {
215
- if (fs.existsSync(filePath)) {
216
- const backupFile = `${filePath}.openchamber.backup`;
217
- fs.copyFileSync(filePath, backupFile);
218
- console.log(`Created config backup: ${backupFile}`);
219
- }
220
-
221
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
222
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
223
- console.log(`Successfully wrote config file: ${filePath}`);
224
- } catch (error) {
225
- console.error(`Failed to write config file: ${filePath}`, error);
226
- throw new Error('Failed to write OpenCode configuration');
227
- }
228
- }
229
-
230
- function getJsonEntrySource(layers, sectionKey, entryName) {
231
- const { userConfig, projectConfig, customConfig, paths } = layers;
232
- const customSection = customConfig?.[sectionKey]?.[entryName];
233
- if (customSection !== undefined) {
234
- return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
235
- }
236
-
237
- const projectSection = projectConfig?.[sectionKey]?.[entryName];
238
- if (projectSection !== undefined) {
239
- return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
240
- }
241
-
242
- const userSection = userConfig?.[sectionKey]?.[entryName];
243
- if (userSection !== undefined) {
244
- return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
245
- }
246
-
247
- return { section: null, config: null, path: null, exists: false };
248
- }
249
-
250
- function getJsonWriteTarget(layers, preferredScope) {
251
- const { userConfig, projectConfig, customConfig, paths } = layers;
252
- if (paths.customPath) {
253
- return { config: customConfig, path: paths.customPath };
254
- }
255
- if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
256
- return { config: projectConfig, path: paths.projectPath };
257
- }
258
- return { config: userConfig, path: paths.userPath };
259
- }
260
-
261
92
  // ============== GIT/WORKTREE HELPERS ==============
262
93
 
263
94
  function getAncestors(startDir, stopDir) {
@@ -0,0 +1,39 @@
1
+ import { OPENCODE2_NPM_PACKAGE } from './opencode2-pin.js';
2
+
3
+ const stableVersion = (value) => typeof value === 'string' && /^2\.\d+\.\d+$/.test(value) ? value : null;
4
+
5
+ // One bounded registry snapshot per host, shared by all connected clients.
6
+ export function createOpenCodeUpdateDiscovery({ fetchImpl = (...args) => fetch(...args), now = Date.now } = {}) {
7
+ let snapshot = null;
8
+ let expiresAt = 0;
9
+ let pending = null;
10
+
11
+ const registry = () => {
12
+ if (snapshot && now() < expiresAt) return Promise.resolve(snapshot);
13
+ if (pending) return pending;
14
+ pending = (async () => {
15
+ try {
16
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(OPENCODE2_NPM_PACKAGE)}/latest`, {
17
+ headers: { Accept: 'application/json' },
18
+ signal: AbortSignal.timeout(5000),
19
+ });
20
+ if (!response.ok) throw new Error(`OpenCode registry returned HTTP ${response.status}`);
21
+ const version = stableVersion((await response.json()).version);
22
+ if (!version) throw new Error('OpenCode registry did not return a stable 2.x version');
23
+ snapshot = { version, error: null };
24
+ expiresAt = now() + 5 * 60_000;
25
+ } catch (error) {
26
+ snapshot = { version: snapshot?.version ?? null, error: error instanceof Error ? error.message : 'OpenCode registry check failed' };
27
+ expiresAt = now() + 30_000;
28
+ }
29
+ return snapshot;
30
+ })().finally(() => { pending = null; });
31
+ return pending;
32
+ };
33
+
34
+ return async () => {
35
+ const remote = await registry();
36
+ if (remote.error) throw new Error(remote.error);
37
+ return remote.version;
38
+ };
39
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createOpenCodeUpdateDiscovery } from './update-discovery.js';
3
+
4
+ describe('OpenCode remote update discovery', () => {
5
+ it('shares concurrent requests, caches for five minutes and refreshes after expiry', async () => {
6
+ let time = 0;
7
+ const fetchImpl = vi.fn(async () => Response.json({ version: '2.0.18' }));
8
+ const discover = createOpenCodeUpdateDiscovery({ fetchImpl, now: () => time });
9
+ expect(await Promise.all([discover(), discover(), discover()])).toEqual(['2.0.18', '2.0.18', '2.0.18']);
10
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
11
+ time = 299_999;
12
+ await discover();
13
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
14
+ time = 300_000;
15
+ fetchImpl.mockResolvedValueOnce(Response.json({ version: '2.0.19' }));
16
+ expect(await discover()).toBe('2.0.19');
17
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
18
+ expect(fetchImpl.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
19
+ });
20
+
21
+ it.each(['1.18.4', '3.0.0', '2.0.19-beta.1', 'latest', undefined])('rejects an invalid stable release %s', async (version) => {
22
+ const discover = createOpenCodeUpdateDiscovery({ fetchImpl: async () => Response.json({ version }) });
23
+ await expect(discover()).rejects.toThrow('stable 2.x');
24
+ });
25
+
26
+ it('backs off errors without reporting stale success, then recovers', async () => {
27
+ let time = 0;
28
+ const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ version: '2.0.18' }))
29
+ .mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(Response.json({ version: '2.0.19' }));
30
+ const discover = createOpenCodeUpdateDiscovery({ fetchImpl, now: () => time });
31
+ await discover();
32
+ time = 300_000;
33
+ await expect(discover()).rejects.toThrow('offline');
34
+ await expect(discover()).rejects.toThrow('offline');
35
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
36
+ time += 30_000;
37
+ expect(await discover()).toBe('2.0.19');
38
+ });
39
+ });