@bongos/core 1.19.1040 → 1.19.1042

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 (75) hide show
  1. package/.bongos-core.json +110 -80
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
  8. package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
  9. package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +64 -7
  12. package/docs/api-reference.md +7 -1
  13. package/docs/copy-inventory.md +41 -21
  14. package/docs/copy-registry.json +207 -27
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +12 -0
  17. package/modules/grading/grader-subagent.js +7 -0
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/copy-desk.html +1 -1
  23. package/modules/hall-ui/public/deploy.html +1 -1
  24. package/modules/hall-ui/public/diagrams.html +1 -1
  25. package/modules/hall-ui/public/drachmae.html +1 -1
  26. package/modules/hall-ui/public/fleet.html +1 -1
  27. package/modules/hall-ui/public/gate.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/palette.js +1 -0
  35. package/modules/hall-ui/public/people.html +1 -1
  36. package/modules/hall-ui/public/primer.html +1 -1
  37. package/modules/hall-ui/public/profile.html +1 -1
  38. package/modules/hall-ui/public/project-settings.html +1 -1
  39. package/modules/hall-ui/public/ranks.html +1 -1
  40. package/modules/hall-ui/public/roadmap.html +1 -1
  41. package/modules/hall-ui/public/roster.html +1 -1
  42. package/modules/hall-ui/public/sessions.html +1 -1
  43. package/modules/hall-ui/public/settings-software-update.js +194 -0
  44. package/modules/hall-ui/public/settings.css +39 -0
  45. package/modules/hall-ui/public/settings.html +24 -2
  46. package/modules/hall-ui/public/settings.js +5 -0
  47. package/modules/hall-ui/public/settings.states.json +2 -1
  48. package/modules/hall-ui/public/shell.js +25 -39
  49. package/modules/hall-ui/public/studio.html +1 -1
  50. package/modules/hall-ui/public/task.html +1 -1
  51. package/modules/hall-ui/public/watch.html +1 -1
  52. package/modules/hall-ui/public/work.html +1 -1
  53. package/modules/hall-ui/records/panel-family.md +9 -0
  54. package/modules/npm-release/public/work.js +8 -3
  55. package/modules/npm-release/work.js +8 -2
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +16 -0
  59. package/scripts/gds/fitness.js +7 -0
  60. package/scripts/gds/main-audit.js +204 -7
  61. package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
  62. package/src/bongos/routes/core-update.js +65 -3
  63. package/src/bongos/serve-internal.js +17 -0
  64. package/src/bongos/software-update.js +127 -0
  65. package/src/module-api.js +10 -1
  66. package/tests/core_upgrade_owner_door.mjs +27 -18
  67. package/tests/grade_model_provider.mjs +1 -1
  68. package/tests/grader_reviewer_isolation.mjs +68 -0
  69. package/tests/helpers.mjs +92 -0
  70. package/tests/main_audit_attestation.mjs +238 -0
  71. package/tests/module_api.mjs +1 -0
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/npm_release_page.mjs +11 -1
  74. package/tests/npm_release_work.mjs +9 -94
  75. package/tests/software_update.mjs +449 -0
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/software-update.js — Settings → Software update (task 1004296): this project's
4
+ // running core, the newest release its update channel lets it take, and what is in the
5
+ // versions between the two.
6
+ //
7
+ // The owner, 2026-09-25: "each builders hall settings has a version description where they
8
+ // can pull the latest release. Just like iOS does." So this is the one place any project —
9
+ // the platform included — reads its version and what an update would bring. It is the CORE's,
10
+ // not a module's, because every project installs releases.
11
+ //
12
+ // WHAT'S NEW COMES FROM THE PACKAGE'S OWN RELEASE NOTES, never from the changelog. Every CI
13
+ // version's changelog line reads "carrier for merges landed since X", which names no work;
14
+ // release-notes.json (scripts/gds/release-notes.js, task 1004218) is the one per-version
15
+ // list of what landed, and the newest package carries the whole history. The running core's
16
+ // own copy stops at its own version, so an update's notes always come off the registry
17
+ // (package-registry.js: streamed, cached, fail-soft).
18
+ //
19
+ // THE CHANNEL RULE IS NOT RESTATED HERE, as in core-update.js: `resolveChannelTarget` is the
20
+ // upgrade runner's own function, so this never offers a version the runner would refuse.
21
+ // No channel is passed, exactly as the banner passes none: an instance does not store its
22
+ // own channel, and the runner's default is what it would get.
23
+ //
24
+ // A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
25
+ // is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
26
+ // and a version whose notes are missing is shown as missing rather than as empty.
27
+
28
+ const {
29
+ CORE_PKG,
30
+ DEFAULT_CHANNEL,
31
+ normalizeChannel,
32
+ resolveChannelTarget,
33
+ compareSemver,
34
+ } = require('../../scripts/gds/update-channel.js');
35
+ const { readRegistry } = require('./package-registry');
36
+
37
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
38
+ // The same bound the deploy page puts on a line: a release note is a sentence, and one that
39
+ // came off the network is re-checked before it is drawn.
40
+ const TEXT_MAX = 400;
41
+ // A project months behind can be hundreds of versions short. The panel lists the newest
42
+ // ones and counts the rest; every one of them is still in the update.
43
+ const VERSIONS_MAX = 40;
44
+
45
+ function boundText(text) {
46
+ const t = String(text || '').trim();
47
+ return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t;
48
+ }
49
+
50
+ /** One version's entries, re-checked; null when the notes do not list the version at all. */
51
+ function entriesFor(notes, version) {
52
+ const list = notes && Object.prototype.hasOwnProperty.call(notes, version) ? notes[version] : undefined;
53
+ if (!Array.isArray(list)) return null;
54
+ const out = [];
55
+ for (const e of list) {
56
+ const text = boundText(e && e.text);
57
+ if (!text) continue;
58
+ const id = e && /^\d{1,12}$/.test(String(e.id == null ? '' : e.id)) ? String(e.id) : null;
59
+ out.push({ id, text });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
66
+ *
67
+ * `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
68
+ * update are the published ones above `running` and at or below `offered`, newest first.
69
+ */
70
+ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
71
+ const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
72
+ const base = { checked_at: checkedAt, running: running || null, channel: ch };
73
+ if (!running || !VERSION_RE.test(String(running))) {
74
+ return { ...base, reading: 'no_answer', error: 'this project could not name the version it runs', offered: null, newest: null, whats_new: null };
75
+ }
76
+ if (!registry || !registry.ok) {
77
+ return { ...base, reading: 'no_answer', error: (registry && registry.error) || 'the registry could not be read', offered: null, newest: null, whats_new: null };
78
+ }
79
+ const offered = resolveChannelTarget({ installed: running, channel: ch, available: registry.versions }) || null;
80
+ const published = registry.published || {};
81
+ const answer = {
82
+ ...base,
83
+ reading: 'ok',
84
+ error: null,
85
+ offered,
86
+ offered_published_at: offered ? published[offered] || null : null,
87
+ newest: registry.newest || null,
88
+ whats_new: null,
89
+ };
90
+ if (!offered) return answer;
91
+
92
+ const inUpdate = registry.versions
93
+ .filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
94
+ .sort((a, b) => compareSemver(b, a));
95
+ const shown = inUpdate.slice(0, VERSIONS_MAX);
96
+ const ok = !!(notes && notes.ok);
97
+ answer.whats_new = {
98
+ ok,
99
+ error: ok ? null : ((notes && notes.error) || 'the release notes could not be read'),
100
+ count: inUpdate.length,
101
+ more: inUpdate.length - shown.length,
102
+ versions: shown.map((version) => ({
103
+ version,
104
+ published_at: published[version] || null,
105
+ entries: ok ? entriesFor(notes.notes, version) : null,
106
+ })),
107
+ };
108
+ return answer;
109
+ }
110
+
111
+ /**
112
+ * What GET /api/bongos/software-update answers — and this file's one export.
113
+ *
114
+ * `get`/`now`/`store` pass through to the registry reader, so a test drives the real
115
+ * packument parsing, the real tarball stream and the real cache rather than a stub.
116
+ */
117
+ async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
118
+ const t = now();
119
+ const { registry, notes } = await readRegistry({
120
+ pkg: CORE_PKG, get, now, store,
121
+ // Nothing to describe when nothing newer is published: skip the download.
122
+ haveNotesFor: (newest) => !VERSION_RE.test(String(running || '')) || compareSemver(newest, running) <= 0,
123
+ });
124
+ return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
125
+ }
126
+
127
+ module.exports = { readSoftwareUpdate };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1040'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1042'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -494,6 +494,15 @@ module.exports = {
494
494
  responsibilityFor,
495
495
  ROLE_RESPONSIBILITIES,
496
496
 
497
+ // --- readPackageRegistry({ pkg, haveNotesFor, get, now, store }) — what the npm
498
+ // registry says about a package: its versions, their publish dates, the
499
+ // `latest` label, and the release notes the newest version was packed with
500
+ // (task 1004296). The core owns it because Settings → Software update needs
501
+ // it in every hall; a module that publishes a package reads the same answer
502
+ // and the same process-local cache through here. Fail-soft: it resolves
503
+ // `{ registry, notes }` with `ok:false` and a sentence, never a throw.
504
+ readPackageRegistry: (...a) => require('./bongos/package-registry').readRegistry(...a),
505
+
497
506
  // --- logging
498
507
  logger,
499
508
  };
@@ -342,8 +342,9 @@ await t('deploy_url is the hub origin plus this project\'s slug, and null withou
342
342
  const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
343
343
 
344
344
  // The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
345
- // /core-update answer carrying a deploy_url and elements that remember their attributes.
346
- function boot({ provisioning, deployUrl }) {
345
+ // /core-update answer optionally carrying a deploy_url — an older server still sends one —
346
+ // and elements that remember their attributes.
347
+ function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
347
348
  const els = new Map();
348
349
  const inserted = [];
349
350
  const makeEl = () => ({
@@ -372,7 +373,7 @@ function boot({ provisioning, deployUrl }) {
372
373
  addEventListener() {}, dispatchEvent() {},
373
374
  get cookie() { return ''; }, set cookie(_v) {},
374
375
  };
375
- const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
376
+ const location = { hostname: 'h', origin: 'https://h', pathname, hash, assign() {}, replace() {} };
376
377
  const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
377
378
  const windowObj = {
378
379
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
@@ -401,40 +402,48 @@ function boot({ provisioning, deployUrl }) {
401
402
  const settle = () => new Promise((r) => setTimeout(r, 0));
402
403
  const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
403
404
 
404
- await t('on a hosted project, the banner\'s Upgrade goes to the hub door for THIS project', async () => {
405
+ // Task 1004296 moved the hand-off: a hall without a door of its own sends its owner to
406
+ // Settings → Software update, whose Update button carries the hub address (GET
407
+ // /software-update's `door`). The banner and the rail no longer link off-site.
408
+ await t('on a hosted project, the banner\'s Upgrade goes to Settings → Software update, not off-site', async () => {
405
409
  const b = boot({ provisioning: false, deployUrl: HUB });
406
410
  await settle();
407
411
  assert.ok(b.banner(), 'the banner renders');
408
- assert.ok(b.banner().innerHTML.includes(`href="${HUB}"`), 'Upgrade links to the hub');
409
- assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the dead local page');
412
+ assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, 'Upgrade opens the Software update panel');
413
+ assert.ok(!b.banner().innerHTML.includes(HUB), 'never the hub address directly, even when an older server sends one');
414
+ assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the local /deploy, which this hall no longer has');
410
415
  });
411
416
 
412
- await t('on a hosted project, the rail\'s Deploy item is re-pointed at the hub', async () => {
417
+ await t('on a hosted project, the rail\'s Deploy item stays hidden — it is the platform hall\'s page', async () => {
413
418
  const b = boot({ provisioning: false, deployUrl: HUB });
414
419
  await settle();
415
420
  const item = b.deployItem();
416
- assert.equal(item.getAttribute('href'), HUB);
417
- assert.equal('module' in item.dataset, false, 'its module gate is lifted so the access loop stops hiding it');
418
- assert.equal(item.hidden, false, 'an Archon on their own project sees it');
421
+ assert.equal(item.getAttribute('href'), null, 'not re-pointed at the hub');
422
+ assert.equal(item.dataset.module, 'provisioning', 'its module gate stays, so a hall without the door hides it');
419
423
  });
420
424
 
421
- await t('a hosted project with no hub, or a hub address that is not plain http(s), offers no link', async () => {
422
- for (const url of [null, 'javascript:alert(1)', 'https://x.test/"><img src=x onerror=alert(1)>', 42]) {
425
+ await t('a hall with no door of its own and no hub still links to Software update', async () => {
426
+ for (const url of [null, 'javascript:alert(1)', 42]) {
423
427
  const b = boot({ provisioning: false, deployUrl: url });
424
428
  await settle();
425
- assert.ok(b.banner(), 'the news is still told');
426
- assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, `no action for ${JSON.stringify(url)}`);
427
- const item = b.deployItem();
428
- assert.equal(item.getAttribute('href'), null, `the rail is not re-pointed for ${JSON.stringify(url)}`);
429
- assert.equal(item.dataset.module, 'provisioning', 'and it keeps its module gate, so a hosted project still hides it');
429
+ assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, `Software update for ${JSON.stringify(url)}`);
430
+ assert.doesNotMatch(b.banner().innerHTML, /javascript:/);
430
431
  }
431
432
  });
432
433
 
433
- await t('on the platform, the local door wins: the hub address is ignored', async () => {
434
+ await t('on the Software update panel itself, the banner points rather than linking to the page it is on', async () => {
435
+ const b = boot({ provisioning: false, pathname: '/settings', hash: '#software-update' });
436
+ await settle();
437
+ assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, 'no link to where you already are');
438
+ assert.match(b.banner().innerHTML, /core-update-notice__hint/);
439
+ });
440
+
441
+ await t('on the platform, the local door wins: the banner links /deploy and the rail keeps it', async () => {
434
442
  const b = boot({ provisioning: true, deployUrl: HUB });
435
443
  await settle();
436
444
  assert.match(b.banner().innerHTML, /href="\/deploy"/);
437
445
  assert.ok(!b.banner().innerHTML.includes(HUB));
446
+ assert.ok(!b.banner().innerHTML.includes('software-update'), 'the platform takes its update at its own door');
438
447
  const item = b.deployItem();
439
448
  assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
440
449
  assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
@@ -74,7 +74,7 @@ test('the Claude spawn is the default provider, with the identical argv', { skip
74
74
  const r = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, model: 'claude-sonnet-5', cwd: dir } });
75
75
  const argv = JSON.parse(subagent.extractAssistantResult(r.stdout));
76
76
  const perm = typeof process.getuid === 'function' && process.getuid() === 0 ? 'acceptEdits' : 'bypassPermissions';
77
- assert.deepEqual(argv, ['-p', '--print', '--model', 'claude-sonnet-5', '--output-format', 'json', '--tools', 'Read', '--disable-slash-commands', '--permission-mode', perm]);
77
+ assert.deepEqual(argv, ['-p', '--print', '--model', 'claude-sonnet-5', '--output-format', 'json', '--tools', 'Read', '--strict-mcp-config', '--disable-slash-commands', '--permission-mode', perm]);
78
78
  assert.equal(r.cost_usd, 0.5);
79
79
  const d = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, cwd: dir } });
80
80
  assert.equal(JSON.parse(subagent.extractAssistantResult(d.stdout))[3], 'claude-sonnet-5', 'no model → the default grader model, as before');
@@ -0,0 +1,68 @@
1
+ // tests/grader_reviewer_isolation.mjs — a grader reviewer sees the Read tool and
2
+ // NOTHING the host happens to have connected (task 1000380, re-scoped 2026-09-26).
3
+ //
4
+ // THE GAP THIS PINS. Every panel worker is a `claude -p` session reading a diff that
5
+ // someone else wrote, launched with permission prompts off (bypassPermissions, or
6
+ // acceptEdits as root — task 1003574). `--tools Read` limits the BUILT-IN tools only.
7
+ // It says nothing about MCP servers, so without `--strict-mcp-config` a reviewer
8
+ // inherits every server the host has configured — the user's settings, the project's
9
+ // .mcp.json, and the claude.ai connectors. Measured on the owner's Mac on 2026-09-26
10
+ // with the reviewer's exact flags: 22 tools (Read + 21 MCP tools, among them a docs
11
+ // connector that can create and delete documents); with `--strict-mcp-config`, 1.
12
+ // A diff that smuggles instructions to its reviewer had those 21 tools to reach for,
13
+ // with nothing to prompt for approval.
14
+ //
15
+ // `--strict-mcp-config` with no `--mcp-config` beside it loads zero servers, which is
16
+ // the whole intent: a reviewer needs no MCP server to read files.
17
+ //
18
+ // Run: node --test tests/grader_reviewer_isolation.mjs
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const subagent = require('../modules/grading/grader-subagent.js');
28
+
29
+ // A stand-in `claude` that answers with the argv it was launched with, in the
30
+ // result envelope the real CLI uses — so the test reads what was ACTUALLY passed
31
+ // to the spawn, not what the source text looks like.
32
+ async function spawnArgv(opts = {}) {
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grader-isolation-'));
34
+ const bin = path.join(dir, 'claude');
35
+ fs.writeFileSync(bin, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ type: "result", result: JSON.stringify(process.argv.slice(2)) })));\n');
36
+ fs.chmodSync(bin, 0o755);
37
+ try {
38
+ const r = await subagent.runSubagent({ prompt: 'review this', opts: { claudeBin: bin, cwd: dir, ...opts } });
39
+ return JSON.parse(subagent.extractAssistantResult(r.stdout));
40
+ } finally {
41
+ fs.rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ const skip = process.platform === 'win32' && 'the stand-in CLI is a shebang script';
46
+
47
+ test('a reviewer is launched with --strict-mcp-config, so no host MCP server loads', { skip }, async () => {
48
+ const argv = await spawnArgv();
49
+ assert.ok(argv.includes('--strict-mcp-config'),
50
+ `every reviewer spawn must ignore the host's MCP configuration; argv was ${JSON.stringify(argv)}`);
51
+ // Strict mode with no config file is what makes the server list EMPTY. Handing it
52
+ // a config would re-open exactly the door the flag closes.
53
+ assert.ok(!argv.includes('--mcp-config'), 'no MCP config is supplied to a reviewer');
54
+ });
55
+
56
+ test('the flag holds for every model the panel runs, not only the default', { skip }, async () => {
57
+ for (const model of ['claude-sonnet-5', 'claude-opus-5-5', 'claude-haiku-4-5-20251001']) {
58
+ const argv = await spawnArgv({ model });
59
+ assert.ok(argv.includes('--strict-mcp-config'), `missing for ${model}`);
60
+ }
61
+ });
62
+
63
+ test('the built-in tool list is still Read alone — the two limits work together', { skip }, async () => {
64
+ const argv = await spawnArgv();
65
+ const i = argv.indexOf('--tools');
66
+ assert.ok(i >= 0, '--tools is passed');
67
+ assert.equal(argv[i + 1], 'Read', 'the only built-in tool is Read');
68
+ });
package/tests/helpers.mjs CHANGED
@@ -73,6 +73,9 @@ import { fileURLToPath } from 'node:url';
73
73
  import { createRequire } from 'node:module';
