@bongos/core 1.20.77 → 1.20.79

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 (56) hide show
  1. package/.bongos-core.json +83 -58
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  8. package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +70 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +22 -14
  13. package/docs/copy-registry.json +125 -41
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +150 -137
  17. package/docs/recipes/upgrading-the-core.md +11 -2
  18. package/modules/copy-desk/artist-stats.js +15 -1
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +41 -0
  21. package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
  23. package/modules/hall-ui/public/deploy.states.json +1 -1
  24. package/modules/hall-ui/public/profile-crafts.js +231 -0
  25. package/modules/hall-ui/public/profile.css +60 -0
  26. package/modules/hall-ui/public/profile.html +31 -12
  27. package/modules/hall-ui/public/profile.js +180 -75
  28. package/modules/hall-ui/public/profile.states.json +34 -0
  29. package/modules/hall-ui/public/sky-draw.js +66 -39
  30. package/modules/hall-ui/public/sky-interaction.js +1 -1
  31. package/modules/hall-ui/public/sky-panel.js +1 -1
  32. package/modules/hall-ui/public/thinking.css +52 -24
  33. package/modules/hall-ui/records/profile-roles.md +18 -0
  34. package/modules/npm-release/public/work.js +72 -0
  35. package/modules/npm-release/work.js +67 -1
  36. package/modules/provisioning/render-standup.js +2 -1
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +24 -0
  40. package/scripts/gds/init.js +16 -6
  41. package/scripts/gds/update-channel.js +68 -1
  42. package/scripts/gds/update-sweep.js +100 -4
  43. package/scripts/migrate.sh +6 -2
  44. package/src/module-api.js +1 -1
  45. package/tests/hall_sky.mjs +77 -0
  46. package/tests/helpers.mjs +32 -7
  47. package/tests/init.mjs +21 -5
  48. package/tests/migrate_applied_check.mjs +94 -0
  49. package/tests/npm_release_page.mjs +67 -0
  50. package/tests/npm_release_work.mjs +82 -0
  51. package/tests/profile_activity.mjs +23 -15
  52. package/tests/profile_role_headline.mjs +4 -4
  53. package/tests/profile_role_tabs.mjs +163 -0
  54. package/tests/update_channel.mjs +80 -1
  55. package/tests/update_subscription_engine.mjs +99 -0
  56. package/tests/upgrade_migrate.mjs +1 -1
@@ -17,6 +17,16 @@
17
17
  // A MAJOR bump is NEVER automatic under any channel — a major is a breaking change that wants a human.
18
18
  // Prereleases (1.17.2-rc.1) are never auto-target either — only stable releases.
19
19
  //
20
+ // A second, independent axis says WHICH published versions an instance may take at all (task 1004297,
21
+ // ADR 0360) — its FOLLOW:
22
+ // released — only versions at or below the one the registry labels `latest`, i.e. what the owner
23
+ // has released to other projects. DEFAULT, for every project.
24
+ // candidates — every published version, the moment it is published. The platform's own hall, and
25
+ // only it: Bongos runs each version before anyone else is offered it.
26
+ // The channel bounds how FAR a jump may go; the follow bounds which versions exist to jump to. Until
27
+ // candidates publish under their own label (task 1004298), `latest` is the newest version and the two
28
+ // follows pick the same target — the axis exists first so that the split changes nothing for Bongos.
29
+ //
20
30
  // WHY the registry as the source (not vendored tarballs): a subscription needs a LIVE source of
21
31
  // truth for "what's the newest published core?" — that's `npm view @bongos/core versions` against the
22
32
  // private registry (ADR 0134, task 2090). The vendored path is for a one-off manual bump.
@@ -28,6 +38,10 @@ const { spawnSync } = require('node:child_process');
28
38
  const CORE_PKG = '@bongos/core';
29
39
  const VALID_CHANNELS = ['pinned', 'patch', 'minor'];
30
40
  const DEFAULT_CHANNEL = 'patch';
41
+ const VALID_FOLLOWS = ['released', 'candidates'];
42
+ // Unknown or absent reads as `released`: the follow that takes FEWER versions is the safe default,
43
+ // the way an unknown channel reads as `patch` rather than `minor`.
44
+ const DEFAULT_FOLLOW = 'released';
31
45
  const NPM_VIEW_TIMEOUT_MS = 30000;
32
46
  // The subscription manifest an operator edits to opt instances in. Ships EMPTY (instances: []) in the
33
47
  // core template — a fresh instance subscribes nothing until the operator lists a target here.
@@ -109,6 +123,22 @@ function normalizeChannel(channel) {
109
123
  return VALID_CHANNELS.includes(c) ? c : DEFAULT_CHANNEL;
110
124
  }
111
125
 
