diff --git a/crates/punktfunk-host/src/plugins.rs b/crates/punktfunk-host/src/plugins.rs index 3811aaa2..72ece455 100644 --- a/crates/punktfunk-host/src/plugins.rs +++ b/crates/punktfunk-host/src/plugins.rs @@ -101,6 +101,20 @@ NOTES: /// Locate the runner and hand it the argv verbatim, inheriting stdio so bun's progress output goes /// straight to the user's terminal. Exits with the runner's own status code. fn forward_to_runner(args: &[String]) -> Result<()> { + // `bun add` installs into the nearest ancestor `package.json`, not into its working directory, + // so the plugins dir has to own one before the runner runs or a stray `~/package.json` captures + // the install — silently, exit 0 (see `store::ensure_plugin_root`). The runner seeds it too, but + // the installed scripting package can predate this binary, so do it on this side as well. + if args.first().map(String::as_str) == Some("add") { + let dir = args + .iter() + .position(|a| a == "--plugins") + .and_then(|i| args.get(i + 1)) + .map(std::path::PathBuf::from) + .unwrap_or_else(crate::store::plugins_dir); + crate::store::ensure_plugin_root(&dir) + .with_context(|| format!("prepare {}", dir.display()))?; + } let (program, prefix) = runner_command()?; let status = Command::new(&program) .args(&prefix) diff --git a/crates/punktfunk-host/src/store.rs b/crates/punktfunk-host/src/store.rs index b79365d9..005bd7f7 100644 --- a/crates/punktfunk-host/src/store.rs +++ b/crates/punktfunk-host/src/store.rs @@ -199,6 +199,48 @@ pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result< Ok(()) } +/// Anchor bun's install root at the plugins dir by giving it a `package.json`. +/// +/// **This is load-bearing.** `bun add` does not install into its working directory — it walks *up* +/// to the nearest ancestor `package.json` and installs into THAT tree. A fresh plugins dir has no +/// `package.json`, so any stray one above it (a `~/package.json` from someone's one-off `bun add` +/// or `npm init`) silently captures the install: bun reports success and **exits 0**, the packages +/// land in the ancestor's `node_modules`, and the plugins dir gets nothing. Reproduced on-glass +/// 2026-07-31 (Nobara 44, host + runner 0.22.3): the runner exits 0 in ~100 ms and the install then +/// fails the presence check with the unhelpful "not present after install" — a user report that +/// looked like a broken store and was a stray `~/package.json` all along. +/// +/// The host writes this itself rather than leaving it to the runner, for the same reason as +/// [`ensure_bunfig_scope`]: the installed scripting package can be older than this binary. +/// +/// Only seeds a tree that has no `node_modules` yet. A dir with packages but no `package.json` is +/// hand-assembled or an older layout, and [`installed_packages`] deliberately falls back to the +/// naming convention there — dropping an empty `dependencies` on it would make every plugin already +/// installed vanish from the store. +pub(crate) fn ensure_plugin_root(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join("package.json"); + if path.exists() || dir.join("node_modules").exists() { + return Ok(()); + } + std::fs::write( + &path, + "{\n \"name\": \"punktfunk-plugins\",\n \"private\": true\n}\n", + ) + .with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +/// The nearest `package.json` **above** `dir`, if any — the thing that would capture a `bun add` +/// run inside `dir` (see [`ensure_plugin_root`]). Used to turn a silent mis-install into an error +/// the operator can act on. +pub(crate) fn capturing_ancestor(dir: &Path) -> Option { + dir.ancestors() + .skip(1) + .map(|a| a.join("package.json")) + .find(|p| p.exists()) +} + /// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray /// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree. pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> { @@ -495,6 +537,72 @@ mod tests { assert_eq!(installed_packages(dir.path()).len(), 1); } + /// A fresh plugins dir must own its install root, or `bun add` installs somewhere else. + /// + /// Field bug 2026-07-31: with no `package.json` here, bun walked up to the operator's stray + /// `~/package.json`, installed the plugin into `~/node_modules`, and exited 0 — the store then + /// failed the presence check on an empty dir. + #[test] + fn a_fresh_dir_is_seeded_as_its_own_install_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("plugins"); + ensure_plugin_root(&root).unwrap(); + let seeded = std::fs::read_to_string(root.join("package.json")).unwrap(); + assert!(seeded.contains("punktfunk-plugins"), "{seeded}"); + // Seeding must not make the dir look like it has installs, nor lose the ones it gets. + assert!(installed_packages(&root).is_empty()); + } + + #[test] + fn seeding_never_touches_an_existing_package_json() { + let dir = tempfile::tempdir().unwrap(); + touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1"); + let manifest = r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"0.3.1"}}"#; + std::fs::write(dir.path().join("package.json"), manifest).unwrap(); + ensure_plugin_root(dir.path()).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("package.json")).unwrap(), + manifest + ); + } + + /// The one tree we must NOT seed: packages present, no `package.json`. `installed_packages` + /// falls back to the naming convention there, and a seeded empty `dependencies` would report + /// every plugin the operator already runs as uninstalled (see + /// `an_emptied_dependency_list_means_nothing_is_installed`). + #[test] + fn seeding_skips_a_tree_that_already_has_packages() { + let dir = tempfile::tempdir().unwrap(); + touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0"); + ensure_plugin_root(dir.path()).unwrap(); + assert!(!dir.path().join("package.json").exists()); + assert_eq!(installed_packages(dir.path()).len(), 1); + } + + #[test] + fn capturing_ancestor_looks_strictly_upwards() { + let dir = tempfile::tempdir().unwrap(); + let plugins = dir.path().join("config/punktfunk/plugins"); + std::fs::create_dir_all(&plugins).unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + assert_eq!( + capturing_ancestor(&plugins), + Some(dir.path().join("package.json")) + ); + + // The nearest one wins — that is the tree bun would pick. + let nearer = dir.path().join("config/package.json"); + std::fs::write(&nearer, "{}").unwrap(); + assert_eq!(capturing_ancestor(&plugins), Some(nearer)); + + // The dir's OWN manifest is not a capture — it is the anchor that prevents one. + std::fs::write(plugins.join("package.json"), "{}").unwrap(); + assert_ne!( + capturing_ancestor(&plugins), + Some(plugins.join("package.json")) + ); + } + /// Uninstalling the last plugin must not resurrect its library as an installed plugin. /// /// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned diff --git a/crates/punktfunk-host/src/store/jobs.rs b/crates/punktfunk-host/src/store/jobs.rs index 960dcba8..a6260dbe 100644 --- a/crates/punktfunk-host/src/store/jobs.rs +++ b/crates/punktfunk-host/src/store/jobs.rs @@ -353,6 +353,10 @@ fn run_install(id: &str, plan: Plan) -> Result<()> { // ---- install ------------------------------------------------------------------------------ set_phase(id, "installing"); + // Before anything else: make this dir bun's install root. Without a `package.json` here, `bun + // add` walks up and installs into the nearest ancestor that has one — successfully, exit 0, + // into somebody else's tree (see `ensure_plugin_root`). + super::ensure_plugin_root(&dir).with_context(|| format!("prepare {}", dir.display()))?; let before = super::installed_packages(&dir); // Map the entry's scope to its registry ourselves rather than through a runner flag: the // installed scripting package can be older than this binary, and an older runner would read an @@ -397,10 +401,22 @@ fn run_install(id: &str, plan: Plan) -> Result<()> { "the install finished but no new plugin package appeared — is this package a \ punktfunk plugin? (it must be named `@scope/plugin-*` or `punktfunk-plugin-*`)", )?; - let installed = after - .iter() - .find(|p| p.pkg == pkg) - .with_context(|| format!("{pkg} is not present after install"))?; + let installed = after.iter().find(|p| p.pkg == pkg).with_context(|| { + // The runner said it succeeded and the package still isn't here. The one field cause is a + // capturing ancestor `package.json` (`ensure_plugin_root`) — which this job now seeds + // against, so reaching here means a tree we deliberately don't seed (packages present, no + // `package.json`). Name the file rather than leaving the operator with a dead end. + match super::capturing_ancestor(&dir) { + Some(p) => format!( + "the runner reported success but {pkg} is not in {} — `{}` is capturing the \ + install (bun installs into the nearest package.json ABOVE the working directory). \ + Move or delete it, or add a package.json to the plugins dir.", + dir.display(), + p.display() + ), + None => format!("{pkg} is not present after install"), + } + })?; if let (Some(want), Some(got)) = (plan.version.as_deref(), installed.version.as_deref()) { if want != got { diff --git a/sdk/src/plugins.ts b/sdk/src/plugins.ts index 50628588..0e4aee01 100644 --- a/sdk/src/plugins.ts +++ b/sdk/src/plugins.ts @@ -54,9 +54,27 @@ export const resolvePackage = ( /** Does this resolved package name install from Punktfunk's own (Gitea) registry? */ const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/"); -/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ +/** + * Create the plugins dir (and parents) if needed, and make it bun's install ROOT. On Windows the + * ACL lockdown is the host's job. + * + * The `package.json` is load-bearing, not decoration: `bun add` installs into the nearest ancestor + * `package.json`, not into its working directory. Without one here, a stray `~/package.json` — one + * old `bun add`/`npm init` in a home dir — silently captures every plugin install. bun reports + * success and exits 0, the packages land in that tree, and the plugins dir stays empty (reproduced + * on-glass 2026-07-31; it presented as a plugin store that installs nothing). + * + * Only seeds a tree with no `node_modules`. A dir with packages but no `package.json` is + * hand-assembled or an older layout, and both this module's [`listInstalled`] and the host's + * installed-package scan fall back to the naming convention there; an empty `dependencies` would + * make the host report every plugin already installed as gone. + */ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { fs.mkdirSync(dir, { recursive: true }); + const manifest = path.join(dir, "package.json"); + if (!fs.existsSync(manifest) && !fs.existsSync(path.join(dir, "node_modules"))) { + fs.writeFileSync(manifest, '{\n "name": "punktfunk-plugins",\n "private": true\n}\n'); + } return dir; }; diff --git a/sdk/test/plugins.test.ts b/sdk/test/plugins.test.ts index a2fae353..175ab632 100644 --- a/sdk/test/plugins.test.ts +++ b/sdk/test/plugins.test.ts @@ -194,4 +194,38 @@ describe("ensurePluginsDir", () => { expect(fs.statSync(dir).isDirectory()).toBe(true); ensurePluginsDir(dir); // idempotent }); + + // Field bug 2026-07-31: `bun add` installs into the nearest ancestor package.json, not into + // its working dir. With none here, a stray ~/package.json captured every plugin install — bun + // exited 0, the packages landed in the home dir, and the plugins dir stayed empty. + test("seeds a package.json so bun cannot install into an ancestor", () => { + const dir = path.join(tmp("ensure-root"), "plugins"); + ensurePluginsDir(dir); + const seeded = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf8"), + ) as { name: string; private: boolean }; + expect(seeded.name).toBe("punktfunk-plugins"); + expect(seeded.private).toBe(true); + }); + + test("never overwrites an existing package.json", () => { + const dir = tmp("ensure-keep"); + const manifest = '{"dependencies":{"@punktfunk/plugin-playnite":"0.3.0"}}'; + fs.writeFileSync(path.join(dir, "package.json"), manifest); + ensurePluginsDir(dir); + expect(fs.readFileSync(path.join(dir, "package.json"), "utf8")).toBe(manifest); + }); + + // The one tree we must not seed: packages present, no package.json. Discovery falls back to + // the naming convention there, and an empty `dependencies` would report every installed plugin + // as gone (the host's installed-package scan narrows to that list). + test("leaves a tree that already has packages alone", () => { + const dir = tmp("ensure-existing"); + writePkg(dir, "punktfunk-plugin-legacy", "0.1.0"); + ensurePluginsDir(dir); + expect(fs.existsSync(path.join(dir, "package.json"))).toBe(false); + expect(listInstalled(dir).map((p) => p.pkg)).toEqual([ + "punktfunk-plugin-legacy", + ]); + }); });