74
74
  import { execFileSync } from 'node:child_process';
75
75
  import vm from 'node:vm';
76
+ import zlib from 'node:zlib';
77
+ import { EventEmitter } from 'node:events';
78
+ import { Readable } from 'node:stream';
76
79
 
77
80
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
78
81
 
@@ -1012,3 +1015,92 @@ export function macShapedTools(binDir) {
1012
1015
  '',
1013
1016
  ].join('\n'), { mode: 0o755 });
1014
1017
  }
1018
+
1019
+ // ---- a fake npm registry (task 1002622; shared since task 1004296) -------------------
1020
+ //
1021
+ // A real gzipped tar built by hand, a packument, and an `https.get` stand-in that answers
1022
+ // in 777-byte chunks — so a reader under test runs its real gunzip, tar walk and JSON parse,
1023
+ // with headers landing across chunk boundaries. Used by tests/npm_release_work.mjs and
1024
+ // tests/software_update.mjs.
1025
+
1026
+ function tarHeader(name, size, type = '0') {
1027
+ const h = Buffer.alloc(512, 0);
1028
+ h.write(name.slice(0, 100), 0, 'utf8');
1029
+ h.write('0000644\0', 100, 'ascii');
1030
+ h.write('0000000\0', 108, 'ascii');
1031
+ h.write('0000000\0', 116, 'ascii');
1032
+ h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
1033
+ h.write('00000000000\0', 136, 'ascii');
1034
+ h.write(' ', 148, 'ascii');
1035
+ h.write(type, 156, 'ascii');
1036
+ h.write('ustar\0', 257, 'ascii');
1037
+ h.write('00', 263, 'ascii');
1038
+ let sum = 0;
1039
+ for (const b of h) sum += b;
1040
+ h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
1041
+ return h;
1042
+ }
1043
+ function tarEntry(name, data, type = '0') {
1044
+ const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
1045
+ const pad = Buffer.alloc((512 - (body.length % 512)) % 512, 0);
1046
+ return Buffer.concat([tarHeader(name, body.length, type), body, pad]);
1047
+ }
1048
+ function paxEntry(path) {
1049
+ // A pax record is "<len> <key>=<value>\n", where <len> counts its own digits too.
1050
+ const rec = (k, v) => {
1051
+ const body = ` ${k}=${v}\n`;
1052
+ let n = body.length + 1;
1053
+ while (String(n).length + body.length !== n) n = String(n).length + body.length;
1054
+ return `${n}${body}`;
1055
+ };
1056
+ return tarEntry('PaxHeader/x', rec('path', path), 'x');
1057
+ }
1058
+ /** A gzipped npm-shaped tarball. `notes` null leaves release-notes.json out. */
1059
+ export function npmTarball({ notes, padBytes = 40000, pax = false, pkg = '@bongos/core' } = {}) {
1060
+ const parts = [
1061
+ tarEntry('package/package.json', JSON.stringify({ name: pkg })),
1062
+ // A large file BEFORE the notes, so the reader has to skip across many chunks.
1063
+ tarEntry('package/big.js', Buffer.alloc(padBytes, 97)),
1064
+ ];
1065
+ if (notes) {
1066
+ const json = JSON.stringify(notes);
1067
+ if (pax) parts.push(paxEntry('package/release-notes.json'), tarEntry('package/SHORTNAME', json));
1068
+ else parts.push(tarEntry('package/release-notes.json', json));
1069
+ }
1070
+ parts.push(Buffer.alloc(1024, 0));
1071
+ return zlib.gzipSync(Buffer.concat(parts));
1072
+ }
1073
+
1074
+ // ---- a fake registry -----------------------------------------------------------------
1075
+
1076
+ export function npmPackument({ versions, latest, times, pkg = '@bongos/core', origin = 'https://registry.npmjs.org' }) {
1077
+ const doc = { name: pkg, 'dist-tags': { latest }, versions: {}, time: {} };
1078
+ for (const v of versions) {
1079
+ doc.versions[v] = { version: v, dist: { tarball: `${origin}/${pkg}/-/${pkg.split('/').pop()}-${v}.tgz` } };
1080
+ doc.time[v] = times[v];
1081
+ }
1082
+ return doc;
1083
+ }
1084
+
1085
+ /** `get` answering from `routes` (url → { status, body }), recording every URL asked for. */
1086
+ export function fakeHttpsGet(routes) {
1087
+ const asked = [];
1088
+ const get = (url, opts, cb) => {
1089
+ asked.push(url);
1090
+ const req = new EventEmitter();
1091
+ req.destroy = () => {};
1092
+ setImmediate(() => {
1093
+ const r = routes[url];
1094
+ if (!r) { req.emit('error', new Error(`no route for ${url}`)); return; }
1095
+ const body = Buffer.isBuffer(r.body) ? r.body : Buffer.from(typeof r.body === 'string' ? r.body : JSON.stringify(r.body));
1096
+ // Small chunks, so a tar header lands across a chunk boundary.
1097
+ const chunks = [];
1098
+ for (let i = 0; i < body.length; i += 777) chunks.push(body.subarray(i, i + 777));
1099
+ const res = Readable.from(chunks);
1100
+ res.statusCode = r.status || 200;
1101
+ cb(res);
1102
+ });
1103
+ return req;
1104
+ };
1105
+ return { get, asked };
1106
+ }