import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { DiscoveredPlugin } from './manifest'; // A plugin's own npm dependencies, installed into its own directory. // // ── The gap this closes ── // // `osDependencies` covers binaries a plugin needs on PATH — `ffmpeg`, `radicale`, `tea`. There was // nothing for a JavaScript package, so a plugin needing one had to add it to the PLATFORM's // `package.json`. Atelier did exactly that with `sharp` on 2026-08-20, carefully and with the frozen // lockfile dance documented, and it still went wrong the first time it met a second machine: alpha // pulled the `package.json`, nobody ran `bun install`, and `officer-atelier` crash-looped on // `Cannot find package 'sharp'`. The plugin reported a successful install and then could not start. // // Three things were wrong with that shape, and none of them are the agent's fault: // // - **The platform carried a dependency only one plugin needs.** Uninstall the plugin and `sharp` is // still in the platform's lockfile, with nothing recording why. // - **Installing a plugin could not install it.** The install flow ran, said "installed", and left a // process that could not boot. Nothing in the pipeline was positioned to notice. // - **It forced the platform's lockfile open.** `frozenLockfile` exists so an unexplained lockfile // change is a signal; a plugin needing a package makes that signal routine. // // ── The shape ── // // A plugin that needs packages ships its own `package.json` and its own lockfile, and they are committed // in the plugin's OWN repository — which is the thing a plugin already is. Bun resolves `node_modules` // up the directory tree, so `plugins//node_modules/` serves that plugin's code and nothing else. // Uninstalling removes the directory and the packages with it. // // The presence of the file is the declaration. No manifest field, for the same reason `sidecar/index.ts` // has none: a second place to say the same thing is a second place for it to be wrong. /** `plugins//package.json`, or null when the plugin needs no packages — the ordinary case. */ export const npmManifestFor = (plugin: DiscoveredPlugin): string | null => { const path = join(plugin.dir, 'package.json'); return existsSync(path) ? path : null; }; export type NpmInstallResult = { ok: boolean; ran: boolean; output: string }; /** * Install a plugin's packages, in its own directory. * * **Frozen.** The plugin ships a lockfile beside its `package.json`, so this resolves from that and * nothing else — the same discipline the platform holds itself to, and for the same reason: a package * that changed without anyone choosing it is exactly what a supply-chain compromise looks like. A plugin * whose lockfile does not satisfy its `package.json` fails here rather than quietly resolving something * newer. * * Never throws. A plugin whose packages fail to install is broken, but so is refusing the whole install * over it — the caller decides, and it has more context than this does. */ export async function installNpmDependencies(plugin: DiscoveredPlugin): Promise { if (!npmManifestFor(plugin)) return { ok: true, ran: false, output: '' }; try { const proc = Bun.spawn(['bun', 'install', '--frozen-lockfile'], { cwd: plugin.dir, stdout: 'pipe', stderr: 'pipe', // The plugin's own directory is the root of this install. `--frozen-lockfile` is passed explicitly // rather than relied upon from a bunfig the plugin may not ship. }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; return { ok: code === 0, ran: true, output: `${out}${err}`.trim() }; } catch (error) { return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error) }; } } /** * Can this plugin's code actually resolve what it declared? * * Asked AFTER installing, because "the install command exited 0" and "the sidecar can import it" are * different claims, and it was the second one that failed on alpha. `sharp` in particular resolves to a * platform-specific binary package, so a lockfile that installs cleanly on one machine can still leave * nothing importable on another. */ export async function verifyNpmDependencies(plugin: DiscoveredPlugin): Promise { const manifestPath = npmManifestFor(plugin); if (!manifestPath) return []; let names: string[]; try { const parsed = (await Bun.file(manifestPath).json()) as { dependencies?: Record }; names = Object.keys(parsed.dependencies ?? {}); } catch { return []; } const missing: string[] = []; for (const name of names) { // `bun.resolveSync` from the plugin's directory answers the question the sidecar will ask, rather // than the question "is there a folder with the right name". try { Bun.resolveSync(name, plugin.dir); } catch { missing.push(name); } } return missing; }