diff --git a/packages/business/README.md b/packages/business/README.md index 9a6baad3c..d2967cb33 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -100,6 +100,13 @@ instance whose definition holds `role.launch` within-role; otherwise the file refuses. Only that instance keeps `role.launch`. Every other instance loses it, so for them it's gated. +`plugins` (optional) switches on plug-in modules, one key per kind from +`packages/plugins/src/kinds.mjs`, each a list of module names matching +`^[a-z][a-z0-9-]*$` with no name twice. An unknown kind or a bad name +refuses the file. Absent means nothing is switched on. Whether a named +module exists and loads is the plug-in loader's check, at load time; see +`packages/plugins/README.md`. + ### Chief-of-staff and assistant instances Two roles stand above the PM (PRD 1.2; row 119, #1613). @@ -227,7 +234,9 @@ file missing). The CLI adds 3 for a system config problem and 4 for usage. `src/organization.mjs` is the trusted host write module. The caller supplies a verified actor and the SHA-256 revision read earlier. It validates the whole -document, refuses stale revisions and concurrent writers, keeps exact previous +document, and again as the bytes it will write read back as JSON, so a value +JSON can't carry (a `Date`, a `Map`) refuses instead of landing as something +else; `changes.jsonl` logs those bytes. It refuses stale revisions and concurrent writers, keeps exact previous bytes under `businesses/.history///previous.json`, then publishes a fsynced temporary file by atomic rename. `changes.jsonl` records prepared and committed transactions with actor, hashes and old/new values per changed field. @@ -237,7 +246,10 @@ launches keep using the whole validated file on disk, without consulting receipt confirm that its old writer is gone before clearing it; the host does not race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves. `launch-settings.mjs` restricts human edits to limits, model table and instance -model selections and validates effective selections before publication. Migration +model selections and validates effective selections before publication. `plugin-settings.mjs` +is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic` +command or Settings form reaches it. A refused `plugins` write throws +`invalid-plugins` or `invalid-request` before the writer runs and logs nothing. Migration uses none of this: it only prints a converted document (decision 145). For inherited selections, print-only migration reads the existing system config diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index cbe04a59b..49ac1fa45 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -9,12 +9,13 @@ import { checkModels, checkLimits } from "./launch.mjs"; import { checkVars } from "./vars.mjs"; import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; +import { checkPlugins } from "../../plugins/src/kinds.mjs"; import { requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze, } from "./util.mjs"; const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/; -const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"]; +const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"]; // The config directory: next to the system config, $MOSAIC_CONFIG or // ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses. @@ -178,9 +179,13 @@ export function validateBusinessDocument(document, file, { rolesDir }) { } } const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file); + // REQ-PLUG-1: the module names switched on per kind. The names are checked + // here; whether each module loads is the loader's check, at load time. + const plugins = document.plugins === undefined ? {} + : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) })); return deepFreeze({ - businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions, + businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions, }); } diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs index 03ddc6714..2649d37a7 100644 --- a/packages/business/src/organization.mjs +++ b/packages/business/src/organization.mjs @@ -186,6 +186,10 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { validateBusinessDocument(document, file, { rolesDir }); const old = JSON.parse(oldBytes); const bytes = JSON.stringify(document, null, 2) + "\n"; + // Check and log exactly what lands on disk, for every setter: a value JSON + // can't carry (a Date, a Map) serializes as something else (row 131, sage-0314). + const written = JSON.parse(bytes); + validateBusinessDocument(written, file, { rolesDir }); const after = sha256(bytes); const transaction = randomUUID(); const tx = join(home, transaction); @@ -197,7 +201,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { syncDir(tx); const event = { transaction, actor, business: id, before, after, - changes: changes(old, document), at: new Date().toISOString(), + changes: changes(old, written), at: new Date().toISOString(), }; log(id, { ...event, state: "prepared" }); syncDir(home); diff --git a/packages/business/src/plugin-settings.mjs b/packages/business/src/plugin-settings.mjs new file mode 100644 index 000000000..9a576bdd7 --- /dev/null +++ b/packages/business/src/plugin-settings.mjs @@ -0,0 +1,45 @@ +// Host setter for the organization file's `plugins` switch (REQ-PLUG-1, +// REQ-ROLE-3). It is the only writer of that key: no broker verb, mosaic +// command, Settings form or chief-of-staff write reaches it (sage-0301). +// Writes go through the organization writer, which logs old and new names +// like a limit change. A refused write throws a code and logs nothing. +import { organizationWriter, OrganizationError } from "./organization.mjs"; +import { isPlainObject } from "./util.mjs"; +import { PluginError, checkPlugins } from "../../plugins/src/kinds.mjs"; + +export function pluginSettings({ dir, rolesDir }) { + const writer = organizationWriter({ dir, rolesDir }); + + function read(id) { + const { document, revision } = writer.read(id); + return { business: id, revision, plugins: structuredClone(document.plugins ?? {}) }; + } + + function set(id, args, actor) { + if (!isPlainObject(args) || Object.keys(args).some(key => key !== "revision" && key !== "plugins")) { + throw new OrganizationError("invalid-request"); + } + if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) { + throw new OrganizationError("invalid-request"); + } + // A Date or a Map has no own keys and would pass checkPlugins, then serialize + // as something else (Filbert F1, sage-0314). Only a JSON-shaped object passes. + const shaped = args.plugins !== null && typeof args.plugins === "object"; + const proto = shaped ? Object.getPrototypeOf(args.plugins) : undefined; + if (proto !== Object.prototype && proto !== null) { + throw new OrganizationError("invalid-plugins", "plugins must be a plain object with one key per kind"); + } + try { + checkPlugins(args.plugins, "plugins"); + } catch (e) { + if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message); + throw e; + } + const next = structuredClone(writer.read(id).document); + next.plugins = structuredClone(args.plugins); + const receipt = writer.write(id, next, { revision: args.revision, actor }); + return { ...read(id), transaction: receipt.transaction }; + } + + return { writer, read, set }; +} diff --git a/packages/business/tests/plugin-settings.test.mjs b/packages/business/tests/plugin-settings.test.mjs new file mode 100644 index 000000000..437eb5e31 --- /dev/null +++ b/packages/business/tests/plugin-settings.test.mjs @@ -0,0 +1,105 @@ +// REQ-PLUG-1 / REQ-ROLE-3 (step 5 write path): the `plugins` key in the organization +// file, written only through the host setter and logged like a limit change. +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { businessDoc, rolesCopy, tmp as makeRoot, writeJson } from "./helpers.mjs"; +import { validateBusinessDocument } from "../src/business.mjs"; +import { pluginSettings } from "../src/plugin-settings.mjs"; +import { OrganizationError } from "../src/organization.mjs"; +import { BusinessError } from "../src/errors.mjs"; + +const roots = []; +after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); + +function fixture() { + const root = makeRoot(); + roots.push(root); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, "acme"); + const file = writeJson(join(root, "businesses", "acme.json"), doc); + return { root, rolesDir, doc, file, settings: pluginSettings({ dir: root, rolesDir }) }; +} + +const events = f => existsSync(f.settings.writer.logFile("acme")) + ? readFileSync(f.settings.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse) : []; + +test("REQ-PLUG-1 schema: plugins is optional, names known kinds and checked module names", () => { + const f = fixture(); + const check = d => validateBusinessDocument(d, f.file, { rolesDir: f.rolesDir }); + assert.deepEqual(check(f.doc).plugins, {}); + const on = { ...structuredClone(f.doc), plugins: { "usage-source": ["meter"], "limit-check": [] } }; + const business = check(on); + assert.deepEqual(business.plugins, on.plugins); + assert.ok(Object.isFrozen(business.plugins)); + assert.ok(!Object.isFrozen(on.plugins), "the document itself stays mutable"); + for (const plugins of [{ "made-up": [] }, { "usage-source": "meter" }, { "usage-source": ["Meter"] }, { "usage-source": ["a", "a"] }, []]) { + assert.throws(() => check({ ...structuredClone(f.doc), plugins }), BusinessError, JSON.stringify(plugins)); + } +}); + +test("REQ-PLUG-1 REQ-ROLE-3 the setter writes plugins through the organization writer and logs old and new names", () => { + const f = fixture(); + const view = f.settings.read("acme"); + assert.deepEqual(view.plugins, {}); + const done = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason"); + assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "usage-source": ["meter"] }); + assert.deepEqual(done.plugins, { "usage-source": ["meter"] }); + assert.notEqual(done.revision, view.revision); + let last = events(f).at(-1); + assert.equal(last.state, "committed"); + assert.equal(last.actor, "jason"); + assert.equal(last.transaction, done.transaction); + assert.deepEqual(last.changes, [{ path: "plugins", old: null, new: { "usage-source": ["meter"] }, oldPresent: false, newPresent: true }]); + f.settings.set("acme", { revision: done.revision, plugins: { "usage-source": ["meter", "other"], "limit-check": ["cap"] } }, "jason"); + last = events(f).at(-1); + assert.deepEqual(last.changes.map(c => [c.path, c.old, c.new]), [ + ["plugins.usage-source", ["meter"], ["meter", "other"]], + ["plugins.limit-check", null, ["cap"]], + ]); + // A null-prototype object is still a plain JSON object. + const bare = Object.assign(Object.create(null), { "limit-check": ["cap"] }); + f.settings.set("acme", { revision: f.settings.read("acme").revision, plugins: bare }, "jason"); + assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "limit-check": ["cap"] }); +}); + +test("REQ-PLUG-1 REQ-ROLE-3 the writer checks the bytes it writes: a Date in plugins is refused there too", () => { + // Without the setter's own check, the organization writer still refuses: + // it validates JSON.parse of the bytes, where the Date is a string. + const f = fixture(); + const { revision } = f.settings.read("acme"); + const bytes = readFileSync(f.file, "utf8"); + assert.throws(() => f.settings.writer.write("acme", { ...structuredClone(f.doc), plugins: new Date(0) }, { revision, actor: "jason" }), + BusinessError); + assert.equal(readFileSync(f.file, "utf8"), bytes); + assert.deepEqual(events(f), []); +}); + +test("REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothing and logs nothing", () => { + const f = fixture(); + const { revision } = f.settings.read("acme"); + const bytes = readFileSync(f.file, "utf8"); + const refusals = [ + [{ revision, plugins: { "made-up": ["x"] } }, "invalid-plugins"], + [{ revision, plugins: { "usage-source": ["Bad"] } }, "invalid-plugins"], + [{ revision, plugins: { "usage-source": "meter" } }, "invalid-plugins"], + [{ revision, plugins: { "limit-check": ["cap", "cap"] } }, "invalid-plugins"], + [{ revision }, "invalid-plugins"], + [{ revision, plugins: [] }, "invalid-plugins"], + // No own keys, so checkPlugins alone passes them; JSON would write something else. + [{ revision, plugins: new Date(0) }, "invalid-plugins"], + [{ revision, plugins: new Map([["usage-source", ["meter"]]]) }, "invalid-plugins"], + [{ revision, plugins: {}, limits: {} }, "invalid-request"], + [{ revision: "nope", plugins: {} }, "invalid-request"], + [{ plugins: {} }, "invalid-request"], + [null, "invalid-request"], + [["x"], "invalid-request"], + ]; + for (const [args, expected] of refusals) { + assert.throws(() => f.settings.set("acme", args, "jason"), + e => e instanceof OrganizationError && e.code === expected, JSON.stringify(args)); + } + assert.equal(readFileSync(f.file, "utf8"), bytes); + assert.deepEqual(events(f), []); +}); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index d98e771ac..d44ce5788 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -34,7 +34,7 @@ export function setup(t, realPin=false){ test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{ const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr); const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555); - for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json'])assert.ok(existsSync(join(copy,f)),f); + for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f); const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755); const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm); git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json')); diff --git a/packages/plugins/README.md b/packages/plugins/README.md new file mode 100644 index 000000000..855030a70 --- /dev/null +++ b/packages/plugins/README.md @@ -0,0 +1,114 @@ +# Plug-ins + +The plug-in layer (PRD REQ-PLUG-1, build step 6b, Gitea #1626). Every part +the core calls out to is a plug-in kind. A module of a kind is a directory +in this package, and the organization file says which modules are switched +on. No core module imports a plug-in module by name; calls go through the +loader. + +| Path | What it is | +|---|---| +| `src/kinds.mjs` | the kind registry and the check for the organization file's `plugins` key | +| `src/loader.mjs` | loads the switched-on modules from this tree | +| `//index.mjs` | one module of a kind | + +## Kinds + +| Kind | Interface | Switch | Exports | +|---|---|---|---| +| `usage-source` | 1 | list | `interfaceVersion`, `readTurn` | +| `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` | + +Every module exports `interfaceVersion`, equal to its kind's interface +version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds +do: a usage source reads what a session used after each turn, and a limit +check answers whether a launch may start (`mayLaunch`) and whether a running +session must stop at the end of its turn (`mustStop`). The arguments and +return values of these functions are set by the rows that call them (row 124 +converts the first usage source). No module of either kind ships yet. + +Adding a kind, or changing a kind's exports, interface version or switch, +is a core change: a reviewed edit to `src/kinds.mjs`, with the code that +calls the kind. + +## Adding a module + +1. Pick a name matching `^[a-z][a-z0-9-]*$`. +2. Create `packages/plugins///index.mjs`. A real directory and + a real file: the loader refuses a symlink anywhere from + `packages/plugins` down, `index.mjs` included. +3. Export `interfaceVersion` and every function the kind lists. +4. Land it like any other change. A running stack sees it only after a + re-pin, because `scripts/stack-copy.sh` copies the whole + `packages/plugins` directory into the pinned copy. +5. Switch it on in the organization file's `plugins` object, under its + kind. The host setter `packages/business/src/plugin-settings.mjs` is the + only writer of that key. + +Adding a module changes no core file. + +## The organization file's `plugins` key + +One key per kind. A list kind takes a list of names; a kind that switches +on one module takes one name. An absent key switches the kind off. + +```json +"plugins": { "usage-source": ["meter"], "limit-check": [] } +``` + +`validateBusinessDocument` checks the shape: known kinds, the name pattern, +no name twice. It doesn't check that a module exists; the loader does that +when it loads. The setter writes through the organization writer, which +logs actor and old and new names in `changes.jsonl` like a limit change. A +refused write throws `OrganizationError` (`invalid-plugins` or +`invalid-request`) and logs nothing. + +## The loader + +```js +import { createLoader } from "../packages/plugins/src/loader.mjs"; + +const loader = createLoader({ log: entry => record(entry) }); +const { loaded, refused } = await loader.load(business.plugins); +loader.modules("limit-check"); // the switched-on modules, in order +loader.loaded(); // { kind: [names actually switched on] } +``` + +The loader finds this package from its own location and takes no path from +config. Until Jason rules on Vikunja #286 it loads modules from this tree +only (lead decision 143). For each name it checks, in order: + +1. the name matches `^[a-z][a-z0-9-]*$` (`bad-name`); +2. `packages/plugins`, ``, `` and `index.mjs` exist, are a + directory or a regular file as expected, and are not symlinks (`missing`, + `symlink`); ancestors of `packages/plugins` are not checked; +3. just before import, `realpath(index.mjs)` is still the expected path + (`symlink`); +4. the module imports (`load-failed`); +5. it has every export, and the kind's functions are functions + (`missing-export`); +6. `interfaceVersion` equals the kind's (`interface-version`). + +At steps 2 and 3, a path that can't be read for another reason, such as a +permission error, refuses as `unreadable`. + +A refusal refuses the whole kind for that load: the kind keeps the modules +it had, and the loader calls `log` with `{event: "plugin-refused", kind, +name, code, message, kept}`. A kind refused on its first load has nothing +switched on. An unknown kind refuses only itself; a `plugins` value that +isn't an object refuses every kind. Loads run one at a time. + +Limits of the checks: + +- A module runs with the host's authority once imported. The checks keep + the loader inside this tree; they don't sandbox the module. +- A module's top-level code runs at import, before the export and + interface-version checks. A module refused for `missing-export` or + `interface-version` has still run, and Node's cache keeps it. +- Someone who can write this tree can swap a file between the realpath + check and the import. The checks catch mistakes and stale links, not a + writer racing the host as the same user. +- Node caches a module by URL, so a changed `index.mjs` is not re-read + until the process restarts. +- The loader is not wired into any host process yet. The rows that call + the first two kinds (124, 125, 126) wire it. diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 000000000..2cbd5f028 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mosaic/plugins", + "version": "0.1.0", + "private": true, + "description": "Plug-in kind registry and in-repo module loader (PRD 1.2 row 131).", + "license": "UNLICENSED", + "type": "module", + "engines": { "node": ">=24" }, + "exports": { "./kinds": "./src/kinds.mjs", "./loader": "./src/loader.mjs" }, + "scripts": { "test": "node --test tests/" } +} diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs new file mode 100644 index 000000000..42b320a51 --- /dev/null +++ b/packages/plugins/src/kinds.mjs @@ -0,0 +1,66 @@ +// The plug-in kind registry (REQ-PLUG-1; the first two kinds are +// REQ-LIMIT-4's). A kind names the exports its modules must provide, the +// interface version they implement, and whether the organization file +// switches on one module or a list. Adding a kind, or changing one, is a +// core change: a reviewed edit to this file. + +export const NAME = /^[a-z][a-z0-9-]*$/; + +export const KINDS = Object.freeze({ + // Reads one turn's usage for the limit checks (row 124 converts the first). + "usage-source": Object.freeze({ + kind: "usage-source", interfaceVersion: 1, exports: Object.freeze(["readTurn"]), switch: "list", + }), + // Decides whether a launch may start and whether a running session must stop. + "limit-check": Object.freeze({ + kind: "limit-check", interfaceVersion: 1, exports: Object.freeze(["mayLaunch", "mustStop"]), switch: "list", + }), +}); + +export class PluginError extends Error { + constructor(code, message, name = null) { + super(message); + this.code = code; + this.name = "PluginError"; + this.plugin = name; + } +} + +const raise = (code, message, name) => { + throw new PluginError(code, message, name); +}; + +// The module names one kind's setting switches on, checked against the +// kind's cardinality and the name pattern. A list may be empty. +export function namesFor(spec, setting, where, fail = raise) { + if (spec.switch === "one") { + if (typeof setting !== "string") fail("bad-setting", `${where} must be one module name`); + } else if (!Array.isArray(setting)) { + fail("bad-setting", `${where} must be a list of module names`); + } + const names = spec.switch === "one" ? [setting] : setting; + const seen = new Set(); + for (const name of names) { + if (typeof name !== "string" || !NAME.test(name)) { + fail("bad-name", `${where} names ${JSON.stringify(name)}, which doesn't match ${NAME}`, + typeof name === "string" ? name : null); + } + if (seen.has(name)) fail("bad-setting", `${where} names ${name} twice`, name); + seen.add(name); + } + return names; +} + +// The organization file's `plugins` object: one key per kind, each a known kind. +export function checkPlugins(value, where, { kinds = KINDS, fail = raise } = {}) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail("bad-setting", `${where} must be an object with one key per kind`); + } + for (const [kind, setting] of Object.entries(value)) { + if (!Object.hasOwn(kinds, kind)) { + fail("unknown-kind", `${where} names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`); + } + namesFor(kinds[kind], setting, `${where}.${kind}`, fail); + } + return value; +} diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs new file mode 100644 index 000000000..cc56f3950 --- /dev/null +++ b/packages/plugins/src/loader.mjs @@ -0,0 +1,144 @@ +// The plug-in loader (REQ-PLUG-1). It switches on the modules the +// organization file's `plugins` object names, one key per kind, and loads +// only packages/plugins///index.mjs from the tree this file runs +// from. It takes no path from config and loads nothing outside that tree +// (lead decision 143). A loaded module runs with the host's authority. +// +// A refusal keeps the kind's last good modules and is logged. A kind refused +// on its first load has nothing switched on. `loaded()` reports what each +// kind actually has switched on. +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { KINDS, PluginError, namesFor } from "./kinds.mjs"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const refuse = (code, message, name) => { + throw new PluginError(code, message, name); +}; + +// Run one filesystem read. Any error becomes a refusal for this path, so load() +// logs it and keeps the kind's old modules (Darkwing N1, Filbert F2, sage-0314). +function fsCall(path, read) { + try { + return read(path); + } catch (e) { + if (e.code === "ENOENT" || e.code === "ENOTDIR") refuse("missing", `${path} is missing`); + refuse("unreadable", `${path} can't be read (${e.code ?? e.message})`); + } +} + +// lstat one path component: it must exist, be the expected type, and not be a symlink. +function component(path, directory) { + const st = fsCall(path, lstatSync); + if (st.isSymbolicLink()) refuse("symlink", `${path} is a symlink`); + if (directory ? !st.isDirectory() : !st.isFile()) { + refuse("missing", `${path} is not a ${directory ? "directory" : "regular file"}`); + } +} + +async function loadModule(spec, name, barrier) { + const kindDir = join(ROOT, spec.kind); + const nameDir = join(kindDir, name); + const index = join(nameDir, "index.mjs"); + component(ROOT, true); + component(kindDir, true); + component(nameDir, true); + component(index, false); + barrier("checked", { kind: spec.kind, name }); + // Last check before import: the resolved file is still the one checked + // above. Ancestors of packages/plugins are not checked (sage-0301). + const expected = join(fsCall(ROOT, realpathSync), spec.kind, name, "index.mjs"); + const real = fsCall(index, realpathSync); + if (real !== expected) refuse("symlink", `${index} resolves to ${real}`); + let module; + try { + module = await import(pathToFileURL(expected).href); + } catch (e) { + refuse("load-failed", `${spec.kind} ${name} failed to load: ${e.message}`); + } + for (const key of ["interfaceVersion", ...spec.exports]) { + if (!Object.hasOwn(module, key)) refuse("missing-export", `${spec.kind} ${name} doesn't export ${key}`); + } + for (const key of spec.exports) { + if (typeof module[key] !== "function") refuse("missing-export", `${spec.kind} ${name} export ${key} isn't a function`); + } + if (module.interfaceVersion !== spec.interfaceVersion) { + refuse("interface-version", + `${spec.kind} ${name} implements interface ${module.interfaceVersion}, not ${spec.interfaceVersion}`); + } + return Object.freeze({ name, module }); +} + +export function createLoader({ log, barrier = () => {} }) { + if (typeof log !== "function") throw new TypeError("createLoader needs a log callback"); + const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])])); + let queue = Promise.resolve(); + + function refused(kind, name, e, report) { + if (!(e instanceof PluginError)) throw e; + // kept: what stays switched on, for the kind or (whole setting refused) for every kind. + const entry = { + event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message, + kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [], + }; + log(entry); + report.push(entry); + } + + async function apply(plugins) { + const report = []; + // A malformed whole setting refuses every kind; an unknown kind refuses only itself. + if (plugins === null || typeof plugins !== "object" || Array.isArray(plugins)) { + refused(null, null, new PluginError("bad-setting", "plugins must be an object with one key per kind"), report); + return { loaded: loaded(), refused: report }; + } + for (const kind of Object.keys(plugins)) { + if (!Object.hasOwn(KINDS, kind)) { + refused(kind, null, new PluginError("unknown-kind", `plugins names unknown kind ${JSON.stringify(kind)}`), report); + } + } + for (const [kind, spec] of Object.entries(KINDS)) { + // An absent key switches the kind off. + let names = []; + try { + if (Object.hasOwn(plugins, kind)) names = namesFor(spec, plugins[kind], `plugins.${kind}`, refuse); + } catch (e) { + refused(kind, typeof plugins[kind] === "string" ? plugins[kind] : null, e, report); + continue; + } + const next = []; + let ok = true; + for (const name of names) { + try { + next.push(await loadModule(spec, name, barrier)); + } catch (e) { + refused(kind, name, e, report); + ok = false; + break; + } + } + if (ok) current.set(kind, Object.freeze(next)); + } + return { loaded: loaded(), refused: report }; + } + + // Loads run one at a time, so a kind's switch never interleaves. + function load(plugins) { + const run = queue.then(() => apply(plugins)); + queue = run.catch(() => {}); + return run; + } + + function loaded() { + return Object.fromEntries([...current].map(([kind, modules]) => [kind, modules.map(m => m.name)])); + } + + function modules(kind) { + if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`); + return current.get(kind).map(m => m.module); + } + + return { load, loaded, modules }; +} diff --git a/packages/plugins/tests/helpers.mjs b/packages/plugins/tests/helpers.mjs new file mode 100644 index 000000000..e74e875ee --- /dev/null +++ b/packages/plugins/tests/helpers.mjs @@ -0,0 +1,38 @@ +// A scratch tree per test: a copy of this package's src/ under +// /packages/plugins, plus fixture modules. The loader finds modules +// from its own location, so each tree's copy loads only that tree's modules. +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..", "src"); + +export const USAGE = name => `export const interfaceVersion = 1; +export function readTurn() { return ${JSON.stringify(name)}; } +`; +export const LIMIT = name => `export const interfaceVersion = 1; +export function mayLaunch() { return ${JSON.stringify(name)}; } +export function mustStop() { return false; } +`; + +export function tree(t) { + const root = mkdtempSync(join(tmpdir(), "mosaic-plugins-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const pkg = join(root, "packages", "plugins"); + cpSync(SRC, join(pkg, "src"), { recursive: true }); + const write = (kind, name, body) => { + const dir = join(pkg, kind, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "index.mjs"), body); + return dir; + }; + const loaderUrl = pathToFileURL(join(pkg, "src", "loader.mjs")).href; + async function loader(options = {}) { + const { createLoader } = await import(loaderUrl); + const logged = []; + const instance = createLoader({ log: entry => logged.push(entry), ...options }); + return { ...instance, logged }; + } + return { root, pkg, write, loader, loaderUrl }; +} diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs new file mode 100644 index 000000000..15b641245 --- /dev/null +++ b/packages/plugins/tests/kinds.test.mjs @@ -0,0 +1,47 @@ +// REQ-PLUG-1: the kind registry and the organization file's `plugins` shape (row 131). +import test from "node:test"; +import assert from "node:assert/strict"; +import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs"; + +const code = expected => e => e instanceof PluginError && e.code === expected; + +test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list", () => { + assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check"]); + assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] }, + { kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" }); + assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] }, + { kind: "limit-check", interfaceVersion: 1, exports: ["mayLaunch", "mustStop"], switch: "list" }); + assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports)); + assert.equal(String(NAME), "/^[a-z][a-z0-9-]*$/"); +}); + +test("REQ-PLUG-1 checkPlugins accepts one key per kind, each a list of names, empty included", () => { + for (const value of [{}, { "usage-source": [] }, { "usage-source": ["a", "b-2"], "limit-check": ["cap"] }]) { + assert.equal(checkPlugins(value, "plugins"), value); + } +}); + +test("REQ-PLUG-1 checkPlugins refuses bad shapes, unknown kinds, bad names and duplicates", () => { + for (const value of [null, [], "x", 3]) assert.throws(() => checkPlugins(value, "plugins"), code("bad-setting")); + assert.throws(() => checkPlugins({ "made-up": [] }, "plugins"), code("unknown-kind")); + assert.throws(() => checkPlugins({ toString: [] }, "plugins"), code("unknown-kind")); + assert.throws(() => checkPlugins({ "usage-source": "meter" }, "plugins"), code("bad-setting")); + for (const name of ["Meter", "1m", "m_x", "../x", "a/b", ".x", "", 7, null]) { + assert.throws(() => checkPlugins({ "usage-source": [name] }, "plugins"), code("bad-name"), String(name)); + } + assert.throws(() => checkPlugins({ "limit-check": ["cap", "cap"] }, "plugins"), code("bad-setting")); +}); + +test("REQ-PLUG-1 a kind that switches on one module takes one name, not a list", () => { + const kinds = { single: { kind: "single", interfaceVersion: 1, exports: ["run"], switch: "one" } }; + assert.ok(checkPlugins({ single: "only" }, "plugins", { kinds })); + assert.throws(() => checkPlugins({ single: ["only"] }, "plugins", { kinds }), code("bad-setting")); + assert.throws(() => checkPlugins({ single: "Only" }, "plugins", { kinds }), code("bad-name")); +}); + +test("REQ-PLUG-1 checkPlugins reports through the caller's fail callback", () => { + const seen = []; + const fail = (c, message) => { seen.push(c); throw new Error(message); }; + assert.throws(() => checkPlugins({ "made-up": [] }, "x plugins", { fail }), /x plugins names unknown kind/); + assert.deepEqual(seen, ["unknown-kind"]); +}); diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs new file mode 100644 index 000000000..3eb9e25f7 --- /dev/null +++ b/packages/plugins/tests/loader.test.mjs @@ -0,0 +1,265 @@ +// REQ-PLUG-1: the loader switches on in-repo modules only, and +// every refusal keeps the kind's old modules and is logged (row 131). +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LIMIT, USAGE, tree } from "./helpers.mjs"; +import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../business/tests/helpers.mjs"; +import { validateBusinessDocument } from "../../business/src/business.mjs"; + +const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] }; + +// A tree with one good module of each kind, already loaded. +async function loadedTree(t, options) { + const s = tree(t); + s.write("usage-source", "meter", USAGE("meter")); + s.write("limit-check", "cap", LIMIT("cap")); + const loader = await s.loader(options); + const first = await loader.load(GOOD); + assert.deepEqual(first.refused, []); + assert.deepEqual(loader.loaded(), GOOD); + return { ...s, loader }; +} + +// Switch usage-source to `names`: the load must refuse with `code`, keep +// ["meter"], log one entry naming the kind and `name`, and leave limit-check on. +async function refusesKeepingOld(s, names, code, name, message) { + const before = s.loader.logged.length; + const result = await s.loader.load({ "usage-source": names, "limit-check": ["cap"] }); + assert.deepEqual(s.loader.loaded(), GOOD, `${name}: old setting kept`); + assert.equal(result.refused.length, 1, name); + const [entry] = result.refused; + assert.equal(entry.code, code, `${name}: ${entry.message}`); + assert.equal(entry.kind, "usage-source"); + assert.equal(entry.name, name); + assert.deepEqual(entry.kept, ["meter"]); + if (message) assert.match(entry.message, message); + assert.deepEqual(s.loader.logged.slice(before), [entry], `${name}: logged once`); + assert.equal(s.loader.modules("usage-source")[0].readTurn(), "meter"); +} + +test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization file, no core file changed", async t => { + const s = tree(t); + s.write("usage-source", "fixture-usage", USAGE("fixture-usage")); + s.write("limit-check", "fixture-limit", LIMIT("fixture-limit")); + const root = businessRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const doc = businessDoc(root, "acme"); + doc.plugins = { "usage-source": ["fixture-usage"], "limit-check": ["fixture-limit"] }; + const file = writeJson(join(root, "businesses", "acme.json"), doc); + const business = validateBusinessDocument(doc, file, { rolesDir: rolesCopy(root) }); + const loader = await s.loader(); + const result = await loader.load(business.plugins); + assert.deepEqual(result.refused, []); + assert.deepEqual(result.loaded, doc.plugins); + assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage"); + assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit"); + assert.equal(loader.modules("limit-check")[0].mustStop(), false); + assert.deepEqual(loader.logged, []); +}); + +test("REQ-PLUG-1 a name not matching ^[a-z][a-z0-9-]*$ refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + for (const name of ["Meter", "1meter", "me_ter", "../cap", "meter/index", ".hidden", ""]) { + await refusesKeepingOld(s, ["meter", name], "bad-name", name); + } +}); + +test("REQ-PLUG-1 a missing module directory or index.mjs refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + await refusesKeepingOld(s, ["meter", "absent"], "missing", "absent", /absent is missing/); + mkdirSync(join(s.pkg, "usage-source", "empty")); + await refusesKeepingOld(s, ["empty"], "missing", "empty", /index\.mjs is missing/); + mkdirSync(join(s.pkg, "usage-source", "dir-index", "index.mjs"), { recursive: true }); + await refusesKeepingOld(s, ["dir-index"], "missing", "dir-index", /not a regular file/); +}); + +test("REQ-PLUG-1 a symlinked module directory or index.mjs refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + const meter = join(s.pkg, "usage-source", "meter"); + symlinkSync(meter, join(s.pkg, "usage-source", "linked")); + await refusesKeepingOld(s, ["linked"], "symlink", "linked", /usage-source\/linked is a symlink/); + s.write("usage-source", "link-index", USAGE("x")); + unlinkSync(join(s.pkg, "usage-source", "link-index", "index.mjs")); + symlinkSync(join(meter, "index.mjs"), join(s.pkg, "usage-source", "link-index", "index.mjs")); + await refusesKeepingOld(s, ["link-index"], "symlink", "link-index", /link-index\/index\.mjs is a symlink/); +}); + +test("REQ-PLUG-1 a symlinked kind directory refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + const real = join(s.pkg, "usage-real"); + renameSync(join(s.pkg, "usage-source"), real); + symlinkSync(real, join(s.pkg, "usage-source")); + await refusesKeepingOld(s, ["meter"], "symlink", "meter", /plugins\/usage-source is a symlink/); +}); + +test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symlinks so the path keeps it)", async t => { + const s = tree(t); + s.write("usage-source", "meter", USAGE("meter")); + const other = mkdtempSync(join(tmpdir(), "mosaic-plugins-link-")); + t.after(() => rmSync(other, { recursive: true, force: true })); + mkdirSync(join(other, "packages")); + symlinkSync(s.pkg, join(other, "packages", "plugins")); + const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href; + const script = `const { createLoader } = await import(${JSON.stringify(url)}); +const logged = []; const loader = createLoader({ log: e => logged.push(e) }); +const r = await loader.load({ "usage-source": ["meter"] }); +console.log(JSON.stringify({ loaded: loader.loaded(), logged }));`; + const run = spawnSync(process.execPath, ["--preserve-symlinks", "--input-type=module", "-e", script], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + const out = JSON.parse(run.stdout); + assert.deepEqual(out.loaded["usage-source"], []); + assert.equal(out.logged[0].code, "symlink"); + assert.match(out.logged[0].message, /packages\/plugins is a symlink/); +}); + +test("REQ-PLUG-1 index.mjs swapped for a symlink after the lstat checks refuses at the realpath check", async t => { + let swap = false; + const s = await loadedTree(t, { + barrier: (point, { name }) => { + if (!swap || point !== "checked" || name !== "swapped") return; + const index = join(s.pkg, "usage-source", "swapped", "index.mjs"); + unlinkSync(index); + symlinkSync(join(s.pkg, "usage-source", "meter", "index.mjs"), index); + }, + }); + s.write("usage-source", "swapped", USAGE("swapped")); + swap = true; + await refusesKeepingOld(s, ["swapped"], "symlink", "swapped", /swapped\/index\.mjs resolves to .*meter\/index\.mjs/); +}); + +// Root reads through any mode, so these run only as a normal user. +const asRoot = process.getuid?.() === 0; + +test("REQ-PLUG-1 a path that can't be read refuses as unreadable, is logged and keeps the old setting", { skip: asRoot }, async t => { + const s = await loadedTree(t); + const locked = s.write("usage-source", "locked", USAGE("locked")); + chmodSync(locked, 0o000); + try { + await refusesKeepingOld(s, ["locked"], "unreadable", "locked", /locked\/index\.mjs can't be read \(EACCES\)/); + } finally { + chmodSync(locked, 0o755); + } +}); + +test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses at either realpath", { skip: asRoot }, async t => { + // `late` locks its own directory (realpath of index.mjs); `later` locks + // /packages (realpath of packages/plugins). Each is restored at once. + let lock = null; + const s = await loadedTree(t, { + barrier: (point, { name }) => { + if (point !== "checked" || (name !== "late" && name !== "later")) return; + lock = name === "late" ? join(s.pkg, "usage-source", "late") : join(s.root, "packages"); + chmodSync(lock, 0o000); + }, + }); + s.write("usage-source", "late", USAGE("late")); + s.write("usage-source", "later", USAGE("later")); + try { + await refusesKeepingOld(s, ["late"], "unreadable", "late", /late\/index\.mjs can't be read \(EACCES\)/); + chmodSync(lock, 0o755); + // A locked /packages would refuse limit-check too, so this load names usage-source only. + const before = s.loader.logged.length; + const result = await s.loader.load({ "usage-source": ["later"] }); + assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["usage-source", "later", "unreadable"]]); + assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/); + assert.deepEqual(result.refused[0].kept, ["meter"]); + assert.deepEqual(s.loader.logged.slice(before), result.refused); + assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [] }); + } finally { + if (lock) chmodSync(lock, 0o755); + } +}); + +test("REQ-PLUG-1 a module that fails to load refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + s.write("usage-source", "syntax", "export const interfaceVersion = 1;\nexport function readTurn( {\n"); + await refusesKeepingOld(s, ["syntax"], "load-failed", "syntax"); + s.write("usage-source", "throws", "throw new Error('boom');\n"); + await refusesKeepingOld(s, ["throws"], "load-failed", "throws", /boom/); +}); + +test("REQ-PLUG-1 a missing export refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + s.write("usage-source", "no-read", "export const interfaceVersion = 1;\n"); + await refusesKeepingOld(s, ["no-read"], "missing-export", "no-read", /doesn't export readTurn/); + s.write("usage-source", "no-version", "export function readTurn() {}\n"); + await refusesKeepingOld(s, ["no-version"], "missing-export", "no-version", /doesn't export interfaceVersion/); + s.write("usage-source", "not-fn", "export const interfaceVersion = 1;\nexport const readTurn = 1;\n"); + await refusesKeepingOld(s, ["not-fn"], "missing-export", "not-fn", /readTurn isn't a function/); + // limit-check needs both of its exports. + s.write("limit-check", "half", "export const interfaceVersion = 1;\nexport function mayLaunch() {}\n"); + const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": ["cap", "half"] }); + assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", "half", "missing-export"]]); + assert.deepEqual(s.loader.loaded(), GOOD); +}); + +test("REQ-PLUG-1 a different interface version refuses and keeps the old setting", async t => { + const s = await loadedTree(t); + s.write("usage-source", "v2", "export const interfaceVersion = 2;\nexport function readTurn() {}\n"); + await refusesKeepingOld(s, ["v2"], "interface-version", "v2", /implements interface 2, not 1/); + s.write("usage-source", "v-string", "export const interfaceVersion = '1';\nexport function readTurn() {}\n"); + await refusesKeepingOld(s, ["v-string"], "interface-version", "v-string"); +}); + +test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and the log names kind and name", async t => { + const s = tree(t); + s.write("usage-source", "meter", USAGE("meter")); + s.write("limit-check", "cap", LIMIT("cap")); + const loader = await s.loader(); + // meter loads, absent refuses: the whole kind stays off, not half on. + const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"] }); + assert.deepEqual(loader.loaded(), result.loaded); + assert.deepEqual(loader.modules("usage-source"), []); + assert.equal(loader.logged.length, 1); + assert.equal(loader.logged[0].event, "plugin-refused"); + assert.equal(loader.logged[0].kind, "usage-source"); + assert.equal(loader.logged[0].name, "absent"); + assert.deepEqual(loader.logged[0].kept, []); +}); + +test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole setting keeps everything", async t => { + const s = await loadedTree(t); + let result = await s.loader.load({ ...GOOD, "made-up": ["x"] }); + assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [["made-up", "unknown-kind"]]); + assert.deepEqual(s.loader.loaded(), GOOD); + for (const kind of ["made-up", "toString"]) { + assert.throws(() => s.loader.modules(kind), e => e.code === "unknown-kind", kind); + } + for (const bad of [null, [], "meter"]) { + result = await s.loader.load(bad); + assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [[null, "bad-setting"]]); + assert.deepEqual(result.refused[0].kept, GOOD); + assert.deepEqual(s.loader.loaded(), GOOD); + } + await refusesKeepingOld(s, "meter", "bad-setting", "meter"); + await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/); + // An absent key switches the kind off. + result = await s.loader.load({ "limit-check": ["cap"] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"] }); +}); + +test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => { + const s = await loadedTree(t); + let release; + globalThis.__row131Gate = new Promise(resolve => { release = resolve; }); + t.after(() => { delete globalThis.__row131Gate; }); + s.write("usage-source", "slow", "await globalThis.__row131Gate;\nexport const interfaceVersion = 1;\nexport function readTurn() {}\n"); + s.write("usage-source", "fast", USAGE("fast")); + const first = s.loader.load({ "usage-source": ["slow"] }); + const second = s.loader.load({ "usage-source": ["fast"] }); + await new Promise(resolve => setTimeout(resolve, 100)); + release(); + await Promise.all([first, second]); + assert.deepEqual(s.loader.loaded()["usage-source"], ["fast"]); +}); + +test("REQ-PLUG-1 the loader needs a log callback", async t => { + const s = tree(t); + const { createLoader } = await import(s.loaderUrl); + assert.throws(() => createLoader({}), /log callback/); +}); diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index f384a983d..769f91c5f 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -28,6 +28,8 @@ COPY_PATHS=( packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json packages/webui/src packages/webui/README.md packages/cli/README.md packages/queue/src + # Whole directory: every in-repo plug-in module ships with the copy (row 131). + packages/plugins ) copy_commit() { [ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy from the canonical checkout"