@manybot/manybot 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { runPlugin } from "#kernel/pluginGuard.js";
|
|
4
|
+
describe("kernel/pluginGuard", () => {
|
|
5
|
+
test("runs handler successfully without modifying plugin error status", async () => {
|
|
6
|
+
const plugin = {
|
|
7
|
+
name: "safePlugin",
|
|
8
|
+
status: "active",
|
|
9
|
+
manifest: { name: "safePlugin", version: "1.0.0" },
|
|
10
|
+
};
|
|
11
|
+
let executed = false;
|
|
12
|
+
const handler = async () => {
|
|
13
|
+
executed = true;
|
|
14
|
+
};
|
|
15
|
+
await runPlugin(plugin, {}, handler);
|
|
16
|
+
assert.equal(executed, true);
|
|
17
|
+
assert.equal(plugin.status, "active");
|
|
18
|
+
assert.equal(plugin.errorCount ?? 0, 0);
|
|
19
|
+
});
|
|
20
|
+
test("tracks errorCount and disables plugin after 3 failures", async () => {
|
|
21
|
+
const plugin = {
|
|
22
|
+
name: "failingPlugin",
|
|
23
|
+
status: "active",
|
|
24
|
+
manifest: { name: "failingPlugin", version: "1.0.0" },
|
|
25
|
+
};
|
|
26
|
+
const failingHandler = async () => {
|
|
27
|
+
throw new Error("Crash");
|
|
28
|
+
};
|
|
29
|
+
await runPlugin(plugin, {}, failingHandler);
|
|
30
|
+
assert.equal(plugin.errorCount, 1);
|
|
31
|
+
assert.equal(plugin.status, "active");
|
|
32
|
+
await runPlugin(plugin, {}, failingHandler);
|
|
33
|
+
assert.equal(plugin.errorCount, 2);
|
|
34
|
+
assert.equal(plugin.status, "active");
|
|
35
|
+
await runPlugin(plugin, {}, failingHandler);
|
|
36
|
+
assert.equal(plugin.errorCount, 3);
|
|
37
|
+
assert.equal(plugin.status, "error");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 2. Loading each plugin from ~/.manybot/plugins folder
|
|
7
7
|
* 3. Registering in pluginRegistry with status and public exports
|
|
8
8
|
* 4. Exposing pluginRegistry to kernel and pluginApi
|
|
9
|
-
* 5. Watching plugin files and
|
|
9
|
+
* 5. Watching plugin files, config files and commands.yaml for hot reloading
|
|
10
10
|
*/
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
@@ -15,10 +15,41 @@ import { t } from "#i18n";
|
|
|
15
15
|
import { pathToFileURL } from "url";
|
|
16
16
|
import { PATHS } from "#config";
|
|
17
17
|
import { buildSetupApi, cleanupPluginEvents } from "#manyapi";
|
|
18
|
+
import { initCommandRegistry } from "#kernel/commandRegistry.js";
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the actual handler function out of either shape a plugin may
|
|
21
|
+
* export under `commands[fnName]`. Shared by the registry build step
|
|
22
|
+
* (commandRegistry.ts) and the dispatch step (runCommand.ts) so both
|
|
23
|
+
* agree on what counts as "this function is callable".
|
|
24
|
+
*/
|
|
25
|
+
export function resolvePluginCommandHandler(def) {
|
|
26
|
+
if (typeof def === "function")
|
|
27
|
+
return def;
|
|
28
|
+
if (def && typeof def === "object" && typeof def.handler === "function")
|
|
29
|
+
return def.handler;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
18
32
|
const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
|
|
19
33
|
export const pluginRegistry = new Map();
|
|
20
34
|
let globalContract = null;
|
|
21
35
|
let globalStore = null;
|
|
36
|
+
/**
|
|
37
|
+
* Live WaContract + BotStore the kernel most-recently wired through
|
|
38
|
+
* `setupPlugins()`. Exposed for the integration-test harness only
|
|
39
|
+
* (see `src/plugins/__manybot_integration__/` and the
|
|
40
|
+
* `*.integration.test.ts` suite under the project root): production
|
|
41
|
+
* code paths go through `ctx.wa.contract` / `ctx.store`, not through
|
|
42
|
+
* this module-scoped singleton.
|
|
43
|
+
*
|
|
44
|
+
* Returns `null` when the bot hasn't connected yet (or after a
|
|
45
|
+
* disconnect). The integration suite waits for a non-null value
|
|
46
|
+
* before issuing any real round-trip.
|
|
47
|
+
*/
|
|
48
|
+
export function getGlobalKernelRefs() {
|
|
49
|
+
if (!globalContract || !globalStore)
|
|
50
|
+
return null;
|
|
51
|
+
return { contract: globalContract, store: globalStore };
|
|
52
|
+
}
|
|
22
53
|
const pluginWatchers = new Map();
|
|
23
54
|
// fs.watch's `recursive: true` emulates recursion on Linux by opening one
|
|
24
55
|
// inotify watch per subdirectory — a plugin shipping its own node_modules
|
|
@@ -65,6 +96,7 @@ export async function loadPlugins(activePlugins) {
|
|
|
65
96
|
await loadPlugin(name);
|
|
66
97
|
}
|
|
67
98
|
startConfigWatcher();
|
|
99
|
+
await initCommandRegistry();
|
|
68
100
|
const total = pluginRegistry.size;
|
|
69
101
|
const active = [...pluginRegistry.values()].filter(p => p.status === "active").length;
|
|
70
102
|
const errors = total - active;
|
|
@@ -131,6 +163,7 @@ export async function loadPlugin(name, isReload = false) {
|
|
|
131
163
|
status: "disabled",
|
|
132
164
|
run: null,
|
|
133
165
|
setup: null,
|
|
166
|
+
commands: null,
|
|
134
167
|
exports: null,
|
|
135
168
|
error: null,
|
|
136
169
|
guardOptions: {},
|
|
@@ -146,6 +179,7 @@ export async function loadPlugin(name, isReload = false) {
|
|
|
146
179
|
status: "disabled",
|
|
147
180
|
run: null,
|
|
148
181
|
setup: null,
|
|
182
|
+
commands: null,
|
|
149
183
|
exports: null,
|
|
150
184
|
error: null,
|
|
151
185
|
guardOptions: {},
|
|
@@ -166,12 +200,20 @@ export async function loadPlugin(name, isReload = false) {
|
|
|
166
200
|
status: "active",
|
|
167
201
|
run: mod.default,
|
|
168
202
|
setup: mod.setup ?? null,
|
|
203
|
+
commands: mod.commands ?? null,
|
|
169
204
|
exports: mod.api ?? null,
|
|
170
205
|
error: null,
|
|
171
206
|
guardOptions: mod.guardOptions ?? {},
|
|
172
207
|
errorCount: 0,
|
|
173
208
|
});
|
|
174
|
-
|
|
209
|
+
// Phase 9: a plugin is a library of ready-to-use functions invoked as
|
|
210
|
+
// commands, not something that loads all its logic at boot — a line
|
|
211
|
+
// per plugin no longer earns its place in default startup output.
|
|
212
|
+
// Still available with --debug.
|
|
213
|
+
logger.debug(t(isReload ? "system.pluginReloaded" : "system.pluginLoaded", { name }));
|
|
214
|
+
if (isReload) {
|
|
215
|
+
await initCommandRegistry();
|
|
216
|
+
}
|
|
175
217
|
watchPluginDirectory(name);
|
|
176
218
|
}
|
|
177
219
|
catch (e) {
|
|
@@ -183,11 +225,15 @@ export async function loadPlugin(name, isReload = false) {
|
|
|
183
225
|
status: newErrorCount >= 3 ? "error" : "active",
|
|
184
226
|
run: null,
|
|
185
227
|
setup: null,
|
|
228
|
+
commands: null,
|
|
186
229
|
exports: null,
|
|
187
230
|
error: err,
|
|
188
231
|
guardOptions: {},
|
|
189
232
|
errorCount: newErrorCount,
|
|
190
233
|
});
|
|
234
|
+
if (isReload) {
|
|
235
|
+
await initCommandRegistry();
|
|
236
|
+
}
|
|
191
237
|
}
|
|
192
238
|
}
|
|
193
239
|
/**
|
|
@@ -266,6 +312,7 @@ export async function syncPlugins() {
|
|
|
266
312
|
}
|
|
267
313
|
}
|
|
268
314
|
}
|
|
315
|
+
await initCommandRegistry();
|
|
269
316
|
}
|
|
270
317
|
/**
|
|
271
318
|
* Watch a plugin's directory for changes.
|
|
@@ -305,22 +352,68 @@ export function unwatchPlugin(name) {
|
|
|
305
352
|
}
|
|
306
353
|
}
|
|
307
354
|
/**
|
|
308
|
-
*
|
|
355
|
+
* Reload the command registry from disk (commands.yaml + imports).
|
|
356
|
+
*
|
|
357
|
+
* Mirrors `reloadPlugin()`: callable on its own and safe to invoke from
|
|
358
|
+
* a watcher. Unlike `reloadPlugin()` there's no setup retry / disable
|
|
359
|
+
* state to track — `initCommandRegistry()` either builds a fresh
|
|
360
|
+
* `CommandRegistry` from the current file content or leaves the
|
|
361
|
+
* previous one in place via `commandsConfig.ts`'s own error handling
|
|
362
|
+
* (a malformed YAML logs an error and returns null; `initCommandRegistry`
|
|
363
|
+
* then falls back to the empty defaults).
|
|
364
|
+
*/
|
|
365
|
+
export async function reloadCommandRegistry() {
|
|
366
|
+
try {
|
|
367
|
+
await initCommandRegistry();
|
|
368
|
+
logger.info(`[watcher] Command registry reloaded from commands.yaml.`);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
372
|
+
logger.error(`[watcher] Failed to reload command registry: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Watch the config directory for changes to manyplug.toml / manybot.toml
|
|
377
|
+
* (plugin list sync) or to commands.yaml and its .yaml/.yml imports
|
|
378
|
+
* (command registry reload). Both paths share the same 500ms debounce
|
|
379
|
+
* so a save that emits several inotify events only fires once.
|
|
309
380
|
*/
|
|
310
381
|
function startConfigWatcher() {
|
|
311
382
|
if (configWatcher)
|
|
312
383
|
return;
|
|
384
|
+
const isTomlChange = (filename) => filename === "manyplug.toml" || filename === "manybot.toml";
|
|
385
|
+
const isYamlChange = (filename) => {
|
|
386
|
+
if (!filename)
|
|
387
|
+
return false;
|
|
388
|
+
const lower = filename.toLowerCase();
|
|
389
|
+
return lower.endsWith(".yaml") || lower.endsWith(".yml");
|
|
390
|
+
};
|
|
313
391
|
try {
|
|
314
392
|
let configTimeout = null;
|
|
393
|
+
let yamlTimeout = null;
|
|
394
|
+
const scheduleTomlReload = (filename) => {
|
|
395
|
+
if (!isTomlChange(filename))
|
|
396
|
+
return;
|
|
397
|
+
if (configTimeout)
|
|
398
|
+
clearTimeout(configTimeout);
|
|
399
|
+
configTimeout = setTimeout(async () => {
|
|
400
|
+
logger.info(`[watcher] Config file change detected: ${filename}. Syncing plugins...`);
|
|
401
|
+
await syncPlugins();
|
|
402
|
+
}, 500);
|
|
403
|
+
};
|
|
404
|
+
const scheduleYamlReload = (filename) => {
|
|
405
|
+
if (!isYamlChange(filename))
|
|
406
|
+
return;
|
|
407
|
+
if (yamlTimeout)
|
|
408
|
+
clearTimeout(yamlTimeout);
|
|
409
|
+
yamlTimeout = setTimeout(async () => {
|
|
410
|
+
logger.info(`[watcher] commands.yaml change detected (${filename}). Reloading command registry...`);
|
|
411
|
+
await reloadCommandRegistry();
|
|
412
|
+
}, 500);
|
|
413
|
+
};
|
|
315
414
|
configWatcher = fs.watch(PATHS.HOME, (eventType, filename) => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
clearTimeout(configTimeout);
|
|
319
|
-
configTimeout = setTimeout(async () => {
|
|
320
|
-
logger.info(`[watcher] Config file change detected: ${filename}. Syncing plugins...`);
|
|
321
|
-
await syncPlugins();
|
|
322
|
-
}, 500);
|
|
323
|
-
}
|
|
415
|
+
scheduleTomlReload(filename);
|
|
416
|
+
scheduleYamlReload(filename);
|
|
324
417
|
});
|
|
325
418
|
}
|
|
326
419
|
catch (e) {
|
|
@@ -328,6 +421,14 @@ function startConfigWatcher() {
|
|
|
328
421
|
logger.warn(`[watcher] Failed to start config directory watcher: ${err.message}`);
|
|
329
422
|
}
|
|
330
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Tear down everything `loadPlugins()` started — config watcher, per-plugin
|
|
426
|
+
* directory watchers, and each plugin's exported `cleanup()` handler.
|
|
427
|
+
*
|
|
428
|
+
* Used by the process-shutdown path in main.ts and by tests that need a
|
|
429
|
+
* clean registry between cases. Idempotent: safe to call multiple times
|
|
430
|
+
* and safe to call before any plugin has been loaded.
|
|
431
|
+
*/
|
|
331
432
|
export async function cleanupPlugins() {
|
|
332
433
|
if (configWatcher) {
|
|
333
434
|
configWatcher.close();
|
|
@@ -352,3 +453,60 @@ export async function cleanupPlugins() {
|
|
|
352
453
|
}
|
|
353
454
|
}
|
|
354
455
|
}
|
|
456
|
+
export async function loadIntegrationPlugin() {
|
|
457
|
+
// Integration mode is opt-in: the bot never loads this plugin in
|
|
458
|
+
// production. The check has to happen before any filesystem work so
|
|
459
|
+
// a forgotten opt-in fails fast with a clear message, not a stack
|
|
460
|
+
// trace from a missing file or a permission error. We only enforce
|
|
461
|
+
// the explicit opt-in flag here — `TEST_CHAT` is consulted by the
|
|
462
|
+
// plugin itself at runtime, not by the loader.
|
|
463
|
+
if (process.env.MANYBOT_RUN_WHATSAPP_TESTS !== "1") {
|
|
464
|
+
throw new Error(`[pluginLoader] cannot load the integration plugin: ` +
|
|
465
|
+
`MANYBOT_RUN_WHATSAPP_TESTS=1 is required to opt in to the integration test harness.`);
|
|
466
|
+
}
|
|
467
|
+
const { INTEGRATION_PLUGIN_NAME, getIntegrationPluginDir } = await import("#kernel/integrationMode.js");
|
|
468
|
+
// Idempotent: if a previous load already registered the integration
|
|
469
|
+
// plugin, hand the same entry back rather than re-importing and
|
|
470
|
+
// duplicating the registry (and the in-process event listeners that
|
|
471
|
+
// would otherwise attach twice).
|
|
472
|
+
const existing = pluginRegistry.get(INTEGRATION_PLUGIN_NAME);
|
|
473
|
+
if (existing)
|
|
474
|
+
return existing;
|
|
475
|
+
const dir = getIntegrationPluginDir();
|
|
476
|
+
const pluginPath = `${dir}/index.ts`;
|
|
477
|
+
try {
|
|
478
|
+
const mod = await import(pathToFileURL(pluginPath).href);
|
|
479
|
+
if (typeof mod.default !== "function") {
|
|
480
|
+
throw new Error(`Integration plugin "${INTEGRATION_PLUGIN_NAME}" does not export a default function`);
|
|
481
|
+
}
|
|
482
|
+
const entry = {
|
|
483
|
+
name: INTEGRATION_PLUGIN_NAME,
|
|
484
|
+
status: "active",
|
|
485
|
+
run: mod.default,
|
|
486
|
+
setup: mod.setup ?? null,
|
|
487
|
+
commands: null,
|
|
488
|
+
exports: mod.api ?? null,
|
|
489
|
+
error: null,
|
|
490
|
+
guardOptions: {},
|
|
491
|
+
errorCount: 0,
|
|
492
|
+
};
|
|
493
|
+
pluginRegistry.set(INTEGRATION_PLUGIN_NAME, entry);
|
|
494
|
+
return entry;
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
498
|
+
const entry = {
|
|
499
|
+
name: INTEGRATION_PLUGIN_NAME,
|
|
500
|
+
status: "error",
|
|
501
|
+
run: null,
|
|
502
|
+
setup: null,
|
|
503
|
+
commands: null,
|
|
504
|
+
exports: null,
|
|
505
|
+
error: err,
|
|
506
|
+
guardOptions: {},
|
|
507
|
+
errorCount: 1,
|
|
508
|
+
};
|
|
509
|
+
pluginRegistry.set(INTEGRATION_PLUGIN_NAME, entry);
|
|
510
|
+
throw err;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, beforeEach, describe, test } from "node:test";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-plugin-loader-"));
|
|
7
|
+
process.env.MANYBOT_CONFIG_DIR = configDir;
|
|
8
|
+
const { cleanupPlugins, loadPlugin, loadPlugins, pluginRegistry, reloadCommandRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
|
|
9
|
+
const { getCommandRegistry } = await import("#kernel/commandRegistry.js");
|
|
10
|
+
const pluginsDir = path.join(configDir, "plugins");
|
|
11
|
+
const commandsFile = path.join(configDir, "commands.yaml");
|
|
12
|
+
async function writePlugin(name, manifest, source) {
|
|
13
|
+
const dir = path.join(pluginsDir, name);
|
|
14
|
+
await fs.mkdir(dir, { recursive: true });
|
|
15
|
+
await fs.writeFile(path.join(dir, "manyplug.json"), manifest, "utf8");
|
|
16
|
+
if (source !== undefined)
|
|
17
|
+
await fs.writeFile(path.join(dir, "index.js"), source, "utf8");
|
|
18
|
+
}
|
|
19
|
+
async function waitFor(label, read, timeoutMs = 3000) {
|
|
20
|
+
const deadline = Date.now() + timeoutMs;
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
const value = read();
|
|
23
|
+
if (value !== undefined)
|
|
24
|
+
return value;
|
|
25
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
28
|
+
}
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
await cleanupPlugins();
|
|
31
|
+
pluginRegistry.clear();
|
|
32
|
+
await fs.rm(pluginsDir, { recursive: true, force: true });
|
|
33
|
+
await fs.rm(commandsFile, { force: true });
|
|
34
|
+
await fs.rm(path.join(configDir, "menu.yaml"), { force: true });
|
|
35
|
+
});
|
|
36
|
+
after(async () => {
|
|
37
|
+
await cleanupPlugins();
|
|
38
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
describe("kernel/pluginLoader", () => {
|
|
41
|
+
test("marks a plugin without a manifest as disabled", async () => {
|
|
42
|
+
await loadPlugin("missing");
|
|
43
|
+
assert.deepEqual(pluginRegistry.get("missing"), {
|
|
44
|
+
name: "missing",
|
|
45
|
+
status: "disabled",
|
|
46
|
+
run: null,
|
|
47
|
+
setup: null,
|
|
48
|
+
commands: null,
|
|
49
|
+
exports: null,
|
|
50
|
+
error: null,
|
|
51
|
+
guardOptions: {},
|
|
52
|
+
errorCount: 0,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
test("loads an ESM plugin with optional public exports", async () => {
|
|
56
|
+
await writePlugin("hello", '{"main":"index.js"}', `
|
|
57
|
+
export default async function run() {}
|
|
58
|
+
export async function setup() {}
|
|
59
|
+
export const commands = { greet: { cmd: "greet", handler: async () => "hello" } }
|
|
60
|
+
export const api = { version: 1 }
|
|
61
|
+
export const guardOptions = { retries: 2 }
|
|
62
|
+
`);
|
|
63
|
+
await loadPlugin("hello");
|
|
64
|
+
const plugin = pluginRegistry.get("hello");
|
|
65
|
+
assert.ok(plugin);
|
|
66
|
+
assert.equal(plugin.status, "active");
|
|
67
|
+
assert.equal(typeof plugin.run, "function");
|
|
68
|
+
assert.equal(typeof plugin.setup, "function");
|
|
69
|
+
assert.equal(plugin.commands?.greet?.cmd, "greet");
|
|
70
|
+
assert.deepEqual(plugin.exports, { version: 1 });
|
|
71
|
+
assert.deepEqual(plugin.guardOptions, { retries: 2 });
|
|
72
|
+
assert.equal(plugin.errorCount, 0);
|
|
73
|
+
});
|
|
74
|
+
test("records a load error when a plugin has no default handler", async () => {
|
|
75
|
+
await writePlugin("invalid", "{}", "export const api = {};\n");
|
|
76
|
+
await loadPlugin("invalid");
|
|
77
|
+
const plugin = pluginRegistry.get("invalid");
|
|
78
|
+
assert.ok(plugin);
|
|
79
|
+
assert.equal(plugin.status, "error");
|
|
80
|
+
assert.equal(plugin.run, null);
|
|
81
|
+
assert.match(plugin.error?.message ?? "", /does not export a default function/);
|
|
82
|
+
assert.equal(plugin.errorCount, 3);
|
|
83
|
+
});
|
|
84
|
+
test("reloads an active plugin", async () => {
|
|
85
|
+
await writePlugin("reloadable", "{}", "export default async function run() {}\nexport const api = { version: 1 };\n");
|
|
86
|
+
await loadPlugin("reloadable");
|
|
87
|
+
assert.deepEqual(pluginRegistry.get("reloadable")?.exports, { version: 1 });
|
|
88
|
+
await reloadPlugin("reloadable");
|
|
89
|
+
const plugin = pluginRegistry.get("reloadable");
|
|
90
|
+
assert.equal(plugin?.status, "active");
|
|
91
|
+
assert.deepEqual(plugin?.exports, { version: 1 });
|
|
92
|
+
assert.equal(plugin?.errorCount, 0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("kernel/pluginLoader — commands.yaml hot reload", () => {
|
|
96
|
+
test("reloadCommandRegistry re-reads commands.yaml and rebuilds the registry", async () => {
|
|
97
|
+
await writePlugin("reloaddummy", '{"main":"index.js"}', `
|
|
98
|
+
export default async function run() {}
|
|
99
|
+
`);
|
|
100
|
+
await loadPlugins(["reloaddummy"]);
|
|
101
|
+
await fs.writeFile(commandsFile, `
|
|
102
|
+
defaults:
|
|
103
|
+
notifyChanges: false
|
|
104
|
+
helloReload:
|
|
105
|
+
cmd: hello
|
|
106
|
+
plugin: reloaddummy
|
|
107
|
+
desc: "First version"
|
|
108
|
+
functions: []
|
|
109
|
+
`, "utf8");
|
|
110
|
+
await reloadCommandRegistry();
|
|
111
|
+
const registry = getCommandRegistry();
|
|
112
|
+
assert.ok(registry, "registry should be initialized after reload");
|
|
113
|
+
assert.equal(registry.byInvocation.get("hello"), "helloReload");
|
|
114
|
+
assert.equal(registry.byId.get("helloReload")?.desc, "First version");
|
|
115
|
+
assert.equal(registry.defaults.notifyChanges, false);
|
|
116
|
+
await fs.writeFile(commandsFile, `
|
|
117
|
+
helloReload:
|
|
118
|
+
cmd: hello
|
|
119
|
+
plugin: reloaddummy
|
|
120
|
+
desc: "Second version"
|
|
121
|
+
functions: []
|
|
122
|
+
`, "utf8");
|
|
123
|
+
await reloadCommandRegistry();
|
|
124
|
+
const registry2 = getCommandRegistry();
|
|
125
|
+
assert.ok(registry2);
|
|
126
|
+
assert.equal(registry2.byId.get("helloReload")?.desc, "Second version");
|
|
127
|
+
assert.equal(registry2.defaults.notifyChanges, true, "defaults should reset to built-in when omitted");
|
|
128
|
+
});
|
|
129
|
+
test("config watcher reloads the registry when commands.yaml is edited", async () => {
|
|
130
|
+
await writePlugin("watchdummy", '{"main":"index.js"}', `
|
|
131
|
+
export default async function run() {}
|
|
132
|
+
`);
|
|
133
|
+
await loadPlugins(["watchdummy"]);
|
|
134
|
+
assert.equal(getCommandRegistry()?.byInvocation.get("watch"), undefined);
|
|
135
|
+
await fs.writeFile(commandsFile, `
|
|
136
|
+
helloWatch:
|
|
137
|
+
cmd: watch
|
|
138
|
+
plugin: watchdummy
|
|
139
|
+
desc: "Watcher picks this up"
|
|
140
|
+
functions: []
|
|
141
|
+
`, "utf8");
|
|
142
|
+
const registry = await waitFor("registry to pick up commands.yaml change", () => {
|
|
143
|
+
const r = getCommandRegistry();
|
|
144
|
+
return r?.byId.get("helloWatch")?.desc === "Watcher picks this up" ? r : undefined;
|
|
145
|
+
});
|
|
146
|
+
assert.equal(registry.byInvocation.get("watch"), "helloWatch");
|
|
147
|
+
});
|
|
148
|
+
test("config watcher reloads the registry when an imported YAML file is edited", async () => {
|
|
149
|
+
await writePlugin("importdummy", '{"main":"index.js"}', `
|
|
150
|
+
export default async function run() {}
|
|
151
|
+
`);
|
|
152
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
153
|
+
helloImport:
|
|
154
|
+
cmd: importcmd
|
|
155
|
+
plugin: importdummy
|
|
156
|
+
desc: "From menu.yaml (first)"
|
|
157
|
+
functions: []
|
|
158
|
+
`, "utf8");
|
|
159
|
+
await fs.writeFile(commandsFile, `import: menu.yaml\n`, "utf8");
|
|
160
|
+
await loadPlugins(["importdummy"]);
|
|
161
|
+
const first = await waitFor("initial import to be picked up", () => {
|
|
162
|
+
const r = getCommandRegistry();
|
|
163
|
+
return r?.byId.get("helloImport")?.desc === "From menu.yaml (first)" ? r : undefined;
|
|
164
|
+
});
|
|
165
|
+
assert.equal(first.byInvocation.get("importcmd"), "helloImport");
|
|
166
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
167
|
+
helloImport:
|
|
168
|
+
cmd: importcmd
|
|
169
|
+
plugin: importdummy
|
|
170
|
+
desc: "From menu.yaml (second)"
|
|
171
|
+
functions: []
|
|
172
|
+
`, "utf8");
|
|
173
|
+
const second = await waitFor("imported yaml change to be picked up", () => {
|
|
174
|
+
const r = getCommandRegistry();
|
|
175
|
+
return r?.byId.get("helloImport")?.desc === "From menu.yaml (second)" ? r : undefined;
|
|
176
|
+
});
|
|
177
|
+
assert.equal(second.byInvocation.get("importcmd"), "helloImport");
|
|
178
|
+
});
|
|
179
|
+
test("config watcher ignores non-yaml/non-toml files in PATHS.HOME", async () => {
|
|
180
|
+
await writePlugin("ignoredummy", '{"main":"index.js"}', `
|
|
181
|
+
export default async function run() {}
|
|
182
|
+
`);
|
|
183
|
+
await loadPlugins(["ignoredummy"]);
|
|
184
|
+
const registryBefore = getCommandRegistry();
|
|
185
|
+
await fs.writeFile(path.join(configDir, "README.md"), "unrelated", "utf8");
|
|
186
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
187
|
+
const registryAfter = getCommandRegistry();
|
|
188
|
+
assert.equal(registryAfter, registryBefore, "registry reference must be stable when no relevant file changed");
|
|
189
|
+
});
|
|
190
|
+
});
|