npmDependencies in the manifest, installed once for every plugin

replaces the per-plugin package.json from earlier tonight. the owner's call,
and the reason is one i had not weighed: sharp is ~50 MB of platform binaries
and the obvious dependency for anything touching images, so a second and third
plugin wanting it would have meant a second and third copy with no version they
could converge on.

so every plugin's `npmDependencies` merge into ONE generated plugins/package.json,
installed into plugins/node_modules/. bun resolves node_modules up the tree, so
that directory serves every plugin and none of the platform's own code.

ranges intersect rather than alternate. two plugins asking for ^0.33.5 and
^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved
version must satisfy both. `||` would have meant "either", which is the silent
version of this failure: one plugin quietly getting a version the other cannot
import. incompatible requirements now fail the install with bun's own message,
and both askers are named in the steps either way.

generated from the plugins ON DISK, like generatePluginSchemas, and never
edited — so a package cannot appear there without a plugin having declared it.

what this gives up, knowingly: a committed lockfile. ranges are the whole point
and a range resolves per machine, so plugin packages are resolved at install
time like osDependencies are, and unlike the platform's own. CLAUDE.md is
emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest
type rather than left to be discovered.

the generated plugins/package.json and lockfile are gitignored — /plugins/*/
only ignores directories, so files at that level needed naming explicitly.
This commit is contained in:
2026-08-20 18:22:58 +00:00
parent 3450f5eb57
commit 21bedf1418
6 changed files with 242 additions and 166 deletions
-18
View File
@@ -3,8 +3,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { PLUGINS_DIR } from './discover';
import { pullPlugin } from './update';
import { discoverPlugins } from './discover';
import { npmManifestFor, npmWouldBlockUpdates } from './npm-deps';
// The REFUSALS are what this test is for. A pull that works is easy to believe; a pull that quietly
// discarded somebody's uncommitted work is the failure that matters, and every plugin directory on a
@@ -55,19 +53,3 @@ describe('pullPlugin refuses rather than guesses', () => {
}
});
});
describe('npm packages and the Update button', () => {
// The trap: `pullPlugin` refuses on ANY uncommitted change, and `node_modules/` that nothing ignores
// is one. So a plugin shipping `package.json` without ignoring it installs perfectly once and can then
// never be updated — the button says "N uncommitted changes" forever, about a directory the platform
// itself created. Every plugin that exists today ignores it, which is luck rather than a rule.
test('atelier — the only plugin with packages — ignores node_modules', async () => {
const { plugins } = await discoverPlugins();
const withPackages = plugins.filter((p) => npmManifestFor(p));
// Not asserting that atelier specifically is installed — a checkout without it is legitimate.
for (const plugin of withPackages) {
expect(await npmWouldBlockUpdates(plugin), `${plugin.appName} would block its own updates`).toBe(false);
}
});
});