Row 145 BUILD.md records both review rounds, the gate on 9357baac9 (GATE_PASSED, 24 steps) and the landed commits 92938ab43..fba879f6a. q167/ holds the issue receipts for rows 135-143 and 146-147. Co-Authored-By: Claude Opus 5.5 <[email protected]>
381 lines
23 KiB
Diff
381 lines
23 KiB
Diff
commit 90443879dff42a72d777124e42fe205c30d5afd7
|
|
Author: Jason Woltje <[email protected]>
|
|
Date: Thu Oct 15 17:32:08 2026 -0500
|
|
|
|
plugins: mosaic plugins and the plugins verbs switch a module through the human-only path (vikunja:19/305, row 145)
|
|
|
|
Jason-approved merge, rebased: git diff 813671596..f4d7b91a9 from
|
|
work/vikunja-19-305, stacked on 295. Business reviewer verdict 57d7f0b7
|
|
approve on f4d7b91a9; Jason resolved merge decision 0eebf83f via the CLI.
|
|
|
|
Conflict resolution only. packages/bus/src/broker.mjs conflicted with
|
|
row 123 (89fd2d6fe): both sides extend the limits branch of request().
|
|
Kept row 123's organization.set role path and ORGANIZATION_KEYS, and
|
|
added 305's plugins and plugins.set to the verb list and its keys lines.
|
|
plugins and plugins.set take the human-only path as in 305: role is true
|
|
only for organization.set, the actor is the human, and the verb reaches
|
|
the organization writer unchanged. organization.set is unchanged.
|
|
packages/plugins/tests/searxng.test.mjs conflicted with 307: both add a
|
|
test at the end of the file. Kept 307's AbortSignal test, then 305's
|
|
hasModule test. Three other paths that refactor also changed merged
|
|
without conflict, with the source commit's +/- lines; the other six
|
|
paths are the source blobs. Filbert reviews the rebased diff.
|
|
|
|
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
|
---
|
|
packages/bus/src/broker.mjs | 6 ++-
|
|
packages/bus/src/runtime.mjs | 8 ++-
|
|
packages/bus/tests/plugins-verb.test.mjs | 66 ++++++++++++++++++++++++
|
|
packages/business/src/plugin-settings.mjs | 11 +++-
|
|
packages/business/tests/plugin-settings.test.mjs | 12 +++++
|
|
packages/cli/src/cli.mjs | 3 ++
|
|
packages/cli/src/plugins.mjs | 41 +++++++++++++++
|
|
packages/cli/tests/limits.test.mjs | 10 ++++
|
|
packages/plugins/README.md | 9 +++-
|
|
packages/plugins/src/loader.mjs | 16 +++++-
|
|
packages/plugins/tests/searxng.test.mjs | 6 +++
|
|
11 files changed, 180 insertions(+), 8 deletions(-)
|
|
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index 274a19b44..703fb6373 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -855,14 +855,15 @@ export class Broker {
|
|
const args = request.args ?? {};
|
|
if (!object(args)) fail('invalid-request');
|
|
this.#secretCheck(args);
|
|
- if (["limits", "limits.set", "organization.set"].includes(request.verb)) {
|
|
+ if (["limits", "limits.set", "organization.set", "plugins", "plugins.set"].includes(request.verb)) {
|
|
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
|
// file lists organization.change. It takes the same keys, runs the same write path and
|
|
// logs the same receipt; only the actor differs: the role definition and instance.
|
|
const role = request.verb === "organization.set";
|
|
if (!role && !s.human) fail("human-required");
|
|
if (!this.#organization) fail("not-configured");
|
|
- if (request.verb === "limits") keys(args, []);
|
|
+ if (["limits", "plugins"].includes(request.verb)) keys(args, []);
|
|
+ else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
|
|
else keys(args, ORGANIZATION_KEYS, ["revision"]);
|
|
let actor = s.human;
|
|
// This branch runs outside the store transaction below, so the grant is checked before the writer
|
|
@@ -878,6 +879,7 @@ export class Broker {
|
|
} catch (e) {
|
|
if (e.code === "revision-conflict") fail("revision-conflict");
|
|
if (e.code?.startsWith("organization-")) fail(e.code);
|
|
+ if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
|
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
|
fail("invalid-request");
|
|
}
|
|
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
|
index 1185456f4..260bf4321 100644
|
|
--- a/packages/bus/src/runtime.mjs
|
|
+++ b/packages/bus/src/runtime.mjs
|
|
@@ -6,7 +6,8 @@ import { Credentials } from './credentials.mjs';
|
|
import { serve } from './server.mjs';
|
|
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
|
import { readSessions } from '../../seat/src/session.mjs';
|
|
-import { createLoader } from '../../plugins/src/loader.mjs';
|
|
+import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
|
+import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
|
import { webService } from './web.mjs';
|
|
import { verifyHuman } from './human.mjs';
|
|
import { verifyTester } from './tester.mjs';
|
|
@@ -58,11 +59,14 @@ export async function startBroker({
|
|
];
|
|
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
|
|
store = new Store(dataRoot);
|
|
+ // vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
|
+ // plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
|
+ const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
|
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null;
|
|
const broker = new Broker({
|
|
store,
|
|
businesses,
|
|
- organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):settings.set(id,args,actor):null,
|
|
+ organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):verb==='limits.set'?settings.set(id,args,actor):verb==='plugins'?plugins.read(id):plugins.set(id,args,actor):null,
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
credentialStatus: () => credentials.status(),
|
|
});
|
|
diff --git a/packages/bus/tests/plugins-verb.test.mjs b/packages/bus/tests/plugins-verb.test.mjs
|
|
new file mode 100644
|
|
index 000000000..aeec1663d
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/plugins-verb.test.mjs
|
|
@@ -0,0 +1,66 @@
|
|
+// vikunja:19/305 (REQ-PLUG-1): the plug-in switch is a human-only broker action, logged with its
|
|
+// actor, and a module that doesn't exist is refused. Same path as the limits verbs.
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
+import { tmpdir } from 'node:os';
|
|
+import { join } from 'node:path';
|
|
+import { Store } from '../src/store.mjs';
|
|
+import { Broker } from '../src/broker.mjs';
|
|
+
|
|
+const biz = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: [], crossRole: [] } }, cto: { authority: { withinRole: [], crossRole: [] } } } } };
|
|
+
|
|
+test('plugins and plugins.set refuse every non-human session and a forged actor, and pass only the patch with the verified actor', (t) => {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'plugins-verb-'));
|
|
+ const store = new Store(root);
|
|
+ t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
+ const calls = [];
|
|
+ const b = new Broker({ store, businesses: biz, organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) });
|
|
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
+ const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
|
+ const reader = b.bindReader({ business: 'demo' });
|
|
+ const patch = { revision: 'a'.repeat(64), plugins: { 'search-source': 'searxng-url' } };
|
|
+ for (const cap of [role, reader]) for (const verb of ['plugins', 'plugins.set']) assert.throws(() => b.request(cap, { verb, args: verb === 'plugins' ? {} : patch }), /human-required/);
|
|
+ assert.equal(calls.length, 0);
|
|
+ assert.throws(() => b.request(human, { verb: 'plugins.set', args: { ...patch, actor: 'forged' } }), /invalid-request/);
|
|
+ assert.throws(() => b.request(human, { verb: 'plugins.set', args: { revision: patch.revision } }), /invalid-request/);
|
|
+ assert.throws(() => b.request(human, { verb: 'plugins', args: { x: 1 } }), /invalid-request/);
|
|
+ b.request(human, { verb: 'plugins' });
|
|
+ b.request(human, { verb: 'plugins.set', args: patch });
|
|
+ assert.deepEqual(calls, [['demo', 'plugins', {}, 'jason'], ['demo', 'plugins.set', patch, 'jason']]);
|
|
+});
|
|
+
|
|
+test('runtime: the switch changes the organization file, the receipt carries the actor, and a bad shape, an unknown module and a stale revision are refused with nothing written', async (t) => {
|
|
+ const { startBroker } = await import('../src/runtime.mjs');
|
|
+ const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
|
+ const root = mkdtempSync(join(tmpdir(), 'plugins-runtime-'));
|
|
+ const rolesDir = rolesCopy(root);
|
|
+ writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
|
+ mkdirSync(join(root, 'data'), { mode: 0o700 });
|
|
+ const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
|
+ t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
|
+ const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
+ const file = join(root, 'businesses/demo.json');
|
|
+ const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
|
+ const view = bus.broker.request(human, { verb: 'plugins' });
|
|
+ assert.deepEqual(view.plugins, {});
|
|
+ const done = bus.broker.request(human, { verb: 'plugins.set', args: { revision: view.revision, plugins: { 'search-source': 'searxng-url' } } });
|
|
+ assert.deepEqual(done.plugins, { 'search-source': 'searxng-url' });
|
|
+ assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).plugins, { 'search-source': 'searxng-url' });
|
|
+ const last = log().at(-1);
|
|
+ assert.deepEqual([last.actor, last.state], ['jason', 'committed']);
|
|
+ assert.deepEqual(last.changes, [{ path: 'plugins', old: null, new: { 'search-source': 'searxng-url' }, oldPresent: false, newPresent: true }]);
|
|
+ const before = readFileSync(file, 'utf8');
|
|
+ const entries = log().length;
|
|
+ const code = (args) => { try { bus.broker.request(human, { verb: 'plugins.set', args }); } catch (e) { return e.code; } return null; };
|
|
+ assert.equal(code({ revision: done.revision, plugins: { 'search-source': 'no-such-module' } }), 'unknown-plugin');
|
|
+ assert.equal(code({ revision: done.revision, plugins: { 'search-source': ['searxng-url'] } }), 'invalid-plugins');
|
|
+ assert.equal(code({ revision: done.revision, plugins: { 'made-up': [] } }), 'invalid-plugins');
|
|
+ assert.equal(code({ revision: view.revision, plugins: { 'search-source': 'harness-builtin' } }), 'revision-conflict');
|
|
+ assert.equal(readFileSync(file, 'utf8'), before);
|
|
+ assert.equal(log().length, entries, 'a refused write logs nothing');
|
|
+ // A real module switches, and the old value is in the receipt.
|
|
+ const next = bus.broker.request(human, { verb: 'plugins.set', args: { revision: done.revision, plugins: { 'search-source': 'harness-builtin' } } });
|
|
+ assert.deepEqual(log().at(-1).changes.map((c) => [c.path, c.old, c.new]), [['plugins.search-source', 'searxng-url', 'harness-builtin']]);
|
|
+ assert.equal(next.plugins['search-source'], 'harness-builtin');
|
|
+});
|
|
diff --git a/packages/business/src/plugin-settings.mjs b/packages/business/src/plugin-settings.mjs
|
|
index 9a576bdd7..6a0264419 100644
|
|
--- a/packages/business/src/plugin-settings.mjs
|
|
+++ b/packages/business/src/plugin-settings.mjs
|
|
@@ -7,7 +7,9 @@ import { organizationWriter, OrganizationError } from "./organization.mjs";
|
|
import { isPlainObject } from "./util.mjs";
|
|
import { PluginError, checkPlugins } from "../../plugins/src/kinds.mjs";
|
|
|
|
-export function pluginSettings({ dir, rolesDir }) {
|
|
+// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
|
|
+// loader's hasModule). Without it the setter checks only the shape, as before.
|
|
+export function pluginSettings({ dir, rolesDir, exists = null }) {
|
|
const writer = organizationWriter({ dir, rolesDir });
|
|
|
|
function read(id) {
|
|
@@ -35,6 +37,13 @@ export function pluginSettings({ dir, rolesDir }) {
|
|
if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message);
|
|
throw e;
|
|
}
|
|
+ if (exists) {
|
|
+ for (const [kind, setting] of Object.entries(args.plugins)) {
|
|
+ for (const name of Array.isArray(setting) ? setting : [setting]) {
|
|
+ if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
const next = structuredClone(writer.read(id).document);
|
|
next.plugins = structuredClone(args.plugins);
|
|
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
|
diff --git a/packages/business/tests/plugin-settings.test.mjs b/packages/business/tests/plugin-settings.test.mjs
|
|
index 437eb5e31..756c7957e 100644
|
|
--- a/packages/business/tests/plugin-settings.test.mjs
|
|
+++ b/packages/business/tests/plugin-settings.test.mjs
|
|
@@ -103,3 +103,15 @@ test("REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothin
|
|
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
|
assert.deepEqual(events(f), []);
|
|
});
|
|
+
|
|
+test("vikunja:19/305 with an exists check, the setter refuses a module no tree file answers to, before anything is written", () => {
|
|
+ const f = fixture();
|
|
+ const settings = pluginSettings({ dir: f.root, rolesDir: f.rolesDir, exists: (kind, name) => kind === "usage-source" && name === "meter" });
|
|
+ const view = settings.read("acme");
|
|
+ const before = events(f).length;
|
|
+ for (const plugins of [{ "usage-source": ["ghost"] }, { "limit-check": ["cap"] }, { "usage-source": ["meter", "ghost"] }]) {
|
|
+ assert.throws(() => settings.set("acme", { revision: view.revision, plugins }, "jason"), e => e instanceof OrganizationError && e.code === "unknown-plugin", JSON.stringify(plugins));
|
|
+ }
|
|
+ assert.equal(events(f).length, before);
|
|
+ assert.deepEqual(settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason").plugins, { "usage-source": ["meter"] });
|
|
+});
|
|
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
|
index fbca789a9..60a0ba29b 100644
|
|
--- a/packages/cli/src/cli.mjs
|
|
+++ b/packages/cli/src/cli.mjs
|
|
@@ -14,6 +14,7 @@ import { createInterface } from "node:readline/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { BINDING_NAME } from "../../discord/src/binding.mjs";
|
|
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
|
+import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
|
import { CliError } from "./errors.mjs";
|
|
import { bootConfig, loadSystem, socketPath } from "./config.mjs";
|
|
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
|
|
@@ -28,6 +29,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
|
|
|
|
export const USAGE = `usage:
|
|
${LIMITS_USAGE}
|
|
+ ${PLUGINS_USAGE}
|
|
mosaic inbox [--business <id>] [--json] [--tester]
|
|
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
|
mosaic tasks [--business <id>] [--json] [--tester]
|
|
@@ -396,6 +398,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
|
const [verb, ...rest] = argv;
|
|
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
|
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
|
+ if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
if (verb === "talk") return talk(rest, io, deps);
|
|
if (verb === "follow") return talk(rest, io, deps, true);
|
|
if (verb === "stop") return stop(rest, io, deps);
|
|
diff --git a/packages/cli/src/plugins.mjs b/packages/cli/src/plugins.mjs
|
|
new file mode 100644
|
|
index 000000000..0afb3f522
|
|
--- /dev/null
|
|
+++ b/packages/cli/src/plugins.mjs
|
|
@@ -0,0 +1,41 @@
|
|
+// `mosaic plugins` (vikunja:19/305, REQ-PLUG-1): read and switch the organization file's plug-in
|
|
+// modules through the broker's human-only plugins and plugins.set verbs. The broker refuses an
|
|
+// agent, a reader and a tester, a stale revision, a bad shape and a module that doesn't exist, and
|
|
+// the change is logged with its actor in the organization's changes.jsonl.
|
|
+import { readFileSync } from "node:fs";
|
|
+import { CliError } from "./errors.mjs";
|
|
+
|
|
+export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] | set --file PATCH [--business ID]";
|
|
+
|
|
+function parse(args) {
|
|
+ const out = { positional: [], flags: new Set(), values: {} };
|
|
+ for (let i = 0; i < args.length; i++) {
|
|
+ const arg = args[i];
|
|
+ if (arg === "--json") out.flags.add(arg);
|
|
+ else if (["--file", "--business"].includes(arg)) {
|
|
+ if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
|
|
+ out.values[arg] = args[++i];
|
|
+ } else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
|
|
+ else out.positional.push(arg);
|
|
+ }
|
|
+ return out;
|
|
+}
|
|
+
|
|
+// PATCH is { "revision": <from a read>, "plugins": { "<kind>": <name or [names]> } }.
|
|
+export async function pluginsCommand(args, io, callFor) {
|
|
+ const parsed = parse(args);
|
|
+ const verb = parsed.positional[0] ?? "view";
|
|
+ if (parsed.positional.length > 1 || !["view", "set"].includes(verb) || (verb === "set") !== Boolean(parsed.values["--file"])) {
|
|
+ throw new CliError(PLUGINS_USAGE, 4);
|
|
+ }
|
|
+ try {
|
|
+ const call = callFor(parsed);
|
|
+ const result = verb === "set"
|
|
+ ? await call("plugins.set", JSON.parse(readFileSync(parsed.values["--file"], "utf8")))
|
|
+ : await call("plugins", {});
|
|
+ io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
+ } catch (error) {
|
|
+ if (error instanceof CliError) throw error;
|
|
+ throw new CliError(error.message, 2);
|
|
+ }
|
|
+}
|
|
diff --git a/packages/cli/tests/limits.test.mjs b/packages/cli/tests/limits.test.mjs
|
|
index 0122e4ed3..c8658f9c2 100644
|
|
--- a/packages/cli/tests/limits.test.mjs
|
|
+++ b/packages/cli/tests/limits.test.mjs
|
|
@@ -45,3 +45,13 @@ test('post-119 example and Jason fixture migrate with checked inherited models;
|
|
writeJson(map,mapping);await assert.rejects(main(args,io({...f.env,MOSAIC_CONFIG:join(root,'missing.json')}),{}),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);writeJson(f.config,'invalid JSON');await assert.rejects(run(),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);
|
|
writeJson(f.config,configBytes);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(readFileSync(file,'utf8'),jasonBytes);
|
|
});
|
|
+
|
|
+test('plugins view/set use the human bus transport and pass only the explicit patch (vikunja:19/305)',async t=>{
|
|
+ const root=tmp(t),patch={revision:'a'.repeat(64),plugins:{'search-source':'searxng-url'}},file=writeJson(join(root,'plugins.json'),patch),calls=[];
|
|
+ const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'b'.repeat(64),plugins:args.plugins??{}};}};
|
|
+ const x=io();await main(['plugins','--business','acme','--json'],x,deps);await main(['plugins','set','--business','acme','--file',file],io(),deps);
|
|
+ assert.deepEqual(calls,[{business:'acme',verb:'plugins',args:{}},{business:'acme',verb:'plugins.set',args:patch}]);assert.equal(JSON.parse(x.out.text).revision,'b'.repeat(64));
|
|
+ await assert.rejects(main(['plugins','--business','acme'],io({CODEX_THREAD_ID:'fixture-agent'}),deps),/agent/i);
|
|
+ for(const bad of [['plugins','set','--business','acme'],['plugins','view','--file',file],['plugins','nope'],['plugins','--write']])await assert.rejects(main(bad,io(),deps),/mosaic plugins/);
|
|
+ assert.equal(calls.length,2);
|
|
+});
|
|
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
|
|
index fbd215b35..1913004e3 100644
|
|
--- a/packages/plugins/README.md
|
|
+++ b/packages/plugins/README.md
|
|
@@ -45,8 +45,13 @@ calls the kind.
|
|
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.
|
|
+ kind: `mosaic plugins` shows the setting and its revision, and
|
|
+ `mosaic plugins set --file PATCH` (`{ "revision": ..., "plugins": {...} }`)
|
|
+ changes it through the broker's human-only `plugins` and `plugins.set`
|
|
+ verbs. The setter refuses a bad shape, a name with no module in this tree
|
|
+ (`unknown-plugin`) and a stale revision, and logs actor and old and new
|
|
+ names in `changes.jsonl`. A running broker reads the setting at bus start
|
|
+ (REQ-WEB-3's reload will change that), so restart the bus after a switch.
|
|
|
|
Adding a module changes no core file.
|
|
|
|
diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs
|
|
index cc56f3950..5a8aa8b5e 100644
|
|
--- a/packages/plugins/src/loader.mjs
|
|
+++ b/packages/plugins/src/loader.mjs
|
|
@@ -10,7 +10,7 @@
|
|
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";
|
|
+import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
@@ -71,6 +71,20 @@ async function loadModule(spec, name, barrier) {
|
|
return Object.freeze({ name, module });
|
|
}
|
|
|
|
+// True when packages/plugins/<kind>/<name>/index.mjs exists in this tree as a real file, with no
|
|
+// symlink on the way down. The setters use it to refuse a name no module answers to before it is
|
|
+// written; the loader's own checks still run at load.
|
|
+export function hasModule(kind, name) {
|
|
+ try {
|
|
+ if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false;
|
|
+ for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true);
|
|
+ component(join(ROOT, kind, name, "index.mjs"), false);
|
|
+ return true;
|
|
+ } catch {
|
|
+ return false;
|
|
+ }
|
|
+}
|
|
+
|
|
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([])]));
|
|
diff --git a/packages/plugins/tests/searxng.test.mjs b/packages/plugins/tests/searxng.test.mjs
|
|
index 640819a15..17503fe93 100644
|
|
--- a/packages/plugins/tests/searxng.test.mjs
|
|
+++ b/packages/plugins/tests/searxng.test.mjs
|
|
@@ -103,3 +103,9 @@ test("the searxng modules end the request when the broker's AbortSignal fires",
|
|
assert.ok(destroyed >= 1, name);
|
|
}
|
|
});
|
|
+
|
|
+test("hasModule: true for a shipped module, false for a missing one, an unknown kind or a bad name", async () => {
|
|
+ const { hasModule } = await import("../src/loader.mjs");
|
|
+ for (const name of ["harness-builtin", "searxng-bundled", "searxng-url"]) assert.equal(hasModule("search-source", name), true, name);
|
|
+ for (const [kind, name] of [["search-source", "ghost"], ["made-up", "searxng-url"], ["search-source", "../x"], ["search-source", "Searxng"], ["usage-source", "searxng-url"]]) assert.equal(hasModule(kind, name), false, `${kind}/${name}`);
|
|
+});
|