node v2 cli support (#36309)

This commit is contained in:
Simon Klee 2026-07-17 14:45:06 +02:00 committed by GitHub
commit a1b274e6f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1502 additions and 367 deletions

View file

@ -11,10 +11,12 @@ import { Flag } from "../flag/flag"
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
const require = createRequire(import.meta.url)
export const Event = { Updated: FileSystem.Event.Changed }
@ -22,7 +24,8 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch {

View file

@ -0,0 +1,4 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
export default photonWasm

View file

@ -0,0 +1,4 @@
import { createRequire } from "node:module"
export default process.env.OPENCODE_PHOTON_WASM_PATH ??
createRequire(import.meta.url).resolve("@silvia-odwyer/photon-node/photon_rs_bg.wasm")

View file

@ -1,5 +1,4 @@
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import photonWasm from "#photon-wasm"
import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"

18
packages/core/src/node-ffi.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
declare module "node:ffi" {
type Signature = {
readonly arguments?: readonly string[]
readonly return?: string
}
type ForeignFunction = (...args: ReadonlyArray<unknown>) => number | bigint
export function dlopen(
path: string,
definitions: Readonly<Record<string, Signature>>,
): {
readonly lib: { close(): void }
readonly functions: Readonly<Record<string, ForeignFunction>>
}
export function getInt32(pointer: number | bigint, offset?: number): number
}

View file

@ -12,6 +12,7 @@ import { filesystem } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
import { resolveModule } from "#runtime-import"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
@ -54,9 +55,7 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[
const entrypoint = subpaths
.map((subpath) => {
try {
return typeof Bun !== "undefined"
? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir)
: import.meta.resolve(dir)
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
} catch {
return undefined
}

View file

@ -2,6 +2,7 @@ import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { importModule } from "#runtime-import"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
@ -17,11 +18,9 @@ export const DynamicProviderPlugin = define({
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => {
return (await import(
installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
)) as Record<string, (options: any) => any>
}).pipe(Effect.orDie)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)

View file

@ -3,6 +3,7 @@ import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
import { importModule } from "#runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
@ -22,9 +23,9 @@ export const SapAICorePlugin = define({
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
const mod: Record<string, unknown> = yield* Effect.promise(
() => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
const factory = mod[match]

View file

@ -39,6 +39,7 @@ import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
import { importModule } from "#runtime-import"
const PluginModule = Schema.Struct({
default: Schema.Union([
@ -164,9 +165,13 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined ? entrypoint : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
operation.mtime === undefined
? entrypoint
: typeof Bun !== "undefined"
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {

View file

@ -1,11 +1,11 @@
export * as ProviderV2 from "./provider"
import { Effect, Schema } from "effect"
import { pathToFileURL } from "url"
import { Provider } from "@opencode-ai/schema/provider"
import type { ProviderPackageDefinition } from "@opencode-ai/ai"
import { Npm } from "./npm"
import type { DeepMutable } from "./schema"
import { importModule, resolveModule } from "#runtime-import"
export const ID = Provider.ID
export type ID = typeof ID.Type
@ -32,8 +32,24 @@ export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderV2.
export type ProviderPackage = ProviderPackageDefinition
const packages = new Map<string, Promise<unknown>>()
const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")],
["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")],
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
["@opencode-ai/ai/providers/openai-compatible", () => import("@opencode-ai/ai/providers/openai-compatible")],
["@opencode-ai/ai/providers/openrouter", () => import("@opencode-ai/ai/providers/openrouter")],
["@opencode-ai/ai/providers/xai", () => import("@opencode-ai/ai/providers/xai")],
])
export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) {
const builtin = builtins.get(specifier)
if (builtin) return yield* importPackage(specifier, specifier, builtin)
const resolved = yield* Effect.sync(() => {
if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/ai/")) return specifier
try {
@ -53,7 +69,8 @@ export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specif
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href),
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
@ -116,12 +133,16 @@ export type Info = Provider.Info
export type MutableInfo = DeepMutable<Info>
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) {
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (
specifier: string,
entrypoint: string,
load = () => importModule(entrypoint),
) {
const module = yield* Effect.tryPromise({
try: () => {
const existing = packages.get(entrypoint)
if (existing) return existing
const loaded = import(entrypoint)
const loaded = load()
packages.set(entrypoint, loaded)
return loaded
},

View file

@ -1,11 +1,15 @@
// ast-grep-ignore: no-star-import
import * as pty from "@lydell/node-pty"
import { createRequire } from "node:module"
import { isSea } from "node:sea"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
const pty = createRequire(import.meta.url)(
process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty",
) as typeof import("@lydell/node-pty")
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
const proc = pty.spawn(file, args, process.platform === "win32" && isSea() ? { ...opts, useConptyDll: true } : opts)
return {
pid: proc.pid,
onData(listener) {

View file

@ -0,0 +1,7 @@
export function importModule(specifier: string) {
return import(specifier) as Promise<unknown>
}
export function resolveModule(specifier: string, directory: string) {
return import.meta.resolve(specifier, directory)
}

View file

@ -0,0 +1,39 @@
import { Script, constants } from "node:vm"
import { createRequire, registerHooks } from "node:module"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { resolve, type Package } from "resolve.exports"
let conditions: readonly string[] = []
const conditionHooks = registerHooks({
resolve(specifier, context, nextResolve) {
conditions = context.conditions
return nextResolve(specifier, context)
},
})
await new Script('import("node:module")', {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()
conditionHooks.deregister()
export async function importModule(specifier: string) {
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()) as unknown
if (typeof imported !== "object" || imported === null) return imported
const module = imported as Record<string, unknown>
const exports = module["module.exports"]
if (exports !== module.default || (typeof exports !== "object" && typeof exports !== "function") || exports === null)
return imported
return Object.assign({}, module, exports)
}
export function resolveModule(specifier: string, directory: string) {
const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package
const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0]
if (target) return pathToFileURL(path.resolve(directory, target)).href
const legacyTarget =
specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1))
return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href
}

View file

@ -8,6 +8,7 @@ import { Global } from "../global"
import { makeGlobalNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import { AbsolutePath } from "../schema"
import { Hash } from "../util/hash"
const skillConcurrency = 4
const fileConcurrency = 8
@ -110,7 +111,7 @@ const layer = Layer.effect(
)
if (!data) return []
const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16))
const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base))
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {

View file

@ -0,0 +1,50 @@
import { dlopen, read, type Pointer } from "bun:ffi"
import { existsSync } from "node:fs"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { args: ["i32", "i32"], returns: "i32" },
__error: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
export function lockLinux(fd: number): LockResult {
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
flock: { args: ["i32", "i32"], returns: "i32" },
__errno_location: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function errorCode(pointer: Pointer | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}

View file

@ -0,0 +1,43 @@
import { dlopen, getInt32 } from "node:ffi"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__error: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0)
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}
export function lockLinux(fd: number): LockResult {
const library = dlopen("libc.so.6", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__errno_location: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0)
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}

View file

@ -1,5 +1,5 @@
import { dlopen, read, type Pointer } from "bun:ffi"
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"
import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi"
import { closeSync, mkdirSync, openSync } from "node:fs"
import { connect, createServer, type Server, type Socket } from "node:net"
import path from "node:path"
import { Effect, Schema } from "effect"
@ -76,60 +76,12 @@ export namespace ProcessLock {
})
}
type Result =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
function lock(fd: number): Result {
function lock(fd: number): LockResult {
if (process.platform === "darwin") return lockDarwin(fd)
if (process.platform === "linux") return lockLinux(fd)
throw new Error(`Unsupported process lock platform: ${process.platform}`)
}
function lockDarwin(fd: number): Result {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { args: ["i32", "i32"], returns: "i32" },
__error: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function lockLinux(fd: number): Result {
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
flock: { args: ["i32", "i32"], returns: "i32" },
__errno_location: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function errorCode(pointer: Pointer | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}
function acquireWindows(file: string) {
return Effect.callback<Server, ProcessLock.LockError>((resume) => {
const server = createServer()