This commit is contained in:
Dax Raad
2026-04-19 20:16:25 -04:00
parent eb36b4d381
commit 64dc81cee0
8 changed files with 0 additions and 569 deletions

View File

@@ -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: () => {},
})

View File

@@ -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<InstallResult, ServiceError>
readonly password: () => Effect.Effect<string, ServiceError>
}
export class Service extends Context.Service<Service, Interface>()("@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),
)

View File

@@ -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 })
}),
)

View File

@@ -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 [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
"<dict>",
" <key>Label</key>",
` <string>${escapeXml(MAC_LABEL)}</string>`,
" <key>ProgramArguments</key>",
" <array>",
` <string>${escapeXml(UNIX_LAUNCHER)}</string>`,
" </array>",
" <key>KeepAlive</key>",
" <true/>",
" <key>RunAtLoad</key>",
" <true/>",
" <key>WorkingDirectory</key>",
` <string>${escapeXml(Global.Path.home)}</string>`,
" <key>StandardOutPath</key>",
` <string>${escapeXml(stdoutLogFile)}</string>`,
" <key>StandardErrorPath</key>",
` <string>${escapeXml(stderrLogFile)}</string>`,
"</dict>",
"</plist>",
"",
].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 })
}),
)

View File

@@ -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<PlatformInstallResult, ServiceError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ServicePlatform") {}

View File

@@ -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<typeof StoredConfig>
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>()("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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;")
}
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}`)))
}

View File

@@ -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}` })),
}),
)

View File

@@ -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 })
}),
)