126
+ function normalizeFollow(follow) {
127
+ const f = String(follow == null ? '' : follow).trim().toLowerCase();
128
+ return VALID_FOLLOWS.includes(f) ? f : DEFAULT_FOLLOW;
129
+ }
130
+
131
+ // The published versions an instance's FOLLOW admits (task 1004297). PURE. `candidates` admits every
132
+ // one; `released` admits those at or below `released` — the version the registry labels `latest` —
133
+ // and NOTHING when that label is unknown: an instance that follows releases must never take a version
134
+ // because nobody could say whether it was released. Feed the result to resolveChannelTarget.
135
+ function versionsForFollow({ follow, available, released } = {}) {
136
+ const list = Array.isArray(available) ? available.filter((v) => typeof v === 'string') : [];
137
+ if (normalizeFollow(follow) === 'candidates') return list;
138
+ if (!parseSemver(released)) return [];
139
+ return list.filter((v) => compareSemver(v, released) <= 0);
140
+ }
141
+
112
142
  // Would `channel` permit an unattended jump from `installed` to `candidate`? True only when candidate
113
143
  // is a STABLE release strictly newer than installed AND within the channel's blast radius. Any invalid
114
144
  // input, an unknown installed version, a prerelease candidate, or a major bump ⇒ false (fail-closed).
@@ -174,6 +204,34 @@ function parseNpmViewVersions(stdout) {
174
204
  return { ok: false, error: 'unexpected npm view shape', versions: [] };
175
205
  }
176
206
 
207
+ // Which version is RELEASED — the one the registry labels `latest` (task 1004297) — via
208
+ // `npm view … dist-tags --json`. A separate read from listAvailableVersions on purpose: that one's
209
+ // output shape is relied on by the deploy door and `bongos upgrade`, and it stays exactly as it is.
210
+ // Fail-SOFT like its sibling: { ok:false, error } on any failure, never a throw. `released` is null
211
+ // unless the label names a stable version.
212
+ function readReleaseTags({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
213
+ let res;
214
+ try {
215
+ res = run('npm', ['view', pkg, 'dist-tags', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs });
216
+ } catch (e) {
217
+ return { ok: false, error: e && e.message ? e.message : String(e), released: null, tags: {} };
218
+ }
219
+ if (!res || res.status !== 0) {
220
+ const reason = (res && (res.stderr || res.error && res.error.message)) || `exit ${res && res.status}`;
221
+ return { ok: false, error: String(reason).trim().split('\n')[0] || 'npm view failed', released: null, tags: {} };
222
+ }
223
+ let tags;
224
+ try { tags = JSON.parse(String(res.stdout || '').trim() || '{}'); } catch (e) {
225
+ return { ok: false, error: `unparseable npm output: ${e.message}`, released: null, tags: {} };
226
+ }
227
+ if (!tags || typeof tags !== 'object' || Array.isArray(tags)) return { ok: false, error: 'unexpected npm view shape', released: null, tags: {} };
228
+ const latest = typeof tags.latest === 'string' ? tags.latest : null;
229
+ const released = latest && isStable(latest) ? latest : null;
230
+ return released
231
+ ? { ok: true, released, tags }
232
+ : { ok: false, error: 'the registry names no released (latest) version', released: null, tags };
233
+ }
234
+
177
235
  // ---- subscription manifest --------------------------------------------------
178
236
 
179
237
  // Read + normalize config/update-subscriptions.json — the operator-edited opt-in list of instances
@@ -191,6 +249,7 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
191
249
  let json;
192
250
  try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, dropped: 0, error: `malformed JSON: ${e.message}`, reason: ROSTER_MALFORMED }; }
193
251
  const defaultChannel = normalizeChannel(json.defaultChannel);
252
+ const defaultFollow = normalizeFollow(json.defaultFollow);
194
253
  const rows = Array.isArray(json.instances) ? json.instances : [];
195
254
  // An entry without a dir can't be upgraded — drop it, but COUNT the drops. Silently discarding an
196
255
  // operator's malformed row is how an instance believes it is enrolled while nothing ever touches it.
@@ -203,6 +262,9 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
203
262
  service: r.service ? String(r.service) : null,
204
263
  healthUrl: r.healthUrl ? String(r.healthUrl) : null,
205
264
  channel: normalizeChannel(r.channel == null ? defaultChannel : r.channel),
265
+ // Which versions this instance takes at all (task 1004297): `released` unless the operator
266
+ // says `candidates` — which only the platform's own hall should.
267
+ follow: normalizeFollow(r.follow == null ? defaultFollow : r.follow),
206
268
  // Deploy topology (task 1003219). Optional, and only meaningful together: with
207
269
  // versionUrl + deployTimer present the sweep can hand the bump to go-live.js and
208
270
  // inherit its backup, pull-deploy-timer guard and INDEPENDENT box-side version
@@ -223,7 +285,7 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
223
285
  : {},
224
286
  }));
225
287
  return {
226
- defaultChannel, instances, source: abs, present: true, dropped,
288
+ defaultChannel, defaultFollow, instances, source: abs, present: true, dropped,
227
289
  reason: instances.length ? ROSTER_OK : ROSTER_EMPTY,
228
290
  };
229
291
  }
