Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,387 changes: 3,195 additions & 3,192 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/plug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type PlugDeps = {
info: (msg: string) => void
success: (msg: string) => void
}
resolve: (spec: string) => Promise<string>
resolve: (spec: string, configCwd: string) => Promise<string>
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
Expand All @@ -51,7 +51,7 @@ const defaultPlugDeps: PlugDeps = {
info: (msg) => log.info(msg),
success: (msg) => log.success(msg),
},
resolve: (spec) => resolvePluginTarget(spec),
resolve: (spec, configCwd) => resolvePluginTarget(spec, configCwd),
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
Expand All @@ -75,7 +75,7 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
return async (ctx: PlugCtx) => {
const install = dep.spinner()
install.start("Installing plugin package...")
const target = await installPlugin(mod, dep)
const target = await installPlugin(mod, dep, ctx.directory)
if (!target.ok) {
install.stop("Install failed", 1)
dep.log.error(`Could not install "${mod}"`)
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,10 +590,11 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}

async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>, configCwd?: string) {
return PluginLoader.loadExternal({
items: list,
kind: "tui",
configCwd,
wait: async () => {
await wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
Expand Down Expand Up @@ -794,7 +795,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {

const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies(), state.directory),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
Expand Down Expand Up @@ -855,7 +856,7 @@ async function installPluginBySpec(
}
}

const install = await installModulePlugin(spec)
const install = await installModulePlugin(spec, undefined, dir.directory)
if (!install.ok) {
const out = installDetail(install.error)
return {
Expand Down Expand Up @@ -1011,7 +1012,7 @@ export namespace TuiPluginRuntime {
})
}

const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies(), cwd)
await addExternalPluginEntries(next, ready)

applyInitialPluginEnabledState(next, config)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,7 +1294,7 @@ export const layer: Layer.Layer<

if (hasDep && hasIgnore && hasPkg) return

yield* Effect.promise(() => Npm.install(dir))
yield* Effect.promise(() => Npm.install(dir, dir))
})

const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) {
Expand Down
102 changes: 87 additions & 15 deletions packages/opencode/src/npm/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Arborist } from "@npmcli/arborist"
import NpmConfig from "@npmcli/config"
import npmConfigDefinitions from "@npmcli/config/lib/definitions"

const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
Expand All @@ -24,6 +26,81 @@ export function sanitize(pkg: string) {
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}

const ARBORIST_OPTIONS = {
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
} as const

function resolveConfigCwd(configCwd: string) {
if (!configCwd.trim()) {
throw new Error("npm config cwd is required")
}
return Filesystem.resolve(configCwd)
}

async function findNpmPackageRoot(start: string) {
let current = Filesystem.resolve(start)
for (let i = 0; i < 4; i++) {
const pkg = await Filesystem.readJson<{ name?: string }>(path.join(current, "package.json")).catch(() => undefined)
if (pkg?.name === "npm") return current
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
}

let npmPathPromise: Promise<string> | undefined

async function resolveNpmPath() {
if (npmPathPromise) return npmPathPromise

npmPathPromise = (async () => {
const execDir = path.dirname(process.execPath)
const candidates = [
process.env.npm_execpath,
path.join(execDir, "..", "lib", "node_modules", "npm"),
path.join(execDir, "..", "node_modules", "npm"),
path.join(execDir, "node_modules", "npm"),
].filter((candidate): candidate is string => Boolean(candidate))

for (const candidate of candidates) {
const npmPath = await findNpmPackageRoot(candidate)
if (npmPath) return npmPath
}

return execDir
})()

return npmPathPromise
}

async function loadArboristConfig(cwd: string) {
const config = new NpmConfig({
definitions: npmConfigDefinitions.definitions,
flatten: npmConfigDefinitions.flatten,
shorthands: npmConfigDefinitions.shorthands,
nerfDarts: npmConfigDefinitions.nerfDarts,
npmPath: await resolveNpmPath(),
cwd,
argv: [],
env: process.env,
})
await config.load()
return config.flat
}

async function createArborist(installPath: string, configCwd: string) {
const cwd = resolveConfigCwd(configCwd)
const config = await loadArboristConfig(cwd)
return new Arborist({
...config,
path: installPath,
...ARBORIST_OPTIONS,
})
}

function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", sanitize(pkg))
}
Expand Down Expand Up @@ -60,19 +137,20 @@ export async function outdated(pkg: string, cachedVersion: string): Promise<bool
return semver.lt(cachedVersion, latestVersion)
}

export async function add(pkg: string) {
export async function add(pkg: string, configCwd = process.cwd()) {
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})

