@bongos/core 1.24.57 → 1.24.59

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 +77 -67
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -2
  4. package/clients/bongos-client/index.cjs +0 -2
  5. package/clients/bongos-client/index.d.ts +1 -3
  6. package/clients/bongos-client/index.mjs +0 -2
  7. package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +1 -37
  10. package/docs/api-reference.md +2 -3
  11. package/docs/architecture.md +1 -1
  12. package/docs/copy-inventory.md +38 -44
  13. package/docs/copy-registry.json +44 -108
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +5 -5
  16. package/docs/recipes/ops-gotchas.md +2 -2
  17. package/modules/hall-ui/public/deploy-handoff.js +11 -3
  18. package/modules/hall-ui/public/deploy.js +10 -3
  19. package/modules/hall-ui/public/settings-software-update.js +4 -71
  20. package/modules/hall-ui/public/update-pill.js +6 -31
  21. package/modules/status-ui/public/style.css +5 -2
  22. package/modules/ui-design/public/brand/png-sources.json +10 -0
  23. package/modules/ui-design/public/brand.css +6 -0
  24. package/modules/ui-design/public/brand.html +2 -2
  25. package/modules/ui-design/scripts/gen-design-reference.js +2 -3
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/public-docs/index.html +3 -1
  29. package/release-notes.json +16 -0
  30. package/scripts/bongos/design-generators-fresh.js +14 -6
  31. package/scripts/bongos/gen-brand.js +82 -36
  32. package/scripts/bongos/provision-core-upgrade.js +30 -1
  33. package/scripts/bongos/provision-units.js +18 -0
  34. package/scripts/hall-preview/server.js +0 -3
  35. package/scripts/status-mirror-sync.js +4 -1
  36. package/src/bongos/routes/auth.js +4 -9
  37. package/src/bongos/routes/core-update.js +36 -76
  38. package/src/bongos/routes/feedback.js +1 -2
  39. package/src/bongos/serve-internal.js +5 -70
  40. package/src/bongos/sign-in-pages.js +20 -4
  41. package/src/brand-mark.js +10 -1
  42. package/src/error-page.js +11 -10
  43. package/src/module-api.js +1 -1
  44. package/src/theme-markup.js +81 -0
  45. package/tests/brand_mark.mjs +11 -1
  46. package/tests/brand_surfaces.mjs +5 -2
  47. package/tests/deploy_handoff.mjs +23 -2
  48. package/tests/deploy_page_projects.mjs +35 -3
  49. package/tests/design_counts_ratchet.mjs +38 -0
  50. package/tests/design_reference.mjs +1 -1
  51. package/tests/provision_settings_apply.mjs +52 -0
  52. package/tests/software_update.mjs +35 -5
  53. package/tests/update_pill.mjs +9 -5
  54. package/tests/wordmark_and_faces.mjs +111 -0
  55. package/src/bongos/upgrade-request.js +0 -158
  56. package/tests/upgrade_request.mjs +0 -348
@@ -66,49 +66,12 @@
66
66
  </div>`;
67
67
  }
68
68
 
69
- // "Ask for this upgrade" (task 1004595). A project the hub's operator runs cannot take an
70
- // update itself, so instead of a dead end the page offers one press that reaches the people
71
- // who run its server, and then says that it was asked. The server decides whether it is
72
- // offered (`ask.available`) and remembers the request (`ask.requested`).
73
69
  function longDate(iso) {
74
70
  const d = new Date(iso);
75
71
  if (!Number.isFinite(d.getTime())) return null;
76
72
  try { return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) { return d.toISOString().slice(0, 10); }
77
73
  }
78
74
 
79
- // Asked, and still waiting: for this very version, or recently enough that asking again
80
- // would only be refused.
81
- function askPending(ask, offered) {
82
- const r = ask && ask.requested;
83
- if (!r) return false;
84
- if (r.version === offered) return true;
85
- const again = Date.parse(ask.ask_again_after);
86
- return Number.isFinite(again) && Date.now() < again;
87
- }
88
-
89
- function askHtml(ask, offered, { lead }) {
90
- const team = ask.hub_host ? `the team at ${escapeHtml(ask.hub_host)}` : 'the team that runs this project\'s server';
91
- if (askPending(ask, offered)) {
92
- const r = ask.requested;
93
- const when = longDate(r.requested_at);
94
- return `<div class="su-door su-ask">
95
- <p class="panel-status panel-status--ok" role="status">Upgrade requested${when ? ` on ${escapeHtml(when)}` : ''}${r.by ? ` by @${escapeHtml(r.by)}` : ''}, for version ${mono(r.version)}. ${team.charAt(0).toUpperCase() + team.slice(1)} has your request. This page shows the new version once it is installed.</p>
96
- </div>`;
97
- }
98
- const last = ask.requested && longDate(ask.requested.requested_at);
99
- const before = last ? ` You last asked on ${escapeHtml(last)}, for ${mono(ask.requested.version)}.` : '';
100
- const intro = lead
101
- ? `<p class="panel-hint">This project runs on a server that ${team} looks after, so they install its updates. Ask them for this one and they are told which version you want.${before}</p>`
102
- : `<p class="panel-hint">If that page does not let you in, ask ${team} to install it for you.${before}</p>`;
103
- return `<div class="su-door su-ask">
104
- ${intro}
105
- <div class="row-end">
106
- <button type="button" class="pbtn ${lead ? 'pbtn--save' : 'pbtn--quiet'}" id="su-ask">Ask for this upgrade</button>
107
- <span class="panel-hint" id="su-ask-status" role="status"></span>
108
- </div>
109
- </div>`;
110
- }
111
-
112
75
  // Where the Update button goes, and what it says it will do. Only a plain same-origin path
113
76
  // or an http(s) URL is ever put in an href; anything else draws no button.
114
77
  function doorHtml(data, offered) {
@@ -116,9 +79,9 @@
116
79
  if (!data.can_update) {
117
80
  return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
118
81
  }
119
- const ask = data.ask || {};
82
+ // No request-the-upgrade button any more (task 1005128): a project the hub hosts always has
83
+ // the hub's door, so `cli` is only ever a server its owner runs themselves.
120
84
  if (door.kind === 'cli') {
121
- if (ask.available) return askHtml(ask, offered, { lead: true });
122
85
  return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
123
86
  }
124
87
  const url = String(door.url || '');
@@ -134,7 +97,7 @@
134
97
  return `<div class="row-end su-door">
135
98
  <a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}</a>
136
99
  <span class="panel-hint">Takes a few minutes. The project restarts once, checks itself, and goes back to the version it runs now if it does not come back healthy.${where ? ` A small window from ${escapeHtml(where)} opens for a moment to start it.` : ''}</span>
137
- </div>${door.kind === 'hub' && ask.available ? askHtml(ask, offered, { lead: false }) : ''}`;
100
+ </div>`;
138
101
  }
139
102
 
140
103
  // One entry: its text, and — only in the hall whose ledger the ids belong to, which is
@@ -223,8 +186,7 @@
223
186
  html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
224
187
  } else {
225
188
  const when = ago(u.offered_published_at);
226
- const pending = data.can_update && data.ask && data.ask.available && askPending(data.ask, u.offered);
227
- html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : pending ? 'Requested' : 'Update ready'}</span>`);
189
+ html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : 'Update ready'}</span>`);
228
190
  // No button to take again the very version that was just rolled back here (task 1004730).
229
191
  if (!onHold) html += doorHtml(data, u.offered);
230
192
  html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
@@ -237,9 +199,6 @@
237
199
  body.innerHTML = html + historyHtml(data.history);
238
200
  if (note) note.textContent = notes.join(' ');
239
201
 
240
- const askBtn = byId('su-ask');
241
- if (askBtn) askBtn.addEventListener('click', () => askForUpgrade(data, askBtn));
242
-
243
202
  // The same press as the pill's Update now; only when it took the update in place is the
244
203
  // link's own navigation stopped.
245
204
  const updateBtn = byId('su-update');
@@ -259,32 +218,6 @@
259
218
  }
260
219
  }
261
220
 
