@bongos/core 1.24.56 → 1.24.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +71 -51
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +0 -2
- package/clients/bongos-client/index.cjs +0 -2
- package/clients/bongos-client/index.d.ts +1 -3
- package/clients/bongos-client/index.mjs +0 -2
- package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -37
- package/docs/api-reference.md +2 -3
- package/docs/copy-inventory.md +38 -44
- package/docs/copy-registry.json +44 -108
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +4 -4
- package/modules/hall-ui/public/deploy-handoff.js +11 -3
- package/modules/hall-ui/public/deploy.js +10 -3
- package/modules/hall-ui/public/settings-software-update.js +4 -71
- package/modules/hall-ui/public/update-pill.js +6 -31
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/css-literal-scan.js +144 -0
- package/scripts/bongos/design-generators-fresh.js +32 -0
- package/scripts/bongos/fitness-ratchets.js +70 -3
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/provision-core-upgrade.js +30 -1
- package/scripts/bongos/provision-units.js +18 -0
- package/scripts/gds/css-literal-scan.js +7 -0
- package/scripts/gds/design-generators-fresh.js +6 -0
- package/scripts/hall-preview/server.js +0 -3
- package/src/bongos/module-scope-map.js +1 -1
- package/src/bongos/routes/core-update.js +36 -76
- package/src/bongos/routes/feedback.js +1 -2
- package/src/module-api.js +1 -1
- package/tests/deploy_handoff.mjs +23 -2
- package/tests/deploy_page_projects.mjs +35 -3
- package/tests/design_counts_ratchet.mjs +134 -0
- package/tests/provision_settings_apply.mjs +52 -0
- package/tests/software_update.mjs +35 -5
- package/tests/update_pill.mjs +9 -5
- package/src/bongos/upgrade-request.js +0 -158
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
|
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
|
|
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;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.58",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.58",
|
|
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.
|
|
3
|
+
"version": "1.24.58",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -11307,5 +11307,17 @@
|
|
|
11307
11307
|
"id": "1005087",
|
|
11308
11308
|
"text": "The Studio, the thinking sky, the tweak editor, the city, approvals, the atlas and the welcome screen now take their colours and fonts from the project's own branding, so a re-branded project sees them in its own colours; the"
|
|
11309
11309
|
}
|
|
11310
|
+
],
|
|
11311
|
+
"1.24.57": [
|
|
11312
|
+
{
|
|
11313
|
+
"id": "1005093",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
11310
11322
|
]
|
|
11311
11323
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// css-literal-scan.js — the detector behind the per-stylesheet DESIGN COUNTS
|
|
4
|
+
// ratchet in fitness-ratchets.js (task 1005093, goal 1000138 "one Cloud Bongos
|
|
5
|
+
// design system"; DESIGN.md, the Fifteen Rule and the --ds-* scales).
|
|
6
|
+
//
|
|
7
|
+
// WHAT IT COUNTS, per tracked stylesheet, in five kinds:
|
|
8
|
+
// colours — every typed-in colour: a hex (#abc, #aabbcc, #aabbccdd) or a
|
|
9
|
+
// colour function (rgb/rgba/hsl/hsla/hwb/lab/lch/oklab/oklch).
|
|
10
|
+
// Counted per OCCURRENCE. The one literal the Fifteen Rule allows
|
|
11
|
+
// — black or white with alpha as a scrim/shadow/halo
|
|
12
|
+
// (rgba(0,0,0,.4), rgb(255 255 255 / .2)) — is not counted.
|
|
13
|
+
// Named colours (red, white…) are not detected.
|
|
14
|
+
// font_sizes — font-size / font declarations whose value carries a typed-in
|
|
15
|
+
// non-zero length (px, rem, em, pt, %, vw…) instead of a token.
|
|
16
|
+
// radii — *-radius declarations with a typed-in non-zero length.
|
|
17
|
+
// shadows — box-shadow / text-shadow declarations that are not none/inherit
|
|
18
|
+
// and carry a typed-in length or colour.
|
|
19
|
+
// breakpoints — @media / @container rules with a typed-in length (a custom
|
|
20
|
+
// property cannot be used in a media query, so these never become
|
|
21
|
+
// tokens; the count stops the set of breakpoints sprawling).
|
|
22
|
+
// Sizes, radii, shadows and breakpoints are counted per DECLARATION / RULE.
|
|
23
|
+
//
|
|
24
|
+
// WHAT IT DOES NOT COUNT. A custom-property declaration (`--x: #123`) is a token
|
|
25
|
+
// DEFINITION — tokens.css and the hall's token block are exactly where literals
|
|
26
|
+
// belong — so its value is skipped. Page-private palettes are guarded separately
|
|
27
|
+
// (tests/hall_private_palettes.mjs). Custom-property NAMES are removed from a
|
|
28
|
+
// value before it is read (so --ds-space-12 is not a "12"), and url(...) bodies
|
|
29
|
+
// are emptied (a data URI is not a colour). A literal inside a var() fallback
|
|
30
|
+
// DOES count: a fallback is a typed-in value.
|
|
31
|
+
//
|
|
32
|
+
// Scope: every tracked *.css outside docs/ (the design mocks there are not
|
|
33
|
+
// served), node_modules and vendor/.
|
|
34
|
+
//
|
|
35
|
+
// Print the counts: node scripts/bongos/css-literal-scan.js [--all]
|
|
36
|
+
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const { execFileSync } = require('child_process');
|
|
40
|
+
|
|
41
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
42
|
+
const KINDS = ['colours', 'font_sizes', 'radii', 'shadows', 'breakpoints'];
|
|
43
|
+
|
|
44
|
+
const EXCLUDE_RE = /^docs\/|(^|\/)(node_modules|vendor)\//;
|
|
45
|
+
const COMMENT_RE = /\/\*[\s\S]*?\*\//g;
|
|
46
|
+
const URL_RE = /url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\)/gi;
|
|
47
|
+
const VAR_NAME_RE = /--[\w-]+/g;
|
|
48
|
+
// A declaration: a property, a colon, and a value that ends at ; or } (a selector
|
|
49
|
+
// such as `a:hover {` ends at `{`, so it never matches).
|
|
50
|
+
const DECL_RE = /(?:^|[{;\s])(-?-?[a-zA-Z][\w-]*)\s*:\s*([^;{}]+)(?=[;}])/g;
|
|
51
|
+
const AT_RULE_RE = /@(media|container)\b([^{;]*)\{/g;
|
|
52
|
+
const HEX_RE = /#[0-9a-fA-F]{3,8}(?![\w-])/g;
|
|
53
|
+
const COLOUR_FN_RE = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(([^()]*)\)/gi;
|
|
54
|
+
const LENGTH_RE = /(?<![\w.#-])(\d*\.?\d+)(px|rem|em|pt|vw|vh|vmin|vmax|ch|ex|%)(?![\w%])/g;
|
|
55
|
+
const MEDIA_LENGTH_RE = /(?<![\w.-])\d*\.?\d+(px|em|rem)(?![\w])/;
|
|
56
|
+
const NO_SHADOW_RE = /^\s*(none|inherit|initial|unset|revert)\s*(!important)?\s*$/i;
|
|
57
|
+
|
|
58
|
+
/** True for black or white with alpha — the Fifteen Rule's one allowed literal. */
|
|
59
|
+
function isScrim(args) {
|
|
60
|
+
const parts = args.split(/[\s,/]+/).filter(Boolean);
|
|
61
|
+
if (parts.length !== 4) return false;
|
|
62
|
+
const rgb = parts.slice(0, 3).map((p) => p.toLowerCase());
|
|
63
|
+
const black = rgb.every((p) => p === '0' || p === '0%');
|
|
64
|
+
const white = rgb.every((p) => p === '255' || p === '100%');
|
|
65
|
+
return black || white;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function colourCount(value) {
|
|
69
|
+
let n = (value.match(HEX_RE) || []).length;
|
|
70
|
+
COLOUR_FN_RE.lastIndex = 0;
|
|
71
|
+
let m;
|
|
72
|
+
while ((m = COLOUR_FN_RE.exec(value))) {
|
|
73
|
+
const fn = m[0].slice(0, m[0].indexOf('(')).toLowerCase();
|
|
74
|
+
if ((fn === 'rgb' || fn === 'rgba') && isScrim(m[1])) continue;
|
|
75
|
+
n += 1;
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasLength(value) {
|
|
81
|
+
LENGTH_RE.lastIndex = 0;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = LENGTH_RE.exec(value))) if (Number(m[1]) !== 0) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The five counts for one stylesheet's text. */
|
|
88
|
+
function countSheet(text) {
|
|
89
|
+
const counts = Object.fromEntries(KINDS.map((k) => [k, 0]));
|
|
90
|
+
const css = String(text).replace(COMMENT_RE, ' ').replace(URL_RE, 'url()');
|
|
91
|
+
AT_RULE_RE.lastIndex = 0;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = AT_RULE_RE.exec(css))) if (MEDIA_LENGTH_RE.test(m[2])) counts.breakpoints += 1;
|
|
94
|
+
DECL_RE.lastIndex = 0;
|
|
95
|
+
while ((m = DECL_RE.exec(css))) {
|
|
96
|
+
const prop = m[1].toLowerCase();
|
|
97
|
+
if (prop.startsWith('--')) continue; // a token definition
|
|
98
|
+
const value = m[2].replace(VAR_NAME_RE, ' ');
|
|
99
|
+
counts.colours += colourCount(value);
|
|
100
|
+
if ((prop === 'font-size' || prop === 'font') && hasLength(value)) counts.font_sizes += 1;
|
|
101
|
+
else if (prop.endsWith('radius') && hasLength(value)) counts.radii += 1;
|
|
102
|
+
else if ((prop === 'box-shadow' || prop === 'text-shadow') && !NO_SHADOW_RE.test(value)
|
|
103
|
+
&& (hasLength(value) || colourCount(value) > 0)) counts.shadows += 1;
|
|
104
|
+
}
|
|
105
|
+
return counts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function trackedStylesheets(root = ROOT) {
|
|
109
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', '*.css'], { cwd: root, encoding: 'utf8' });
|
|
110
|
+
return out.split('\0').filter((f) => f && !EXCLUDE_RE.test(f)).sort();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Counts for every tracked stylesheet: { sheets: { [path]: counts }, scanned }.
|
|
115
|
+
* Sheets whose five counts are all zero are omitted (a missing sheet reads as zero).
|
|
116
|
+
*/
|
|
117
|
+
function scanCssLiterals({ root = ROOT, files = null, read = null } = {}) {
|
|
118
|
+
const list = files || trackedStylesheets(root);
|
|
119
|
+
const reader = read || ((f) => fs.readFileSync(path.join(root, f), 'utf8'));
|
|
120
|
+
const sheets = {};
|
|
121
|
+
let scanned = 0;
|
|
122
|
+
for (const f of list) {
|
|
123
|
+
let text;
|
|
124
|
+
try { text = reader(f); } catch { continue; }
|
|
125
|
+
scanned += 1;
|
|
126
|
+
const c = countSheet(text);
|
|
127
|
+
if (KINDS.some((k) => c[k] > 0)) sheets[f] = c;
|
|
128
|
+
}
|
|
129
|
+
return { sheets, scanned };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (require.main === module) {
|
|
133
|
+
const { sheets, scanned } = scanCssLiterals();
|
|
134
|
+
const totals = Object.fromEntries(KINDS.map((k) => [k, 0]));
|
|
135
|
+
for (const [f, c] of Object.entries(sheets)) {
|
|
136
|
+
for (const k of KINDS) totals[k] += c[k];
|
|
137
|
+
if (process.argv.includes('--all') || KINDS.some((k) => c[k] > 0)) {
|
|
138
|
+
process.stdout.write(`${f} ${KINDS.map((k) => `${k}=${c[k]}`).join(' ')}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(`\n${scanned} stylesheet(s) scanned · totals ${KINDS.map((k) => `${k}=${totals[k]}`).join(' ')}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { scanCssLiterals, countSheet, KINDS };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// design-generators-fresh.js — the design system's generated files are fresh
|
|
3
|
+
// (task 1005093, goal 1000138). Part of fitness.js's "generated artifacts fresh"
|
|
4
|
+
// check; kept in its own file because fitness.js sits at the 1,500-line budget.
|
|
5
|
+
//
|
|
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
|
|
8
|
+
// regeneration used to surface only as a failing unit test; this names it in fitness.
|
|
9
|
+
// Skipped when the ui-design module is not in the checkout. `root` is injectable for
|
|
10
|
+
// tests/design_counts_ratchet.mjs, which runs it against a copied tree.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const { REPO_ROOT } = require('./fitness-lib.js');
|
|
17
|
+
|
|
18
|
+
function checkDesignGeneratorsFresh(root = REPO_ROOT) {
|
|
19
|
+
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'],
|
|
23
|
+
]) {
|
|
24
|
+
const gen = path.join(root, 'modules', 'ui-design', 'scripts', script);
|
|
25
|
+
if (!fs.existsSync(gen)) continue;
|
|
26
|
+
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}).`); }
|
|
28
|
+
}
|
|
29
|
+
return violations;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { checkDesignGeneratorsFresh };
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
// docs-entropy scanner's three counts, reused as a library
|
|
28
28
|
// source_text_only_tests — test files that only read source text and match
|
|
29
29
|
// its wording, never running the code (task 1004868)
|
|
30
|
+
// design counts — PER STYLESHEET, the typed-in colours, font sizes, radii,
|
|
31
|
+
// shadows and breakpoints (task 1005093, goal 1000138; detector:
|
|
32
|
+
// css-literal-scan.js). Kept in their own `design_counts` object rather than
|
|
33
|
+
// `metrics` — ~60 sheets × 5 kinds would drown the metric line — but with the
|
|
34
|
+
// same verdict: higher than baseline fails, lower nudges a ratchet-down. A
|
|
35
|
+
// sheet with no baseline row has a baseline of zero, so a NEW sheet starts on
|
|
36
|
+
// the --ds-* tokens.
|
|
30
37
|
//
|
|
31
38
|
// The knip dead-code ratchet needs node_modules + ~seconds, so it is NOT part
|
|
32
39
|
// of the in-suite check: CI runs `node scripts/bongos/fitness-ratchets.js --knip`
|
|
@@ -535,6 +542,46 @@ function skillListingMetrics() {
|
|
|
535
542
|
}
|
|
536
543
|
}
|
|
537
544
|
|
|
545
|
+
// ---------- design counts (task 1005093) ----------
|
|
546
|
+
|
|
547
|
+
const DESIGN_HINT = ' — use a --ds-* scale token (modules/ui-design/public/tokens.css) or one of the fifteen colour '
|
|
548
|
+
+ 'tokens instead of a typed-in value (DESIGN.md, the Fifteen Rule). Run '
|
|
549
|
+
+ '`node scripts/bongos/css-literal-scan.js` to see every sheet\'s counts and the rule.';
|
|
550
|
+
|
|
551
|
+
// Current per-sheet counts, or null when the scan found no stylesheet at all —
|
|
552
|
+
// a collapsed enumeration is a scan defect, never a flattering all-zero tree.
|
|
553
|
+
function designCounts() {
|
|
554
|
+
const { scanCssLiterals } = require('./css-literal-scan.js');
|
|
555
|
+
const { sheets, scanned } = scanCssLiterals();
|
|
556
|
+
if (!scanned) {
|
|
557
|
+
process.stderr.write('fitness-ratchets: design counts NOT MEASURED — the stylesheet scan enumerated ZERO files, which is a scan defect, not a clean tree\n');
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
return sheets;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Pure verdict over { sheet: { kind: n } } maps. Keys starting with `_` are notes.
|
|
564
|
+
function compareDesignCounts(current, baseline) {
|
|
565
|
+
const { KINDS } = require('./css-literal-scan.js');
|
|
566
|
+
const violations = [];
|
|
567
|
+
const warnings = [];
|
|
568
|
+
const sheets = new Set([...Object.keys(current || {}), ...Object.keys(baseline || {})].filter((k) => !k.startsWith('_')));
|
|
569
|
+
for (const sheet of [...sheets].sort()) {
|
|
570
|
+
const cur = (current || {})[sheet] || {};
|
|
571
|
+
const base = (baseline || {})[sheet] || {};
|
|
572
|
+
for (const kind of KINDS) {
|
|
573
|
+
const value = cur[kind] || 0;
|
|
574
|
+
const floor = base[kind] || 0;
|
|
575
|
+
if (value > floor) {
|
|
576
|
+
violations.push(`design count ${kind} in ${sheet} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass${DESIGN_HINT}`);
|
|
577
|
+
} else if (value < floor) {
|
|
578
|
+
warnings.push(`ratchet-down available: design count ${kind} in ${sheet} improved to ${value} (baseline ${floor}) — lower it in config/fitness-baselines.json design_counts`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return { violations, warnings };
|
|
583
|
+
}
|
|
584
|
+
|
|
538
585
|
function computeMetrics({ withDocs = true } = {}) {
|
|
539
586
|
const files = trackedJsFiles();
|
|
540
587
|
const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
|
|
@@ -560,15 +607,28 @@ function computeMetrics({ withDocs = true } = {}) {
|
|
|
560
607
|
// computeMetrics() sweep — git ls-files plus every tracked .js plus the
|
|
561
608
|
// docs-entropy scan, measured at 23.7s + 2.5s + 2.8s, i.e. ~29s of this file's
|
|
562
609
|
// 29s runtime, which is what pushed it into the 90s timeout in the first place.
|
|
563
|
-
|
|
610
|
+
// `designCounts` (task 1005093) is injectable the same way; when a test injects
|
|
611
|
+
// `metrics` alone, the stylesheet scan is skipped rather than run for real.
|
|
612
|
+
function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null, designCounts: injectedDesign = null } = {}) {
|
|
564
613
|
const metrics = injectedMetrics === null ? computeMetrics() : injectedMetrics;
|
|
565
|
-
const { metrics: baselines, error: baselineError } = readBaselines(baselinePath);
|
|
614
|
+
const { metrics: baselines, error: baselineError, parsed } = readBaselines(baselinePath);
|
|
566
615
|
const { violations, warnings } = compareToBaselines(metrics, baselines);
|
|
616
|
+
const design = injectedDesign !== null ? injectedDesign : (injectedMetrics === null ? designCounts() : null);
|
|
617
|
+
if (design) {
|
|
618
|
+
const designBaseline = parsed && parsed.design_counts;
|
|
619
|
+
if (!baselineError && (!designBaseline || typeof designBaseline !== 'object')) {
|
|
620
|
+
violations.push('config/fitness-baselines.json has no "design_counts" object — the per-stylesheet design ratchet cannot verify anything, so it fails CLOSED. Bootstrap deliberately: node scripts/bongos/fitness-ratchets.js --update-baselines');
|
|
621
|
+
} else if (designBaseline) {
|
|
622
|
+
const d = compareDesignCounts(design, designBaseline);
|
|
623
|
+
violations.push(...d.violations);
|
|
624
|
+
warnings.push(...d.warnings);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
567
627
|
// task 1003227: an unreadable baseline file is a hard failure, not silence. Put it
|
|
568
628
|
// FIRST so it is the headline rather than buried under per-metric noise.
|
|
569
629
|
if (baselineError) violations.unshift(baselineError);
|
|
570
630
|
return {
|
|
571
|
-
name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests only tighten (task 1003129)',
|
|
631
|
+
name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests, per-stylesheet design counts only tighten (task 1003129)',
|
|
572
632
|
hardFail: violations.length > 0,
|
|
573
633
|
violations,
|
|
574
634
|
warnings,
|
|
@@ -598,6 +658,12 @@ function main() {
|
|
|
598
658
|
// this file cannot be eaten by a refresh either.
|
|
599
659
|
const preserved = existingFile && typeof existingFile === 'object' ? { ...existingFile } : {};
|
|
600
660
|
delete preserved.metrics;
|
|
661
|
+
// design_counts is recomputed too (task 1005093), keeping its `_` notes.
|
|
662
|
+
const design = designCounts();
|
|
663
|
+
if (design) {
|
|
664
|
+
const notes = Object.fromEntries(Object.entries(preserved.design_counts || {}).filter(([k]) => k.startsWith('_')));
|
|
665
|
+
preserved.design_counts = { ...notes, ...design };
|
|
666
|
+
}
|
|
601
667
|
if (!preserved._comment) {
|
|
602
668
|
preserved._comment = 'fitness-ratchets baselines (task 1003129): quality debt recorded at gate-adoption. Shrink-only — lower a value when the metric improves; raising one to pass CI is out of policy.';
|
|
603
669
|
}
|
|
@@ -647,6 +713,7 @@ module.exports = {
|
|
|
647
713
|
readBaselines,
|
|
648
714
|
computeMetrics,
|
|
649
715
|
compareToBaselines,
|
|
716
|
+
compareDesignCounts,
|
|
650
717
|
sourceTextTestMetrics,
|
|
651
718
|
duplicateWindowCount,
|
|
652
719
|
normalizeLines,
|
|
@@ -200,7 +200,7 @@ function checkNestedDocsPresent() {
|
|
|
200
200
|
// here would false-fail every PR (the gitignored whole file is absent from a clean checkout, so it
|
|
201
201
|
// always reads as "would change"); `--check-block` is the exact, non-noisy gate. (docs/file-map.md +
|
|
202
202
|
// the OpenAPI docs are still fully committed and unchanged by ADR 0110 — they keep the plain --check.)
|
|
203
|
-
function checkGeneratedArtifactsFresh() {
|
|
203
|
+
function checkGeneratedArtifactsFresh({ designRoot = REPO_ROOT } = {}) { // designRoot: tests only (task 1005093)
|
|
204
204
|
const violations = [];
|
|
205
205
|
try {
|
|
206
206
|
execFileSync(process.execPath, [path.join(__dirname, 'gen-repo-map.js'), '--check-block'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
@@ -254,9 +254,10 @@ function checkGeneratedArtifactsFresh() {
|
|
|
254
254
|
} catch {
|
|
255
255
|
violations.push('docs/page-inventory.json + docs/page-inventory.md are STALE (or two pages claim one id) — a page was added, renamed, retitled or changed its scripts/states. Run `node scripts/bongos/page-inventory.js` and commit (task 1004314).');
|
|
256
256
|
}
|
|
257
|
+
violations.push(...require('./design-generators-fresh.js').checkDesignGeneratorsFresh(designRoot)); // task 1005093
|
|
257
258
|
// NB the role packs' responsibility blocks are generated too, but their freshness
|
|
258
259
|
// is asserted by role-pack-guard.js — one check owns "the packs are correct".
|
|
259
|
-
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
|
|
260
|
+
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314), and the design system config/design-tokens.neutral.json + public /brand reference (task 1005093). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
|
|
260
261
|
}
|
|
261
262
|
|
|
262
263
|
// ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
|
|
@@ -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 };
|
|
@@ -447,9 +447,27 @@ function hubUpgradeEnvVarsFor(inst) {
|
|
|
447
447
|
return { [`${provisionedEnvPrefix()}_HUB_UPGRADE_URL`]: origin ? `${origin}/deploy?project=${encodeURIComponent(String(inst.slug))}` : '' };
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
// THE BACKFILL (task 1005128). Only a settings-apply wrote that line, and one runs only when a
|
|
451
|
+
// setting changes, so a project provisioned before task 1004608 (toast) never got it and its
|
|
452
|
+
// owner was offered "Ask for this upgrade" instead of the Update button. Every core move now
|
|
453
|
+
// carries the line too: the web.env as it stands, with that ONE line set, or null when it is
|
|
454
|
+
// already right, there is nothing to write, or the file is empty (a missing file is never
|
|
455
|
+
// "patched" into a one-line body). PURE; the core-upgrade leg does the read and the write.
|
|
456
|
+
function hubUpgradeEnvPatch(body, inst) {
|
|
457
|
+
const vars = hubUpgradeEnvVarsFor(inst);
|
|
458
|
+
const [key] = Object.keys(vars);
|
|
459
|
+
const text = String(body == null ? '' : body);
|
|
460
|
+
if (!key || !vars[key] || !text.trim()) return null;
|
|
461
|
+
const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
462
|
+
const have = new RegExp(`^${esc}=(.*)$`, 'm').exec(text);
|
|
463
|
+
if (have && have[1].trim() === vars[key]) return null;
|
|
464
|
+
return upsertEnvVars(text, vars);
|
|
465
|
+
}
|
|
466
|
+
|
|
450
467
|
module.exports = {
|
|
451
468
|
federationHubOrigin,
|
|
452
469
|
hubUpgradeEnvVarsFor,
|
|
470
|
+
hubUpgradeEnvPatch,
|
|
453
471
|
ENV_PREFIX_PACK,
|
|
454
472
|
envPrefixMismatch,
|
|
455
473
|
normalizePackEnvPrefix,
|