diff --git a/packages/app/src/runtime/i18n/desktop-native.ts b/packages/app/src/runtime/i18n/desktop-native.ts index d0dfb53594..d858e4fcd0 100644 --- a/packages/app/src/runtime/i18n/desktop-native.ts +++ b/packages/app/src/runtime/i18n/desktop-native.ts @@ -236,6 +236,7 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.menu.window": "Window", "desktop.menu.help": "Help", "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.installCli": "Install CLI...", "desktop.menu.settings": "Settings", "desktop.menu.reloadWebview": "Reload Webview", "desktop.menu.restart": "Restart", @@ -283,6 +284,11 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.updater.dialog.restart": "Restart", "desktop.updater.dialog.later": "Later", + "desktop.cli.installed.title": "CLI Installed", + "desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.", + "desktop.cli.failed.title": "Installation Failed", + "desktop.cli.failed.message": "Failed to install CLI: {{error}}", + "desktop.recovery.action.relaunch": "Relaunch", "desktop.recovery.action.exportLogs": "Export Logs", "desktop.recovery.action.keepWaiting": "Keep Waiting", diff --git a/packages/app/src/shell/commands/desktop-menu.test.ts b/packages/app/src/shell/commands/desktop-menu.test.ts index 79dbe89adb..c51bad9ae5 100644 --- a/packages/app/src/shell/commands/desktop-menu.test.ts +++ b/packages/app/src/shell/commands/desktop-menu.test.ts @@ -2,6 +2,13 @@ import { describe, expect, test } from "bun:test" import { DESKTOP_MENU } from "./desktop-menu" describe("desktop menu", () => { + test("installs the CLI from the macOS application menu", () => { + const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app") + const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli") + + expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" }) + }) + test("exports logs through the desktop command registry", () => { const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( (item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs", diff --git a/packages/app/src/shell/commands/desktop-menu.ts b/packages/app/src/shell/commands/desktop-menu.ts index 970e96a64e..965e980bd4 100644 --- a/packages/app/src/shell/commands/desktop-menu.ts +++ b/packages/app/src/shell/commands/desktop-menu.ts @@ -4,6 +4,7 @@ export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuAction = | "app.checkForUpdates" + | "app.installCli" | "app.relaunch" | "edit.undo" | "edit.redo" @@ -84,6 +85,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ action: "app.checkForUpdates", enabled: "updater", }, + { type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" }, { type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, { type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" }, { type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" }, diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index 8d07e436ce..6e2300e0ec 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc" import { openExternalURL } from "../files" import { checkAppExists, resolveAppPath } from "../files/apps" import { setForceFocus } from "../native/debug" +import { showCliInstaller } from "../native/install-cli" import { DesktopLogging, scoped } from "../native/logging" import { createMenu, sendMenuCommand } from "../native/menu" import { setNativeTranslations } from "../native/translations" @@ -12,6 +13,7 @@ import { IpcPortHandoff } from "../ipc-transport" import { ApplicationLifecycle } from "../lifecycle" import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding" import { BackgroundService } from "../service/background-service" +import { DesktopCli } from "../service/desktop-cli" import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings" import { Updater } from "../updater" import { getLastFocusedWindow, setBackgroundColor } from "../windows" @@ -22,6 +24,7 @@ export const appHandlers = AppRpcs.toLayer( const handoff = yield* IpcPortHandoff const lifecycle = yield* ApplicationLifecycle.Service const background = yield* BackgroundService.Service + const desktopCli = yield* DesktopCli.Service const updater = yield* Updater.Service const logging = yield* DesktopLogging.Service const runFork = Effect.runForkWith(yield* Effect.context()) @@ -56,6 +59,7 @@ export const appHandlers = AppRpcs.toLayer( if (win) sendMenuCommand(win, id) }, checkForUpdates: () => runFork(updater.show), + installCli: () => runFork(showCliInstaller(desktopCli)), createWindow: lifecycle.createWindow, openExternal: (url) => runFork(openExternalURL(url)), relaunch: lifecycle.relaunch, diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 7fb4158dd9..a5c7f96626 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -16,7 +16,9 @@ import { windowHandlers } from "./ipc-handlers/window" import { wslHandlers } from "./ipc-handlers/wsl" import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport" import { ApplicationLifecycle } from "./lifecycle" +import { showCliInstaller } from "./native/install-cli" import { createMenu, sendMenuCommand } from "./native/menu" +import { DesktopCli } from "./service/desktop-cli" import { DesktopStorage } from "./storage" import { Updater } from "./updater" import { getLastFocusedWindow } from "./windows" @@ -42,6 +44,7 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }) export const registerIpcHandlers = Effect.gen(function* () { const handoff = yield* IpcPortHandoff const lifecycle = yield* ApplicationLifecycle.Service + const desktopCli = yield* DesktopCli.Service const updater = yield* Updater.Service const runFork = Effect.runForkWith(yield* Effect.context()) const menu = { @@ -50,6 +53,7 @@ export const registerIpcHandlers = Effect.gen(function* () { if (win) sendMenuCommand(win, id) }, checkForUpdates: () => runFork(updater.show), + installCli: () => runFork(showCliInstaller(desktopCli)), createWindow: lifecycle.createWindow, openExternal: (url: string) => runFork(openExternalURL(url)), relaunch: lifecycle.relaunch, diff --git a/packages/desktop/src/main/native/install-cli.ts b/packages/desktop/src/main/native/install-cli.ts new file mode 100644 index 0000000000..de26641c0c --- /dev/null +++ b/packages/desktop/src/main/native/install-cli.ts @@ -0,0 +1,28 @@ +import { dialog } from "electron" +import { Effect } from "effect" +import { DesktopCli } from "../service/desktop-cli" +import { nativeT } from "./translations" + +export function showCliInstaller(desktopCli: DesktopCli.Interface) { + return desktopCli.install.pipe( + Effect.tap((path) => + Effect.promise(() => + dialog.showMessageBox({ + type: "info", + message: nativeT("desktop.cli.installed.message", { path }), + title: nativeT("desktop.cli.installed.title"), + }), + ), + ), + Effect.catch((error) => + Effect.promise(() => + dialog.showMessageBox({ + type: "error", + message: nativeT("desktop.cli.failed.message", { error: error.message }), + title: nativeT("desktop.cli.failed.title"), + }), + ), + ), + Effect.asVoid, + ) +} diff --git a/packages/desktop/src/main/native/menu-actions.ts b/packages/desktop/src/main/native/menu-actions.ts index 831b948782..9e04acdb5a 100644 --- a/packages/desktop/src/main/native/menu-actions.ts +++ b/packages/desktop/src/main/native/menu-actions.ts @@ -4,6 +4,7 @@ import { updateTitlebar } from "../windows" export type DesktopMenuActionHandlers = Partial<{ checkForUpdates: () => void + installCli: () => void createWindow: () => void relaunch: () => void }> @@ -17,6 +18,9 @@ export function runDesktopMenuAction( case "app.checkForUpdates": handlers.checkForUpdates?.() return + case "app.installCli": + handlers.installCli?.() + return case "app.relaunch": handlers.relaunch?.() return diff --git a/packages/desktop/src/main/native/menu.ts b/packages/desktop/src/main/native/menu.ts index 535eb8c7b0..44121e2c79 100644 --- a/packages/desktop/src/main/native/menu.ts +++ b/packages/desktop/src/main/native/menu.ts @@ -16,6 +16,7 @@ import { nativeT } from "./translations" type Deps = { trigger: (id: string) => void checkForUpdates: () => void + installCli: () => void createWindow: () => void openExternal: (url: string) => void relaunch: () => void @@ -60,6 +61,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt item.click = () => runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, { checkForUpdates: deps.checkForUpdates, + installCli: deps.installCli, createWindow: deps.createWindow, relaunch: deps.relaunch, }) diff --git a/packages/desktop/src/main/service/desktop-cli.ts b/packages/desktop/src/main/service/desktop-cli.ts index 1602730b41..ad03252024 100644 --- a/packages/desktop/src/main/service/desktop-cli.ts +++ b/packages/desktop/src/main/service/desktop-cli.ts @@ -1,9 +1,10 @@ export * as DesktopCli from "./desktop-cli" -import { execFile } from "node:child_process" +import { execFile, spawn } from "node:child_process" import { promisify } from "node:util" import { app } from "electron" import { Context, Effect, FileSystem, Layer, Path } from "effect" +import installer from "../../../../../install?raw" import { DesktopPaths } from "../paths" import { parseCliVersion } from "./cli-version" @@ -18,6 +19,7 @@ export interface Resolved { export interface Interface { readonly resolve: Effect.Effect + readonly install: Effect.Effect } export class Service extends Context.Service()("opencode/desktop/DesktopCli") {} @@ -25,10 +27,19 @@ export class Service extends Context.Service()("opencode/des export const layer = Layer.effect( Service, Effect.gen(function* () { + const path = yield* Path.Path const resolve = yield* Effect.cached( make().pipe(Effect.provide(yield* Effect.context()), Effect.orDie), ) - return Service.of({ resolve }) + const install = Effect.gen(function* () { + if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS")) + const cli = yield* resolve + if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable")) + const home = app.getPath("home") + yield* runInstaller(cli.binary, home) + return path.join(home, ".opencode", "bin", "opencode2") + }) + return Service.of({ resolve, install }) }), ) @@ -134,6 +145,27 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[ return stdout }) +const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) { + yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], { + env: { ...process.env, HOME: home }, + stdio: ["pipe", "ignore", "pipe"], + }) + let stderr = "" + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) return resolve() + reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`)) + }) + child.stdin.end(installer) + }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) +}) + function executableName() { return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" } diff --git a/packages/desktop/src/shared/ipc-rpc/menu.ts b/packages/desktop/src/shared/ipc-rpc/menu.ts index c20c0c2ece..c64d8e9bcf 100644 --- a/packages/desktop/src/shared/ipc-rpc/menu.ts +++ b/packages/desktop/src/shared/ipc-rpc/menu.ts @@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc" const DesktopMenuAction = Schema.Literals([ "app.checkForUpdates", + "app.installCli", "app.relaunch", "edit.undo", "edit.redo",