262
- // One press: the server sends the request and remembers it; the panel redraws from its
263
- // answer. "Asked recently" carries the stored request, so it redraws as pending too.
264
- async function askForUpgrade(data, btn) {
265
- const status = byId('su-ask-status');
266
- btn.disabled = true;
267
- btn.textContent = 'Asking…';
268
- if (status) status.textContent = '';
269
- try {
270
- const res = await api.request('POST', `${API}/software-update/request`, { body: {} });
271
- const err = (res.data && res.data.error) || {};
272
- const request = (res.data && res.data.request) || (err.details && err.details.request);
273
- if ((res.ok || res.status === 429) && request) {
274
- const again = new Date(Date.parse(request.requested_at) + 24 * 3600 * 1000).toISOString();
275
- render({ ...data, ask: { ...data.ask, requested: request, ask_again_after: again } });
276
- return;
277
- }
278
- // Every refusal the server sends carries a sentence; a bare code is only ever a
279
- // proxy or a crash, so the status stands in for it.
280
- throw new Error(err.message && err.message !== err.code ? err.message : `HTTP ${res.status}`);
281
- } catch (e) {
282
- btn.disabled = false;
283
- btn.textContent = 'Ask for this upgrade';
284
- if (status) status.textContent = `Could not send the request: ${String(e.message || 'unknown error').replace(/\.$/, '')}.`;
285
- }
286
- }
287
-
288
221
  async function load() {
289
222
  if (asked) return;
290
223
  asked = true;
@@ -23,7 +23,6 @@
23
23
  // with &handoff=1, this page tells it the version over postMessage, and the hub
24
24
  // posts the move with the owner's OWN hub session (deploy-handoff.js). This
25
25
  // project's server gains no capability; the only thing that crosses is a version.
26
- // ask a hosted project the hub's operator runs: "Ask for this update" (task 1004595).
27
26
  // cli a self-hosted instance: no button; the card names the command.
28
27
  //
29
28
  // PROGRESS IS READ, NEVER GUESSED (deploy.js's rule). The page watches its own public
@@ -41,9 +40,10 @@
41
40
  var RUN_KEY = 'bongos.update.run';
42
41
  var MSG = 'bongos-update'; // the tag deploy-handoff.js answers to; both sides check it
43
42
  var VERSION_RE = /^\d+\.\d+\.\d+$/;
44
- // A hub door as the server words it (core-update.js HUB_UPGRADE_URL_RE's shape): an
45
- // origin and /deploy?project=<slug>, nothing else. Anything else draws no button.
46
- var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?project=[A-Za-z0-9._%-]+$/;
43
+ // A hub door as the server words it (core-update.js updateDoor): an origin and
44
+ // /deploy?project=<slug>, or /deploy?host=<address> for a hosted project whose server never
45
+ // learned its slug (task 1005128). Anything else draws no button.
46
+ var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?(?:project|host)=[A-Za-z0-9._%-]+$/;
47
47
  var SETTINGS_HREF = '/settings#software-update';
48
48
  var HIGHLIGHTS = 3;
49
49
  var LINE_MAX = 120;
@@ -101,14 +101,13 @@
101
101
  function doorPlan(sw) {
102
102
  if (!sw || sw.can_update !== true) return { kind: 'none' };
103
103
  var door = sw.door || {};
104
- var ask = sw.ask || {};
105
104
  if (door.kind === 'local' && door.url === '/deploy') return { kind: 'local' };
106
105
  if (door.kind === 'hub') {
107
106
  var m = HUB_DOOR_RE.exec(String(door.url || ''));
108
- if (!m) return ask.available ? { kind: 'ask' } : { kind: 'none' };
107
+ if (!m) return { kind: 'none' };
109
108
  return { kind: 'hub', url: door.url, origin: m[1] };
110
109
  }
111
- if (door.kind === 'cli') return ask.available ? { kind: 'ask' } : { kind: 'cli' };
110
+ if (door.kind === 'cli') return { kind: 'cli' };
112
111
  return { kind: 'none' };
113
112
  }
114
113
 
@@ -263,13 +262,6 @@
263
262
  note = '<p class="upd-card__note">Takes a few minutes. The project restarts once, and goes back to the version it runs now on its own if the new one is not healthy.' +
264
263
  (host ? ' A small window from ' + esc(host) + ' opens for a moment to start it.' : '') + '</p>';
265
264
  action = '<button type="button" class="upd-btn upd-btn--go" id="upd-go">Update now</button>';
266
- } else if (plan.kind === 'ask') {
267
- var ask = state.sw.ask || {};
268
- var team = ask.hub_host ? 'the team at ' + ask.hub_host : 'the team that runs this project\'s server';
269
- var asked = ask.requested && ask.requested.version === state.offered;
270
- note = '<p class="upd-card__note">' + esc(asked ? 'You asked ' + team + ' for this update. This shows the new version once it is installed.'
271
- : 'This project runs on a server that ' + team + ' looks after, so they install its updates.') + '</p>';
272
- if (!asked) action = '<button type="button" class="upd-btn upd-btn--go" id="upd-ask">Ask for this update</button>';
273
265
  } else if (plan.kind === 'cli') {
274
266
  note = '<p class="upd-card__note">This project is updated on the server that runs it: <code>bongos upgrade</code> there takes this version, and puts the old one back if it fails.</p>';
275
267
  } else {
@@ -294,7 +286,6 @@
294
286
  var on = function (id, fn) { var el = c.querySelector('#' + id); if (el) el.addEventListener('click', fn); };
295
287
  on('upd-close', closeCard);
296
288
  on('upd-go', function () { updateNow(); });
297
- on('upd-ask', function (e) { askForIt(e.currentTarget); });
298
289
  on('upd-reload', reload);
299
290
  on('upd-open', function () { updateNow(); });
300
291
  }
@@ -565,22 +556,6 @@
565
556
  if (root.location && root.location.reload) root.location.reload();
566
557
  }
567
558
 
568
- // "Ask for this update" (task 1004595): one press reaches the people who run the server.
569
- function askForIt(btn) {
570
- btn.disabled = true;
571
- btn.textContent = 'Asking…';
572
- state.api.request('POST', '/api/bongos/software-update/request', { body: {} }).then(function (res) {
573
- var data = (res && res.data) || {};
574
- var request = data.request || (data.error && data.error.details && data.error.details.request);
575
- if (!((res.ok || res.status === 429) && request)) throw new Error(errorOf(res, 'Could not send the request.'));
576
- state.sw.ask = Object.assign({}, state.sw.ask, { requested: request });
577
- drawCard();
578
- }).then(null, function (e) {
579
- state.error = String((e && e.message) || 'Could not send the request.');
580
- drawCard();
581
- });
582
- }
583
-
584
559
  /** shell.js hands over GET /core-update's answer; the pill decides what, if anything, to show. */
