@yuriai/web 0.1.4 → 0.1.6

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 (82) hide show
  1. package/README.md +2 -0
  2. package/dist/assets/{AgentAvatar-1UO8KpCd.js → AgentAvatar-D6H7fWWf.js} +1 -1
  3. package/dist/assets/{AssistantView-D4pGrXQ2.js → AssistantView-CLk1T4Of.js} +3 -3
  4. package/dist/assets/{DiagramView-ClqTTADy.js → DiagramView-BXVAioBW.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-BsRKluhI.js → FadeInOnReveal-eVZbrwte.js} +1 -1
  6. package/dist/assets/{Icon-jsQQXouE.js → Icon-2cKLcXGe.js} +1 -1
  7. package/dist/assets/{ImageLoadingPlaceholder-CGHGcrEo.js → ImageLoadingPlaceholder-DmCEIw55.js} +1 -1
  8. package/dist/assets/{LoadingImage-BG9q4Tm_.js → LoadingImage-B-cD_lfU.js} +1 -1
  9. package/dist/assets/{MarkdownRenderer-CnHu4XPA.js → MarkdownRenderer-BY7SOGeo.js} +2 -2
  10. package/dist/assets/{MarkdownRendererImpl-BsnfVIC9.js → MarkdownRendererImpl-KSaMBtRF.js} +1 -1
  11. package/dist/assets/{MarkstreamRendererImpl-Cmzb7xFE.js → MarkstreamRendererImpl-BvkGmuQ6.js} +1 -1
  12. package/dist/assets/{MobileDetailNavigation-OWYq5ZOS.js → MobileDetailNavigation-DACAHTpS.js} +1 -1
  13. package/dist/assets/{MobileShareBridge-DukiyCpU.js → MobileShareBridge-DLPBToEM.js} +1 -1
  14. package/dist/assets/{MobileTabPageHeader-Cp_gk7cy.js → MobileTabPageHeader-CvtrPaC2.js} +1 -1
  15. package/dist/assets/{MultiRunWindow-kOpD2Th8.js → MultiRunWindow-4G9PetNl.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-DTr_WxYY.js → OnboardingScreen-CG2xI_UT.js} +1 -1
  17. package/dist/assets/{OverlayScrollbar-C4VUlRzo.js → OverlayScrollbar-piA-chKb.js} +1 -1
  18. package/dist/assets/{SettingsView-CNxbrxjd.js → SettingsView-pRXWe1jD.js} +3 -3
  19. package/dist/assets/{SettingsWindow-B_Fgbas9.js → SettingsWindow-DGK3Earv.js} +1 -1
  20. package/dist/assets/{TerminalView-Cz3XX-pa.js → TerminalView-CagyLr39.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-Ck3maV4e.js → ToolOutputDialog-DHukIjoH.js} +1 -1
  22. package/dist/assets/{appThemeRegistry-8PNdkIWO.js → appThemeRegistry-U9eZywBt.js} +1 -1
  23. package/dist/assets/{button-C3292Ee6.js → button-_ReBqWzC.js} +1 -1
  24. package/dist/assets/{capgoAdapter-CnsewE-n.js → capgoAdapter-ChF9Cg3G.js} +1 -1
  25. package/dist/assets/{collapsible-B0yvtXQR.js → collapsible-BRKOX_ML.js} +1 -1
  26. package/dist/assets/{diagram-Ddq5F6R_.js → diagram-C9F3kAJt.js} +1 -1
  27. package/dist/assets/{dialog-DrgcVFLG.js → dialog-NsOVcGdh.js} +1 -1
  28. package/dist/assets/{dropdown-menu.styles-CeLR2UTs.js → dropdown-menu.styles-CjPDePJF.js} +1 -1
  29. package/dist/assets/{es-D0ZhEIQm.js → es-Cu6zoTXv.js} +1 -1
  30. package/dist/assets/{fileMentionTouchSelection-CRG8r4mq.js → fileMentionTouchSelection-Bip-B2J9.js} +1 -1
  31. package/dist/assets/{fr-Cx3Hkjq_.js → fr-DA4Apf6C.js} +1 -1
  32. package/dist/assets/{gitApi-2OimpBnB.js → gitApi-BN0aozGg.js} +1 -1
  33. package/dist/assets/{input-DaOOTCQd.js → input-BI3Ya8O_.js} +1 -1
  34. package/dist/assets/{insertionBoundaries-DEY-uxCq.js → insertionBoundaries-gfsrexDz.js} +1 -1
  35. package/dist/assets/{ja-BRvZxcKF.js → ja-DHQdrjZd.js} +1 -1
  36. package/dist/assets/{ko-HRfDZwXS.js → ko-DbAl47mv.js} +1 -1
  37. package/dist/assets/{main-y7Aekp2v.js → main-C8xj4UeZ.js} +2 -2
  38. package/dist/assets/{main-D_ttGvxI.js → main-CjjADMt-.js} +4 -4
  39. package/dist/assets/{markdownHeightCache-BwSTMSRn.js → markdownHeightCache-CHFSvA8u.js} +1 -1
  40. package/dist/assets/{mcpQueries-Qx_eVfxx.js → mcpQueries-DJCrzyc3.js} +1 -1
  41. package/dist/assets/{miniChat-DcATvJc9.js → miniChat-D17acnB9.js} +2 -2
  42. package/dist/assets/{mobile-CfPAWSXr.js → mobile-DiL1n1m5.js} +2 -2
  43. package/dist/assets/{mobileBackNavigation-B_aT7hZA.js → mobileBackNavigation-B1NT3K0K.js} +1 -1
  44. package/dist/assets/{multirun-B2GaFlNf.js → multirun-D2KlubbP.js} +1 -1
  45. package/dist/assets/{native-config-Cu2rtzKa.js → native-config-D9DfX51I.js} +1 -1
  46. package/dist/assets/{pl-CxLc1wGT.js → pl-CogXecG6.js} +1 -1
  47. package/dist/assets/{projectMeta-CfvCStpj.js → projectMeta-B5lVMdxF.js} +1 -1
  48. package/dist/assets/{pt-BR-D4Lpwv4-.js → pt-BR-Ditt0LyE.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-D0v1rp6C.js → renderElectronMiniChatApp-DQGRyvOy.js} +2 -2
  50. package/dist/assets/{renderMobileApp-1NEaEWn6.js → renderMobileApp-BlCyG-WF.js} +4 -4
  51. package/dist/assets/{runtimeConfig-CE0t2CEh.js → runtimeConfig-MKo97i27.js} +1 -1
  52. package/dist/assets/{runtimeEndpointReset-BPBRlu2g.js → runtimeEndpointReset-DrO__UOn.js} +2 -2
  53. package/dist/assets/{scheduledTaskNavigation-C6UYwVbD.js → scheduledTaskNavigation-DUskP6qh.js} +1 -1
  54. package/dist/assets/{sessionChangeSummary-C8fjiAIl.js → sessionChangeSummary-DN7H-rqE.js} +1 -1
  55. package/dist/assets/{sessionLookup-CxeDvyRN.js → sessionLookup-DezY-ONT.js} +1 -1
  56. package/dist/assets/{sortable-tabs-strip-BMrSDJob.js → sortable-tabs-strip-Benojjgi.js} +1 -1
  57. package/dist/assets/{speechSettingsQueries-L-IonKwV.js → speechSettingsQueries-DFAUd_Aw.js} +1 -1
  58. package/dist/assets/{store-CZu30-QE.js → store-szlu5Wxv.js} +2 -2
  59. package/dist/assets/{textarea-BcA4uouw.js → textarea-C5vjnZcE.js} +1 -1
  60. package/dist/assets/{tooltip-DGUZtHif.js → tooltip-C73L9gDf.js} +1 -1
  61. package/dist/assets/{types-0GGhodUf.js → types-DZcrt4Z0.js} +1 -1
  62. package/dist/assets/{uk-BdjdFLjV.js → uk-DFoTnIxi.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-BzvaFRBa.js → useAppFontEffects-DUPuwJH5.js} +5 -5
  64. package/dist/assets/{useDesktopWindowControlsLayout-B7YLipUA.js → useDesktopWindowControlsLayout-Co_z6hbP.js} +1 -1
  65. package/dist/assets/{useEffectiveDirectory-BkXDzQaJ.js → useEffectiveDirectory-DXPmcf8E.js} +1 -1
  66. package/dist/assets/{useMobileNavigationStore-CjhF9j5V.js → useMobileNavigationStore-DcG6Ukmu.js} +6 -6
  67. package/dist/assets/{useWorkerHighlightedLines-C-BpvG5l.js → useWorkerHighlightedLines-BnmJy3ZO.js} +1 -1
  68. package/dist/assets/{zh-CN-BIC0OlRu.js → zh-CN-Cw2OYvFl.js} +1 -1
  69. package/dist/assets/{zh-TW-Bavvf14t.js → zh-TW-CPuXOw_f.js} +1 -1
  70. package/dist/index.html +5 -5
  71. package/dist/mini-chat.html +5 -5
  72. package/dist/mobile.html +5 -5
  73. package/package.json +1 -1
  74. package/server/lib/content-search/DOCUMENTATION.md +5 -1
  75. package/server/lib/opencode/DOCUMENTATION.md +8 -7
  76. package/server/lib/opencode/config-entity-routes.js +5 -1
  77. package/server/lib/opencode/config-entity-routes.test.js +23 -0
  78. package/server/lib/opencode/ensure-cli.js +48 -11
  79. package/server/lib/opencode/ensure-cli.test.js +53 -1
  80. package/server/lib/opencode/env-runtime.js +5 -5
  81. package/server/lib/opencode/terminal-path.js +67 -0
  82. package/server/lib/opencode/terminal-path.test.js +68 -0
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-CfPAWSXr.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-DiL1n1m5.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-ef9W7A43.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-CAZiOV2G.js">
@@ -86,10 +86,10 @@
86
86
  <link rel="modulepreload" crossorigin href="/assets/platform-7bX6qtBV.js">
