@bongos/core 1.21.146 → 1.21.148

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 (68) hide show
  1. package/.bongos-core.json +98 -68
  2. package/bin/bongos.js +5 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +6 -2
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/api/openapi.json +86 -7
  9. package/docs/api-reference.md +3 -2
  10. package/docs/copy-inventory.md +17 -15
  11. package/docs/copy-registry.json +37 -19
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +70 -70
  14. package/modules/builder-settings/core-move-seen.js +41 -0
  15. package/modules/builder-settings/routes/settings.js +22 -0
  16. package/modules/builder-settings/settings.js +5 -0
  17. package/modules/hall-ui/public/atlas.html +1 -1
  18. package/modules/hall-ui/public/blockers.html +1 -1
  19. package/modules/hall-ui/public/board-room.html +1 -1
  20. package/modules/hall-ui/public/city.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/commands.html +1 -1
  23. package/modules/hall-ui/public/copy-desk.html +1 -1
  24. package/modules/hall-ui/public/core-move-notice.js +70 -0
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +1 -1
  28. package/modules/hall-ui/public/fleet.html +1 -1
  29. package/modules/hall-ui/public/gate.html +1 -1
  30. package/modules/hall-ui/public/goal-map.html +1 -1
  31. package/modules/hall-ui/public/goals.html +1 -1
  32. package/modules/hall-ui/public/government.html +1 -1
  33. package/modules/hall-ui/public/idea.html +1 -1
  34. package/modules/hall-ui/public/ideas.html +1 -1
  35. package/modules/hall-ui/public/index.html +1 -1
  36. package/modules/hall-ui/public/modules.html +1 -1
  37. package/modules/hall-ui/public/my-agents.html +1 -1
  38. package/modules/hall-ui/public/primer.html +1 -1
  39. package/modules/hall-ui/public/profile.html +1 -1
  40. package/modules/hall-ui/public/project-settings.html +1 -1
  41. package/modules/hall-ui/public/ranks.html +1 -1
  42. package/modules/hall-ui/public/roadmap.html +1 -1
  43. package/modules/hall-ui/public/roster.html +1 -1
  44. package/modules/hall-ui/public/sessions.html +1 -1
  45. package/modules/hall-ui/public/settings-software-update.js +24 -2
  46. package/modules/hall-ui/public/settings.html +2 -2
  47. package/modules/hall-ui/public/shell.js +8 -0
  48. package/modules/hall-ui/public/street.html +1 -1
  49. package/modules/hall-ui/public/studio.html +1 -1
  50. package/modules/hall-ui/public/style.css +11 -0
  51. package/modules/hall-ui/public/task.html +1 -1
  52. package/modules/hall-ui/public/thinking.html +1 -1
  53. package/modules/hall-ui/public/tweak-editor.html +1 -1
  54. package/modules/hall-ui/public/watch.html +1 -1
  55. package/modules/hall-ui/public/work.html +1 -1
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +12 -0
  59. package/scripts/gds/core-origin-follow.js +185 -0
  60. package/scripts/gds/core-pin-sync.js +31 -5
  61. package/scripts/gds/provision-core-upgrade.js +9 -3
  62. package/src/bongos/core-moves.js +155 -0
  63. package/src/bongos/routes/core-update.js +43 -3
  64. package/src/module-api.js +1 -1
  65. package/tests/core_move_notice.mjs +356 -0
  66. package/tests/core_origin_follow.mjs +287 -0
  67. package/tests/core_pin_sync.mjs +7 -2
  68. package/tests/nav_permission_atoms.mjs +3 -2