585
560
  function mount(data, opts) {
586
561
  state.api = opts && opts.api;
@@ -250,11 +250,14 @@ a:hover { text-decoration-color: currentColor; }
250
250
  /* drawn from the one mark (modules/ui-design/public/brand/mark.svg, task 1005088) */
251
251
  --mark-ink: var(--ink);
252
252
  }
253
+ /* the wordmark: the pack's productName, lowercase and light, as the hall's lockup
254
+ (DESIGN.md "Naming"; task 1005135) */
253
255
  .topbar__name {
254
256
  font-family: var(--font-display);
255
- font-size: var(--ds-text-xl);
256
- font-weight: 500;
257
+ font-size: var(--ds-text-lg);
258
+ font-weight: 300;
257
259
  letter-spacing: -0.01em;
260
+ text-transform: lowercase;
258
261
  color: var(--ink);
259
262
  white-space: nowrap;
260
263
  overflow: hidden;
@@ -0,0 +1,10 @@
1
+ {
2
+ "apple-touch-icon.png": {
3
+ "page": "b665654a1cdfa7fb91f9aef0ab4a06831922c2e12bd7eb3dea572dd0db4988a2",
4
+ "png": "953557984586881b25452b8642f595dfad39d17b86459cee5c5b080b74165362"
5
+ },
6
+ "og-card.png": {
7
+ "page": "5f7d738dd440ed4760017fe4e468ec8a593745536336f819d4a3bd2c32a5c38d",
8
+ "png": "e168fdd0f4ac94754af089e7240be7cbba46704b36a617252f2942c97e8835e2"
9
+ }
10
+ }
@@ -15,6 +15,11 @@ body[data-page="brand"] {
15
15
  font-family: var(--font-body);
16
16
  }
17
17
 
18
+ /* The hall's sheet paints its plate veil as a fixed body::after over the page
19
+ (black in dark); the hall lifts its shell above it, and so does this page —
20
+ otherwise the whole reference reads through the veil, dimmed (task 1005135). */
21
+ body[data-page="brand"] > * { position: relative; z-index: 1; }
22
+
18
23
  /* ---- the bar: the mark, the name, the sections ---- */
19
24
  .ref-head {
20
25
  box-sizing: border-box;
@@ -134,6 +139,7 @@ body[data-page="brand"] {
134
139
  font-weight: 300;
135
140
  font-size: var(--ds-text-headline);
136
141
  letter-spacing: -0.01em;
142
+ text-transform: lowercase;
137
143
  color: var(--ink);
138
144
  }
139
145
  .ref-lockup__mark { width: 72px; height: 72px; }
@@ -13,7 +13,7 @@
13
13
  </head>
14
14
  <body data-page="brand">
15
15
  <header class="ref-head">
16
- <a class="ref-head__brand" href="/"><svg class="ref-head__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg><span>cloud bongos</span></a>
16
+ <a class="ref-head__brand" href="/"><svg class="ref-head__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg><span>{{productName}}</span></a>
17
17
  </header>
18
18
  <main class="ref-main">
19
19
  <h1 class="ref-title">Design system</h1>
@@ -27,7 +27,7 @@
27
27
 
28
28
  <div class="ref-lockup">
29
29
  <svg class="ref-lockup__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg>
30
- <span class="ref-lockup__name">cloud bongos</span>
30
+ <span class="ref-lockup__name">{{productName}}</span>
31
31
  </div>
32
32
  <div class="ref-naming">
33
33
  <p><strong>The wordmark is lowercase; the name in a sentence is capitalised</strong> (owner, 2026-11-06):</p>
@@ -152,7 +152,6 @@ function build(src = read()) {
152
152
  for (const cls of NEEDS) {
153
153
  if (!new RegExp(cls.replace(/[.[\]-]/g, '\\$&') + '(?![a-z0-9_-])').test(allCss)) throw new Error(`the hall's sheets no longer define ${cls}`);
154
154
  }
155
- const product = (src.pack.identity && src.pack.identity.productName) || '';
156
155
  const naming = readNaming(src.design);
157
156
  const files = readBrandFiles(src.genBrand, SRC.brandDir);
158
157
  const OTB = src.otb;
@@ -199,7 +198,7 @@ function build(src = read()) {
199
198
  parts.push(section('ref-logo', 'Logo and name', 'The mark is one file every page draws from. The ring and the planet take the ink; the moon takes the accent.', `
200
199
  <div class="ref-lockup">
201
200
  <svg class="ref-lockup__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="${BRAND_URL}mark.svg#mark"/></svg>
202
- <span class="ref-lockup__name">${esc(product.toLowerCase())}</span>
201
+ <span class="ref-lockup__name">{{productName}}</span>
203
202
  </div>
204
203
  <div class="ref-naming">
205
204
  ${naming.lead.map((p) => `<p>${p}</p>`).join('\n ')}
@@ -315,7 +314,7 @@ function build(src = read()) {
315
314
  </head>
316
315
  <body data-page="brand">
317
316
  <header class="ref-head">
318
- <a class="ref-head__brand" href="/"><svg class="ref-head__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="${BRAND_URL}mark.svg#mark"/></svg><span>${esc(product.toLowerCase())}</span></a>
317
+ <a class="ref-head__brand" href="/"><svg class="ref-head__mark" viewBox="0 0 200 200" aria-hidden="true"><use href="${BRAND_URL}mark.svg#mark"/></svg><span>{{productName}}</span></a>
319
318
  </header>
320
319
  <main class="ref-main">
321
320
  <h1 class="ref-title">Design system</h1>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.57",
3
+ "version": "1.24.59",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.57",
9
+ "version": "1.24.59",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.57",
3
+ "version": "1.24.59",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -54,6 +54,8 @@
54
54
  }
55
55
  #topbar .mark { width: 28px; height: 28px; flex-shrink: 0; --mark-ink: var(--ink); }
56
56
  #topbar strong { font-family: var(--font-display); font-size: var(--ds-text-xl); font-weight: 500; }
57
+ /* the wordmark: the pack's productName, lowercase and light (DESIGN.md "Naming"; task 1005135) */
58
+ #topbar .wordmark { font-size: var(--ds-text-lg); font-weight: 300; letter-spacing: -0.01em; text-transform: lowercase; }
57
59
  #topbar .muted {
58
60
  font-family: var(--font-mono); font-size: var(--ds-text-2xs); letter-spacing: .12em;
59
61
  text-transform: uppercase; color: var(--ink-soft);
@@ -72,7 +74,7 @@
72
74
  <body>
73
75
  <div id="topbar">
74
76
  <svg class="mark" viewBox="0 0 200 200" aria-hidden="true"><use href="/modules/ui-design/public/brand/mark.svg#mark"/></svg>
75
- <strong>{{productName}} — API</strong>
77
+ <strong><span class="wordmark">{{productName}}</span> — API</strong>
76
78
  <span class="muted">OpenAPI 3.1</span>
77
79
  <span class="spacer"></span>
78
80
  <a href="/docs/openapi.json" download>Download spec</a>
@@ -11313,5 +11313,21 @@
11313
11313
  "id": "1005093",
11314
11314
  "text": "Stylesheets can no longer gain new typed-in colours, text sizes, corners, shadows or breakpoints without the build failing, and the public design reference must be regenerated whenever its sources change."
11315
11315
  }
11316
+ ],
11317
+ "1.24.58": [
11318
+ {
11319
+ "id": "1005128",
11320
+ "text": "A project's owner now takes a new Bongos version with one Update button; the \"ask the team for this upgrade\" step is gone for every hosted project."
11321
+ }
11322
+ ],
11323
+ "1.24.59": [
11324
+ {
11325
+ "id": "1005135",
11326
+ "text": "The name beside the logo now reads 'cloud bongos' in lowercase everywhere, taken from the project's branding, and the sign-in and error pages use the same fonts and colours as the rest of the site."
11327
+ },
11328
+ {
11329
+ "id": "1005133",
11330
+ "text": "The logo files and the link-preview pictures are now checked automatically, so a logo change can no longer ship with out-of-date copies; the docs now say plainly that the off-site status page is updated by hand."
11331
+ }
11316
11332
  ]
11317
11333
  }
@@ -4,7 +4,8 @@
4
4
  // check; kept in its own file because fitness.js sits at the 1,500-line budget.
5
5
  //
6
6
  // Both generated files read the shared token sheet (modules/ui-design/public/tokens.css) and the brand pack; the public
7
- // reference also reads the hall's sheets and DESIGN.md. A stylesheet edit without a
7
+ // reference also reads the hall's sheets and DESIGN.md. The logo set (scripts/bongos/gen-brand.js)
8
+ // reads mark.svg and the pack, and checks its two PNGs by hash (task 1005133). A stylesheet edit without a
8
9
  // regeneration used to surface only as a failing unit test; this names it in fitness.
9
10
  // Skipped when the ui-design module is not in the checkout. `root` is injectable for
10
11
  // tests/design_counts_ratchet.mjs, which runs it against a copied tree.
@@ -17,14 +18,21 @@ const { REPO_ROOT } = require('./fitness-lib.js');
17
18
 
18
19
  function checkDesignGeneratorsFresh(root = REPO_ROOT) {
19
20
  const violations = [];
20
- for (const [script, out, task] of [
21
- ['gen-design-tokens.js', 'config/design-tokens.neutral.json', '1005082'],
22
- ['gen-design-reference.js', 'modules/ui-design/public/brand.html (the public /brand page)', '1005083'],
21
+ const UI = ['modules', 'ui-design', 'scripts'];
22
+ for (const [dir, script, out, why, task] of [
23
+ [UI, 'gen-design-tokens.js', 'config/design-tokens.neutral.json', 'a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md)', '1005082'],
24
+ [UI, 'gen-design-reference.js', 'modules/ui-design/public/brand.html (the public /brand page)', 'a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md)', '1005083'],
25
+ // The logo and favicon set, and the two PNGs by hash (task 1005133): mark.svg or the
26
+ // pack's colours changed without a regeneration. A PNG needs `--png` (a system browser).
27
+ [['scripts', 'bongos'], 'gen-brand.js', 'the logo set in modules/ui-design/public/brand/', 'mark.svg or the brand pack', '1005133'],
23
28
  ]) {
24
- const gen = path.join(root, 'modules', 'ui-design', 'scripts', script);
29
+ const gen = path.join(root, ...dir, script);
25
30
  if (!fs.existsSync(gen)) continue;
26
31
  try { execFileSync(process.execPath, [gen, '--check'], { cwd: root, stdio: 'pipe' }); }
27
- catch { violations.push(`${out} is STALE — a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md) changed without a regeneration. Run \`node modules/ui-design/scripts/${script}\` and commit (task ${task}).`); }
32
+ catch (e) {
33
+ const said = String((e && e.stderr) || '').trim().split('\n').filter(Boolean).join(' ');
34
+ violations.push(`${out} is STALE — ${why} changed without a regeneration. Run \`node ${[...dir, script].join('/')}\`${script === 'gen-brand.js' ? ' (with --png for a PNG)' : ''} and commit (task ${task}).${said ? ` [${said}]` : ''}`);
35
+ }
28
36
  }
29
37
  return violations;
30
38
  }
@@ -17,17 +17,23 @@
17
17
  // og-card.png 1200x630, the link-preview card every public page names (task 1005091):
18
18
  // the dark mark and the lowercase wordmark on the void (--png; needs a browser)
19
19
  //
20
- // Deterministic text, so `--check` is a clean gate (tests/brand_mark.mjs runs it). The PNG is
21
- // rendered by the system browser and only written with --png.
20
+ // Deterministic text, so `--check` is a clean gate (tests/brand_mark.mjs runs it; fitness runs it
21
+ // through scripts/bongos/design-generators-fresh.js). The PNGs are rendered by the system browser
22
+ // and only written with --png; a browser's pixels are not byte-stable across machines, so they
23
+ // are checked by hash instead (task 1005133): --png records, in png-sources.json beside them,
24
+ // a hash of the exact page each was rendered from and a hash of the PNG written. --check fails
25
+ // when the page a PNG would be rendered from today differs from the one recorded (a source or a
26
+ // colour moved without a re-render), or when the PNG is not the one that render wrote.
22
27
  //
23
28
  // Usage:
24
29
  // node scripts/bongos/gen-brand.js # write the SVG variants
25
30
  // node scripts/bongos/gen-brand.js --png # … and render apple-touch-icon.png + og-card.png
26
- // node scripts/bongos/gen-brand.js --check # write nothing; exit 2 if a variant is stale
31
+ // node scripts/bongos/gen-brand.js --check # write nothing; exit 2 if a variant or a PNG is stale
27
32
 
28
33
  const fs = require('node:fs');
29
34
  const path = require('node:path');
30
- const { NIGHT_INK, readMarkSource, markParts, markFile, faviconSvg } = require('../../src/brand-mark');
35
+ const crypto = require('node:crypto');
36
+ const { readMarkSource, markParts, markFile, faviconSvg } = require('../../src/brand-mark');
31
37
  const { escapeHtml } = require('../../src/html-escape');
32
38
 
33
39
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -59,22 +65,71 @@ function ogCardHtml({ markSvg, name, fontLinks = [], fontFamily = 'sans-serif' }
59
65
  '</body></html>';
60
66
  }
61
67
 
62
- async function renderOgCard(dir) {
68
+ // The page apple-touch-icon.png is rendered from: the light mark on the pack's ground. Pure.
69
+ function touchIconHtml({ markSvg, bg }) {
70
+ return `<html><body style="margin:0;background:${bg}">` +
71
+ `<img src="data:image/svg+xml;base64,${Buffer.from(markSvg).toString('base64')}" style="display:block;width:128px;height:128px;margin:26px">` +
72
+ '</body></html>';
73
+ }
74
+
75
+ // Each PNG as the exact page and viewport it is rendered from, given the SVG variants it
76
+ // draws (the text gen-brand writes, not the files on disk, so a stale SVG shows here too).
77
+ function pngPages(files, pack) {
78
+ return {
79
+ 'apple-touch-icon.png': {
80
+ width: 180, height: 180, wait: 'load',
81
+ html: touchIconHtml({ markSvg: files['mark-light.svg'], bg: pack.theme.ui.colors.bg }),
82
+ },
83
+ 'og-card.png': {
84
+ width: 1200, height: 630, wait: 'networkidle',
85
+ html: ogCardHtml({
86
+ markSvg: files['mark-dark.svg'],
87
+ name: pack.identity.productName,
88
+ fontLinks: pack.theme.ui.fonts.links,
89
+ fontFamily: pack.theme.ui.fonts.display,
90
+ }),
91
+ },
92
+ };
93
+ }
94
+
95
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
96
+ // The hash of a render's input: the page and the viewport it is shot at.
97
+ function pageHash(p) { return sha256(JSON.stringify([p.width, p.height, p.html])); }
98
+
99
+ const PNG_MANIFEST = 'png-sources.json';
100
+
101
+ // The PNGs that do not match what --png would write today, each with the reason. Pure I/O
102
+ // read of the brand folder; writes nothing.
103
+ function stalePngs(dir, pages) {
104
+ let manifest = {};
105
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, PNG_MANIFEST), 'utf8')); } catch { manifest = {}; }
106
+ const stale = [];
107
+ for (const [name, p] of Object.entries(pages)) {
108
+ const rec = manifest[name];
109
+ const file = path.join(dir, name);
110
+ if (!rec || !fs.existsSync(file)) { stale.push(`${name} (not recorded in ${PNG_MANIFEST})`); continue; }
111
+ if (rec.page !== pageHash(p)) stale.push(`${name} (its source page changed since it was rendered)`);
112
+ else if (rec.png !== sha256(fs.readFileSync(file))) stale.push(`${name} (the file is not the one --png wrote)`);
113
+ }
114
+ return stale;
115
+ }
116
+
117
+ async function renderPngs(dir, pages) {
63
118
  const { launchBrowser } = require('../../modules/ui-design/kit/lib'); // eslint-disable-line global-require
64
- const pack = readPack();
65
119
  const browser = await launchBrowser();
120
+ const manifest = {};
66
121
  try {
67
- const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
68
- await page.setContent(ogCardHtml({
69
- markSvg: fs.readFileSync(path.join(dir, 'mark-dark.svg'), 'utf8'),
70
- name: pack.identity.productName,
71
- fontLinks: pack.theme.ui.fonts.links,
72
- fontFamily: pack.theme.ui.fonts.display,
73
- }));
74
- await page.waitForLoadState('networkidle');
75
- await page.evaluate(() => document.fonts.ready);
76
- await page.screenshot({ path: path.join(dir, 'og-card.png'), omitBackground: false });
122
+ for (const [name, p] of Object.entries(pages)) {
123
+ const page = await browser.newPage({ viewport: { width: p.width, height: p.height }, deviceScaleFactor: 1 });
124
+ await page.setContent(p.html);
125
+ await page.waitForLoadState(p.wait);
126
+ await page.evaluate(() => document.fonts.ready);
127
+ await page.screenshot({ path: path.join(dir, name), omitBackground: false });
128
+ await page.close();
129
+ manifest[name] = { page: pageHash(p), png: sha256(fs.readFileSync(path.join(dir, name))) };
130
+ }
77
131
  } finally { await browser.close(); }
132
+ fs.writeFileSync(path.join(dir, PNG_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
78
133
  }
79
134
 
80
135
  // { filename: text } for every SVG variant. Pure given the source and the colours.
@@ -84,33 +139,21 @@ function brandFiles(src, colors) {
84
139
  const mono = '#000000';
85
140
  return {
86
141
  'mark-light.svg': markFile(parts, { ink: colors.ink, dot: colors.accent, label: 'logo' }),
87
- 'mark-dark.svg': markFile(parts, { ink: NIGHT_INK, dot: colors.accent, label: 'logo' }),
142
+ 'mark-dark.svg': markFile(parts, { ink: colors.bgDeep, dot: colors.accent, label: 'logo' }),
88
143
  'mark-mono.svg': markFile(parts, { ink: mono, dot: mono, label: 'logo' }),
89
144
  'mark-body.svg': markFile(parts, { ink: mono, dot: mono, only: ['ring', 'disc'] }),
90
145
  'mark-dot.svg': markFile(parts, { ink: mono, dot: mono, only: ['dot'] }),
91
- 'favicon.svg': faviconSvg({ ink: colors.ink, accent: colors.accent }, src) + '\n',
146
+ 'favicon.svg': faviconSvg({ ink: colors.ink, accent: colors.accent, night: colors.bgDeep }, src) + '\n',
92
147
  };
93
148
  }
94
149
 
95
- async function renderTouchIcon(dir, colors) {
96
- const { launchBrowser } = require('../../modules/ui-design/kit/lib'); // eslint-disable-line global-require
97
- const svg = fs.readFileSync(path.join(dir, 'mark-light.svg'), 'utf8');
98
- const browser = await launchBrowser();
99
- try {
100
- const page = await browser.newPage({ viewport: { width: 180, height: 180 }, deviceScaleFactor: 1 });
101
- await page.setContent(`<html><body style="margin:0;background:${colors.bg}">` +
102
- `<img src="data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}" style="display:block;width:128px;height:128px;margin:26px">` +
103
- '</body></html>');
104
- await page.waitForLoadState('load');
105
- await page.screenshot({ path: path.join(dir, 'apple-touch-icon.png'), omitBackground: false });
106
- } finally { await browser.close(); }
107
- }
108
-
109
150
  async function main() {
110
151
  const args = process.argv.slice(2);
111
152
  const dir = brandDir();
112
- const colors = packColors();
153
+ const pack = readPack();
154
+ const colors = pack.theme.ui.colors;
113
155
  const files = brandFiles(readMarkSource({ root: ROOT }), colors);
156
+ const pages = pngPages(files, pack);
114
157
  const stale = [];
115
158
  for (const [name, text] of Object.entries(files)) {
116
159
  const file = path.join(dir, name);
@@ -120,11 +163,14 @@ async function main() {
120
163
  if (!args.includes('--check')) fs.writeFileSync(file, text);
121
164
  }
122
165
  if (args.includes('--check')) {
123
- if (stale.length) { process.stderr.write(`gen-brand: stale — ${stale.join(', ')}. Run: node scripts/bongos/gen-brand.js\n`); process.exit(2); }
124
- process.stdout.write('gen-brand: the logo and favicon set match mark.svg\n');
166
+ const pngs = stalePngs(dir, pages);
167
+ if (stale.length) process.stderr.write(`gen-brand: stale — ${stale.join(', ')}. Run: node scripts/bongos/gen-brand.js\n`);
168
+ if (pngs.length) process.stderr.write(`gen-brand: stale PNG — ${pngs.join('; ')}. Run: node scripts/bongos/gen-brand.js --png\n`);
169
+ if (stale.length || pngs.length) process.exit(2);
170
+ process.stdout.write('gen-brand: the logo and favicon set match mark.svg, and the PNGs match their sources\n');
125
171
  return;
126
172
  }
127
- if (args.includes('--png')) { await renderTouchIcon(dir, colors); await renderOgCard(dir); }
173
+ if (args.includes('--png')) await renderPngs(dir, pages);
128
174
  process.stdout.write(`gen-brand: ${stale.length ? `wrote ${stale.join(', ')}` : 'nothing to write'}${args.includes('--png') ? ' + apple-touch-icon.png + og-card.png' : ''}\n`);
129
175
  }
130
176
 
@@ -338,6 +338,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
338
338
  log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
339
339
  return { ok: true, served: null, preflight: verdict };
340
340
  }
341
+ backfillHubUpgradeUrl(inst, deps, boxExec);
341
342
  try {
342
343
  log(` [upgrade] (in ${runDir}) ${cmd}`);
343
344
  boxExec(cmd, { cwd: runDir });
@@ -672,6 +673,34 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
672
673
  // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
673
674
  // a call site whose safety depends on a helper's internals is one refactor away from a
674
675
  // shell-injection path (and the apply leg's own guard sits at the route, not here).
676
+ // WHERE THIS PROJECT'S UPDATE BUTTON GOES, written on every move (task 1005128): a project
677
+ // provisioned before task 1004608 has no <PREFIX>_HUB_UPGRADE_URL line, and only a
678
+ // settings-apply ever wrote one. The move restarts the service, so the line is read the moment
679
+ // the new core comes up. BEST-EFFORT: it is written before the move and never decides it; an
680
+ // unreadable file, a refused write or a line already right changes nothing and fails nothing.
681
+ function backfillHubUpgradeUrl(inst, deps, boxExec) {
682
+ // The slug is interpolated into a shell command below, so it is checked HERE, at the point
683
+ // of use, not trusted to a caller that validated it earlier (the rule coreUpgradeInstance
684
+ // states for its own command).
685
+ if (!inst || !isValidSlug(inst.slug)) return false;
686
+ try {
687
+ const { hubUpgradeEnvPatch, webEnvPath } = require('./provision-units.js'); // eslint-disable-line global-require
688
+ const sudoP = deps.privileged ? 'sudo ' : '';
689
+ const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
690
+ if (!read || read.softFailed) return false;
691
+ const next = hubUpgradeEnvPatch(read.stdout, inst);
692
+ if (!next) return false;
693
+ const write = inst.hosting_shape === 'cloud-host' ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
694
+ if (typeof write !== 'function') return false;
695
+ write(webEnvPath(inst), next, { mode: 0o600, sudo: true });
696
+ deps.log(' [config] wrote where this project\'s Update button goes into web.env (read on restart)');
697
+ return true;
698
+ } catch (e) {
699
+ deps.log(` [config] could not write the Update button's address (${(e && e.message) || e}); the move goes on`);
700
+ return false;
701
+ }
702
+ }
703
+
675
704
  function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', automatic = false, selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
676
705
  if (!isValidSlug(inst && inst.slug)) {
677
706
  throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
@@ -1164,4 +1193,4 @@ async function snapshotAllCoreVersions(deps) {
1164
1193
  return { seen };
1165
1194
  }
1166
1195
 
1167
- module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
1196
+ module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, backfillHubUpgradeUrl, snapshotCoreVersions, snapshotAllCoreVersions };