Refuse a node_modules at the plugins/ root, where it outranks the host's
CI / full-gate (push) Successful in 2m43s
CI / full-gate (push) Successful in 2m43s
This commit is contained in:
+2
-2
@@ -31,8 +31,8 @@ export interface RequestContext {
|
||||
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
|
||||
// wraps the hrefs it builds itself.
|
||||
localeHref(href: string): string;
|
||||
// Every installed locale, sorted. With `localeLabel` (from the barrel) it is what a plugin needs
|
||||
// to build its own language picker; the host's own picker is already in the shell.
|
||||
// Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) it is what a
|
||||
// plugin needs to build its own language picker; the host's own picker is already in the shell.
|
||||
locales: string[];
|
||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
|
||||
@@ -61,9 +61,9 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
||||
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
||||
{ name: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s },
|
||||
// Written by the documented install command with one path segment dropped — and it would silently
|
||||
// make every plugin below it part of its scope.
|
||||
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
||||
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
||||
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||
];
|
||||
@@ -118,16 +118,15 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
||||
"node_modules/hoisted/index.js": `export default 1;`,
|
||||
});
|
||||
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["shop"]); // node_modules is not a plugin folder
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["shop"]);
|
||||
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) => {
|
||||
test("a plugin folder may be a symlink", 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"));
|
||||
|
||||
@@ -27,8 +27,12 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
const errors: string[] = [];
|
||||
const plugins: Plugin[] = [];
|
||||
|
||||
if (existsSync(join(dir, "package.json"))) {
|
||||
errors.push(`plugins/package.json must not exist — it becomes the package scope for every plugin below it; install into plugins/<id>, not plugins/`);
|
||||
// `npm install --prefix plugins` instead of `--prefix plugins/<id>`: the package.json becomes the
|
||||
// scope for every plugin below it, and the node_modules outranks the host's own — barrel included.
|
||||
for (const stray of ["node_modules", "package.json"]) {
|
||||
if (existsSync(join(dir, stray))) {
|
||||
errors.push(`plugins/${stray} must not exist — it sits above every plugin and shadows the host's own; delete plugins/{node_modules,package.json,package-lock.json} and install into plugins/<id>`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of pluginFolders(dir)) {
|
||||
@@ -83,9 +87,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
|
||||
// entries, plain files and node_modules are skipped; a symlink counts, so a plugin kept in its own
|
||||
// repo joins the tree with `ln -s`, and a dangling one trips "no plugin.ts found" rather than vanishing.
|
||||
// Sorted for deterministic load order + stable conflict messages. A symlink counts as a folder, and
|
||||
// one whose target the container cannot see trips "no plugin.ts found" rather than vanishing.
|
||||
function pluginFolders(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && e.name !== "node_modules")
|
||||
@@ -94,7 +97,7 @@ function pluginFolders(dir: string): string[] {
|
||||
}
|
||||
|
||||
// A barrel copy resolves before the host's, so its GuardError matches no `instanceof` here and a
|
||||
// sign-in redirect becomes a 500. A typeless folder re-parses every file it loads, and warns on each.
|
||||
// sign-in redirect becomes a 500.
|
||||
function packagingError(folder: string): string | null {
|
||||
if (existsSync(join(folder, "node_modules", "@plainpages", "plugin-api"))) {
|
||||
return "ships its own copy of @plainpages/plugin-api — remove it; the host provides the one instance";
|
||||
|
||||
Reference in New Issue
Block a user