87
87
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-BHXvha-U.js">
88
88
  <link rel="modulepreload" crossorigin href="/assets/runtime-file-download-DAOam_oV.js">
89
- <link rel="modulepreload" crossorigin href="/assets/types-0GGhodUf.js">
90
- <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-CnsewE-n.js">
91
- <link rel="modulepreload" crossorigin href="/assets/native-config-Cu2rtzKa.js">
92
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CE0t2CEh.js">
89
+ <link rel="modulepreload" crossorigin href="/assets/types-DZcrt4Z0.js">
90
+ <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-ChF9Cg3G.js">
91
+ <link rel="modulepreload" crossorigin href="/assets/native-config-D9DfX51I.js">
92
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-MKo97i27.js">
93
93
  <link rel="stylesheet" crossorigin href="/assets/src-CmermOKP.css">
94
94
  </head>
95
95
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuriai/web",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -69,7 +69,11 @@ session/message APIs supply the history directly.
69
69
 
70
70
  Web/desktop and hosted/Capacitor mobile mount the same `OnboardingScreen`
71
71
  `content-index` step after connecting. It displays thirty-day progress, preserves
72
- progress on request failure, and closes when verification succeeds. Continuing
72
+ progress on request failure, and closes when verification succeeds. The dialog
73
+ waits for a nonzero session total: pending catalog discovery and a successfully
74
+ empty fresh installation never flash onboarding. The status check continues
75
+ while the total is unknown/zero, so subsequently discovered history still opens
76
+ the step; hiding it does not certify coverage or cancel Host indexing. Continuing
73
77
  in the background dismisses only this startup/runtime's UI, never writes a
74
78
  completion marker and never cancels Host work. Polling stops after completion or
75
79
  dismissal; runtime changes recheck. Older Hosts without the new status fields
@@ -32,8 +32,8 @@ This module provides OpenCode server integration utilities for the web server ru
32
32
  - `packages/web/server/lib/opencode/v1-migration-gate.js`: pure V1→v2 migration admission gate (`GET /api/experimental/migration/v1` → whether transcript may be fetched).