const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
const arborist = await createArborist(dir, configCwd).catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
Expand Down Expand Up @@ -102,18 +180,12 @@ export async function add(pkg: string) {
return resolveEntryPoint(first.name, first.path)
}

export async function install(dir: string) {
export async function install(dir: string, configCwd = process.cwd()) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })

const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const arb = await createArborist(dir, configCwd)
await arb.reify().catch(() => {})
}

Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/npm/npmcli-config.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
declare module "@npmcli/config" {
export type FlatOptions = Record<string, unknown>

export type ConfigOptions = {
definitions: Record<string, unknown>
shorthands: Record<string, unknown>
flatten: (input: Record<string, unknown>, flat?: FlatOptions) => FlatOptions
nerfDarts?: string[]
npmPath: string
cwd?: string
argv?: string[]
env?: NodeJS.ProcessEnv
}

export default class Config {
constructor(options: ConfigOptions)
load(): Promise<void>
readonly flat: FlatOptions
}
}

declare module "@npmcli/config/lib/definitions" {
const definitions: {
definitions: Record<string, unknown>
shorthands: Record<string, unknown>
flatten: (input: Record<string, unknown>, flat?: Record<string, unknown>) => Record<string, unknown>
nerfDarts: string[]
}

export default definitions
}
12 changes: 8 additions & 4 deletions packages/opencode/src/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type Target = {
}

export type InstallDeps = {
resolve: (spec: string) => Promise<string>
resolve: (spec: string, configCwd: string) => Promise<string>
}

export type PatchDeps = {
Expand Down Expand Up @@ -76,7 +76,7 @@ type PatchOne = Ok<{ item: PatchItem }> | PatchErr
export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })

const defaultInstallDeps: InstallDeps = {
resolve: (spec) => resolvePluginTarget(spec),
resolve: (spec, configCwd) => resolvePluginTarget(spec, configCwd),
}

const defaultPatchDeps: PatchDeps = {
Expand Down Expand Up @@ -256,8 +256,12 @@ function patchPluginList(
}
}

export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
const target = await dep.resolve(spec).then(
export async function installPlugin(
spec: string,
dep: InstallDeps = defaultInstallDeps,
configCwd = process.cwd(),
): Promise<InstallResult> {
const target = await dep.resolve(spec, configCwd).then(
(item) => ({
ok: true as const,
item,
Expand Down
39 changes: 35 additions & 4 deletions packages/opencode/src/plugin/loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path"
import { Config } from "@/config"
import { Installation } from "@/installation"
import {
Expand Down Expand Up @@ -54,14 +55,15 @@ export namespace PluginLoader {
export async function resolve(
plan: Plan,
kind: PluginKind,
configCwd?: string,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
target = await resolvePluginTarget(plan.spec, configCwd)
} catch (error) {
return { ok: false, stage: "install", error }
}
Expand Down Expand Up @@ -107,18 +109,28 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } }
}

function configCwd(origin: Config.PluginOrigin, fallback?: string) {
if (origin.source === "OPENCODE_CONFIG_CONTENT") return fallback
if (origin.source.startsWith("http://") || origin.source.startsWith("https://")) return fallback
if (origin.source.endsWith(".json") || origin.source.endsWith(".jsonc")) {
return path.dirname(origin.source)
}
return origin.source
}

async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
retry: boolean,
configRoot: string | undefined,
finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
const resolved = await resolve(plan, kind, configRoot)
if (!resolved.ok) {
if (resolved.stage === "missing") {
if (missing) {
Expand All @@ -143,6 +155,7 @@ export namespace PluginLoader {
type Input<R> = {
items: Config.PluginOrigin[]
kind: PluginKind
configCwd?: string
wait?: () => Promise<void>
finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
Expand All @@ -153,7 +166,17 @@ export namespace PluginLoader {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
list.push(
attempt(
candidate,
input.kind,
false,
configCwd(candidate.origin, input.configCwd),
input.finish,
input.missing,
input.report,
),
)
}
const out = await Promise.all(list)
if (input.wait) {
Expand All @@ -164,7 +187,15 @@ export namespace PluginLoader {
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
await deps
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
out[i] = await attempt(
candidate,
input.kind,
true,
configCwd(candidate.origin, input.configCwd),
input.finish,
input.missing,
input.report,
)
}
}
const ready: R[] = []
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const layer = Layer.effect(
PluginLoader.loadExternal({
items: plugins,
kind: "server",
configCwd: ctx.directory,
report: {
start(candidate) {
log.info("loading plugin", { path: candidate.plan.spec })
Expand Down
Loading
Loading