@bongos/core 1.19.1039 → 1.19.1040
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 +79 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +39 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +2 -2
- package/docs/copy-registry.json +2 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/deploy.html +9 -0
- package/modules/hall-ui/public/deploy.js +14 -1
- package/modules/hall-ui/public/deploy.states.json +14 -0
- package/modules/lifecycle/conflict-resolve.js +9 -1
- package/modules/npm-release/module.json +19 -0
- package/modules/npm-release/public/work.css +8 -0
- package/modules/npm-release/public/work.js +261 -0
- package/modules/npm-release/registry.js +271 -0
- package/modules/npm-release/routes/work.js +46 -0
- package/modules/npm-release/work.js +252 -0
- package/modules/provisioning/starter-bundles.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/install-git-hooks.js +18 -2
- package/scripts/gds/publish-manifest.js +1 -0
- package/src/bongos/module-scope-map.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +15 -1
- package/tests/merge_driver_register.mjs +28 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_page.mjs +173 -0
- package/tests/npm_release_work.mjs +350 -0
- package/tests/render_check.mjs +3 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// modules/npm-release/public/work.js — the deploy page's "where is the work?" and version
|
|
2
|
+
// history sections (task 1002622). Loaded by /deploy only when the npm-release module is on
|
|
3
|
+
// (hall-ui/public/deploy.js), and rendered into #nr-work / #nr-versions.
|
|
4
|
+
//
|
|
5
|
+
// WHY IT LEADS THE PAGE. The owner, 2026-09-26: "I did so many tasks and changes yesterday
|
|
6
|
+
// and it's not showing anything that's been done." The page answered with two version
|
|
7
|
+
// numbers and a button. So the work comes first, by name, with no click needed — each task
|
|
8
|
+
// in the stage it has actually reached — and the version numbers come after it, as the list
|
|
9
|
+
// of what each version carried.
|
|
10
|
+
//
|
|
11
|
+
// THE PAGE KNOWS NOTHING (deploy.js's rule, kept here). Every stage, version and date is the
|
|
12
|
+
// server's reading; nothing is compared or inferred in the browser, and a reading the server
|
|
13
|
+
// could not take is shown as that, never as an empty list.
|
|
14
|
+
(() => {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const API = '/api/bongos';
|
|
18
|
+
const client = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
19
|
+
const { escapeHtml } = window.OTB;
|
|
20
|
+
const $ = (id) => document.getElementById(id);
|
|
21
|
+
|
|
22
|
+
// Rows shown per stage before "Show all" — the released pile is most of a fortnight's
|
|
23
|
+
// work and would push every other stage off the screen.
|
|
24
|
+
const ROWS_SHOWN = 8;
|
|
25
|
+
// Tasks shown per version in the history: a busy release carries seventeen.
|
|
26
|
+
const ENTRIES_SHOWN = 3;
|
|
27
|
+
// Versions on first paint, and per "Show older versions": 20–30 publish a day, so the
|
|
28
|
+
// first screen is today's and the rest is one press away.
|
|
29
|
+
const VERSIONS_FIRST = 6;
|
|
30
|
+
const VERSIONS_MORE = 10;
|
|
31
|
+
// Stage keys and `v:<version>` keys the reader has opened with "Show all".
|
|
32
|
+
const expanded = new Set();
|
|
33
|
+
|
|
34
|
+
// The stages in the order work travels, each with the sentence that says what it means.
|
|
35
|
+
// `live` is the version this hall runs; it is null for a package this hall does not run.
|
|
36
|
+
// `fig` is the short form the count strip uses: its cells truncate past ~12 capitals.
|
|
37
|
+
function stageList(live) {
|
|
38
|
+
return [
|
|
39
|
+
{ key: 'not_merged', fig: 'Not merged', name: 'Done, not merged', note: 'Marked done in the ledger; the code is not on main yet. A task that sits here for hours is a stranded ship.' },
|
|
40
|
+
{ key: 'unversioned', fig: 'Unversioned', name: 'Merged, waiting for a version', note: 'On main, and not yet inside a published version.' },
|
|
41
|
+
live
|
|
42
|
+
? { key: 'not_live', fig: 'Not running', name: 'Published, not running here', note: `Inside a published version newer than ${live}, the one this hall runs.` }
|
|
43
|
+
: null,
|
|
44
|
+
live
|
|
45
|
+
? { key: 'live_unreleased', fig: 'Unreleased', name: 'Running here, not released', note: 'Other projects are not offered these yet.' }
|
|
46
|
+
: { key: 'live_unreleased', fig: 'Unreleased', name: 'Published, not released', note: 'Other projects are not offered these yet.' },
|
|
47
|
+
{ key: 'released', fig: 'Released', name: 'Released', note: live ? 'Running here and offered to other projects.' : 'Offered to other projects.' },
|
|
48
|
+
{ key: 'no_version', fig: 'No version', name: 'Shipped, in no version', note: 'No published version names these: ledger-only ships such as scope edits and checks, or a merge whose note did not name its task.' },
|
|
49
|
+
].filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function when(iso) {
|
|
53
|
+
const t = Date.parse(iso);
|
|
54
|
+
if (!Number.isFinite(t)) return '';
|
|
55
|
+
const d = new Date(t);
|
|
56
|
+
const mins = Math.round((Date.now() - t) / 60000);
|
|
57
|
+
let label;
|
|
58
|
+
if (mins < 1) label = 'just now';
|
|
59
|
+
else if (mins < 60) label = `${mins}m ago`;
|
|
60
|
+
else if (mins < 48 * 60) label = `${Math.round(mins / 60)}h ago`;
|
|
61
|
+
else label = d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
|
62
|
+
return `<span class="ov-row__when" title="${escapeHtml(d.toLocaleString())}">${escapeHtml(label)}</span>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The title IS the link, the hall's own row idiom (oversight.css `a.ov-row__title`),
|
|
66
|
+
// which is what buys it the 24px floor and the row's ink rather than link colour.
|
|
67
|
+
function taskTitle(id, title) {
|
|
68
|
+
return `<a class="ov-row__title" href="/task/${encodeURIComponent(id)}">${escapeHtml(title || `Task ${id}`)}</a>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function taskRow(t, { offeredInNote = false } = {}) {
|
|
72
|
+
const facts = [];
|
|
73
|
+
if (t.builder) facts.push(`<span class="ov-fact">${escapeHtml(t.builder)}</span>`);
|
|
74
|
+
if (t.version) facts.push(`<span class="ov-fact">in <b>${escapeHtml(t.version)}</b></span>`);
|
|
75
|
+
if (t.status && t.status !== 'shipped') facts.push(`<span class="ov-fact">${escapeHtml(t.status)}</span>`);
|
|
76
|
+
if (t.offered && !offeredInNote) facts.push('<span class="ov-fact"><b>already offered to other projects</b></span>');
|
|
77
|
+
return `<li class="ov-row ov-row--dense" data-task="${escapeHtml(t.id)}">
|
|
78
|
+
<div class="ov-row__main">
|
|
79
|
+
${taskTitle(t.id, t.title)}
|
|
80
|
+
<div class="ov-row__facts">${facts.join('')}</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="ov-row__right">${when(t.at)}</div>
|
|
83
|
+
</li>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function moreButton(key, total, shown) {
|
|
87
|
+
return total > shown ? `<button type="button" class="ov-toggle" data-expand="${escapeHtml(key)}">Show all ${total}</button>` : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stageHtml(stage, rows) {
|
|
91
|
+
if (!rows.length) return '';
|
|
92
|
+
const shown = expanded.has(stage.key) ? rows : rows.slice(0, ROWS_SHOWN);
|
|
93
|
+
// Published before this hall ran it is the order Release exists to prevent. When it
|
|
94
|
+
// is true of the whole group (every merge is released today) it is said once, in the
|
|
95
|
+
// group's note, rather than on every row.
|
|
96
|
+
const allOffered = rows.every((t) => t.offered);
|
|
97
|
+
const note = allOffered ? `${stage.note} Already offered to other projects.` : stage.note;
|
|
98
|
+
// The sentence sits UNDER the head, not in `.ov-group__note`: that slot is sized for a
|
|
99
|
+
// few words, and a sentence there squeezes the stage name to one word a line on a phone.
|
|
100
|
+
return `<div class="ov-group" data-stage="${stage.key}">
|
|
101
|
+
<div class="ov-group__head">
|
|
102
|
+
<span class="ov-group__name">${escapeHtml(stage.name)}</span>
|
|
103
|
+
<span class="ov-group__n">${rows.length}</span>
|
|
104
|
+
</div>
|
|
105
|
+
<p class="ov-row__sub">${escapeHtml(note)}</p>
|
|
106
|
+
<ul class="ov-rows">${shown.map((t) => taskRow(t, { offeredInNote: allOffered })).join('')}</ul>
|
|
107
|
+
${moreButton(stage.key, rows.length, shown.length)}
|
|
108
|
+
</div>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function figsHtml(stages, d) {
|
|
112
|
+
return `<div class="ov-figs">${stages.map((s) => `
|
|
113
|
+
<div class="ov-fig">
|
|
114
|
+
<span class="ov-fig__l" title="${escapeHtml(s.name)}">${escapeHtml(s.fig)}</span>
|
|
115
|
+
<span class="ov-fig__v">${(d.stages[s.key] || []).length}</span>
|
|
116
|
+
</div>`).join('')}</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// What the page could NOT read, said plainly above what it could.
|
|
120
|
+
function problemsHtml(d) {
|
|
121
|
+
const out = [];
|
|
122
|
+
if (d.registry && !d.registry.ok) out.push(`The npm registry could not be read (${d.registry.error}), so which version is newest and which is released is unknown.`);
|
|
123
|
+
if (d.notes && d.notes.error) {
|
|
124
|
+
out.push(d.notes.covers_to
|
|
125
|
+
? `The release notes of the newest version could not be read (${d.notes.error}). Versions after ${d.notes.covers_to} show no work below, and work inside them may be listed as waiting.`
|
|
126
|
+
: `No release notes could be read (${d.notes.error}), so no task can be placed in a version.`);
|
|
127
|
+
}
|
|
128
|
+
if (!out.length) return '';
|
|
129
|
+
return `<div class="ov-note ov-note--warn">${out.map((p, i) => `<p>${i === 0 ? '<strong>Partial reading.</strong> ' : ''}${escapeHtml(p)}</p>`).join('')}</div>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ledeHtml(d) {
|
|
133
|
+
const parts = [`Every task finished in the last ${d.window_days} days, in the stage it has actually reached.`];
|
|
134
|
+
if (d.live) parts.push(`This hall runs ${d.live}.`);
|
|
135
|
+
if (d.newest) parts.push(`The newest published version is ${d.newest}.`);
|
|
136
|
+
if (d.released) parts.push(`Other projects are offered ${d.released}.`);
|
|
137
|
+
return `<p class="scroll__lede">${escapeHtml(parts.join(' '))}</p>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function versionHtml(v) {
|
|
141
|
+
const marks = [];
|
|
142
|
+
if (v.live) marks.push('running here');
|
|
143
|
+
if (v.released) marks.push('released');
|
|
144
|
+
const date = v.published_at ? new Date(v.published_at).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '';
|
|
145
|
+
const note = [date, ...marks].filter(Boolean).join(' · ');
|
|
146
|
+
let body;
|
|
147
|
+
if (v.entries === null) body = '<p class="ov-row__sub">This version\'s notes could not be read.</p>';
|
|
148
|
+
else if (!v.entries.length) body = '<p class="ov-row__sub">No task work — housekeeping merges only.</p>';
|
|
149
|
+
else {
|
|
150
|
+
const key = `v:${v.version}`;
|
|
151
|
+
const shown = expanded.has(key) ? v.entries : v.entries.slice(0, ENTRIES_SHOWN);
|
|
152
|
+
body = `<ul class="ov-rows">${shown.map((e) => `<li class="ov-row ov-row--dense">
|
|
153
|
+
<div class="ov-row__main">
|
|
154
|
+
${e.id ? taskTitle(e.id, e.title) : `<span class="ov-row__title">${escapeHtml(e.text)}</span>`}
|
|
155
|
+
${e.id ? `<p class="ov-row__sub">${escapeHtml(e.text)}</p>` : ''}
|
|
156
|
+
</div>
|
|
157
|
+
</li>`).join('')}</ul>${moreButton(key, v.entries.length, shown.length)}`;
|
|
158
|
+
}
|
|
159
|
+
return `<div class="ov-group" data-version="${escapeHtml(v.version)}">
|
|
160
|
+
<div class="ov-group__head">
|
|
161
|
+
<span class="ov-group__name ov-key">${escapeHtml(v.version)}</span>
|
|
162
|
+
<span class="ov-group__n">${v.entries ? v.entries.length : '?'}</span>
|
|
163
|
+
<span class="ov-group__note">${escapeHtml(note)}</span>
|
|
164
|
+
</div>
|
|
165
|
+
${body}
|
|
166
|
+
</div>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let data = null;
|
|
170
|
+
let history = [];
|
|
171
|
+
let hasMore = false;
|
|
172
|
+
let loadingMore = false;
|
|
173
|
+
|
|
174
|
+
function paint() {
|
|
175
|
+
const work = $('nr-work');
|
|
176
|
+
const vers = $('nr-versions');
|
|
177
|
+
if (!work || !data) return;
|
|
178
|
+
const stages = stageList(data.live);
|
|
179
|
+
const groups = stages.map((s) => stageHtml(s, data.stages[s.key] || [])).join('');
|
|
180
|
+
work.innerHTML = `
|
|
181
|
+
<h2 class="scroll__h" id="nr-work-h">Where the work is</h2>
|
|
182
|
+
${ledeHtml(data)}
|
|
183
|
+
${problemsHtml(data)}
|
|
184
|
+
${figsHtml(stages, data)}
|
|
185
|
+
${groups || '<p class="ov-row__sub">Nothing was finished in this window.</p>'}
|
|
186
|
+
<p class="ov-foot">read ${escapeHtml(new Date(data.checked_at).toLocaleTimeString())} · ${escapeHtml(data.package)} · release notes from ${escapeHtml(data.notes.source ? `${data.notes.source.from === 'package' ? 'the published' : 'this hall\'s'} ${data.notes.source.version} package` : 'nowhere')}</p>`;
|
|
187
|
+
work.hidden = false;
|
|
188
|
+
if (vers) {
|
|
189
|
+
vers.innerHTML = `
|
|
190
|
+
<h2 class="scroll__h" id="nr-versions-h">Versions</h2>
|
|
191
|
+
<p class="scroll__lede">What each version carried, newest first — the task each merge named, in the words its ship wrote.</p>
|
|
192
|
+
${history.map(versionHtml).join('')}
|
|
193
|
+
${hasMore ? `<button type="button" class="ov-toggle" id="nr-older"${loadingMore ? ' disabled' : ''}>${loadingMore ? 'Loading…' : 'Show older versions'}</button>` : ''}`;
|
|
194
|
+
vers.hidden = false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function fetchWork(query) {
|
|
199
|
+
const res = await client.request('GET', `${API}/npm-release/work${query ? `?${query}` : ''}`);
|
|
200
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) return null; // not this reader's, or module off
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
const err = (res.data && res.data.error) || {};
|
|
203
|
+
throw new Error(err.message || `work → ${res.status}`);
|
|
204
|
+
}
|
|
205
|
+
return res.data;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The module's own sheet, beside this script — fetched only where the module is on.
|
|
209
|
+
function loadSheet() {
|
|
210
|
+
if (document.getElementById('nr-work-css') || !document.createElement) return;
|
|
211
|
+
const l = document.createElement('link');
|
|
212
|
+
l.id = 'nr-work-css';
|
|
213
|
+
l.rel = 'stylesheet';
|
|
214
|
+
l.href = '/modules/npm-release/public/work.css';
|
|
215
|
+
document.head.appendChild(l);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function load() {
|
|
219
|
+
const work = $('nr-work');
|
|
220
|
+
if (!work) return;
|
|
221
|
+
loadSheet();
|
|
222
|
+
try {
|
|
223
|
+
const d = await fetchWork(`limit=${VERSIONS_FIRST}`);
|
|
224
|
+
if (!d) return;
|
|
225
|
+
data = d;
|
|
226
|
+
history = d.history.versions;
|
|
227
|
+
hasMore = d.history.more;
|
|
228
|
+
paint();
|
|
229
|
+
} catch (e) {
|
|
230
|
+
work.innerHTML = `<h2 class="scroll__h">Where the work is</h2>
|
|
231
|
+
<div class="ov-note ov-note--warn"><p><strong>Could not read where the work is.</strong> ${escapeHtml(e.message || String(e))}</p></div>`;
|
|
232
|
+
work.hidden = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function loadOlder() {
|
|
237
|
+
if (loadingMore || !history.length) return;
|
|
238
|
+
loadingMore = true;
|
|
239
|
+
paint();
|
|
240
|
+
try {
|
|
241
|
+
const last = history[history.length - 1].version;
|
|
242
|
+
const d = await fetchWork(`before=${encodeURIComponent(last)}&limit=${VERSIONS_MORE}`);
|
|
243
|
+
if (d) { history = history.concat(d.history.versions); hasMore = d.history.more; }
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (window.OTB.toast) window.OTB.toast(`Could not load older versions: ${e.message || e}`);
|
|
246
|
+
} finally {
|
|
247
|
+
loadingMore = false;
|
|
248
|
+
paint();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
document.addEventListener('click', (ev) => {
|
|
253
|
+
const t = ev.target;
|
|
254
|
+
if (!(t instanceof Element)) return;
|
|
255
|
+
const ex = t.closest('[data-expand]');
|
|
256
|
+
if (ex) { expanded.add(ex.getAttribute('data-expand')); paint(); return; }
|
|
257
|
+
if (t.closest('#nr-older')) loadOlder();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
load();
|
|
261
|
+
})();
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/registry.js — what the npm registry says about the package: which
|
|
4
|
+
// versions exist, when each was published, which one carries the `latest` label, and the
|
|
5
|
+
// release notes the newest version was packed with (task 1002622).
|
|
6
|
+
//
|
|
7
|
+
// WHY THE NEWEST PACKAGE'S NOTES. package-core writes release-notes.json from the WHOLE
|
|
8
|
+
// history at pack time (scripts/gds/release-notes.js, task 1004218), so the newest version
|
|
9
|
+
// alone lists every version before it. One file answers the whole page. The copy the
|
|
10
|
+
// running core carries stops at its own version, which is exactly the range the deploy
|
|
11
|
+
// page is asked about least: the work that is published but not running yet.
|
|
12
|
+
//
|
|
13
|
+
// NEVER A CHILD PROCESS, NEVER A SYNC CALL. This runs inside the web process, so the
|
|
14
|
+
// upgrade runner's `npm pack` + `tar -xzOf` (provision-core-upgrade.js readPackagedNotes)
|
|
15
|
+
// is the wrong tool here: spawnSync would stall every other request for the length of a
|
|
16
|
+
// 24 MB download. The tarball is streamed through gunzip and a small tar reader that keeps
|
|
17
|
+
// only the one file it wants, and the download is dropped the moment that file is read.
|
|
18
|
+
//
|
|
19
|
+
// FAIL-SOFT, AND SAYS SO. Every read resolves `{ ok: false, error }` rather than throwing,
|
|
20
|
+
// and the page renders the error. "Could not read" and "nothing there" are different
|
|
21
|
+
// answers and neither is ever allowed to look like the other.
|
|
22
|
+
|
|
23
|
+
const https = require('node:https');
|
|
24
|
+
const zlib = require('node:zlib');
|
|
25
|
+
|
|
26
|
+
const REGISTRY_ORIGIN = 'https://registry.npmjs.org';
|
|
27
|
+
const RELEASE_NOTES_PATH = 'package/release-notes.json';
|
|
28
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
29
|
+
|
|
30
|
+
// The full packument, not the abbreviated one core-update.js asks for: only the full
|
|
31
|
+
// document carries `time`, and a version history without dates is not reviewable. It is
|
|
32
|
+
// ~2 MB for ~1,000 releases (measured 2026-09-26), so it is read at most every few minutes.
|
|
33
|
+
const PACKUMENT_TTL_MS = 5 * 60 * 1000;
|
|
34
|
+
const PACKUMENT_TIMEOUT_MS = 15000;
|
|
35
|
+
// One tarball download. ~24 MB for @bongos/core 1.19.1039, well under a second from a
|
|
36
|
+
// droplet; the bound is for a registry that stops answering mid-stream.
|
|
37
|
+
const TARBALL_TIMEOUT_MS = 90000;
|
|
38
|
+
// release-notes.json is bounded at 1 MB by its generator; anything past 4 MB is not it.
|
|
39
|
+
const NOTES_MAX_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
// A failed read is retried after this, not on every page load.
|
|
41
|
+
const RETRY_AFTER_MS = 2 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
function compareVersions(a, b) {
|
|
44
|
+
const x = String(a).split('.').map(Number);
|
|
45
|
+
const y = String(b).split('.').map(Number);
|
|
46
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** GET a URL, resolving `{ res }` or `{ error }` — never a throw. */
|
|
51
|
+
function open(get, url, { accept, timeoutMs }) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
55
|
+
let req;
|
|
56
|
+
try {
|
|
57
|
+
req = get(url, { headers: accept ? { accept } : {}, timeout: timeoutMs }, (res) => {
|
|
58
|
+
if (res.statusCode !== 200) { res.resume(); return done({ error: `the registry answered ${res.statusCode}` }); }
|
|
59
|
+
done({ res, req });
|
|
60
|
+
});
|
|
61
|
+
} catch (e) { return done({ error: e.message }); }
|
|
62
|
+
if (req && req.on) {
|
|
63
|
+
req.on('error', (e) => done({ error: e.message }));
|
|
64
|
+
req.on('timeout', () => { try { req.destroy(); } catch { /* gone */ } done({ error: 'the registry did not answer in time' }); });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The facts the page needs out of one packument. PURE.
|
|
71
|
+
* Returns `{ versions, published, latest, newest, tarballs }` — stable x.y.z versions only,
|
|
72
|
+
* because a prerelease is never a release and never a candidate.
|
|
73
|
+
*/
|
|
74
|
+
function summarizePackument(doc) {
|
|
75
|
+
const all = Object.keys((doc && doc.versions) || {}).filter((v) => VERSION_RE.test(v)).sort(compareVersions);
|
|
76
|
+
const time = (doc && doc.time) || {};
|
|
77
|
+
const published = {};
|
|
78
|
+
const tarballs = {};
|
|
79
|
+
for (const v of all) {
|
|
80
|
+
if (typeof time[v] === 'string') published[v] = time[v];
|
|
81
|
+
const dist = doc.versions[v] && doc.versions[v].dist;
|
|
82
|
+
if (dist && typeof dist.tarball === 'string') tarballs[v] = dist.tarball;
|
|
83
|
+
}
|
|
84
|
+
const tag = doc && doc['dist-tags'] && doc['dist-tags'].latest;
|
|
85
|
+
return {
|
|
86
|
+
versions: all,
|
|
87
|
+
published,
|
|
88
|
+
latest: VERSION_RE.test(String(tag || '')) ? tag : null,
|
|
89
|
+
newest: all.length ? all[all.length - 1] : null,
|
|
90
|
+
tarballs,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fetchPackument({ pkg, get, origin }) {
|
|
95
|
+
return open(get, `${origin}/${pkg.replace('/', '%2f')}`, { accept: 'application/json', timeoutMs: PACKUMENT_TIMEOUT_MS })
|
|
96
|
+
.then(({ res, error }) => {
|
|
97
|
+
if (error) return { ok: false, error };
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const chunks = [];
|
|
100
|
+
res.on('data', (c) => chunks.push(c));
|
|
101
|
+
res.on('error', (e) => resolve({ ok: false, error: e.message }));
|
|
102
|
+
res.on('end', () => {
|
|
103
|
+
try {
|
|
104
|
+
const facts = summarizePackument(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
105
|
+
resolve(facts.newest ? { ok: true, ...facts } : { ok: false, error: 'the registry lists no versions of this package' });
|
|
106
|
+
} catch (e) { resolve({ ok: false, error: `the registry's answer could not be read (${e.message})` }); }
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One file out of a gzipped tar stream, as a Buffer — or null when the archive ends
|
|
114
|
+
* without it. Understands the three header kinds npm's tarballs use: plain ustar entries,
|
|
115
|
+
* pax extended headers ('x', whose `path` renames the next entry) and GNU long names ('L').
|
|
116
|
+
* Skipped entries are counted past, never buffered, so a 24 MB tarball costs one file.
|
|
117
|
+
*/
|
|
118
|
+
function extractTarEntry(stream, wanted, { maxBytes = NOTES_MAX_BYTES } = {}) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let buf = Buffer.alloc(0);
|
|
121
|
+
let skip = 0; // bytes of the current entry (plus padding) still to pass over
|
|
122
|
+
let take = null; // { size, pad, parts, got } while reading the wanted entry
|
|
123
|
+
let meta = null; // { size, pad, kind, parts, got } while reading an 'x' / 'L' header
|
|
124
|
+
let nextName = null; // a name a pax/GNU header gave the entry that follows it
|
|
125
|
+
let finished = false;
|
|
126
|
+
const finish = (v, err) => {
|
|
127
|
+
if (finished) return;
|
|
128
|
+
finished = true;
|
|
129
|
+
try { stream.destroy(); } catch { /* already closed */ }
|
|
130
|
+
if (err) reject(err); else resolve(v);
|
|
131
|
+
};
|
|
132
|
+
const cstr = (b, from, len) => {
|
|
133
|
+
const s = b.subarray(from, from + len);
|
|
134
|
+
const z = s.indexOf(0);
|
|
135
|
+
return (z < 0 ? s : s.subarray(0, z)).toString('utf8');
|
|
136
|
+
};
|
|
137
|
+
const padOf = (size) => (512 - (size % 512)) % 512;
|
|
138
|
+
const onBytes = () => {
|
|
139
|
+
while (!finished) {
|
|
140
|
+
if (skip > 0) {
|
|
141
|
+
const n = Math.min(skip, buf.length);
|
|
142
|
+
buf = buf.subarray(n);
|
|
143
|
+
skip -= n;
|
|
144
|
+
if (skip > 0) return;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const body = take || meta;
|
|
148
|
+
if (body) {
|
|
149
|
+
const need = body.size - body.got;
|
|
150
|
+
const n = Math.min(need, buf.length);
|
|
151
|
+
body.parts.push(buf.subarray(0, n));
|
|
152
|
+
body.got += n;
|
|
153
|
+
buf = buf.subarray(n);
|
|
154
|
+
if (body.got < body.size) return;
|
|
155
|
+
const data = Buffer.concat(body.parts);
|
|
156
|
+
if (take) return finish(data);
|
|
157
|
+
if (meta.kind === 'L') nextName = cstr(data, 0, data.length);
|
|
158
|
+
else {
|
|
159
|
+
const m = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString('utf8'));
|
|
160
|
+
if (m) nextName = m[1];
|
|
161
|
+
}
|
|
162
|
+
skip = meta.pad;
|
|
163
|
+
meta = null;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (buf.length < 512) return;
|
|
167
|
+
const h = buf.subarray(0, 512);
|
|
168
|
+
buf = buf.subarray(512);
|
|
169
|
+
if (h.every((x) => x === 0)) return finish(null);
|
|
170
|
+
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8);
|
|
171
|
+
if (!Number.isFinite(size) || size < 0) return finish(null, new Error('the package archive is damaged'));
|
|
172
|
+
const type = String.fromCharCode(h[156] || 48);
|
|
173
|
+
if (type === 'x' || type === 'L') {
|
|
174
|
+
if (size > maxBytes) return finish(null, new Error('the package archive is damaged'));
|
|
175
|
+
meta = { size, pad: padOf(size), kind: type, parts: [], got: 0 };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const prefix = cstr(h, 345, 155);
|
|
179
|
+
const name = nextName || (prefix ? `${prefix}/${cstr(h, 0, 100)}` : cstr(h, 0, 100));
|
|
180
|
+
nextName = null;
|
|
181
|
+
if (name === wanted && (type === '0' || type === '\0')) {
|
|
182
|
+
if (size > maxBytes) return finish(null, new Error(`${wanted} is larger than any release notes file`));
|
|
183
|
+
take = { size, pad: 0, parts: [], got: 0 };
|
|
184
|
+
if (size === 0) return finish(Buffer.alloc(0));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
skip = size + padOf(size);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
stream.on('data', (c) => { if (finished) return; buf = buf.length ? Buffer.concat([buf, c]) : c; onBytes(); });
|
|
191
|
+
stream.on('end', () => finish(null));
|
|
192
|
+
stream.on('error', (e) => finish(null, e));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fetchPackagedNotes({ version, tarball, get, origin }) {
|
|
197
|
+
// The URL came off the registry, but it is still only followed on the registry's own
|
|
198
|
+
// origin: a packument pointing somewhere else is not something this page will fetch.
|
|
199
|
+
if (typeof tarball !== 'string' || !tarball.startsWith(`${origin}/`)) {
|
|
200
|
+
return Promise.resolve({ ok: false, error: `the registry gave no download for ${version}` });
|
|
201
|
+
}
|
|
202
|
+
return open(get, tarball, { timeoutMs: TARBALL_TIMEOUT_MS }).then(async ({ res, req, error }) => {
|
|
203
|
+
if (error) return { ok: false, error: `could not download ${version} (${error})` };
|
|
204
|
+
const gunzip = zlib.createGunzip();
|
|
205
|
+
res.on('error', (e) => gunzip.destroy(e));
|
|
206
|
+
res.pipe(gunzip);
|
|
207
|
+
try {
|
|
208
|
+
const data = await extractTarEntry(gunzip, RELEASE_NOTES_PATH);
|
|
209
|
+
if (!data) return { ok: false, missing: true, error: `${version} was published without release notes` };
|
|
210
|
+
const notes = JSON.parse(data.toString('utf8'));
|
|
211
|
+
if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return { ok: false, error: `the release notes in ${version} are not a list of versions` };
|
|
212
|
+
return { ok: true, notes };
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return { ok: false, error: `could not read the release notes in ${version} (${e.message})` };
|
|
215
|
+
} finally {
|
|
216
|
+
try { res.destroy(); } catch { /* gone */ }
|
|
217
|
+
try { if (req && req.destroy) req.destroy(); } catch { /* gone */ }
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Process-local, like core-update.js's: derived facts with a short life, and a restart
|
|
223
|
+
// re-deriving them costs one request. Notes are immutable per version, so only the newest
|
|
224
|
+
// one is ever worth keeping.
|
|
225
|
+
function makeStore() {
|
|
226
|
+
return { packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } };
|
|
227
|
+
}
|
|
228
|
+
const defaultStore = makeStore();
|
|
229
|
+
|
|
230
|
+
async function cached(slot, fresh, load, t) {
|
|
231
|
+
if (slot.value && fresh(slot)) return slot.value;
|
|
232
|
+
if (slot.inflight) return slot.inflight;
|
|
233
|
+
slot.inflight = (async () => {
|
|
234
|
+
try {
|
|
235
|
+
const v = await load();
|
|
236
|
+
slot.value = v;
|
|
237
|
+
slot.at = t;
|
|
238
|
+
return v;
|
|
239
|
+
} finally { slot.inflight = null; }
|
|
240
|
+
})();
|
|
241
|
+
return slot.inflight;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Everything the registry can tell the deploy page about `pkg`, in one call.
|
|
246
|
+
*
|
|
247
|
+
* Returns `{ registry, notes }`:
|
|
248
|
+
* registry — `{ ok, versions, published, latest, newest }` or `{ ok:false, error }`
|
|
249
|
+
* notes — the newest version's release notes, `{ ok, version, notes }` or
|
|
250
|
+
* `{ ok:false, version, error }`; `{ ok:false, skipped }` when the caller
|
|
251
|
+
* already holds notes that reach the newest version (`haveNotesFor`).
|
|
252
|
+
*
|
|
253
|
+
* `get` swaps the HTTPS transport and `store`/`now` the cache, so tests drive the real
|
|
254
|
+
* parsing and failure paths rather than a stub standing in front of them.
|
|
255
|
+
*/
|
|
256
|
+
async function readRegistry({ pkg, haveNotesFor = () => false, get = https.get, origin = REGISTRY_ORIGIN, now = Date.now, store = defaultStore } = {}) {
|
|
257
|
+
const t = now();
|
|
258
|
+
const pk = store.packument;
|
|
259
|
+
const registry = await cached(pk, (s) => t - s.at < (s.value.ok ? PACKUMENT_TTL_MS : RETRY_AFTER_MS),
|
|
260
|
+
() => fetchPackument({ pkg, get, origin }), t);
|
|
261
|
+
if (!registry.ok) return { registry, notes: { ok: false, error: 'the registry could not be read' } };
|
|
262
|
+
const version = registry.newest;
|
|
263
|
+
if (haveNotesFor(version)) return { registry, notes: { ok: false, skipped: true, version } };
|
|
264
|
+
const ns = store.notes;
|
|
265
|
+
if (ns.version !== version) { ns.version = version; ns.value = null; ns.at = 0; }
|
|
266
|
+
const notes = await cached(ns, (s) => s.value.ok || t - s.at < RETRY_AFTER_MS,
|
|
267
|
+
() => fetchPackagedNotes({ version, tarball: registry.tarballs[version], get, origin }), t);
|
|
268
|
+
return { registry, notes: { ...notes, version } };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = { readRegistry, compareVersions };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/routes/work.js — GET /npm-release/work, the deploy page's
|
|
4
|
+
// "where is my work?" reading (task 1002622). See ../work.js for what it answers.
|
|
5
|
+
//
|
|
6
|
+
// GATED LIKE THE PAGE IT FEEDS. /deploy is served only to holders of `core.pin.move`
|
|
7
|
+
// (serve-internal.js DEPLOY_PAGE_RE), and this answer carries the same disclosure the
|
|
8
|
+
// door's own preview does — the exact core this hall runs and how far behind the
|
|
9
|
+
// newest it is (ADR 0043's reason for gating that preview). A read no wider than the
|
|
10
|
+
// page is also what keeps the page from ever offering more than its API will answer.
|
|
11
|
+
// An atom, not requireRank: tests/government_require_permission.mjs fails the build on
|
|
12
|
+
// a new requireRank in a modules/*/routes gate (BV1.R105 / ADR 0151).
|
|
13
|
+
//
|
|
14
|
+
// READ-ONLY. Nothing here writes, queues or moves anything; the Release button is a
|
|
15
|
+
// separate route with its own gate.
|
|
16
|
+
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const api = require('../../../src/module-api');
|
|
19
|
+
const { readWork } = require('../work');
|
|
20
|
+
|
|
21
|
+
const log = api.logger('npm-release');
|
|
22
|
+
|
|
23
|
+
module.exports = function buildNpmReleaseWorkRouter({ read = readWork } = {}) {
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
// Query: days (ledger window, default 14, max 60), before + limit (history paging).
|
|
27
|
+
// Read by hand rather than through validateOrRespond: every field is optional and
|
|
28
|
+
// clamped, so there is nothing to refuse.
|
|
29
|
+
router.get(
|
|
30
|
+
'/npm-release/work',
|
|
31
|
+
api.requireBuilder,
|
|
32
|
+
api.requirePermission('core.pin.move'),
|
|
33
|
+
async (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const q = req.query || {};
|
|
36
|
+
const before = typeof q.before === 'string' ? q.before : undefined;
|
|
37
|
+
res.json(await read({ pool: api.pool, days: q.days, before, limit: q.limit }));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
log.error(`npm-release work read failed: ${e && e.message}`);
|
|
40
|
+
res.status(500).json({ error: { code: 'work_read_failed', message: 'Could not read where the work is right now.' } });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return router;
|
|
46
|
+
};
|