33
33
  - `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
34
34
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
35
- - `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode` version and install the pinned official V2 into the Yuri data dir when missing or too old. Never uses a packaged/bundled binary.
36
- - `packages/web/server/lib/opencode/upgrade-screen.js`: startup gate for the running OpenCode below `2.0.20`. Client/schema dependencies and the default CLI install target are pinned to `2.0.23`; the compatible execution floor is independently `2.0.20` (credential API). An unreadable running version is unavailable even when the local CLI is compatible. `POST /api/opencode/install-required` upgrades the selected shared global CLI in place; other supported local installations use the owned-cache installer. Global upgrades do not persist a binary override, so subsequent launches still follow shell discovery. Success requires the running service to meet both the floor and selected target. External services are not replaced; compatible newer shared services are not automatically downgraded. Failure restores binary selection and reports `upgraded: false`, without claiming process or package-manager rollback.
35
+ - `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode` version and install the latest stable official V2 into the Yuri data dir when no usable CLI exists, falling back to the tested pin if registry lookup fails. Never uses a packaged/bundled binary.
36
+ - `packages/web/server/lib/opencode/upgrade-screen.js`: startup gate for the running OpenCode below `2.0.20`. Client/schema dependencies and the required-upgrade target are pinned to `2.0.23`; fresh automatic installations resolve latest stable 2.x. The compatible execution floor is independently `2.0.20` (credential API). An unreadable running version is unavailable even when the local CLI is compatible. `POST /api/opencode/install-required` upgrades the selected shared global CLI in place; other supported local installations use the owned-cache installer. Global upgrades do not persist a binary override, so subsequent launches still follow shell discovery. Success requires the running service to meet both the floor and selected target. External services are not replaced; compatible newer shared services are not automatically downgraded. Failure restores binary selection and reports `upgraded: false`, without claiming process or package-manager rollback.
37
37
  - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
38
38
  - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
39
39
  - `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/Yuri route wiring.
@@ -167,9 +167,10 @@ This module provides OpenCode server integration utilities for the web server ru
167
167
  - `buildUpgradeStatusSnapshot` / `evaluateOwnedUpgradeResult` / `createUpgradeOperationState` / `resolveOwnedCacheBinaryPath`.
168
168
 
169
169
  ## Public exports (ensure-cli.js)
170
- - `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`). Install the pin into `~/.config/yuri/opencode-cli/<pin>/opencode` only when nothing usable is installed. `YURI_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
170
+ - `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`), then the newest verified owned cache. Only a fresh installation queries the stable 2.x release via `update-discovery.js`; an explicit pin or failed registry lookup uses the tested pin. Downloads stay under `~/.config/yuri/opencode-cli/<version>/opencode`. `YURI_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
171
171
  - `installPinnedOpenCode2Cli(options)`: download `@opencode/cli-<os>-<arch>@pin`, extract, copy as `opencode`, chmod, verify `--version`.
172
- - `installedOpenCode2BinaryPath(version)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`.
172
+ - `installedOpenCode2BinaryPath(version)` / `installedOpenCode2BinaryCandidates(options)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`. Cache candidates are stable 2.x directories ordered by numeric version, so an offline restart discovers a newer installation rather than reverting to the packaged pin.
173
+ - `terminal-path.js` exposes owned-cache CLIs to new terminals on installation/reuse. It writes an idempotent source line to zsh `.zprofile`/`.zshrc` (including `ZDOTDIR`), bash `.bashrc` and the active login profile, POSIX `.profile`, or fish `config.fish`. The sourced file in the Yuri data directory selects the verified executable directory. Windows updates only user PATH, retaining unrelated entries, broadcasts the environment change, and records `opencode-path.windows` in the data directory. Subsequent owned-cache upgrades refresh an existing terminal integration after binary verification. Existing global installations do not trigger shell edits. PATH-setup failure warns and keeps the working Yuri runtime; a later startup retries. Existing terminal processes need a new shell, and shell-file changes already completed before a failure are retained.
173
174
  - `upgradeSelectedOpenCodeCli({ binaryPath, version, launchSpec, env })`: runs the selected CLI's official `upgrade <version>` without a shell, with a five-minute deadline and bounded captured output. Returns the original CLI entry path, preserving Homebrew symlinks. Windows launch specs unwrap native/runtime shims; unresolved batch wrappers require terminal upgrade instead of spawning console grandchildren. Child output is not returned or logged. Package-manager failures never fall back to another installation; callers verify the original entry's version before restarting. Package-manager file changes cannot be rolled back by restoring Yuri's binary selection.
174
175
 
175
176
  ## Public exports (v1-migration-gate.js)
@@ -179,7 +180,7 @@ This module provides OpenCode server integration utilities for the web server ru
179
180
  - `fetchV1MigrationGate({ url, headers, signal, fetchImpl })`: GET-only status poll used by lifecycle.
180
181
 
181
182
  ## Public exports (lifecycle.js)
