@bongos/core 1.19.99 → 1.19.100

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.
@@ -22,6 +22,15 @@
22
22
  // version of the loader's silent coreVersion SKIP (which would just drop the
23
23
  // module at boot). Disabled mismatches are warnings, not blockers.
24
24
  //
25
+ // bongos module list [--catalog] [--search <q>] [--json]
26
+ // ADR 0107 §2/§3 (task 1001772) — the BROWSABLE CATALOG from the terminal:
27
+ // every module this instance can enable, each with its author + origin
28
+ // credit, its enabled state, and who maintains it. `--catalog` narrows to
29
+ // the CORE-shipped modules (the shared catalog an instance opts into, §6);
30
+ // without it you also see the instance's own modules. It renders the same
31
+ // model as the hall's Modules tab (src/module-loader/catalog.js), so the two
32
+ // surfaces cannot disagree about who built what.
33
+ //
25
34
  // bongos module check <key>
26
35
  // ADR 0107 §4 — the submit-time pre-check a module must clear before it can
27
36
  // be upstreamed into the shared core catalog. REUSES (never re-types) the
@@ -48,6 +57,7 @@
48
57
  // Usage:
49
58
  // bongos module new <key> scaffold modules/<key>/
50
59
  // bongos module new <key> --force overwrite an existing modules/<key>/
60
+ // bongos module list [--catalog] browse the module catalog (author + origin credit)
51
61
  // bongos upgrade pre-check enabled modules vs the core version
52
62
  // bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
53
63
 
@@ -60,6 +70,7 @@ const MODULES_DIR = path.join(REPO_ROOT, 'modules');
60
70
  const { validateManifest, assertValidManifest, KEY_RE, SUNSET_STATUSES } = require('../../src/module-loader/manifest-schema');
61
71
  const { parseVersion, cmp } = require('../../src/module-loader/semver');
62
72
  const loader = require('../../src/module-loader/loader');
73
+ const catalog = require('../../src/module-loader/catalog');
63
74
  const { resolveModules, MODULE_REGISTRY, NEUTRAL_PATH, INSTANCE_PATH } = require('../../src/modules');
64
75
  // The public-mirror publish boundary (ADR 0098) — REUSED, never re-typed, for
65
76
  // the ADR 0107 §4 upstreaming pre-check. Only matchesDeny is used (not the full
@@ -327,6 +338,125 @@ function gatherUpgrade({ modulesDir = MODULES_DIR, core = coreVersion(), env = p
327
338
  return { rows, errors, core };
328
339
  }
329
340
 
341
+ // ---------------------------------------------------------------------------
342
+ // bongos module list — ADR 0107 §2/§3 the browsable catalog (task 1001772)
343
+ // ---------------------------------------------------------------------------
344
+
345
+ // The live catalog off disk + config. Unlike gatherUpgrade (which scans ONE
346
+ // modulesDir because it is answering a question about this checkout's own
347
+ // modules), this uses the loader's DEFAULT roots so a split instance sees both
348
+ // trees — the core package's modules/ AND its own (ADR 0108 §1). That two-root
349
+ // scan is what makes "core-shipped vs. built here" a structural fact here
350
+ // rather than a guess.
351
+ function gatherCatalog({ roots, core = coreVersion(), env = process.env, neutral, instance, prefix } = {}) {
352
+ neutral = neutral || readJson(NEUTRAL_PATH) || {};
353
+ instance = instance || readJson(INSTANCE_PATH) || {};
354
+ prefix = prefix || envPrefix();
355
+
356
+ const { loaded, skipped, errors } = loader.discoverModules(roots ? { roots, core } : { core });
357
+
358
+ // Same registry-spanning rule as gatherUpgrade: resolveModules throws on an
359
+ // unknown key, so every discovered key must be in the registry it is given.
360
+ const registry = { ...MODULE_REGISTRY };
361
+ for (const { key, manifest } of loaded) {
362
+ if (!(key in registry)) registry[key] = { default: manifest.default === true, contributes: manifest.contributes || {} };
363
+ }
364
+ for (const { key } of skipped) {
365
+ if (key && !(key in registry)) registry[key] = { default: false, contributes: {} };
366
+ }
367
+
368
+ let enabled = {};
369
+ try { enabled = resolveModules({ neutral, instance, env, envPrefix: prefix, registry }); }
370
+ catch { /* a malformed config must not blank the catalog — show it all as disabled */ }
371
+
372
+ const { coreRoot, instanceRoot, coreCheckout } = catalog.detectRoots();
373
+ const rows = catalog.buildCatalog({
374
+ loaded, enabled, coreRoot, instanceRoot, coreCheckout, ownProjectName: ownProjectName(),
375
+ });
376
+ return { rows, skipped, errors, core };
377
+ }
378
+
379
+ // The instance's own product name, for the credit axis (deriveProvenance).
380
+ // Fail-soft: no brand pack resolvable → null, which makes provenance fall back
381
+ // to "own-built" rather than guessing (provenance.js's documented behaviour).
382
+ function ownProjectName() {
383
+ try { return require('../../src/branding').branding().identity.productName; } catch { return null; }
384
+ }
385
+
386
+ function pad(s, n) { return String(s == null ? '' : s).padEnd(n); }
387
+ function clip(s, n) {
388
+ const v = String(s == null ? '' : s);
389
+ return v.length > n ? `${v.slice(0, n - 1)}…` : v;
390
+ }
391
+
392
+ // Pure renderer — every input arrives as a parameter (the tests pass catalog
393
+ // rows straight in), so it touches no disk and no config.
394
+ function formatCatalog(rows, { catalogOnly = false, query = '', core = null } = {}) {
395
+ const scoped = catalogOnly ? catalog.catalogRows(rows) : rows;
396
+ const shown = catalog.searchCatalog(scoped, query);
397
+ const counts = catalog.summarize(rows);
398
+ const out = [];
399
+
400
+ out.push(catalogOnly
401
+ ? 'bongos module list --catalog — core modules this instance can enable (ADR 0107 §6: opt-in, default-off)'
402
+ : 'bongos module list — every module on disk, with author + origin credit (ADR 0107 §2)');
403
+ if (core) out.push(` core (src/module-api.js CORE_VERSION): ${core}`);
404
+ out.push(` ${counts.catalog} in the core catalog (${counts.catalog_enabled} enabled) · ${counts.instance} built here · ${counts.total} total`);
405
+ if (query) out.push(` filter "${query}" — ${shown.length} match(es)`);
406
+ out.push('');
407
+
408
+ if (!shown.length) {
409
+ out.push(query ? ' no module matches that filter.' : ' no modules discovered.');
410
+ return out.join('\n');
411
+ }
412
+
413
+ out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('SOURCE', 10)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
414
+ for (const r of shown) {
415
+ const mark = r.enabled ? '[x]' : '[ ]';
416
+ // An inferred credit is marked so it never reads as something the author
417
+ // typed — the catalog's whole job is honest attribution (ADR 0107 §2).
418
+ const suffix = r.credit.inferred ? '*' : '';
419
+ const upkeep = (r.maintenance && r.maintenance.status) || 'undeclared';
420
+ out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(r.source, 10)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
421
+ }
422
+ out.push('');
423
+ out.push(' [x] enabled here · [ ] available, not enabled');
424
+ if (shown.some((r) => r.credit.inferred)) {
425
+ out.push(` * credit not declared in module.json — shown as the platform (${catalog.CORE_PROJECT_NAME}), which ships it.`);
426
+ }
427
+ out.push(' enable one: add it to config/modules.json, or use the hall\'s Modules tab (Metic+).');
428
+ return out.join('\n');
429
+ }
430
+
431
+ function cmdList(args, { log = console.log, errlog = console.error, gather = gatherCatalog } = {}) {
432
+ const catalogOnly = args.includes('--catalog');
433
+ const json = args.includes('--json');
434
+ const sIdx = args.indexOf('--search');
435
+ const query = sIdx !== -1 ? (args[sIdx + 1] || '') : '';
436
+ if (sIdx !== -1 && (!args[sIdx + 1] || args[sIdx + 1].startsWith('--'))) {
437
+ errlog('usage: bongos module list [--catalog] [--search <text>] [--json]');
438
+ return 2;
439
+ }
440
+
441
+ let data;
442
+ try { data = gather(); }
443
+ catch (e) { errlog(`bongos module list: ${e.message}`); return 1; }
444
+
445
+ if (json) {
446
+ const scoped = catalogOnly ? catalog.catalogRows(data.rows) : data.rows;
447
+ const modules = catalog.searchCatalog(scoped, query);
448
+ log(JSON.stringify({ core: data.core, summary: catalog.summarize(data.rows), modules }, null, 2));
449
+ return 0;
450
+ }
451
+
452
+ log(formatCatalog(data.rows, { catalogOnly, query, core: data.core }));
453
+ for (const s of data.skipped) {
454
+ errlog(` ! ${s.key}: not listed — ${s.reason}`);
455
+ }
456
+ for (const e of data.errors) errlog(` ! ${e.dir}: ${(e.errors || []).join('; ')}`);
457
+ return 0;
458
+ }
459
+
330
460
  // ---------------------------------------------------------------------------
331
461
  // bongos module check <key> — ADR 0107 §4 upstreaming pre-check + DCO sign-off
332
462
  // ---------------------------------------------------------------------------
@@ -649,6 +779,8 @@ function printHelp(out = console.log) {
649
779
  out('bongos module — scaffold + manage Cloud Bongos modules\n');
650
780
  out('Usage:');
651
781
  out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + CLAUDE.md)');
782
+ out(' bongos module list [--catalog] [--search <text>] [--json]');
783
+ out(' Browse the module catalog — what you can enable, with author + origin credit');
652
784
  out(' bongos upgrade Pre-check enabled modules against the core version (fail-closed)');
653
785
  out(' bongos module check <key> [--sign-off "Name <email>"]');
654
786
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
@@ -660,6 +792,7 @@ async function main(argv) {
660
792
  const args = argv.slice(2);
661
793
  const sub = args[0];
662
794
  if (sub === 'new') return cmdNew(args.slice(1));
795
+ if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
663
796
  if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
664
797
  if (sub === 'check') return cmdCheck(args.slice(1));
665
798
  if (sub === 'submit') return cmdSubmit(args.slice(1));
@@ -673,7 +806,8 @@ async function main(argv) {
673
806
  }
674
807
 
675
808
  module.exports = {
676
- main, cmdNew, cmdUpgrade, cmdCheck, cmdSubmit, printHelp,
809
+ main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, printHelp,
810
+ gatherCatalog, formatCatalog, ownProjectName,
677
811
  scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
678
812
  formatMaintenanceHint, isPastRemoveAfter,
679
813
  checkModulePublishability, recordSignOff, readSignOff, DCO_STATEMENT,
@@ -43,7 +43,7 @@ const path = require('node:path');
43
43
  const auth = require('../auth');
44
44
  const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
45
45
  const loader = require('../../module-loader/loader');
46
- const { deriveProvenance } = require('../../module-loader/provenance');
46
+ const catalog = require('../../module-loader/catalog');
47
47
  const modulesLib = require('../../modules');
48
48
  // scripts/gds/ is a sibling CORE-package dir (both resolve under
49
49
  // resolveCoreRoot() — see scripts/gds/CLAUDE.md), so requiring its pure fs
@@ -80,31 +80,26 @@ function readInstanceConfig() {
80
80
  catch (e) { if (e.code === 'ENOENT') return { modules: {} }; throw e; }
81
81
  }
82
82
 
83
+ // The catalog model (src/module-loader/catalog.js) is the SINGLE builder behind
84
+ // both surfaces — this route and `bongos module list` — so the hall and the
85
+ // terminal can never disagree about who built a module or which tree it ships
86
+ // from (task 1001772). Every field the pre-catalog version returned is still
87
+ // here, byte-for-byte; what's new is `source` (core-shipped vs. built here,
88
+ // read structurally from the discovery root) and `credit` (the display
89
+ // attribution, never blank for a core module, with `inferred` marking a value
90
+ // the manifest didn't declare). `author`/`origin` stay the DECLARED manifest
91
+ // values — `provenance` is derived from those, not from the display credit.
83
92
  function listModules() {
84
93
  const { loaded } = loader.discoverModules();
85
- const enabledSet = modulesLib.modules();
86
- const own = ownProjectName();
87
- return loaded.map(({ key, manifest }) => {
88
- const provenance = deriveProvenance(manifest.origin, own);
89
- return {
90
- key,
91
- title: manifest.title,
92
- description: manifest.description,
93
- default: manifest.default === true,
94
- enabled: enabledSet[key] === true,
95
- coreVersion: manifest.coreVersion,
96
- contributes: manifest.contributes || {},
97
- provenance,
98
- author: manifest.author || null,
99
- origin: manifest.origin || null,
100
- license: manifest.license || null,
101
- maintainer: manifest.maintainer || null,
102
- // Upkeep posture (ADR 0107 §7 / ADR 0166) — the catalog's job is to show
103
- // who keeps a module true as the core advances, so a deprecated or
104
- // unowned entry reads as such instead of looking like any other module.
105
- maintenance: manifest.maintenance || null,
106
- };
107
- }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
94
+ const { coreRoot, instanceRoot, coreCheckout } = catalog.detectRoots();
95
+ return catalog.buildCatalog({
96
+ loaded,
97
+ enabled: modulesLib.modules(),
98
+ coreRoot,
99
+ instanceRoot,
100
+ coreCheckout,
101
+ ownProjectName: ownProjectName(),
102
+ });
108
103
  }
109
104
 
110
105
  module.exports = function buildModulesRouter() {
@@ -117,6 +112,10 @@ module.exports = function buildModulesRouter() {
117
112
  modules: all,
118
113
  own_built: all.filter((m) => m.provenance === 'own').length,
119
114
  core_loaded: all.filter((m) => m.provenance === 'core').length,
115
+ // The catalog roll-up (task 1001772): how many modules the core ships that
116
+ // this instance can opt into, and how many it has. Distinct from the
117
+ // own_built/core_loaded pair above, which counts the CREDIT axis.
118
+ catalog: catalog.summarize(all),
120
119
  submit_pipeline_live: true, // task 1770 shipped — POST /modules/:key/submit is real
121
120
  });
122
121
  }));
package/src/module-api.js CHANGED
@@ -625,8 +625,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
625
625
  // landed since 1.19.97 with no explicit bump. run 31749841394. (task 1002620)
626
626
  // 1.19.99 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
627
627
  // landed since 1.19.98 with no explicit bump. run 31750941866. (task 1002620)
628
+ // 1.19.100 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
629
+ // landed since 1.19.99 with no explicit bump. run 31751275973. (task 1002620)
628
630
  // ---------------------------------------------------------------------------
629
- const CORE_VERSION = '1.19.99'; // CI auto-patch carrier (ADR 0161); see changelog above
631
+ const CORE_VERSION = '1.19.100'; // CI auto-patch carrier (ADR 0161); see changelog above
630
632
 
631
633
  // A namespaced logger so a module's log lines are attributable + consistent.
632
634
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,217 @@
1
+ // src/module-loader/catalog.js — the browsable module catalog (ADR 0107 §2/§3,
2
+ // task 1001772, the last task of goal 1000029). The "larger repository of
3
+ // modules built by the sub-projects": every module an instance can enable, each
4
+ // carrying its author + origin credit.
5
+ //
6
+ // Two surfaces read this ONE model so they cannot drift: the hall's Modules tab
7
+ // (GET /api/bongos/modules) and `bongos module list --catalog`. Pure functions
8
+ // only — the fs/config reads live in detectRoots() at the bottom, and every
9
+ // builder below takes its inputs injected so the tests drive it hermetically.
10
+ //
11
+ // ---------------------------------------------------------------------------
12
+ // The two axes, and why they are NOT the same question
13
+ // ---------------------------------------------------------------------------
14
+ //
15
+ // source WHICH TREE does this module ship from — the core package, or
16
+ // this instance's own repo? Structural: it comes from the loader's
17
+ // discovery root (src/module-loader/loader.js scans both, ADR 0108
18
+ // §1). This is the CATALOG axis — "core" means an instance can
19
+ // enable it because the core shipped it.
20
+ //
21
+ // provenance WHO BUILT it — read off the manifest's `origin` string
22
+ // (provenance.js, ADR 0107 §9). This is the CREDIT axis.
23
+ //
24
+ // They were conflated before this file existed: the Modules tab derived
25
+ // "core-loaded" from `origin` alone, so in a split instance every core module
26
+ // (economy, governance, hall-ui…) — none of which declares an `origin`, because
27
+ // none was upstreamed from anywhere — read as "built here". A module's author
28
+ // and the tree it ships from are independent facts; the catalog needs both.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { deriveProvenance } = require('./provenance');
33
+
34
+ // The core's own published + source package names. A checkout whose package.json
35
+ // says one of these IS the core, so its modules/ IS the catalog (see
36
+ // coreCheckout below). Mirrors CORE_PKG in scripts/gds/{init,upgrade,update-channel}.js.
37
+ const CORE_PACKAGE_NAMES = ['@bongos/core', 'cloud-bongos'];
38
+
39
+ // The credit a core-shipped module carries when its manifest declares none.
40
+ // Core's own modules predate the ADR 0107 §2 attribution fields and mostly leave
41
+ // them unset; the honest answer is "the platform authored this", not a blank
42
+ // cell. Callers may override (an instance rebrands nothing here — this names the
43
+ // PLATFORM, not the host instance, and is the same string platform-identity's
44
+ // manifest already carries).
45
+ const CORE_PROJECT_NAME = 'Cloud Bongos';
46
+
47
+ // Is `child` inside `parent`? Path-segment aware, so ".../modules-extra" never
48
+ // counts as inside ".../modules".
49
+ function isInside(child, parent) {
50
+ if (!child || !parent) return false;
51
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
52
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
53
+ }
54
+
55
+ function samePath(a, b) {
56
+ if (!a || !b) return false;
57
+ return path.resolve(a) === path.resolve(b);
58
+ }
59
+
60
+ // Does this checkout's package.json name it as the core itself?
61
+ function isCorePackageName(name) {
62
+ return CORE_PACKAGE_NAMES.includes(String(name || '').trim());
63
+ }
64
+
65
+ // WHICH TREE a discovered module ships from. `dir` is the module directory the
66
+ // loader recorded (…/modules/<key>).
67
+ //
68
+ // The roots are DISTINCT in a split instance (core in node_modules/@bongos/core,
69
+ // the instance at the repo root) — then this is a purely structural read and is
70
+ // exactly right. They COINCIDE in a single checkout, where the same directory is
71
+ // both roots and the structure can no longer answer it; there we fall back to
72
+ // what the checkout says it is: the core repo's modules/ is the catalog, any
73
+ // other checkout's is that instance's own.
74
+ function deriveSource(dir, { coreRoot, instanceRoot, coreCheckout = false } = {}) {
75
+ if (coreRoot && instanceRoot && !samePath(coreRoot, instanceRoot)) {
76
+ if (isInside(dir, path.join(coreRoot, 'modules'))) return 'core';
77
+ return 'instance';
78
+ }
79
+ return coreCheckout ? 'core' : 'instance';
80
+ }
81
+
82
+ // Author + origin credit for one module (ADR 0107 §2 — a portable RECORD, never
83
+ // a cross-GDS foreign key). This is the DISPLAY credit; the declared manifest
84
+ // fields stay untouched beside it on every row, because "what the manifest says"
85
+ // and "what the catalog shows" are different questions and callers need both
86
+ // (tests/modules_route.mjs asserts provenance off the DECLARED origin).
87
+ //
88
+ // `inferred` is true when either value did not come from the manifest, so a
89
+ // surface can mark it — an inferred credit must never read as a claim the author
90
+ // typed, which is the whole point of an attribution record.
91
+ function creditFor(manifest = {}, { source = 'instance', coreProjectName = CORE_PROJECT_NAME } = {}) {
92
+ const declaredAuthor = typeof manifest.author === 'string' && manifest.author.trim() ? manifest.author.trim() : null;
93
+ const declaredOrigin = typeof manifest.origin === 'string' && manifest.origin.trim() ? manifest.origin.trim() : null;
94
+ const fallback = source === 'core' ? coreProjectName : null;
95
+ return {
96
+ author: declaredAuthor || fallback,
97
+ origin: declaredOrigin || fallback,
98
+ inferred: !(declaredAuthor && declaredOrigin) && Boolean(fallback),
99
+ };
100
+ }
101
+
102
+ // One catalog row per discovered module. `loaded` is the loader's
103
+ // discoverModules().loaded ([{ key, dir, manifest }]); `enabled` is the resolved
104
+ // enablement map (src/modules.js modules()).
105
+ function buildCatalog({
106
+ loaded = [],
107
+ enabled = {},
108
+ coreRoot,
109
+ instanceRoot,
110
+ coreCheckout = false,
111
+ coreProjectName = CORE_PROJECT_NAME,
112
+ ownProjectName = null,
113
+ } = {}) {
114
+ return loaded
115
+ .map(({ key, dir, manifest = {} }) => {
116
+ const source = deriveSource(dir, { coreRoot, instanceRoot, coreCheckout });
117
+ const credit = creditFor(manifest, { source, coreProjectName });
118
+ return {
119
+ key,
120
+ title: manifest.title || key,
121
+ description: manifest.description || '',
122
+ source,
123
+ // The credit axis, unchanged from ADR 0107 §9 — kept alongside `source`
124
+ // so the hall's existing own/core-loaded split keeps working verbatim.
125
+ provenance: deriveProvenance(manifest.origin, ownProjectName),
126
+ enabled: enabled[key] === true,
127
+ default: manifest.default === true,
128
+ coreVersion: manifest.coreVersion || null,
129
+ contributes: manifest.contributes || {},
130
+ // DECLARED — exactly what module.json says (null when it says nothing).
131
+ author: manifest.author || null,
132
+ origin: manifest.origin || null,
133
+ // DISPLAY — never blank for a core-shipped module; `inferred` marks it.
134
+ credit,
135
+ license: manifest.license || null,
136
+ maintainer: manifest.maintainer || null,
137
+ maintenance: manifest.maintenance || null,
138
+ };
139
+ })
140
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
141
+ }
142
+
143
+ // The CATALOG proper — the core-shipped modules an instance can opt into
144
+ // (ADR 0107 §6: accepted modules land default-off, every instance may enable).
145
+ function catalogRows(rows = []) {
146
+ return rows.filter((r) => r.source === 'core');
147
+ }
148
+
149
+ // The instance's own modules — the other half of the Modules tab, and the pool
150
+ // `bongos module submit` draws from.
151
+ function instanceRows(rows = []) {
152
+ return rows.filter((r) => r.source === 'instance');
153
+ }
154
+
155
+ // Free-text browse over the fields a builder would actually search by. Empty
156
+ // query returns everything (so a surface can pipe through it unconditionally).
157
+ function searchCatalog(rows = [], query = '') {
158
+ const q = String(query || '').trim().toLowerCase();
159
+ if (!q) return rows;
160
+ return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license]
161
+ .some((f) => f && String(f).toLowerCase().includes(q)));
162
+ }
163
+
164
+ function summarize(rows = []) {
165
+ const core = catalogRows(rows);
166
+ return {
167
+ total: rows.length,
168
+ catalog: core.length,
169
+ instance: rows.length - core.length,
170
+ enabled: rows.filter((r) => r.enabled).length,
171
+ catalog_enabled: core.filter((r) => r.enabled).length,
172
+ };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // The one impure helper — resolve the roots + "is this the core checkout?"
177
+ // ---------------------------------------------------------------------------
178
+
179
+ // Fail-soft by construction: a missing/garbled package.json means "not the core
180
+ // checkout", which degrades the catalog to "everything is this instance's own"
181
+ // rather than crashing a page load or a CLI listing.
182
+ function computeRoots({ coreRoot, instanceRoot } = {}) {
183
+ const cfg = require('../instance-config');
184
+ const core = coreRoot || cfg.resolveCoreRoot();
185
+ // NB resolveInstanceRoot() itself attempts a sync read of bongos.config.json —
186
+ // another reason this whole result is computed once, not per request.
187
+ const instance = instanceRoot || cfg.resolveInstanceRoot();
188
+ let name = null;
189
+ try { name = JSON.parse(fs.readFileSync(path.join(core, 'package.json'), 'utf8')).name; } catch { /* not the core, or unreadable */ }
190
+ return { coreRoot: core, instanceRoot: instance, coreCheckout: isCorePackageName(name), corePackageName: name || null };
191
+ }
192
+
193
+ // Cached singleton — the same lazy-memo + `_reset()` pattern loader.js uses for
194
+ // `discovered()`, and for the same reason: this reads the filesystem, the answer
195
+ // is invariant for the process lifetime (a running server's core root and
196
+ // package identity cannot change under it), and `listModules()` is on the
197
+ // request path for GET /modules. Recomputing two blocking readFileSync calls per
198
+ // request to re-learn a constant is the kind of cost that only shows up under
199
+ // load, so it is paid once at first use.
200
+ //
201
+ // An EXPLICIT root bypasses the cache entirely: a caller passing its own roots
202
+ // is asking about a specific tree, not about this process, and must never get a
203
+ // memoized answer for a different one (nor poison the cache for everyone else).
204
+ let _rootsCache = null;
205
+ function detectRoots({ coreRoot, instanceRoot } = {}) {
206
+ if (coreRoot || instanceRoot) return computeRoots({ coreRoot, instanceRoot });
207
+ return (_rootsCache ||= computeRoots());
208
+ }
209
+
210
+ // Drop the memo — for tests, and for any tool that relocates the roots mid-process.
211
+ function _resetRoots() { _rootsCache = null; }
212
+
213
+ module.exports = {
214
+ buildCatalog, catalogRows, instanceRows, searchCatalog, summarize,
215
+ deriveSource, creditFor, isCorePackageName, detectRoots, _resetRoots,
216
+ CORE_PACKAGE_NAMES, CORE_PROJECT_NAME,
217
+ };