@@ -396,6 +458,11 @@ module.exports = {
396
458
  CORE_PKG,
397
459
  VALID_CHANNELS,
398
460
  DEFAULT_CHANNEL,
461
+ VALID_FOLLOWS,
462
+ DEFAULT_FOLLOW,
463
+ normalizeFollow,
464
+ versionsForFollow,
465
+ readReleaseTags,
399
466
  SUBSCRIPTIONS_REL,
400
467
  parseSemver,
401
468
  isStable,
@@ -51,6 +51,13 @@
51
51
  // instance's OWN core says this upgrade rewrites (laneGeneratedPaths). Anything outside that set is
52
52
  // still a human's, and still stops us.
53
53
  //
54
+ // ── WHICH VERSIONS AN INSTANCE TAKES AT ALL (task 1004297, ADR 0360) ───────────────────────────────
55
+ // Each roster entry has a FOLLOW beside its channel. `released` (the default) takes only versions at
56
+ // or below the registry's `latest` label — what the owner has released to other projects; the
57
+ // platform's own hall is set to `candidates` and takes every version as soon as it is published, so
58
+ // Bongos runs each one before anyone else is offered it. An instance that follows releases and whose
59
+ // released label cannot be read is SKIPPED, never upgraded on a guess.
60
+ //
54
61
  // Usage:
55
62
  // node scripts/gds/update-sweep.js # dry-run (plan only)
56
63
  // node scripts/gds/update-sweep.js --apply # actually upgrade
@@ -472,6 +479,83 @@ function preflightFlagsFor(pinMode) {
472
479
 
473
480
  // ── the sweep ────────────────────────────────────────────────────────────────────────────────────
474
481
 
482
+ // ── A VERSION THAT FAILED HERE IS NOT RETRIED EVERY SWEEP (task 1004297, ADR 0360 D4) ─────────────
483
+ // At a daily cadence a version that failed its health check was retried once a day. At the 15-minute
484
+ // cadence the platform now runs, the same version would be installed, fail, restart the service and
485
+ // roll back every quarter of an hour until something newer was published — an outage generator. So a
486
+ // version whose upgrade failed here is HELD for a day for that instance: the sweep skips it and says
487
+ // so, and any NEWER version is taken at once (it is the fix). The hold expires so a transient failure
488
+ // (a registry blip, a full disk since cleared) is retried rather than refused forever.
489
+ //
490
+ // The record lives in the sweep's own config home, never in an instance's tree: a file written there
491
+ // would be "a human's work" to pinState and wedge the lane it exists to protect.
492
+ const REFUSAL_HOLD_MS = 24 * 60 * 60 * 1000;
493
+ const REFUSALS_FILE = 'core-update-refused.json';
494
+
495
+ function refusalsPath() {
496
+ try { return path.join(require('../../src/instance-config.js').configHome(), REFUSALS_FILE); } catch { return null; }
497
+ }
498
+
499
+ function readRefusals(file, readFile = fs.readFileSync) {
500
+ if (!file) return {};
501
+ try {
502
+ const j = JSON.parse(readFile(file, 'utf8'));
503
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
504
+ } catch { return {}; }
505
+ }
506
+
507
+ // The versions held for `slug` at `now`: a Map version → { at, reason }, only those still inside
508
+ // the hold. PURE.
509
+ function heldVersions(refusals, slug, now) {
510
+ const out = new Map();
511
+ const rows = refusals && Array.isArray(refusals[slug]) ? refusals[slug] : [];
512
+ for (const r of rows) {
513
+ const at = r && Date.parse(r.at);
514
+ if (r && typeof r.version === 'string' && Number.isFinite(at) && now - at < REFUSAL_HOLD_MS) out.set(r.version, { at: r.at, reason: r.reason || null });
515
+ }
516
+ return out;
517
+ }
518
+
519
+ // Record that `version` failed on `slug`. Keeps only rows still inside the hold, so the file cannot
520
+ // grow without bound. Best-effort: a record that cannot be written costs one retry, never the sweep.
521
+ function recordRefusal({ file, slug, version, reason, now = Date.now(), readFile = fs.readFileSync, writeFile = fs.writeFileSync, mkdir = fs.mkdirSync } = {}) {
522
+ if (!file || !slug || !version) return false;
523
+ try {
524
+ const all = readRefusals(file, readFile);
525
+ const keep = [...heldVersions(all, slug, now)].filter(([v]) => v !== version).map(([v, x]) => ({ version: v, at: x.at, reason: x.reason }));
526
+ all[slug] = [...keep, { version, at: new Date(now).toISOString(), reason: reason || null }];
527
+ mkdir(path.dirname(file), { recursive: true });
528
+ writeFile(file, JSON.stringify(all, null, 2) + '\n');
529
+ return true;
530
+ } catch { return false; }
531
+ }
532
+
533
+ // What this instance should move to, and under which follow (task 1004297, ADR 0360). The channel
534
+ // bounds how far a jump may go; the follow bounds which published versions exist to jump to at all.
535
+ //
536
+ // `inst.follow` is absent only when the core this sweep resolved predates the axis. That core also
537
+ // predates any split between candidates and releases, so every published version IS a release and
538
+ // the old reading — all of them — is the right one. With the axis present, `released` asks the
539
+ // registry which version carries the `latest` label and SKIPS the instance when it cannot say: an
540
+ // instance that follows releases is never upgraded on a guess.
541
+ //
542
+ // Returns { follow, target } (target null = up to date) or { follow, skip: '<why>' }.
543
+ function followTarget({ inst, installed, channel, versions, env, held = new Map() }) {
544
+ const follow = inst.follow || 'candidates';
545
+ let admitted = versions;
546
+ if (follow === 'released' && typeof channel.readReleaseTags === 'function') {
547
+ const tags = channel.readReleaseTags({ cwd: inst.dir, env });
548
+ if (!tags.ok) return { follow, skip: `could not read which version is released (${tags.error})` };
549
+ admitted = channel.versionsForFollow({ follow, available: admitted, released: tags.released });
550
+ }
551
+ const pick = (list) => channel.resolveChannelTarget({ installed, channel: inst.channel, available: list }) || null;
552
+ const target = pick(admitted.filter((v) => !held.has(v)));
553
+ // A hold is named only when it is what changed the answer: the version this instance would have
554
+ // taken without it.
555
+ const unheld = pick(admitted);
556
+ return { follow, target, held: unheld && unheld !== target && held.has(unheld) ? { version: unheld, ...held.get(unheld) } : null };
557
+ }
558
+
475
559
  // `instanceRoot` is the default roster root when no --instance is given: the subscribe.js shim passes
476
560
  // its own instance root; run directly, it is the working directory.
477
561
  function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
@@ -503,6 +587,8 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
503
587
  if (only && roster.length === 0) console.error(` ! --only ${only} matched no subscribed instance`);
504
588
 
505
589
  let upgraded = 0, skipped = 0, failed = 0, upToDate = 0, healed = 0;
590
+ const refusalsFile = refusalsPath();
591
+ const refusals = readRefusals(refusalsFile);
506
592
 
507
593
  for (const inst of roster) {
508
594
  const label = inst.slug;
@@ -574,8 +660,12 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
574
660
  const listed = channel.listAvailableVersions({ cwd: inst.dir, env: { ...process.env, ...inst.env } });
575
661
  if (!listed.ok) { console.error(` ! ${label}: could not list registry versions (${listed.error}) — skipped`); skipped++; continue; }
576
662
 
577
- const target = channel.resolveChannelTarget({ installed, channel: inst.channel, available: listed.versions });
578
- if (!target) { console.log(` ✓ ${label}: up to date on ${installed} (channel: ${inst.channel})`); upToDate++; continue; }
663
+ const aim = followTarget({ inst, installed, channel, versions: listed.versions, env: { ...process.env, ...inst.env }, held: heldVersions(refusals, label, Date.now()) });
664
+ if (aim.skip) { console.error(` ! ${label}: ${aim.skip} — skipped`); skipped++; continue; }
665
+ if (aim.held) console.log(` • ${label}: ${aim.held.version} failed here at ${aim.held.at} — held for a day, or until a newer version is published`);
666
+ const target = aim.target;
667
+ const pace = `channel: ${inst.channel}, follows: ${aim.follow}`;
668
+ if (!target) { console.log(` ✓ ${label}: up to date on ${installed} (${pace})`); upToDate++; continue; }
579
669
 
580
670
  const { script: upgradeScript, source: scriptSource } = resolveUpgradeScript(inst.dir, { fallback: sweepingCoreUpgradeScript });
581
671
  if (!upgradeScript) { console.error(` ! ${label}: no upgrade.js resolvable for this instance — skipped`); skipped++; continue; }
@@ -590,7 +680,7 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
590
680
  // promise a policy the real sweep will not use (task 1004122). It read `canCommitPin` alone
591
681
  // before, and so said "--commit-pin" for a co-tenant that was never going to get it.
592
682
  const dryPinFlags = [...pinFlagsFor({ pinMode, canCommitPin, canPinNoPush }), ...preflightFlagsFor(pinMode)];
593
- console.log(` → ${label}: would upgrade ${installed} → ${target} (channel: ${inst.channel}, registry, via the ${scriptSource} core${dryPinFlags.length ? `, ${dryPinFlags.join(' ')}` : ''})`);
683
+ console.log(` → ${label}: would upgrade ${installed} → ${target} (${pace}, registry, via the ${scriptSource} core${dryPinFlags.length ? `, ${dryPinFlags.join(' ')}` : ''})`);
594
684
  console.log(` engine: ${dryEngine}`);
595
685
  continue;
596
686
  }
@@ -656,6 +746,12 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
656
746
 
657
747
  if (res.status !== 0) {
658
748
  console.error(` ✖ ${label}: upgrade to ${target} exited ${res.status == null ? '(signal)' : res.status} — see output above (auto-rollback runs on install/migrate/health failure)`);
749
+ // Hold the version unless it actually went in (a failure AFTER a good install, such as an
750
+ // unpushed pin, is not the version's fault and must not stop it being retried).
751
+ if (readInstalledCoreVersion(inst.dir) !== target) {
752
+ const held = recordRefusal({ file: refusalsFile, slug: label, version: target, reason: `exit ${res.status == null ? 'signal' : res.status}` });
753
+ console.error(` ${held ? `${target} is held for this instance for a day; a newer version is still taken at once.` : 'could not record the hold — the next sweep will try this version again.'}`);
754
+ }
659
755
  // A rollback restores the OLD pin, which can itself leave the tree dirty. Tidy it so one bad
660
756
  // patch does not wedge every future sweep.
661
757
  const after = pinState(inst.dir, spawnSync, generatedAfter);
@@ -714,4 +810,4 @@ if (require.main === module) {
714
810
  catch (e) { console.error(`core-update-subscription: fatal — ${e && e.message ? e.message : e}`); process.exit(1); }
715
811
  }
716
812
 
717
- module.exports = { main, coreModulePathFrom, CORE_PKG_NAMES, pinState, laneGeneratedPaths, commitPin, preflightFlagsFor, resolveUpgradeScript, supportsCommitPin, supportsPinNoPush, pinFlagsFor, goLiveTargetFor, resolveGoLive, goLiveEngineFor, needsCoTenantMode, pinModeOf, healDecision, rawTopologyFor, PIN_FILES, PIN_MODES, GO_LIVE_REQUIRED };
813
+ module.exports = { main, followTarget, heldVersions, recordRefusal, readRefusals, REFUSAL_HOLD_MS, coreModulePathFrom, CORE_PKG_NAMES, pinState, laneGeneratedPaths, commitPin, preflightFlagsFor, resolveUpgradeScript, supportsCommitPin, supportsPinNoPush, pinFlagsFor, goLiveTargetFor, resolveGoLive, goLiveEngineFor, needsCoTenantMode, pinModeOf, healDecision, rawTopologyFor, PIN_FILES, PIN_MODES, GO_LIVE_REQUIRED };
@@ -218,7 +218,11 @@ apply_migration() {
218
218
  MIGRATIONS_APPLIED=$((MIGRATIONS_APPLIED + 1))
219
219
  return
220
220
  fi
221
- if echo "$APPLIED" | grep -q "^${version}$"; then
221
+ # NO PIPE (task 1004488). Under pipefail, `echo "$APPLIED" | grep -q` fails whenever grep -q
222
+ # matches and exits before echo has written the whole list (echo dies of SIGPIPE), so an applied
223
+ # migration read as NOT applied and was re-run: a random handful per deploy on the droplet.
224
+ # A here-string has no writer to kill. -x whole line, -F literal: a stem is not a pattern.
225
+ if grep -qxF -- "$version" <<< "$APPLIED"; then
222
226
  echo "skip $version (already applied)"
223
227
  MIGRATIONS_ALREADY=$((MIGRATIONS_ALREADY + 1))
224
228
  return
@@ -343,7 +347,7 @@ for root in "$CORE_ROOT" "$INSTANCE_ROOT"; do
343
347
  mig_dir="${mod_dir}migrations"
344
348
  [ -d "$mig_dir" ] || continue
345
349
  # Check if this module is enabled
346
- if ! echo " $NODE_ENABLED_MODULES " | grep -q " $mod_key "; then
350
+ if ! grep -qF -- " $mod_key " <<< " $NODE_ENABLED_MODULES "; then
347
351
  echo "module $mod_key disabled — skipping ${mig_dir}/*.sql"
348
352
  continue
349
353
  fi
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.77'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.79'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -513,4 +513,81 @@ await test('a ring carries its UAT state in the goal page words, and says it on
513
513
  assert.equal((src.match(/esc\(c\.uatLabel\)/g) || []).length, 2, 'the hover card and the panel both print the label, escaped');
514
514
  });
515
515
 
516
+ // ═══ light mode (task 1004500) ════════════════════════════════════════════════
517
+
518
+ // The sky follows the hall's theme. Dark is the template's set on the stage;
519
+ // light overrides it, written twice (html[data-theme] once shell.js runs, the
520
+ // media query for first paint) — the pair style.css keeps for its dark tier,
521
+ // and the same drift risk, so the same lockstep.
522
+ const SKY_CSS = FS.readFileSync('modules/hall-ui/public/thinking.css', 'utf8').replace(/\r\n/g, '\n');
523
+ const declLines = (body) => body.split('\n').map((l) => l.trim()).filter(Boolean);
524
+ const declNames = (body) => new Set([...body.matchAll(/(--[\w-]+|color-scheme)\s*:/g)].map((m) => m[1]));
525
+ const skyBlocks = () => {
526
+ const dark = /^body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\}/m.exec(SKY_CSS);
527
+ const light = /^html\[data-theme="light"\] body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\}/m.exec(SKY_CSS);
528
+ const twin = /@media \(prefers-color-scheme: light\) \{\n\s*html:not\(\[data-theme\]\) body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\s*\}\n\}/.exec(SKY_CSS);
529
+ return { dark: dark && dark[1], light: light && light[1], twin: twin && twin[1] };
530
+ };
531
+
532
+ await test('LIGHT IS WRITTEN TWICE AND THE TWO ARE ONE — the data-theme block and its first-paint twin declare the same lines', () => {
533
+ const { dark, light, twin } = skyBlocks();
534
+ assert.ok(dark, 'the dark (template) token block is found');
535
+ assert.ok(light, 'the html[data-theme="light"] block is found');
536
+ assert.ok(twin, 'the prefers-color-scheme: light twin is found');
537
+ same(declLines(twin), declLines(light), 'the twin is the data-theme block, line for line');
538
+ assert.ok(declLines(light).length >= 6, 'and the block is not vacuously empty');
539
+ });
540
+
541
+ await test('light re-declares every colour the dark set declares, and every channel is three bytes', () => {
542
+ const { dark, light } = skyBlocks();
543
+ const type = new Set(['--label', '--mono', '--face']); // the faces are not a theme
544
+ const want = [...declNames(dark)].filter((n) => !type.has(n));
545
+ assert.ok(want.length >= 15, `the dark set names its colours (${want.length})`);
546
+ const have = declNames(light);
547
+ same(want.filter((n) => !have.has(n)), [], 'no dark colour is left for light to inherit');
548
+ for (const [name, body] of [['dark', dark], ['light', light]]) {
549
+ const chans = [...body.matchAll(/(--sky-[\w-]+-rgb)\s*:\s*([^;]+);/g)];
550
+ assert.ok(chans.length >= 5, `${name} declares the canvas's channels`);
551
+ for (const [, n, v] of chans) {
552
+ const parts = v.split(',').map((x) => Number(x.trim()));
553
+ assert.ok(parts.length === 3 && parts.every((x) => Number.isInteger(x) && x >= 0 && x <= 255), `${name} ${n} is r,g,b (got "${v}")`);
554
+ }
555
+ }
556
+ });
557
+
558
+ // What the stage's computed style answers in each theme: the two blocks above,
559
+ // read the way the browser would.
560
+ const stageStyles = (body) => Object.fromEntries([...body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
561
+
562
+ await test('THE CANVAS FOLLOWS THE TOGGLE — a theme flip re-reads the stage and repaints, with no reload', async () => {
563
+ const { dark, light } = skyBlocks();
564
+ const styles = stageStyles(dark);
565
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions(), styles });
566
+ const SKY = p.SKY(); const acc = SKY.ACC;
567
+ assert.ok(p.themeObservers() >= 1, 'the page watches html[data-theme]');
568
+ p.frame();
569
+ same(SKY.INK, [255, 255, 255], 'dark: the ink is the template white');
570
+ same(SKY.VOID, [0, 0, 0], 'dark: the void is black');
571
+ same(SKY.tone([255, 238, 222]), [255, 238, 222], 'dark: a thought keeps its temperature');
572
+
573
+ Object.assign(styles, stageStyles(light));
574
+ p.S().dirty = false;
575
+ p.setTheme('light');
576
+ assert.equal(!!p.S().dirty, true, 'the flip marks the sky for a redraw');
577
+ p.frame();
578
+ same(SKY.INK, stageStyles(light)['--sky-ink-rgb'].split(',').map(Number), 'light: the ink is the stage\'s');
579
+ same(SKY.VOID, stageStyles(light)['--sky-void-rgb'].split(',').map(Number), 'light: the void is paper');
580
+ same(SKY.ACC, stageStyles(light)['--sky-accent-rgb'].split(',').map(Number), 'light: the accent darkens');
581
+ assert.equal(SKY.ACC === acc, true, 'refilled in place, so SKY.ACC is still the object every file holds');
582
+ const k = Number(stageStyles(light)['--sky-temp']);
583
+ same(SKY.tone([255, 238, 222]), [255, 238, 222].map((v) => Math.round(v * k)), 'light: a near-white thought darkens to read on paper');
584
+ assert.equal(SKY.ink(0.5), `rgba(${SKY.INK.join(',')},0.5)`, 'ink() paints in the new ink');
585
+
586
+ Object.assign(styles, stageStyles(dark));
587
+ p.setTheme('dark');
588
+ p.frame();
589
+ same(SKY.INK, [255, 255, 255], 'and back: the white returns');
590
+ assert.equal(SKY.ink(0.5), 'rgba(255,255,255,0.5)', 'ink() forgot the light strings');
591
+ });
592
+
516
593
  summary();
