mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-19 07:24:03 +00:00
feat(opencode): enforce stable runtime support and add managed setup
Require stable OpenCode >=2.0.11 <3.0.0 from authenticated daemon metadata before functional clients and plugin provisioning. Keep bounded contract recognition, connection-generation fencing and native ownership checks while retiring old request, response and event conversions. Unify missing and outdated runtime recovery with Preferences. Install exact stable releases through bundled Node/npm into a verified versioned user prefix, preserve custom and WSL selections, and separate installation from explicit shared-service restart through the native-parent starter. Fence stale setup responses, retain pending folder opening and never replay prompts. Certify isolated 2.0.3-to-2.0.11 history migration with executable hashes, untouched seed storage, paginated messages, forks, compaction checkpoints, location moves and pending inbox data. Add minimum/latest cross-platform migration and installation gates, packaged npm closure checks, real Solid recovery regressions and supported-contract authority tests. Validated server/UI types, focused unit and reducer suites, browser captures, actual packaging-prepared Node 24.20.0 installation without system Node, and native location, automation and pruning fixtures. Document packaged-desktop, WSL, broader historical-state and remote-CI evidence as remaining release gates.
This commit is contained in:
parent
3e14312908
commit
7869e609b3
61 changed files with 1486 additions and 768 deletions
22
.github/workflows/pr-build.yml
vendored
22
.github/workflows/pr-build.yml
vendored
|
|
@ -400,6 +400,28 @@ jobs:
|
|||
"packages/server/src/opencode-update/*.test.ts"
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
|
||||
- name: Test first installation without system Node
|
||||
run: node scripts/test-opencode-install-native.mjs
|
||||
|
||||
- name: Certify native history migration at the minimum and latest stable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$platform = node -p "process.platform === 'win32' ? 'windows' : process.platform"
|
||||
$arch = node -p "process.arch"
|
||||
$extension = node -p "process.platform === 'win32' ? '.exe' : ''"
|
||||
$seed = Join-Path $env:RUNNER_TEMP 'opencode-migration-seed'
|
||||
npm install --prefix $seed '@opencode/cli@2.0.3' --ignore-scripts --no-audit --no-fund
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
$oldCli = Join-Path $seed "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
|
||||
foreach ($version in @('2.0.11', 'latest')) {
|
||||
$target = Join-Path $env:RUNNER_TEMP "opencode-migration-$version"
|
||||
npm install --prefix $target "@opencode/cli@$version" --ignore-scripts --no-audit --no-fund
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
$cli = Join-Path $target "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
|
||||
node scripts/test-opencode-history-migration.mjs $oldCli $cli
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# Exercise the window persistence regressions on the architecture reported in
|
||||
# #676, independently of the Linux server gate. Packaging alone cannot test them.
|
||||
tests-tauri-macos:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ description: |
|
|||
|
||||
## Native OpenCode V2 Baseline
|
||||
|
||||
- PR #696 sets the release runtime floor to stable `>=2.0.11 <3.0.0` in `opencode/runtime-support.ts`, independently of contract recognition. Admission uses authenticated daemon metadata before functional clients/plugin provisioning. Missing/outdated runtimes share setup with bundled Node/npm and a versioned user prefix; shared-daemon restart is explicit and uses the existing native-parent launch bridge. Legacy request/response/event conversions are retired; historical identity/authority checks remain. See `dev-docs/OPENCODE_V2_POST_BETA.md` for migration evidence and release gates.
|
||||
|
||||
- Server and UI pin the official `@opencode/client@2.0.11` together; the pruning plugin pins `@opencode/plugin@2.0.11`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. Qualify against the latest published stable runtime. Each CodeNomad release sets its minimum supported OpenCode version to the latest stable available at that CodeNomad release; the minimum stays fixed for that release. Record results in PR/CI logs and update `dev-docs/OPENCODE_V2_COMPATIBILITY.md` in place rather than adding per-version reports. The runtime CLI is managed independently and startup has no exact version gate.
|
||||
- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations.
|
||||
- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The narrow integrations are the bundled `codenomad.automation` plugin and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md`, `dev-docs/BROWSER_AUTOMATION.md` and `dev-docs/SESSION_PRUNING_RPC.md`. All automation tools follow backend presence without a Developer Mode gate, sharing the authenticated native transport and execution-time session/window fences.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
CodeNomad server and UI pin `@opencode/client@2.0.11`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.info()` to the discovered route. Older services do not expose `paths.tmp`; consumers may use only the metadata actually provided. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
|
||||
|
||||
Cross-runtime adaptation lives in `packages/server/src/opencode/compatibility/`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Known published contracts select their serializer directly; unknown versions require authenticated bounded OpenAPI recognition before calls. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for the evidence matrix and maintained issue register.
|
||||
Runtime admission lives in `packages/server/src/opencode/runtime-support.ts`: stable `>=2.0.11 <3.0.0`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Admission precedes functional clients/plugin provisioning; unlisted supported versions additionally require authenticated bounded OpenAPI recognition. Legacy request/response/event translations are retired. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_POST_BETA.md` for setup and migration evidence.
|
||||
|
||||
- Promise client: `import { OpenCode } from "@opencode/client"`
|
||||
- Service authentication headers: `import { Service } from "@opencode/client/service"`
|
||||
|
|
@ -30,7 +30,7 @@ Native methods return decoded Promise values. Follow the installed declarations
|
|||
|
||||
Native Forms own pending interruption state. Global Forms use `sessionID: "global"` and `x-opencode-directory: encodeURIComponent(directory)`; ordinary session Forms derive location from the session. Question tool output rendering is independent of pending Forms.
|
||||
|
||||
Earlier V2 location identity must survive modern generated-client field selection. Use `locationRequestOptions` (server) / `requestLocationOptions` (UI) for the explicit private context channel; the proxy authorizes the complete pair, translates its directory, and the selected transport serializes the appropriate legacy slots. Modern public APIs still reject workspace selectors. Session move/rollback uses `moveSessionToLocation`, not a cast adding fields to the modern method input.
|
||||
Historical location identity must not silently disappear through generated-client field selection. `locationRequestOptions` (server) / `requestLocationOptions` (UI) retain an explicit private context channel so the proxy can reject obsolete selectors rather than accidentally authorizing directory-only requests. Supported public APIs reject workspace selectors. Session move/rollback uses `moveSessionToLocation`, not a cast adding fields to the generated method input.
|
||||
|
||||
Stable mutations use `permission.reply({ decision })`, `session.command({ name })`, `session.interrupt({ resume })`, `session.fork({ before? })`, `session.inbox.update({ delivery })` and `session.message.get(...)`. Credential removal is global and takes only `credentialID`. There is no plugin activation-wait endpoint; catalog reads and `plugin.updated` supply native state.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy.
|
||||
- That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically.
|
||||
- Proxy authorization and forwarding share one acquired connection. A stale generation must be rejected at actual HTTP dispatch, including after asynchronous body preparation; late streams cannot invalidate a replacement connection.
|
||||
- Adapt legacy HTTP inbox timestamps before the native Solid reducer. Native `session.inbox.enqueued` is a different shape: its timestamp belongs to the event metadata and must not be treated as an HTTP inbox record.
|
||||
- Compatibility never retries a write using another contract after a 400/404/transport failure. Unknown version numbers are recognized through authenticated OpenAPI structure rather than an exact runtime-version gate.
|
||||
- Require stable `>=2.0.11 <3.0.0` from authenticated daemon metadata before clients or plugin provisioning; unlisted supported versions additionally require bounded OpenAPI recognition. Legacy HTTP inbox and event conversions are retired. Native `session.inbox.enqueued` still has a distinct shape: its timestamp belongs to event metadata, not an HTTP inbox record.
|
||||
- Never retry a write using another contract after a 400/404/transport failure. Keep the setup/recovery path distinct from functional transport and never replay prompts.
|
||||
|
||||
## Location Is Authority
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ OpenCode owns standard state/database. Allowed configured environment variables
|
|||
|
||||
Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory. Its `project` field supplies project metadata. Public locations have no workspace selector. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts.
|
||||
|
||||
The paragraph above describes the canonical modern wire contract. Preserve any native legacy `workspaceID` internally and authorize it through the connection-scoped adapter; directory-only serialization must not erase identity from cache keys, pending Forms, imported history or move rollback. See `opencode/compatibility/location.ts` for the explicit context channel that survives generated serialization.
|
||||
The supported wire contract requires stable `>=2.0.11 <3.0.0`; old request/response/event translations are retired. Preserve historical `workspaceID` validation internally: obsolete selectors must be rejected rather than erased from imports, cursors, pending Forms or move rollback. Native 2.0.3→2.0.11 migration collapses workspace selectors to local directory scope while preserving session IDs. See `dev-docs/OPENCODE_V2_POST_BETA.md`.
|
||||
|
||||
The instance proxy is method/path allowlisted, rejects unowned paths, `directory`, `location.directory`, and `location[directory]` values, and verifies session location before forwarding. Keep this check at the server trust boundary; new upstream routes require explicit review.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
|
||||
## Coding Principles
|
||||
|
||||
- OpenCode runtime admission is fixed per release in `opencode/runtime-support.ts` (currently stable `>=2.0.11 <3.0.0`). Validate authenticated daemon metadata before client use/plugin provisioning. Shared setup handles missing/outdated installations with bundled Node/npm and a versioned user prefix; a running daemon restart is a separate explicit action. Preserve historical identity/ownership checks even after retiring old wire adapters. See `dev-docs/OPENCODE_V2_POST_BETA.md` and its isolated installation/migration fixtures.
|
||||
|
||||
- Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`.
|
||||
|
||||
- One bundled `codenomad.automation` V2 plugin uses native discovery and backend presence. All browser/developer tools are available without a Developer Mode toggle; native instrumentation starts with the desktop host. Loading, tool availability and execution targeting are separate: retain the authenticated bridge and session/window fences. Tauri preview children receive no application capabilities; primary-renderer reload must dispose them and final-window checks must count native windows. See `dev-docs/BROWSER_AUTOMATION.md`.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ Server and UI pin the official `@opencode/client@2.0.11`; the bundled pruning pl
|
|||
|
||||
The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md).
|
||||
|
||||
The current cross-version issue register, published API-change timeline and connection-scoped compatibility architecture are maintained in [`dev-docs/OPENCODE_V2_COMPATIBILITY.md`](dev-docs/OPENCODE_V2_COMPATIBILITY.md). The server integration module adapts earlier V2 requests/responses for both the guarded UI proxy and direct server callers; native regression runs cross the pinned client with beta-19271, 2.0.3, 2.0.4 and 2.0.5. Discovery success alone does not certify an older runtime's conversation, event or pruning contract.
|
||||
The current cross-version issue register and historical API-change audit are maintained in [`dev-docs/OPENCODE_V2_COMPATIBILITY.md`](dev-docs/OPENCODE_V2_COMPATIBILITY.md). This release requires stable OpenCode **>=2.0.11 <3.0.0**, checked against authenticated daemon metadata before client use or plugin provisioning. Earlier wire adapters are retired; their regression results below are historical evidence rather than live support.
|
||||
|
||||
### Installation and required updates (PR #696)
|
||||
|
||||
Missing and outdated OpenCode use the same setup screen in startup/recovery and Preferences. Default host installation uses CodeNomad's bundled Node/npm, including when system Node is absent, into `~/.local/share/codenomad/opencode`. Custom/WSL binaries retain explicit execution-host instructions. Updating files does not update an already-running daemon: the screen offers a separate explicit shared-service restart, then revalidates authority. Pending folder opening can resume; prompts are never replayed.
|
||||
|
||||
The isolated 2.0.3→2.0.11 migration fixture preserves session IDs, complete paginated message content, forks, compaction checkpoints, moved sessions and a pending inbox record. OpenCode itself collapses old workspace selectors to local directory scope. Historical ownership checks remain; CodeNomad does not rewrite the database. See the [transition register](dev-docs/OPENCODE_V2_POST_BETA.md) for validation commands and outstanding packaged/platform release gates.
|
||||
|
||||
## Native V2 Adoption
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ compatibility code and historical-data handling do not imply support for older
|
|||
runtimes. Keep detailed results in the change's PR and CI logs, not in per-version
|
||||
reports. Update this reference in place.
|
||||
|
||||
PR #696 implements a fixed minimum of **2.0.11** for this development release:
|
||||
stable `>=2.0.11 <3.0.0`, with separate authenticated contract recognition for
|
||||
unlisted releases. Missing/outdated runtimes share installation and recovery UI;
|
||||
the bundled Node/npm installer and explicit daemon restart are distinct actions.
|
||||
Legacy request/response/event translations have been retired. Historical
|
||||
identity, import/cursor, cancellation and connection-authority checks remain.
|
||||
The isolated 2.0.3→2.0.11 seed confirms native workspace-selector collapse while
|
||||
preserving session IDs and complete history. See the
|
||||
[transition register](OPENCODE_V2_POST_BETA.md) for actual coverage and release gates.
|
||||
|
||||
The audit and implementation record below is historical context, not a runtime
|
||||
qualification matrix to maintain.
|
||||
|
||||
|
|
@ -22,7 +32,7 @@ qualification matrix to maintain.
|
|||
|
||||
## Decision summary
|
||||
|
||||
Future retirement of the earlier-runtime adapter and the shared installation/required-update experience are tracked in the [OpenCode V2 stable-runtime transition plan](OPENCODE_V2_POST_BETA.md). That maintained plan preserves this register as the current support/evidence baseline and selects no new minimum runtime yet.
|
||||
The [OpenCode V2 stable-runtime transition register](OPENCODE_V2_POST_BETA.md) supersedes the earlier live-support set and records implementation and acceptance evidence. The sections below preserve the original #695 audit.
|
||||
|
||||
At `bcfe4d24`, CodeNomad had a working modern-client path and backward-compatible service discovery, **not a complete backward-compatible V2 integration**. The follow-up change keeps that discovery fix and addresses the remaining issues through one connection-scoped integration module and one cross-runtime acceptance matrix.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,171 +1,121 @@
|
|||
# OpenCode V2 stable-runtime transition and installation plan
|
||||
# OpenCode V2 stable-runtime transition
|
||||
|
||||
**Status:** maintained implementation plan; this documentation increment is mergeable independently of the implementation gates. Runtime support is unchanged.
|
||||
**Starting point:** [PR #695](https://github.com/NeuralNomadsAI/CodeNomad/pull/695), merged into `dev` at `aa51cbb9` on 2026-09-16.
|
||||
**Related register:** [current compatibility contracts and evidence](OPENCODE_V2_COMPATIBILITY.md).
|
||||
**Implementation:** PR #696, based on #695 and the client/plugin 2.0.11 alignment
|
||||
in #728. This register describes code and local evidence; remote CI and packaged
|
||||
desktop acceptance are separate release gates.
|
||||
|
||||
## Objective
|
||||
## Release contract
|
||||
|
||||
Track the transition to published stable OpenCode V2 contracts, a shared installation/update experience, and deliberate retirement of earlier wire contracts. Land this plan, then maintain it through focused implementation PRs. Keep the eventual support-policy change and adapter deletion reviewable together.
|
||||
- This development release fixes `MINIMUM_OPENCODE_VERSION` at **2.0.11**.
|
||||
npm `@opencode/cli@latest` resolved to 2.0.11 on 2026-09-20.
|
||||
- Supported versions are stable `>=2.0.11 <3.0.0`. Prereleases, malformed
|
||||
versions, older releases and future majors are refused with
|
||||
`opencode_update_required` / HTTP 426.
|
||||
- Client and plugin dependencies remain separately pinned at 2.0.11.
|
||||
- Authenticated daemon metadata, not the selected executable's version, controls
|
||||
connection admission. Admission precedes functional clients and plugin
|
||||
provisioning. Replacement daemons are checked again, even at the same URL.
|
||||
- Unlisted supported releases must also pass bounded authenticated OpenAPI
|
||||
recognition. A retired or unrecognized contract never receives speculative
|
||||
writes. The minimum is never fetched from npm at application startup.
|
||||
|
||||
Three versions must remain distinct:
|
||||
## Setup and recovery
|
||||
|
||||
| Version | Current baseline | Transition decision |
|
||||
| --- | --- | --- |
|
||||
| CodeNomad dependencies | Exact `@opencode/client@2.0.4` and `@opencode/plugin@2.0.4` | Select reviewed dependency versions and update their lockfile together. |
|
||||
| Connected OpenCode daemon | Native acceptance on beta-19271, 2.0.3, 2.0.4 and 2.0.5 | Choose an explicit minimum runtime and supported release range. |
|
||||
| CodeNomad release | Support established by #695 | Announce the release that adopts the new minimum. |
|
||||
The global setup dialog and Preferences share `OpenCodeUpdateCard` and the same
|
||||
store. Missing and below-minimum installations use one screen with state-specific
|
||||
actions, installed/running/minimum versions, retry and executable selection.
|
||||
Dismissal leaves a recovery entry; optional updates do not force the dialog.
|
||||
Connection changes, foreground entry and unsupported proxy responses refresh
|
||||
recovery state. Stale responses cannot overwrite a changed executable or a
|
||||
completed action. Only a pending folder open may resume; prompts are not replayed.
|
||||
|
||||
OpenCode V2 is available on the stable `latest` channel; beta exit is no longer a pending gate. Upstream's [availability announcement commit](https://github.com/anomalyco/opencode/commit/7b7a67080e) and the published `@opencode/cli`, `@opencode/client` and `@opencode/plugin` 2.0.10 packages (checked on 2026-09-19) establish that publication status. They do not establish API immutability or certify every runtime behavior.
|
||||
Default host installations use bundled Node and the npm distributed in its pinned
|
||||
official archive. Both Electron and Tauri resources include npm's complete
|
||||
dependency/license closure. npm installs an exact policy-compatible release into
|
||||
a staging directory under `~/.local/share/codenomad/opencode`, verifies the real
|
||||
executable, then publishes a versioned installation and atomic `current` marker.
|
||||
No administrator rights, system Node, global npm install or application-resource
|
||||
writes are required. Execution is bounded to five minutes and 1 MiB output.
|
||||
Installation failures retain the previous marker. Concurrent requests coalesce;
|
||||
another backend's same-version installation is accepted only after verification.
|
||||
|
||||
No minimum or retirement date is selected yet: choose these from reviewed contracts and historical-data acceptance. A newer CLI installed on disk also does not prove the already-running daemon was upgraded. [PR #715](https://github.com/NeuralNomadsAI/CodeNomad/pull/715) already moved the updater to the stable `@opencode/cli` package; first installation and the shared setup screen below remain implementation work.
|
||||
Explicit custom binaries remain selected. WSL and custom installations receive
|
||||
execution-host instructions rather than a Windows-side Linux installation.
|
||||
Remote setup runs on the CodeNomad server, not the browser machine.
|
||||
|
||||
## Permanent interface
|
||||
Installation and service activation are separate. An old running daemon requires
|
||||
the explicit **Restart shared service** action, whose copy explains interruption
|
||||
of other clients' active work. Activation uses official `service stop` and the
|
||||
existing native-parent `service start` bridge, authenticates the daemon, validates
|
||||
its contract and replaces local authority. Ordinary backend shutdown never stops
|
||||
OpenCode. Unknown/newer daemons cannot be downgraded by this action.
|
||||
|
||||
Keep `OpenCodeSharedService.acquire()` as the shared seam for the guarded browser proxy and direct server callers, including Yolo. It binds authenticated endpoint/daemon identity, client, transport, connection lifetime and invalidation. Retiring one adapter must not move contract decisions into UI stores or individual callers.
|
||||
## Historical migration
|
||||
|
||||
The future single-contract implementation must preserve:
|
||||
`scripts/test-opencode-history-migration.mjs OLD_CLI TARGET_CLI` creates synthetic
|
||||
2.0.3 storage, closes it, copies its DB/WAL and lets the target runtime perform its
|
||||
own migration. It retains seed exports, untouched seed storage, CLI versions,
|
||||
executable SHA-256 hashes and logs in a fresh temporary fixture. It never connects
|
||||
to the shared service or rewrites native tables.
|
||||
|
||||
- Authentication, loopback/origin checks, credential-safe redirects, bounded discovery and absolute deadlines.
|
||||
- Dispatch-generation checks after asynchronous body preparation; retired streams cannot publish or invalidate a replacement connection.
|
||||
- Independent caller cancellation and connection-scoped negotiation/validation.
|
||||
- Native error envelopes, especially streamed 401 responses; no mutation retry under another contract after failure.
|
||||
- Location/session/path ownership, the proxy allowlist and worktree mutation fences.
|
||||
- Exact project/destination directory identity, distinct from containing-worktree identity, with exact session rollback.
|
||||
- Native `cursor.next`, scoped Forms and validated encoded global Form headers.
|
||||
- Separate background `shell.*` and interactive `pty.*` behavior.
|
||||
- Authoritative cache reconciliation, historical message access, and intentional handling of native idle control records.
|
||||
- Pruning's fresh storage-identity challenge, synchronous SQLite execution-claim fence and explicit-request-only deletion.
|
||||
- A standalone pruning package with complete local imports, plus bundled-plugin presence/disposal and late discovery.
|
||||
Local Windows acceptance for **2.0.3 → 2.0.11** covers:
|
||||
|
||||
The directory name `compatibility/` is not a deletion instruction. Some of its implementation provides these permanent guarantees.
|
||||
- Three source location identities, including two workspace identities at one
|
||||
directory, with independently addressable session IDs and forks.
|
||||
- 215 messages per seeded conversation: synthetic history, reasoning, text,
|
||||
completed tool output and a pre-compaction checkpoint. Export equality and
|
||||
native message pagination preserve complete history.
|
||||
- Native session pagination, a moved worktree session and a pending inbox record.
|
||||
- **Native identity rule:** the target migrates historical `workspaceID` values
|
||||
to the local directory scope while preserving session IDs and content. The
|
||||
fixture reports this explicitly; CodeNomad does not manufacture aliases.
|
||||
|
||||
## Retirement inventory
|
||||
The existing native pruning fixture independently verifies current Forms,
|
||||
Shell/PTY scope, permission/Yolo replies, model payload, fork independence,
|
||||
pre-compaction pruning, execution-claim races, plugin disposal and daemon restart.
|
||||
This is not a claim that every old pending Form or provider configuration has
|
||||
been tested across the storage migration.
|
||||
|
||||
Paths below are relative to the repository root. This is a working deletion map, not authorization to remove code before its evidence is available.
|
||||
## Retirement disposition
|
||||
|
||||
| Area / entry points | Retirement candidate | Evidence required / behavior retained |
|
||||
| --- | --- | --- |
|
||||
| `packages/server/src/opencode/compatibility/runtime.ts` | Audited legacy release list and legacy profile classification | Keep authenticated daemon metadata. Introduce a support decision separately from wire-contract recognition. |
|
||||
| `packages/server/src/opencode/compatibility/negotiate.ts` | Recognition of the retired contract family | Decide how future/unlisted releases are recognized. A version above the minimum is not blanket certification of future majors or snapshots. |
|
||||
| `packages/server/src/opencode/compatibility/requests.ts` | Legacy route, method and payload translations | Verify all consumed operations against the selected minimum and latest validated runtime before deletion. |
|
||||
| `packages/server/src/opencode/compatibility/transport.ts` | Legacy status-envelope and HTTP inbox timestamp conversions | Preserve authentication, lifetime/cancellation, origin checks, diagnostics and modern forwarding. Native SSE inbox timestamps remain a distinct schema. |
|
||||
| `packages/server/src/opencode/compatibility/events.ts` | Legacy permission-event renaming | Prove supported runtimes emit the canonical event; preserve durable metadata and reconnect reconciliation. |
|
||||
| `packages/server/src/workspaces/opencode-cli-service.ts` | Older discovery routes | Current discovery tries authenticated `/api/status`, `/api/health`, then `/api/info`, advancing only on 404. Retire a route only against the selected supported contracts; retain authentication, response bounds and shared deadlines. |
|
||||
| `packages/server/src/opencode/compatibility/location.ts` and `proxy-locations.ts` | Legacy wire selectors and private-header reconstruction | Prove historical location identity remains representable and authorized. Keep import/cursor validation and global Forms scope. |
|
||||
| `packages/ui/src/stores/request-locations.ts` and its callers | Legacy request options/private context | Review Forms, metadata/provider credentials, session creation/moves and Shell calls together. Do not collapse cache keys while distinct identities remain observable. |
|
||||
| `packages/ui/src/stores/instance-invalidation.ts` and `session-pruning-events.ts` | Legacy-only catalog/content event branches | Keep current authoritative refresh, pruning events and in-flight invalidation coverage. |
|
||||
| `packages/server/src/opencode/session-pruning/location.ts`, `service.ts`, `preview-store.ts` | No automatic deletion | Identity/storage validation concerns historical data, not only old HTTP clients. Preserve exact membership and package closure. |
|
||||
| `.github/workflows/pr-build.yml` and native fixtures | Positive runtime-support jobs for retired releases | Replace with minimum + latest validated runtime coverage; retain migration seeds and former authority/race regressions where meaningful. |
|
||||
|
||||
Keep the original compatibility audit as historical evidence. Record which predicates/tests were removed, retained or replaced in the implementation PR rather than overwriting the old results.
|
||||
|
||||
## Work packages
|
||||
|
||||
### P0 — Establish and maintain the transition register (this increment)
|
||||
|
||||
- [x] Base the draft on the merged compatibility implementation.
|
||||
- [x] Distinguish dependency versions, daemon support and the CodeNomad release decision.
|
||||
- [x] Map retirement candidates and permanent guarantees to current files.
|
||||
- [x] Define historical-data acceptance, support-policy decisions and release gates below.
|
||||
- [x] Record stable publication and specify one setup experience for missing and below-minimum OpenCode, including the bundled-Node installation proposal.
|
||||
|
||||
This increment adds documentation only. All implementation and native-validation checkboxes below are deliberately open.
|
||||
|
||||
### P1 — Make support policy an explicit connection decision
|
||||
|
||||
- [ ] Separate "recognized contract" from "supported runtime" at the shared connection seam, preserving today's accepted releases during preparation.
|
||||
- [ ] Define the future minimum, supported release range and treatment of development/unlisted versions from publication evidence.
|
||||
- [ ] Evaluate the actual authenticated daemon identity before functional calls or mutations, for both proxy and direct callers; re-evaluate on connection replacement.
|
||||
- [ ] Surface a structured unsupported-runtime error with actual/required versions and an upgrade action explained in every UI locale. Authentication/transport errors retain their own classification.
|
||||
- [ ] Use the shared setup screen in P1a for missing binaries and below-minimum runtimes; gate affected connections using backend policy rather than a UI-only version comparison.
|
||||
- [ ] Verify below-minimum refusal, unknown-contract refusal, same-port replacement, independent cancellation and absence of speculative calls/retries.
|
||||
- [ ] Review plugin provisioning order so rejecting an unsupported runtime does not bypass the existing explicit-request-only pruning rule or daemon ownership policy.
|
||||
|
||||
Do not add an inactive universal capability framework or a second client interface. The first implementation should keep the existing profile resolver and consumer seam, with the smallest support-policy decision needed by real callers. CodeNomad does not silently upgrade, restart or stop the shared daemon.
|
||||
|
||||
### P1a — Shared installation and required-update experience
|
||||
|
||||
**Proposed behavior, not yet implemented.** Use one setup screen with state-specific copy and actions, reachable from startup, connection recovery and Preferences. A persistent connection notice opens this same screen when a running application detects an unsupported replacement daemon; deduplicate it per execution target and connection generation. Block affected OpenCode operations, while keeping settings and unrelated supported connections accessible.
|
||||
|
||||
| Detected state | Screen and action |
|
||||
| Area | Implementation |
|
||||
| --- | --- |
|
||||
| OpenCode command missing | Explain the selected host or WSL distribution; offer **Install and start OpenCode**, choose an existing executable, and retry detection. |
|
||||
| Installed CLI or authenticated daemon below CodeNomad's minimum | Show installed CLI version, running daemon version when available, minimum required version and proposed target; offer **Update OpenCode** using the same installer. |
|
||||
| Supported daemon, newer optional release available | Non-blocking update notice using the same screen; being behind `latest` is not itself unsupported. |
|
||||
| Installed CLI is current but the running daemon is too old | Explain that installation succeeded but the shared service still needs an explicit restart; do not report connection readiness yet. |
|
||||
| Authentication, transport or unrecognized-contract failure | Preserve that diagnosis and provide retry/details; do not mislabel it as a missing installation or assume upgrading fixes it. |
|
||||
| Audited beta/old-stable live-support list | Removed; fixed policy and current-contract admission replace it. |
|
||||
| Legacy routes, methods and payload translation | `compatibility/requests.ts` removed. |
|
||||
| Legacy HTTP inbox/status conversions | Removed from transport. |
|
||||
| Legacy permission/step event normalization | Removed; admitted runtimes emit native canonical events. |
|
||||
| Authentication, cancellation, origin and connection generations | Retained and regression-tested at the existing shared seam. |
|
||||
| Older discovery routes | Retained for authenticated diagnosis of an outdated running daemon, not functional live support. |
|
||||
| Historical location/context, import/cursor and pruning checks | Retained. Obsolete public selectors remain rejected; these are authority checks, not permission to run old daemons. |
|
||||
| Legacy schema recognition | Retained to diagnose and reject a retired contract; no legacy serializer remains. |
|
||||
|
||||
- [ ] Reuse `packages/server/src/opencode-update/service.ts` for installation and upgrade. Its package-manager command already performs an install, but status/upgrade currently require a successful existing-binary probe. Add a genuine missing state, not a fabricated version or an install attempt after any probe error.
|
||||
- [ ] Resolve the stable channel to an exact policy-compatible target before installation; re-resolve the executable and verify its actual version afterward. Coordinate concurrent requests per installation target and bound execution time/output. Preserve explicitly selected custom executables and their update instructions.
|
||||
- [ ] For desktop hosts without a system Node/npm, use the bundled Node to execute a bundled, pinned npm CLI. `scripts/prepare-node-runtime.cjs` currently copies only the Node executable, so npm and its dependencies/licensing must be included and packaging verified. Install into a persistent user-writable prefix outside application resources; explicitly resolve the resulting executable and child-process PATH without requiring a terminal or administrator rights.
|
||||
- [ ] Keep host and WSL execution targets explicit. Windows' bundled Node cannot install a native Linux CLI inside WSL; use a verified Linux runtime/package manager in the selected distribution or show its installation instructions. Remote connections require installation on the execution host, not on the browser's computer.
|
||||
- [ ] After **Install and start**, use the existing official `service status` / `service start` lifecycle and native-parent launch bridge, then authenticate and validate the actual daemon contract before resuming the pending workspace open. Do not spawn a private `serve` process or assume installer exit means readiness.
|
||||
- [ ] Treat updating files and restarting an existing shared daemon as separate outcomes. Explain that restart affects other OpenCode clients and active work; perform it only through an explicit user action and the official service lifecycle. Reacquire connection authority afterward; never replay a prompt or mutation automatically.
|
||||
- [ ] Localize all copy, show progress and actionable failures, and retain retry/manual-path choices in the same screen. Preserve the user's pending project selection and drafts through recovery.
|
||||
- [ ] Verify absent CLI with/without system Node/npm, below-minimum CLI and daemon, stale daemon after upgrade, optional updates, registry/install failures, custom paths, repeated clicks, and host/WSL targeting. Use isolated installation prefixes and synthetic services; test the rendered screen and packaged Electron/Tauri launches without changing a user's global CLI or shared daemon.
|
||||
## Validation and release gates
|
||||
|
||||
Deliver P1/P1a in focused implementation PRs and update this register with their evidence. The setup experience must be available before raising the runtime floor in P4.
|
||||
Implemented checks include server/UI typechecks, server regressions, real Solid
|
||||
browser setup tests and rendered captures, isolated bundled-Node installation,
|
||||
native migration, native locations/worktrees, automation heartbeat/provisioning
|
||||
and pruning acceptance. CI adds first installation and old-seed migration at
|
||||
the fixed minimum and resolved latest stable on Windows, Linux and macOS ARM64.
|
||||
The existing cross-platform native pruning gate remains in place.
|
||||
|
||||
### P2 — Certify historical data on the replacement runtime
|
||||
Open release gates, not completed local claims:
|
||||
|
||||
- [ ] Add reproducible synthetic-history seeds using the already-audited legacy runtimes; retain exact executable/package versions and integrity metadata.
|
||||
- [ ] Keep an untouched seed copy, then let the official target runtime migrate an isolated copy through its normal startup path. Do not manually rewrite OpenCode tables to manufacture compatibility.
|
||||
- [ ] Validate this matrix through the real shared connection, guarded proxy, generated client and reducer:
|
||||
- Green remote CI for the final diff and independent review.
|
||||
- Packaged Electron/Tauri startup, update/restart and reconnect interaction on
|
||||
release artifacts. Native desktop automation was unavailable in this session.
|
||||
- Windows→WSL traversal and mounted/aliased configuration-root watcher coverage.
|
||||
- Broader old-runtime seeds and old pending Forms/provider-state migration.
|
||||
|
||||
| Synthetic legacy history | Required result on target runtime |
|
||||
| --- | --- |
|
||||
| Root and worktree sessions, including two identities at the same directory | Every session remains distinguishable/accessible, or an explicit native migration rule is documented and tested; ambiguous identity collapse blocks retirement. |
|
||||
| More than 200 messages and multiple native session pages | Full history remains reachable through native pagination; obsolete cursor state is discarded and reread without rewriting history. |
|
||||
| Moved sessions and location-switched history | Current ownership and historical locations are preserved; exact evacuation/rollback and foreign import/cursor refusal still pass. |
|
||||
| Tool/reasoning content, forks and pre-compaction history | Content and fork independence survive migration/restart; pruning affects only explicit selections and preserves model-context semantics. |
|
||||
| Native pending inbox and control records | Reopened state converges from authoritative reads/events; idle outcomes do not become fabricated assistant text. |
|
||||
| Global/session Forms, Shell/PTY scope and provider metadata | Location-sensitive operations retain their documented authority and current native lifecycle behavior. |
|
||||
Do not remove the remaining historical authority checks based solely on this
|
||||
synthetic seed. Keep the compatibility audit in
|
||||
[OPENCODE_V2_COMPATIBILITY.md](OPENCODE_V2_COMPATIBILITY.md) as historical evidence.
|
||||
|
||||
Old-runtime execution for generating migration fixtures is distinct from promising live support for those runtimes. Keep that purpose explicit when reducing the CI matrix.
|
||||
## Decision history
|
||||
|
||||
### P3 — Simplify behind the existing interface
|
||||
|
||||
- [ ] Remove legacy request/response/event translations once P1 and P2 establish the supported replacement contract and data behavior.
|
||||
- [ ] Remove the discovery fallback only with matching minimum-runtime evidence.
|
||||
- [ ] Simplify private location context and UI callers only where the migration matrix proves it unnecessary; retain historical identity validation where the target still exposes it.
|
||||
- [ ] Preserve and rerun the original authority, connection-generation, no-retry, import/cursor, rollback and pruning regressions against the supported contract.
|
||||
- [ ] Review imports from both the bundled and separately packed pruning plugin; no dependency may escape the standalone archive.
|
||||
|
||||
### P4 — Publish the new support floor
|
||||
|
||||
- [x] Record upstream stable availability (see Objective).
|
||||
- [ ] Select exact target contract/dependency versions from acceptance evidence.
|
||||
- [ ] Choose and announce the CodeNomad release/minimum OpenCode runtime, including the distinction between updating the CLI and the running daemon.
|
||||
- [ ] Run minimum + latest validated native acceptance, including packed/bundled pruning and rendered controls, on Windows, macOS and Linux.
|
||||
- [ ] Verify Electron and Tauri startup/reconnect/refusal behavior, plus host-to-WSL traversal for supported WSL configurations.
|
||||
- [ ] Resolve or explicitly scope the existing mounted-configuration late-discovery limitation and aliased config-root watcher behavior; canonical temporary fixtures alone do not certify those user environments.
|
||||
- [ ] Update release notes, `MIGRATION_V2.md`, architecture references and `AGENTS.md` together with the implementation.
|
||||
- [ ] Complete independent gatekeeper review, fix/retest findings, and obtain green CI for the final diff.
|
||||
|
||||
## Working in parallel
|
||||
|
||||
Merge this documentation increment as the maintained transition register. Track P1–P4 in focused implementation PRs, updating this document's progress and evidence links with each increment. Open implementation checkboxes are not blockers for the documentation PR; they remain gates for changing runtime support and retiring adapters.
|
||||
|
||||
For each increment, record:
|
||||
|
||||
1. Base commit and the work package advanced.
|
||||
2. Any change to the currently supported runtime set (normally none during preparation).
|
||||
3. Contract/publication evidence, exact native artifacts and isolated migration results.
|
||||
4. Which invariants/tests replace retired compatibility tests.
|
||||
5. Remaining decisions or failed checks, without converting local evidence into a remote-CI claim.
|
||||
|
||||
The eventual retirement change is ready when the support-policy, historical-data and release gates are complete. Until then, the merged #695 compatibility behavior remains the product baseline.
|
||||
|
||||
## Decision log
|
||||
|
||||
| Date | Decision / open question | Disposition |
|
||||
| --- | --- | --- |
|
||||
| 2026-09-16 | Start from merged #695 rather than a parallel rewrite. | Adopted; preparation branch based on `aa51cbb9`. |
|
||||
| 2026-09-16 | Runtime 2.0.5 and client/plugin 2.0.4 are different version axes. | Preserve that distinction in the draft and release communication. |
|
||||
| 2026-09-16 | Remove legacy wire support separately from historical-data identity handling. | Adopted; P2 gates identity simplification. |
|
||||
| 2026-09-16 | Which upstream release marks beta exit, and which CodeNomad release raises the minimum? | Open; no version/date selected. |
|
||||
| 2026-09-16 | What supported range and future-version recognition replace two-profile negotiation? | Open; decide during P1 from the actual publication contract. |
|
||||
| 2026-09-20 | Stable V2 publication is established; stop waiting for beta exit. | Supersedes the beta-exit question above; the CodeNomad minimum/release decision remains open. |
|
||||
| 2026-09-20 | Land and maintain the plan independently of its implementation. | Documentation completion is distinct from P1–P4 acceptance. |
|
||||
| 2026-09-20 | Missing and too-old OpenCode share a setup screen and installer. | Specified in P1a, including required-update notices, bundled Node/npm, execution-host targeting and daemon revalidation; implementation pending. |
|
||||
- 2026-09-16: #695 established connection-scoped compatibility; #696 began as a
|
||||
documentation-only retirement plan without selecting a floor.
|
||||
- 2026-09-20: stable V2 publication is established. The user requested the actual
|
||||
implementation in #696. The plan-only status is superseded by the code above.
|
||||
- 2026-09-20: minimum/client/plugin baseline 2.0.11; setup precedes admission
|
||||
recovery, updates never silently restart the externally owned daemon, and
|
||||
native historical identity validation remains independent of live support.
|
||||
|
|
|
|||
|
|
@ -373,11 +373,19 @@ export interface BinaryValidationResult {
|
|||
}
|
||||
|
||||
export interface OpenCodeUpdateStatus {
|
||||
currentVersion: string
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
updateAvailable: boolean | null
|
||||
canUpgrade: boolean
|
||||
checkError?: "update_check_failed"
|
||||
minimumVersion: string
|
||||
state: "missing" | "update_required" | "ready" | "error"
|
||||
binaryPath: string
|
||||
daemonVersion?: string
|
||||
serviceState?: "stopped" | "ready" | "restart_required" | "error"
|
||||
serviceError?: string
|
||||
target: "host" | "wsl"
|
||||
canRestart: boolean
|
||||
}
|
||||
|
||||
export interface OpenCodeUpdateResponse {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { installManagedOpenCode, readManagedExecutable } from "./managed-installation"
|
||||
|
||||
test("failed install never changes the selected version; verified install publishes atomically", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-installer-"))
|
||||
try {
|
||||
let correct = false
|
||||
const options = { root, node: process.execPath, npm: "fixture-npm.js",
|
||||
execute: async (file: string, args: string[], env: NodeJS.ProcessEnv) => {
|
||||
assert.equal(file, process.execPath)
|
||||
assert.ok(args.includes("@opencode/cli@2.0.11"))
|
||||
assert.ok(Object.entries(env).some(([key, value]) => key.toLowerCase() === "path" && value?.startsWith(path.dirname(process.execPath))))
|
||||
const staging = args[args.indexOf("--prefix") + 1]!
|
||||
await mkdir(path.join(staging, "node_modules/@opencode/cli/bin"), { recursive: true })
|
||||
await writeFile(path.join(staging, "node_modules/@opencode/cli/bin/opencode.exe"), "fixture")
|
||||
},
|
||||
probe: (file: string) => ({ valid: correct && file.includes(".install-"), version: correct && file.includes(".install-") ? "2.0.11" : undefined }),
|
||||
}
|
||||
await assert.rejects(installManagedOpenCode("2.0.11", options), /verification failed/)
|
||||
assert.equal(readManagedExecutable(root), undefined)
|
||||
correct = true
|
||||
const binary = await installManagedOpenCode("2.0.11", options)
|
||||
assert.equal(readManagedExecutable(root), binary)
|
||||
await assert.rejects(installManagedOpenCode("2.0.10", options), /opencode_update_required/)
|
||||
assert.equal(readManagedExecutable(root), binary)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
89
packages/server/src/opencode-update/managed-installation.ts
Normal file
89
packages/server/src/opencode-update/managed-installation.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { execFile } from "node:child_process"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { assertSupportedOpenCode } from "../opencode/runtime-support"
|
||||
import { probeBinaryVersion } from "../workspaces/spawn"
|
||||
|
||||
// Never place changing installations in OpenCode's recursively watched config.
|
||||
export function managedInstallRoot(): string {
|
||||
return path.join(os.homedir(), ".local", "share", "codenomad", "opencode")
|
||||
}
|
||||
|
||||
export function managedExecutable(root: string, version: string): string {
|
||||
return path.join(root, version, "node_modules", "@opencode", "cli", "bin", "opencode.exe")
|
||||
}
|
||||
|
||||
export function readManagedExecutable(root = managedInstallRoot()): string | undefined {
|
||||
try {
|
||||
const version = readFileSync(path.join(root, "current"), "utf8").trim()
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) return undefined
|
||||
const binary = managedExecutable(root, version)
|
||||
return existsSync(binary) ? binary : undefined
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
/** npm from the official Node archive, beside the backend runtime on both hosts. */
|
||||
export function bundledNpm(execPath = process.execPath): string | undefined {
|
||||
const directory = path.dirname(execPath)
|
||||
const candidates = [path.join(directory, "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
path.join(directory, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")]
|
||||
return candidates.find(candidate => existsSync(candidate))
|
||||
}
|
||||
|
||||
export function executeInstaller(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, { env, windowsHide: true, timeout: 300_000, maxBuffer: 1024 * 1024 }, error => {
|
||||
// Do not return npm's environment/config diagnostics to the browser.
|
||||
if (error) reject(new Error(`OpenCode installation failed (${error.killed ? "timeout" : error.code ?? "execution"})`))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function installManagedOpenCode(version: string, options: {
|
||||
root?: string; node?: string; npm?: string
|
||||
execute?: typeof executeInstaller
|
||||
probe?: typeof probeBinaryVersion
|
||||
env?: NodeJS.ProcessEnv
|
||||
} = {}): Promise<string> {
|
||||
assertSupportedOpenCode(version)
|
||||
const root = options.root ?? managedInstallRoot()
|
||||
const node = options.node ?? process.execPath
|
||||
const npm = options.npm ?? bundledNpm(node)
|
||||
if (!npm) throw new Error("npm is unavailable beside the CodeNomad Node runtime")
|
||||
const destination = path.join(root, version)
|
||||
const staging = path.join(root, `.install-${randomUUID()}`)
|
||||
const probe = options.probe ?? probeBinaryVersion
|
||||
await mkdir(root, { recursive: true })
|
||||
try {
|
||||
// Never overwrite the executable used by a running Windows daemon.
|
||||
const existing = probe(managedExecutable(root, version))
|
||||
if (!existing.valid || existing.version !== version) {
|
||||
await mkdir(staging)
|
||||
const env = { ...(options.env ?? process.env) }
|
||||
const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"
|
||||
env[pathKey] = `${path.dirname(node)}${path.delimiter}${env[pathKey] ?? ""}`
|
||||
await (options.execute ?? executeInstaller)(node, [npm, "install", "--prefix", staging,
|
||||
"--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", `@opencode/cli@${version}`], env)
|
||||
const stagedBinary = path.join(staging, "node_modules", "@opencode", "cli", "bin", "opencode.exe")
|
||||
const result = probe(stagedBinary)
|
||||
if (!result.valid || result.version !== version) throw new Error("Installed OpenCode version verification failed")
|
||||
await rename(staging, destination).catch(error => {
|
||||
// A second backend can finish the same exact version first. Accept only
|
||||
// a verified winner; never remove or overwrite an existing executable.
|
||||
const installed = probe(managedExecutable(root, version))
|
||||
if (!installed.valid || installed.version !== version) throw error
|
||||
})
|
||||
}
|
||||
const marker = path.join(root, `.current-${randomUUID()}`)
|
||||
await writeFile(marker, version, "utf8")
|
||||
try { await rename(marker, path.join(root, "current")) }
|
||||
finally { await rm(marker, { force: true }) }
|
||||
return managedExecutable(root, version)
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +1,175 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
OpenCodeUpdateError,
|
||||
OpenCodeUpdateService,
|
||||
TARGET_OPENCODE_CHANNEL,
|
||||
buildOpenCodeUpgradeCommand,
|
||||
compareOpenCodeVersionStrings,
|
||||
detectOpenCodePackageManager,
|
||||
resolveLatestOpenCodeVersion,
|
||||
type OpenCodeUpdateServiceDeps,
|
||||
} from "./service"
|
||||
import { OpenCodeUpdateService, OpenCodeUpdateError, buildOpenCodeUpgradeCommand,
|
||||
resolveLatestOpenCodeVersion, detectOpenCodePackageManager, compareOpenCodeVersionStrings,
|
||||
type OpenCodeUpdateServiceDeps } from "./service"
|
||||
import { MINIMUM_OPENCODE_VERSION as minimum } from "../opencode/runtime-support"
|
||||
import { rememberRuntime } from "../opencode/compatibility/runtime"
|
||||
import type { Endpoint } from "@opencode/client/service"
|
||||
|
||||
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
|
||||
let currentVersion = "0.0.0-beta-19271"
|
||||
const latestVersion = "2.0.10"
|
||||
function deps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
|
||||
let version = "0.0.0-beta-19271"
|
||||
return {
|
||||
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
resolveLatestVersion: async () => latestVersion,
|
||||
resolveBinary: () => ({ path: "opencode2", label: "OpenCode" }),
|
||||
probeBinary: () => ({ valid: true, version }),
|
||||
resolveLatestVersion: async () => minimum,
|
||||
canUpgradeBinary: () => true,
|
||||
upgradeBinary: async (_binary, target) => {
|
||||
currentVersion = latestVersion
|
||||
return { success: true, version: target }
|
||||
},
|
||||
upgradeBinary: async (_binary, target) => { version = target; return { success: true, version: target } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("offers the current stable release to an installation on the old beta package", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps())
|
||||
|
||||
assert.deepEqual(await service.getStatus(), {
|
||||
currentVersion: "0.0.0-beta-19271",
|
||||
latestVersion: "2.0.10",
|
||||
updateAvailable: true,
|
||||
canUpgrade: true,
|
||||
})
|
||||
test("missing installs and old releases use one updater; probe failures are not absence", async () => {
|
||||
for (const initial of [undefined, "0.0.0-beta-19271", "2.0.10"]) {
|
||||
let version = initial
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
probeBinary: () => version ? { valid: true, version } : { valid: false, missing: true },
|
||||
upgradeBinary: async (_binary, target) => { version = target; return { success: true, version: target } },
|
||||
}))
|
||||
const status = await service.getStatus()
|
||||
assert.equal(status.state, initial ? "update_required" : "missing")
|
||||
assert.equal(status.minimumVersion, minimum)
|
||||
assert.equal(status.canUpgrade, true)
|
||||
assert.deepEqual(await service.upgrade(), { success: true, version: minimum })
|
||||
assert.equal((await service.getStatus()).state, "ready")
|
||||
}
|
||||
const invalid = new OpenCodeUpdateService(deps({ probeBinary: () => ({ valid: false, error: "EACCES" }) }))
|
||||
assert.equal((await invalid.getStatus()).state, "error")
|
||||
assert.equal((await invalid.getStatus()).canUpgrade, false)
|
||||
await assert.rejects(invalid.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "binary_unavailable")
|
||||
})
|
||||
|
||||
test("keeps the update visible for a custom binary", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
|
||||
|
||||
assert.deepEqual(await service.getStatus(), {
|
||||
currentVersion: "0.0.0-beta-19271",
|
||||
latestVersion: "2.0.10",
|
||||
updateAvailable: true,
|
||||
canUpgrade: false,
|
||||
})
|
||||
test("offline registry retains local required-update diagnosis", async () => {
|
||||
const service = new OpenCodeUpdateService(deps({ resolveLatestVersion: async () => { throw new Error("offline") } }))
|
||||
const status = await service.getStatus()
|
||||
assert.equal(status.state, "update_required")
|
||||
assert.equal(status.checkError, "update_check_failed")
|
||||
assert.equal(status.canUpgrade, false)
|
||||
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "update_check_failed")
|
||||
})
|
||||
|
||||
test("upgrades the managed OpenCode binary to the advertised version", async () => {
|
||||
const calls: Array<{ path: string; target: string }> = []
|
||||
let currentVersion = "0.0.0-beta-19271"
|
||||
const latestVersion = "2.0.10"
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
resolveLatestVersion: async () => latestVersion,
|
||||
upgradeBinary: async (binary, target) => {
|
||||
calls.push({ path: binary.path, target })
|
||||
currentVersion = latestVersion
|
||||
return { success: true, version: target }
|
||||
},
|
||||
test("custom and WSL executables remain user managed", async () => {
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
resolveBinary: () => ({ path: "\\\\wsl.localhost\\Ubuntu\\usr\\bin\\opencode", label: "WSL" }),
|
||||
canUpgradeBinary: () => false,
|
||||
}))
|
||||
|
||||
assert.deepEqual(await service.upgrade(), { success: true, version: latestVersion })
|
||||
assert.deepEqual(calls, [{ path: "opencode", target: latestVersion }])
|
||||
const status = await service.getStatus()
|
||||
assert.equal(status.target, "wsl")
|
||||
assert.equal(status.canUpgrade, false)
|
||||
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary")
|
||||
})
|
||||
|
||||
test("rejects success when the configured binary was not updated", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: "0.0.0-beta-1" }),
|
||||
test("installation re-resolves the executable and rejects false success", async () => {
|
||||
let installed = false
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
resolveBinary: () => ({ path: installed ? "managed" : "opencode2", label: "OpenCode" }),
|
||||
probeBinary: path => path === "managed" ? { valid: true, version: minimum } : { valid: false, missing: true },
|
||||
upgradeBinary: async (_binary, target) => { installed = true; return { success: true, version: target } },
|
||||
}))
|
||||
assert.equal((await service.upgrade()).version, minimum)
|
||||
const wrong = new OpenCodeUpdateService(deps({
|
||||
probeBinary: () => ({ valid: true, version: "2.0.10" }),
|
||||
upgradeBinary: async (_binary, target) => ({ success: true, version: target }),
|
||||
}))
|
||||
|
||||
await assert.rejects(
|
||||
() => service.upgrade(),
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
|
||||
)
|
||||
await assert.rejects(wrong.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed")
|
||||
})
|
||||
|
||||
test("rejects a different release installed while the update command is running", async () => {
|
||||
let currentVersion = "2.0.9"
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
upgradeBinary: async (_binary, target) => {
|
||||
currentVersion = "2.0.11"
|
||||
return { success: true, version: target }
|
||||
},
|
||||
}))
|
||||
|
||||
await assert.rejects(
|
||||
() => service.upgrade(),
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
|
||||
)
|
||||
})
|
||||
|
||||
test("joins concurrent upgrades for the same binary", async () => {
|
||||
let currentVersion = "2.0.9"
|
||||
const latestVersion = "2.0.10"
|
||||
test("coalesces overlapping installations and never downgrades a newer version", async () => {
|
||||
let upgrades = 0
|
||||
let finishUpgrade: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finishUpgrade = resolve
|
||||
})
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
resolveLatestVersion: async () => latestVersion,
|
||||
upgradeBinary: async (_binary, target) => {
|
||||
upgrades += 1
|
||||
await gate
|
||||
currentVersion = latestVersion
|
||||
return { success: true, version: target }
|
||||
},
|
||||
let version = "2.0.10"
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
probeBinary: () => ({ valid: true, version }),
|
||||
upgradeBinary: async (_binary, target) => { upgrades++; await gate; version = target; return { success: true, version: target } },
|
||||
}))
|
||||
|
||||
const first = service.upgrade()
|
||||
const second = service.upgrade()
|
||||
finishUpgrade?.()
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
{ success: true, version: latestVersion },
|
||||
{ success: true, version: latestVersion },
|
||||
])
|
||||
const first = service.upgrade(), second = service.upgrade()
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
assert.equal(upgrades, 1)
|
||||
version = "2.0.999"
|
||||
assert.equal((await service.getStatus()).updateAvailable, false)
|
||||
assert.equal((await service.upgrade()).version, version)
|
||||
assert.equal(upgrades, 1)
|
||||
})
|
||||
|
||||
test("rejects an upgrade for a custom binary", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
|
||||
|
||||
await assert.rejects(
|
||||
() => service.upgrade(),
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary",
|
||||
)
|
||||
})
|
||||
|
||||
test("reports registry failures as update check failures", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
resolveLatestVersion: async () => {
|
||||
throw new Error("registry unavailable")
|
||||
},
|
||||
test("stale daemon needs explicit restart; status and installation never restart it", async () => {
|
||||
let daemonVersion = "2.0.10", restarts = 0, reconnects = 0
|
||||
const endpoint = () => {
|
||||
const value: Endpoint = { url: "http://127.0.0.1:9876" }
|
||||
rememberRuntime(value, { version: daemonVersion, pid: 123, discovery: "info" })
|
||||
return value
|
||||
}
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
probeBinary: () => ({ valid: true, version: minimum }),
|
||||
lifecycle: async () => ({ discover: async () => endpoint(), ensure: async () => endpoint(),
|
||||
restart: async () => { restarts++; daemonVersion = minimum; return endpoint() } }),
|
||||
reconnect: async () => { reconnects++ },
|
||||
}))
|
||||
|
||||
await assert.rejects(
|
||||
() => service.getStatus(),
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "update_check_failed",
|
||||
)
|
||||
assert.equal((await service.getStatus()).serviceState, "restart_required")
|
||||
assert.equal(restarts, 0)
|
||||
await assert.rejects(service.start(), /opencode_update_required/)
|
||||
assert.equal(restarts, 0)
|
||||
assert.equal(reconnects, 0)
|
||||
assert.equal((await service.start(true)).serviceState, "ready")
|
||||
assert.equal(restarts, 1)
|
||||
assert.equal(reconnects, 1)
|
||||
})
|
||||
|
||||
test("resolves stable latest from the current package even when a newer beta is available", async () => {
|
||||
const version = await resolveLatestOpenCodeVersion(async (url, init) => {
|
||||
test("resolves exact stable package versions and rejects malformed/prerelease tags", async () => {
|
||||
assert.equal(await resolveLatestOpenCodeVersion(async (url) => {
|
||||
assert.equal(url, "https://registry.npmjs.org/-/package/%40opencode%2Fcli/dist-tags")
|
||||
assert.deepEqual(init?.headers, { Accept: "application/json" })
|
||||
return new Response(JSON.stringify({ latest: "2.0.10", beta: "2.1.0-beta.1" }))
|
||||
})
|
||||
|
||||
assert.equal(version, "2.0.10")
|
||||
})
|
||||
|
||||
test("rejects malformed registry dist-tags data", async () => {
|
||||
await assert.rejects(
|
||||
() => resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify({ "dist-tags": { beta: "beta" } }))),
|
||||
/did not resolve to a valid version/,
|
||||
)
|
||||
})
|
||||
|
||||
test("builds official V2 package-manager update commands", () => {
|
||||
assert.equal(TARGET_OPENCODE_CHANNEL, "latest")
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "npm"), {
|
||||
command: "npm",
|
||||
args: ["install", "-g", "@opencode/cli@2.0.10"],
|
||||
})
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "pnpm"), {
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "--allow-build=@opencode/cli", "@opencode/cli@2.0.10"],
|
||||
})
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "bun"), {
|
||||
command: "bun",
|
||||
args: ["install", "-g", "--trust", "@opencode/cli@2.0.10"],
|
||||
})
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("2.0.10", "yarn"), {
|
||||
command: "yarn",
|
||||
args: ["global", "add", "@opencode/cli@2.0.10"],
|
||||
})
|
||||
})
|
||||
|
||||
test("does not downgrade installations newer than the stable release", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: "2.0.11" }),
|
||||
upgradeBinary: async () => { assert.fail("a newer installation must not be overwritten") },
|
||||
}))
|
||||
assert.equal((await service.getStatus()).updateAvailable, false)
|
||||
assert.deepEqual(await service.upgrade(), { success: true, version: "2.0.11" })
|
||||
})
|
||||
|
||||
test("rejects a missing or prerelease latest tag instead of falling back to beta", async () => {
|
||||
for (const tags of [{ beta: "0.0.0-beta-19271" }, { latest: "2.1.0-beta.1" }]) {
|
||||
await assert.rejects(
|
||||
resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify(tags))),
|
||||
/did not resolve to a valid version/,
|
||||
)
|
||||
return new Response(JSON.stringify({ latest: minimum, beta: "2.1.0-beta.1" }))
|
||||
}), minimum)
|
||||
for (const tags of [{ beta: "0.0.0-beta-19271" }, { latest: "2.1.0-beta.1" }, { latest: "2.0.11;whoami" }]) {
|
||||
await assert.rejects(resolveLatestOpenCodeVersion(async () => new Response(JSON.stringify(tags))), /valid version/)
|
||||
}
|
||||
})
|
||||
|
||||
test("detects the package manager from the binary path or launch environment", () => {
|
||||
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
|
||||
assert.equal(detectOpenCodePackageManager("/usr/local/bin/opencode2", { npm_config_user_agent: "yarn/1.22" }), "yarn")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", {}), "npm")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", { npm_config_user_agent: "pnpm/10" }), "npm")
|
||||
assert.equal(detectOpenCodePackageManager("/home/ubuntu/bin/opencode2", {}), "npm")
|
||||
test("activation coalesces repeated clicks and refuses a changed selection before reconnect", async () => {
|
||||
let selected = "original", restarts = 0, reconnects = 0
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
const endpoint = (version: string) => {
|
||||
const value: Endpoint = { url: "http://127.0.0.1:9876" }
|
||||
rememberRuntime(value, { version, pid: 123, discovery: "info" })
|
||||
return value
|
||||
}
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
resolveBinary: () => ({ path: selected, label: selected }),
|
||||
probeBinary: () => ({ valid: true, version: minimum }),
|
||||
lifecycle: async () => ({ discover: async () => endpoint("2.0.10"), ensure: async () => endpoint(minimum),
|
||||
restart: async () => { restarts++; await gate; return endpoint(minimum) } }),
|
||||
reconnect: async () => { reconnects++ },
|
||||
}))
|
||||
const first = service.start(true), second = service.start(true)
|
||||
assert.equal(first, second)
|
||||
selected = "replacement"
|
||||
release()
|
||||
await assert.rejects(first, /selection changed/)
|
||||
assert.equal(restarts, 1)
|
||||
assert.equal(reconnects, 0)
|
||||
})
|
||||
|
||||
test("compares monotonically numbered V2 beta builds numerically", () => {
|
||||
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
|
||||
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-9999", "0.0.0-beta-10000") < 0, true)
|
||||
test("an unsupported latest or newer daemon cannot trigger a downgrade or restart", async () => {
|
||||
const value: Endpoint = { url: "http://127.0.0.1:9876" }
|
||||
rememberRuntime(value, { version: "3.0.0", pid: 123, discovery: "info" })
|
||||
const service = new OpenCodeUpdateService(deps({
|
||||
probeBinary: () => ({ valid: true, version: minimum }),
|
||||
resolveLatestVersion: async () => "3.0.0",
|
||||
lifecycle: async () => ({ discover: async () => value, ensure: async () => value,
|
||||
restart: async () => { assert.fail("must not restart a newer runtime") } }),
|
||||
}))
|
||||
assert.equal((await service.getStatus()).canUpgrade, false)
|
||||
assert.equal((await service.getStatus()).canRestart, false)
|
||||
await assert.rejects(service.upgrade(), /opencode_update_required/)
|
||||
await assert.rejects(service.start(true), /not an older runtime/)
|
||||
})
|
||||
|
||||
test("legacy package-manager helpers retain V2 commands and beta comparison", () => {
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand(minimum, "npm"), { command: "npm", args: ["install", "-g", `@opencode/cli@${minimum}`] })
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand(minimum, "pnpm"), { command: "pnpm", args: ["add", "-g", "--allow-build=@opencode/cli", `@opencode/cli@${minimum}`] })
|
||||
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
|
||||
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,23 +4,31 @@ import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types"
|
|||
import type { SettingsService } from "../settings/service"
|
||||
import { BinaryResolver, type ResolvedBinary } from "../settings/binaries"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import { probeBinaryVersion } from "../workspaces/spawn"
|
||||
import { probeBinaryVersion, probeBinaryVersionAsync } from "../workspaces/spawn"
|
||||
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor"
|
||||
import { legacyOpenCodeRemoval } from "./legacy-package"
|
||||
import { assertSupportedOpenCode, MINIMUM_OPENCODE_VERSION, supportsOpenCodeVersion } from "../opencode/runtime-support"
|
||||
import { runtimeIdentity } from "../opencode/compatibility/runtime"
|
||||
import { installManagedOpenCode } from "./managed-installation"
|
||||
import type { OpenCodeServiceLifecycle } from "../workspaces/opencode-service"
|
||||
import { parseWslUncPath } from "../workspaces/spawn"
|
||||
|
||||
const OPENCODE_PACKAGE_NAME = "@opencode/cli"
|
||||
const OPENCODE_REGISTRY_URL = "https://registry.npmjs.org/-/package/%40opencode%2Fcli/dist-tags"
|
||||
export const TARGET_OPENCODE_CHANNEL = "latest"
|
||||
const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
|
||||
const inFlightActivations = new Map<string, Promise<OpenCodeUpdateStatus>>()
|
||||
|
||||
type UpgradeResult = { success: true; version: string } | { success: false; error: string }
|
||||
|
||||
export interface OpenCodeUpdateServiceDeps {
|
||||
resolveBinary: () => ResolvedBinary
|
||||
probeBinary: typeof probeBinaryVersion
|
||||
probeBinary: (path: string) => ReturnType<typeof probeBinaryVersion> | Promise<ReturnType<typeof probeBinaryVersion>>
|
||||
resolveLatestVersion: () => Promise<string>
|
||||
canUpgradeBinary: (binary: ResolvedBinary) => boolean
|
||||
upgradeBinary: (binary: ResolvedBinary, target: string) => Promise<UpgradeResult>
|
||||
lifecycle?: (binary: ResolvedBinary) => Promise<OpenCodeServiceLifecycle>
|
||||
reconnect?: (binary: ResolvedBinary) => Promise<void>
|
||||
}
|
||||
|
||||
export class OpenCodeUpdateError extends Error {
|
||||
|
|
@ -43,17 +51,70 @@ export class OpenCodeUpdateService {
|
|||
|
||||
async getStatus(): Promise<OpenCodeUpdateStatus> {
|
||||
const binary = this.deps.resolveBinary()
|
||||
const currentVersion = this.readCurrentVersion(binary.path)
|
||||
const latestVersion = await this.readLatestVersion()
|
||||
const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0
|
||||
const canUpgrade = this.deps.canUpgradeBinary(binary)
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
canUpgrade: updateAvailable && canUpgrade,
|
||||
let currentVersion: string | null = null
|
||||
let missing = false
|
||||
let invalid = false
|
||||
const probe = await this.deps.probeBinary(binary.path)
|
||||
try { currentVersion = await this.readCurrentVersion(binary.path, probe) }
|
||||
catch { missing = probe.missing === true; invalid = !missing }
|
||||
let latestVersion: string | null = null
|
||||
try { latestVersion = await this.readLatestVersion() } catch { /* Local admission remains available offline. */ }
|
||||
const state = invalid ? "error" : missing ? "missing"
|
||||
: currentVersion && supportsOpenCodeVersion(currentVersion) ? "ready" : "update_required"
|
||||
const updateAvailable = latestVersion ? !currentVersion || compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 : null
|
||||
const status: OpenCodeUpdateStatus = {
|
||||
currentVersion, latestVersion, updateAvailable,
|
||||
canUpgrade: !invalid && Boolean(updateAvailable) && Boolean(latestVersion && supportsOpenCodeVersion(latestVersion)) && this.deps.canUpgradeBinary(binary),
|
||||
minimumVersion: MINIMUM_OPENCODE_VERSION, state, binaryPath: binary.path,
|
||||
target: parseWslUncPath(binary.path) ? "wsl" : "host", canRestart: false,
|
||||
...(!latestVersion ? { checkError: "update_check_failed" as const } : {}),
|
||||
}
|
||||
if (!missing && !invalid && this.deps.lifecycle) {
|
||||
try {
|
||||
const lifecycle = await this.deps.lifecycle(binary)
|
||||
const endpoint = await lifecycle.discover()
|
||||
const identity = endpoint && runtimeIdentity(endpoint)
|
||||
status.daemonVersion = identity?.version
|
||||
status.serviceState = !endpoint ? "stopped" : identity && supportsOpenCodeVersion(identity.version) ? "ready"
|
||||
: identity && currentVersion && /^(?:0|1|2)\./.test(identity.version)
|
||||
&& compareOpenCodeVersionStrings(currentVersion, identity.version) > 0 ? "restart_required" : "error"
|
||||
status.canRestart = Boolean(lifecycle.restart) && state === "ready" && status.serviceState === "restart_required"
|
||||
} catch { status.serviceState = "error"; status.serviceError = "service_check_failed" }
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
start(restart = false): Promise<OpenCodeUpdateStatus> {
|
||||
const binary = this.deps.resolveBinary()
|
||||
const existing = inFlightActivations.get(binary.path)
|
||||
if (existing) return existing
|
||||
const pending = this.activate(binary, restart).finally(() => {
|
||||
if (inFlightActivations.get(binary.path) === pending) inFlightActivations.delete(binary.path)
|
||||
})
|
||||
inFlightActivations.set(binary.path, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
private async activate(binary: ResolvedBinary, restart: boolean): Promise<OpenCodeUpdateStatus> {
|
||||
const installedVersion = await this.readCurrentVersion(binary.path)
|
||||
assertSupportedOpenCode(installedVersion)
|
||||
const lifecycle = await this.deps.lifecycle?.(binary)
|
||||
if (!lifecycle) throw new Error("OpenCode service lifecycle unavailable")
|
||||
const previous = await lifecycle.discover()
|
||||
if (restart && previous) {
|
||||
const identity = runtimeIdentity(previous)
|
||||
if (!identity || !/^(?:0|1|2)\./.test(identity.version)
|
||||
|| compareOpenCodeVersionStrings(installedVersion, identity.version) <= 0) {
|
||||
throw new Error("The shared daemon is not an older runtime eligible for this update")
|
||||
}
|
||||
}
|
||||
const endpoint = restart && previous ? await lifecycle.restart?.() : previous ?? await lifecycle.ensure()
|
||||
const identity = endpoint && runtimeIdentity(endpoint)
|
||||
if (!identity) throw new Error("OpenCode did not report an authenticated runtime version")
|
||||
assertSupportedOpenCode(identity.version)
|
||||
if (this.deps.resolveBinary().path !== binary.path) throw new Error("OpenCode selection changed during activation")
|
||||
await this.deps.reconnect?.(binary)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
upgrade(): Promise<OpenCodeUpdateResponse> {
|
||||
|
|
@ -69,10 +130,12 @@ export class OpenCodeUpdateService {
|
|||
}
|
||||
|
||||
private async performUpgrade(binary: ResolvedBinary): Promise<OpenCodeUpdateResponse> {
|
||||
const currentVersion = this.readCurrentVersion(binary.path)
|
||||
const probe = await this.deps.probeBinary(binary.path)
|
||||
const currentVersion = probe.missing ? null : await this.readCurrentVersion(binary.path, probe)
|
||||
const latestVersion = await this.readLatestVersion()
|
||||
assertSupportedOpenCode(latestVersion)
|
||||
|
||||
if (compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
|
||||
if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
|
||||
return { success: true, version: currentVersion }
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +151,7 @@ export class OpenCodeUpdateService {
|
|||
if (!result.success) {
|
||||
throw new OpenCodeUpdateError("upgrade_failed", result.error)
|
||||
}
|
||||
const installedVersion = this.readCurrentVersion(binary.path)
|
||||
const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path)
|
||||
if (installedVersion !== latestVersion) {
|
||||
throw new OpenCodeUpdateError(
|
||||
"upgrade_verification_failed",
|
||||
|
|
@ -105,11 +168,11 @@ export class OpenCodeUpdateService {
|
|||
}
|
||||
}
|
||||
|
||||
private readCurrentVersion(binaryPath: string): string {
|
||||
private async readCurrentVersion(binaryPath: string, probe?: ReturnType<typeof probeBinaryVersion>): Promise<string> {
|
||||
if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) {
|
||||
throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid")
|
||||
}
|
||||
const result = this.deps.probeBinary(binaryPath)
|
||||
const result = probe ?? await this.deps.probeBinary(binaryPath)
|
||||
const version = stripTagPrefix(result.version)
|
||||
if (!result.valid || !version) {
|
||||
throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version")
|
||||
|
|
@ -244,9 +307,17 @@ export function createOpenCodeUpdateService(
|
|||
const binary = binaryResolver.resolveDefault()
|
||||
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
|
||||
},
|
||||
probeBinary: probeBinaryVersion,
|
||||
probeBinary: probeBinaryVersionAsync,
|
||||
resolveLatestVersion: resolveLatestOpenCodeVersion,
|
||||
canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2",
|
||||
upgradeBinary: installOpenCodeCli,
|
||||
canUpgradeBinary: () => {
|
||||
const configured = settings.getOwner("config", "server").opencodeBinary
|
||||
return !configured || configured === "opencode" || configured === "opencode2"
|
||||
},
|
||||
upgradeBinary: async (_binary, version) => {
|
||||
await installManagedOpenCode(version)
|
||||
return { success: true, version }
|
||||
},
|
||||
lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle),
|
||||
reconnect: binary => workspaceManager.reconnectAfterSetup(binary.path),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
|
||||
export function normalizeRuntimeEvent(event: OpenCodeEvent): OpenCodeEvent {
|
||||
// Native metadata/durable identity is retained. Catalog/content changes stay
|
||||
// invalidation intents; no synthetic upstream durable records are invented.
|
||||
if ((event.type as string) === "session.permissions.updated") {
|
||||
return { ...event, type: "session.permissions" } as OpenCodeEvent
|
||||
}
|
||||
if (event.type === "session.step.started" && event.data.started === undefined) {
|
||||
// Before 2.0.7 the native reducer used the durable event's creation time.
|
||||
// Keep that meaning for old daemons; preserve precise start times when sent.
|
||||
return { ...event, data: { ...event.data, started: event.created } }
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ test("global Forms and catalog requests preserve explicit identity without forwa
|
|||
assert.throws(() => applyLocationContext(url, "GET", undefined, new Headers(options.headers), "modern"), /Unsupported/)
|
||||
})
|
||||
|
||||
test("real generated list, RPC, eviction and credential calls use their distinct legacy wire locations", async () => {
|
||||
test("retired runtimes cannot forward historical selectors through generated calls", async () => {
|
||||
const endpoint = { url: "http://localhost:1234" }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
|
||||
const location = { directory: "/repo", workspaceID: "wrk_one" }
|
||||
|
|
@ -74,17 +74,17 @@ test("real generated list, RPC, eviction and credential calls use their distinct
|
|||
return url.pathname.startsWith("/api/rpc/") ? Response.json({ output: true }) : new Response(null, { status: 204 })
|
||||
}) })
|
||||
const options = locationRequestOptions(location)
|
||||
await client.session.list({ directory: location.directory }, options)
|
||||
await client.rpc.call({ rpcID: "fixture", method: "check", input: {}, location: { directory: location.directory } }, options)
|
||||
await client.debug.location.evict({ location: { directory: location.directory } }, options)
|
||||
await client.credential.remove({ credentialID: "credential" }, options)
|
||||
assert.equal(calls.length, 4)
|
||||
await assert.rejects(client.session.list({ directory: location.directory }, options))
|
||||
await assert.rejects(client.rpc.call({ rpcID: "fixture", method: "check", input: {}, location: { directory: location.directory } }, options))
|
||||
await assert.rejects(client.debug.location.evict({ location: { directory: location.directory } }, options))
|
||||
await assert.rejects(client.credential.remove({ credentialID: "credential" }, options))
|
||||
assert.equal(calls.length, 0)
|
||||
await assert.rejects(client.session.list({ cursor: "native-cursor" }, options))
|
||||
assert.equal(calls.length, 4, "conflicting cursor context never dispatches")
|
||||
assert.equal(calls.length, 0, "conflicting cursor context never dispatches")
|
||||
})
|
||||
|
||||
test("directory-only credential context is native-scoped on legacy and global on modern", async () => {
|
||||
for (const version of ["2.0.3", "2.0.4"]) {
|
||||
test("directory-only credential context remains global on the supported runtime", async () => {
|
||||
for (const version of ["2.0.11"]) {
|
||||
const endpoint = { url: "http://localhost:1234" }
|
||||
rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => {
|
||||
|
|
@ -109,8 +109,8 @@ test("worktree refresh retains canonical project requests and adapts legacy dire
|
|||
assert.equal(modernUrl.search, "")
|
||||
})
|
||||
|
||||
test("generated worktree methods retain their payload while adapting the legacy location scope", async () => {
|
||||
for (const version of ["2.0.3", "2.0.4"]) {
|
||||
test("generated worktree methods retain canonical project payloads", async () => {
|
||||
for (const version of ["2.0.11"]) {
|
||||
const endpoint = { url: "http://localhost:4321", auth: { type: "basic" as const, username: "fixture", password: "fixture" } }
|
||||
rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" })
|
||||
const requests: Array<{ method: string; url: URL; body: any }> = []
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|||
import { test } from "node:test"
|
||||
import { createRuntimeFetch } from "./transport"
|
||||
import { rememberRuntime } from "./runtime"
|
||||
import { legacyContractFixture } from "./contract-fixture"
|
||||
import { modernContractFixture } from "./contract-fixture"
|
||||
|
||||
test("unknown contracts perform authenticated bounded read negotiation before any mutation", async () => {
|
||||
for (const kind of ["unauthorized", "unrecognized", "oversized"] as const) {
|
||||
|
|
@ -30,7 +30,7 @@ test("unknown contracts perform authenticated bounded read negotiation before an
|
|||
|
||||
test("shared negotiation has independent subscriber cancellation and never retries a mutation", async () => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" })
|
||||
rememberRuntime(endpoint, { version: "2.0.100", pid: 1, discovery: "info" })
|
||||
let complete!: (response: Response) => void
|
||||
let negotiationSignal: AbortSignal | null | undefined
|
||||
const calls: string[] = []
|
||||
|
|
@ -41,7 +41,7 @@ test("shared negotiation has independent subscriber cancellation and never retri
|
|||
negotiationSignal = init?.signal
|
||||
return new Promise<Response>(resolve => { complete = resolve })
|
||||
}
|
||||
assert.equal(pathname, "/api/session/s/rename")
|
||||
assert.equal(pathname, "/api/session/s")
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
const first = new AbortController(), third = new AbortController()
|
||||
|
|
@ -53,7 +53,7 @@ test("shared negotiation has independent subscriber cancellation and never retri
|
|||
await assert.rejects(a, { name: "AbortError" })
|
||||
await assert.rejects(c, { name: "AbortError" })
|
||||
assert.equal(negotiationSignal?.aborted, false)
|
||||
complete(Response.json(legacyContractFixture))
|
||||
complete(Response.json(modernContractFixture))
|
||||
assert.equal((await b).status, 204)
|
||||
assert.deepEqual(calls, ["/openapi.json", "/api/session/s/rename"])
|
||||
assert.deepEqual(calls, ["/openapi.json", "/api/session/s"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
// Only translate consumed, reviewed operations. The external proxy allowlist is
|
||||
// checked before reaching this module; translation never expands that allowlist.
|
||||
export function legacyRequest(url: URL, method: string, body: Record<string, unknown> | undefined) {
|
||||
const original = url.pathname
|
||||
if (original === "/api/status" && method === "GET") url.pathname = "/api/health"
|
||||
if (/^\/api\/experimental\/session\/(?:stats|import|[^/]+\/(?:export|wait|instructions\/entries(?:\/[^/]+)?))$/.test(original)
|
||||
|| /^\/api\/experimental\/mcp\/[^/]+\/(?:connect|disconnect)$/.test(original)) {
|
||||
url.pathname = original.replace("/experimental", "")
|
||||
}
|
||||
if (original === "/api/form" && method === "GET") url.pathname = "/api/form/request"
|
||||
if (/^\/api\/session\/[^/]+$/.test(original) && method === "PATCH") {
|
||||
if (!body || typeof body.title !== "string" || Object.keys(body).some(key => key !== "title")) {
|
||||
throw new Error("Earlier OpenCode V2 supports title-only session updates")
|
||||
}
|
||||
url.pathname += "/rename"
|
||||
method = "POST"
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/fork$/.test(original) && method === "POST") {
|
||||
if (body?.before !== undefined && typeof body.before !== "string") throw new Error("Invalid fork boundary")
|
||||
body = { boundary: body?.before === undefined ? { type: "through" } : { type: "before", messageID: body.before } }
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/command$/.test(original) && method === "POST" && body) {
|
||||
const { name, ...rest } = body
|
||||
body = { ...rest, command: name }
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(original) && method === "POST" && body) {
|
||||
const { decision, ...rest } = body
|
||||
body = { ...rest, reply: decision }
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/interrupt$/.test(original) && method === "POST" && url.searchParams.has("resume")) {
|
||||
url.searchParams.set("continue", url.searchParams.get("resume")!)
|
||||
url.searchParams.delete("resume")
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/inbox\/[^/]+$/.test(original) && method === "PATCH") {
|
||||
if (body?.delivery !== "steer" && body?.delivery !== "queue") throw new Error("Invalid inbox delivery")
|
||||
url.pathname += `/${body.delivery}`
|
||||
method = "POST"
|
||||
body = undefined
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/revert$/.test(original) && method === "DELETE") {
|
||||
url.pathname += "/clear"
|
||||
method = "POST"
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+$/.test(original) && method === "DELETE") {
|
||||
url.pathname += "/cancel"
|
||||
method = "POST"
|
||||
}
|
||||
return { url, method, body }
|
||||
}
|
||||
|
|
@ -22,12 +22,6 @@ export function contractProfile(identity: RuntimeIdentity | undefined): Contract
|
|||
// Custom embedded/test lifecycles that don't negotiate use the pinned contract.
|
||||
if (!identity) return "modern"
|
||||
if (identity.contract?.profile) return identity.contract.profile
|
||||
if (/^2\.0\.[0-3]$/.test(identity.version)) return "legacy"
|
||||
if (/^2\.0\.[45]$/.test(identity.version)) return "modern"
|
||||
const beta = /^0\.0\.0-beta-(\d+)$/.exec(identity.version)?.[1]
|
||||
const audited = new Set([18866, 18955, 18965, 18985, 18992, 18999, 19059, 19086,
|
||||
19124, 19129, 19133, 19135, 19151, 19157, 19187, 19192, 19215, 19213,
|
||||
19228, 19234, 19242, 19266, 19271, 19275, 19278, 19283, 19288, 19289,
|
||||
19296, 19365, 19378, 19381, 19398, 19419, 19422, 19425, 19500, 19507])
|
||||
return beta && audited.has(Number(beta)) ? "legacy" : "unknown"
|
||||
if (identity.version === "2.0.11") return "modern"
|
||||
return "unknown"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,121 +1,73 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { createServer } from "node:http"
|
||||
import { once } from "node:events"
|
||||
import { test } from "node:test"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import type { Endpoint } from "@opencode/client/service"
|
||||
import { rememberRuntime } from "./runtime"
|
||||
import { createRuntimeFetch } from "./transport"
|
||||
import { modernContractFixture } from "./contract-fixture"
|
||||
|
||||
test("the pinned info client follows the discovered route across legacy, modern and info runtimes", async () => {
|
||||
for (const [version, discovery, route] of [
|
||||
["2.0.0", "health", "/api/health"],
|
||||
["2.0.4", "status", "/api/status"],
|
||||
["2.0.7", "info", "/api/info"],
|
||||
["2.0.11", "info", "/api/info"],
|
||||
["future-release", "info", "/api/info"],
|
||||
] as const) {
|
||||
const endpoint: Endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "opencode", password: "fixture" } }
|
||||
rememberRuntime(endpoint, { version, pid: 123, discovery })
|
||||
test("current and future supported releases preserve info, authentication and bounded negotiation", async () => {
|
||||
for (const version of ["2.0.11", "2.0.100"]) {
|
||||
const endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "opencode", password: "fixture" } }
|
||||
rememberRuntime(endpoint, { version, pid: 123, discovery: "info" })
|
||||
const seen: string[] = []
|
||||
const fetch = createRuntimeFetch(endpoint, async (input, init) => {
|
||||
const path = new URL(String(input)).pathname
|
||||
seen.push(path)
|
||||
assert.equal(init?.redirect, "error")
|
||||
assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:fixture").toString("base64")}`)
|
||||
if (path === "/openapi.json") return Response.json(modernContractFixture)
|
||||
assert.equal(path, route)
|
||||
return Response.json(discovery === "health" ? { healthy: true, version, pid: 123 }
|
||||
: { version, pid: 123, urls: [endpoint.url], ...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
|
||||
return Response.json(path === "/openapi.json" ? modernContractFixture : { version, pid: 123, urls: [endpoint.url], paths: { tmp: "/native/tmp" } })
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
|
||||
assert.deepEqual(await client.server.info(), { version, pid: 123, urls: [endpoint.url],
|
||||
...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
|
||||
assert.deepEqual(seen, discovery === "info" ? ["/openapi.json", route] : [route])
|
||||
assert.equal((await client.server.info()).paths.tmp, "/native/tmp")
|
||||
assert.deepEqual(seen, version === "2.0.11" ? ["/api/info"] : ["/openapi.json", "/api/info"])
|
||||
}
|
||||
})
|
||||
|
||||
test("info translation is GET-only and does not probe alternate routes on failure", async () => {
|
||||
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.7", pid: 123, discovery: "info" })
|
||||
const seen: string[] = []
|
||||
const fetch = createRuntimeFetch(endpoint, async (input, init) => {
|
||||
const path = new URL(String(input)).pathname
|
||||
seen.push(`${init?.method ?? "GET"} ${path}`)
|
||||
return path === "/openapi.json" ? Response.json(modernContractFixture) : new Response(null, { status: 404 })
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
|
||||
await assert.rejects(client.server.info())
|
||||
await fetch(`${endpoint.url}/api/info`, { method: "POST" })
|
||||
await fetch(`${endpoint.url}/api/status`, { method: "POST" })
|
||||
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/info", "POST /api/status"])
|
||||
test("retired runtime contracts never dispatch even through the direct transport", async () => {
|
||||
for (const version of ["2.0.0", "2.0.3", "2.0.4", "2.0.10", "0.0.0-beta-19271", "3.0.0"]) {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version, pid: 1, discovery: "health" })
|
||||
const fetch = createRuntimeFetch(endpoint, async () => { assert.fail("unsupported runtime must not receive calls") })
|
||||
await assert.rejects(fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' }), /opencode_update_required/)
|
||||
}
|
||||
})
|
||||
|
||||
test("the pinned client uses the earlier wire contract and retains admission/list timestamps", async () => {
|
||||
const seen: Array<{ method: string; path: string; body: unknown }> = []
|
||||
const item = { id: "inbox-fixture", sessionID: "s", type: "user", payload: { text: "fixture" }, delivery: "queue", timeCreated: 123 }
|
||||
const server = createServer(async (req, res) => {
|
||||
let raw = ""
|
||||
for await (const data of req) raw += data
|
||||
assert.equal(req.headers.authorization, `Basic ${Buffer.from("opencode:fixture").toString("base64")}`)
|
||||
seen.push({ method: req.method!, path: req.url!, body: raw ? JSON.parse(raw) : undefined })
|
||||
res.setHeader("content-type", "application/json")
|
||||
if (req.url === "/api/session/s/prompt") return res.end(JSON.stringify({ data: item }))
|
||||
if (req.url === "/api/session/s/inbox") return res.end(JSON.stringify({ data: [item] }))
|
||||
if (req.url === "/api/session/s/fork") return res.end(JSON.stringify({ data: { id: "fork" } }))
|
||||
if (req.url?.startsWith("/api/session/s/interrupt")) return res.end("{}")
|
||||
res.writeHead(204).end()
|
||||
})
|
||||
server.listen(0, "127.0.0.1")
|
||||
await once(server, "listening")
|
||||
const endpoint: Endpoint = { url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint) })
|
||||
try {
|
||||
assert.equal((await client.session.prompt({ sessionID: "s", text: "fixture" })).time.created, 123)
|
||||
assert.equal((await client.session.inbox.list({ sessionID: "s" }))[0].time.created, 123)
|
||||
await client.permission.reply({ sessionID: "s", requestID: "p", decision: "once" })
|
||||
await client.session.command({ sessionID: "s", name: "test", text: "args", delivery: "queue" })
|
||||
await client.session.fork({ sessionID: "s", before: "m" })
|
||||
await client.session.interrupt({ sessionID: "s", resume: true })
|
||||
await client.session.inbox.update({ sessionID: "s", inboxID: "i", delivery: "steer" })
|
||||
await client.session.form.cancel({ sessionID: "s", formID: "f" })
|
||||
await client.session.instructions.entry.remove({ sessionID: "s", key: "voice" })
|
||||
assert.deepEqual(seen.slice(2), [
|
||||
{ method: "POST", path: "/api/session/s/permission/p/reply", body: { reply: "once" } },
|
||||
{ method: "POST", path: "/api/session/s/command", body: { command: "test", text: "args", delivery: "queue" } },
|
||||
{ method: "POST", path: "/api/session/s/fork", body: { boundary: { type: "before", messageID: "m" } } },
|
||||
{ method: "POST", path: "/api/session/s/interrupt?continue=true", body: undefined },
|
||||
{ method: "POST", path: "/api/session/s/inbox/i/steer", body: undefined },
|
||||
{ method: "POST", path: "/api/session/s/form/f/cancel", body: undefined },
|
||||
{ method: "DELETE", path: "/api/session/s/instructions/entries/voice", body: undefined },
|
||||
])
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
test("canonical mutations and inbox timestamps are forwarded unchanged", async () => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
|
||||
const calls: Array<{ path: string; method: string; body: unknown }> = []
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
calls.push({ path: new URL(request.url).pathname, method: request.method, body: request.body ? await request.json() : undefined })
|
||||
if (request.url.endsWith("/prompt")) return Response.json({ data: { time: { created: 123 } } })
|
||||
return new Response(null, { status: 204 })
|
||||
}) })
|
||||
assert.equal((await client.session.prompt({ sessionID: "s", text: "fixture" })).time.created, 123)
|
||||
await client.permission.reply({ sessionID: "s", requestID: "p", decision: "once" })
|
||||
await client.session.command({ sessionID: "s", name: "test", text: "args", delivery: "queue" })
|
||||
await client.session.form.cancel({ sessionID: "s", formID: "f" })
|
||||
assert.deepEqual(calls.slice(1), [
|
||||
{ method: "POST", path: "/api/session/s/permission/p/reply", body: { decision: "once" } },
|
||||
{ method: "POST", path: "/api/session/s/command", body: { name: "test", text: "args", delivery: "queue" } },
|
||||
{ method: "DELETE", path: "/api/session/s/form/f", body: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
test("errors never retry a mutation, unsupported fields are rejected, and abort/auth are preserved", async () => {
|
||||
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 2, discovery: "health" })
|
||||
test("errors never retry mutations; native error envelopes, abort and origin fencing survive", async () => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.11", pid: 2, discovery: "info" })
|
||||
let calls = 0
|
||||
for (const status of [400, 401, 404, 500]) {
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status }) }) })
|
||||
await assert.rejects(client.session.update({ sessionID: "s", title: "title" }))
|
||||
const envelope = { _tag: "NativeError", message: "fixture" }
|
||||
const fetch = createRuntimeFetch(endpoint, async () => { calls++; return Response.json(envelope, { status }) })
|
||||
const response = await fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", body: '{"title":"fixture"}' })
|
||||
assert.equal(response.status, status)
|
||||
assert.deepEqual(await response.json(), envelope)
|
||||
}
|
||||
assert.equal(calls, 4)
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => { calls++; return new Response(null, { status: 204 }) }) })
|
||||
await assert.rejects(client.session.update({ sessionID: "s", title: "title", permissions: [] }), (error: Error) => {
|
||||
assert.match(String((error as Error & { cause?: unknown }).cause), /title-only/)
|
||||
return true
|
||||
})
|
||||
assert.equal(calls, 4)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const transport = createRuntimeFetch(endpoint, async (_url, init) => {
|
||||
assert.equal(init?.redirect, "error")
|
||||
init?.signal?.throwIfAborted()
|
||||
throw new Error("unexpected live request")
|
||||
})
|
||||
await assert.rejects(transport(new Request(`${endpoint.url}/api/session/s/wait`, { method: "POST", signal: controller.signal })), { name: "AbortError" })
|
||||
await assert.rejects(transport("http://127.0.0.1:4322/api/session"), /origin mismatch/)
|
||||
const fetch = createRuntimeFetch(endpoint, async () => { assert.fail("must not dispatch") })
|
||||
await assert.rejects(fetch(`${endpoint.url}/api/session/s`, { method: "PATCH", signal: controller.signal }), { name: "AbortError" })
|
||||
await assert.rejects(fetch("http://127.0.0.1:4322/api/session"), /origin mismatch/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Service, type Endpoint } from "@opencode/client/service"
|
||||
import { contractProfile, runtimeIdentity } from "./runtime"
|
||||
import { legacyRequest } from "./requests"
|
||||
import { assertSupportedOpenCode } from "../runtime-support"
|
||||
import { negotiateRuntime } from "./negotiate"
|
||||
import { applyLocationContext, LOCATION_CONTEXT_HEADER } from "./location"
|
||||
|
||||
|
|
@ -8,16 +8,6 @@ function object(value: unknown): value is Record<string, unknown> {
|
|||
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeInbox(value: unknown): unknown {
|
||||
if (!object(value)) throw new Error("Invalid native inbox item")
|
||||
if (object(value.time) && typeof value.time.created === "number") return value
|
||||
if (typeof value.timeCreated !== "number" || !Number.isFinite(value.timeCreated)) {
|
||||
throw new Error("Invalid native inbox creation time")
|
||||
}
|
||||
const { timeCreated, ...rest } = value
|
||||
return { ...rest, time: { created: timeCreated } }
|
||||
}
|
||||
|
||||
// Both the server's generated client and the authorized browser proxy use this
|
||||
// transport. No error classification here retries a mutation.
|
||||
export function createRuntimeFetch(endpoint: Endpoint, fetcher: typeof fetch = globalThis.fetch): typeof fetch {
|
||||
|
|
@ -31,8 +21,11 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
|
|||
const resolveProfile = async (signal?: AbortSignal) => {
|
||||
signal?.throwIfAborted()
|
||||
lifetime.throwIfAborted()
|
||||
if (identity) assertSupportedOpenCode(identity.version)
|
||||
if (profile === "legacy") throw new Error("Unsupported OpenCode runtime contract")
|
||||
if (profile !== "unknown") return profile
|
||||
negotiation ??= negotiateRuntime(endpoint, fetcher, lifetime).then(value => {
|
||||
if (value !== "modern") throw new Error("Unsupported OpenCode runtime contract")
|
||||
profile = value
|
||||
return value
|
||||
}).catch(error => { negotiation = undefined; throw error })
|
||||
|
|
@ -52,17 +45,15 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
|
|||
if (serverInfo) {
|
||||
// Discovery already selected one authenticated route. The current client
|
||||
// uses info; an older renderer may still request status. Never probe here.
|
||||
const discovery = identity?.discovery ?? (profile === "legacy" ? "health" : "info")
|
||||
const discovery = identity?.discovery ?? "info"
|
||||
url.pathname = `/api/${discovery}`
|
||||
}
|
||||
if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) {
|
||||
if (headers.has(LOCATION_CONTEXT_HEADER)) {
|
||||
const text = request.body ? await request.text() : undefined
|
||||
let body: unknown = text ? JSON.parse(text) : undefined
|
||||
body = applyLocationContext(url, request.method, body, headers, profile)
|
||||
if (body !== undefined && !object(body)) throw new Error("Invalid OpenCode request body")
|
||||
const translated = profile === "legacy" ? legacyRequest(url, request.method, body) : { method: request.method, body }
|
||||
options.method = translated.method
|
||||
options.body = translated.body === undefined ? undefined : JSON.stringify(translated.body)
|
||||
options.body = body === undefined ? undefined : JSON.stringify(body)
|
||||
headers.delete("content-length")
|
||||
if (options.body === undefined) headers.delete("content-type")
|
||||
} else if (request.body) {
|
||||
|
|
@ -76,21 +67,7 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
|
|||
status: response.status, profile, method: request.method, path: originalPath,
|
||||
}, { status: response.status })
|
||||
}
|
||||
if (serverInfo && response.ok && url.pathname === "/api/health") {
|
||||
const health = await response.json() as { version: string; pid: number }
|
||||
// Older services do not expose paths.tmp. Do not invent a filesystem path;
|
||||
// our service consumers use only the authenticated version/PID/endpoint.
|
||||
return Response.json({ version: health.version, pid: health.pid, urls: [endpoint.url] })
|
||||
}
|
||||
if (profile !== "legacy" || !response.ok || !/^\/api\/session\/[^/]+\/(?:inbox|prompt|synthetic|compact)$/.test(originalPath)
|
||||
|| response.status === 204) return response
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value)) throw new Error("Invalid native inbox response")
|
||||
const normalized = { ...value, data: Array.isArray(value.data) ? value.data.map(normalizeInbox) : normalizeInbox(value.data) }
|
||||
const responseHeaders = new Headers(response.headers)
|
||||
responseHeaders.delete("content-length")
|
||||
responseHeaders.delete("content-encoding")
|
||||
return new Response(JSON.stringify(normalized), { status: response.status, headers: responseHeaders })
|
||||
return response
|
||||
}
|
||||
return { fetch: adaptedFetch, profile: resolveProfile }
|
||||
}
|
||||
|
|
|
|||
26
packages/server/src/opencode/runtime-support.test.ts
Normal file
26
packages/server/src/opencode/runtime-support.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import type { Endpoint } from "@opencode/client/service"
|
||||
import { supportsOpenCodeVersion, UnsupportedOpenCodeError } from "./runtime-support"
|
||||
import { rememberRuntime } from "./compatibility/runtime"
|
||||
import { OpenCodeSharedService } from "../workspaces/opencode-service"
|
||||
|
||||
test("fixed release minimum is independent of publication and contract recognition", () => {
|
||||
for (const version of ["2.0.11", "2.0.12", "2.1.0"]) assert.equal(supportsOpenCodeVersion(version), true)
|
||||
for (const version of ["2.0.10", "2.0.0", "0.0.0-beta-19507", "2.0.11-dev.1", "3.0.0", "garbage"]) assert.equal(supportsOpenCodeVersion(version), false)
|
||||
})
|
||||
|
||||
test("refuses old authenticated daemons before client calls or plugin preparation", async () => {
|
||||
let constructed = 0, prepared = 0
|
||||
const endpoint: Endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.10", pid: 1, discovery: "info" })
|
||||
const service = new OpenCodeSharedService({ headers: () => ({ authorization: "fixture" }), makeClient: () => { constructed++; throw new Error("not reached") } })
|
||||
const options = { kind: "lifecycle" as const, identity: "fixture",
|
||||
lifecycle: { discover: async () => endpoint, ensure: async () => { throw new Error("must not start") } },
|
||||
prepareDesktopPlugins: async () => { prepared++; return true },
|
||||
}
|
||||
await assert.rejects(service.client(options), (error: unknown) => error instanceof UnsupportedOpenCodeError
|
||||
&& error.actualVersion === "2.0.10" && error.minimumVersion === "2.0.11")
|
||||
assert.equal(constructed, 0)
|
||||
assert.equal(prepared, 0)
|
||||
})
|
||||
25
packages/server/src/opencode/runtime-support.ts
Normal file
25
packages/server/src/opencode/runtime-support.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** Fixed for this CodeNomad release; never derive admission from npm at runtime. */
|
||||
export const MINIMUM_OPENCODE_VERSION = "2.0.11"
|
||||
export const OPENCODE_UPDATE_REQUIRED = "opencode_update_required"
|
||||
|
||||
export function supportsOpenCodeVersion(version: string): boolean {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
|
||||
if (!match) return false
|
||||
const [major, minor, patch] = match.slice(1).map(Number)
|
||||
const [minimumMajor, minimumMinor, minimumPatch] = MINIMUM_OPENCODE_VERSION.split(".").map(Number)
|
||||
return major === minimumMajor && (minor! > minimumMinor! || (minor === minimumMinor && patch! >= minimumPatch!))
|
||||
}
|
||||
|
||||
export class UnsupportedOpenCodeError extends Error {
|
||||
readonly code = OPENCODE_UPDATE_REQUIRED
|
||||
readonly statusCode = 426
|
||||
readonly minimumVersion = MINIMUM_OPENCODE_VERSION
|
||||
constructor(readonly actualVersion: string) {
|
||||
super(`${OPENCODE_UPDATE_REQUIRED}: OpenCode ${actualVersion}; requires stable OpenCode >= ${MINIMUM_OPENCODE_VERSION} and < 3.0.0`)
|
||||
this.name = "UnsupportedOpenCodeError"
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSupportedOpenCode(version: string): void {
|
||||
if (!supportsOpenCodeVersion(version)) throw new UnsupportedOpenCodeError(version)
|
||||
}
|
||||
|
|
@ -11,15 +11,15 @@ import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuat
|
|||
import { OpenCodeSharedService } from "../../workspaces/opencode-service"
|
||||
import { legacyContractFixture } from "../../opencode/compatibility/contract-fixture"
|
||||
|
||||
test("legacy authority precedes translation for Forms, session writes, cursors and imported history", async () => {
|
||||
test("modern proxy preserves Forms scope and rejects obsolete selectors in cursors and history", async () => {
|
||||
const upstream = Fastify()
|
||||
let forwarded = 0
|
||||
upstream.all("/*", async request => { forwarded++; return { method: request.method, url: request.url, body: request.body, headers: request.headers } })
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint: Endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}`, auth: { type: "basic", username: "opencode", password: "fixture" } }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
|
||||
const client = { session: { get: async ({ sessionID }: { sessionID: string }) => ({ location: { directory: "/native", workspaceID: sessionID === "foreign" ? "two" : "one" } }) } } as unknown as OpenCodeClient
|
||||
const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent() {}, invalidate() {}, profile: async () => "legacy" as const }
|
||||
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
|
||||
const client = { session: { get: async ({ sessionID }: { sessionID: string }) => ({ location: { directory: sessionID === "foreign" ? "/foreign" : "/native" } }) } } as unknown as OpenCodeClient
|
||||
const connection = { endpoint, client, fetch: createRuntimeFetch(endpoint), assertCurrent() {}, invalidate() {}, profile: async () => "modern" as const }
|
||||
const directories = new Set(["/host", "/native"])
|
||||
const manager: InstanceProxyWorkspaceManager = {
|
||||
get: () => ({ id: "w", path: "/host" }) as never,
|
||||
|
|
@ -42,21 +42,21 @@ test("legacy authority precedes translation for Forms, session writes, cursors a
|
|||
const prefix = "/workspaces/w/instance"
|
||||
const context = (workspaceID: string) => locationRequestOptions({ directory: "/host", workspaceID })!.headers
|
||||
try {
|
||||
const form = await app.inject({ method: "DELETE", url: `${prefix}/api/session/global/form/f`, headers: { ...context("one"), "x-opencode-directory": encodeURIComponent("/host"), cookie: "browser=secret" } })
|
||||
const form = await app.inject({ method: "DELETE", url: `${prefix}/api/session/global/form/f`, headers: { "x-opencode-directory": encodeURIComponent("/host"), cookie: "browser=secret" } })
|
||||
assert.equal(form.statusCode, 200, form.body)
|
||||
assert.equal(form.json().url, "/api/session/global/form/f/cancel")
|
||||
assert.equal(form.json().method, "POST")
|
||||
assert.equal(form.json().url, "/api/session/global/form/f")
|
||||
assert.equal(form.json().method, "DELETE")
|
||||
assert.equal(form.json().headers["x-opencode-directory"], encodeURIComponent("/native"))
|
||||
assert.equal(form.json().headers["x-opencode-workspace"], "one")
|
||||
assert.equal(form.json().headers["x-opencode-workspace"], undefined)
|
||||
assert.equal(form.json().headers[LOCATION_CONTEXT_HEADER], undefined)
|
||||
assert.equal(form.json().headers.cookie, undefined)
|
||||
const create = await app.inject({ method: "POST", url: `${prefix}/api/session`, headers: context("one"), payload: { location: { directory: "/host" } } })
|
||||
const create = await app.inject({ method: "POST", url: `${prefix}/api/session`, payload: { location: { directory: "/host" } } })
|
||||
assert.equal(create.statusCode, 200, create.body)
|
||||
assert.deepEqual(create.json().body.location, { directory: "/native", workspaceID: "one" })
|
||||
const cursor = (workspace: string) => Buffer.from(JSON.stringify({ directory: "/native", workspace, anchor: { id: "s", time: 1, direction: "next" } })).toString("base64url")
|
||||
const listed = await app.inject(`${prefix}/api/session?cursor=${cursor("one")}&workspace=ignored&directory=/foreign`)
|
||||
assert.deepEqual(create.json().body.location, { directory: "/native" })
|
||||
const cursor = (workspace?: string) => Buffer.from(JSON.stringify({ directory: "/native", workspace, anchor: { id: "s", time: 1, direction: "next" } })).toString("base64url")
|
||||
const listed = await app.inject(`${prefix}/api/session?cursor=${cursor()}&directory=/foreign`)
|
||||
assert.equal(listed.statusCode, 200, listed.body)
|
||||
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.get("cursor"), cursor("one"))
|
||||
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.get("cursor"), cursor())
|
||||
assert.equal(new URL(listed.json().url, endpoint.url).searchParams.has("workspace"), false)
|
||||
const before = forwarded
|
||||
for (const request of [
|
||||
|
|
@ -67,12 +67,12 @@ test("legacy authority precedes translation for Forms, session writes, cursors a
|
|||
location: { directory: "/host", workspaceID: "one" }, info: { location: { directory: "/host", workspaceID: "one" } },
|
||||
messages: [{ type: "location-switched", location: { directory: "/host", workspaceID: "two" } }],
|
||||
} },
|
||||
]) assert.equal((await app.inject(request)).statusCode, 403)
|
||||
]) assert.ok([400, 403].includes((await app.inject(request)).statusCode))
|
||||
assert.equal(forwarded, before, "foreign identity is refused before forwarding any mutation")
|
||||
} finally { await app.close(); await upstream.close() }
|
||||
})
|
||||
|
||||
test("the first legacy-context proxy request after reconnect negotiates before authorization", async () => {
|
||||
test("future releases advertising retired schemas are refused before functional calls", async () => {
|
||||
const upstream = Fastify()
|
||||
const calls: string[] = []
|
||||
upstream.get("/openapi.json", async () => { calls.push("schema"); return legacyContractFixture })
|
||||
|
|
@ -83,24 +83,10 @@ test("the first legacy-context proxy request after reconnect negotiates before a
|
|||
})
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
|
||||
rememberRuntime(endpoint, { version: "next-contract", pid: 1, discovery: "health" })
|
||||
rememberRuntime(endpoint, { version: "2.0.100", pid: 1, discovery: "info" })
|
||||
const service = new OpenCodeSharedService()
|
||||
await service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })
|
||||
const app = Fastify()
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: {
|
||||
get: () => ({ path: "/repo" }) as never,
|
||||
getSharedServiceConnection: () => service.acquire(), getSharedServiceEndpoint: async () => endpoint,
|
||||
getSharedServiceClient: () => service.client(), getInstanceAuthorizationHeader: () => undefined,
|
||||
getSessionEnvironment: async () => ({}),
|
||||
getWorktreeIdentityForPath: async () => "root", ownsDirectory: async () => true, ownsPath: async () => false,
|
||||
ownsLocation: async (_id, location) => {
|
||||
assert.deepEqual(calls, ["schema"], "contract must be selected before profile-dependent authorization")
|
||||
return location.directory === "/repo" && (location.workspaceID === undefined || location.workspaceID === "one")
|
||||
},
|
||||
}, worktreeDeletionFence: new WorktreeDeletionFence(), logger: { debug() {}, error() {} } as never })
|
||||
try {
|
||||
const response = await app.inject({ url: "/workspaces/w/instance/api/form?location[directory]=/repo", headers: locationRequestOptions({ directory: "/repo", workspaceID: "one" })!.headers })
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.deepEqual(calls, ["schema", "forms"])
|
||||
} finally { await service.shutdown(); await app.close(); await upstream.close() }
|
||||
await assert.rejects(service.client({ kind: "lifecycle", identity: "isolated-test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } }), /Unsupported OpenCode runtime contract/)
|
||||
assert.deepEqual(calls, ["schema"])
|
||||
} finally { await service.shutdown(); await upstream.close() }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,3 +37,21 @@ test("does not pass request-controlled binary paths to the update service", asyn
|
|||
])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test("service activation accepts only an explicit boolean restart intent", async () => {
|
||||
const calls: boolean[] = []
|
||||
const app = Fastify()
|
||||
registerOpenCodeUpdateRoutes(app, {
|
||||
service: { start: async (restart: boolean) => { calls.push(restart); return { state: "ready" } } } as OpenCodeUpdateService,
|
||||
logger: { warn() {} } as unknown as Logger,
|
||||
})
|
||||
try {
|
||||
for (const payload of [{ restart: "true" }, { restart: true, binary: "foreign" }, { command: "stop" }]) {
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 400)
|
||||
}
|
||||
for (const payload of [{}, { restart: true }]) {
|
||||
assert.equal((await app.inject({ method: "POST", url: "/api/opencode/service", payload })).statusCode, 200)
|
||||
}
|
||||
assert.deepEqual(calls, [false, true])
|
||||
} finally { await app.close() }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FastifyInstance } from "fastify"
|
||||
import type { Logger } from "../../logger"
|
||||
import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service"
|
||||
import { z } from "zod"
|
||||
|
||||
interface RouteDeps {
|
||||
service: OpenCodeUpdateService
|
||||
|
|
@ -19,6 +20,15 @@ function requestError(error: unknown, fallback: string): { status: number; code:
|
|||
}
|
||||
|
||||
export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/opencode/service", async (request, reply) => {
|
||||
const parsed = z.object({ restart: z.boolean().default(false) }).strict().safeParse(request.body ?? {})
|
||||
if (!parsed.success) return reply.code(400).send({ error: "invalid_service_action" })
|
||||
try { return await deps.service.start(parsed.data.restart) }
|
||||
catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to activate OpenCode")
|
||||
return reply.code(502).send({ error: "service_activation_failed" })
|
||||
}
|
||||
})
|
||||
app.get("/api/opencode/update", async (_request, reply) => {
|
||||
try {
|
||||
return await deps.service.getStatus()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
|
|||
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
|
||||
import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { WorkspaceSearchBusyError } from "../../filesystem/search-cache"
|
||||
import { UnsupportedOpenCodeError } from "../../opencode/runtime-support"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
|
|
@ -83,6 +84,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
return result.created ? result.workspace : { ...result.workspace, reused: true as const }
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
if (error instanceof UnsupportedOpenCodeError) return reply.code(error.statusCode).send({
|
||||
error: error.code, message: error.message, actualVersion: error.actualVersion, minimumVersion: error.minimumVersion,
|
||||
})
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
reply.code(400).type("text/plain").send(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SettingsService } from "./service"
|
||||
import { readManagedExecutable } from "../opencode-update/managed-installation"
|
||||
|
||||
export interface OpenCodeBinaryEntry {
|
||||
path: string
|
||||
|
|
@ -42,7 +43,8 @@ export class BinaryResolver {
|
|||
resolveDefault(): ResolvedBinary {
|
||||
const binaries = this.list()
|
||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||
const path = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault
|
||||
const selected = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault
|
||||
const path = selected === "opencode2" ? readManagedExecutable() ?? selected : selected
|
||||
|
||||
const entry = binaries.find((b) => b.path === path)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ import { runtimeIdentity } from "../opencode/compatibility/runtime"
|
|||
const url = "http://127.0.0.1:4321"
|
||||
|
||||
describe("HostOpenCodeService", () => {
|
||||
it("explicit restart stops through CLI and starts through the native-parent bridge", async () => {
|
||||
const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = []
|
||||
let delegated = 0
|
||||
const service = createService(calls, { FIXTURE: "startup" }, {
|
||||
startFile: async (file, args, options) => {
|
||||
delegated++
|
||||
calls.push({ file, args, options })
|
||||
assert.equal(options.env?.FIXTURE, "startup")
|
||||
return { stdout: `${url}\n`, stderr: "" }
|
||||
},
|
||||
fetch: async () => Response.json({ version: "2.0.11", pid: 123, urls: [url] }),
|
||||
})
|
||||
assert.equal(runtimeIdentity(await service.restart())?.version, "2.0.11")
|
||||
assert.equal(delegated, 1)
|
||||
assert.deepEqual(calls.map(call => call.args), [["service", "stop"], ["service", "start"], ["service", "get", "password"]])
|
||||
assert.equal(calls[0].options.env, undefined)
|
||||
})
|
||||
|
||||
it("uses status, start, and password through buildSpawnSpec without a shell", async () => {
|
||||
const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = []
|
||||
const service = createService(calls, { PROVIDER_TOKEN: "secret", NODE_EXTRA_CA_CERTS: "/ca.pem" })
|
||||
|
|
|
|||
|
|
@ -622,27 +622,7 @@ export class WorkspaceManager {
|
|||
const launch = buildServiceLaunchSpec(resolvedBinaryPath, {
|
||||
platform: this.options.platform,
|
||||
})
|
||||
const timeoutMs = Math.max(1, launchDeadlineAt - this.now())
|
||||
const startupEnvironment = launch.kind === "wsl"
|
||||
? await this.wslStartupEnvironment(this.serviceStartupEnvironment(), launch.distro, launchDeadlineAt)
|
||||
: this.serviceStartupEnvironment()
|
||||
const serviceOptions: OpenCodeSharedServiceOptions = {
|
||||
kind: "lifecycle",
|
||||
identity: launch.kind === "host"
|
||||
? hostOpenCodeServiceIdentity({
|
||||
binary: launch.binary,
|
||||
platform: launch.platform,
|
||||
startupEnvironment,
|
||||
})
|
||||
: `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}`
|
||||
+ `:env:${startupEnvironmentHash(startupEnvironment, "linux")}`,
|
||||
lifecycle: launch.kind === "host"
|
||||
? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment)
|
||||
: this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment),
|
||||
prepareDesktopPlugins: this.options.prepareDesktopPlugins
|
||||
? (connection, deadlineAt) => this.options.prepareDesktopPlugins!(launch, connection, deadlineAt)
|
||||
: undefined,
|
||||
}
|
||||
const serviceOptions = await this.setupServiceOptions(resolvedBinaryPath, launchDeadlineAt)
|
||||
state.serviceOptions = serviceOptions
|
||||
this.throwIfCancelled(record)
|
||||
record.wslDistro = launch.kind === "wsl" ? launch.distro : undefined
|
||||
|
|
@ -971,6 +951,41 @@ export class WorkspaceManager {
|
|||
return resolveWslHostDirectory(directory, distro, undefined, timeoutMs)
|
||||
}
|
||||
|
||||
async setupServiceOptions(binary: string, deadlineAt = Date.now() + DEFAULT_LAUNCH_TIMEOUT_MS): Promise<OpenCodeSharedServiceOptions> {
|
||||
const launch = buildServiceLaunchSpec(binary, { platform: this.options.platform })
|
||||
const timeoutMs = Math.max(1, deadlineAt - this.now())
|
||||
const startupEnvironment = launch.kind === "wsl"
|
||||
? await this.wslStartupEnvironment(this.serviceStartupEnvironment(), launch.distro, deadlineAt)
|
||||
: this.serviceStartupEnvironment()
|
||||
return {
|
||||
kind: "lifecycle",
|
||||
identity: launch.kind === "host"
|
||||
? hostOpenCodeServiceIdentity({ binary: launch.binary, platform: launch.platform, startupEnvironment })
|
||||
: `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}:env:${startupEnvironmentHash(startupEnvironment, "linux")}`,
|
||||
lifecycle: launch.kind === "host"
|
||||
? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment)
|
||||
: this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment),
|
||||
prepareDesktopPlugins: this.options.prepareDesktopPlugins
|
||||
? (connection, deadline) => this.options.prepareDesktopPlugins!(launch, connection, deadline) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async reconnectAfterSetup(binary: string): Promise<void> {
|
||||
const launch = buildServiceLaunchSpec(binary, { platform: this.options.platform })
|
||||
for (const record of this.workspaces.values()) {
|
||||
const distro = launch.kind === "wsl" ? launch.distro.toLowerCase() : undefined
|
||||
if (record.wslDistro?.toLowerCase() !== distro) throw new Error("Close workspaces before changing the OpenCode execution host")
|
||||
}
|
||||
const options = await this.setupServiceOptions(binary)
|
||||
// This disposes client authority only; it never stops the shared daemon.
|
||||
await this.sharedService.shutdown()
|
||||
for (const record of this.workspaces.values()) {
|
||||
record[WORKSPACE_STATE].serviceOptions = options
|
||||
record.binaryId = binary
|
||||
}
|
||||
this.serviceAuthorization = (await this.sharedService.headers(options))?.authorization
|
||||
}
|
||||
|
||||
private createWslServiceLifecycle(
|
||||
spec: Extract<ServiceLaunchSpec, { kind: "wsl" }>,
|
||||
timeoutMs: number,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ export class OpenCodeCliService implements OpenCodeServiceLifecycle {
|
|||
return this.endpoint(url, deadlineAt)
|
||||
}
|
||||
|
||||
async restart(deadlineAt = Date.now() + this.timeoutMs): Promise<Endpoint> {
|
||||
// Explicit user action only. Delegate the subsequent starter outside native
|
||||
// backend containment exactly as normal first startup does.
|
||||
await this.run(["service", "stop"], false, deadlineAt)
|
||||
return this.ensure(deadlineAt)
|
||||
}
|
||||
|
||||
private async endpoint(value: string, deadlineAt: number): Promise<Endpoint> {
|
||||
const url = this.assertServiceUrl(value)
|
||||
const password = this.singleLine(
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ test("same URL and credentials cannot keep the previous daemon's adapter or inva
|
|||
let now = 0
|
||||
const makeEndpoint = (version: string, pid: number): Endpoint => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version, pid, discovery: version === "2.0.3" ? "health" : "status" })
|
||||
rememberRuntime(endpoint, { version, pid, discovery: "info" })
|
||||
return endpoint
|
||||
}
|
||||
let endpoint = makeEndpoint("2.0.3", 1)
|
||||
let endpoint = makeEndpoint("2.0.11", 1)
|
||||
const service = new OpenCodeSharedService({ headers: () => undefined, makeClient: () => ({}) as never, now: () => now })
|
||||
await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })
|
||||
const old = await service.acquire()
|
||||
endpoint = makeEndpoint("2.0.4", 2)
|
||||
endpoint = makeEndpoint("2.0.11", 2)
|
||||
now = 30_000
|
||||
const current = await service.acquire()
|
||||
assert.notEqual(current, old)
|
||||
|
|
@ -24,13 +24,17 @@ test("same URL and credentials cannot keep the previous daemon's adapter or inva
|
|||
old.invalidate()
|
||||
current.assertCurrent()
|
||||
assert.equal(await service.acquire(), current)
|
||||
endpoint = makeEndpoint("2.0.10", 3)
|
||||
now = 60_000
|
||||
await assert.rejects(service.acquire(), /opencode_update_required/)
|
||||
assert.throws(current.assertCurrent, /connection changed/)
|
||||
await service.shutdown()
|
||||
assert.throws(current.assertCurrent, /connection changed/)
|
||||
})
|
||||
|
||||
test("invalidation during asynchronous mutation preparation prevents upstream dispatch", async (context) => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
|
||||
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
|
||||
const upstream = context.mock.method(globalThis, "fetch", async () => new Response(null, { status: 204 }))
|
||||
const service = new OpenCodeSharedService()
|
||||
await service.endpoint({ kind: "lifecycle", identity: "test", lifecycle: { discover: async () => endpoint, ensure: async () => endpoint } })
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ describe("OpenCodeSharedService", () => {
|
|||
await new Promise((resolve) => setImmediate(resolve))
|
||||
await service.shutdown()
|
||||
resolveStart(endpoint)
|
||||
assert.equal(await pending, endpoint)
|
||||
await assert.rejects(pending, /connection changed/)
|
||||
assert.equal(await service.endpoint(lifecycleOptions("host:test", lifecycle)), endpoint)
|
||||
assert.equal(discoveries, 2)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Service, type Endpoint } from "@opencode/client/service"
|
|||
import { assertLoopbackServiceUrl } from "./service-state"
|
||||
import { createRuntimeTransport } from "../opencode/compatibility/transport"
|
||||
import { contractProfile, rememberRuntime, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime"
|
||||
import { normalizeRuntimeEvent } from "../opencode/compatibility/events"
|
||||
import { locationRequestOptions } from "../opencode/compatibility/location"
|
||||
import { assertSupportedOpenCode } from "../opencode/runtime-support"
|
||||
|
||||
type RequestOptions = { signal?: AbortSignal; deadlineAt?: number }
|
||||
const CONNECTION_RECHECK_INTERVAL_MS = 30_000
|
||||
|
|
@ -18,6 +18,7 @@ const CONNECTION_RECHECK_INTERVAL_MS = 30_000
|
|||
export interface OpenCodeServiceLifecycle {
|
||||
discover: (deadlineAt?: number) => Promise<Endpoint | undefined>
|
||||
ensure: (deadlineAt?: number) => Promise<Endpoint>
|
||||
restart?: (deadlineAt?: number) => Promise<Endpoint>
|
||||
}
|
||||
|
||||
export type OpenCodeSharedServiceOptions = {
|
||||
|
|
@ -148,6 +149,7 @@ export class OpenCodeSharedService {
|
|||
|
||||
private connect(options?: OpenCodeSharedServiceOptions, deadlineAt?: number): Promise<ServiceConnection> {
|
||||
return this.connectService(options, deadlineAt).then(async connection => {
|
||||
if (await connection.profile() !== "modern") throw new Error("Unsupported OpenCode runtime contract")
|
||||
const prepare = this.serviceOptions?.prepareDesktopPlugins
|
||||
if (!prepare) return connection
|
||||
connection.assertCurrent()
|
||||
|
|
@ -224,6 +226,10 @@ export class OpenCodeSharedService {
|
|||
}
|
||||
|
||||
private createConnection(endpoint: Endpoint, generation: number): ServiceConnection {
|
||||
const runtime = runtimeIdentity(endpoint)
|
||||
// Real CLI lifecycles attach authenticated metadata. Admission precedes
|
||||
// client construction and plugin provisioning for proxy and direct callers.
|
||||
if (runtime) assertSupportedOpenCode(runtime.version)
|
||||
const wildcard = new URL(endpoint.url).hostname === "0.0.0.0"
|
||||
const url = assertLoopbackServiceUrl(endpoint.url)
|
||||
if (wildcard) {
|
||||
|
|
@ -289,7 +295,7 @@ export class OpenCodeSharedService {
|
|||
try {
|
||||
for await (const event of events) {
|
||||
connection.assertCurrent()
|
||||
yield normalizeRuntimeEvent(event)
|
||||
yield event
|
||||
}
|
||||
} finally {
|
||||
this.invalidateConnection(connection)
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export function probeBinaryVersion(
|
|||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
missing?: boolean
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
|
|
@ -210,7 +211,7 @@ export function probeBinaryVersion(
|
|||
|
||||
function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof probeBinaryVersion> {
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
return { valid: false, error: result.error.message, ...((result.error as NodeJS.ErrnoException).code === "ENOENT" ? { missing: true } : {}) }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
|
|
@ -241,6 +242,11 @@ function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof pro
|
|||
return { valid: true, version, reported }
|
||||
}
|
||||
|
||||
export async function probeBinaryVersionAsync(binaryPath: string): Promise<ReturnType<typeof probeBinaryVersion>> {
|
||||
try { return parseBinaryVersion(await executeAsyncBinaryProbe(buildSpawnSpec(binaryPath, ["--version"]), 5_000)) }
|
||||
catch (error) { return { valid: false, error: error instanceof Error ? error.message : String(error) } }
|
||||
}
|
||||
|
||||
export async function probeOpenCodeBinary(
|
||||
binaryPath: string,
|
||||
execute: AsyncBinaryProbeExecutor = executeAsyncBinaryProbe,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { loadedRestorableSession } from "./stores/client-state"
|
|||
import { shouldShowAppHomeOverlay, shouldShowAppRestoreLoading } from "./stores/app-session-restore-gate"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||
import OpenCodeSetup from "./components/opencode-setup"
|
||||
import { openOpenCodeSetup } from "./stores/opencode-setup"
|
||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||
|
|
@ -516,6 +518,10 @@ const App: Component = () => {
|
|||
t("opencodeBinarySelector.validation.v2Required"),
|
||||
)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
if (missingBinary || message.includes("opencode_update_required")) {
|
||||
openOpenCodeSetup(() => handleSelectFolder(folderPath))
|
||||
return false
|
||||
}
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
log.error("Failed to create instance", error)
|
||||
return false
|
||||
|
|
@ -737,6 +743,7 @@ const App: Component = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<OpenCodeSetup />
|
||||
<InstanceDisconnectedModal
|
||||
open={Boolean(disconnectedInstance())}
|
||||
folder={disconnectedInstance()?.folder}
|
||||
|
|
|
|||
54
packages/ui/src/components/opencode-setup.tsx
Normal file
54
packages/ui/src/components/opencode-setup.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createEffect, onCleanup, onMount, Show, untrack } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { OpenCodeUpdateCard } from "./settings/opencode-update-card"
|
||||
import { invalidateOpenCodeSetup, needsOpenCodeSetup, openCodeSetupOpen, openCodeSetupStatus,
|
||||
openOpenCodeSetup, refreshOpenCodeSetup, setOpenCodeSetupOpen } from "../stores/opencode-setup"
|
||||
|
||||
export default function OpenCodeSetup() {
|
||||
const { t } = useI18n()
|
||||
const { serverSettings } = useConfig()
|
||||
createEffect(() => { serverSettings().opencodeBinary; untrack(invalidateOpenCodeSetup) })
|
||||
createEffect(() => {
|
||||
sseManager.getStatuses()
|
||||
untrack(() => { if (!document.hidden) void refreshOpenCodeSetup() })
|
||||
})
|
||||
let announced = ""
|
||||
createEffect(() => {
|
||||
const status = openCodeSetupStatus()
|
||||
if (!status || !needsOpenCodeSetup(status)) { announced = ""; return }
|
||||
const key = `${status.binaryPath}:${status.state}:${status.daemonVersion ?? ""}`
|
||||
if (announced === key) return
|
||||
announced = key
|
||||
setOpenCodeSetupOpen(true)
|
||||
})
|
||||
onMount(() => {
|
||||
const refresh = () => { if (!document.hidden) void refreshOpenCodeSetup() }
|
||||
window.addEventListener("focus", refresh)
|
||||
document.addEventListener("visibilitychange", refresh)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("focus", refresh)
|
||||
document.removeEventListener("visibilitychange", refresh)
|
||||
})
|
||||
})
|
||||
return <>
|
||||
<Show when={needsOpenCodeSetup() && !openCodeSetupOpen()}>
|
||||
<div class="fixed bottom-4 right-4 z-50 border border-base bg-surface-secondary p-3" role="status">
|
||||
<button class="selector-button" onClick={() => openOpenCodeSetup()}>{t("settings.opencode.setup.required")}</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Dialog open={openCodeSetupOpen()} onOpenChange={setOpenCodeSetupOpen}>
|
||||
<Dialog.Portal><Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-2xl max-h-[85vh] overflow-auto">
|
||||
<header class="window-header"><Dialog.Title class="window-title">{t("settings.opencode.update.title")}</Dialog.Title>
|
||||
<Dialog.CloseButton class="window-action" aria-label={t("app.launchError.close")}>{t("app.launchError.close")}</Dialog.CloseButton></header>
|
||||
<div class="window-body"><OpenCodeUpdateCard /></div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
|
|
@ -1,123 +1,49 @@
|
|||
import { createEffect, createResource, createSignal, Show, type Component } from "solid-js"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { onMount, Show, type Component } from "solid-js"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { openSettings } from "../../stores/settings-screen"
|
||||
import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError,
|
||||
refreshOpenCodeSetup, runOpenCodeSetup, setOpenCodeSetupOpen } from "../../stores/opencode-setup"
|
||||
|
||||
export const OpenCodeUpdateCard: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const { serverSettings } = useConfig()
|
||||
const [status, { mutate, refetch }] = createResource(
|
||||
() => serverSettings().opencodeBinary || "opencode2",
|
||||
() => serverApi.fetchOpenCodeUpdateStatus(),
|
||||
)
|
||||
const [updating, setUpdating] = createSignal(false)
|
||||
const [updatedVersion, setUpdatedVersion] = createSignal<string | null>(null)
|
||||
const [updateFailed, setUpdateFailed] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
serverSettings().opencodeBinary
|
||||
setUpdatedVersion(null)
|
||||
setUpdateFailed(false)
|
||||
})
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (updating()) return
|
||||
const binary = serverSettings().opencodeBinary || "opencode2"
|
||||
setUpdating(true)
|
||||
setUpdateFailed(false)
|
||||
try {
|
||||
const result = await serverApi.updateOpenCode()
|
||||
if ((serverSettings().opencodeBinary || "opencode2") !== binary) return
|
||||
setUpdatedVersion(result.version)
|
||||
mutate({
|
||||
currentVersion: result.version,
|
||||
latestVersion: result.version,
|
||||
updateAvailable: false,
|
||||
canUpgrade: false,
|
||||
})
|
||||
} catch {
|
||||
if ((serverSettings().opencodeBinary || "opencode2") === binary) setUpdateFailed(true)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.opencode.update.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.opencode.update.subtitle")}</p>
|
||||
onMount(() => void refreshOpenCodeSetup())
|
||||
return <section class="settings-card" aria-busy={busy()}>
|
||||
<div class="settings-card-header"><div>
|
||||
<h3 class="settings-card-title">{t("settings.opencode.update.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.opencode.setup.description")}</p>
|
||||
</div><span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span></div>
|
||||
<Show when={status()} fallback={<p role="status">{t("settings.opencode.update.checking")}</p>}>
|
||||
{data => <>
|
||||
<p role="status">{t(`settings.opencode.setup.${data().state}`)}</p>
|
||||
<div class="settings-info-grid">
|
||||
<div class="settings-info-row"><span>{t("settings.opencode.update.installed")}</span><span>{data().currentVersion ?? "—"}</span></div>
|
||||
<div class="settings-info-row"><span>{t("settings.opencode.setup.minimum")}</span><span>{data().minimumVersion}</span></div>
|
||||
<div class="settings-info-row"><span>{t("settings.opencode.setup.daemon")}</span><span>{data().daemonVersion ?? "—"}</span></div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<Show when={!status.loading} fallback={<div class="settings-toggle-caption">{t("settings.opencode.update.checking")}</div>}>
|
||||
<Show
|
||||
when={!status.error && status()}
|
||||
fallback={
|
||||
<div>
|
||||
<div class="settings-error-message" role="alert">{t("settings.opencode.update.checkFailed")}</div>
|
||||
<button type="button" class="settings-pill-button" onClick={() => void refetch()}>
|
||||
{t("settings.opencode.update.retry")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(updateStatus) => (
|
||||
<>
|
||||
<div class="settings-info-grid">
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-label">{t("settings.opencode.update.installed")}</span>
|
||||
<span class="settings-info-value">{updateStatus().currentVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={updateStatus().checkError}>
|
||||
<div class="settings-error-message" role="status">{t("settings.opencode.update.checkFailed")}</div>
|
||||
<div class="settings-info-actions">
|
||||
<button type="button" class="settings-pill-button" onClick={() => void refetch()}>
|
||||
{t("settings.opencode.update.retry")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!updateStatus().checkError}>
|
||||
<Show when={updateStatus().updateAvailable} fallback={
|
||||
<div class="settings-toggle-caption" role="status">{t("settings.opencode.update.upToDate")}</div>
|
||||
}>
|
||||
<Show
|
||||
when={updateStatus().canUpgrade}
|
||||
fallback={<div class="settings-toggle-caption">{t("settings.opencode.update.availableUnsupported", { version: updateStatus().latestVersion ?? "" })}</div>}
|
||||
>
|
||||
<div class="settings-info-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-pill-button"
|
||||
disabled={updating()}
|
||||
onClick={() => void handleUpdate()}
|
||||
>
|
||||
{updating()
|
||||
? t("settings.opencode.update.updating")
|
||||
: t("settings.opencode.update.action", { version: updateStatus().latestVersion ?? "" })}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
<p class="settings-toggle-caption break-all">{data().binaryPath}</p>
|
||||
<Show when={data().target === "wsl"}><p>{t("settings.opencode.setup.wsl")}</p></Show>
|
||||
<Show when={data().serviceState === "restart_required"}><p role="status">{t("settings.opencode.setup.restartRequired")}</p></Show>
|
||||
<Show when={data().serviceState === "error"}><p role="alert">{t("settings.opencode.setup.serviceError")}</p></Show>
|
||||
<Show when={data().checkError}><p role="status">{t("settings.opencode.update.checkFailed")}</p></Show>
|
||||
<Show when={!data().canUpgrade && (data().state === "missing" || data().state === "update_required")}>
|
||||
<p>{t("settings.opencode.setup.manual")}</p>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={updatedVersion()}>
|
||||
{(version) => (
|
||||
<div class="settings-info-toast" role="status" aria-live="polite">
|
||||
{t("settings.opencode.update.success", { version: version() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={updateFailed()}>
|
||||
<div class="settings-error-message" role="alert">{t("settings.opencode.update.failed")}</div>
|
||||
</Show>
|
||||
<div class="settings-info-actions">
|
||||
<Show when={data().canUpgrade}><button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("install")}>
|
||||
{busy() ? t("settings.opencode.update.updating") : data().state === "missing" ? t("settings.opencode.setup.install")
|
||||
: t("settings.opencode.update.action", { version: data().latestVersion ?? "" })}
|
||||
</button></Show>
|
||||
<Show when={data().state === "ready" && data().serviceState !== "restart_required"}>
|
||||
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("start")}>{t("settings.opencode.setup.connect")}</button>
|
||||
</Show>
|
||||
<Show when={data().canRestart}><button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void runOpenCodeSetup("restart")}>{t("settings.opencode.setup.restart")}</button></Show>
|
||||
</div>
|
||||
</>}
|
||||
</Show>
|
||||
<Show when={openCodeSetupError()}><p class="settings-error-message" role="alert">{t("settings.opencode.update.failed")}</p></Show>
|
||||
<div class="settings-info-actions">
|
||||
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => void refreshOpenCodeSetup()}>{t("settings.opencode.update.retry")}</button>
|
||||
<button type="button" class="settings-pill-button" disabled={busy()} onClick={() => { setOpenCodeSetupOpen(false); void openSettings("opencode") }}>{t("settings.opencode.setup.chooseBinary")}</button>
|
||||
</div>
|
||||
)
|
||||
</section>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,6 +446,9 @@ export const serverApi = {
|
|||
updateOpenCode(): Promise<OpenCodeUpdateResponse> {
|
||||
return request<OpenCodeUpdateResponse>("/api/opencode/update", { method: "POST" })
|
||||
},
|
||||
startOpenCode(restart = false): Promise<OpenCodeUpdateStatus> {
|
||||
return request<OpenCodeUpdateStatus>("/api/opencode/service", { method: "POST", body: JSON.stringify({ restart }) })
|
||||
},
|
||||
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
|
||||
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "OpenCode auf dem Server Ihrer Projekte installieren, aktualisieren und verbinden.",
|
||||
"settings.opencode.setup.missing": "OpenCode ist nicht installiert.",
|
||||
"settings.opencode.setup.update_required": "Diese OpenCode-Version wird von dieser CodeNomad-Version nicht unterstützt.",
|
||||
"settings.opencode.setup.ready": "Die installierte OpenCode-Version wird unterstützt.",
|
||||
"settings.opencode.setup.error": "Die gewählte Programmdatei konnte nicht überprüft werden.",
|
||||
"settings.opencode.setup.minimum": "Mindestversion",
|
||||
"settings.opencode.setup.daemon": "Laufender Dienst",
|
||||
"settings.opencode.setup.install": "OpenCode installieren und starten",
|
||||
"settings.opencode.setup.connect": "Verbinden und fortfahren",
|
||||
"settings.opencode.setup.restart": "Gemeinsamen Dienst neu starten",
|
||||
"settings.opencode.setup.restartRequired": "Der laufende Dienst muss aktualisiert werden. Ein Neustart unterbricht aktive Arbeit in allen verbundenen OpenCode-Clients.",
|
||||
"settings.opencode.setup.serviceError": "Der gemeinsame Dienst kann nicht überprüft werden. Verbindung und Anmeldung prüfen und erneut versuchen.",
|
||||
"settings.opencode.setup.manual": "Eine unterstützte Programmdatei wählen oder @opencode/cli auf dem Ausführungshost installieren und erneut versuchen.",
|
||||
"settings.opencode.setup.wsl": "Diese Programmdatei läuft in WSL. OpenCode in der gewählten Distribution installieren oder aktualisieren.",
|
||||
"settings.opencode.setup.chooseBinary": "Programmdatei wählen",
|
||||
"settings.opencode.setup.required": "OpenCode-Einrichtung erforderlich",
|
||||
"settings.appearance.palette.mode": "Darstellungsmodus",
|
||||
"settings.appearance.palette.auto": "Auto",
|
||||
"settings.appearance.palette.independent": "Speichere eine Palette pro Erscheinungsbild. Auto folgt dem System und zeigt die passende Palette an.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "Install, update and connect OpenCode on the server running your projects.",
|
||||
"settings.opencode.setup.missing": "OpenCode is not installed.",
|
||||
"settings.opencode.setup.update_required": "This OpenCode version is not supported by this CodeNomad release.",
|
||||
"settings.opencode.setup.ready": "The installed OpenCode version is supported.",
|
||||
"settings.opencode.setup.error": "The selected executable could not be verified.",
|
||||
"settings.opencode.setup.minimum": "Minimum version",
|
||||
"settings.opencode.setup.daemon": "Running service",
|
||||
"settings.opencode.setup.install": "Install and start OpenCode",
|
||||
"settings.opencode.setup.connect": "Connect and continue",
|
||||
"settings.opencode.setup.restart": "Restart shared service",
|
||||
"settings.opencode.setup.restartRequired": "The running service needs updating. Restart interrupts active work in all connected OpenCode clients.",
|
||||
"settings.opencode.setup.serviceError": "Cannot verify the shared service. Check connection and authentication, then retry.",
|
||||
"settings.opencode.setup.manual": "Select a supported executable or install @opencode/cli on the execution host, then retry.",
|
||||
"settings.opencode.setup.wsl": "This executable runs inside WSL. Install or update OpenCode inside the selected distribution.",
|
||||
"settings.opencode.setup.chooseBinary": "Choose executable",
|
||||
"settings.opencode.setup.required": "OpenCode setup required",
|
||||
"settings.appearance.palette.mode": "Appearance mode",
|
||||
"settings.appearance.palette.auto": "Auto",
|
||||
"settings.appearance.palette.independent": "Save a palette for each appearance. Auto follows the system and shows the matching palette.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "Instala, actualiza y conecta OpenCode en el servidor que ejecuta tus proyectos.",
|
||||
"settings.opencode.setup.missing": "OpenCode no está instalado.",
|
||||
"settings.opencode.setup.update_required": "Esta versión de OpenCode no es compatible con esta versión de CodeNomad.",
|
||||
"settings.opencode.setup.ready": "La versión instalada de OpenCode es compatible.",
|
||||
"settings.opencode.setup.error": "No se pudo verificar el ejecutable seleccionado.",
|
||||
"settings.opencode.setup.minimum": "Versión mínima",
|
||||
"settings.opencode.setup.daemon": "Servicio en ejecución",
|
||||
"settings.opencode.setup.install": "Instalar e iniciar OpenCode",
|
||||
"settings.opencode.setup.connect": "Conectar y continuar",
|
||||
"settings.opencode.setup.restart": "Reiniciar servicio compartido",
|
||||
"settings.opencode.setup.restartRequired": "El servicio en ejecución necesita actualizarse. Reiniciarlo interrumpe el trabajo activo de todos los clientes OpenCode conectados.",
|
||||
"settings.opencode.setup.serviceError": "No se puede verificar el servicio compartido. Comprueba la conexión y la autenticación e inténtalo de nuevo.",
|
||||
"settings.opencode.setup.manual": "Selecciona un ejecutable compatible o instala @opencode/cli en el equipo de ejecución y vuelve a intentarlo.",
|
||||
"settings.opencode.setup.wsl": "Este ejecutable se ejecuta en WSL. Instala o actualiza OpenCode en la distribución seleccionada.",
|
||||
"settings.opencode.setup.chooseBinary": "Elegir ejecutable",
|
||||
"settings.opencode.setup.required": "Configuración de OpenCode necesaria",
|
||||
"settings.appearance.palette.mode": "Modo de apariencia",
|
||||
"settings.appearance.palette.auto": "Auto",
|
||||
"settings.appearance.palette.independent": "Guarda una paleta para cada apariencia. Auto sigue el sistema y muestra la paleta correspondiente.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "Installez, mettez à jour et connectez OpenCode sur le serveur qui exécute vos projets.",
|
||||
"settings.opencode.setup.missing": "OpenCode n’est pas installé.",
|
||||
"settings.opencode.setup.update_required": "Cette version d’OpenCode n’est pas prise en charge par cette version de CodeNomad.",
|
||||
"settings.opencode.setup.ready": "La version installée d’OpenCode est prise en charge.",
|
||||
"settings.opencode.setup.error": "L’exécutable sélectionné n’a pas pu être vérifié.",
|
||||
"settings.opencode.setup.minimum": "Version minimale",
|
||||
"settings.opencode.setup.daemon": "Service en cours",
|
||||
"settings.opencode.setup.install": "Installer et démarrer OpenCode",
|
||||
"settings.opencode.setup.connect": "Connecter et continuer",
|
||||
"settings.opencode.setup.restart": "Redémarrer le service partagé",
|
||||
"settings.opencode.setup.restartRequired": "Le service en cours doit être mis à jour. Son redémarrage interrompt le travail actif de tous les clients OpenCode connectés.",
|
||||
"settings.opencode.setup.serviceError": "Impossible de vérifier le service partagé. Vérifiez la connexion et l’authentification, puis réessayez.",
|
||||
"settings.opencode.setup.manual": "Sélectionnez un exécutable compatible ou installez @opencode/cli sur l’hôte d’exécution, puis réessayez.",
|
||||
"settings.opencode.setup.wsl": "Cet exécutable tourne dans WSL. Installez ou mettez à jour OpenCode dans la distribution sélectionnée.",
|
||||
"settings.opencode.setup.chooseBinary": "Choisir l’exécutable",
|
||||
"settings.opencode.setup.required": "Configuration d’OpenCode requise",
|
||||
"settings.appearance.palette.mode": "Mode d’apparence",
|
||||
"settings.appearance.palette.auto": "Auto",
|
||||
"settings.appearance.palette.independent": "Mémorisez une palette par mode d’apparence. Auto suit le système et affiche la palette correspondante.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "התקנה, עדכון וחיבור של OpenCode בשרת שמריץ את הפרויקטים שלך.",
|
||||
"settings.opencode.setup.missing": "OpenCode אינו מותקן.",
|
||||
"settings.opencode.setup.update_required": "גרסת OpenCode זו אינה נתמכת בגרסה זו של CodeNomad.",
|
||||
"settings.opencode.setup.ready": "גרסת OpenCode המותקנת נתמכת.",
|
||||
"settings.opencode.setup.error": "לא ניתן לאמת את קובץ ההפעלה שנבחר.",
|
||||
"settings.opencode.setup.minimum": "גרסה מינימלית",
|
||||
"settings.opencode.setup.daemon": "השירות הפעיל",
|
||||
"settings.opencode.setup.install": "התקנה והפעלה של OpenCode",
|
||||
"settings.opencode.setup.connect": "התחברות והמשך",
|
||||
"settings.opencode.setup.restart": "הפעלה מחדש של השירות המשותף",
|
||||
"settings.opencode.setup.restartRequired": "יש לעדכן את השירות הפעיל. הפעלה מחדש תקטע עבודה פעילה בכל לקוחות OpenCode המחוברים.",
|
||||
"settings.opencode.setup.serviceError": "לא ניתן לאמת את השירות המשותף. יש לבדוק את החיבור והאימות ולנסות שוב.",
|
||||
"settings.opencode.setup.manual": "יש לבחור קובץ הפעלה נתמך או להתקין @opencode/cli במחשב המבצע ולנסות שוב.",
|
||||
"settings.opencode.setup.wsl": "קובץ זה פועל בתוך WSL. יש להתקין או לעדכן את OpenCode בהפצה שנבחרה.",
|
||||
"settings.opencode.setup.chooseBinary": "בחירת קובץ הפעלה",
|
||||
"settings.opencode.setup.required": "נדרשת הגדרת OpenCode",
|
||||
"settings.appearance.palette.mode": "מצב תצוגה",
|
||||
"settings.appearance.palette.auto": "אוטומטי",
|
||||
"settings.appearance.palette.independent": "שמרו ערכת צבעים לכל מצב תצוגה. המצב האוטומטי עוקב אחר המערכת ומציג את הערכה המתאימה.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "プロジェクトを実行するサーバーで OpenCode をインストール、更新、接続します。",
|
||||
"settings.opencode.setup.missing": "OpenCode がインストールされていません。",
|
||||
"settings.opencode.setup.update_required": "この OpenCode バージョンは、この CodeNomad リリースではサポートされていません。",
|
||||
"settings.opencode.setup.ready": "インストール済みの OpenCode はサポートされています。",
|
||||
"settings.opencode.setup.error": "選択した実行ファイルを確認できませんでした。",
|
||||
"settings.opencode.setup.minimum": "最小バージョン",
|
||||
"settings.opencode.setup.daemon": "実行中のサービス",
|
||||
"settings.opencode.setup.install": "OpenCode をインストールして起動",
|
||||
"settings.opencode.setup.connect": "接続して続行",
|
||||
"settings.opencode.setup.restart": "共有サービスを再起動",
|
||||
"settings.opencode.setup.restartRequired": "実行中のサービスの更新が必要です。再起動すると、接続中のすべての OpenCode クライアントで作業が中断されます。",
|
||||
"settings.opencode.setup.serviceError": "共有サービスを確認できません。接続と認証を確認して再試行してください。",
|
||||
"settings.opencode.setup.manual": "対応する実行ファイルを選ぶか、実行ホストに @opencode/cli をインストールして再試行してください。",
|
||||
"settings.opencode.setup.wsl": "この実行ファイルは WSL 内で動作します。選択したディストリビューション内で OpenCode をインストールまたは更新してください。",
|
||||
"settings.opencode.setup.chooseBinary": "実行ファイルを選択",
|
||||
"settings.opencode.setup.required": "OpenCode の設定が必要です",
|
||||
"settings.appearance.palette.mode": "外観モード",
|
||||
"settings.appearance.palette.auto": "自動",
|
||||
"settings.appearance.palette.independent": "外観ごとにパレットを保存します。自動モードではシステムに合わせて対応するパレットを表示します。",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "तपाईंका परियोजनाहरू चलाउने सर्भरमा OpenCode स्थापना, अद्यावधिक र जडान गर्नुहोस्।",
|
||||
"settings.opencode.setup.missing": "OpenCode स्थापना गरिएको छैन।",
|
||||
"settings.opencode.setup.update_required": "यो OpenCode संस्करण यस CodeNomad संस्करणमा समर्थित छैन।",
|
||||
"settings.opencode.setup.ready": "स्थापित OpenCode संस्करण समर्थित छ।",
|
||||
"settings.opencode.setup.error": "छानिएको कार्यान्वयन फाइल प्रमाणित गर्न सकिएन।",
|
||||
"settings.opencode.setup.minimum": "न्यूनतम संस्करण",
|
||||
"settings.opencode.setup.daemon": "चलिरहेको सेवा",
|
||||
"settings.opencode.setup.install": "OpenCode स्थापना गरी सुरु गर्नुहोस्",
|
||||
"settings.opencode.setup.connect": "जडान गरी जारी राख्नुहोस्",
|
||||
"settings.opencode.setup.restart": "साझा सेवा पुनः सुरु गर्नुहोस्",
|
||||
"settings.opencode.setup.restartRequired": "चलिरहेको सेवा अद्यावधिक गर्नुपर्छ। पुनः सुरु गर्दा सबै जडित OpenCode क्लाइन्टको सक्रिय काम रोकिन्छ।",
|
||||
"settings.opencode.setup.serviceError": "साझा सेवा प्रमाणित गर्न सकिएन। जडान र प्रमाणीकरण जाँच गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"settings.opencode.setup.manual": "समर्थित कार्यान्वयन फाइल छान्नुहोस् वा कार्यान्वयन होस्टमा @opencode/cli स्थापना गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"settings.opencode.setup.wsl": "यो फाइल WSL भित्र चल्छ। छानिएको वितरणमा OpenCode स्थापना वा अद्यावधिक गर्नुहोस्।",
|
||||
"settings.opencode.setup.chooseBinary": "कार्यान्वयन फाइल छान्नुहोस्",
|
||||
"settings.opencode.setup.required": "OpenCode सेटअप आवश्यक छ",
|
||||
"settings.appearance.palette.mode": "रूप मोड",
|
||||
"settings.appearance.palette.auto": "स्वतः",
|
||||
"settings.appearance.palette.independent": "हरेक देखावटका लागि रङ योजना सुरक्षित गर्नुहोस्। स्वतः मोडले प्रणालीअनुसार सम्बन्धित रङ योजना देखाउँछ।",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "Установите, обновите и подключите OpenCode на сервере, выполняющем ваши проекты.",
|
||||
"settings.opencode.setup.missing": "OpenCode не установлен.",
|
||||
"settings.opencode.setup.update_required": "Эта версия OpenCode не поддерживается текущим выпуском CodeNomad.",
|
||||
"settings.opencode.setup.ready": "Установленная версия OpenCode поддерживается.",
|
||||
"settings.opencode.setup.error": "Не удалось проверить выбранный исполняемый файл.",
|
||||
"settings.opencode.setup.minimum": "Минимальная версия",
|
||||
"settings.opencode.setup.daemon": "Работающая служба",
|
||||
"settings.opencode.setup.install": "Установить и запустить OpenCode",
|
||||
"settings.opencode.setup.connect": "Подключиться и продолжить",
|
||||
"settings.opencode.setup.restart": "Перезапустить общую службу",
|
||||
"settings.opencode.setup.restartRequired": "Работающую службу необходимо обновить. Перезапуск прервёт активную работу всех подключённых клиентов OpenCode.",
|
||||
"settings.opencode.setup.serviceError": "Не удалось проверить общую службу. Проверьте соединение и аутентификацию, затем повторите попытку.",
|
||||
"settings.opencode.setup.manual": "Выберите поддерживаемый исполняемый файл или установите @opencode/cli на узле выполнения и повторите попытку.",
|
||||
"settings.opencode.setup.wsl": "Этот файл выполняется в WSL. Установите или обновите OpenCode в выбранном дистрибутиве.",
|
||||
"settings.opencode.setup.chooseBinary": "Выбрать исполняемый файл",
|
||||
"settings.opencode.setup.required": "Требуется настройка OpenCode",
|
||||
"settings.appearance.palette.mode": "Режим оформления",
|
||||
"settings.appearance.palette.auto": "Авто",
|
||||
"settings.appearance.palette.independent": "Сохраните палитру для каждого режима оформления. Авто следует настройкам системы и показывает соответствующую палитру.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "Projelerinizi çalıştıran sunucuda OpenCode'u kurun, güncelleyin ve bağlanın.",
|
||||
"settings.opencode.setup.missing": "OpenCode kurulu değil.",
|
||||
"settings.opencode.setup.update_required": "Bu OpenCode sürümü, bu CodeNomad sürümü tarafından desteklenmiyor.",
|
||||
"settings.opencode.setup.ready": "Kurulu OpenCode sürümü destekleniyor.",
|
||||
"settings.opencode.setup.error": "Seçilen çalıştırılabilir dosya doğrulanamadı.",
|
||||
"settings.opencode.setup.minimum": "En düşük sürüm",
|
||||
"settings.opencode.setup.daemon": "Çalışan hizmet",
|
||||
"settings.opencode.setup.install": "OpenCode'u kur ve başlat",
|
||||
"settings.opencode.setup.connect": "Bağlan ve devam et",
|
||||
"settings.opencode.setup.restart": "Paylaşılan hizmeti yeniden başlat",
|
||||
"settings.opencode.setup.restartRequired": "Çalışan hizmet güncellenmeli. Yeniden başlatmak tüm bağlı OpenCode istemcilerindeki etkin çalışmayı keser.",
|
||||
"settings.opencode.setup.serviceError": "Paylaşılan hizmet doğrulanamıyor. Bağlantıyı ve kimlik doğrulamayı kontrol edip tekrar deneyin.",
|
||||
"settings.opencode.setup.manual": "Desteklenen bir çalıştırılabilir dosya seçin veya yürütme makinesine @opencode/cli kurup tekrar deneyin.",
|
||||
"settings.opencode.setup.wsl": "Bu dosya WSL içinde çalışır. Seçilen dağıtımda OpenCode'u kurun veya güncelleyin.",
|
||||
"settings.opencode.setup.chooseBinary": "Çalıştırılabilir dosya seç",
|
||||
"settings.opencode.setup.required": "OpenCode kurulumu gerekli",
|
||||
"settings.appearance.palette.mode": "Görünüm modu",
|
||||
"settings.appearance.palette.auto": "Otomatik",
|
||||
"settings.appearance.palette.independent": "Her görünüm için bir palet kaydedin. Otomatik mod sistemi izler ve ilgili paleti gösterir.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
export const settingsMessages = {
|
||||
"settings.opencode.setup.description": "在运行项目的服务器上安装、更新并连接 OpenCode。",
|
||||
"settings.opencode.setup.missing": "尚未安装 OpenCode。",
|
||||
"settings.opencode.setup.update_required": "此 CodeNomad 版本不支持当前 OpenCode 版本。",
|
||||
"settings.opencode.setup.ready": "已安装的 OpenCode 版本受支持。",
|
||||
"settings.opencode.setup.error": "无法验证所选可执行文件。",
|
||||
"settings.opencode.setup.minimum": "最低版本",
|
||||
"settings.opencode.setup.daemon": "运行中的服务",
|
||||
"settings.opencode.setup.install": "安装并启动 OpenCode",
|
||||
"settings.opencode.setup.connect": "连接并继续",
|
||||
"settings.opencode.setup.restart": "重启共享服务",
|
||||
"settings.opencode.setup.restartRequired": "运行中的服务需要更新。重启会中断所有已连接 OpenCode 客户端的当前工作。",
|
||||
"settings.opencode.setup.serviceError": "无法验证共享服务。请检查连接和身份验证后重试。",
|
||||
"settings.opencode.setup.manual": "请选择受支持的可执行文件,或在执行主机上安装 @opencode/cli 后重试。",
|
||||
"settings.opencode.setup.wsl": "此可执行文件在 WSL 中运行。请在所选发行版中安装或更新 OpenCode。",
|
||||
"settings.opencode.setup.chooseBinary": "选择可执行文件",
|
||||
"settings.opencode.setup.required": "需要设置 OpenCode",
|
||||
"settings.appearance.palette.mode": "外观模式",
|
||||
"settings.appearance.palette.auto": "自动",
|
||||
"settings.appearance.palette.independent": "为每种外观保存配色。自动模式跟随系统并显示相应的配色。",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ export function createInstanceFetch(baseUrl: string): typeof globalThis.fetch {
|
|||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
})
|
||||
if (response.status === 426) {
|
||||
const { reportOpenCodeSetupRequired } = await import("../stores/opencode-setup")
|
||||
reportOpenCodeSetupRequired()
|
||||
}
|
||||
if (response.status === 502) {
|
||||
const body = await response.clone().json().catch(() => undefined)
|
||||
if (body?.error === SESSION_ENVIRONMENT_FAILED_ERROR_CODE) {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import { OpenCode, type OpenCodeEvent } from "@opencode/client"
|
|||
import { createData } from "@opencode/client/solid"
|
||||
import { createRuntimeFetch } from "../../../server/src/opencode/compatibility/transport"
|
||||
import { rememberRuntime } from "../../../server/src/opencode/compatibility/runtime"
|
||||
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events"
|
||||
import { normalizeSessionMessage } from "./message-v2/normalizers"
|
||||
|
||||
test("legacy pending HTTP data and enqueued SSE materialize through the actual stable reducer", async () => {
|
||||
test("native pending HTTP data and enqueued SSE retain their distinct shapes through the stable reducer", async () => {
|
||||
const endpoint = { url: "http://127.0.0.1:4321" }
|
||||
rememberRuntime(endpoint, { version: "2.0.3", pid: 1, discovery: "health" })
|
||||
const item = { id: "inbox", sessionID: "session", type: "user", payload: { text: "Pending" }, delivery: "queue", timeCreated: 123 }
|
||||
rememberRuntime(endpoint, { version: "2.0.11", pid: 1, discovery: "info" })
|
||||
const item = { id: "inbox", sessionID: "session", type: "user", payload: { text: "Pending" }, delivery: "queue", time: { created: 123 } }
|
||||
const api = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async () => Response.json({ data: [item] })) })
|
||||
let dispose!: () => void
|
||||
let emit!: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void
|
||||
|
|
@ -22,9 +21,9 @@ test("legacy pending HTTP data and enqueued SSE materialize through the actual s
|
|||
try {
|
||||
await data.session.pending.sync("session")
|
||||
assert.equal(data.session.message.list("session")[0].time.created, 123)
|
||||
const event = normalizeRuntimeEvent({ id: "event", created: 456, type: "session.inbox.enqueued",
|
||||
const event = { id: "event", created: 456, type: "session.inbox.enqueued",
|
||||
data: { sessionID: "session", inboxID: "second", item: { type: "user", payload: { text: "Second" }, delivery: "queue" } },
|
||||
} as OpenCodeEvent)
|
||||
} as OpenCodeEvent
|
||||
emit({ name: event.type, details: event })
|
||||
assert.equal(data.session.message.list("session").find(message => message.id === "second")?.time.created, 456)
|
||||
} finally { dispose() }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
|
||||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
import { sseManager } from "../lib/sse-manager.ts"
|
||||
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
|
||||
|
|
@ -53,7 +53,7 @@ function fixture(instanceId: string) {
|
|||
let sequence = 0
|
||||
const emit = (type: string, data: Record<string, unknown>) => {
|
||||
const created = ++sequence
|
||||
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
|
||||
const event = { id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as OpenCodeEvent
|
||||
handleInstanceInvalidation(instanceId, event)
|
||||
handleNativeSessionEvent(instanceId, event)
|
||||
}
|
||||
|
|
|
|||
82
packages/ui/src/stores/opencode-setup.ts
Normal file
82
packages/ui/src/stores/opencode-setup.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import type { OpenCodeUpdateStatus } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
export const [openCodeSetupStatus, setOpenCodeSetupStatus] = createSignal<OpenCodeUpdateStatus>()
|
||||
export const [openCodeSetupOpen, setOpenCodeSetupOpen] = createSignal(false)
|
||||
export const [openCodeSetupBusy, setOpenCodeSetupBusy] = createSignal(false)
|
||||
export const [openCodeSetupError, setOpenCodeSetupError] = createSignal(false)
|
||||
let generation = 0
|
||||
let notifiedGeneration = -1
|
||||
let pending: Promise<void> | undefined
|
||||
let resume: (() => Promise<unknown>) | undefined
|
||||
|
||||
export function needsOpenCodeSetup(status = openCodeSetupStatus()): boolean {
|
||||
return status?.state === "missing" || status?.state === "update_required" || status?.serviceState === "restart_required"
|
||||
}
|
||||
|
||||
export function openOpenCodeSetup(retry?: () => Promise<unknown>) {
|
||||
if (retry) resume = retry
|
||||
setOpenCodeSetupOpen(true)
|
||||
void refreshOpenCodeSetup()
|
||||
}
|
||||
|
||||
export function reportOpenCodeSetupRequired() {
|
||||
if (openCodeSetupBusy() || notifiedGeneration === generation) return
|
||||
notifiedGeneration = generation
|
||||
openOpenCodeSetup()
|
||||
}
|
||||
|
||||
export function invalidateOpenCodeSetup() {
|
||||
generation++
|
||||
pending = undefined
|
||||
// Keep the selected folder while the user chooses a different executable.
|
||||
// Its callback resolves the current settings again; old mutations stay fenced.
|
||||
setOpenCodeSetupStatus(undefined)
|
||||
void refreshOpenCodeSetup()
|
||||
}
|
||||
|
||||
export function refreshOpenCodeSetup(afterMutation = false): Promise<void> {
|
||||
if (openCodeSetupBusy() && !afterMutation) return Promise.resolve()
|
||||
if (pending) return pending
|
||||
const epoch = generation
|
||||
const request = serverApi.fetchOpenCodeUpdateStatus().then(status => {
|
||||
if (epoch !== generation) return
|
||||
setOpenCodeSetupStatus(status)
|
||||
setOpenCodeSetupError(false)
|
||||
}).catch(() => { if (epoch === generation) setOpenCodeSetupError(true) })
|
||||
.finally(() => { if (pending === request) pending = undefined })
|
||||
pending = request
|
||||
return request
|
||||
}
|
||||
|
||||
export async function runOpenCodeSetup(action: "install" | "start" | "restart") {
|
||||
if (openCodeSetupBusy()) return
|
||||
const epoch = ++generation
|
||||
pending = undefined
|
||||
setOpenCodeSetupBusy(true)
|
||||
setOpenCodeSetupError(false)
|
||||
try {
|
||||
if (action === "install") await serverApi.updateOpenCode()
|
||||
if (epoch !== generation) return
|
||||
// Installation can leave an older shared daemon running. Re-read first so
|
||||
// restart remains a separate explicit action, never an implicit interruption.
|
||||
await refreshOpenCodeSetup(true)
|
||||
if (epoch !== generation) return
|
||||
if (action !== "restart" && openCodeSetupStatus()?.serviceState === "restart_required") return
|
||||
const status = await serverApi.startOpenCode(action === "restart")
|
||||
if (epoch !== generation) return
|
||||
setOpenCodeSetupStatus(status)
|
||||
if (status.state === "ready" && status.serviceState === "ready") {
|
||||
const retry = resume
|
||||
resume = undefined
|
||||
setOpenCodeSetupOpen(false)
|
||||
await retry?.() // Workspace-open retry only; never a session prompt/mutation.
|
||||
}
|
||||
} catch {
|
||||
if (epoch === generation) { await refreshOpenCodeSetup(true); setOpenCodeSetupError(true) }
|
||||
} finally {
|
||||
setOpenCodeSetupBusy(false)
|
||||
if (epoch !== generation) void refreshOpenCodeSetup()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
|
||||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData } from "./opencode-data.ts"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
import { sseManager } from "../lib/sse-manager.ts"
|
||||
|
|
@ -9,19 +9,19 @@ import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from
|
|||
import { messageStoreBus } from "./message-v2/bus.ts"
|
||||
import { setActiveSession, setSessions } from "./session-state.ts"
|
||||
|
||||
test("the current native reducer preserves old event timing and newer precise start times", () => {
|
||||
for (const started of [undefined, 0, 45]) {
|
||||
test("the native reducer preserves canonical precise start times and durable identity", () => {
|
||||
for (const started of [0, 45]) {
|
||||
const instanceId = `runtime-started-${started}`
|
||||
const original = {
|
||||
id: "evt_step", type: "session.step.started", created: 100,
|
||||
durable: { aggregateID: "s", seq: 2, version: 1 },
|
||||
data: { sessionID: "s", assistantMessageID: "m", agent: "build", model: { providerID: "fixture", id: "fixture" },
|
||||
...(started === undefined ? {} : { started }) },
|
||||
started },
|
||||
}
|
||||
try {
|
||||
const event = normalizeRuntimeEvent(original as any)
|
||||
const event = original as OpenCodeEvent
|
||||
const data = applyOpenCodeDataEvent(instanceId, "/fixture", event)
|
||||
assert.equal(data.session.message.get("s", "m")?.time.created, started ?? 100)
|
||||
assert.equal(data.session.message.get("s", "m")?.time.created, started)
|
||||
assert.ok("durable" in event)
|
||||
assert.deepEqual(event.durable, original.durable)
|
||||
assert.equal(original.data.started, started, "normalization must not mutate the input event")
|
||||
|
|
@ -56,7 +56,7 @@ test(`settled tools reach the visible store with following ${following}`, async
|
|||
} as any]])))
|
||||
setActiveSession(instanceId, sessionId)
|
||||
const emit = (type: string, data: Record<string, unknown>, created: number) => {
|
||||
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
|
||||
const event = { id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as OpenCodeEvent
|
||||
handleInstanceInvalidation(instanceId, event)
|
||||
handleNativeSessionEvent(instanceId, event)
|
||||
}
|
||||
|
|
|
|||
19
packages/ui/tests/browser/fixtures/opencode-setup.tsx
Normal file
19
packages/ui/tests/browser/fixtures/opencode-setup.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { render } from "solid-js/web"
|
||||
import OpenCodeSetup from "../../../src/components/opencode-setup"
|
||||
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import { openOpenCodeSetup } from "../../../src/stores/opencode-setup"
|
||||
import { createInstanceFetch } from "../../../src/lib/sdk-manager"
|
||||
import "../../../src/index.css"
|
||||
serverApi.fetchConfigOwner = async () => ({ settings: { locale: "en" } }) as any
|
||||
serverApi.fetchStateOwner = async () => ({}) as any
|
||||
serverApi.patchConfigOwner = async (_owner, patch) => patch as any
|
||||
let resumed = 0
|
||||
render(() => <ConfigProvider><I18nProvider><OpenCodeSetup /></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
await updatePreferences({ locale: "en" })
|
||||
;(window as any).fixture = {
|
||||
open: () => openOpenCodeSetup(async () => { resumed++ }), resumed: () => resumed,
|
||||
unsupported: () => createInstanceFetch(`${location.origin}/workspaces/w/instance/`)(`${location.origin}/api/session/s/prompt`,
|
||||
{ method: "POST", body: "{}" }).then(response => response.status),
|
||||
}
|
||||
133
packages/ui/tests/browser/opencode-setup.test.ts
Normal file
133
packages/ui/tests/browser/opencode-setup.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium, type Browser } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import path from "node:path"
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "setup-fixture", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/opencode-setup.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
test("missing and required-update flows share a screen; stale daemon restart is explicit", async () => {
|
||||
const page = await browser.newPage()
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
let installed = false, running = false, restarts = 0, installs = 0
|
||||
await page.route("**/api/**", async route => {
|
||||
const request = route.request()
|
||||
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
|
||||
installed = true; installs++
|
||||
return route.fulfill({ json: { success: true, version: "2.0.11" } })
|
||||
}
|
||||
if (request.url().endsWith("/api/opencode/service")) {
|
||||
assert.equal(request.postDataJSON().restart, true)
|
||||
restarts++; running = true
|
||||
}
|
||||
return route.fulfill({ json: { state: installed ? "ready" : "missing", currentVersion: installed ? "2.0.11" : null,
|
||||
latestVersion: "2.0.11", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host",
|
||||
canUpgrade: !installed, updateAvailable: !installed, daemonVersion: running ? "2.0.11" : "2.0.10",
|
||||
serviceState: installed ? running ? "ready" : "restart_required" : undefined, canRestart: installed && !running } })
|
||||
})
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await page.evaluate(() => (window as any).fixture.open())
|
||||
await page.getByRole("button", { name: "Install and start OpenCode" }).click()
|
||||
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
|
||||
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-restart.png") })
|
||||
assert.equal(installs, 1)
|
||||
assert.equal(restarts, 0)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
|
||||
await page.getByText(/Restart interrupts active work/).waitFor()
|
||||
await page.getByRole("button", { name: "Restart shared service" }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
|
||||
assert.equal(restarts, 1)
|
||||
assert.equal(await page.getByRole("dialog").count(), 0)
|
||||
assert.deepEqual(errors, [])
|
||||
} catch (error) { console.error(errors, await page.locator("body").innerText()); throw error }
|
||||
finally { await page.close() }
|
||||
})
|
||||
|
||||
test("closing required-update screen leaves a persistent recovery entry", async () => {
|
||||
const page = await browser.newPage()
|
||||
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.10",
|
||||
latestVersion: "2.0.11", minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false } }))
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
|
||||
await page.getByRole("dialog").waitFor()
|
||||
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-required.png") })
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await page.getByRole("button", { name: "OpenCode setup required" }).click()
|
||||
await page.getByRole("dialog").waitFor()
|
||||
assert.equal(await page.getByText("2.0.10", { exact: true }).count(), 1)
|
||||
} catch (error) { console.error(await page.locator("body").innerText()); throw error }
|
||||
finally { await page.close() }
|
||||
})
|
||||
|
||||
test("optional updates stay non-blocking and a failed install remains recoverable", async () => {
|
||||
const page = await browser.newPage()
|
||||
let attempts = 0, starts = 0
|
||||
await page.route("**/api/**", async route => {
|
||||
const request = route.request()
|
||||
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
|
||||
attempts++
|
||||
return route.fulfill({ status: 500, json: { error: "upgrade_failed" } })
|
||||
}
|
||||
if (request.url().endsWith("/api/opencode/service")) starts++
|
||||
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.12",
|
||||
minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false,
|
||||
serviceState: "ready", daemonVersion: "2.0.11" } })
|
||||
})
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
assert.equal(await page.getByRole("dialog").count(), 0)
|
||||
await page.evaluate(() => (window as any).fixture.open())
|
||||
await page.getByRole("button", { name: "Update to OpenCode 2.0.12" }).click()
|
||||
await page.locator(".settings-error-message").waitFor()
|
||||
assert.equal(starts, 0)
|
||||
assert.equal(attempts, 1)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
|
||||
await page.getByRole("button", { name: "Update to OpenCode 2.0.12" }).click()
|
||||
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
|
||||
assert.equal(attempts, 2)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("unsupported proxy calls open recovery once without replaying mutations", async () => {
|
||||
const page = await browser.newPage()
|
||||
let blocked = false, prompts = 0
|
||||
await page.route("**/api/**", route => {
|
||||
if (route.request().url().includes("/instance/api/")) {
|
||||
prompts++; blocked = true
|
||||
return route.fulfill({ status: 426, json: { code: "opencode_update_required" } })
|
||||
}
|
||||
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11",
|
||||
minimumVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: false,
|
||||
serviceState: blocked ? "restart_required" : "ready", canRestart: blocked, daemonVersion: blocked ? "2.0.10" : "2.0.11" } })
|
||||
})
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
|
||||
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click()
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
|
||||
assert.equal(await page.getByRole("dialog").count(), 0)
|
||||
assert.equal(prompts, 2, "each intentional call occurs once; recovery never retries a prompt")
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
|
@ -139,6 +139,11 @@ function pruneForRuntime(sourceRoot, destinationRoot, binaryRelativePath) {
|
|||
fs.rmSync(destinationRoot, { recursive: true, force: true })
|
||||
fs.mkdirSync(path.dirname(destinationBinary), { recursive: true })
|
||||
fs.copyFileSync(sourceBinary, destinationBinary)
|
||||
const npmRelativePath = binaryRelativePath === "node.exe" ? "node_modules/npm" : "lib/node_modules/npm"
|
||||
const npmSource = path.join(sourceRoot, npmRelativePath)
|
||||
if (!fs.existsSync(path.join(npmSource, "bin/npm-cli.js"))) throw new Error("Node archive is missing npm")
|
||||
fs.cpSync(npmSource, path.join(destinationRoot, npmRelativePath), { recursive: true })
|
||||
fs.copyFileSync(path.join(sourceRoot, "LICENSE"), path.join(destinationRoot, "LICENSE"))
|
||||
}
|
||||
|
||||
async function prepareBundledNodeRuntime(options) {
|
||||
|
|
@ -197,6 +202,7 @@ async function prepareBundledNodeRuntime(options) {
|
|||
|
||||
module.exports = {
|
||||
MANAGED_NODE_VERSION,
|
||||
pruneForRuntime,
|
||||
prepareBundledNodeRuntime,
|
||||
normalizeTarget,
|
||||
currentTarget,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const fs = require("node:fs")
|
|||
const path = require("node:path")
|
||||
const test = require("node:test")
|
||||
const yaml = require("yaml")
|
||||
const os = require("node:os")
|
||||
const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
|
|
@ -40,3 +41,23 @@ test("both macOS bundles require the OS supported by Node 24", () => {
|
|||
assert.equal(electron.build.mac.minimumSystemVersion, "13.5")
|
||||
assert.equal(tauri.bundle.macOS.minimumSystemVersion, "13.5")
|
||||
})
|
||||
|
||||
test("both desktop resource layouts retain the complete npm runtime and licenses", () => {
|
||||
const { pruneForRuntime } = require("./prepare-node-runtime.cjs")
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-node-packaging-"))
|
||||
try {
|
||||
for (const binary of ["node.exe", "bin/node"]) {
|
||||
const source = path.join(fixture, binary === "node.exe" ? "windows" : "posix")
|
||||
const destination = `${source}-out`
|
||||
const npm = binary === "node.exe" ? "node_modules/npm" : "lib/node_modules/npm"
|
||||
for (const file of [binary, "LICENSE", `${npm}/bin/npm-cli.js`, `${npm}/node_modules/fixture/index.js`, `${npm}/LICENSE`]) {
|
||||
fs.mkdirSync(path.dirname(path.join(source, file)), { recursive: true })
|
||||
fs.writeFileSync(path.join(source, file), file)
|
||||
}
|
||||
pruneForRuntime(source, destination, binary)
|
||||
assert.equal(fs.readFileSync(path.join(destination, `${npm}/node_modules/fixture/index.js`), "utf8"), `${npm}/node_modules/fixture/index.js`)
|
||||
assert.ok(fs.existsSync(path.join(destination, "LICENSE")))
|
||||
assert.ok(fs.existsSync(path.join(destination, `${npm}/bin/npm-cli.js`)))
|
||||
}
|
||||
} finally { fs.rmSync(fixture, { recursive: true, force: true }) }
|
||||
})
|
||||
|
|
|
|||
140
scripts/test-opencode-history-migration.mjs
Normal file
140
scripts/test-opencode-history-migration.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Native migration only: isolated synthetic storage, never service discovery or
|
||||
// a user's database. Keep an untouched seed and migrate a copied DB via OpenCode.
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn, execFileSync } from "node:child_process"
|
||||
import { randomUUID, createHash } from "node:crypto"
|
||||
import { mkdtemp, mkdir, readFile, writeFile, copyFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
const [oldCli, newCli] = process.argv.slice(2)
|
||||
if (![oldCli, newCli].every(value => value && path.isAbsolute(value))) throw new Error("Pass absolute old and target CLI paths")
|
||||
const parent = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(parent, { recursive: true })
|
||||
const root = await mkdtemp(path.join(parent, "codenomad-history-migration-"))
|
||||
const repo = path.join(root, "repo"), worktree = path.join(root, "worktree")
|
||||
await mkdir(repo)
|
||||
const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" })
|
||||
git("init"); git("config", "user.name", "Synthetic fixture"); git("config", "user.email", "fixture@example.invalid")
|
||||
await writeFile(path.join(repo, "fixture.txt"), "synthetic")
|
||||
git("add", "."); git("commit", "-m", "fixture"); git("worktree", "add", "-b", "fixture-worktree", worktree)
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/.test(key)))
|
||||
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
const password = randomUUID()
|
||||
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
||||
OPENCODE_SERVER_PASSWORD: password, OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", OPENCODE_CONFIG_CONTENT: "{}" })
|
||||
await mkdir(env.OPENCODE_CONFIG_DIR)
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const versions = [oldCli, newCli].map(cli => execFileSync(cli, ["--version"], { encoding: "utf8" }).trim())
|
||||
const hashes = await Promise.all([oldCli, newCli].map(async cli => createHash("sha256").update(await readFile(cli)).digest("hex")))
|
||||
await writeFile(path.join(root, "artifacts.json"), JSON.stringify({ versions, hashes }, null, 2))
|
||||
|
||||
async function start(cli, database, name) {
|
||||
let output = ""
|
||||
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], {
|
||||
cwd: root, env: { ...env, OPENCODE_DB: database }, windowsHide: true,
|
||||
})
|
||||
const stopped = new Promise(resolve => child.once("close", resolve))
|
||||
child.stdout.on("data", data => { output += data }); child.stderr.on("data", data => { output += data })
|
||||
const close = async () => { child.kill(); await stopped; await writeFile(path.join(root, `${name}.log`), output) }
|
||||
try {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) {
|
||||
if (child.exitCode !== null || Date.now() > deadline) throw new Error(`Fixture startup failed: ${output}`)
|
||||
await delay(20)
|
||||
}
|
||||
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
||||
const raw = async (route, body, method = body === undefined ? "GET" : "POST") => {
|
||||
const response = await fetch(`${baseUrl}${route}`, { method, headers: { authorization, "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000) })
|
||||
const text = await response.text()
|
||||
assert.ok(response.ok, `${method} ${route}: ${response.status} ${text}`)
|
||||
return text ? JSON.parse(text) : undefined
|
||||
}
|
||||
return { baseUrl, raw, close }
|
||||
} catch (error) { await close(); throw error }
|
||||
}
|
||||
|
||||
const seedDb = path.join(root, "seed.db"), migratedDb = path.join(root, "migrated.db")
|
||||
const seed = await start(oldCli, seedDb, "seed")
|
||||
const snapshots = []
|
||||
let pendingSession
|
||||
try {
|
||||
for (const workspaceID of [undefined, "wrk_fixture_one", "wrk_fixture_two"]) {
|
||||
const location = { directory: repo, ...(workspaceID ? { workspaceID } : {}) }
|
||||
const created = (await seed.raw("/api/session", { location })).data
|
||||
const exported = (await seed.raw(`/api/session/${created.id}/export`)).data
|
||||
const time = Date.now()
|
||||
const messages = Array.from({ length: 213 }, (_, index) => ({ type: "synthetic", id: `msg_${randomUUID().replaceAll("-", "")}`,
|
||||
text: `history-${workspaceID ?? "local"}-${index}`, time: { created: time + index } }))
|
||||
messages.push({ type: "assistant", id: `msg_${randomUUID().replaceAll("-", "")}`, time: { created: time + 214, completed: time + 215 },
|
||||
agent: "build", model: { providerID: "fixture", id: "fixture" }, finish: "stop", content: [
|
||||
{ type: "reasoning", text: "historical reasoning" }, { type: "text", text: "historical answer" },
|
||||
{ type: "tool", id: "fixture-tool", name: "fixture", time: { created: time + 214, completed: time + 215 },
|
||||
state: { status: "completed", input: {}, content: [{ type: "text", text: "historical tool output" }] } },
|
||||
] })
|
||||
messages.push({ type: "compaction", id: `msg_${randomUUID().replaceAll("-", "")}`, time: { created: time + 216 },
|
||||
status: "completed", reason: "manual", summary: "Synthetic historical checkpoint", recent: messages[212].id })
|
||||
const info = { ...exported.info, id: `ses_${randomUUID().replaceAll("-", "")}`, title: `history-${workspaceID ?? "local"}` }
|
||||
const imported = (await seed.raw("/api/session/import", { info, messages, location })).data
|
||||
const fork = (await seed.raw(`/api/session/${imported.id}/fork`, { boundary: { type: "through" } })).data
|
||||
for (const session of [imported, fork]) snapshots.push((await seed.raw(`/api/session/${session.id}/export`)).data)
|
||||
}
|
||||
const moved = (await seed.raw("/api/session", { location: { directory: repo } })).data
|
||||
await seed.raw(`/api/session/${moved.id}/move`, { directory: worktree })
|
||||
// Move is queued by native OpenCode; wait for its authoritative record.
|
||||
for (let n = 0; n < 200; n++) {
|
||||
const current = (await seed.raw(`/api/session/${moved.id}`)).data
|
||||
if (current.location.directory.replaceAll("\\", "/").toLowerCase() === worktree.replaceAll("\\", "/").toLowerCase()) break
|
||||
if (n === 199) throw new Error("Native move did not settle")
|
||||
await delay(25)
|
||||
}
|
||||
snapshots.push((await seed.raw(`/api/session/${moved.id}/export`)).data)
|
||||
pendingSession = (await seed.raw("/api/session", { location: { directory: repo } })).data.id
|
||||
await seed.raw(`/api/session/${pendingSession}/synthetic`, { text: "pending migration fixture", resume: false })
|
||||
assert.equal((await seed.raw(`/api/session/${pendingSession}/inbox`)).data.length, 1)
|
||||
await writeFile(path.join(root, "seed-exports.json"), JSON.stringify(snapshots))
|
||||
} finally { await seed.close() }
|
||||
await copyFile(seedDb, migratedDb)
|
||||
await copyFile(`${seedDb}-wal`, `${migratedDb}-wal`).catch(error => { if (error.code !== "ENOENT") throw error })
|
||||
const untouchedHash = createHash("sha256").update(await readFile(seedDb)).digest("hex")
|
||||
const target = await start(newCli, migratedDb, "target")
|
||||
try {
|
||||
const { OpenCodeCliService } = await tsImport("../packages/server/src/workspaces/opencode-cli-service.ts", import.meta.url)
|
||||
const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url)
|
||||
const lifecycle = new OpenCodeCliService({ label: "Migration fixture", timeoutMs: 30_000, command: args => ({ command: newCli, args, options: {} }) },
|
||||
{ execFile: async (_file, args) => ({ stdout: args.at(-1) === "password" ? password : target.baseUrl, stderr: "" }) })
|
||||
const shared = new OpenCodeSharedService()
|
||||
const client = await shared.client({ kind: "lifecycle", identity: "migration-fixture", lifecycle })
|
||||
const inbox = await client.session.inbox.list({ sessionID: pendingSession })
|
||||
assert.equal(inbox.length, 1)
|
||||
assert.equal(inbox[0].payload.text, "pending migration fixture")
|
||||
assert.equal(typeof inbox[0].time.created, "number")
|
||||
for (const original of snapshots) {
|
||||
const restored = await client.session.export({ sessionID: original.info.id })
|
||||
assert.equal(restored.info.id, original.info.id)
|
||||
assert.equal(restored.info.location.directory, original.info.location.directory)
|
||||
assert.deepEqual(restored.messages, original.messages, `history survives native migration: ${original.info.id}`)
|
||||
const messages = []
|
||||
let page = await client.message.list({ sessionID: original.info.id, limit: 200 })
|
||||
messages.push(...page.data)
|
||||
while (page.cursor.next) { page = await client.message.list({ sessionID: original.info.id, cursor: page.cursor.next }); messages.push(...page.data) }
|
||||
assert.equal(messages.length, original.messages.length)
|
||||
console.log(`PASS migrated ${original.info.id}: ${messages.length} messages, old identity ${original.info.location.workspaceID ?? "local"}, current identity ${restored.info.location.workspaceID ?? "local"}`)
|
||||
}
|
||||
const listed = new Set()
|
||||
let page = await client.session.list({ directory: repo, limit: 2 })
|
||||
for (const item of page.data) listed.add(item.id)
|
||||
while (page.cursor.next) {
|
||||
page = await client.session.list({ cursor: page.cursor.next })
|
||||
for (const item of page.data) listed.add(item.id)
|
||||
}
|
||||
for (const original of snapshots.filter(snapshot => snapshot.info.title?.startsWith("history-"))) {
|
||||
assert.ok(listed.has(original.info.id), `native pagination retains migrated session ${original.info.id}`)
|
||||
}
|
||||
await shared.shutdown()
|
||||
} finally { await target.close() }
|
||||
assert.equal(createHash("sha256").update(await readFile(seedDb)).digest("hex"), untouchedHash)
|
||||
console.log(`PASS: native synthetic history migration ${versions.join(" -> ")}; evidence ${root}`)
|
||||
32
scripts/test-opencode-install-native.mjs
Normal file
32
scripts/test-opencode-install-native.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Downloads only into a fresh fixture. No global npm install or shared daemon.
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdtemp, mkdir, cp, copyFile, chmod, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
const { bundledNpm, installManagedOpenCode, readManagedExecutable } = await tsImport("../packages/server/src/opencode-update/managed-installation.ts", import.meta.url)
|
||||
const { MINIMUM_OPENCODE_VERSION } = await tsImport("../packages/server/src/opencode/runtime-support.ts", import.meta.url)
|
||||
const parent = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(parent, { recursive: true })
|
||||
const root = await mkdtemp(path.join(parent, "codenomad-install-native-"))
|
||||
const runtime = path.join(root, "runtime")
|
||||
await mkdir(runtime)
|
||||
const node = path.join(runtime, process.platform === "win32" ? "node.exe" : "bin/node")
|
||||
await mkdir(path.dirname(node), { recursive: true })
|
||||
await copyFile(process.execPath, node)
|
||||
await chmod(node, 0o755)
|
||||
const npm = bundledNpm()
|
||||
assert.ok(npm, "Fixture requires the npm from its Node installation")
|
||||
const npmRoot = path.resolve(npm, "../..")
|
||||
await cp(npmRoot, path.join(runtime, process.platform === "win32" ? "node_modules/npm" : "lib/node_modules/npm"), { recursive: true })
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(PATH|OPENCODE_|XDG_|npm_)/i.test(key)))
|
||||
Object.assign(env, { HOME: root, USERPROFILE: root, LOCALAPPDATA: root,
|
||||
npm_config_cache: path.join(root, "npm-cache"), npm_config_userconfig: path.join(root, "npmrc"),
|
||||
PATH: process.platform === "win32" ? `${process.env.SystemRoot}\\System32` : "" })
|
||||
try {
|
||||
const binary = await installManagedOpenCode(MINIMUM_OPENCODE_VERSION, { root: path.join(root, "install"), node, env })
|
||||
assert.equal(readManagedExecutable(path.join(root, "install")), binary)
|
||||
assert.match(execFileSync(binary, ["--version"], { encoding: "utf8", env }), new RegExp(MINIMUM_OPENCODE_VERSION.replaceAll(".", "\\.")))
|
||||
console.log(`PASS: native npm installation with isolated bundled Node, no system Node PATH, verified executable (${MINIMUM_OPENCODE_VERSION})`)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
|
|
@ -154,14 +154,11 @@ try {
|
|||
const client = await sharedService.client({ kind: "lifecycle", identity: "isolated-native", lifecycle })
|
||||
const connection = await sharedService.acquire()
|
||||
const runtimeFetch = connection.fetch
|
||||
// Recognition follows the actual authenticated schema, not an exact version
|
||||
// allowlist. Exercise this route against every real runtime in the matrix.
|
||||
// Independently verify the authenticated schema even for the known minimum.
|
||||
const { negotiateRuntime } = await tsImport("../packages/server/src/opencode/compatibility/negotiate.ts", import.meta.url)
|
||||
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
||||
const { createRuntimeFetch } = await tsImport("../packages/server/src/opencode/compatibility/transport.ts", import.meta.url)
|
||||
const futureEndpoint = { ...connection.endpoint }
|
||||
rememberRuntime(futureEndpoint, { version: "contract-probe", pid: 1, discovery: "status" })
|
||||
const negotiatedClient = OpenCode.make({ baseUrl, fetch: createRuntimeFetch(futureEndpoint) })
|
||||
assert.equal((await negotiatedClient.location.get({ location: { directory: root } })).directory, (await client.location.get({ location: { directory: root } })).directory)
|
||||
assert.equal(await negotiateRuntime(connection.endpoint, fetch, AbortSignal.timeout(10_000)), "modern")
|
||||
const makeClient = () => OpenCode.make({ baseUrl, headers: {
|
||||
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
||||
}, fetch: runtimeFetch })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue