@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.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. 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 config file for hot reloading
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
- logger.info(t(isReload ? "system.pluginReloaded" : "system.pluginLoaded", { name }));
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
- * Watch the config directory for manyplug.toml or manybot.toml changes.
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
- if (filename === "manyplug.toml" || filename === "manybot.toml") {
317
- if (configTimeout)
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
+ });