@bongos/core 1.19.98 → 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.
- package/.bongos-core.json +38 -23
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0167-module-catalog-source-vs-provenance.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -0
- package/docs/file-map.md +2 -0
- package/docs/modules-contract.md +2 -0
- package/modules/hall-ui/public/modules.css +30 -1
- package/modules/hall-ui/public/modules.html +24 -13
- package/modules/hall-ui/public/modules.js +86 -25
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/module.js +135 -1
- package/src/bongos/routes/modules.js +23 -24
- package/src/module-api.js +5 -1
- package/src/module-loader/catalog.js +217 -0
- package/src/platform-server.js +47 -1
- package/tests/module_catalog.mjs +304 -0
- package/tests/public_landing_projects.mjs +59 -0
package/scripts/gds/module.js
CHANGED
|
@@ -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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
@@ -623,8 +623,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
623
623
|
// landed since 1.19.96 with no explicit bump. run 31749223878. (task 1002620)
|
|
624
624
|
// 1.19.98 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
625
625
|
// landed since 1.19.97 with no explicit bump. run 31749841394. (task 1002620)
|
|
626
|
+
// 1.19.99 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
626
630
|
// ---------------------------------------------------------------------------
|
|
627
|
-
const CORE_VERSION = '1.19.
|
|
631
|
+
const CORE_VERSION = '1.19.100'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
628
632
|
|
|
629
633
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
630
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
|
+
};
|
package/src/platform-server.js
CHANGED
|
@@ -60,12 +60,58 @@ const HOST = process.env.HOST || '127.0.0.1';
|
|
|
60
60
|
// branding pack is injected once at boot (theme + window.__BRANDING__), exactly
|
|
61
61
|
// as the hall pages are, so the look + identity come from config, not hardcode.
|
|
62
62
|
const LANDING_DIR = path.join(__dirname, '..', 'public-landing');
|
|
63
|
+
// ── CONTENT-STAMPED STATIC ASSETS (task 1002766) ──
|
|
64
|
+
// The landing HTML is `cf-cache-status: DYNAMIC` and never cached, but the
|
|
65
|
+
// assets it references come back `Cache-Control: public, max-age=14400` — four
|
|
66
|
+
// hours, Cloudflare's default Browser Cache TTL, not a header this app sets. So
|
|
67
|
+
// after a deploy a RETURNING visitor gets brand-new markup styled by a
|
|
68
|
+
// stylesheet up to four hours old: markup whose components that stylesheet has
|
|
69
|
+
// never heard of. Measured live — a cached cosmos.css of 25,693 bytes with no
|
|
70
|
+
// `.shell{` against 42,610 fresh — and again when the project card shipped and
|
|
71
|
+
// rendered as an unstyled block at the foot of the page.
|
|
72
|
+
//
|
|
73
|
+
// A COLD visitor is unaffected, which is exactly what makes it easy to miss:
|
|
74
|
+
// whoever deploys usually reloads in the browser holding the stale copy.
|
|
75
|
+
//
|
|
76
|
+
// Fixed by changing the URL rather than by fighting the header: a content hash
|
|
77
|
+
// means a new core produces a new URL, so there is nothing to invalidate and no
|
|
78
|
+
// Cloudflare purge token to hold (task 1002766 weighed both). Same `?v=<sha8>`
|
|
79
|
+
// shape serve-internal.js already stamps onto the hall's own scripts.
|
|
80
|
+
//
|
|
81
|
+
// Stamped by RESOLUTION, not by a path allow-list: a URL is an asset if it
|
|
82
|
+
// actually names a file under public-landing/. Routes (`/projects`, `/create`)
|
|
83
|
+
// and API paths resolve to nothing and are left alone, so a new asset is
|
|
84
|
+
// stamped the day it is added and nobody has to remember to list it.
|
|
85
|
+
const _assetStamps = new Map();
|
|
86
|
+
function assetStamp(url) {
|
|
87
|
+
if (!_assetStamps.has(url)) {
|
|
88
|
+
let v = '';
|
|
89
|
+
try {
|
|
90
|
+
const abs = path.resolve(LANDING_DIR, '.' + url);
|
|
91
|
+
// never let a template's own text walk out of the landing dir
|
|
92
|
+
if (abs.startsWith(LANDING_DIR + path.sep)) {
|
|
93
|
+
v = require('node:crypto').createHash('sha256')
|
|
94
|
+
.update(fs.readFileSync(abs)).digest('hex').slice(0, 8);
|
|
95
|
+
}
|
|
96
|
+
} catch { v = ''; } // not a file → not an asset → no stamp
|
|
97
|
+
_assetStamps.set(url, v);
|
|
98
|
+
}
|
|
99
|
+
return _assetStamps.get(url);
|
|
100
|
+
}
|
|
101
|
+
const LOCAL_ASSET = /(href|src)="(\/[A-Za-z0-9._~/-]+\.[A-Za-z0-9]+)"/g;
|
|
102
|
+
function stampAssets(html) {
|
|
103
|
+
return String(html).replace(LOCAL_ASSET, (m, attr, url) =>
|
|
104
|
+
assetStamp(url) ? `${attr}="${url}?v=${assetStamp(url)}"` : m);
|
|
105
|
+
}
|
|
106
|
+
|
|
63
107
|
const _landingPages = new Map(); // memoized per file; '' if absent (instance ships no landing)
|
|
64
108
|
function landingPage(file) {
|
|
65
109
|
if (!_landingPages.has(file)) {
|
|
66
110
|
let html = '';
|
|
67
111
|
try {
|
|
68
|
-
|
|
112
|
+
// stamped LAST, so anything a later transform introduces is stamped too
|
|
113
|
+
html = stampAssets(injectBrandingGlobal(stripApexOnly(
|
|
114
|
+
fs.readFileSync(path.join(LANDING_DIR, file), 'utf8'))));
|
|
69
115
|
} catch { /* absent → '' */ }
|
|
70
116
|
_landingPages.set(file, html);
|
|
71
117
|
}
|