package/tests/helpers.mjs CHANGED
@@ -518,9 +518,17 @@ export function shallowCloneReason(root = ROOT) {
518
518
  // each caller — is the /inbox/by-builder/ payload to answer with.
519
519
  const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
520
520
  const PROFILE_ROLES_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
521
+ // The Activity tabs draw from these two (task 1004435), loaded first as the page does.
522
+ const IDEA_OBJECTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea-objects.js'), 'utf8');
523
+ const PROFILE_CRAFTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-crafts.js'), 'utf8');
521
524
  // Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
522
525
  // ledger's rowHtml, and return what it rendered plus every request it made.
523
- export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
526
+ //
527
+ // `roles` is the profile's roles.shown, main first (task 1004435): one Activity
528
+ // tab each. The default leads with the ideator, so the trail is the first tab
529
+ // and a suite that reads it needs no switch; switchCraft('engineer') opens the
530
+ // works and goals, switchCraft('artist') the approved pages.
531
+ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator', 'engineer'], artistPages = [] } = {}) {
524
532
  const els = new Map();
525
533
  const makeEl = () => ({
526
534
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
@@ -551,12 +559,15 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
551
559
  : { status: 401, ok: false, data: null };
552
560
  }
553
561
  if (/\/builders\/\d+\/profile$/.test(url)) {
554
- return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
562
+ return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [], roles: { main: roles[0], shown: roles } } };
555
563
  }
