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:
2026-08-20 18:10:08 +00:00
parent b04059cdd6
commit 7e654d75bc
4 changed files with 155 additions and 23 deletions
+29
View File
@@ -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.