From 82af77356f81c8ff1c0f98790ca09d919d61deb0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 18 Aug 2026 07:49:51 +0200 Subject: [PATCH] Follow symlinked plugin folders, and keep a plugin .npmrc out of the image --- .dockerignore | 4 ++++ AGENTS.md | 5 +++++ src/plugin-host/discovery.test.ts | 19 ++++++++++++++++++- src/plugin-host/discovery.ts | 6 +++--- todo.md | 3 ++- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 05a6c89..2872634 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,10 @@ npm-debug.log *.log .DS_Store +# A plugin author's pin setting, per README → Plugin dependencies. Nothing reads it at runtime, and +# an .npmrc is where a private-registry token would sit — never bake one into a shipped image. +plugins/**/.npmrc + e2e-tests/artifacts # Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./) e2e-tests/Dockerfile diff --git a/AGENTS.md b/AGENTS.md index dbb4d53..7ed9948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,11 @@ Revisit only if the stated reason stops holding. - **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves against that instead and boot fails loud. An operator's menu override has no use for dependencies; if that changes, it needs the same package treatment. +- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a + warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper + loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway + because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins. + **Valid while nothing is installed in the wild.** - **`examples/` mirrors the drop-in mount dirs** — `examples/plugins//` copies to `plugins//`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and* diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 0243dc8..2b633cf 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test, type TestContext } from "node:test"; @@ -127,6 +127,23 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" }); }); +test("a plugin folder may be a symlink — a plugin kept in its own repo", async (t) => { + const ownRepo = scaffold(t, { "my-plugin/plugin.ts": full("my-plugin") }); + const dir = scaffold(t, {}); + symlinkSync(join(ownRepo, "my-plugin"), join(dir, "linked")); + + const plugins = await discoverPlugins({ dir }); + + assert.deepEqual(plugins.map((p) => p.id), ["linked"]); // the link name is the id, not the target's +}); + +test("a dangling plugin symlink fails loud rather than vanishing", async (t) => { + const dir = scaffold(t, {}); + symlinkSync(join(dir, "gone"), join(dir, "broken")); + + await assert.rejects(discoverPlugins({ dir }), /broken.*plugin\.ts/s); +}); + test("a shared permission name only warns — both plugins still load", async (t) => { const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index e69d203..1d4953c 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -84,11 +84,11 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise