Files
punktfunk/sdk/test/plugins.test.ts
T
enricobuehlerandClaude Opus 5 78926be4ac
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
fix(store): a plugin install lands in the plugins dir, not the nearest package.json above it
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>
2026-07-31 12:48:25 +02:00

232 lines
9.0 KiB
TypeScript

// The `punktfunk-host plugins …` package-op helpers: friendly-name resolution, the bunfig scope
// wiring (idempotent + merges into an existing file), and installed-plugin discovery. `add`/`remove`
// shell out to bun over the network, so the live install is verified on-glass, not here.
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import {
ensureBunfig,
ensurePluginsDir,
listInstalled,
REGISTRY,
resolvePackage,
} from "../src/plugins.js";
const ROOT = path.join(import.meta.dir, "..", `.plugins-fixtures-${process.pid}`);
fs.mkdirSync(ROOT, { recursive: true });
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const tmp = (name: string): string => {
const dir = path.join(ROOT, name);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
const writePkg = (dir: string, name: string, version: string) => {
const pkgDir = path.join(dir, "node_modules", name);
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({ name, version }),
);
};
describe("resolvePackage", () => {
test("maps bare first-party names into the @punktfunk scope", () => {
expect(resolvePackage("playnite")).toBe("@punktfunk/plugin-playnite");
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
});
test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => {
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
"@punktfunk/plugin-playnite",
);
});
test("refuses public-registry names without allowPublicRegistry", () => {
expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow(
/public/i,
);
expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i);
expect(() => resolvePackage("some/registry-path")).toThrow(/public/i);
});
test("passes public-registry names through with allowPublicRegistry", () => {
const allow = { allowPublicRegistry: true };
expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe(
"punktfunk-plugin-custom",
);
expect(resolvePackage("@someone/plugin-x", allow)).toBe(
"@someone/plugin-x",
);
});
test("trims and rejects empty", () => {
expect(resolvePackage(" playnite ")).toBe("@punktfunk/plugin-playnite");
expect(() => resolvePackage(" ")).toThrow();
});
});
describe("ensureBunfig", () => {
test("writes the @punktfunk scope map when absent", () => {
const dir = tmp("bunfig-fresh");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("is idempotent — a second call doesn't duplicate the scope", () => {
const dir = tmp("bunfig-idempotent");
ensureBunfig(dir);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml.match(/@punktfunk/g)?.length).toBe(1);
});
test("merges into an existing [install.scopes] table, keeping other scopes", () => {
const dir = tmp("bunfig-merge");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@other" = "https://example.test/npm/"\n',
);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain('"@other" = "https://example.test/npm/"');
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(toml.match(/\[install\.scopes\]/g)?.length).toBe(1);
});
test("appends a table to an unrelated existing bunfig", () => {
const dir = tmp("bunfig-append");
fs.writeFileSync(path.join(dir, "bunfig.toml"), "telemetry = false\n");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("telemetry = false");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
});
describe("listInstalled", () => {
test("returns empty for a dir with no node_modules", () => {
expect(listInstalled(tmp("list-empty"))).toEqual([]);
});
test("finds both scoped and unscoped plugins with versions, ignoring other packages", () => {
const dir = tmp("list-mixed");
writePkg(dir, "punktfunk-plugin-custom", "1.2.3");
writePkg(dir, path.join("@punktfunk", "plugin-playnite"), "0.2.0");
writePkg(dir, "effect", "4.0.0"); // an ordinary dep — must not be listed
writePkg(dir, path.join("@punktfunk", "host"), "0.1.1"); // scoped non-plugin — ignored
const found = listInstalled(dir);
expect(found).toEqual([
{ pkg: "@punktfunk/plugin-playnite", version: "0.2.0" },
{ pkg: "punktfunk-plugin-custom", version: "1.2.3" },
]);
});
test("tolerates a plugin with an unreadable package.json", () => {
const dir = tmp("list-broken");
fs.mkdirSync(path.join(dir, "node_modules", "punktfunk-plugin-broken"), {
recursive: true,
});
expect(listInstalled(dir)).toEqual([
{ pkg: "punktfunk-plugin-broken", version: undefined },
]);
});
test("finds plugins in ANY scope, not just @punktfunk", () => {
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
// registry, so a third-party plugin necessarily arrives as `@their-scope/plugin-*`.
// Discovery limited to @punktfunk would let it install and then never run.
const dir = tmp("list-foreign-scope");
writePkg(dir, path.join("@retro-hub", "plugin-x"), "1.0.0");
writePkg(dir, path.join("@retro-hub", "helper"), "1.0.0"); // scoped non-plugin — ignored
expect(listInstalled(dir)).toEqual([{ pkg: "@retro-hub/plugin-x", version: "1.0.0" }]);
});
});
describe("ensureBunfig with extra scopes", () => {
const read = (dir: string) => fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
test("maps a third-party scope alongside @punktfunk", () => {
const dir = tmp("bunfig-extra");
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
});
test("is idempotent and rewrites a scope whose registry changed", () => {
const dir = tmp("bunfig-rewrite");
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
expect(read(dir).match(/@retro-hub/g)?.length).toBe(1);
ensureBunfig(dir, { "@retro-hub": "https://new.example/npm/" });
const out = read(dir);
expect(out).toContain('"@retro-hub" = "https://new.example/npm/"');
expect(out).not.toContain("old.example");
// the first-party mapping survives an unrelated scope edit
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("preserves unrelated scopes already in the file", () => {
const dir = tmp("bunfig-preserve");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@acme" = "https://acme.example/"\n',
);
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain('"@acme" = "https://acme.example/"');
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
});
describe("ensurePluginsDir", () => {
test("creates the dir (and parents) and returns it", () => {
const dir = path.join(tmp("ensure-dir"), "nested", "plugins");
expect(ensurePluginsDir(dir)).toBe(dir);
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",
]);
});
});