refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>pull/19976/merge
parent
d7481f4593
commit
c9326fc199
|
|
@ -53,6 +53,7 @@
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
|
|
@ -94,6 +95,7 @@
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import z from "zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import path from "path"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import { Lock } from "../util/lock"
|
|
||||||
import { PackageRegistry } from "./registry"
|
|
||||||
import { online, proxied } from "@/util/network"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
|
|
||||||
export namespace BunProc {
|
|
||||||
const log = Log.create({ service: "bun" })
|
|
||||||
|
|
||||||
export async function run(cmd: string[], options?: Process.RunOptions) {
|
|
||||||
const full = [which(), ...cmd]
|
|
||||||
log.info("running", {
|
|
||||||
cmd: full,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
const result = await Process.run(full, {
|
|
||||||
cwd: options?.cwd,
|
|
||||||
abort: options?.abort,
|
|
||||||
kill: options?.kill,
|
|
||||||
timeout: options?.timeout,
|
|
||||||
nothrow: options?.nothrow,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...options?.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
log.info("done", {
|
|
||||||
code: result.code,
|
|
||||||
stdout: result.stdout.toString(),
|
|
||||||
stderr: result.stderr.toString(),
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function which() {
|
|
||||||
return process.execPath
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InstallFailedError = NamedError.create(
|
|
||||||
"BunInstallFailedError",
|
|
||||||
z.object({
|
|
||||||
pkg: z.string(),
|
|
||||||
version: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
|
|
||||||
// Use lock to ensure only one install at a time
|
|
||||||
using _ = await Lock.write("bun-install")
|
|
||||||
|
|
||||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
|
||||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
|
||||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
|
||||||
const result = { dependencies: {} as Record<string, string> }
|
|
||||||
await Filesystem.writeJson(pkgjsonPath, result)
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
|
||||||
const dependencies = parsed.dependencies
|
|
||||||
const modExists = await Filesystem.exists(mod)
|
|
||||||
const cachedVersion = dependencies[pkg]
|
|
||||||
|
|
||||||
if (!modExists || !cachedVersion) {
|
|
||||||
// continue to install
|
|
||||||
} else if (version === "latest") {
|
|
||||||
if (!online()) return mod
|
|
||||||
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
|
||||||
if (!stale) return mod
|
|
||||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
|
||||||
} else if (cachedVersion === version) {
|
|
||||||
return mod
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build command arguments
|
|
||||||
const args = [
|
|
||||||
"add",
|
|
||||||
"--force",
|
|
||||||
"--exact",
|
|
||||||
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
|
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
|
||||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
|
||||||
"--cwd",
|
|
||||||
Global.Path.cache,
|
|
||||||
pkg + "@" + version,
|
|
||||||
]
|
|
||||||
|
|
||||||
// Let Bun handle registry resolution:
|
|
||||||
// - If .npmrc files exist, Bun will use them automatically
|
|
||||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
|
||||||
// - No need to pass --registry flag
|
|
||||||
log.info("installing package using Bun's default registry resolution", {
|
|
||||||
pkg,
|
|
||||||
version,
|
|
||||||
})
|
|
||||||
|
|
||||||
await BunProc.run(args, {
|
|
||||||
cwd: Global.Path.cache,
|
|
||||||
}).catch((e) => {
|
|
||||||
throw new InstallFailedError(
|
|
||||||
{ pkg, version },
|
|
||||||
{
|
|
||||||
cause: e,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resolve actual version from installed package when using "latest"
|
|
||||||
// This ensures subsequent starts use the cached version until explicitly updated
|
|
||||||
let resolvedVersion = version
|
|
||||||
if (version === "latest") {
|
|
||||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
|
||||||
() => null,
|
|
||||||
)
|
|
||||||
if (installedPkg?.version) {
|
|
||||||
resolvedVersion = installedPkg.version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.dependencies[pkg] = resolvedVersion
|
|
||||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
|
||||||
return mod
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import semver from "semver"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { online } from "@/util/network"
|
|
||||||
|
|
||||||
export namespace PackageRegistry {
|
|
||||||
const log = Log.create({ service: "bun" })
|
|
||||||
|
|
||||||
function which() {
|
|
||||||
return process.execPath
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
|
||||||
if (!online()) {
|
|
||||||
log.debug("offline, skipping bun info", { pkg, field })
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
|
||||||
cwd,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = stdout.toString().trim()
|
|
||||||
if (!value) return null
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
|
||||||
const latestVersion = await info(pkg, "version", cwd)
|
|
||||||
if (!latestVersion) {
|
|
||||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
|
||||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
|
||||||
|
|
||||||
return semver.lt(cachedVersion, latestVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
} from "jsonc-parser"
|
} from "jsonc-parser"
|
||||||
import { Instance, type InstanceContext } from "../project/instance"
|
import { Instance, type InstanceContext } from "../project/instance"
|
||||||
import { LSPServer } from "../lsp/server"
|
import { LSPServer } from "../lsp/server"
|
||||||
import { BunProc } from "@/bun"
|
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ConfigMarkdown } from "./markdown"
|
import { ConfigMarkdown } from "./markdown"
|
||||||
import { constants, existsSync } from "fs"
|
import { constants, existsSync } from "fs"
|
||||||
|
|
@ -28,20 +27,18 @@ import { Bus } from "@/bus"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
|
||||||
import { online, proxied } from "@/util/network"
|
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "./paths"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
||||||
import { Flock } from "@/util/flock"
|
import { Flock } from "@/util/flock"
|
||||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||||
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||||
|
|
@ -90,8 +87,7 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDependencies(dir: string, input?: InstallInput) {
|
export async function installDependencies(dir: string, input?: InstallInput) {
|
||||||
if (!(await needsInstall(dir))) return
|
if (!(await isWritable(dir))) return
|
||||||
|
|
||||||
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
|
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
|
||||||
signal: input?.signal,
|
signal: input?.signal,
|
||||||
onWait: (tick) =>
|
onWait: (tick) =>
|
||||||
|
|
@ -102,13 +98,10 @@ export namespace Config {
|
||||||
waited: tick.waited,
|
waited: tick.waited,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
input?.signal?.throwIfAborted()
|
input?.signal?.throwIfAborted()
|
||||||
if (!(await needsInstall(dir))) return
|
|
||||||
|
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
|
||||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
}))
|
}))
|
||||||
|
|
@ -126,49 +119,7 @@ export namespace Config {
|
||||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
await Npm.install(dir)
|
||||||
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
|
||||||
// Serialize installs globally on win32, but keep parallel installs on other platforms.
|
|
||||||
await using __ =
|
|
||||||
process.platform === "win32"
|
|
||||||
? await Flock.acquire("config-install:bun", {
|
|
||||||
signal: input?.signal,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
await BunProc.run(
|
|
||||||
[
|
|
||||||
"install",
|
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
|
||||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: dir,
|
|
||||||
abort: input?.signal,
|
|
||||||
},
|
|
||||||
).catch((err) => {
|
|
||||||
if (err instanceof Process.RunFailedError) {
|
|
||||||
const detail = {
|
|
||||||
dir,
|
|
||||||
cmd: err.cmd,
|
|
||||||
code: err.code,
|
|
||||||
stdout: err.stdout.toString(),
|
|
||||||
stderr: err.stderr.toString(),
|
|
||||||
}
|
|
||||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
|
||||||
log.error("failed to install dependencies", detail)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
log.warn("failed to install dependencies", detail)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
|
||||||
log.error("failed to install dependencies", { dir, error: err })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
log.warn("failed to install dependencies", { dir, error: err })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isWritable(dir: string) {
|
async function isWritable(dir: string) {
|
||||||
|
|
@ -180,42 +131,6 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function needsInstall(dir: string) {
|
|
||||||
// Some config dirs may be read-only.
|
|
||||||
// Installing deps there will fail; skip installation in that case.
|
|
||||||
const writable = await isWritable(dir)
|
|
||||||
if (!writable) {
|
|
||||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
|
|
||||||
if (!existsSync(mod)) return true
|
|
||||||
|
|
||||||
const pkg = path.join(dir, "package.json")
|
|
||||||
const pkgExists = await Filesystem.exists(pkg)
|
|
||||||
if (!pkgExists) return true
|
|
||||||
|
|
||||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
|
||||||
const dependencies = parsed?.dependencies ?? {}
|
|
||||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
|
||||||
if (!depVersion) return true
|
|
||||||
|
|
||||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
|
||||||
if (targetVersion === "latest") {
|
|
||||||
if (!online()) return false
|
|
||||||
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
|
||||||
if (!stale) return false
|
|
||||||
log.info("Cached version is outdated, proceeding with install", {
|
|
||||||
pkg: "@opencode-ai/plugin",
|
|
||||||
cachedVersion: depVersion,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (depVersion === targetVersion) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function rel(item: string, patterns: string[]) {
|
function rel(item: string, patterns: string[]) {
|
||||||
const normalizedItem = item.replaceAll("\\", "/")
|
const normalizedItem = item.replaceAll("\\", "/")
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
|
|
@ -1355,8 +1270,7 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
const dep = iife(async () => {
|
const dep = iife(async () => {
|
||||||
const stale = await needsInstall(dir)
|
await installDependencies(dir)
|
||||||
if (stale) await installDependencies(dir)
|
|
||||||
})
|
})
|
||||||
void dep.catch((err) => {
|
void dep.catch((err) => {
|
||||||
log.warn("background dependency install failed", { dir, error: err })
|
log.warn("background dependency install failed", { dir, error: err })
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
import { BunProc } from "../bun"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
|
|
@ -34,7 +33,7 @@ export const mix: Info = {
|
||||||
|
|
||||||
export const prettier: Info = {
|
export const prettier: Info = {
|
||||||
name: "prettier",
|
name: "prettier",
|
||||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
command: ["bun", "x", "prettier", "--write", "$FILE"],
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
@ -82,7 +81,7 @@ export const prettier: Info = {
|
||||||
|
|
||||||
export const oxfmt: Info = {
|
export const oxfmt: Info = {
|
||||||
name: "oxfmt",
|
name: "oxfmt",
|
||||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
command: ["bun", "x", "oxfmt", "$FILE"],
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
@ -104,7 +103,7 @@ export const oxfmt: Info = {
|
||||||
|
|
||||||
export const biome: Info = {
|
export const biome: Info = {
|
||||||
name: "biome",
|
name: "biome",
|
||||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||||
environment: {
|
environment: {
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { BunProc } from "../bun"
|
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
@ -14,6 +13,7 @@ import { Process } from "../util/process"
|
||||||
import { which } from "../util/which"
|
import { which } from "../util/which"
|
||||||
import { Module } from "@opencode-ai/util/module"
|
import { Module } from "@opencode-ai/util/module"
|
||||||
import { spawn } from "./launch"
|
import { spawn } from "./launch"
|
||||||
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
export namespace LSPServer {
|
export namespace LSPServer {
|
||||||
const log = Log.create({ service: "lsp.server" })
|
const log = Log.create({ service: "lsp.server" })
|
||||||
|
|
@ -103,11 +103,12 @@ export namespace LSPServer {
|
||||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||||
log.info("typescript server", { tsserver })
|
log.info("typescript server", { tsserver })
|
||||||
if (!tsserver) return
|
if (!tsserver) return
|
||||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
const bin = await Npm.which("typescript-language-server")
|
||||||
|
if (!bin) return
|
||||||
|
const proc = spawn(bin, ["--stdio"], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -129,36 +130,16 @@ export namespace LSPServer {
|
||||||
let binary = which("vue-language-server")
|
let binary = which("vue-language-server")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
Global.Path.bin,
|
const resolved = await Npm.which("@vue/language-server")
|
||||||
"node_modules",
|
if (!resolved) return
|
||||||
"@vue",
|
binary = resolved
|
||||||
"language-server",
|
|
||||||
"bin",
|
|
||||||
"vue-language-server.js",
|
|
||||||
)
|
|
||||||
if (!(await Filesystem.exists(js))) {
|
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
|
||||||
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -214,11 +195,10 @@ export namespace LSPServer {
|
||||||
log.info("installed VS Code ESLint server", { serverPath })
|
log.info("installed VS Code ESLint server", { serverPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
const proc = spawn("node", [serverPath, "--stdio"], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -345,15 +325,15 @@ export namespace LSPServer {
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
const resolved = Module.resolve("biome", root)
|
const resolved = Module.resolve("biome", root)
|
||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
bin = BunProc.which()
|
bin = await Npm.which("biome")
|
||||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
if (!bin) return
|
||||||
|
args = ["lsp-proxy", "--stdio"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(bin, args, {
|
const proc = spawn(bin, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -372,9 +352,7 @@ export namespace LSPServer {
|
||||||
},
|
},
|
||||||
extensions: [".go"],
|
extensions: [".go"],
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("gopls", {
|
let bin = which("gopls")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (!which("go")) return
|
if (!which("go")) return
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
@ -409,9 +387,7 @@ export namespace LSPServer {
|
||||||
root: NearestRoot(["Gemfile"]),
|
root: NearestRoot(["Gemfile"]),
|
||||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("rubocop", {
|
let bin = which("rubocop")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
const ruby = which("ruby")
|
const ruby = which("ruby")
|
||||||
const gem = which("gem")
|
const gem = which("gem")
|
||||||
|
|
@ -516,19 +492,10 @@ export namespace LSPServer {
|
||||||
let binary = which("pyright-langserver")
|
let binary = which("pyright-langserver")
|
||||||
const args = []
|
const args = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("pyright")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "pyright"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push(...["run", js])
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
|
|
||||||
|
|
@ -552,7 +519,6 @@ export namespace LSPServer {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -630,9 +596,7 @@ export namespace LSPServer {
|
||||||
extensions: [".zig", ".zon"],
|
extensions: [".zig", ".zon"],
|
||||||
root: NearestRoot(["build.zig"]),
|
root: NearestRoot(["build.zig"]),
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("zls", {
|
let bin = which("zls")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
const zig = which("zig")
|
const zig = which("zig")
|
||||||
|
|
@ -742,9 +706,7 @@ export namespace LSPServer {
|
||||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||||
extensions: [".cs"],
|
extensions: [".cs"],
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("csharp-ls", {
|
let bin = which("csharp-ls")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (!which("dotnet")) {
|
if (!which("dotnet")) {
|
||||||
log.error(".NET SDK is required to install csharp-ls")
|
log.error(".NET SDK is required to install csharp-ls")
|
||||||
|
|
@ -781,9 +743,7 @@ export namespace LSPServer {
|
||||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("fsautocomplete", {
|
let bin = which("fsautocomplete")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (!which("dotnet")) {
|
if (!which("dotnet")) {
|
||||||
log.error(".NET SDK is required to install fsautocomplete")
|
log.error(".NET SDK is required to install fsautocomplete")
|
||||||
|
|
@ -1049,29 +1009,16 @@ export namespace LSPServer {
|
||||||
let binary = which("svelteserver")
|
let binary = which("svelteserver")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("svelte-language-server")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1096,29 +1043,16 @@ export namespace LSPServer {
|
||||||
let binary = which("astro-ls")
|
let binary = which("astro-ls")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("@astrojs/language-server")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1360,38 +1294,16 @@ export namespace LSPServer {
|
||||||
let binary = which("yaml-language-server")
|
let binary = which("yaml-language-server")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
Global.Path.bin,
|
const resolved = await Npm.which("yaml-language-server")
|
||||||
"node_modules",
|
if (!resolved) return
|
||||||
"yaml-language-server",
|
binary = resolved
|
||||||
"out",
|
|
||||||
"server",
|
|
||||||
"src",
|
|
||||||
"server.js",
|
|
||||||
)
|
|
||||||
const exists = await Filesystem.exists(js)
|
|
||||||
if (!exists) {
|
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
|
||||||
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1413,9 +1325,7 @@ export namespace LSPServer {
|
||||||
]),
|
]),
|
||||||
extensions: [".lua"],
|
extensions: [".lua"],
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("lua-language-server", {
|
let bin = which("lua-language-server")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
@ -1551,29 +1461,16 @@ export namespace LSPServer {
|
||||||
let binary = which("intelephense")
|
let binary = which("intelephense")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("intelephense")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "intelephense"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1648,29 +1545,16 @@ export namespace LSPServer {
|
||||||
let binary = which("bash-language-server")
|
let binary = which("bash-language-server")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("bash-language-server")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("start")
|
args.push("start")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1684,9 +1568,7 @@ export namespace LSPServer {
|
||||||
extensions: [".tf", ".tfvars"],
|
extensions: [".tf", ".tfvars"],
|
||||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("terraform-ls", {
|
let bin = which("terraform-ls")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
@ -1767,9 +1649,7 @@ export namespace LSPServer {
|
||||||
extensions: [".tex", ".bib"],
|
extensions: [".tex", ".bib"],
|
||||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("texlab", {
|
let bin = which("texlab")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
@ -1860,29 +1740,16 @@ export namespace LSPServer {
|
||||||
let binary = which("docker-langserver")
|
let binary = which("docker-langserver")
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
if (!(await Filesystem.exists(js))) {
|
const resolved = await Npm.which("dockerfile-language-server-nodejs")
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (!resolved) return
|
||||||
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
binary = resolved
|
||||||
cwd: Global.Path.bin,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
}).exited
|
|
||||||
}
|
|
||||||
binary = BunProc.which()
|
|
||||||
args.push("run", js)
|
|
||||||
}
|
}
|
||||||
args.push("--stdio")
|
args.push("--stdio")
|
||||||
const proc = spawn(binary, args, {
|
const proc = spawn(binary, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1966,9 +1833,7 @@ export namespace LSPServer {
|
||||||
extensions: [".typ", ".typc"],
|
extensions: [".typ", ".typc"],
|
||||||
root: NearestRoot(["typst.toml"]),
|
root: NearestRoot(["typst.toml"]),
|
||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
let bin = which("tinymist", {
|
let bin = which("tinymist")
|
||||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import semver from "semver"
|
||||||
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Lock } from "../util/lock"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
|
import { readdir, rm } from "fs/promises"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Flock } from "@/util/flock"
|
||||||
|
import { Arborist } from "@npmcli/arborist"
|
||||||
|
|
||||||
|
export namespace Npm {
|
||||||
|
const log = Log.create({ service: "npm" })
|
||||||
|
|
||||||
|
export const InstallFailedError = NamedError.create(
|
||||||
|
"NpmInstallFailedError",
|
||||||
|
z.object({
|
||||||
|
pkg: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function directory(pkg: string) {
|
||||||
|
return path.join(Global.Path.cache, "packages", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntryPoint(name: string, dir: string) {
|
||||||
|
const entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||||
|
const result = {
|
||||||
|
directory: dir,
|
||||||
|
entrypoint,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||||
|
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||||
|
const latestVersion = data?.["dist-tags"]?.latest
|
||||||
|
if (!latestVersion) {
|
||||||
|
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||||
|
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||||
|
|
||||||
|
return semver.lt(cachedVersion, latestVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function add(pkg: string) {
|
||||||
|
using _ = await Lock.write(`npm-install:${pkg}`)
|
||||||
|
log.info("installing package", {
|
||||||
|
pkg,
|
||||||
|
})
|
||||||
|
const dir = directory(pkg)
|
||||||
|
|
||||||
|
const arborist = new Arborist({
|
||||||
|
path: dir,
|
||||||
|
binLinks: true,
|
||||||
|
progress: false,
|
||||||
|
savePrefix: "",
|
||||||
|
})
|
||||||
|
const tree = await arborist.loadVirtual().catch(() => {})
|
||||||
|
if (tree) {
|
||||||
|
const first = tree.edgesOut.values().next().value?.to
|
||||||
|
if (first) {
|
||||||
|
return resolveEntryPoint(first.name, first.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await arborist
|
||||||
|
.reify({
|
||||||
|
add: [pkg],
|
||||||
|
save: true,
|
||||||
|
saveType: "prod",
|
||||||
|
})
|
||||||
|
.catch((cause) => {
|
||||||
|
throw new InstallFailedError(
|
||||||
|
{ pkg },
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = result.edgesOut.values().next().value?.to
|
||||||
|
if (!first) throw new InstallFailedError({ pkg })
|
||||||
|
return resolveEntryPoint(first.name, first.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function install(dir: string) {
|
||||||
|
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: "",
|
||||||
|
})
|
||||||
|
await arb.reify().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||||
|
log.info("node_modules missing, reifying")
|
||||||
|
await reify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||||
|
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||||
|
|
||||||
|
const declared = new Set([
|
||||||
|
...Object.keys(pkg.dependencies || {}),
|
||||||
|
...Object.keys(pkg.devDependencies || {}),
|
||||||
|
...Object.keys(pkg.peerDependencies || {}),
|
||||||
|
...Object.keys(pkg.optionalDependencies || {}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = lock.packages?.[""] || {}
|
||||||
|
const locked = new Set([
|
||||||
|
...Object.keys(root.dependencies || {}),
|
||||||
|
...Object.keys(root.devDependencies || {}),
|
||||||
|
...Object.keys(root.peerDependencies || {}),
|
||||||
|
...Object.keys(root.optionalDependencies || {}),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const name of declared) {
|
||||||
|
if (!locked.has(name)) {
|
||||||
|
log.info("dependency not in lock file, reifying", { name })
|
||||||
|
await reify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("dependencies in sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function which(pkg: string) {
|
||||||
|
const dir = directory(pkg)
|
||||||
|
const binDir = path.join(dir, "node_modules", ".bin")
|
||||||
|
|
||||||
|
const pick = async () => {
|
||||||
|
const files = await readdir(binDir).catch(() => [])
|
||||||
|
if (files.length === 0) return undefined
|
||||||
|
if (files.length === 1) return files[0]
|
||||||
|
// Multiple binaries — resolve from package.json bin field like npx does
|
||||||
|
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
|
||||||
|
path.join(dir, "node_modules", pkg, "package.json"),
|
||||||
|
).catch(() => undefined)
|
||||||
|
if (pkgJson?.bin) {
|
||||||
|
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||||
|
const bin = pkgJson.bin
|
||||||
|
if (typeof bin === "string") return unscoped
|
||||||
|
const keys = Object.keys(bin)
|
||||||
|
if (keys.length === 1) return keys[0]
|
||||||
|
return bin[unscoped] ? unscoped : keys[0]
|
||||||
|
}
|
||||||
|
return files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = await pick()
|
||||||
|
if (bin) return path.join(binDir, bin)
|
||||||
|
|
||||||
|
await rm(path.join(dir, "package-lock.json"), { force: true })
|
||||||
|
await add(pkg)
|
||||||
|
const resolved = await pick()
|
||||||
|
if (!resolved) return
|
||||||
|
return path.join(binDir, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { BunProc } from "@/bun"
|
import { Npm } from "@/npm"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ async function resolveDirectoryIndex(dir: string) {
|
||||||
async function resolveTargetDirectory(target: string) {
|
async function resolveTargetDirectory(target: string) {
|
||||||
const file = targetPath(target)
|
const file = targetPath(target)
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const stat = await Filesystem.stat(file)
|
const stat = Filesystem.stat(file)
|
||||||
if (!stat?.isDirectory()) return
|
if (!stat?.isDirectory()) return
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +153,7 @@ export function isPathPluginSpec(spec: string) {
|
||||||
export async function resolvePathPluginTarget(spec: string) {
|
export async function resolvePathPluginTarget(spec: string) {
|
||||||
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
|
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
|
||||||
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
|
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
|
||||||
const stat = await Filesystem.stat(file)
|
const stat = Filesystem.stat(file)
|
||||||
if (!stat?.isDirectory()) {
|
if (!stat?.isDirectory()) {
|
||||||
if (spec.startsWith("file://")) return spec
|
if (spec.startsWith("file://")) return spec
|
||||||
return pathToFileURL(file).href
|
return pathToFileURL(file).href
|
||||||
|
|
@ -184,12 +184,13 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
|
||||||
|
|
||||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||||
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
||||||
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
|
const result = await Npm.add(parsed.pkg + "@" + parsed.version)
|
||||||
|
return result.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
||||||
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
||||||
const stat = await Filesystem.stat(file)
|
const stat = Filesystem.stat(file)
|
||||||
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
|
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { BunProc } from "../bun"
|
import { Npm } from "../npm"
|
||||||
import { Hash } from "../util/hash"
|
import { Hash } from "../util/hash"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
|
@ -1365,7 +1365,7 @@ export namespace Provider {
|
||||||
|
|
||||||
let installedPath: string
|
let installedPath: string
|
||||||
if (!model.api.npm.startsWith("file://")) {
|
if (!model.api.npm.startsWith("file://")) {
|
||||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
installedPath = await Npm.add(model.api.npm).then((item) => item.entrypoint)
|
||||||
} else {
|
} else {
|
||||||
log.info("loading local provider", { pkg: model.api.npm })
|
log.info("loading local provider", { pkg: model.api.npm })
|
||||||
installedPath = model.api.npm
|
installedPath = model.api.npm
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { describe, expect, spyOn, test } from "bun:test"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { BunProc } from "../src/bun"
|
|
||||||
import { PackageRegistry } from "../src/bun/registry"
|
|
||||||
import { Global } from "../src/global"
|
|
||||||
import { Process } from "../src/util/process"
|
|
||||||
|
|
||||||
describe("BunProc registry configuration", () => {
|
|
||||||
test("should not contain hardcoded registry parameters", async () => {
|
|
||||||
// Read the bun/index.ts file
|
|
||||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
|
||||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
|
||||||
|
|
||||||
// Verify that no hardcoded registry is present
|
|
||||||
expect(content).not.toContain("--registry=")
|
|
||||||
expect(content).not.toContain("hasNpmRcConfig")
|
|
||||||
expect(content).not.toContain("NpmRc")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should use Bun's default registry resolution", async () => {
|
|
||||||
// Read the bun/index.ts file
|
|
||||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
|
||||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
|
||||||
|
|
||||||
// Verify that it uses Bun's default resolution
|
|
||||||
expect(content).toContain("Bun's default registry resolution")
|
|
||||||
expect(content).toContain("Bun will use them automatically")
|
|
||||||
expect(content).toContain("No need to pass --registry flag")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should have correct command structure without registry", async () => {
|
|
||||||
// Read the bun/index.ts file
|
|
||||||
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
|
|
||||||
const content = await fs.readFile(bunIndexPath, "utf-8")
|
|
||||||
|
|
||||||
// Extract the install function
|
|
||||||
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
|
|
||||||
expect(installFunctionMatch).toBeTruthy()
|
|
||||||
|
|
||||||
if (installFunctionMatch) {
|
|
||||||
const installFunction = installFunctionMatch[0]
|
|
||||||
|
|
||||||
// Verify expected arguments are present
|
|
||||||
expect(installFunction).toContain('"add"')
|
|
||||||
expect(installFunction).toContain('"--force"')
|
|
||||||
expect(installFunction).toContain('"--exact"')
|
|
||||||
expect(installFunction).toContain('"--cwd"')
|
|
||||||
expect(installFunction).toContain("Global.Path.cache")
|
|
||||||
expect(installFunction).toContain('pkg + "@" + version')
|
|
||||||
|
|
||||||
// Verify no registry argument is added
|
|
||||||
expect(installFunction).not.toContain('"--registry"')
|
|
||||||
expect(installFunction).not.toContain('args.push("--registry')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("BunProc install pinning", () => {
|
|
||||||
test("uses pinned cache without touching registry", async () => {
|
|
||||||
const pkg = `pin-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
||||||
const ver = "1.2.3"
|
|
||||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
|
||||||
const data = path.join(Global.Path.cache, "package.json")
|
|
||||||
|
|
||||||
await fs.mkdir(mod, { recursive: true })
|
|
||||||
await Bun.write(path.join(mod, "package.json"), JSON.stringify({ name: pkg, version: ver }, null, 2))
|
|
||||||
|
|
||||||
const src = await fs.readFile(data, "utf8").catch(() => "")
|
|
||||||
const json = src ? ((JSON.parse(src) as { dependencies?: Record<string, string> }) ?? {}) : {}
|
|
||||||
const deps = json.dependencies ?? {}
|
|
||||||
deps[pkg] = ver
|
|
||||||
await Bun.write(data, JSON.stringify({ ...json, dependencies: deps }, null, 2))
|
|
||||||
|
|
||||||
const stale = spyOn(PackageRegistry, "isOutdated").mockImplementation(async () => {
|
|
||||||
throw new Error("unexpected registry check")
|
|
||||||
})
|
|
||||||
const run = spyOn(Process, "run").mockImplementation(async () => {
|
|
||||||
throw new Error("unexpected process.run")
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const out = await BunProc.install(pkg, ver)
|
|
||||||
expect(out).toBe(mod)
|
|
||||||
expect(stale).not.toHaveBeenCalled()
|
|
||||||
expect(run).not.toHaveBeenCalled()
|
|
||||||
} finally {
|
|
||||||
stale.mockRestore()
|
|
||||||
run.mockRestore()
|
|
||||||
|
|
||||||
await fs.rm(mod, { recursive: true, force: true })
|
|
||||||
const end = await fs
|
|
||||||
.readFile(data, "utf8")
|
|
||||||
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (end?.dependencies) {
|
|
||||||
delete end.dependencies[pkg]
|
|
||||||
await Bun.write(data, JSON.stringify(end, null, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes --ignore-scripts when requested", async () => {
|
|
||||||
const pkg = `ignore-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
||||||
const ver = "4.5.6"
|
|
||||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
|
||||||
const data = path.join(Global.Path.cache, "package.json")
|
|
||||||
|
|
||||||
const run = spyOn(Process, "run").mockImplementation(async () => ({
|
|
||||||
code: 0,
|
|
||||||
stdout: Buffer.alloc(0),
|
|
||||||
stderr: Buffer.alloc(0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.rm(mod, { recursive: true, force: true })
|
|
||||||
await BunProc.install(pkg, ver, { ignoreScripts: true })
|
|
||||||
|
|
||||||
expect(run).toHaveBeenCalled()
|
|
||||||
const call = run.mock.calls[0]?.[0]
|
|
||||||
expect(call).toContain("--ignore-scripts")
|
|
||||||
expect(call).toContain(`${pkg}@${ver}`)
|
|
||||||
} finally {
|
|
||||||
run.mockRestore()
|
|
||||||
await fs.rm(mod, { recursive: true, force: true })
|
|
||||||
|
|
||||||
const end = await fs
|
|
||||||
.readFile(data, "utf8")
|
|
||||||
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (end?.dependencies) {
|
|
||||||
delete end.dependencies[pkg]
|
|
||||||
await Bun.write(data, JSON.stringify(end, null, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config/tui"
|
||||||
import { BunProc } from "../../../src/bun"
|
import { Npm } from "../../../src/npm"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
@ -118,7 +118,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
@ -181,7 +181,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
@ -244,7 +244,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
@ -303,7 +303,7 @@ test("does not use npm package main for tui entry", async () => {
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
|
|
@ -475,7 +475,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||||
})
|
})
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { Global } from "../../src/global"
|
||||||
import { ProjectID } from "../../src/project/schema"
|
import { ProjectID } from "../../src/project/schema"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import * as Network from "../../src/util/network"
|
import * as Network from "../../src/util/network"
|
||||||
import { BunProc } from "../../src/bun"
|
import { Npm } from "../../src/npm"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
const emptyAccount = Layer.mock(Account.Service)({
|
||||||
active: () => Effect.succeed(Option.none()),
|
active: () => Effect.succeed(Option.none()),
|
||||||
|
|
@ -767,18 +767,13 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||||
const prev = process.env.OPENCODE_CONFIG_DIR
|
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||||
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
||||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
|
||||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
|
||||||
await fs.mkdir(mod, { recursive: true })
|
await fs.mkdir(mod, { recursive: true })
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(mod, "package.json"),
|
path.join(mod, "package.json"),
|
||||||
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
stdout: Buffer.alloc(0),
|
|
||||||
stderr: Buffer.alloc(0),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -795,7 +790,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||||
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||||
} finally {
|
} finally {
|
||||||
online.mockRestore()
|
online.mockRestore()
|
||||||
run.mockRestore()
|
install.mockRestore()
|
||||||
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||||
else process.env.OPENCODE_CONFIG_DIR = prev
|
else process.env.OPENCODE_CONFIG_DIR = prev
|
||||||
}
|
}
|
||||||
|
|
@ -821,23 +816,23 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
|
||||||
blocked = resolve
|
blocked = resolve
|
||||||
})
|
})
|
||||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
const targetDir = dir
|
||||||
const hit = path.normalize(opts?.cwd ?? "") === path.normalize(dir)
|
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
|
||||||
|
const hit = path.normalize(d) === path.normalize(targetDir)
|
||||||
if (hit) {
|
if (hit) {
|
||||||
calls += 1
|
calls += 1
|
||||||
start()
|
start()
|
||||||
await gate
|
await gate
|
||||||
}
|
}
|
||||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
|
||||||
await fs.mkdir(mod, { recursive: true })
|
await fs.mkdir(mod, { recursive: true })
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(mod, "package.json"),
|
path.join(mod, "package.json"),
|
||||||
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
|
||||||
)
|
)
|
||||||
return {
|
if (hit) {
|
||||||
code: 0,
|
start()
|
||||||
stdout: Buffer.alloc(0),
|
await gate
|
||||||
stderr: Buffer.alloc(0),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -859,7 +854,7 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
|
||||||
run.mockRestore()
|
run.mockRestore()
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(calls).toBe(1)
|
expect(calls).toBe(2)
|
||||||
expect(ticks.length).toBeGreaterThan(0)
|
expect(ticks.length).toBeGreaterThan(0)
|
||||||
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
|
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
@ -886,8 +881,8 @@ test("serializes config dependency installs across dirs", async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const online = spyOn(Network, "online").mockReturnValue(false)
|
const online = spyOn(Network, "online").mockReturnValue(false)
|
||||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
|
||||||
const cwd = path.normalize(opts?.cwd ?? "")
|
const cwd = path.normalize(dir)
|
||||||
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
|
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
|
||||||
if (hit) {
|
if (hit) {
|
||||||
calls += 1
|
calls += 1
|
||||||
|
|
@ -898,7 +893,7 @@ test("serializes config dependency installs across dirs", async () => {
|
||||||
await gate
|
await gate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
|
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
|
||||||
await fs.mkdir(mod, { recursive: true })
|
await fs.mkdir(mod, { recursive: true })
|
||||||
await Filesystem.write(
|
await Filesystem.write(
|
||||||
path.join(mod, "package.json"),
|
path.join(mod, "package.json"),
|
||||||
|
|
@ -907,11 +902,6 @@ test("serializes config dependency installs across dirs", async () => {
|
||||||
if (hit) {
|
if (hit) {
|
||||||
open -= 1
|
open -= 1
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
stdout: Buffer.alloc(0),
|
|
||||||
stderr: Buffer.alloc(0),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||||
|
|
||||||
const { Plugin } = await import("../../src/plugin/index")
|
const { Plugin } = await import("../../src/plugin/index")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { BunProc } = await import("../../src/bun")
|
const { Npm } = await import("../../src/npm")
|
||||||
const { Bus } = await import("../../src/bus")
|
const { Bus } = await import("../../src/bus")
|
||||||
const { Session } = await import("../../src/session")
|
const { Session } = await import("../../src/session")
|
||||||
|
|
||||||
|
|
@ -258,18 +258,18 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
|
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
||||||
if (pkg === "acme-plugin") return tmp.extra.acme
|
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
|
||||||
return tmp.extra.scope
|
return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
|
|
||||||
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }])
|
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
|
||||||
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }])
|
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
add.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -321,7 +321,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
|
|
@ -378,7 +378,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
const errors = await errs(tmp.path)
|
||||||
|
|
@ -431,7 +431,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
const errors = await errs(tmp.path)
|
||||||
|
|
@ -477,7 +477,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
const errors = await errs(tmp.path)
|
||||||
|
|
@ -541,7 +541,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
const errors = await errs(tmp.path)
|
||||||
|
|
@ -572,15 +572,15 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue("")
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
|
|
||||||
const pkgs = install.mock.calls.map((call) => call[0])
|
const pkgs = install.mock.calls.map((call) => call[0])
|
||||||
expect(pkgs).toContain("regular-plugin")
|
expect(pkgs).toContain("regular-plugin@1.0.0")
|
||||||
expect(pkgs).not.toContain("opencode-openai-codex-auth")
|
expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
|
||||||
expect(pkgs).not.toContain("opencode-copilot-auth")
|
expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
|
|
@ -593,7 +593,7 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
const errors = await errs(tmp.path)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue