backend-manager 5.9.1 → 5.9.3

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/CHANGELOG.md CHANGED
@@ -14,6 +14,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.9.3] - 2026-06-21
18
+
19
+ ### Changed
20
+ - **`admin/hook` route now triggers BEM internal crons.** Path resolution searches BEM's events directory (crons), consumer project root, and consumer hooks/ directory. Supports both function exports (`module.exports = async (opts) => {}`) and class-based hooks. Example: `POST /admin/hook { path: "cron/daily/blog-auto-publisher" }`.
21
+ - **MCP `run_hook` tool updated** with expanded description and example paths for all built-in cron jobs.
22
+
23
+ ### Available hook paths (built-in BEM crons)
24
+ - `cron/daily/blog-auto-publisher` — generate + publish blog articles
25
+ - `cron/daily/marketing-newsletter-generate` — pre-generate newsletter content
26
+ - `cron/daily/marketing-prune` — prune inactive marketing contacts
27
+ - `cron/daily/reset-usage` — reset monthly usage counters
28
+ - `cron/daily/data-requests` — process GDPR data export requests
29
+ - `cron/daily/expire-paypal-cancellations` — expire pending PayPal cancellations
30
+ - `cron/frequent/marketing-campaigns` — send pending marketing campaigns
31
+
32
+ # [5.9.2] - 2026-06-20
33
+
34
+ ### Added
35
+ - Blog generation extended test (`test/content/blog-generate.js`). Run `npx mgr test mgr:content/blog-generate --extended` to exercise the full blog auto-publisher pipeline (source resolution → Ghostii → publish). Supports `BLOG_SOURCE`, `BLOG_NO_PUBLISH`, `BLOG_OPEN` env vars.
36
+
17
37
  # [5.9.1] - 2026-06-20
18
38
 
19
39
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.9.1",
3
+ "version": "5.9.3",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -296,6 +296,7 @@
296
296
  "80fight.cn",
297
297
  "8127ep.com",
298
298
  "816qs.com",
299
+ "862808.xyz",
299
300
  "875888.xyz",
300
301
  "8mail.cf",
301
302
  "8mail.ga",
@@ -1137,6 +1138,8 @@
1137
1138
  "bundes-li.ga",
1138
1139
  "bunsenhoneydew.com",
1139
1140
  "burangir.com",
1141
+ "burnermail.bond",
1142
+ "burnermail.cyou",
1140
1143
  "burnthespam.info",
1141
1144
  "burry.eu.org",
1142
1145
  "burstmail.info",
@@ -1274,6 +1277,7 @@
1274
1277
  "chammy.info",
1275
1278
  "chanceforfuture.org",
1276
1279
  "chansd.com",
1280
+ "chantellegribbon.com",
1277
1281
  "chaocosen.com",
1278
1282
  "chapsmail.com",
1279
1283
  "charityrcao.org.uk",
@@ -1365,6 +1369,7 @@
1365
1369
  "clockemail.com",
1366
1370
  "clone21.io.vn",
1367
1371
  "cloneemail.com",
1372
+ "clonekhoe.com",
1368
1373
  "clonemailsieure.com",
1369
1374
  "clonemoi.tk",
1370
1375
  "clonetrust.com",
@@ -2615,6 +2620,7 @@
2615
2620
  "gendeng.indevs.in",
2616
2621
  "genderfuck.net",
2617
2622
  "generator.email",
2623
+ "generator1email.com",
2618
2624
  "genzmaile.com",
2619
2625
  "genzotp.com",
2620
2626
  "geometrics.icu",
@@ -3682,6 +3688,7 @@
3682
3688
  "kommunity.biz",
3683
3689
  "kon42.com",
3684
3690
  "konican.com",
3691
+ "konterkulo.com",
3685
3692
  "kontoko.org",
3686
3693
  "konultant-jurist.ru",
3687
3694
  "kook.ml",
@@ -4074,6 +4081,7 @@
4074
4081
  "mailfa.tk",
4075
4082
  "mailfall.com",
4076
4083
  "mailfast.pro",
4084
+ "mailfirefly.com",
4077
4085
  "mailfirst.icu",
4078
4086
  "mailforspam.com",
4079
4087
  "mailfree.ga",
@@ -4204,6 +4212,7 @@
4204
4212
  "mailuniverse.co.uk",
4205
4213
  "mailvip.net",
4206
4214
  "mailw.site",
4215
+ "mailx.click",
4207
4216
  "mailyan.my.id",
4208
4217
  "mailzi.ru",
4209
4218
  "mailzilla.com",
@@ -4252,6 +4261,7 @@
4252
4261
  "matra.site",
4253
4262
  "max-mail.org",
4254
4263
  "maxresistance.com",
4264
+ "maxseeding.com",
4255
4265
  "maxturns.com",
4256
4266
  "mayre.shop",
4257
4267
  "mbox.re",
@@ -4294,6 +4304,7 @@
4294
4304
  "memoniclife.online",
4295
4305
  "memsg.site",
4296
4306
  "mengma.eu.cc",
4307
+ "mengundang.live",
4297
4308
  "mentonit.net",
4298
4309
  "meocon.org",
4299
4310
  "meomo.store",
@@ -4778,6 +4789,8 @@
4778
4789
  "nfkah.eu.cc",
4779
4790
  "nghienplus.io.vn",
4780
4791
  "nghienplus.store",
4792
+ "nguyenlieu24h.com",
4793
+ "nguyentinhblog.com",
4781
4794
  "nguyenusedcars.com",
4782
4795
  "nh3.ro",
4783
4796
  "nhienpv1.io.vn",
@@ -5297,6 +5310,7 @@
5297
5310
  "pricegh.fun",
5298
5311
  "pride-worldwi.de",
5299
5312
  "primabananen.net",
5313
+ "primejetnet.com",
5300
5314
  "prin.be",
5301
5315
  "printz.site",
5302
5316
  "prisonity.com",
@@ -5406,6 +5420,7 @@
5406
5420
  "qq.my",
5407
5421
  "qsl.ro",
5408
5422
  "qsmail.cc.cd",
5423
+ "qsuo.uno",
5409
5424
  "qtum-ico.com",
5410
5425
  "quadrafit.com",
5411
5426
  "quarvex.cfd",
@@ -5462,6 +5477,7 @@
5462
5477
  "rawr.foo",
5463
5478
  "rax.la",
5464
5479
  "raxtest.com",
5480
+ "rayrayactive.com",
5465
5481
  "razemail.com",
5466
5482
  "razeny.com",
5467
5483
  "razuz.com",
@@ -5686,6 +5702,7 @@
5686
5702
  "scpulse.com",
5687
5703
  "scrsot.com",
5688
5704
  "sd3.in",
5705
+ "sdbbs.cc",
5689
5706
  "sdhaiok.eu.cc",
5690
5707
  "sdj.fr.nf",
5691
5708
  "sdvft.com",
@@ -5781,6 +5798,7 @@
5781
5798
  "shitmail.org",
5782
5799
  "shizuku-lulu.love",
5783
5800
  "shmeriously.com",
5801
+ "shopaccmmo.com",
5784
5802
  "shopbantkclone.com",
5785
5803
  "shopempva11.com",
5786
5804
  "shopld.linkpc.net",
@@ -6206,6 +6224,7 @@
6206
6224
  "talmetry.com",
6207
6225
  "tamthomca.click",
6208
6226
  "tamttts.com",
6227
+ "tandlplith.se",
6209
6228
  "tanlanav.com",
6210
6229
  "tanukis.org",
6211
6230
  "tanvo.online",
@@ -7107,6 +7126,7 @@
7107
7126
  "whatifanalytics.com",
7108
7127
  "whatpaas.com",
7109
7128
  "whatsaas.com",
7129
+ "whf222.top",
7110
7130
  "whiffles.org",
7111
7131
  "whirlygirlink.org",
7112
7132
  "whispermail.org",
@@ -7258,6 +7278,7 @@
7258
7278
  "xn--ihq4pool8g32cwxiiqcovaa9159jhvah03g.top",
7259
7279
  "xn--jxsa73o.eu.org",
7260
7280
  "xn--yaho-sqa.com",
7281
+ "xn-xp8h.bond",
7261
7282
  "xojxe.com",
7262
7283
  "xopmail.fun",
7263
7284
  "xost.us",
@@ -7460,6 +7481,7 @@
7460
7481
  "zhengwz.dpdns.org",
7461
7482
  "zhewei88.com",
7462
7483
  "zhizhiwei.fun",
7484
+ "zhjjq.tech",
7463
7485
  "zhorachu.com",
7464
7486
  "zhousj.eu.org",
7465
7487
  "zik.dj",
@@ -7522,6 +7544,7 @@
7522
7544
  "zzzzzzzzzzzzzz-3928.dynv6.net",
7523
7545
  "zzzzzzzzzzzzzz-3940.dynv6.net",
7524
7546
  "zzzzzzzzzzzzzz-4283.dynv6.net",
7547
+ "zzzzzzzzzzzzzz-4904.dynv6.net",
7525
7548
  "zzzzzzzzzzzzzz-5055.dynv6.net",
7526
7549
  "zzzzzzzzzzzzzz-5246.dynv6.net",
7527
7550
  "zzzzzzzzzzzzzz-546.dynv6.net",
@@ -1,75 +1,96 @@
1
1
  /**
2
- * POST /admin/hook - Run hook manually
3
- * Admin-only endpoint to trigger hooks
2
+ * POST /admin/hook - Run a hook or cron job manually
3
+ *
4
+ * Resolves the hook from multiple locations:
5
+ * 1. BEM internal crons (e.g. path="cron/daily/blog-auto-publisher")
6
+ * 2. BEM internal functions/core hooks
7
+ * 3. Consumer project root
8
+ * 4. Consumer hooks/ directory
9
+ *
10
+ * Supports both calling conventions:
11
+ * - Function export: module.exports = async ({ Manager, assistant, ... }) => {}
12
+ * - Class export: module.exports = class { main(assistant) {} }
4
13
  */
5
14
  module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
6
15
 
7
- // Require authentication (allow in dev)
8
16
  if (!user.authenticated && assistant.isProduction()) {
9
17
  return assistant.respond('Authentication required', { code: 401 });
10
18
  }
11
19
 
12
- // Require admin (allow in dev)
13
20
  if (!user.roles.admin && assistant.isProduction()) {
14
21
  return assistant.respond('Admin required.', { code: 403 });
15
22
  }
16
23
 
17
- // Check for required parameter
18
24
  if (!settings.path) {
19
25
  return assistant.respond('Missing required parameter: path', { code: 400 });
20
26
  }
21
27
 
22
28
  assistant.log('Running hook:', settings.path);
23
29
 
24
- // Load the hook
25
- const hook = loadHook(assistant, settings.path);
30
+ const loaded = loadHook(assistant, settings.path);
26
31
 
27
- if (!hook) {
32
+ if (!loaded) {
28
33
  return assistant.respond(`Hook not found: ${settings.path}`, { code: 404 });
29
34
  }
30
35
 
31
- // Run the hook
32
- try {
33
- // Set variables
34
- hook.Manager = Manager;
35
- hook.assistant = assistant;
36
- hook.context = null;
37
- hook.libraries = Manager.libraries;
38
-
39
- // Get hook name
40
- const hookName = settings.path.split('/').pop();
36
+ const hookName = settings.path.split('/').pop();
37
+ assistant.setLogPrefix(`hook/${hookName}()`);
41
38
 
42
- // Set log prefix
43
- assistant.setLogPrefix(`cron/daily/${hookName}()`);
44
-
45
- // Run the hook
46
- const result = await hook.main(assistant);
39
+ try {
40
+ let result;
41
+
42
+ if (loaded.type === 'function') {
43
+ result = await loaded.handler({
44
+ Manager,
45
+ assistant,
46
+ context: {},
47
+ libraries: Manager.libraries,
48
+ });
49
+ } else {
50
+ const instance = loaded.handler;
51
+ instance.Manager = Manager;
52
+ instance.assistant = assistant;
53
+ instance.context = null;
54
+ instance.libraries = Manager.libraries;
55
+ result = await instance.main(assistant);
56
+ }
47
57
 
48
- // Track analytics
49
58
  analytics.event('admin/hook', { path: settings.path });
50
59
 
51
- return assistant.respond(result);
60
+ return assistant.respond(result || { success: true });
52
61
  } catch (e) {
62
+ assistant.error(`Hook error: ${e.message}`, e);
53
63
  return assistant.respond(e.message, { code: 500 });
54
64
  }
55
65
  };
56
66
 
57
- // Helper: Load hook from multiple paths
58
67
  function loadHook(assistant, hookPath) {
59
68
  const Manager = assistant.Manager;
60
- const paths = [
61
- `${Manager.rootDirectory}/functions/core/${hookPath}`,
62
- `${Manager.cwd}/${hookPath}`,
63
- `${Manager.cwd}/hooks/${hookPath}`,
69
+ const path = require('path');
70
+
71
+ const searchPaths = [
72
+ // BEM internal crons + events (e.g. "cron/daily/blog-auto-publisher")
73
+ path.join(Manager.rootDirectory, 'events', hookPath),
74
+ // BEM internal functions/core
75
+ path.join(Manager.rootDirectory, '..', '..', 'functions', 'core', hookPath),
76
+ // Consumer project root
77
+ path.join(Manager.cwd, hookPath),
78
+ // Consumer hooks/ directory
79
+ path.join(Manager.cwd, 'hooks', hookPath),
64
80
  ];
65
81
 
66
- for (let i = 0; i < paths.length; i++) {
67
- const current = pathify(paths[i]);
68
-
69
- assistant.log('Trying path:', current);
82
+ for (const searchPath of searchPaths) {
83
+ const resolved = pathify(searchPath);
84
+ assistant.log('Trying path:', resolved);
70
85
 
71
86
  try {
72
- return (new (require(current))());
87
+ const mod = require(resolved);
88
+
89
+ if (typeof mod === 'function' && !mod.prototype?.main) {
90
+ return { type: 'function', handler: mod };
91
+ }
92
+
93
+ return { type: 'class', handler: new mod() };
73
94
  } catch (e) {
74
95
  // Continue to next path
75
96
  }
@@ -78,7 +99,6 @@ function loadHook(assistant, hookPath) {
78
99
  return null;
79
100
  }
80
101
 
81
- // Helper: Normalize path
82
- function pathify(path) {
83
- return `${path.replace('.js', '')}.js`;
102
+ function pathify(p) {
103
+ return `${p.replace(/\.js$/, '')}.js`;
84
104
  }
package/src/mcp/tools.js CHANGED
@@ -459,15 +459,15 @@ module.exports = [
459
459
  // --- Hooks ---
460
460
  {
461
461
  name: 'run_hook',
462
- description: 'Execute a custom hook by path (e.g. "cron/daily/my-job")',
462
+ description: 'Execute a hook or BEM cron job by path. Searches: BEM internal crons (e.g. "cron/daily/blog-auto-publisher", "cron/daily/marketing-newsletter-generate"), consumer hooks/ directory, and consumer project root. Supports both function exports and class-based hooks.',
463
463
  role: 'admin',
464
464
  method: 'POST',
465
465
  path: 'admin/hook',
466
- annotations: { title: 'Run a custom hook', readOnlyHint: false, destructiveHint: false },
466
+ annotations: { title: 'Run hook or cron', readOnlyHint: false, destructiveHint: false },
467
467
  inputSchema: {
468
468
  type: 'object',
469
469
  properties: {
470
- path: { type: 'string', description: 'Hook path to execute' },
470
+ path: { type: 'string', description: 'Hook path (e.g. "cron/daily/blog-auto-publisher", "cron/daily/marketing-newsletter-generate", "cron/daily/reset-usage", "cron/frequent/marketing-campaigns", or a consumer hook path)' },
471
471
  },
472
472
  required: ['path'],
473
473
  },
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Blog article generation test.
3
+ *
4
+ * Two modes:
5
+ *
6
+ * 1. DEFAULT MODE (no env required)
7
+ * Verifies the blog auto-publisher config is readable and source resolution
8
+ * works correctly. No AI calls, no publishing. Fast, free, deterministic.
9
+ *
10
+ * 2. AI PIPELINE MODE (set TEST_EXTENDED_MODE=1)
11
+ * Runs the full blog auto-publisher pipeline: resolves a source, calls
12
+ * Ghostii to write an article, and publishes it to the website repo via
13
+ * admin/post. Same code path the daily cron uses. Costs money (AI tokens).
14
+ *
15
+ * Run from any consumer project's functions/ directory:
16
+ * npx mgr test mgr:content/blog-generate # config check (fast, free)
17
+ * TEST_EXTENDED_MODE=1 npx mgr test mgr:content/blog-generate # full AI pipeline
18
+ *
19
+ * --- Env vars (AI mode only, require TEST_EXTENDED_MODE=1) ---
20
+ * BLOG_SOURCE=<type> Override the source type for this run.
21
+ * Values: '$brand', '$parent', '$feed:<url>', a URL, or text.
22
+ * Default: picks randomly from the first content entry's sources[].
23
+ * BLOG_NO_PUBLISH=1 Generate the article but do NOT publish to the website repo.
24
+ * Useful for iterating on prompt quality without committing posts.
25
+ * BLOG_OPEN=1 Auto-open the generated article in the default browser (macOS only).
26
+ *
27
+ * AI mode requires:
28
+ * BACKEND_MANAGER_KEY — authenticates with Ghostii + parent server
29
+ * OPENAI_API_KEY — Ghostii uses OpenAI internally (or BACKEND_MANAGER_OPENAI_API_KEY)
30
+ * PARENT_API_URL — or set `parent` in backend-manager-config.json (for $parent sources)
31
+ *
32
+ * Default mode requires: nothing.
33
+ */
34
+ const path = require('path');
35
+ const { execSync } = require('child_process');
36
+ const jetpack = require('fs-jetpack');
37
+ const powertools = require('node-powertools');
38
+
39
+ module.exports = {
40
+ description: 'Generate a blog article (config check by default, full AI pipeline with TEST_EXTENDED_MODE)',
41
+ auth: 'none',
42
+ timeout: 300000,
43
+ async run({ assert, config, Manager, assistant, skip }) {
44
+ const env = process.env;
45
+ const blogConfig = Manager.config.blog;
46
+
47
+ // --- Default mode: config validation ---
48
+ if (!env.TEST_EXTENDED_MODE) {
49
+ assert.ok(blogConfig, 'blog config exists');
50
+ assert.ok(blogConfig.platform, 'blog.platform is set');
51
+ assert.equal(typeof blogConfig.enabled, 'boolean', 'blog.enabled is a boolean');
52
+
53
+ const contentArray = powertools.arrayify(blogConfig.content);
54
+ assert.ok(contentArray.length > 0, 'blog.content has at least one entry');
55
+
56
+ const entry = contentArray[0];
57
+ assert.ok(Array.isArray(entry.sources), 'first entry has sources array');
58
+ assert.ok(entry.sources.length > 0, 'first entry has at least one source');
59
+
60
+ // Verify source types are valid
61
+ for (const source of entry.sources) {
62
+ const validTypes = source === '$brand'
63
+ || source === '$parent'
64
+ || source.startsWith('$feed:')
65
+ || source.startsWith('http')
66
+ || typeof source === 'string';
67
+ assert.ok(validTypes, `source "${source}" is a valid type`);
68
+ }
69
+
70
+ // Verify provider exists
71
+ const providerPath = path.join(__dirname, '..', '..', 'src', 'manager', 'libraries', 'content', `${blogConfig.platform}.js`);
72
+ assert.ok(jetpack.exists(providerPath), `provider "${blogConfig.platform}" exists at ${providerPath}`);
73
+
74
+ console.log(`\n[blog-generate] Config OK:`);
75
+ console.log(` platform: ${blogConfig.platform}`);
76
+ console.log(` enabled: ${blogConfig.enabled}`);
77
+ console.log(` entries: ${contentArray.length}`);
78
+ console.log(` sources: ${entry.sources.join(', ')}`);
79
+ console.log(` categories: ${(entry.categories || []).join(', ') || '(none)'}`);
80
+ console.log(` tone: ${entry.tone || '(default)'}`);
81
+ console.log(` (Set TEST_EXTENDED_MODE=1 to run the full AI pipeline.)`);
82
+ return;
83
+ }
84
+
85
+ // --- Extended mode: full AI pipeline ---
86
+ if (!blogConfig?.enabled) {
87
+ return skip('blog.enabled is false in config');
88
+ }
89
+
90
+ const publisherPath = path.join(__dirname, '..', '..', 'src', 'manager', 'events', 'cron', 'daily', 'blog-auto-publisher.js');
91
+ const publisher = require(publisherPath);
92
+
93
+ // Get content entries
94
+ const contentArray = powertools.arrayify(blogConfig.content);
95
+ assert.ok(contentArray.length > 0, 'blog.content has at least one entry');
96
+
97
+ // Clone and override for test
98
+ const content = JSON.parse(JSON.stringify(contentArray));
99
+ content[0].chance = 1.0;
100
+ content[0].quantity = 1;
101
+
102
+ // Override source if env specified
103
+ if (env.BLOG_SOURCE) {
104
+ content[0].sources = [env.BLOG_SOURCE];
105
+ }
106
+
107
+ Manager.config.blog.content = content;
108
+
109
+ // Output dir
110
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-(\d{3})Z$/, '');
111
+ const outDir = path.join(process.cwd(), '..', '.temp', 'blog', `run-${stamp}`);
112
+ jetpack.dir(outDir);
113
+
114
+ console.log(`\n[blog-generate] Extended mode — full AI pipeline`);
115
+ console.log(` platform: ${blogConfig.platform}`);
116
+ console.log(` sources: ${content[0].sources.join(', ')}`);
117
+ console.log(` publish: ${env.BLOG_NO_PUBLISH ? 'NO (BLOG_NO_PUBLISH=1)' : 'YES'}`);
118
+ console.log(` output: ${outDir}`);
119
+
120
+ // Intercept publishArticle if BLOG_NO_PUBLISH is set
121
+ if (env.BLOG_NO_PUBLISH) {
122
+ const provider = require(path.join(__dirname, '..', '..', 'src', 'manager', 'libraries', 'content', `${blogConfig.platform}.js`));
123
+ const originalPublish = provider.publishArticle;
124
+ provider.publishArticle = async (ast, args) => {
125
+ console.log(`[blog-generate] SKIPPED publishArticle (BLOG_NO_PUBLISH=1)`);
126
+ console.log(` title: ${args.article?.title || '(unknown)'}`);
127
+ return { post: null, url: null, slug: 'dry-run', path: null };
128
+ };
129
+
130
+ // Restore after test
131
+ process.on('exit', () => { provider.publishArticle = originalPublish; });
132
+ }
133
+
134
+ // Run the publisher
135
+ await publisher({
136
+ Manager,
137
+ assistant,
138
+ context: {},
139
+ libraries: Manager.libraries,
140
+ });
141
+
142
+ // Write metadata
143
+ jetpack.write(path.join(outDir, 'metadata.json'), JSON.stringify({
144
+ mode: 'extended',
145
+ platform: blogConfig.platform,
146
+ sources: content[0].sources,
147
+ published: !env.BLOG_NO_PUBLISH,
148
+ timestamp: new Date().toISOString(),
149
+ }, null, 2));
150
+
151
+ console.log(`\n[blog-generate] Done. Output: ${outDir}`);
152
+
153
+ // Auto-open (macOS)
154
+ if (env.BLOG_OPEN === '1' && process.platform === 'darwin') {
155
+ try { execSync(`open "${outDir}"`); } catch (e) { /* no-op */ }
156
+ }
157
+
158
+ assert.ok(true, 'Blog auto-publisher completed');
159
+ },
160
+ };