@@ -0,0 +1,356 @@
1
+ // tests/core_move_notice.mjs — after the project's core moves, its own hall says so once,
2
+ // with what changed, and Settings → Software update keeps a short history (task 1004731).
3
+ //
4
+ // The move writes one row to THIS project's own core_upgrades ledger (scripts/gds/upgrade.js);
5
+ // the running core carries the release notes for every version up to itself
6
+ // (release-notes.json). src/bongos/core-moves.js reads both back: GET /core-update carries
7
+ // the one-time notice (`moved`) for the banner, GET /software-update the history. The
8
+ // builder's "seen" is server-side, in the builder-settings module's behavior_prefs.
9
+ //
10
+ // Run: node tests/core_move_notice.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const { moveNotice, moveHistory, readInstalledReleaseNotes } = require('../src/bongos/core-moves.js');
22
+ const seen = require('../modules/builder-settings/core-move-seen.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ const t = async (name, fn) => {
27
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
28
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
29
+ };
30
+
31
+ const NOW = Date.parse('2026-10-16T12:00:00.000Z');
32
+ const NOTES = {
33
+ '1.21.20': [{ id: '1', text: 'Old work, already on the project.' }],
34
+ '1.21.21': [{ id: '1004700', text: 'The board shows who is reviewing.' }],
35
+ '1.21.22': [],
36
+ '1.21.23': [{ text: 'Settings remembers your filters.' }, { id: '1004702', text: 'Faster task pages.' }],
37
+ '1.21.24': [{ id: '9', text: 'Beyond this move.' }],
38
+ };
39
+ // The rows upgrade.js writes: a landed move, an automatic one, a door one, a rollback.
40
+ const moved = (over = {}) => ({ id: '41', from_version: '1.21.20', to_version: '1.21.23', note: null, source: null,
41
+ applied_at: '2026-10-15T09:00:00.000Z', ...over });
42
+ const rollback = { id: '40', from_version: '1.21.20', to_version: '1.21.22', source: null, applied_at: '2026-10-14T09:00:00.000Z',
43
+ note: 'rolled_back: migrate failed (attempted 1.21.20 → 1.21.22, restored 1.21.20)' };
44
+
45
+ console.log('the notice after a core move, and the history of moves (task 1004731)');
46
+
47
+ await t('a recorded move renders as a notice: version, date, and the release-note entries between old and new', () => {
48
+ const n = moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, now: NOW });
49
+ assert.equal(n.id, '41');
50
+ assert.equal(n.version, '1.21.23');
51
+ assert.equal(n.from, '1.21.20');
52
+ assert.equal(n.message, 'Upgraded to core 1.21.23 on 15 October 2026.');
53
+ assert.deepEqual(n.entries.map((e) => e.text),
54
+ ['Settings remembers your filters.', 'Faster task pages.', 'The board shows who is reviewing.'],
55
+ 'newest first, only the versions after the old one and up to the new one');
56
+ assert.equal(n.more, 0);
57
+ });
58
+
59
+ await t('an automatic move says so; a move by hand reads the same, without the word', () => {
60
+ assert.equal(moveNotice({ rows: [moved({ note: 'auto-subscription' })], running: '1.21.23', notes: NOTES, now: NOW }).message,
61
+ 'Upgraded automatically to core 1.21.23 on 15 October 2026.');
62
+ const door = moveNotice({ rows: [moved({ source: 'owner-control' })], running: '1.21.23', notes: NOTES, now: NOW });
63
+ assert.equal(door.how, 'by_hand');
64
+ assert.equal(door.message, 'Upgraded to core 1.21.23 on 15 October 2026.');
65
+ });
66
+
67
+ await t('the notice clears once this builder has seen that move, and the next move is news again', () => {
68
+ assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, seenId: '41', now: NOW }), null);
69
+ assert.ok(moveNotice({ rows: [moved({ id: '42' })], running: '1.21.23', notes: NOTES, seenId: '41', now: NOW }));
70
+ });
71
+
72
+ await t('no notice for a rollback, a version this server does not run, or a move months old', () => {
73
+ assert.equal(moveNotice({ rows: [rollback, moved()], running: '1.21.20', notes: NOTES, now: NOW }), null);
74
+ assert.equal(moveNotice({ rows: [moved()], running: '1.21.20', notes: NOTES, now: NOW }), null);
75
+ assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: NOTES, now: NOW + 40 * 86400000 }), null);
76
+ assert.equal(moveNotice({ rows: [], running: '1.21.23', notes: NOTES, now: NOW }), null);
77
+ });
78
+
79
+ await t('notes that cannot be read, or do not cover the move, still give the notice, with no entries', () => {
80
+ const none = moveNotice({ rows: [moved()], running: '1.21.23', notes: null, now: NOW });
81
+ assert.equal(none.entries, null);
82
+ assert.match(none.message, /1\.21\.23/);
83
+ assert.equal(moveNotice({ rows: [moved()], running: '1.21.23', notes: { '1.21.22': [] }, now: NOW }).entries, null);
84
+ });
85
+
86
+ await t('a long range shows a few entries and counts the rest', () => {
87
+ const many = { '1.21.20': [] };
88
+ for (let i = 21; i <= 30; i++) many[`1.21.${i}`] = [{ text: `Change ${i}.` }];
89
+ const n = moveNotice({ rows: [moved({ to_version: '1.21.30' })], running: '1.21.30', notes: many, now: NOW });
90
+ assert.equal(n.entries.length, 6);
91
+ assert.equal(n.more, 4);
92
+ });
93
+
94
+ await t('the history lists every move, newest first, failed ones included, with how and outcome', () => {
95
+ const h = moveHistory([moved({ note: 'auto-subscription' }), rollback,
96
+ { id: '39', from_version: '1.21.19', to_version: '1.21.20', source: 'owner-control', note: null, applied_at: '2026-10-10T09:00:00.000Z' },
97
+ { id: '38', from_version: '1.21.20', to_version: '1.21.19', source: null, note: 'downgrade (--allow-downgrade)', applied_at: '2026-10-09T09:00:00.000Z' }]);
98
+ assert.deepEqual(h.map((m) => [m.version, m.how, m.outcome]), [
99
+ ['1.21.23', 'automatic', 'upgraded'],
100
+ ['1.21.22', 'on_server', 'rolled_back'],
101
+ ['1.21.20', 'by_hand', 'upgraded'],
102
+ ['1.21.19', 'on_server', 'moved_back'],
103
+ ]);
104
+ assert.equal(h[0].at, '2026-10-15T09:00:00.000Z');
105
+ assert.equal(moveHistory(Array.from({ length: 20 }, (_, i) => moved({ id: String(i) }))).length, 8, 'a short history');
106
+ });
107
+
108
+ await t('the running core\'s own release notes are read from its package root; none is null, never a throw', () => {
109
+ const fake = { readFileSync: () => JSON.stringify(NOTES) };
110
+ assert.deepEqual(readInstalledReleaseNotes({ fsImpl: fake, root: '/x' }), NOTES);
111
+ assert.equal(readInstalledReleaseNotes({ fsImpl: { readFileSync: () => { throw new Error('ENOENT'); } }, root: '/x' }), null);
112
+ });
113
+
114
+ // ---- the per-builder seen state (builder-settings module) -------------------------------
115
+
116
+ await t('seen is one ledger id per builder, kept beside the other prefs', () => {
117
+ assert.equal(seen.seenId({}), null);
118
+ assert.equal(seen.seenId(seen.applySeen({ wandering: 'low' }, '41')), '41');
119
+ assert.equal(seen.applySeen({ wandering: 'low' }, '41').wandering, 'low');
120
+ assert.equal(seen.validatePatch({ id: '41' }).id, '41');
121
+ assert.equal(seen.validatePatch({ id: 'x' }).ok, false);
122
+ assert.equal(seen.validatePatch({ id: '41', other: 1 }).ok, false);
123
+ });
124
+
125
+ await t('PATCH /me/core-move-seen stores it for the signed-in builder only, and the port reads it back', async () => {
126
+ const rows = new Map([[1, { wandering: 'low' }]]);
127
+ const db = require('../modules/builder-settings/db.js');
128
+ db.getBuilderBehaviorPrefs = async (id) => rows.get(id) || {};
129
+ db.setBuilderBehaviorPrefs = async (id, prefs) => { rows.set(id, prefs); return prefs; };
130
+ const router = require('../modules/builder-settings/routes/settings.js')();
131
+ const layer = router.stack.find((l) => l.route && l.route.path === '/me/core-move-seen' && l.route.methods.patch);
132
+ assert.ok(layer, 'PATCH /me/core-move-seen is registered');
133
+ const out = { status: 200, body: null };
134
+ const res = { status(s) { out.status = s; return this; }, json(b) { out.body = b; return this; },
135
+ fail(code, a, b) { out.status = typeof a === 'number' ? a : (a && a.status) || 400; out.body = { error: code, ...(b || {}) }; return this; } };
136
+ await layer.route.stack[layer.route.stack.length - 1].handle({ builder: { id: 1 }, body: { id: '41' } }, res);
137
+ assert.equal(out.status, 200);
138
+ assert.deepEqual(out.body, { ok: true, seen: '41' });
139
+ assert.equal(rows.get(1).wandering, 'low', 'sibling prefs survive');
140
+ assert.equal(rows.has(2), false);
141
+ const port = require('../modules/builder-settings/settings.js');
142
+ assert.equal(port.coreMoveSeen(rows.get(1)), '41');
143
+ });
144
+
145
+ // ---- the two reads the hall makes ------------------------------------------------------
146
+
147
+ const { CORE_VERSION } = require('../src/module-api.js');
148
+ const nowMove = (over = {}) => moved({ to_version: CORE_VERSION, from_version: '0.0.1', applied_at: new Date().toISOString(), ...over });
149
+
150
+ async function drive(p, deps) {
151
+ const auth = require('../src/bongos/auth.js');
152
+ const saved = { b: auth.requireBuilder, p: auth.requirePermission };
153
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: '7' }; next(); };
154
+ auth.requirePermission = () => (_req, _res, next) => next();
155
+ const build = require('../src/bongos/routes/core-update.js');
156
+ const express = require('express');
157
+ const app = express();
158
+ try {
159
+ app.use(build({ askDeps: () => ({ hub: null, getSetting: async () => null }), canUpdate: async () => true,
160
+ doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: null }), upgradeHold: async () => null,
161
+ releaseNotes: () => ({ '0.0.1': [], [CORE_VERSION]: [{ text: 'Shown after the move.' }] }), ...deps }));
162
+ } finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
163
+ const server = app.listen(0);
164
+ await new Promise((r) => server.once('listening', r));
165
+ try {
166
+ const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`);
167
+ return { status: res.status, body: await res.json() };
168
+ } finally { server.close(); }
169
+ }
170
+
171
+ await t('GET /core-update carries the notice for this builder, and none once they have seen it', async () => {
172
+ const r = await drive('/core-update', {
173
+ coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
174
+ upgrades: async () => [nowMove()], seenMove: async () => ({ remember: 'server', seen: null }),
175
+ });
176
+ assert.equal(r.status, 200);
177
+ assert.equal(r.body.moved.version, CORE_VERSION);
178
+ assert.equal(r.body.moved.remember, 'server');
179
+ assert.deepEqual(r.body.moved.entries.map((e) => e.text), ['Shown after the move.']);
180
+ const after = await drive('/core-update', {
181
+ coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
182
+ upgrades: async () => [nowMove()], seenMove: async () => ({ remember: 'server', seen: '41' }),
183
+ });
184
+ assert.equal(after.body.moved, null);
185
+ });
186
+
187
+ await t('a ledger that cannot be read is no notice, never a failed banner read', async () => {
188
+ const r = await drive('/core-update', { coreUpdate: async () => ({ reading: 'ok', behind: false }), selfChannel: async () => null,
189
+ upgrades: async () => { throw new Error('no table'); }, seenMove: async () => ({ remember: 'server', seen: null }) });
190
+ assert.equal(r.status, 200);
191
+ assert.equal(r.body.moved, null);
192
+ });
193
+
194
+ await t('GET /software-update carries the history for those who may update, and not for anyone else', async () => {
195
+ const deps = { softwareUpdate: async ({ running }) => ({ reading: 'ok', running, offered: null }),
196
+ upgrades: async () => [nowMove({ note: 'auto-subscription' }), rollback] };
197
+ const r = await drive('/software-update', deps);
198
+ assert.deepEqual(r.body.history.map((m) => [m.how, m.outcome]), [['automatic', 'upgraded'], ['on_server', 'rolled_back']]);
199
+ const other = await drive('/software-update', { ...deps, canUpdate: async () => false });
200
+ assert.equal(other.body.history, null, 'when and to what the core moved is a vulnerability-window fact (ADR 0043)');
201
+ });
202
+
203
+ // ---- the hall draws the notice, and it clears after it is seen --------------------------
204
+
205
+ const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
206
+ // shell.js loads the notice's own file only when there is a notice; the fake <head> runs it.
207
+ const NOTICE = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/core-move-notice.js'), 'utf8');
208
+
209
+ function boot({ data, permissions = { 'core.pin.move': true }, stored = {} } = {}) {
210
+ const inserted = [];
211
+ const requested = [];
212
+ const loaded = [];
213
+ const els = new Map();
214
+ let sandbox = null;
215
+ const makeEl = () => ({
216
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _on: {},
217
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
218
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
219
+ addEventListener(ev, fn) { this._on[ev] = fn; },
220
+ querySelector(sel) { const self = this; return { addEventListener: (e, f) => { (self._q ||= {})[sel] = f; } }; },
221
+ querySelectorAll() { return []; },
222
+ appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() { this._removed = true; },
223
+ insertAdjacentElement(_pos, el) { inserted.push(el); },
224
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
225
+ });
226
+ const documentObj = {
227
+ body: { dataset: { page: 'index' } },
228
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
229
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
230
+ querySelectorAll() { return []; }, createElement() { return makeEl(); },
231
+ addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
232
+ head: { appendChild(tag) { loaded.push(tag.src); vm.runInContext(NOTICE, sandbox, { filename: 'core-move-notice.js' }); tag.onload(); } },
233
+ };
234
+ const location = { hostname: 'h', origin: 'https://h', pathname: '/', hash: '', assign() {}, replace() {} };
235
+ const windowObj = {
236
+ __BRANDING__: {}, __MODULES__: { enabled: { provisioning: false } },
237
+ BongosClient: { createClient: () => ({ request: (method, p, opts) => {
238
+ requested.push({ method, p, body: opts && opts.body });
239
+ if (p === '/api/bongos/core-update') return Promise.resolve({ status: 200, data });
240
+ if (p === '/api/bongos/me/core-move-seen') return Promise.resolve({ status: 200, ok: true, data: { ok: true } });
241
+ return new Promise(() => {});
242
+ } }) },
243
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
244
+ localStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = v; } },
245
+ requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
246
+ };
247
+ sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
248
+ CustomEvent: class { constructor(tp, i) { this.type = tp; Object.assign(this, i); } } };
249
+ sandbox.globalThis = sandbox;
250
+ vm.createContext(sandbox);
251
+ vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
252
+ windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions });
253
+ return { inserted, requested, stored, loaded, notice: () => inserted.find((e) => String(e._html).includes('core-move-notice__list') || String(e._html).includes('Upgraded')) };
254
+ }
255
+ const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); };
256
+
257
+ const recorded = moveNotice({ rows: [moved({ note: 'auto-subscription' })], running: '1.21.23', notes: NOTES, now: NOW });
258
+
259
+ await t('the hall renders the notice from a recorded move, with its release-note entries', async () => {
260
+ const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'server' } } });
261
+ await settle();
262
+ const el = b.notice();
263
+ assert.ok(el, 'the notice is drawn');
264
+ assert.match(el.innerHTML, /Upgraded automatically to core 1\.21\.23 on 15 October 2026\./);
265
+ assert.match(el.innerHTML, /Settings remembers your filters\./);
266
+ assert.match(el.innerHTML, /Faster task pages\./);
267
+ assert.match(el.innerHTML, /href="\/settings#software-update"/);
268
+ assert.equal(b.loaded.length, 1, 'its file is loaded once, only because there is a notice');
269
+ assert.match(b.loaded[0], /^\/builders\/core-move-notice\.js/);
270
+ });
271
+
272
+ await t('no notice, no extra file: a page with nothing to say loads nothing', async () => {
273
+ const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: null } });
274
+ await settle();
275
+ assert.equal(b.loaded.length, 0);
276
+ assert.equal(b.notice(), undefined);
277
+ });
278
+
279
+ await t('the notice clears once seen: closing it tells the server, for this move, and removes it', async () => {
280
+ const b = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'server' } } });
281
+ await settle();
282
+ const el = b.notice();
283
+ el._q['.core-move-notice__close']();
284
+ await settle();
285
+ assert.equal(el._removed, true);
286
+ const patch = b.requested.find((r) => r.method === 'PATCH');
287
+ assert.equal(patch && patch.p, '/api/bongos/me/core-move-seen');
288
+ assert.equal(JSON.stringify(patch.body), JSON.stringify({ id: '41' }));
289
+ // The next page load: the server answers moved:null for this builder.
290
+ const next = boot({ data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: null } });
291
+ await settle();
292
+ assert.equal(next.notice(), undefined);
293
+ });
294
+
295
+ await t('without the server-side store the browser remembers it instead', async () => {
296
+ const stored = {};
297
+ const b = boot({ stored, data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'browser' } } });
298
+ await settle();
299
+ b.notice()._q['.core-move-notice__close']();
300
+ await settle();
301
+ assert.equal(stored['bongos.coreMove.seen'], '41');
302
+ assert.ok(!b.requested.some((r) => r.method === 'PATCH'));
303
+ const again = boot({ stored, data: { ok: true, update: { reading: 'ok', behind: false }, held: null, moved: { ...recorded, remember: 'browser' } } });
304
+ await settle();
305
+ assert.equal(again.notice(), undefined);
306
+ });
307
+
308
+ // ---- Settings → Software update draws the history ---------------------------------------
309
+
310
+ const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
311
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
312
+ async function panel(data) {
313
+ const el = () => ({ hidden: true, textContent: '', _html: '', dataset: {}, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, querySelectorAll: () => [] });
314
+ const els = new Map([['software-update-scroll', el()], ['su-body', el()], ['su-note', el()], ['settings-groups', el()]]);
315
+ els.get('settings-groups').dataset.active = 'update';
316
+ const windowObj = { BongosClient: { createClient: () => ({ request: async () => ({ ok: true, status: 200, data }) }) }, OTB: { escapeHtml }, location: { assign() {} } };
317
+ const sandbox = { window: windowObj, document: { getElementById: (id) => els.get(id) || null, addEventListener() {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
318
+ vm.createContext(sandbox);
319
+ vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
320
+ for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
321
+ return els.get('su-body').innerHTML;
322
+ }
323
+
324
+ await t('Settings → Software update lists the moves: version, date, automatic or by hand, outcome', async () => {
325
+ const history = moveHistory([moved({ note: 'auto-subscription' }), rollback,
326
+ { id: '39', from_version: '1.21.19', to_version: '1.21.20', source: 'owner-control', note: null, applied_at: '2026-10-10T09:00:00.000Z' }]);
327
+ const html = await panel({ ok: true, update: { reading: 'ok', running: '1.21.23', offered: null }, door: { kind: 'cli' }, can_update: true, ask: {}, held: null, history });
328
+ assert.match(html, /Recent updates/);
329
+ assert.match(html, /1\.21\.23/);
330
+ assert.match(html, /15 October 2026/);
331
+ assert.match(html, /Automatic/);
332
+ assert.match(html, /By hand/);
333
+ assert.match(html, /Rolled back/);
334
+ assert.match(html, /Installed/);
335
+ assert.doesNotMatch(html.slice(html.indexOf('Recent updates')), /[—–]/, 'voice rule: no dashes in new copy');
336
+ });
337
+
338
+ await t('no history, no change: the panel draws exactly what it did before', async () => {
339
+ const data = { ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.116', whats_new: null }, door: { kind: 'cli', url: null }, can_update: true, ask: {}, held: null };
340
+ assert.equal(await panel({ ...data, history: null }), await panel(data));
341
+ assert.equal(await panel({ ...data, history: [] }), await panel(data));
342
+ });
343
+
344
+ // ---- the hub's automatic move marks itself in the project's ledger -------------------------
345
+
346
+ await t('the hub\'s automatic move passes the auto-subscription note; a door move and a preview do not', () => {
347
+ const R = require('../scripts/gds/provision-core-upgrade.js');
348
+ const box = { slug: 'toast', port: 4100, hosting_shape: 'cloud-host' };
349
+ const as = { runAs: 'bongos', migrateAs: 'bongos' };
350
+ assert.match(R.upgradeInvocation(box, '1.21.23', { ...as, automatic: true }).cmd, / --note auto-subscription(\s|$)/);
351
+ assert.doesNotMatch(R.upgradeInvocation(box, '1.21.23', { ...as, requestedBy: '42' }).cmd, /--note/);
352
+ assert.doesNotMatch(R.upgradeInvocation(box, '1.21.23', { ...as, automatic: true, dryRun: true }).cmd, /--note/);
353
+ });
354
+
355
+ console.log(`\n${passed} passed, ${failed} failed`);
356
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,287 @@
1
+ // tests/core_origin_follow.mjs — the laptop picks up a core move the hub made (task 1004732)
2
+ //
3
+ // The bug: when the hub moves a hosted project's core it commits the new pin on the box
4
+ // and pushes it to the project's GitHub repo. The owner's laptop checkout never heard of
5
+ // it, so the next session and the next ship ran — and merged against — the old pin.
6
+ //
7
+ // Each case is a REAL temp git setup: a bare "GitHub" origin, a "box" clone that makes
8
+ // the hub's pin commit and pushes it, and a "laptop" clone that is the checkout under
9
+ // test. npm is stubbed (it lays down a fake installed core); git is real.
10
+ //
11
+ // Run: node tests/core_origin_follow.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const follow = require('../scripts/gds/core-origin-follow.js');
22
+ const pinSync = require('../scripts/gds/core-pin-sync.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ function test(name, fn) {
27
+ try { fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
29
+ }
30
+
31
+ function git(cwd, ...args) {
32
+ const r = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' });
33
+ assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
34
+ return r.stdout;
35
+ }
36
+
37
+ const OLD = '1.21.20';
38
+ const NEW = '1.21.115';
39
+
40
+ function writePin(dir, spec, version) {
41
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'toast', private: true, dependencies: { '@bongos/core': spec } }, null, 2) + '\n');
42
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({
43
+ name: 'toast', lockfileVersion: 3,
44
+ packages: { 'node_modules/@bongos/core': { version, resolved: spec.startsWith('file:') ? spec : `https://registry.npmjs.org/@bongos/core/-/core-${version}.tgz` } },
45
+ }, null, 2) + '\n');
46
+ }
47
+
48
+ // A fake installed core in `root`: what npm would leave behind.
49
+ function installCore(root, version) {
50
+ const core = path.join(root, 'node_modules', '@bongos', 'core');
51
+ fs.rmSync(path.join(root, 'node_modules'), { recursive: true, force: true });
52
+ fs.mkdirSync(path.join(core, 'bin'), { recursive: true });
53
+ fs.mkdirSync(path.join(core, 'scripts', 'gds'), { recursive: true });
54
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({ name: '@bongos/core', version }));
55
+ fs.writeFileSync(path.join(core, 'bin', 'bongos.js'), '');
56
+ fs.writeFileSync(path.join(core, 'scripts', 'gds', 'fitness.js'), '');
57
+ return core;
58
+ }
59
+
60
+ // origin (bare) ← box pushes the hub's move; laptop is a clone made BEFORE the move.
61
+ // `move` makes the hub's commit: vendored tgz pin → exact registry pin, tgz removed.
62
+ function setup({ move = true, alsoOnBox = null } = {}) {
63
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'core-origin-follow-'));
64
+ const origin = path.join(base, 'origin.git');
65
+ const box = path.join(base, 'box');
66
+ const laptop = path.join(base, 'laptop');
67
+ git(base, 'init', '-q', '--bare', '-b', 'main', origin);
68
+ fs.mkdirSync(box);
69
+ git(box, 'init', '-q', '-b', 'main');
70
+ writePin(box, `file:vendor/bongos-core-${OLD}.tgz`, OLD);
71
+ fs.mkdirSync(path.join(box, 'vendor'));
72
+ fs.writeFileSync(path.join(box, 'vendor', `bongos-core-${OLD}.tgz`), 'old-tarball');
73
+ fs.writeFileSync(path.join(box, '.gitignore'), 'node_modules/\n');
74
+ git(box, 'add', '-A');
75
+ git(box, 'commit', '-qm', 'init: vendored core');
76
+ git(box, 'remote', 'add', 'origin', origin);
77
+ git(box, 'push', '-q', 'origin', 'main');
78
+ git(base, 'clone', '-q', origin, laptop);
79
+ // The laptop has the old core installed and the untracked, excluded scripts link.
80
+ installCore(laptop, OLD);
81
+ fs.symlinkSync(path.join(laptop, 'node_modules', '@bongos', 'core', 'scripts'), path.join(laptop, 'scripts'), 'junction');
82
+ fs.appendFileSync(path.join(laptop, '.git', 'info', 'exclude'), 'scripts\n');
83
+ if (move) {
84
+ writePin(box, NEW, NEW);
85
+ git(box, 'rm', '-q', `vendor/bongos-core-${OLD}.tgz`);
86
+ if (alsoOnBox) alsoOnBox(box);
87
+ git(box, 'add', '-A');
88
+ git(box, 'commit', '-qm', `core: upgrade @bongos/core to ${NEW}`);
89
+ git(box, 'push', '-q', 'origin', 'main');
90
+ }
91
+ // The core the laptop runs (node_modules/@bongos/core) — the dispatcher's REPO_ROOT.
92
+ const coreRoot = path.join(laptop, 'node_modules', '@bongos', 'core');
93
+ return { base, origin, box, laptop, coreRoot };
94
+ }
95
+
96
+ // git → real; npm → lay down the pinned core; the re-run of bongos → recorded.
97
+ function stubRun(root) {
98
+ const calls = { installs: [], reruns: [], git: 0 };
99
+ const run = (cmd, args, opts) => {
100
+ if (cmd === 'git') { calls.git++; return spawnSync(cmd, args, opts); }
101
+ if (cmd === 'npm' || String(args[0] || '').endsWith('npm-cli.js')) {
102
+ calls.installs.push(cmd === 'npm' ? args : args.slice(1));
103
+ const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
104
+ installCore(root, lock.packages['node_modules/@bongos/core'].version);
105
+ return { status: 0 };
106
+ }
107
+ if (String(args[0] || '').endsWith('bongos.js')) { calls.reruns.push(args.slice(1)); return { status: 0 }; }
108
+ return { status: 1 };
109
+ };
110
+ return { run, calls };
111
+ }
112
+
113
+ const pinOf = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'];
114
+ const head = (dir) => git(dir, 'rev-parse', 'HEAD').trim();
115
+
116
+ test('clean main: says the project moved, fast-forwards, reinstalls, re-runs, scripts link resolves', () => {
117
+ const { laptop, coreRoot } = setup();
118
+ const { run, calls } = stubRun(laptop);
119
+ const logs = [];
120
+ const code = follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s), npmCli: null });
121
+ assert.equal(code, 0, 'the re-run exit code: ' + logs.join('\n'));
122
+ assert.ok(logs.some((l) => l.includes(`upgraded to core ${NEW} on GitHub`) && l.includes(OLD)), logs.join('\n'));
123
+ assert.equal(pinOf(laptop), NEW, 'fast-forwarded onto the hub commit');
124
+ assert.equal(calls.installs.length, 1, 'npm ran once');
125
+ assert.equal(calls.installs[0][0], 'ci');
126
+ assert.deepEqual(calls.reruns, [['start']], 'start re-ran under the new core');
127
+ assert.ok(fs.existsSync(path.join(laptop, 'scripts', 'gds', 'fitness.js')), 'the scripts link still resolves');
128
+ const installed = JSON.parse(fs.readFileSync(path.join(laptop, 'node_modules', '@bongos', 'core', 'package.json'), 'utf8'));
129
+ assert.equal(installed.version, NEW);
130
+ });
131
+
132
+ test('vendored → registry pin: no tgz left referenced after the follow', () => {
133
+ const { laptop, coreRoot } = setup();
134
+ const { run } = stubRun(laptop);
135
+ follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'ship', '7'], verb: 'ship', cwd: laptop, env: {}, run, log: () => {}, npmCli: null });
136
+ assert.equal(pinOf(laptop), NEW);
137
+ for (const f of ['package.json', 'package-lock.json']) {
138
+ assert.ok(!/bongos-core-[\d.]+\.tgz/.test(fs.readFileSync(path.join(laptop, f), 'utf8')), `${f} still names a tarball`);
139
+ }
140
+ assert.ok(!fs.existsSync(path.join(laptop, 'vendor', `bongos-core-${OLD}.tgz`)), 'the hub removed the old tarball; the follow carried that');
141
+ });
142
+
143
+ test('dirty tree: reports the move only — no fast-forward, no install', () => {
144
+ const { laptop, coreRoot } = setup();
145
+ fs.writeFileSync(path.join(laptop, '.gitignore'), 'node_modules/\nlocal-edit\n');
146
+ const before = head(laptop);
147
+ const { run, calls } = stubRun(laptop);
148
+ const logs = [];
149
+ const code = follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s), npmCli: null });
150
+ assert.equal(code, null, 'carry on with the command');
151
+ assert.ok(logs.some((l) => l.includes(`upgraded to core ${NEW} on GitHub`)), logs.join('\n'));
152
+ assert.ok(logs.some((l) => /uncommitted/.test(l)), 'says why it was not applied: ' + logs.join('\n'));
153
+ assert.equal(head(laptop), before);
154
+ assert.equal(pinOf(laptop), `file:vendor/bongos-core-${OLD}.tgz`);
155
+ assert.equal(calls.installs.length, 0);
156
+ assert.match(fs.readFileSync(path.join(laptop, '.gitignore'), 'utf8'), /local-edit/, 'the edit is untouched');
157
+ });
158
+
159
+ test('feature branch: reports only, never moves the branch', () => {
160
+ const { laptop, coreRoot } = setup();
161
+ git(laptop, 'checkout', '-qb', 'task-49');
162
+ const before = head(laptop);
163
+ const { run, calls } = stubRun(laptop);
164
+ const logs = [];
165
+ const r = follow.followOriginPin({ coreRoot, cwd: laptop, env: {}, run, log: (s) => logs.push(s) });
166
+ assert.equal(r.action, 'reported');
167
+ assert.ok(logs.some((l) => l.includes('task-49')), logs.join('\n'));
168
+ assert.equal(head(laptop), before);
169
+ assert.equal(calls.installs.length, 0);
170
+ });
171
+
172
+ // The fast-forward installs and RUNS what it brings in, so only the hub's pin-only commit
173
+ // shape onto a published release is applied unattended; anything wider is reported.
174
+ function expectReportedOnly(alsoOnBox, why) {
175
+ const { laptop, coreRoot } = setup({ alsoOnBox });
176
+ const before = head(laptop);
177
+ const { run, calls } = stubRun(laptop);
178
+ const logs = [];
179
+ const code = follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s), npmCli: null });
180
+ assert.equal(code, null);
181
+ assert.ok(logs.some((l) => l.includes('upgraded to core') ), logs.join('\n'));
182
+ assert.ok(logs.some((l) => why.test(l)), `names why (${why}): ` + logs.join('\n'));
183
+ assert.equal(head(laptop), before, 'not fast-forwarded');
184
+ assert.equal(calls.installs.length, 0, 'nothing installed');
185
+ }
186
+
187
+ test('a move that carries other code too is reported, never applied', () => {
188
+ expectReportedOnly((box) => fs.writeFileSync(path.join(box, 'postinstall.js'), 'evil()'), /other changes besides the core move/);
189
+ });
190
+
191
+ test('a move whose package.json changes more than the core pin is reported, never applied', () => {
192
+ expectReportedOnly((box) => {
193
+ const p = JSON.parse(fs.readFileSync(path.join(box, 'package.json'), 'utf8'));
194
+ p.scripts = { prestart: 'evil' };
195
+ fs.writeFileSync(path.join(box, 'package.json'), JSON.stringify(p, null, 2) + '\n');
196
+ }, /package\.json changed beyond the core pin/);
197
+ });
198
+
199
+ test('a move onto a tarball from GitHub (not a published release) is reported, never applied', () => {
200
+ // the pin alone names a tarball (the spec gate) …
201
+ expectReportedOnly((box) => writePin(box, `file:vendor/bongos-core-${NEW}.tgz`, NEW), /not a published release/);
202
+ // … and a tarball file brought in with it is caught by the file gate first
203
+ expectReportedOnly((box) => {
204
+ writePin(box, `file:vendor/bongos-core-${NEW}.tgz`, NEW);
205
+ fs.mkdirSync(path.join(box, 'vendor'), { recursive: true });
206
+ fs.writeFileSync(path.join(box, 'vendor', `bongos-core-${NEW}.tgz`), 'unreleased');
207
+ }, /other changes besides the core move/);
208
+ });
209
+
210
+ test('a lockfile that resolves from outside the public registry is reported, never applied', () => {
211
+ expectReportedOnly((box) => {
212
+ const lock = JSON.parse(fs.readFileSync(path.join(box, 'package-lock.json'), 'utf8'));
213
+ lock.packages['node_modules/left-pad'] = { version: '1.0.0', resolved: 'https://evil.example/left-pad.tgz' };
214
+ fs.writeFileSync(path.join(box, 'package-lock.json'), JSON.stringify(lock, null, 2) + '\n');
215
+ }, /source outside the public registry/);
216
+ });
217
+
218
+ test('the core repo itself: a no-op, without a single git spawn', () => {
219
+ const { laptop } = setup();
220
+ const coreRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'core-origin-follow-core-'));
221
+ fs.writeFileSync(path.join(coreRepo, 'package.json'), JSON.stringify({ name: 'cloud-bongos', version: '0.0.2' }));
222
+ const { run, calls } = stubRun(laptop);
223
+ const logs = [];
224
+ assert.equal(follow.ensureCurrentCore({ coreRoot: coreRepo, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s) }), null);
225
+ assert.equal(calls.git, 0);
226
+ assert.deepEqual(logs, []);
227
+ });
228
+
229
+ test('origin unreadable: silent no-op', () => {
230
+ const { laptop, coreRoot, origin } = setup();
231
+ // Drop the remote-tracking ref and point origin at nothing: the fetch fails and there is no origin/main.
232
+ git(laptop, 'update-ref', '-d', 'refs/remotes/origin/main');
233
+ git(laptop, 'remote', 'set-url', 'origin', origin + '-gone');
234
+ const before = head(laptop);
235
+ const { run, calls } = stubRun(laptop);
236
+ const logs = [];
237
+ assert.equal(follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s) }), null);
238
+ assert.deepEqual(logs, [], 'nothing said');
239
+ assert.equal(head(laptop), before);
240
+ assert.equal(calls.installs.length, 0);
241
+ });
242
+
243
+ test('no move on origin: silent, and start pays no install', () => {
244
+ const { laptop, coreRoot } = setup({ move: false });
245
+ const { run, calls } = stubRun(laptop);
246
+ const logs = [];
247
+ assert.equal(follow.ensureCurrentCore({ coreRoot, argv: ['node', 'bongos', 'start'], verb: 'start', cwd: laptop, env: {}, run, log: (s) => logs.push(s) }), null);
248
+ assert.deepEqual(logs, []);
249
+ assert.equal(calls.installs.length, 0);
250
+ });
251
+
252
+ test('a pin the LAPTOP moved (not pushed yet) is not reported as a hub move', () => {
253
+ const { laptop, coreRoot } = setup({ move: false });
254
+ writePin(laptop, '1.21.99', '1.21.99');
255
+ git(laptop, 'commit', '-qam', 'local pin bump');
256
+ const { run } = stubRun(laptop);
257
+ const logs = [];
258
+ const r = follow.followOriginPin({ coreRoot, cwd: laptop, env: {}, run, log: (s) => logs.push(s) });
259
+ assert.equal(r.action, 'none');
260
+ assert.deepEqual(logs, []);
261
+ });
262
+
263
+ test('a scripts link left dangling after the install is re-pointed at the installed core', () => {
264
+ const { laptop } = setup({ move: false });
265
+ const link = path.join(laptop, 'scripts');
266
+ fs.rmSync(link, { force: true, recursive: false });
267
+ const gone = path.join(laptop, 'old-core-scripts');
268
+ fs.mkdirSync(gone);
269
+ fs.symlinkSync(gone, link, 'junction');
270
+ fs.rmSync(gone, { recursive: true });
271
+ const logs = [];
272
+ assert.equal(pinSync.healScriptsLink(laptop, { log: (s) => logs.push(s) }), 'repointed');
273
+ assert.ok(fs.existsSync(path.join(link, 'gds', 'fitness.js')));
274
+ // a real scripts/ directory (the core repo shape) is never touched
275
+ const real = fs.mkdtempSync(path.join(os.tmpdir(), 'core-origin-follow-real-'));
276
+ fs.mkdirSync(path.join(real, 'scripts'));
277
+ assert.equal(pinSync.healScriptsLink(real, { log: () => {} }), 'not-a-link');
278
+ });
279
+
280
+ test('the dispatcher runs the follow before ship and start', () => {
281
+ const src = fs.readFileSync(new URL('../bin/bongos.js', import.meta.url), 'utf8');
282
+ assert.match(src, /verb === 'ship' \|\| verb === 'start'/);
283
+ assert.match(src, /require\('\.\.\/scripts\/gds\/core-origin-follow\.js'\)\.ensureCurrentCore/);
284
+ });
285
+
286
+ console.log(`\n${passed} passed, ${failed} failed`);
287
+ if (failed) process.exit(1);