diff --git a/packages/opencode/src/cli/cmd/service.ts b/packages/opencode/src/cli/cmd/service.ts deleted file mode 100644 index 490064a1ba..0000000000 --- a/packages/opencode/src/cli/cmd/service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { ServiceManager } from "@/service" -import { UI } from "../ui" -import type { Argv } from "yargs" -import { cmd } from "./cmd" - -function exitWithError(error: unknown): never { - UI.error(error instanceof Error ? error.message : String(error)) - process.exit(1) -} - -const InstallCommand = cmd({ - command: "install", - describe: "install opencode as a background service", - builder: (yargs: Argv) => - yargs - .option("password", { - type: "string", - describe: "basic auth password (defaults to OPENCODE_PASSWORD, then OPENCODE_SERVER_PASSWORD, else random)", - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - }), - handler: async (args: { password?: string; hostname?: string }) => { - const result = await AppRuntime.runPromise(ServiceManager.Service.use((svc) => svc.install(args))).catch(exitWithError) - - console.log(`Installed OpenCode service on ${result.platform}`) - console.log(`Target: ${result.target}`) - console.log(`Hostname: ${result.hostname}`) - console.log(`Password: ${result.password}`) - }, -}) - -const PasswordCommand = cmd({ - command: "password", - describe: "print the installed service password", - handler: async () => { - const password = await AppRuntime.runPromise(ServiceManager.Service.use((svc) => svc.password())).catch(exitWithError) - - console.log(password) - }, -}) - -export const ServiceCommand = cmd({ - command: "service", - describe: "manage the background opencode service", - builder: (yargs: Argv) => yargs.command(InstallCommand).command(PasswordCommand).demandCommand(), - handler: () => {}, -}) diff --git a/packages/opencode/src/service/index.ts b/packages/opencode/src/service/index.ts deleted file mode 100644 index 0f6444ee59..0000000000 --- a/packages/opencode/src/service/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -export * as ServiceManager from "." - -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Context, Effect, Layer } from "effect" -import * as ServiceLinux from "./linux" -import * as ServiceMacos from "./macos" -import * as ServicePlatform from "./platform" -import { - chmodFile, - CONFIG_FILE, - generatePassword, - type InstallInput, - type InstallResult, - readStoredConfig, - resolveServeCommand, - ServiceError, - writeJsonFile, -} from "./shared" -import * as ServiceUnsupported from "./unsupported" -import * as ServiceWindows from "./windows" - -export interface Interface { - readonly install: (input: InstallInput) => Effect.Effect - readonly password: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ServiceManager") {} - -const platformLayer = - process.platform === "linux" - ? ServiceLinux.layer - : process.platform === "darwin" - ? ServiceMacos.layer - : process.platform === "win32" - ? ServiceWindows.layer - : ServiceUnsupported.layer - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const platform = yield* ServicePlatform.Service - - const install = Effect.fn("ServiceManager.install")(function* (input: InstallInput) { - const stored = yield* readStoredConfig(fs) - const password = - input.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD ?? stored?.password ?? generatePassword() - const hostname = input.hostname ?? stored?.hostname ?? "127.0.0.1" - - yield* writeJsonFile(fs, CONFIG_FILE, { password, hostname }, 0o600) - yield* chmodFile(fs, CONFIG_FILE, 0o600).pipe(Effect.catch(() => Effect.void)) - - const result = yield* platform.install({ - password, - hostname, - command: yield* resolveServeCommand(fs, hostname), - }) - - return { - password, - hostname, - platform: process.platform, - target: result.target, - } - }) - - const password = Effect.fn("ServiceManager.password")(function* () { - const stored = yield* readStoredConfig(fs) - if (stored?.password) return stored.password - if (process.env.OPENCODE_PASSWORD) return process.env.OPENCODE_PASSWORD - if (process.env.OPENCODE_SERVER_PASSWORD) return process.env.OPENCODE_SERVER_PASSWORD - return yield* new ServiceError({ - message: "OpenCode service password is not configured. Run `opencode service install` first.", - }) - }) - - return Service.of({ install, password }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provideMerge(platformLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), -) diff --git a/packages/opencode/src/service/linux.ts b/packages/opencode/src/service/linux.ts deleted file mode 100644 index 48c05b2815..0000000000 --- a/packages/opencode/src/service/linux.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Effect, Layer } from "effect" -import { ChildProcessSpawner } from "effect/unstable/process" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as ServicePlatform from "./platform" -import { - buildUnixLauncher, - chmodFile, - quoteSystemd, - UNIX_LAUNCHER, - writeTextFile, - ServiceError, -} from "./shared" -import path from "path" -import { Global } from "@/global" -import { ChildProcess } from "effect/unstable/process" -import { Stream } from "effect" - -const SYSTEMD_SERVICE_NAME = "opencode.service" -const systemdUnitFile = path.join(path.dirname(Global.Path.config), "systemd", "user", SYSTEMD_SERVICE_NAME) - -function buildLinuxUnit() { - return [ - "[Unit]", - "Description=OpenCode background server", - "", - "[Service]", - "Type=simple", - `ExecStart=${quoteSystemd(UNIX_LAUNCHER)}`, - "WorkingDirectory=%h", - "Restart=on-failure", - "RestartSec=5", - "", - "[Install]", - "WantedBy=default.target", - "", - ].join("\n") -} - -export const layer: Layer.Layer< - ServicePlatform.Service, - never, - ChildProcessSpawner.ChildProcessSpawner | AppFileSystem.Service -> = Layer.effect( - ServicePlatform.Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fnUntraced( - function* (command: string[]) { - const handle = yield* spawner.spawn( - ChildProcess.make(command[0], command.slice(1), { extendEnv: true, stdin: "ignore" }), - ) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - if (code === 0) return { stdout, stderr } - return yield* new ServiceError({ - message: stderr.trim() || stdout.trim() || `Command failed: ${command.join(" ")}`, - }) - }, - Effect.scoped, - Effect.catch((cause) => { - if (cause instanceof ServiceError) return Effect.fail(cause) - return Effect.fail(new ServiceError({ message: "Failed to execute service manager command", cause })) - }), - ) - - const install = Effect.fn("ServiceLinux.install")(function* (input) { - yield* writeTextFile(fs, UNIX_LAUNCHER, buildUnixLauncher(input.command, input.password), 0o700) - yield* chmodFile(fs, UNIX_LAUNCHER, 0o700) - yield* writeTextFile(fs, systemdUnitFile, buildLinuxUnit()) - yield* run(["systemctl", "--user", "daemon-reload"]) - yield* run(["systemctl", "--user", "enable", "--now", SYSTEMD_SERVICE_NAME]) - yield* run(["systemctl", "--user", "restart", SYSTEMD_SERVICE_NAME]) - return { target: systemdUnitFile } - }) - - return ServicePlatform.Service.of({ install }) - }), -) diff --git a/packages/opencode/src/service/macos.ts b/packages/opencode/src/service/macos.ts deleted file mode 100644 index b09c795995..0000000000 --- a/packages/opencode/src/service/macos.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Effect, Layer } from "effect" -import { ChildProcessSpawner } from "effect/unstable/process" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as ServicePlatform from "./platform" -import { - buildUnixLauncher, - chmodFile, - escapeXml, - stderrLogFile, - ServiceError, - stdoutLogFile, - UNIX_LAUNCHER, - writeTextFile, -} from "./shared" -import { Global } from "@/global" -import path from "path" -import os from "os" -import { ChildProcess } from "effect/unstable/process" -import { Stream } from "effect" - -const MAC_LABEL = "ai.opencode.service" -const macLaunchAgentFile = path.join(Global.Path.home, "Library", "LaunchAgents", `${MAC_LABEL}.plist`) - -function buildLaunchAgent() { - return [ - '', - '', - '', - "", - " Label", - ` ${escapeXml(MAC_LABEL)}`, - " ProgramArguments", - " ", - ` ${escapeXml(UNIX_LAUNCHER)}`, - " ", - " KeepAlive", - " ", - " RunAtLoad", - " ", - " WorkingDirectory", - ` ${escapeXml(Global.Path.home)}`, - " StandardOutPath", - ` ${escapeXml(stdoutLogFile)}`, - " StandardErrorPath", - ` ${escapeXml(stderrLogFile)}`, - "", - "", - "", - ].join("\n") -} - -export const layer: Layer.Layer< - ServicePlatform.Service, - never, - ChildProcessSpawner.ChildProcessSpawner | AppFileSystem.Service -> = Layer.effect( - ServicePlatform.Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fnUntraced( - function* (command: string[]) { - const handle = yield* spawner.spawn( - ChildProcess.make(command[0], command.slice(1), { extendEnv: true, stdin: "ignore" }), - ) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - if (code === 0) return { stdout, stderr } - return yield* new ServiceError({ - message: stderr.trim() || stdout.trim() || `Command failed: ${command.join(" ")}`, - }) - }, - Effect.scoped, - Effect.catch((cause) => { - if (cause instanceof ServiceError) return Effect.fail(cause) - return Effect.fail(new ServiceError({ message: "Failed to execute service manager command", cause })) - }), - ) - - const install = Effect.fn("ServiceMacos.install")(function* (input) { - const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid - const domain = `gui/${uid}` - - yield* writeTextFile(fs, UNIX_LAUNCHER, buildUnixLauncher(input.command, input.password), 0o700) - yield* chmodFile(fs, UNIX_LAUNCHER, 0o700) - yield* writeTextFile(fs, macLaunchAgentFile, buildLaunchAgent()) - yield* run(["launchctl", "bootout", domain, macLaunchAgentFile]).pipe(Effect.catch(() => Effect.void)) - yield* run(["launchctl", "bootstrap", domain, macLaunchAgentFile]) - yield* run(["launchctl", "kickstart", "-k", `${domain}/${MAC_LABEL}`]) - return { target: macLaunchAgentFile } - }) - - return ServicePlatform.Service.of({ install }) - }), -) diff --git a/packages/opencode/src/service/platform.ts b/packages/opencode/src/service/platform.ts deleted file mode 100644 index 43892bae91..0000000000 --- a/packages/opencode/src/service/platform.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context, Effect } from "effect" -import type { PlatformInstallInput, PlatformInstallResult } from "./shared" -import { ServiceError } from "./shared" - -export interface Interface { - readonly install: (input: PlatformInstallInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ServicePlatform") {} diff --git a/packages/opencode/src/service/shared.ts b/packages/opencode/src/service/shared.ts deleted file mode 100644 index 512ffd9f56..0000000000 --- a/packages/opencode/src/service/shared.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Global } from "@/global" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Effect, Option, Schema } from "effect" -import path from "path" - -export const SERVICE_DIR = path.join(Global.Path.data, "service") -export const CONFIG_FILE = path.join(SERVICE_DIR, "config.json") -export const UNIX_LAUNCHER = path.join(SERVICE_DIR, "run.sh") -export const WINDOWS_LAUNCHER = path.join(SERVICE_DIR, "run.ps1") -export const stdoutLogFile = path.join(Global.Path.log, "service.stdout.log") -export const stderrLogFile = path.join(Global.Path.log, "service.stderr.log") - -export const StoredConfig = Schema.Struct({ - password: Schema.String, - hostname: Schema.String, -}) - -export type StoredConfig = Schema.Schema.Type - -export interface InstallInput { - readonly password?: string - readonly hostname?: string -} - -export interface InstallResult { - readonly password: string - readonly hostname: string - readonly platform: NodeJS.Platform - readonly target: string -} - -export interface PlatformInstallInput { - readonly password: string - readonly hostname: string - readonly command: string[] -} - -export interface PlatformInstallResult { - readonly target: string -} - -export class ServiceError extends Schema.TaggedErrorClass()("ServiceError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export const fail = (message: string) => (cause: unknown) => new ServiceError({ message, cause }) - -export function generatePassword() { - const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_" - return Array.from(crypto.getRandomValues(new Uint8Array(24)), (byte) => alphabet[byte % alphabet.length]).join("") -} - -export function quoteShell(value: string) { - return `'${value.replaceAll("'", `'"'"'`)}'` -} - -export function quotePowerShell(value: string) { - return `'${value.replaceAll("'", "''")}'` -} - -export function quoteSystemd(value: string) { - return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` -} - -export function quoteWindowsArg(value: string) { - if (!value) return '""' - if (!/[\s"]/u.test(value)) return value - let result = '"' - let slashes = 0 - for (const char of value) { - if (char === "\\") { - slashes += 1 - continue - } - if (char === '"') { - result += "\\".repeat(slashes * 2 + 1) + char - slashes = 0 - continue - } - result += "\\".repeat(slashes) + char - slashes = 0 - } - return result + "\\".repeat(slashes * 2) + '"' -} - -export function escapeXml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") -} - -function usesScriptRuntime() { - const basename = path.basename(process.execPath).toLowerCase() - return basename === "node" || basename === "node.exe" || basename === "bun" || basename === "bun.exe" -} - -export function resolveServeCommand(fs: AppFileSystem.Interface, hostname: string) { - return Effect.gen(function* () { - if (usesScriptRuntime() && process.argv[1] && (yield* fs.existsSafe(process.argv[1]).pipe(Effect.orElseSucceed(() => false)))) { - return [process.execPath, ...process.execArgv, process.argv[1], "serve", "--hostname", hostname] - } - return [process.execPath, "serve", "--hostname", hostname] - }) -} - -export function buildUnixLauncher(command: string[], password: string) { - return [ - "#!/bin/sh", - "set -eu", - `export OPENCODE_SERVER_PASSWORD=${quoteShell(password)}`, - `exec ${command.map(quoteShell).join(" ")}`, - "", - ].join("\n") -} - -export function buildWindowsLauncher(command: string[], password: string) { - const args = command.slice(1).map(quotePowerShell).join(", ") - return [ - "$ErrorActionPreference = 'Stop'", - `[Environment]::SetEnvironmentVariable('OPENCODE_SERVER_PASSWORD', ${quotePowerShell(password)}, 'Process')`, - `$arguments = @(${args})`, - `& ${quotePowerShell(command[0])} @arguments`, - "exit $LASTEXITCODE", - "", - ].join("\r\n") -} - -export function readStoredConfig(fs: AppFileSystem.Interface) { - const decodeStored = Schema.decodeUnknownOption(StoredConfig) - return Effect.gen(function* () { - const exists = yield* fs.existsSafe(CONFIG_FILE).pipe(Effect.mapError(fail(`Failed to access ${CONFIG_FILE}`))) - if (!exists) return undefined - const raw = yield* fs.readJson(CONFIG_FILE).pipe(Effect.mapError(fail(`Failed to read ${CONFIG_FILE}`))) - return Option.getOrUndefined(decodeStored(raw)) - }) -} - -export function writeTextFile(fs: AppFileSystem.Interface, file: string, content: string, mode?: number) { - return fs.writeWithDirs(file, content, mode).pipe(Effect.mapError(fail(`Failed to write ${file}`))) -} - -export function writeJsonFile(fs: AppFileSystem.Interface, file: string, content: unknown, mode?: number) { - return fs.writeWithDirs(file, JSON.stringify(content, null, 2), mode).pipe(Effect.mapError(fail(`Failed to write ${file}`))) -} - -export function chmodFile(fs: AppFileSystem.Interface, file: string, mode: number) { - if (process.platform === "win32") return Effect.void - return fs.chmod(file, mode).pipe(Effect.mapError(fail(`Failed to update permissions for ${file}`))) -} diff --git a/packages/opencode/src/service/unsupported.ts b/packages/opencode/src/service/unsupported.ts deleted file mode 100644 index 40609dc4b4..0000000000 --- a/packages/opencode/src/service/unsupported.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Effect, Layer } from "effect" -import * as ServicePlatform from "./platform" -import { ServiceError } from "./shared" - -export const layer = Layer.succeed( - ServicePlatform.Service, - ServicePlatform.Service.of({ - install: () => Effect.fail(new ServiceError({ message: `Unsupported platform: ${process.platform}` })), - }), -) diff --git a/packages/opencode/src/service/windows.ts b/packages/opencode/src/service/windows.ts deleted file mode 100644 index aa0cce2b3a..0000000000 --- a/packages/opencode/src/service/windows.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Effect, Layer } from "effect" -import { ChildProcessSpawner } from "effect/unstable/process" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as ServicePlatform from "./platform" -import { buildWindowsLauncher, quoteWindowsArg, ServiceError, WINDOWS_LAUNCHER, writeTextFile } from "./shared" -import { ChildProcess } from "effect/unstable/process" -import { Stream } from "effect" - -const WINDOWS_TASK_NAME = "OpenCode Service" - -function buildWindowsTaskCommand() { - return [ - "powershell.exe", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-WindowStyle", - "Hidden", - "-File", - WINDOWS_LAUNCHER, - ] - .map(quoteWindowsArg) - .join(" ") -} - -export const layer: Layer.Layer< - ServicePlatform.Service, - never, - ChildProcessSpawner.ChildProcessSpawner | AppFileSystem.Service -> = Layer.effect( - ServicePlatform.Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fnUntraced( - function* (command: string[]) { - const handle = yield* spawner.spawn( - ChildProcess.make(command[0], command.slice(1), { extendEnv: true, stdin: "ignore" }), - ) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - if (code === 0) return { stdout, stderr } - return yield* new ServiceError({ - message: stderr.trim() || stdout.trim() || `Command failed: ${command.join(" ")}`, - }) - }, - Effect.scoped, - Effect.catch((cause) => { - if (cause instanceof ServiceError) return Effect.fail(cause) - return Effect.fail(new ServiceError({ message: "Failed to execute service manager command", cause })) - }), - ) - - const install = Effect.fn("ServiceWindows.install")(function* (input) { - yield* writeTextFile(fs, WINDOWS_LAUNCHER, buildWindowsLauncher(input.command, input.password)) - yield* run(["schtasks", "/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.catch(() => Effect.void)) - yield* run([ - "schtasks", - "/create", - "/tn", - WINDOWS_TASK_NAME, - "/sc", - "onlogon", - "/tr", - buildWindowsTaskCommand(), - "/f", - ]) - yield* run(["schtasks", "/run", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.catch(() => Effect.void)) - return { target: WINDOWS_TASK_NAME } - }) - - return ServicePlatform.Service.of({ install }) - }), -)