Compare commits

..
Author SHA1 Message Date
Filip Hejmowski b5921c86fc confirm prompts 2026-09-07 23:50:01 +02:00
Filip Hejmowski 827e9a7160 feat: uninstall command 2026-09-07 18:59:50 +02:00
34 changed files with 468 additions and 834 deletions
-6
View File
@@ -86,7 +86,6 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
@@ -104,7 +103,6 @@
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"uqr": "0.1.3",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14",
@@ -3208,8 +3206,6 @@
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
@@ -5142,8 +5138,6 @@
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
-1
View File
@@ -6,7 +6,6 @@ export function createWebApp(domain: string) {
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_OPENCODE_SERVER_MODE: "none",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
+2 -22
View File
@@ -83,28 +83,8 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
| Mode | Initial server |
| ------------------ | ------------------------------------------------------------------------------------ |
| `none` | No initial server. The beta deployment uses this mode. |
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
Scanning fills the form; **Connect** checks the credentials before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
Run `bun run test:server-connect` to build in `none` mode and test onboarding, password authentication,
persistence, and QR scanning through Chromium's virtual camera. To use an existing `none`-mode app build:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4446 bun run test:server-connect
```
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
@@ -1,18 +0,0 @@
import { expect, test } from "@playwright/test"
test.use({ launchOptions: { args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] } })
test("stops the camera on cancel", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Scan QR code" }).click()
const video = page.getByLabel("Pairing camera")
await expect(video).toHaveJSProperty("readyState", 4)
await expect(video).toBeVisible()
await expect(video).toHaveCSS("opacity", "1")
const stream = await video.evaluateHandle((element: HTMLVideoElement) => element.srcObject as MediaStream)
await page.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(page.getByLabel("Password", { exact: true })).toBeVisible()
await expect
.poll(() => stream.evaluate((value) => value.getTracks().every((track) => track.readyState === "ended")))
.toBe(true)
})
@@ -1,69 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const password = "pairing-test-password"
test("checks the password before saving and restores the connection after reload", async ({ page }) => {
await mockOpenCodeServer(page, {
provider: { all: [], default: {}, connected: [] },
directory: "/fixture",
project: { id: "fixture", worktree: "/fixture", time: { created: 1 } },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route(`${server}/api/health`, (route) =>
route.fulfill({
status:
route.request().headers().authorization === `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
? 200
: 401,
json: { healthy: true, version: "2.0.0" },
}),
)
await page.goto("/")
await expect(page.getByRole("main", { name: "Connect to a server" })).toBeVisible()
await page.getByLabel("Server address").fill(server)
await page.getByLabel("Password", { exact: true }).fill("wrong-password")
await page.getByRole("button", { name: "Connect", exact: true }).click()
await expect(page.getByRole("alert")).toHaveText(
"Could not connect. Check the server address and password, then try again.",
)
expect(await page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toBeNull()
await page.getByLabel("Password", { exact: true }).fill(password)
await page.getByRole("button", { name: "Connect", exact: true }).click()
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
await expect.poll(() => page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toContain(password)
await page.reload()
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
})
test("a saved offline server does not trigger first-server onboarding", async ({ page }) => {
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: server } }] }),
)
}, server)
await page.route(`${server}/**`, (route) => route.abort())
await page.goto("/")
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
})
test("camera failure allows returning to the manual form", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto("/")
await page.getByLabel("Server address").fill(server)
await page.getByRole("button", { name: "Scan QR code" }).click()
await expect(page.getByRole("alert")).toHaveText(
"Could not open the camera. Allow camera access or enter your connection details manually.",
)
await page.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(page.getByLabel("Server address")).toHaveValue(server)
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
})
@@ -1,29 +0,0 @@
import { defineConfig } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4456)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/server-connect",
timeout: 60_000,
expect: { timeout: 15_000 },
workers: 1,
use: {
baseURL,
browserName: "chromium",
serviceWorkers: "block",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`,
cwd: "../..",
url: baseURL,
timeout: 120_000,
env: { VITE_OPENCODE_SERVER_MODE: "none" },
},
})
@@ -1,58 +0,0 @@
import { expect, test } from "@playwright/test"
import { writeFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { encode } from "uqr"
const video = path.join(tmpdir(), `opencode-pairing-${process.pid}.y4m`)
const pairing = {
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
username: "opencode",
password: "qr-test-password",
}
test.use({
viewport: { width: 390, height: 844 },
launchOptions: {
args: [
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
`--use-file-for-fake-video-capture=${video}`,
],
},
})
test.beforeAll(async () => {
// Feed the real decoder an inverted terminal QR through Chromium's virtual camera.
const qr = encode(JSON.stringify(pairing), { border: 4, invert: true })
const size = 640
const scale = Math.floor(400 / qr.size)
const offset = Math.floor((size - qr.size * scale) / 2)
const luma = Buffer.from(
Array.from({ length: size * size }, (_, index) => {
const row = Math.floor((Math.floor(index / size) - offset) / scale)
const column = Math.floor(((index % size) - offset) / scale)
return qr.data[row]?.[column] === false ? 235 : 16
}),
)
await writeFile(
video,
Buffer.concat([
Buffer.from(`YUV4MPEG2 W${size} H${size} F10:1 Ip A1:1 C420jpeg\nFRAME\n`),
luma,
Buffer.alloc((size * size) / 2, 128),
]),
)
})
test.afterAll(() => rm(video, { force: true }))
test("scans opencode pair into the address and password fields", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Scan QR code" }).click()
await expect(page.getByLabel("Server address")).toHaveValue(pairing.urls[0])
await expect(page.getByLabel("Password", { exact: true })).toHaveValue(pairing.password)
await expect(page.getByLabel("Password", { exact: true })).toHaveAttribute("type", "password")
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
await expect(page.getByLabel("Pairing camera")).toHaveCount(0)
})
-3
View File
@@ -32,7 +32,6 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:server-connect": "playwright test --config e2e/server-connect/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
@@ -52,7 +51,6 @@
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"uqr": "0.1.3",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
@@ -89,7 +87,6 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
-1
View File
@@ -20,7 +20,6 @@ export default defineConfig({
testDir: "./e2e",
testIgnore: [
"service-worker/**",
"server-connect/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
outputDir: "./e2e/test-results",
+1 -1
View File
@@ -95,7 +95,7 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer?: ServerConnection.Key
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
+11 -13
View File
@@ -76,24 +76,22 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
>
<KeyboardInsets />
{standalone && <PwaRoutePersistence />}
-1
View File
@@ -1,5 +1,4 @@
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_MODE?: "none" | "origin"
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
+19 -23
View File
@@ -198,30 +198,26 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<Show
when={props.servers.length > 1}
fallback={
<Show when={props.servers[0]}>
{(server) => (
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
>
<HomeProjectList {...props} {...contextMenuProps} server={server()} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(server())?.healthy === false}
onClick={() => props.onChooseProject(server())}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
)}
</Show>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
}
>
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
@@ -311,10 +311,8 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
},
}
}
+38 -43
View File
@@ -2,7 +2,6 @@ import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
import { Show } from "solid-js"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -10,47 +9,43 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<Show when={props.sessions.session.server()}>
{(server) => (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)}
</Show>
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)
}
-14
View File
@@ -366,20 +366,6 @@ export const dict = {
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.password": "Password",
"dialog.server.add.passwordPlaceholder": "password",
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+1 -1
View File
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
name: "Server",
gate: true,
init: (props: {
defaultServer?: ServerConnection.Key
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
@@ -1,60 +0,0 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, serverAddress } from "./pairing"
describe("pairing code", () => {
test("reads the JSON payload emitted by opencode pair", () => {
expect(
decodePairingCode(
JSON.stringify({
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
username: "opencode",
password: "test:password",
}),
),
).toEqual({ urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"], password: "test:password" })
})
test("normalizes and deduplicates addresses while dropping invalid addresses", () => {
expect(
decodePairingCode(
JSON.stringify({
urls: ["https://server.example/", "https://server.example", "file:///etc/passwd"],
username: "opencode",
password: "test-password",
}),
),
).toEqual({ urls: ["https://server.example"], password: "test-password" })
})
test.each([
"not json",
"null",
"[]",
JSON.stringify({ urls: [], username: "opencode", password: "test" }),
JSON.stringify({ urls: ["file:///etc/passwd"], username: "opencode", password: "test" }),
JSON.stringify({ urls: [42], username: "opencode", password: "test" }),
JSON.stringify({ urls: ["https://server.example"], username: "someone", password: "test" }),
JSON.stringify({ urls: ["https://server.example"], username: "opencode", password: 42 }),
JSON.stringify({ urls: ["https://server.example"], username: "opencode" }),
])("rejects an unrelated or malformed QR payload: %s", (value) => {
expect(decodePairingCode(value)).toBeUndefined()
})
})
describe("server address", () => {
test("accepts a bare address and preserves an explicit server path", () => {
expect(serverAddress(" 192.168.1.20:4096/ ")).toBe("http://192.168.1.20:4096")
expect(serverAddress("https://server.example/opencode/")).toBe("https://server.example/opencode")
})
test.each([
"",
"http://",
"file:///tmp/server",
"https://user:password@server.example",
"https://server.example?token=secret",
"https://server.example#fragment",
])("rejects invalid or embedded connection data: %s", (value) => {
expect(serverAddress(value)).toBeUndefined()
})
})
@@ -1,28 +0,0 @@
import { Option, Schema } from "effect"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
if (!normalized || !URL.canParse(normalized)) return
const url = new URL(normalized)
if (url.protocol !== "http:" && url.protocol !== "https:") return
if (url.username || url.password || url.search || url.hash) return
return normalized
}
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
}
@@ -1,62 +0,0 @@
import QrScanner from "qr-scanner"
import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onCancel: () => void
}) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
onMount(() => {
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
)
})
return (
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button variant="neutral" size="large" onClick={props.onCancel}>
{language.t("common.cancel")}
</Button>
</section>
)
}
-119
View File
@@ -1,119 +0,0 @@
[data-component="connect-server"] {
display: flex;
flex: 1;
min-height: 0;
width: 100%;
overflow: auto;
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
.server-connect-content {
display: flex;
flex-direction: column;
width: min(100%, 360px);
margin: auto;
gap: 16px;
}
.server-connect-brand {
width: 160px;
margin-inline: auto;
margin-block-end: 16px;
}
header {
text-align: center;
margin-block-end: 16px;
}
h1 {
font-size: 20px;
line-height: 28px;
font-weight: 530;
margin-block-end: 8px;
}
p {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
form,
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-connect-field {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 13px;
line-height: var(--line-height-compact);
font-weight: 530;
}
[data-component="text-input-v2"] {
width: 100%;
height: 44px;
}
[data-slot="text-input-v2-input"] {
font-size: 16px;
line-height: 24px;
}
button {
min-height: 44px;
width: 100%;
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
footer {
text-align: center;
margin-block-start: 16px;
}
code {
display: inline-block;
margin-block-start: 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
line-height: var(--line-height-base);
background: var(--v2-background-bg-layer-01);
user-select: all;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
-142
View File
@@ -1,142 +0,0 @@
import { lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const request = useMutation(() => ({
mutationFn: async () => {
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
return
}
const http = { url, password: state.password || undefined }
const result = await check(http)
if (!result.healthy) {
setState("error", language.t("server.connect.failed"))
return
}
servers.add({ type: "http", http })
},
onError: () => setState("error", language.t("server.connect.failed")),
}))
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
<div class="server-connect-brand" role="img" aria-label="OpenCode">
<Wordmark />
</div>
<header>
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
<p>{language.t("server.connect.description")}</p>
</header>
<Show
when={!state.scanning}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
onCancel={() => setState("scanning", false)}
onScan={(pairing) =>
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
}
/>
</Suspense>
}
>
<form
onSubmit={(event) => {
event.preventDefault()
if (request.isPending) return
setState("error", "")
request.mutate()
}}
>
<div class="server-connect-field">
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
<TextInput
id="server-connect-url"
name="server"
dir="ltr"
type="text"
inputMode="url"
autocomplete="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
<TextInput
id="server-connect-password"
name="password"
type="password"
autocomplete="current-password"
appearance="large"
value={state.password}
disabled={request.isPending}
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
/>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
</Button>
</form>
<Show when={platform.platform === "web"}>
<Button
variant="neutral"
size="large"
disabled={request.isPending}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
)
}
+8 -14
View File
@@ -3,7 +3,7 @@ import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
@@ -15,9 +15,6 @@ export const File = lazy(() => import("@opencode-ai/session-ui/file").then((modu
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -74,16 +71,13 @@ function TargetServerRoute(props: ParentProps) {
}
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
)
}
@@ -14,19 +14,6 @@ describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("restores layout without an initial server and retains a saved selection", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout())
const decode = Schema.decodeUnknownSync(schema)
const empty = decode({})
expect(empty.home.selection).toEqual({})
expect(decode(Schema.encodeSync(schema)(empty)).home.selection).toEqual({})
const selection = { server: ServerConnection.Key.make("https://server.example.test"), directory: "/project" }
const saved = decode({ home: { selection } })
expect(saved.home.selection).toEqual(selection)
expect(decode(Schema.encodeSync(schema)(saved)).home.selection).toEqual(selection)
})
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
+4 -4
View File
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: Schema.optional(TabStorage.ServerKey),
server: TabStorage.ServerKey,
directory: Schema.optional(Schema.String),
}),
}),
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
),
)
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
@@ -233,7 +233,7 @@ export function initialLayout(server?: ServerConnection.Key): typeof layoutSchem
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: server ? { server } : {} },
home: { selection: { server } },
}
}
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
initialLayout(ServerConnection.key(servers.list[0])),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
+1 -3
View File
@@ -6,9 +6,7 @@ import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
+24
View File
@@ -72,6 +72,30 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
},
}),
Spec.make("uninstall", {
description: "Uninstall OpenCode and remove its user files",
params: {
keepConfig: Flag.boolean("keep-config").pipe(
Flag.withAlias("c"),
Flag.withDescription("Keep configuration files"),
Flag.withDefault(false),
),
keepData: Flag.boolean("keep-data").pipe(
Flag.withAlias("d"),
Flag.withDescription("Keep session data and snapshots"),
Flag.withDefault(false),
),
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription("Show what would be removed without removing it"),
Flag.withDefault(false),
),
force: Flag.boolean("force").pipe(
Flag.withAlias("f"),
Flag.withDescription("Skip confirmation prompts"),
Flag.withDefault(false),
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -0,0 +1,137 @@
import { confirm, intro, log, outro, spinner } from "@clack/prompts"
import { Service } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Installation } from "../../services/installation"
import { ServerConnection } from "../../services/server-connection"
import { ServiceConfig } from "../../services/service-config"
import { handlePromptErrors, prompt, requireInteractive } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.uninstall,
Effect.fn("cli.uninstall")(function* (input) {
intro("Uninstall OpenCode")
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const installation = yield* Installation.make()
const method = yield* installation.method()
log.info(`Installation method: ${method ?? "unknown"}`)
if (!method && !input.dryRun)
return yield* Effect.fail(
new Error(
"Could not detect the installation method. Uninstall OpenCode with your package manager or remove its binary manually.",
),
)
const removal =
method && method !== "curl" && installation.installedPackage
? { method, command: Installation.uninstallCommand(method, installation.installedPackage) }
: undefined
const shell = method === "curl" ? yield* installation.shellChanges() : []
const v1Installed = (!input.keepConfig || !input.keepData) && (yield* installation.v1Installed())
const interactive = !input.force && !input.dryRun
if (interactive)
yield* requireInteractive("Pass --force to uninstall without an interactive terminal, or --dry-run to preview.")
const removeConfig = yield* confirmRemoval({
keep: input.keepConfig,
interactive,
message: v1Installed
? "Delete global OpenCode config? OpenCode v1 also uses it."
: "Delete global OpenCode config?",
})
const removeData = yield* confirmRemoval({
keep: input.keepData,
interactive,
message: v1Installed ? "Delete all OpenCode data? OpenCode v1 also uses it." : "Delete all OpenCode data?",
})
const directories = [
{ path: global.data, label: "Data", keep: !removeData },
{ path: global.cache, label: "Cache", keep: false },
{ path: global.config, label: "Config", keep: !removeConfig },
{ path: global.state, label: "State", keep: false },
]
log.message("Uninstall plan:")
log.info("Stop the local background service")
yield* Effect.forEach(
directories,
(directory) =>
fs
.exists(directory.path)
.pipe(
Effect.flatMap((exists) =>
exists
? Effect.sync(() =>
log.info(`${directory.keep ? "Keep" : "Remove"} ${directory.label}: ${directory.path}`),
)
: Effect.void,
),
),
{ discard: true },
)
shell.forEach((change) => log.info(`Remove installer PATH entry: ${change.path}`))
if (removal) log.info(`Package: ${removal.command.join(" ")}`)
if (method === "curl") log.info(`Binary (manual removal): ${process.execPath}`)
if (v1Installed && (removeConfig || removeData))
log.warn("OpenCode v1 is also installed and uses the files marked for removal.")
if (input.dryRun) {
outro("Dry run - no changes made")
return undefined
}
if (!input.force) {
if (!(yield* prompt(() => confirm({ message: "Proceed with uninstalling OpenCode?", initialValue: false })))) {
outro("Cancelled")
return undefined
}
}
const progress = spinner()
yield* Effect.gen(function* () {
progress.start("Stopping background service...")
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
progress.stop("Background service stopped")
if (removal) {
progress.start(`Running ${removal.command.join(" ")}...`)
yield* installation.uninstall(removal.method)
progress.stop("Package removed")
}
yield* Effect.forEach(
shell,
(change) =>
Effect.gen(function* () {
progress.start(`Cleaning ${change.path}...`)
yield* fs.writeFileString(change.path, change.content)
progress.stop(`Cleaned ${change.path}`)
}),
{ discard: true },
)
yield* Effect.forEach(
directories.filter((directory) => !directory.keep),
(directory) =>
Effect.gen(function* () {
progress.start(`Removing ${directory.label}...`)
yield* fs.remove(directory.path, { recursive: true, force: true })
progress.stop(`Removed ${directory.label}`)
}),
{ discard: true },
)
}).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Uninstall failed", 1))))
if (method === "curl") {
log.message("To finish removing the binary, run:")
log.info(Installation.binaryRemovalCommand())
}
outro("Done")
return undefined
}, handlePromptErrors),
)
function confirmRemoval(input: { readonly keep: boolean; readonly interactive: boolean; readonly message: string }) {
if (input.keep) return Effect.succeed(false)
if (!input.interactive) return Effect.succeed(true)
return prompt(() => confirm({ message: input.message, initialValue: true }))
}
+17 -23
View File
@@ -6,6 +6,16 @@ import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
type Requirements =
| FileSystem.FileSystem
| Global.Service
| Npm.Service
| AppProcess.Service
| Updater.Service
| Config.Service
| Scope.Scope
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -14,29 +24,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Requirements>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (
input: Input<Node>,
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
default: (input: Input<Node>) => Effect.Effect<void, any, Requirements>
}>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Requirements>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
@@ -67,12 +59,14 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
}
if (value.$) {
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
}
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
}
+1
View File
@@ -18,6 +18,7 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
uninstall: () => import("./commands/handlers/uninstall"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
+190
View File
@@ -0,0 +1,190 @@
export * as Installation from "./installation"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Duration, Effect, FileSystem, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type ShellChange = {
readonly path: string
readonly content: string
}
export interface Interface {
readonly installedPackage: string | undefined
readonly method: () => Effect.Effect<Method | undefined>
readonly v1Installed: () => Effect.Effect<boolean>
readonly uninstall: (method: Exclude<Method, "curl">) => Effect.Effect<void, Error>
readonly shellChanges: () => Effect.Effect<ReadonlyArray<ShellChange>>
}
const Manifest = Schema.fromJsonString(
Schema.Struct({
name: Schema.String,
bin: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
)
export const make = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Manifest)))
return Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable)
? manifest.name
: undefined
}).pipe(Effect.orElseSucceed(() => undefined))
const run = Effect.fnUntraced(
function* (command: ReadonlyArray<string>, timeout: Duration.Input = "10 seconds") {
const result = yield* appProcess.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch((error) =>
Effect.succeed({
code: 1,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}),
),
)
const method = Effect.fn("cli.installation.method")(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return undefined
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const v1Installed = Effect.fn("cli.installation.v1-installed")(
function* () {
const curlBinary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode.exe" : "opencode",
)
if (yield* fs.exists(curlBinary)) return true
// Package-manager installs resolve through PATH (including Bun, nvm, pnpm, Yarn, and Homebrew).
yield* appProcess.run(ChildProcess.make("opencode", ["--version"]), {
timeout: "5 seconds",
maxOutputBytes: 10_000,
maxErrorBytes: 10_000,
})
return true
},
Effect.catch(() => Effect.succeed(false)),
)
const uninstall = Effect.fn("cli.installation.uninstall")(function* (method: Exclude<Method, "curl">) {
if (!installedPackage) return yield* Effect.fail(new Error("Could not identify the installed OpenCode package"))
const result = yield* run(uninstallCommand(method, installedPackage), "5 minutes")
if (result.code !== 0)
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`))
return undefined
})
const shellChanges = Effect.fn("cli.installation.shell-changes")(function* () {
const bin = path.join(global.home, ".opencode", "bin")
const files = yield* fs.readDirectory(bin).pipe(Effect.orElseSucceed(() => []))
// V1 and other installed binaries still need this shared PATH entry.
if (files.some((name) => name !== path.basename(process.execPath))) return []
const shell = path.basename(process.env.SHELL || "bash")
const entry = shell === "fish" ? `fish_add_path ${bin}` : `export PATH=${bin}:$PATH`
const changes = yield* Effect.forEach(shellConfigFiles(shell, global.home), (file) =>
fs.readFileString(file).pipe(
Effect.flatMap((content) => {
const updated = removeShellEntry(content, entry)
return Effect.succeed(updated === undefined ? undefined : { path: file, content: updated })
}),
Effect.orElseSucceed(() => undefined),
),
)
return changes.filter((change) => change !== undefined)
})
return { installedPackage, method, v1Installed, uninstall, shellChanges } satisfies Interface
})
export function uninstallCommand(method: Exclude<Method, "curl">, name: string): [string, ...string[]] {
const commands: Record<Exclude<Method, "curl">, [string, ...string[]]> = {
npm: ["npm", "uninstall", "--global", name],
pnpm: ["pnpm", "remove", "--global", name],
bun: ["bun", "remove", "--global", name],
yarn: ["yarn", "global", "remove", name],
}
return commands[method]
}
export function binaryRemovalCommand(executable = process.execPath) {
return process.platform === "win32"
? `Remove-Item -LiteralPath '${executable.replaceAll("'", "''")}'`
: `rm -- '${executable.replaceAll("'", "'\\''")}'`
}
function shellConfigFiles(shell: string, home: string) {
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
const zsh = process.env.ZDOTDIR || home
const candidates: Record<string, string[]> = {
fish: [path.join(home, ".config", "fish", "config.fish")],
zsh: [
path.join(zsh, ".zshrc"),
path.join(zsh, ".zshenv"),
path.join(xdg, "zsh", ".zshrc"),
path.join(xdg, "zsh", ".zshenv"),
],
bash: [
path.join(home, ".bashrc"),
path.join(home, ".bash_profile"),
path.join(home, ".profile"),
path.join(xdg, "bash", ".bashrc"),
path.join(xdg, "bash", ".bash_profile"),
],
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
sh: [path.join(home, ".ashrc"), path.join(home, ".profile")],
}
return [...new Set(candidates[shell] ?? [])]
}
function removeShellEntry(content: string, entry: string) {
const newline = content.includes("\r\n") ? "\r\n" : "\n"
const lines = content.split(/\r?\n/)
const marker = lines.findIndex((line, index) => line === "# opencode" && lines[index + 1] === entry)
if (marker === -1) return undefined
const start = marker > 0 && lines[marker - 1] === "" ? marker - 1 : marker
return [...lines.slice(0, start), ...lines.slice(marker + 2)].join(newline)
}
+6 -34
View File
@@ -6,9 +6,10 @@ import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { Installation } from "./installation"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export const methods = Installation.methods
export type Method = Installation.Method
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
@@ -56,17 +57,10 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installation = yield* Installation.make()
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const installedPackage = installation.installedPackage
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -95,29 +89,7 @@ const make = Effect.gen(function* () {
)
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const method = installation.method
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
+5 -24
View File
@@ -171,18 +171,8 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const appendPart = (ref: PartRef, part: AppendPart) =>
setRows(
produce((draft) => {
if (!hasPart(draft, ref)) {
append(draft, ref, part, queuedStart(draft))
return
}
if (part.type !== "reasoning" || part.time?.completed === undefined) return
const row = draft.find(
(row) =>
row.type === "group" &&
row.kind === "reasoning" &&
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
)
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
if (hasPart(draft, ref)) return
append(draft, ref, part, queuedStart(draft))
}),
)
@@ -260,7 +250,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning", time: { completed: event.created } },
{ type: "reasoning" },
)
}),
data.on("session.tool.input.started", (event) => {
@@ -447,26 +437,17 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
return message.content.filter((part) => part.type === match[1])[ordinal]
}
type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(index, 0, {
type: "group",
kind: "reasoning",
refs: [ref],
completed: part.time?.completed !== undefined,
})
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
return
}
if (part.type === "tool" && exploration(part.name)) {