fix(store): a plugin install lands in the plugins dir, not the nearest package.json above it
ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
Installing any plugin from the console failed on a user's Fedora 44 host with
plugin store job failed: @punktfunk/plugin-virtualhere is not present after install
while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.
`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` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.
Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.
Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.
The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.
Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+19
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user