mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 01:46:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5921c86fc | ||
|
|
827e9a7160 |
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 />}
|
||||
|
||||
Vendored
-1
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user