182
- - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs the pinned official V2 when missing or too old (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt is one internal probe — health, then V1 migration, then contract publish — under one abort timer that covers **both** fetches so a hung migration cannot outrun the per-attempt budget. `waitForOpenCodeReady` only loops that probe until its deadline; `retryOpenCodeStartup` calls the same probe with a fresh generation, ownership, and shutdown scope. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. Startup does not prewarm persisted project directories: v2 session lists are storage-scoped and do not initialize locations, while location-scoped reads must follow actual consumer demand. External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the Yuri `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
183
+ - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs official V2 through the fresh-install policy above when no usable CLI exists (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt is one internal probe — health, then V1 migration, then contract publish — under one abort timer that covers **both** fetches so a hung migration cannot outrun the per-attempt budget. `waitForOpenCodeReady` only loops that probe until its deadline; `retryOpenCodeStartup` calls the same probe with a fresh generation, ownership, and shutdown scope. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. Startup does not prewarm persisted project directories: v2 session lists are storage-scoped and do not initialize locations, while location-scoped reads must follow actual consumer demand. External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the Yuri `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
183
184
  - Shared official service (default for Web and Electron hosts, including mobile clients connected to those hosts): when `sharedService` is injected and neither an OpenCode port nor host is configured, bootstrap runs `opencode service start` before HMR/private-process reuse or any port-4096 probe. It attaches to the registered stable-channel service, adopts its password/base URL/port, and marks it external for shutdown. Closing Yuri leaves that service running; no managed capabilities (scheduled-task plugin, bridge token, `v2-plugin-host-shim`) are injected. Attach failure clears URL/port/password, reports an unavailable integration and permits explicit retry, without silently spawning a private server. Explicit host/port and shared-service opt-out retain their existing behavior; VS Code retains its extension-owned sidecar lifecycle.
184
185
  - While attached, ordinary `restartOpenCode()` re-runs `service start` and adopts the latest registration before re-probing; a failed ensure propagates instead of probing stale credentials as success. Upgrade-only `restartOpenCode({ binaryPath })` bypasses settings rediscovery and runs the verified target's `service restart`. Configuration refresh uses `POST /api/location/reload` instead of process replacement (returns `{ reloaded: true, external: true }`). Health monitoring uses registration-PID liveness. Normal attachment/recovery does not version-pin or downgrade a healthy registered service; explicit in-app upgrade is the version-replacement operation.
185
186
  - Returned API:
@@ -212,7 +213,7 @@ This module provides OpenCode server integration utilities for the web server ru
212
213
  - `remote-upgrade.integration.test.js` is opt-in via `YURI_TEST_REMOTE_UPGRADE=1`; it downloads official binaries, starts an isolated 2.0.16 shared service, discovers the remote latest release through the status route, upgrades through the HTTP route, and verifies the running version and cleared update availability. It never restarts the user's shared service.
213
214
 
214
215
  ## Public exports (env-runtime.js)
215
- - `createOpenCodeEnvRuntime(dependencies)`: owns CLI environment and binary discovery state. Discovery order: settings/env, then interactive login-shell PATH `opencode` on macOS/Linux (host PATH if the shell snapshot is unavailable; Windows uses its hydrated host PATH), then known install locations, then a previously installed pin. Shell PATH order is authoritative for automatic discovery even if later host/runtime PATH augmentation prefers another installation. Shell aliases/functions are not executable PATH entries. Automatic discovery and bundled resources use only `opencode` (`opencode.exe` on Windows); 1.x binaries are skipped by version. Managed startup installs the pin only when no usable 2.x CLI exists; a usable bundled CLI is the last resort if installation fails. `upgradeOpenCodeCli({ binaryPath, version })` supplies the selected launch spec and shell PATH to the official CLI upgrader. Explicit settings/environment overrides retain precedence.
216
+ - `createOpenCodeEnvRuntime(dependencies)`: owns CLI environment and binary discovery state. Discovery order: settings/env, then interactive login-shell PATH `opencode` on macOS/Linux (host PATH if the shell snapshot is unavailable; Windows uses its hydrated host PATH), then known install locations, then the newest installed stable 2.x cache. Shell PATH order is authoritative for automatic discovery even if later host/runtime PATH augmentation prefers another installation. Shell aliases/functions are not executable PATH entries. Automatic discovery and bundled resources use only `opencode` (`opencode.exe` on Windows); 1.x binaries are skipped by version. Managed startup installs through the fresh-install policy above only when no usable 2.x CLI exists; a usable bundled CLI is the last resort if installation fails. `upgradeOpenCodeCli({ binaryPath, version })` supplies the selected launch spec and shell PATH to the official CLI upgrader. Explicit settings/environment overrides retain precedence.
216
217
  - VS Code keeps a copied sidecar (`packages/vscode/src/opencode-sidecar.ts`) with the same invariants: discovery order may differ (no Electron bundled fallback), but reject list, listening lines, health path order (`/api/health` then `/global/health`), `{ healthy: true }`, and Basic username `opencode` must stay aligned. Changing one copy requires changing the other.
217
218
  - Returned API:
218
219
  - `applyLoginShellEnvSnapshot()`
@@ -378,7 +379,7 @@ Unrelated upstream failures are not retried.
378
379
  - Provider catalog responses include only provider `id`, `name`, and safe models. Safe models include `id`, `name`, fixed text/audio/image/video/pdf capability modalities, bounded cost/limit fields, `release_date`, and variant names with `{}` values. v2 `ModelInfo.variants` is an array of `{ id, settings?, headers?, body? }`; projection keeps ids in array order as empty-object keys and drops request payloads. v2 `capabilities.tools` maps to `toolcall`, and `input`/`output` modality name arrays map to boolean maps. v2 `cost` tier arrays keep the untiered base price. Identifiers are bounded to 512 characters, display names to 1024, release dates to 64, numeric values to absolute 1e9, and catalog collections to 200 providers, 500 models per provider, and 100 defaults or variants. Provider credentials/configuration and unallowlisted model fields stay server-side. Malformed catalog roots and SDK error envelopes return HTTP 502. `partial: true` is structural only: provider/model/default truncation, dropped providers/models/defaults, or truncated variant dictionaries. Soft allowlist stripping of optional metadata (unknown modalities, non-boolean capability flags, out-of-range cost/limit numbers, empty/null/invalid `release_date`, invalid variant ids) keeps valid models and must not set `partial: true`, or UI refresh retains a stale complete snapshot after OpenCode provider updates. The same input shapes are accepted by the VS Code projector and `packages/ui/src/lib/configCatalogParser.ts`.
379
380
  - Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config`. A `native` create or update writes only the fields in that object into the selected user or project markdown file. `scope` chooses the file. `confirmDrop` removes fields the current format cannot represent; every other field already in the file stays unchanged.
380
381
  - Commands: batched metadata via `POST /api/config/commands/metadata`; `{ catalog: true }` returns the compact autocomplete catalog without templates, plus CRUD at `/api/config/commands/:name`
381
- - Global raw configs: `GET /api/config/global` discovers existing config targets; `GET/PUT /api/config/global/:target` reads and writes `opencode`, `oh-my-opencode-slim`, and `oh-my-openagent` JSON or JSONC files
382
+ - Global raw configs: `GET /api/config/global` always offers OpenCode and discovers existing plugin targets; `GET/PUT /api/config/global/:target` reads and writes `opencode`, `oh-my-opencode-slim`, and `oh-my-openagent` JSON or JSONC files. Missing OpenCode configuration returns an editable schema template without writing to disk; the first save creates `opencode.jsonc` (or the explicit `OPENCODE_CONFIG` path). Existing JSON/JSONC precedence and comments are preserved.
382
383
  - MCP servers: `/api/config/mcp` and `/api/config/mcp/:name`
383
384
 
384
385
  ### Catalog / Yuri-owned API change checklist
@@ -166,7 +166,7 @@ async function findGlobalConfigPath(target, configDirectory) {
166
166
  }
167
167
  }
168
168
 
169
- return null;
169
+ return target === 'opencode' ? configPath : null;
170
170
  }
171
171
 
172
172
  function validateGlobalConfigContent(content) {
@@ -282,6 +282,10 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
282
282
  return res.json({ target: req.params.target, fileName: target.fileName, content });
283
283
  } catch (error) {
284
284
  if (error?.code === 'ENOENT') {
285
+ if (req.params.target === 'opencode') {
286
+ return res.json({ target: 'opencode', fileName: target.fileName,
287
+ content: '{\n "$schema": "https://opencode.ai/config.json"\n}\n' });
288
+ }
285
289
  return res.status(404).json({ error: `${target.fileName} does not exist` });
286
290
  }
287
291
  console.error('Failed to read global configuration:', error);
@@ -495,6 +495,29 @@ describe('config entity agent metadata route', () => {
495
495
  describe('global raw configuration routes', () => {
496
496
  beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
497
497
  afterEach(() => vi.unstubAllEnvs());
498
+ it('offers an editable JSONC template on a fresh machine and creates it only on save', async () => {
499
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'yuri-global-config-'));
500
+ const configDirectory = path.join(root, 'opencode');
501
+ const app = express();
502
+ app.use(express.json());
503
+ registerConfigEntityRoutes(app, createDependencies(vi.fn(), configDirectory));
504
+ try {
505
+ const available = await request(app).get('/api/config/global').expect(200);
506
+ expect(available.body.targets).toEqual([{ target: 'opencode', fileName: 'opencode.jsonc' }]);
507
+ const template = await request(app).get('/api/config/global/opencode').expect(200);
508
+ expect(JSON.parse(template.body.content)).toEqual({ $schema: 'https://opencode.ai/config.json' });
509
+ await expect(fs.stat(configDirectory)).rejects.toMatchObject({ code: 'ENOENT' });
510
+ const content = '{\n // first configuration\n "model": "provider/model"\n}\n';
511
+ await request(app).put('/api/config/global/opencode').send({ content }).expect(200);
512
+ expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toBe(content);
513
+ const loaded = await request(app).get('/api/config/global/opencode').expect(200);
514
+ expect(loaded.body.content).toBe(content);
515
+ await request(app).get('/api/config/global/oh-my-openagent').expect(404);
516
+ } finally {
517
+ await fs.rm(root, { recursive: true, force: true });
518
+ }
519
+ });
520
+
498
521
  it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
499
522
  const app = express();
500
523
  app.use(express.json());
@@ -2,8 +2,11 @@ import { execFile, spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { createOpenCodeUpdateDiscovery } from './update-discovery.js';
6
+ import { configureOpenCodeTerminalPath } from './terminal-path.js';
5
7
  import {
6
8
  PINNED_OPENCODE2_VERSION,
9
+ compareOpenCode2Versions,
7
10
  isAcceptableOpenCode2HealthVersion,
8
11
  isOpenCode2VersionAtLeast,
9
12
  npmPackageForOpenCode2,
@@ -12,6 +15,7 @@ import {
12
15
  } from './opencode2-pin.js';
13
16
 
14
17
  const INSTALL_FETCH_TIMEOUT_MS = 30_000;
18
+ const discoverLatestVersion = createOpenCodeUpdateDiscovery();
15
19
 
16
20
  export async function upgradeSelectedOpenCodeCli({ binaryPath, version, launchSpec, env = process.env, execFileImpl = execFile }) {
17
21
  if (launchSpec?.wrapperType === 'cmd-wrapper') {
@@ -55,6 +59,15 @@ export function installedOpenCode2BinaryPath(version = PINNED_OPENCODE2_VERSION,
55
59
  return path.join(dataDir, 'opencode-cli', version, openCodeBinaryName(options.platform));
56
60
  }
57
61
 
62
+ export function installedOpenCode2BinaryCandidates(options = {}) {
63
+ const root = path.dirname(path.dirname(installedOpenCode2BinaryPath(undefined, options)));
64
+ let versions;
65
+ try { versions = fs.readdirSync(root).filter((name) => /^2\.\d+\.\d+$/.test(name)); }
66
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
67
+ return versions.sort((a, b) => compareOpenCode2Versions(b, a))
68
+ .map((version) => installedOpenCode2BinaryPath(version, options));
69
+ }
70
+
58
71
  export function readOpenCode2BinaryVersion(binaryPath, options = {}) {
59
72
  if (typeof binaryPath !== 'string' || !binaryPath.trim()) return '';
60
73
  const run = typeof options.spawnSync === 'function' ? options.spawnSync : spawnSync;
@@ -217,12 +230,16 @@ export async function installPinnedOpenCode2Cli(options = {}) {
217
230
  if (preparedVersion !== version) {
218
231
  throw new Error(`Installed opencode2 version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`);
219
232
  }
233
+ if (['opencode-path.sh', 'opencode-path.fish', 'opencode-path.windows'].some((name) => fs.existsSync(path.join(dataDir, name)))) {
234
+ try { configureOpenCodeTerminalPath(outputBinary, { ...options, dataDir }); }
235
+ catch (error) { console.warn('[OpenCode] Terminal PATH refresh failed:', error.message); }
236
+ }
220
237
  return outputBinary;
221
238
  }
222
239
 
223
240
  /**
224
- * Reuse any discovered acceptable 2.x CLI. Install the pin only when nothing
225
- * usable is already installed (global PATH, explicit override, or cache).
241
+ * Reuse any discovered acceptable 2.x CLI. Fresh installs resolve stable 2.x;
242
+ * an explicit pin or an unavailable registry uses the tested version instead.
226
243
  */
227
244
  export async function ensurePinnedOpenCode2Cli(input = {}) {
228
245
  const pin = input.pin || PINNED_OPENCODE2_VERSION;
@@ -232,18 +249,29 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
232
249
  ? input.readVersion
233
250
  : (binaryPath) => readOpenCode2BinaryVersion(binaryPath, input);
234
251
  const install = typeof input.install === 'function' ? input.install : installPinnedOpenCode2Cli;
252
+ const finish = (result) => {
253
+ const root = path.dirname(path.dirname(installedOpenCode2BinaryPath(pin, input)));
254
+ const relative = path.relative(root, result.path);
255
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
256
+ try { configureOpenCodeTerminalPath(result.path, input); }
257
+ catch (error) { console.warn('[OpenCode] CLI is available in Yuri, but terminal PATH setup failed:', error.message); }
258
+ }
259
+ return result;
260
+ };
235
261
 
236
262
  if (discoveredPath) {
237
263
  const version = readVersion(discoveredPath);
238
264
  if (isAcceptableOpenCode2HealthVersion(version)) {
239
- return { path: discoveredPath, version, source: 'discovered', installed: false };
265
+ return finish({ path: discoveredPath, version, source: 'discovered', installed: false });
240
266
  }
241
267
  }
242
268
 
243
- const cached = installedOpenCode2BinaryPath(pin, input);
244
- const cachedVersion = readVersion(cached);
245
- if (cachedVersion === pin || isOpenCode2VersionAtLeast(cachedVersion, pin)) {
246
- return { path: cached, version: cachedVersion, source: 'installed', installed: false };
269
+ const candidates = [...new Set([...installedOpenCode2BinaryCandidates(input), installedOpenCode2BinaryPath(pin, input)])];
270
+ for (const cached of candidates) {
271
+ const cachedVersion = readVersion(cached);
272
+ if (isOpenCode2VersionAtLeast(cachedVersion, pin)) {
273
+ return finish({ path: cached, version: cachedVersion, source: 'installed', installed: false });
274
+ }
247
275
  }
248
276
 
249
277
  if (!autoInstall) {
@@ -254,10 +282,19 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
254
282
  throw error;
255
283
  }
256
284
 
257
- const installedPath = await install({ ...input, version: pin });
285
+ let target = pin;
286
+ if (!input.pin) {
287
+ try {
288
+ const latest = await (input.resolveLatestVersion || discoverLatestVersion)();
289
+ if (/^2\.\d+\.\d+$/.test(latest) && isOpenCode2VersionAtLeast(latest, pin)) target = latest;
290
+ } catch {
291
+ console.warn('[OpenCode] Latest version lookup failed; using the tested fallback version.');
292
+ }
293
+ }
294
+ const installedPath = await install({ ...input, version: target });
258
295
  const version = readVersion(installedPath);
259
- if (version !== pin) {
260
- throw new Error(`Installed OpenCode version mismatch: expected ${pin}, got ${version || 'unknown'}`);
296
+ if (version !== target) {
297
+ throw new Error(`Installed OpenCode version mismatch: expected ${target}, got ${version || 'unknown'}`);
261
298
  }
262
- return { path: installedPath, version, source: 'installed', installed: true };
299
+ return finish({ path: installedPath, version, source: 'installed', installed: true });
263
300
  }
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { afterEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
6
+ import { configureOpenCodeTerminalPath } from './terminal-path.js';
6
7
  import {
7
8
  ensurePinnedOpenCode2Cli,
8
9
  installedOpenCode2BinaryPath,
@@ -11,6 +12,8 @@ import {
11
12
  upgradeSelectedOpenCodeCli,
12
13
  } from './ensure-cli.js';
13
14
 
15
+ vi.mock('./terminal-path.js', () => ({ configureOpenCodeTerminalPath: vi.fn() }));
16
+
14
17
  const tempDirs = [];
15
18
 
16
19
  const createTempDir = (prefix) => {
@@ -20,12 +23,60 @@ const createTempDir = (prefix) => {
20
23
  };
21
24
 
22
25
  afterEach(() => {
26
+ vi.clearAllMocks();
23
27
  for (const dir of tempDirs.splice(0)) {
24
28
  fs.rmSync(dir, { recursive: true, force: true });
25
29
  }
26
30
  });
27
31
 
28
32
  describe('ensurePinnedOpenCode2Cli', () => {
33
+ it('resolves the latest stable 2.x for a fresh installation', async () => {
34
+ const dataDir = createTempDir('yuri-latest-cli-');
35
+ const installed = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode');
36
+ const install = vi.fn(async () => installed);
37
+ const result = await ensurePinnedOpenCode2Cli({ dataDir,
38
+ resolveLatestVersion: async () => '2.9.1', install,
39
+ readVersion: (file) => file === installed ? '2.9.1' : '',
40
+ });
41
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: '2.9.1' }));
42
+ expect(result.version).toBe('2.9.1');
43
+ expect(configureOpenCodeTerminalPath).toHaveBeenCalledWith(installed, expect.objectContaining({ dataDir }));
44
+ });
45
+
46
+ it.each(['1.18.4', '3.0.0', '2.9.1-beta.1', '2.0.1'])('never chooses an unsuitable fresh-install target %s', async (latest) => {
47
+ const install = vi.fn(async () => '/installed/opencode');
48
+ await ensurePinnedOpenCode2Cli({ dataDir: createTempDir('yuri-invalid-cli-'), install,
49
+ resolveLatestVersion: async () => latest,
50
+ readVersion: (file) => file === '/installed/opencode' ? PINNED_OPENCODE2_VERSION : '',
51
+ });
52
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: PINNED_OPENCODE2_VERSION }));
53
+ });
54
+
55
+ it('reuses the newest verified cache offline instead of reinstalling the pin', async () => {
56
+ const dataDir = createTempDir('yuri-new-cache-');
57
+ for (const version of [PINNED_OPENCODE2_VERSION, '2.9.1', '2.10.0']) {
58
+ fs.mkdirSync(path.join(dataDir, 'opencode-cli', version), { recursive: true });
59
+ }
60
+ const latest = installedOpenCode2BinaryPath('2.10.0', { dataDir });
61
+ const resolveLatestVersion = vi.fn();
62
+ const result = await ensurePinnedOpenCode2Cli({ dataDir, resolveLatestVersion,
63
+ readVersion: (file) => path.basename(path.dirname(file)),
64
+ install: async () => { throw new Error('must not install'); },
65
+ });
66
+ expect(result).toMatchObject({ path: latest, version: '2.10.0', installed: false });
67
+ expect(resolveLatestVersion).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('uses the tested pin when the registry is unavailable', async () => {
71
+ const dataDir = createTempDir('yuri-offline-cli-');
72
+ const install = vi.fn(async () => '/installed/opencode');
73
+ await ensurePinnedOpenCode2Cli({ dataDir, install,
74
+ resolveLatestVersion: async () => { throw new Error('offline'); },
75
+ readVersion: (file) => file === '/installed/opencode' ? PINNED_OPENCODE2_VERSION : '',
76
+ });
77
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: PINNED_OPENCODE2_VERSION }));
78
+ });
79
+
29
80
  it('runs upgrade through the selected launch target and preserves its CLI entry path', async () => {
30
81
  const dir = createTempDir('yuri-upgrade-cli-');
31
82
  const script = path.join(dir, 'cli.cjs');
@@ -86,6 +137,7 @@ describe('ensurePinnedOpenCode2Cli', () => {
86
137
  });
87
138
  expect(result.source).toBe('discovered');
88
139
  expect(result.path).toBe('/opt/old-opencode');
140
+ expect(configureOpenCodeTerminalPath).not.toHaveBeenCalled();
89
141
  });
90
142
 
91
143
  it('installs the pin only when no usable 2.x binary is present', async () => {
@@ -3,8 +3,8 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { mergePathValues } from './path-utils.js';
6
- import { PINNED_OPENCODE2_VERSION, isAcceptableOpenCode2HealthVersion, isOpenCode1xVersion } from './opencode2-pin.js';
7
- import { ensurePinnedOpenCode2Cli, installedOpenCode2BinaryPath, readOpenCode2BinaryVersion, upgradeSelectedOpenCodeCli } from './ensure-cli.js';
6
+ import { isAcceptableOpenCode2HealthVersion, isOpenCode1xVersion } from './opencode2-pin.js';
7
+ import { ensurePinnedOpenCode2Cli, installedOpenCode2BinaryCandidates, readOpenCode2BinaryVersion, upgradeSelectedOpenCodeCli } from './ensure-cli.js';
8
8
 
9
9
  export const createOpenCodeEnvRuntime = (deps) => {
10
10
  const {
@@ -280,12 +280,12 @@ export const createOpenCodeEnvRuntime = (deps) => {
280
280
  };
281
281
 
282
282
  const resolveInstalledOpenCodeCliPath = () => {
283
- const installed = installedOpenCode2BinaryPath(PINNED_OPENCODE2_VERSION, {
283
+ const installed = installedOpenCode2BinaryCandidates({
284
284
  env: process.env,
285
285
  homedir: resolveHomeDir,
286
286
  platform: process.platform,
287
- });
288
- if (isExecutable(installed) && !isWindowsOpenCodeDesktopAppPath(installed)) {
287
+ }).find((binary) => isExecutable(binary) && !isWindowsOpenCodeDesktopAppPath(binary));
288
+ if (installed) {
289
289
  clearWslOpencodeResolution();
290
290
  state.resolvedOpencodeBinarySource = 'installed';
291
291
  return installed;
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export function configureOpenCodeTerminalPath(binaryPath, options = {}) {
7
+ const env = options.env || process.env;
8
+ const home = typeof options.homedir === 'function' ? options.homedir() : options.homedir || os.homedir();
9
+ const platform = options.platform || process.platform;
10
+ const dataDir = options.dataDir || env.YURI_DATA_DIR || path.join(home, '.config', 'yuri');
11
+ const directory = path.dirname(binaryPath);
12
+ if (platform === 'win32') {
13
+ const run = options.spawnSync || spawnSync;
14
+ const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `
15
+ $ErrorActionPreference = 'Stop'
16
+ $root = $env:YURI_CLI_CACHE_ROOT.TrimEnd('\\') + '\\'
17
+ $entries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and -not $_.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) })
18
+ $value = (@($env:YURI_CLI_BIN_DIR) + $entries) -join ';'
19
+ [Environment]::SetEnvironmentVariable('Path', $value, 'User')
20
+ Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class YuriEnvironment { [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result); }'
21
+ $result = [UIntPtr]::Zero
22
+ [void][YuriEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)
23
+ `], { env: { ...env, YURI_CLI_CACHE_ROOT: path.join(dataDir, 'opencode-cli'), YURI_CLI_BIN_DIR: directory },
24
+ encoding: 'utf8', timeout: 15_000, windowsHide: true });
25
+ if (result.status !== 0) throw new Error('Cannot add OpenCode to the Windows user PATH');
26
+ fs.mkdirSync(dataDir, { recursive: true });
27
+ fs.writeFileSync(path.join(dataDir, 'opencode-path.windows'), directory);
28
+ return;
29
+ }
30
+
31
+ const shell = env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh');
32
+ const kind = path.basename(shell);
33
+ const files = [];
34
+ if (kind === 'zsh') {
35
+ const run = options.spawnSync || spawnSync;
36
+ const probe = run(shell, ['-c', 'printf "%s" "${ZDOTDIR:-$HOME}"'], { env: { ...env, HOME: home }, encoding: 'utf8', timeout: 5000 });
37
+ if (probe.status !== 0 || !path.isAbsolute(probe.stdout || '')) throw new Error('Cannot resolve zsh startup directory');
38
+ files.push(path.join(probe.stdout, '.zprofile'), path.join(probe.stdout, '.zshrc'));
39
+ } else if (kind === 'bash') {
40
+ files.push(path.join(home, '.bashrc'));
41
+ files.push(['.bash_profile', '.bash_login', '.profile'].map((name) => path.join(home, name)).find((file) => fs.existsSync(file)) || path.join(home, '.profile'));
42
+ } else if (['sh', 'dash', 'ksh'].includes(kind)) {
43
+ files.push(path.join(home, '.profile'));
44
+ } else if (kind === 'fish') {
45
+ files.push(path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'fish', 'config.fish'));
46
+ } else {
47
+ throw new Error(`Cannot configure OpenCode PATH for shell ${kind}`);
48
+ }
49
+
50
+ const quote = kind === 'fish'
51
+ ? (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
52
+ : (value) => `'${value.replace(/'/g, "'\\''")}'`;
53
+ const envFile = path.join(dataDir, kind === 'fish' ? 'opencode-path.fish' : 'opencode-path.sh');
54
+ const content = kind === 'fish'
55
+ ? `contains -- ${quote(directory)} $PATH; or set -gx PATH ${quote(directory)} $PATH\n`
56
+ : `case ":$PATH:" in *${quote(`:${directory}:`)}*) ;; *) export PATH=${quote(directory)}:"$PATH" ;; esac\n`;
57
+ fs.mkdirSync(path.dirname(envFile), { recursive: true });
58
+ fs.writeFileSync(`${envFile}.tmp`, content, { mode: 0o600 });
59
+ fs.renameSync(`${envFile}.tmp`, envFile);
60
+ const line = `${kind === 'fish' ? 'source' : '.'} ${quote(envFile)} # Yuri OpenCode CLI PATH`;
61
+ for (const file of files) {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ let existing = '';
64
+ try { existing = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ if (!existing.split('\n').includes(line)) fs.appendFileSync(file, `\n${line}\n`);
66
+ }
67
+ }
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { afterEach, expect, test, vi } from 'vitest';
6
+ import { ensurePinnedOpenCode2Cli, installPinnedOpenCode2Cli } from './ensure-cli.js';
7
+ import { configureOpenCodeTerminalPath } from './terminal-path.js';
8
+
9
+ const roots = [];
10
+ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
11
+
12
+ test.each(['/bin/bash', '/bin/zsh'].filter((shell) => fs.existsSync(shell)))('fresh installation is available in %s, preserves rc content and is idempotent', async (shell) => {
13
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "yuri cli's "));
14
+ roots.push(home);
15
+ const dataDir = path.join(home, '.config', 'yuri');
16
+ const binary = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode');
17
+ const zdotdir = path.join(home, 'zsh');
18
+ fs.mkdirSync(zdotdir);
19
+ const rc = shell.endsWith('zsh') ? path.join(zdotdir, '.zshrc') : path.join(home, '.bashrc');
20
+ fs.writeFileSync(rc, '# user configuration\n');
21
+ const options = { pin: '2.9.1', dataDir, homedir: () => home,
22
+ env: { HOME: home, ZDOTDIR: zdotdir, SHELL: shell, PATH: '/usr/bin:/bin' },
23
+ install: vi.fn(async () => {
24
+ fs.mkdirSync(path.dirname(binary), { recursive: true });
25
+ fs.writeFileSync(binary, '#!/bin/sh\nprintf "2.9.1\\n"\n', { mode: 0o755 });
26
+ return binary;
27
+ }),
28
+ };
29
+ await ensurePinnedOpenCode2Cli(options);
30
+ const first = fs.readFileSync(rc, 'utf8');
31
+ expect(first).toContain('# user configuration\n');
32
+ const result = spawnSync(shell, ['-ic', 'opencode --version'], {
33
+ env: options.env, encoding: 'utf8', timeout: 5000,
34
+ });
35
+ expect(result.status, result.stderr).toBe(0);
36
+ expect(result.stdout.trim()).toBe('2.9.1');
37
+ await ensurePinnedOpenCode2Cli(options);
38
+ expect(fs.readFileSync(rc, 'utf8')).toBe(first);
39
+ expect(options.install).toHaveBeenCalledTimes(1);
40
+
41
+ await installPinnedOpenCode2Cli({ ...options, version: '2.10.0',
42
+ fetch: async (url) => String(url).endsWith('/2.10.0')
43
+ ? Response.json({ dist: { tarball: 'https://example.com/cli.tgz' } }) : new Response('fixture'),
44
+ extract: async (_archive, target) => {
45
+ fs.mkdirSync(target, { recursive: true });
46
+ fs.writeFileSync(path.join(target, 'opencode'), '#!/bin/sh\nprintf "2.10.0\\n"\n');
47
+ },
48
+ });
49
+ const upgraded = spawnSync(shell, ['-ic', 'opencode --version'], { env: options.env, encoding: 'utf8', timeout: 5000 });
50
+ expect(upgraded.status, upgraded.stderr).toBe(0);
51
+ expect(upgraded.stdout.trim()).toBe('2.10.0');
52
+ expect(fs.readFileSync(rc, 'utf8')).toBe(first);
53
+ });
54
+
55
+ test('Windows updates only user PATH using a hidden first-level process and reports failure', () => {
56
+ const spawn = vi.fn(() => ({ status: 0 }));
57
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'yuri-win-path-'));
58
+ roots.push(dataDir);
59
+ const binary = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode.exe');
60
+ const options = { platform: 'win32', dataDir, homedir: dataDir, spawnSync: spawn, env: {} };
61
+ configureOpenCodeTerminalPath(binary, options);
62
+ expect(spawn).toHaveBeenCalledWith('powershell.exe', expect.arrayContaining(['-NoProfile', '-NonInteractive']), expect.objectContaining({
63
+ windowsHide: true, env: expect.objectContaining({ YURI_CLI_BIN_DIR: path.dirname(binary) }),
64
+ }));
65
+ expect(spawn.mock.calls[0][1].at(-1)).toContain("GetEnvironmentVariable('Path', 'User')");
66
+ spawn.mockReturnValue({ status: 1 });
67
+ expect(() => configureOpenCodeTerminalPath(binary, options)).toThrow('Windows user PATH');
68
+ });