mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-16 01:06:45 +00:00
Compare commits
4 Commits
production
...
server-dis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f64f29fb | ||
|
|
13822b9424 | ||
|
|
09549661e1 | ||
|
|
da495fd2e0 |
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
- ci
|
||||
- dev
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -100,7 +100,11 @@ const providerHttpErrorsQuery = () => {
|
||||
name: "FAILED",
|
||||
column: failedProviderHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }, { column: "llm.error.code", op: "!=", value: "404" }],
|
||||
filters: [
|
||||
...filters,
|
||||
{ column: "event_type", op: "=", value: "llm.error" },
|
||||
{ column: "llm.error.code", op: "!=", value: "404" },
|
||||
],
|
||||
},
|
||||
],
|
||||
formulas: [
|
||||
|
||||
@@ -244,6 +244,7 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -1,102 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "windows"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
}
|
||||
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binaryName)
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, the .exe is already included in the package and bin field points to it
|
||||
// No postinstall setup needed
|
||||
console.log("Windows detected: binary setup not needed (using packaged .exe)")
|
||||
return
|
||||
}
|
||||
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
fs.linkSync(binaryPath, target)
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
return false
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
void main()
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -32,22 +32,32 @@ console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')",
|
||||
"process.exit(1)",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs).option("discoverable", {
|
||||
type: "boolean",
|
||||
describe: "write this server to the local discovery file for default TUI startup",
|
||||
default: false,
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
|
||||
if (discovery) {
|
||||
yield* discovery.write(server.url)
|
||||
process.on("exit", ServerDiscovery.removeSync)
|
||||
}
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
yield* Effect.never.pipe(
|
||||
Effect.ensuring(
|
||||
discovery
|
||||
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
|
||||
network.mdns ||
|
||||
network.port !== 0 ||
|
||||
network.hostname !== "127.0.0.1"
|
||||
const discovered = external ? undefined : await ServerDiscovery.find()
|
||||
|
||||
const transport = external
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: discovered
|
||||
? {
|
||||
url: discovered,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
headers: undefined,
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
@@ -239,6 +252,7 @@ export const TuiThreadCommand = cmd({
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
|
||||
112
packages/opencode/src/cli/server-discovery.ts
Normal file
112
packages/opencode/src/cli/server-discovery.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export * as ServerDiscovery from "./server-discovery"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { readFileSync, unlinkSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export const file = path.join(Global.Path.state, "server.json")
|
||||
|
||||
const Entry = Schema.Struct({
|
||||
url: Schema.String,
|
||||
pid: Schema.Number,
|
||||
})
|
||||
type Entry = typeof Entry.Type
|
||||
const decodeEntry = Schema.decodeUnknownOption(Entry)
|
||||
|
||||
export interface Interface {
|
||||
readonly write: (url: URL) => Effect.Effect<void>
|
||||
readonly remove: () => Effect.Effect<void>
|
||||
readonly find: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const read = Effect.fn("CliServerDiscovery.read")(function* () {
|
||||
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return Option.getOrUndefined(decodeEntry(entry))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
|
||||
const entry = yield* read()
|
||||
if (entry?.pid !== process.pid) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
|
||||
const current = yield* read()
|
||||
if (current?.pid !== entry.pid || current.url !== entry.url) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
|
||||
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
|
||||
}),
|
||||
remove,
|
||||
find: Effect.fn("CliServerDiscovery.find")(function* () {
|
||||
const entry = yield* read()
|
||||
if (!entry) return undefined
|
||||
const url = yield* healthy(entry.url)
|
||||
if (url) return url
|
||||
yield* removeStale(entry)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const find = () => runPromise((discovery) => discovery.find())
|
||||
|
||||
export function removeSync() {
|
||||
const entry = readSync()
|
||||
if (entry?.pid !== process.pid) return
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readSync() {
|
||||
try {
|
||||
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function healthy(input: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const url = new URL(input)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
||||
const response = await fetch(new URL("/global/health", url), {
|
||||
headers: ServerAuth.headers(),
|
||||
signal: AbortSignal.timeout(1000),
|
||||
})
|
||||
if (!response.ok) return undefined
|
||||
const body = (await response.json()) as unknown
|
||||
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
|
||||
return url.toString()
|
||||
}
|
||||
},
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function localURL(url: URL) {
|
||||
const result = new URL(url)
|
||||
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
|
||||
if (result.hostname === "::") result.hostname = "::1"
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user