556
564
  if (url.includes('/activity?')) {
557
565
  const isGoals = url.includes('section=goals');
558
566
  return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
559
567
  }
568
+ if (url.includes('/copy-desk/artists/')) {
569
+ return { status: 200, ok: true, data: { rows: artistPages, total: artistPages.length } };
570
+ }
560
571
  if (url.includes('/inbox/by-builder/')) {
561
572
  return { status: 200, ok: true, data: { ideas: trail, total: trail.length } };
562
573
  }
@@ -589,7 +600,7 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
589
600
  let current = tabs[0].id;
590
601
  tabsOnChange = onChange;
591
602
  onChange(current);
592
- return { active: () => current, activate(id) { current = id; onChange(id); } };
603
+ return { active: () => current, activate(id) { current = id; onChange(id); }, setCount() {} };
593
604
  },
594
605
  shipVisualThumbHtml: () => '',
595
606
  };
@@ -616,17 +627,20 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
616
627
  location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
617
628
  localStorage: { getItem: () => null, setItem() {} },
618
629
  addEventListener() {},
630
+ matchMedia: () => ({ matches: false }),
619
631
  };
620
632
 
621
633
  const sandbox = {
622
634
  window: windowObj, document: documentObj, location: windowObj.location,
623
- console, setTimeout, clearTimeout, URLSearchParams,
635
+ console, setTimeout, clearTimeout, URLSearchParams, matchMedia: windowObj.matchMedia,
624
636
  };
