plugins can declare their own npm packages
osDependencies covered binaries on PATH — ffmpeg, radicale, tea — and there was nothing for a javascript package. so a plugin needing one had to add it to the PLATFORM's package.json, which is what atelier did with sharp, carefully, with the frozen-lockfile dance documented in BLOCKERS.md. it still broke on the first machine that was not this one. alpha pulled the package.json, nobody ran bun install, and officer-atelier crash-looped on "Cannot find package 'sharp'" — after the install flow had reported success. three things were wrong with that shape. the platform carried a dependency only one plugin needs, and uninstalling the plugin would not have removed it. installing a plugin could not install it, so "installed" and "can start" were different facts with nothing checking the second. and it forced the platform's lockfile open, which is precisely the signal frozenLockfile exists to keep rare. now: a plugin ships its own package.json and lockfile, committed in its own repository, and the install flow runs `bun install --frozen-lockfile` in the plugin's directory. bun resolves node_modules up the tree, so plugins/<name>/node_modules serves that plugin and nothing else, and uninstall takes the packages with the directory. the presence of the file is the declaration — no manifest field, same reason sidecar/index.ts has none: a second place to say it is a second place to be wrong. verified by RESOLVING, not by the exit code. sharp resolves to a platform-specific binary package, so a lockfile that installs cleanly on one machine can leave nothing importable on another — which is the exact failure being fixed, and checking the exit code would have reproduced it one layer up. sharp is off the platform's package.json. the lockfile diff is that removal plus @img/sharp-libvips-* re-resolving 1.0.x → 1.2.4, because the platform no longer pins sharp; nothing else moved, checked line by line. the platform still reaches sharp transitively through next, which is where it came from.
This commit is contained in:
@@ -24,6 +24,7 @@ import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
import { declaredTables, dropTables, existingTables } from './tables';
|
||||
import type { OnLog } from './run-logged';
|
||||
import { installDependencies, installRelease, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
|
||||
import { installNpmDependencies, npmManifestFor, verifyNpmDependencies } from './npm-deps';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
|
||||
// The install runner: the four verbs, each as a short ordered list of effects.
|
||||
@@ -268,6 +269,34 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `dependencies: ${deps.dependencies.map((d) => d.binary).join(', ')} already present`);
|
||||
}
|
||||
|
||||
// ── npm packages, in the plugin's own directory ──
|
||||
//
|
||||
// Beside the OS dependencies above and verified the same way, because the same failure is available:
|
||||
// an install that exits 0 and still leaves nothing the sidecar can import. Atelier hit exactly that
|
||||
// on 2026-08-20 — `sharp` in the PLATFORM's package.json, alpha never told to `bun install`, and the
|
||||
// sidecar crash-looped on a plugin that had reported a clean install.
|
||||
if (npmManifestFor(plugin)) {
|
||||
await step(steps, onStep, "packages: installing from the plugin's own package.json");
|
||||
const npm = await installNpmDependencies(plugin);
|
||||
if (!npm.ok) {
|
||||
return { ok: false, appName, steps, error: `package install failed: ${npm.output || 'no output'}` };
|
||||
}
|
||||
|
||||
// Resolved, not assumed. `sharp` resolves to a platform-specific binary package, so a lockfile that
|
||||
// installs cleanly on one machine can still leave nothing importable on another — which is the
|
||||
// shape of the failure this whole block exists to stop shipping.
|
||||
const unresolvable = await verifyNpmDependencies(plugin);
|
||||
if (unresolvable.length) {
|
||||
return {
|
||||
ok: false,
|
||||
appName,
|
||||
steps,
|
||||
error: `packages installed, but ${unresolvable.join(', ')} cannot be resolved from ${plugin.dir}`,
|
||||
};
|
||||
}
|
||||
await step(steps, onStep, 'packages: installed and resolvable');
|
||||
}
|
||||
|
||||
if (plugin.schema) {
|
||||
// The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from
|
||||
// what is installed — see schema.ts for why that difference is the whole safety property.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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/<name>/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/<name>/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<NpmInstallResult> {
|
||||
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<string[]> {
|
||||
const manifestPath = npmManifestFor(plugin);
|
||||
if (!manifestPath) return [];
|
||||
|
||||
let names: string[];
|
||||
try {
|
||||
const parsed = (await Bun.file(manifestPath).json()) as { dependencies?: Record<string, string> };
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user