import path from "node:path"; import process from "node:process"; import { mkdir, rm } from "node:fs/promises"; import semver from "semver"; import BaseMarketplacePluginManager from "./BaseMarketplacePluginManager.ts"; import type NpmjsPluginRepository from "./plugin_repository/NpmjsPluginRepository.ts"; import type NpmPluginRepository from "./plugin_repository/NpmPluginRepository.ts"; import type PluginManager from "../api/plugin_manager/PluginManager.ts"; import type VersionedPluginDescriptor from "../api/plugin_repository/VersionedPluginDescriptor.ts"; import type VersionedPluginRepository from "../api/plugin_repository/VersionedPluginRepository.ts"; import type SpawnCapable from "../api/spawn/SpawnCapable.ts"; import type SpawnInterface from "../api/spawn/SpawnInterface.ts"; /** * On Windows, batch-file shims (e.g. `npm.cmd`, `bun.cmd`) cannot be launched directly via * `CreateProcess` - they must be run through `cmd.exe /c`. Resolve the command through the shell * in that case; leave it untouched on other platforms. */ export function resolveForPlatform(args: string[]): string[] { if (process.platform !== "win32") { return args; } const [bin, ...rest] = args; if (bin === undefined) { return args; } const resolved = Bun.which(bin) ?? bin; if (!/\.(cmd|bat)$/i.test(resolved)) { return [resolved, ...rest]; } return ["cmd.exe", "/d", "/s", "/c", resolved, ...rest]; } /** * Default timeout applied to an install/uninstall command spawn. * * Resolving and downloading a dependency tree can legitimately take a while on a slow * connection, so this is generous compared to the registry pre-check timeout used by * `checkAvailable()` (10s, see `defaultFetch.ts`'s `DEFAULT_FETCH_TIMEOUT_MS`) - but it must * still be bounded so a stalled network connection during the package manager's own registry * access fails with a clear error instead of hanging the calling process indefinitely. */ export const DEFAULT_INSTALL_TIMEOUT_MS = 120_000; async function pumpToStdout(stream: ReadableStream): Promise { for await (const chunk of stream) { process.stdout.write(chunk); } } async function pumpToStderr(stream: ReadableStream): Promise { for await (const chunk of stream) { process.stderr.write(chunk); } } /** * {@link BaseMarketplacePluginManager} for the npm ecosystem. * * Combines one or more {@link NpmjsPluginRepository} remotes with a local * {@link NpmPluginRepository} (backed by `node_modules`). Plugins are installed and * removed by shelling out to bun/npm, or via an injected {@link SpawnInterface} if * {@link setSpawn} has been called. */ export default class NpmPluginManager extends BaseMarketplacePluginManager implements SpawnCapable { private readonly installCommand: string; private readonly installTimeoutMs: number; private spawn: SpawnInterface | undefined; /** * @param remotes marketplace repositories used for search and as install sources. * @param local repository used to load installed plugins, backed by `node_modules`. * @param options.installCommand optional install command (e.g. `"npm install"`). If not * specified, `"bun add"` is used if `bun` is on `PATH`, falling back to `"npm install"` if * `npm` is on `PATH` instead. Throws if neither is found and no explicit command is given. * @param options.pluginManager optional {@link PluginManager} to delegate to; see * {@link BaseMarketplacePluginManager}. * @param options.installTimeoutMs optional timeout applied to each install/uninstall command * spawn; defaults to {@link DEFAULT_INSTALL_TIMEOUT_MS}. */ public constructor( remotes: NpmjsPluginRepository[], local: NpmPluginRepository, { installCommand, pluginManager, installTimeoutMs, }: { installCommand?: string; pluginManager?: PluginManager; installTimeoutMs?: number } = {}, ) { super(remotes, local, pluginManager); this.installCommand = installCommand ?? NpmPluginManager.resolveDefaultInstallCommand(); this.installTimeoutMs = installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS; const binary = this.installCommand.split(" ")[0]!; if (!Bun.which(binary)) { throw new Error( `Install command binary '${binary}' not found on PATH; cannot install plugins`, ); } } private static resolveDefaultInstallCommand(): string { if (Bun.which("bun")) return "bun add"; if (Bun.which("npm")) return "npm install"; throw new Error( "Neither 'bun' nor 'npm' found on PATH; specify installCommand explicitly to use a different package manager", ); } public setSpawn(spawn: SpawnInterface): void { this.spawn = spawn; } /** * Ensures `cwd` has its own `package.json` before an install/uninstall command is run there. * * Without one, `bun add`/`bun remove` (and npm, under workspace configs) walk UP the * directory tree looking for an ancestor `package.json` and, if found, treat that * ancestor as the install root - silently installing into the ancestor's `node_modules` * instead of `cwd`, while still exiting 0. Seeding an empty `package.json` in `cwd` first * makes `cwd` itself the install root, regardless of what exists further up the tree. */ private async ensurePackageJson(cwd: string): Promise { const pkgJsonPath = path.join(cwd, "package.json"); const pkgFile = Bun.file(pkgJsonPath); if (await pkgFile.exists()) return; await Bun.write(pkgJsonPath, JSON.stringify({ name: "plugins", private: true }, null, 2)); } private async runCommand(args: string[], cwd: string): Promise { if (this.spawn) { const result = await this.spawn.spawn(args, { cwd, timeoutMs: this.installTimeoutMs }); if (!result.ok) { if (result.timedOut) { throw new Error(`Command '${args.join(" ")}' timed out after ${this.installTimeoutMs}ms`); } if (result.error) { throw new Error(`Command '${args.join(" ")}' failed to launch: ${result.error.message}`); } throw new Error(`Command '${args.join(" ")}' failed with exit code ${result.exitCode}`); } return; } const proc = Bun.spawn(resolveForPlatform(args), { cwd, // Only inherit stdin when we actually have an interactive terminal to read a // prompt from (e.g. bun's "trust dependencies with postinstall scripts?" prompt). // Inheriting unconditionally hangs non-interactive invocations (CI, piped input) // where the parent's stdin is an open, non-TTY stream that never reaches EOF. stdin: process.stdin.isTTY ? "inherit" : "ignore", // "pipe" rather than "inherit": on Windows, "inherit" hands the child our own // stdout/stderr OS handles, which get duplicated again into every process it // spawns (cmd.exe -> bun.exe -> further helper processes). If any of those // grandchildren doesn't close its copy promptly, a caller reading our output // (e.g. Python's subprocess.communicate()) can block past the point where the // whole visible process tree has already exited. "pipe" gives Bun its own // dedicated pipe that never leaves this process, so we forward bytes ourselves // instead of sharing the caller's handle down an uncontrolled process chain. stdout: "pipe", stderr: "pipe", }); // No injected SpawnInterface to enforce a timeout for us here, so do it manually: a plain // setTimeout()/proc.kill() rather than AbortSignal.timeout() for the same cross-platform // portability reasons as defaultFetch.ts's timeout (established Bun-on-Windows unreliability // during the earlier fetch-timeout investigation). The timer is always cleared once the // process exits so no dangling timer is left behind. let timedOut = false; const timer = setTimeout(() => { timedOut = true; proc.kill(); }, this.installTimeoutMs); let exitCode: number; try { [, , exitCode] = await Promise.all([ pumpToStdout(proc.stdout), pumpToStderr(proc.stderr), proc.exited, ]); } finally { clearTimeout(timer); } if (timedOut) { throw new Error(`Command '${args.join(" ")}' timed out after ${this.installTimeoutMs}ms`); } if (exitCode !== 0) { throw new Error(`Command '${args.join(" ")}' failed with exit code ${exitCode}`); } } public async install( descriptor: Readonly, options?: { includeDependencies?: boolean }, ): Promise { let source: VersionedPluginRepository | undefined; for (const remote of this.remotes) { for await (const d of remote.getPlugins()) { if (d.pluginId === descriptor.pluginId) { source = remote; break; } } if (source) break; } if (!source) { if (this.remotes.length === 0) { throw new Error( `Plugin ${descriptor.pluginId} not found in any configured remote repository`, ); } source = this.remotes[0]!; } await this.installOne(descriptor, source, this.local, options?.includeDependencies ?? false); } private async installOne( descriptor: Readonly, source: VersionedPluginRepository, target: NpmPluginRepository, includeDependencies: boolean, ): Promise { if (includeDependencies && descriptor.dependencies) { for (const dep of descriptor.dependencies) { const depId = dep.scope ? `${dep.scope}/${dep.name}` : dep.name; let satisfied = false; for await (const installed of target.getPlugins()) { const installedId = installed.scope ? `${installed.scope}/${installed.name}` : installed.name; if (installedId === depId && semver.satisfies(installed.version, dep.versionRange)) { satisfied = true; break; } } if (satisfied) continue; let found: Readonly | undefined; for await (const remote of source.getPlugins()) { const remoteId = remote.scope ? `${remote.scope}/${remote.name}` : remote.name; if (remoteId === depId && semver.satisfies(remote.version, dep.versionRange)) { found = remote; break; } } if (!found) { throw new Error(`Dependency ${depId}@${dep.versionRange} not found in source repository`); } await this.installOne(found, source, target, includeDependencies); } } const cwd = path.dirname(target.nodeModulesPath); await mkdir(cwd, { recursive: true }); await this.ensurePackageJson(cwd); const installArg = descriptor.version && descriptor.version !== "latest" ? `${descriptor.pluginId}@${descriptor.version}` : descriptor.pluginId; const cmdParts = [...this.installCommand.split(" "), installArg]; await this.runCommand(cmdParts, cwd); await this.validatePluginBundled(descriptor.pluginId, target.nodeModulesPath, cwd); } private async validatePluginBundled( pluginId: string, nodeModulesPath: string, cwd: string, ): Promise { const pluginDir = path.join(nodeModulesPath, pluginId); const pkgFile = Bun.file(path.join(pluginDir, "package.json")); if (!(await pkgFile.exists())) return; const pkg = (await pkgFile.json()) as Record; const exports = pkg["exports"] as Record | undefined; const rootExport = exports?.["."] as Record | undefined; const candidates = [rootExport?.["default"], pkg["main"] as string | undefined]; for (const rel of candidates) { if (!rel || typeof rel !== "string") continue; if (await Bun.file(path.join(pluginDir, rel)).exists()) return; } // No bundled entry found — uninstall and surface a clear error. let removeCmd: string; if (this.installCommand.startsWith("bun")) { removeCmd = "bun remove"; } else if (this.installCommand.startsWith("npm")) { removeCmd = "npm uninstall"; } else { removeCmd = this.installCommand.replace(/add|install/, "remove"); } await this.runCommand([...removeCmd.split(" "), pluginId], cwd); throw new Error( `Plugin ${pluginId} does not ship a pre-built bundle (no "default" export entry found). Only bundled plugins are supported.`, ); } public async uninstall(pluginId: string): Promise { for await (const plugin of this.local.getPlugins()) { if (plugin.pluginId === pluginId) continue; if (!plugin.dependencies) continue; for (const dep of plugin.dependencies) { const depId = dep.scope ? `${dep.scope}/${dep.name}` : dep.name; if (depId === pluginId) { throw new Error(`Cannot uninstall ${pluginId}: plugin ${plugin.pluginId} depends on it`); } } } const cwd = path.dirname(this.local.nodeModulesPath); await mkdir(cwd, { recursive: true }); await this.ensurePackageJson(cwd); let removeCmd: string; if (this.installCommand.startsWith("bun")) { removeCmd = "bun remove"; } else if (this.installCommand.startsWith("npm")) { removeCmd = "npm uninstall"; } else { removeCmd = this.installCommand.replace(/add|install/, "remove"); } const cmdParts = [...removeCmd.split(" "), pluginId]; await this.runCommand(cmdParts, cwd); if (this.installCommand.startsWith("bun")) { // bun does not prune node_modules of packages orphaned by removal, leaving stale // transitive dependencies on disk even though the lockfile is correct. // See https://github.com/oven-sh/bun/issues/3605 if (path.basename(this.local.nodeModulesPath) === "node_modules") { await rm(this.local.nodeModulesPath, { recursive: true, force: true }); await this.runCommand(["bun", "install"], cwd); } } } }