625
637
  sandbox.globalThis = sandbox;
626
638
  vm.createContext(sandbox);
627
- // profile.js draws the header from profile-roles.js (task 1004434), which the
628
- // page loads first.
639
+ // profile.js draws the header from profile-roles.js (task 1004434) and the
640
+ // Activity tabs from profile-crafts.js (task 1004435), which the page loads first.
629
641
  vm.runInContext(PROFILE_ROLES_JS, sandbox, { filename: 'profile-roles.js' });
642
+ vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
643
+ vm.runInContext(PROFILE_CRAFTS_JS, sandbox, { filename: 'profile-crafts.js' });
630
644
  vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
631
645
 
632
646
  const htmlFor = (mountId) => {
@@ -789,6 +803,7 @@ export function bootSkyPage({
789
803
  mine = null, sky = null, versions = null, me = null, template = undefined,
790
804
  viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
791
805
  innerWidth = 1280, innerHeight = 800, reduce = true, page = 'thinking',
806
+ styles = null,
792
807
  } = {}) {
793
808
  // ── the DOM ──────────────────────────────────────────────────────────────
794
809
  const windowListeners = new Map();
@@ -927,14 +942,22 @@ export function bootSkyPage({
927
942
  clearTimeout: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
928
943
  };
929
944
  class Event { constructor(type) { this.type = type; } preventDefault() {} }
945
+ // The theme seam (task 1004500): `styles` is the stage's computed custom
946
+ // properties (mutable, so a test can change them before a flip), and every
947
+ // MutationObserver the page makes is kept so `setTheme` can fire it the way
948
+ // shell.js writing html[data-theme] would. No `styles` means no
949
+ // getComputedStyle at all, the sandbox every other case has always run in.
950
+ const themeObservers = [];
951
+ class MutationObserver { constructor(fn) { this.fn = fn; } observe(target, opts) { themeObservers.push({ fn: this.fn, target, opts }); } disconnect() {} }
930
952
 
931
953
  const sandbox = {
932
954
  window: windowObj, document: documentObj, location: windowObj.location, localStorage: windowObj.localStorage,
933
955
  innerWidth, innerHeight, devicePixelRatio: 1, matchMedia: windowObj.matchMedia, performance: windowObj.performance,
934
956
  requestAnimationFrame: windowObj.requestAnimationFrame, addEventListener: windowObj.addEventListener,
935
957
  setTimeout: windowObj.setTimeout, clearTimeout: windowObj.clearTimeout,
936
- console, Event, URLSearchParams, Math, Number, Date, JSON, Map, Set, Promise, Object, Array, String, Infinity, NaN,
958
+ MutationObserver, console, Event, URLSearchParams, Math, Number, Date, JSON, Map, Set, Promise, Object, Array, String, Infinity, NaN,
937
959
  };
960
+ if (styles) sandbox.getComputedStyle = (el) => ({ getPropertyValue: (name) => (el && String(el.className || '').split(/\s+/).includes('sky-stage') && name in styles ? styles[name] : '') });
938
961
  sandbox.globalThis = sandbox;
939
962
  vm.createContext(sandbox);
940
963
  for (const [name, src] of SKY_SRC) vm.runInContext(src, sandbox, { filename: name });
@@ -970,6 +993,8 @@ export function bootSkyPage({
970
993
  submit: () => { byId('file').fire('submit', new Event('submit')); },
971
994
  click: (id) => { const el = byId(id); if (!el) throw new Error(`no element #${id}`); el.fire('click', new Event('click')); },
972
995
  frame: () => { if (raf) raf(Date.now()); },
996
+ setTheme: (t) => { documentObj.documentElement.dataset.theme = t; for (const o of themeObservers) if (o.target === documentObj.documentElement && (!o.opts.attributeFilter || o.opts.attributeFilter.includes('data-theme'))) o.fn([{ type: 'attributes', attributeName: 'data-theme' }]); },
997
+ themeObservers: () => themeObservers.length,
973
998
  ready: () => root.dataset.skyReady === '1',
974
999
  };
975
1000
  return out;
package/tests/init.mjs CHANGED
@@ -389,15 +389,18 @@ test('layerPackageJson: no package.json → creates one; second run → present,
389
389
  // ---- greenfield installability: package.json minting + writeConfigs (ADR 0108 gap 1/4, task 2053) ----
390
390
  // PURE — no fs, no npm. The imperative vendor + `npm install` lockfile step lives in main()
391
391
  // and is deliberately NOT unit-tested (keeps real npm out of the suite, per the task).
392
- test('buildInstancePackageJson: no package.json → mints a private one with the caret core dep + start/migrate scripts', () => {
392
+ test('buildInstancePackageJson: no package.json → mints a private one with the caret core dep + hall/migrate scripts', () => {
393
393
  const pkg = init.buildInstancePackageJson(null, { name: 'demo' });
394
394
  assert.equal(pkg.name, 'demo');
395
395
  assert.equal(pkg.private, true);
396
396
  assert.equal(pkg.dependencies[init.CORE_PKG], init.coreDepRange()); // caret when no tarball vendored
397
- assert.equal(pkg.scripts.start, init.INSTANCE_START_SCRIPT);
397
+ assert.equal(pkg.scripts.hall, init.INSTANCE_HALL_SCRIPT);
398
398
  assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT);
399
- // the start/migrate scripts must target the installed core package so `npm ci` output is runnable
400
- assert.match(pkg.scripts.start, /node_modules\/@bongos\/core\/src\/platform-server\.js$/);
399
+ // `npm start` is left for the project's OWN app (task 1004484): a host like Render reads it
400
+ // as "how this app starts", and the hall there has no database and times out.
401
+ assert.equal(pkg.scripts.start, undefined);
402
+ // the hall/migrate scripts must target the installed core package so `npm ci` output is runnable
403
+ assert.match(pkg.scripts.hall, /node_modules\/@bongos\/core\/src\/platform-server\.js$/);
401
404
  assert.match(pkg.scripts.migrate, /node_modules\/@bongos\/core\/scripts\/migrate\.sh$/);
402
405
  });
403
406
 
@@ -419,6 +422,18 @@ test('buildInstancePackageJson: preserves existing fields + never clobbers a cus
419
422
  assert.equal(pkg.dependencies[init.CORE_PKG], 'file:vendor/bongos-core-1.17.0.tgz'); // dep (re)pinned
420
423
  });
421
424
 
425
+ test('buildInstancePackageJson: a start that is exactly the old scaffolded hall command moves to "hall" (task 1004484)', () => {
426
+ const old = 'node node_modules/@bongos/core/src/platform-server.js';
427
+ const pkg = init.buildInstancePackageJson({ scripts: { start: old, test: 'node --test' } }, { name: 'demo' });
428
+ assert.equal(pkg.scripts.start, undefined, 'npm start no longer runs the hall');
429
+ assert.equal(pkg.scripts.hall, old);
430
+ assert.equal(pkg.scripts.test, 'node --test');
431
+ // an owner's own "hall" script is never overwritten by the move
432
+ const kept = init.buildInstancePackageJson({ scripts: { start: old, hall: 'node my-hall.js' } }, { name: 'demo' });
433
+ assert.equal(kept.scripts.hall, 'node my-hall.js');
434
+ assert.equal(kept.scripts.start, old, 'with "hall" taken, start is left as the owner has it');
435
+ });
436
+
422
437
  // --- enabled-module dep composition (ADR 0138 / task 2160) --------------------
423
438
 
424
439
  test('enabledModuleDeps: merges only the ENABLED modules’ declared deps', () => {
@@ -475,7 +490,8 @@ test('writeConfigs (greenfield): mints package.json with the caret core dep + st
475
490
  assert.equal(pkg.name, 'cloud-bongos'); // slugified productName
476
491
  assert.equal(pkg.private, true);
477
492
  assert.equal(pkg.dependencies[init.CORE_PKG], init.coreDepRange());
478
- assert.equal(pkg.scripts.start, init.INSTANCE_START_SCRIPT);
493
+ assert.equal(pkg.scripts.hall, init.INSTANCE_HALL_SCRIPT);
494
+ assert.equal(pkg.scripts.start, undefined);
479
495
  assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT);
480
496
  } finally {
481
497
  rmSync(dir, { recursive: true, force: true });