@bongos/core 1.17.0 → 1.17.1

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 (90) hide show
  1. package/.bongos-core.json +154 -83
  2. package/CLAUDE.md +11 -238
  3. package/bin/bongos.js +1 -0
  4. package/clients/bongos-client/README.md +4 -4
  5. package/clients/bongos-client/bongos-client.global.js +14 -1
  6. package/clients/bongos-client/index.cjs +14 -1
  7. package/clients/bongos-client/index.d.ts +19 -2
  8. package/clients/bongos-client/index.mjs +11 -1
  9. package/clients/bongos-client/package.json +1 -1
  10. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -0
  11. package/docs/adr/0134-private-first-npm-distribution.md +20 -6
  12. package/docs/adr/0135-module-upstream-submission-interim-queue.md +34 -0
  13. package/docs/adr/README.md +5 -4
  14. package/docs/api/openapi.json +315 -6
  15. package/docs/api-reference.md +10 -5
  16. package/docs/architecture.md +1 -1
  17. package/docs/file-map.md +2 -2
  18. package/docs/modules-contract.md +2 -0
  19. package/docs/recipes/instance-deploy.md +4 -4
  20. package/docs/recipes/instance-first-run.md +51 -0
  21. package/docs/recipes/packaging-the-core.md +5 -5
  22. package/docs/recipes/private-npm-distribution.md +22 -12
  23. package/docs/recipes/self-host.md +1 -1
  24. package/docs/recipes/upgrading-the-core.md +8 -8
  25. package/migrations/core_191_module_submissions.sql +45 -0
  26. package/modules/autonomy/CLAUDE.md +11 -45
  27. package/modules/builder-settings/CLAUDE.md +11 -54
  28. package/modules/dev-box/CLAUDE.md +11 -34
  29. package/modules/discord/CLAUDE.md +11 -46
  30. package/modules/economy/CLAUDE.md +11 -44
  31. package/modules/grading/CLAUDE.md +11 -38
  32. package/modules/hall-ui/CLAUDE.md +11 -57
  33. package/modules/hall-ui/public/goals-page.js +227 -22
  34. package/modules/hall-ui/public/goals.html +16 -0
  35. package/modules/hall-ui/public/new-project-wizard.js +5 -4
  36. package/modules/ideas/CLAUDE.md +11 -33
  37. package/modules/lifecycle/CLAUDE.md +11 -59
  38. package/modules/lifecycle/routes/goals.js +80 -0
  39. package/modules/memory/CLAUDE.md +11 -36
  40. package/modules/onboarding/CLAUDE.md +11 -51
  41. package/modules/security/CLAUDE.md +11 -40
  42. package/modules/sessions/CLAUDE.md +11 -35
  43. package/modules/status-ui/CLAUDE.md +11 -43
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/public-landing/create.html +58 -12
  47. package/public-landing/index.html +40 -21
  48. package/scripts/gds/CLAUDE.md +11 -140
  49. package/scripts/gds/claude-materialize.js +151 -18
  50. package/scripts/gds/claude-md-template.js +84 -0
  51. package/scripts/gds/cli-lib.js +112 -2
  52. package/scripts/gds/gen-api-client.js +10 -10
  53. package/scripts/gds/init.js +53 -13
  54. package/scripts/gds/migration-namespace.js +1 -1
  55. package/scripts/gds/module.js +118 -4
  56. package/scripts/gds/npmrc.js +93 -0
  57. package/scripts/gds/onboard.js +13 -24
  58. package/scripts/gds/package-core.js +56 -14
  59. package/scripts/gds/provision.js +102 -17
  60. package/scripts/gds/seed-adopt-tasks.js +1 -1
  61. package/scripts/gds/status.js +6 -1
  62. package/scripts/gds/task.js +166 -0
  63. package/scripts/gds/upgrade.js +58 -18
  64. package/src/bongos/CLAUDE.md +11 -60
  65. package/src/bongos/auth.js +11 -0
  66. package/src/bongos/module-submissions.js +56 -0
  67. package/src/bongos/route-rank-check.js +6 -0
  68. package/src/bongos/routes/CLAUDE.md +11 -39
  69. package/src/bongos/routes/auth.js +6 -1
  70. package/src/bongos/routes/modules.js +123 -21
  71. package/src/instance-config.js +1 -1
  72. package/src/module-api.js +13 -1
  73. package/tests/claude_materialize.mjs +105 -0
  74. package/tests/device_flow_start.mjs +73 -0
  75. package/tests/first_run_engine_check.mjs +79 -0
  76. package/tests/goal_membership_requests.mjs +33 -3
  77. package/tests/hook_user_agents.mjs +62 -0
  78. package/tests/init.mjs +59 -6
  79. package/tests/instance_mismatch_guard.mjs +101 -0
  80. package/tests/module_cli.mjs +130 -2
  81. package/tests/module_submissions.mjs +135 -0
  82. package/tests/npmrc.mjs +105 -0
  83. package/tests/onboard.mjs +49 -0
  84. package/tests/package_core.mjs +68 -22
  85. package/tests/platform_boot.mjs +5 -2
  86. package/tests/provision.mjs +107 -8
  87. package/tests/provisioning_github_app_routes.mjs +29 -2
  88. package/tests/served_paths_shipped.mjs +2 -2
  89. package/tests/task_cli.mjs +99 -0
  90. package/tests/upgrade.mjs +89 -25
@@ -268,6 +268,126 @@ test('cmdCheck: usage error (exit 2) when no key is given', () => {
268
268
  assert.equal(code, 2);
269
269
  });
270
270
 
271
+ // ---------------------------------------------------------------------------
272
+ // packageModuleForSubmission / readSignOff — the fs half of `module submit`
273
+ // ---------------------------------------------------------------------------
274
+
275
+ test('readSignOff: null when never signed off; the recorded record once it is', () => {
276
+ const root = tmpModulesRoot();
277
+ mod.scaffoldModule({ key: 'widget', modulesDir: root, core: '1.0.0' });
278
+ assert.equal(mod.readSignOff('widget', { modulesDir: root }), null);
279
+ mod.recordSignOff('widget', 'Ada Lovelace <ada@example.com>', { modulesDir: root });
280
+ const signOff = mod.readSignOff('widget', { modulesDir: root });
281
+ assert.equal(signOff.signOff.signedBy, 'Ada Lovelace <ada@example.com>');
282
+ });
283
+
284
+ test('packageModuleForSubmission: snapshots the manifest + every file (base64) + the sign-off', () => {
285
+ const root = tmpModulesRoot();
286
+ mod.scaffoldModule({ key: 'widget', modulesDir: root, core: '1.0.0' });
287
+ mod.recordSignOff('widget', 'Ada Lovelace <ada@example.com>', { modulesDir: root });
288
+
289
+ const pkg = mod.packageModuleForSubmission('widget', { modulesDir: root });
290
+ assert.equal(pkg.key, 'widget');
291
+ assert.equal(pkg.manifest.key, 'widget');
292
+ assert.equal(pkg.signOff.signOff.signedBy, 'Ada Lovelace <ada@example.com>');
293
+ assert.ok(pkg.totalBytes > 0);
294
+
295
+ const paths = pkg.files.map((f) => f.path).sort();
296
+ assert.deepEqual(paths, [...paths].sort(), 'files are sorted by path');
297
+ assert.ok(paths.includes('module.json'));
298
+ assert.ok(paths.includes('routes/widget.js'));
299
+ // the sign-off file itself travels in the snapshot too — it's part of the module tree
300
+ assert.ok(paths.includes('.upstream-signoff.json'));
301
+
302
+ const manifestFile = pkg.files.find((f) => f.path === 'module.json');
303
+ const roundTripped = JSON.parse(Buffer.from(manifestFile.contentBase64, 'base64').toString('utf8'));
304
+ assert.deepEqual(roundTripped, pkg.manifest);
305
+ assert.equal(manifestFile.bytes, Buffer.byteLength(JSON.stringify(pkg.manifest, null, 2) + '\n'));
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // bongos module submit <key> — ADR 0107 §1 (network call is always injected —
310
+ // never hits cli-lib.js / the real API in a unit test)
311
+ // ---------------------------------------------------------------------------
312
+
313
+ test('cmdSubmit: refuses (exit 1) when the pre-check fails, without ever attempting the network call', async () => {
314
+ const lines = [];
315
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
316
+ let submitCalled = false;
317
+ const check = () => ({ key: 'game', ok: false, findings: [{ kind: 'content', detail: 'contains denied content' }], files: [], denied: [] });
318
+ const submit = async () => { submitCalled = true; return { ok: true, status: 201, data: { submission: { id: 1, status: 'pending' } } }; };
319
+
320
+ const code = await mod.cmdSubmit(['game'], { ...sink, check, submit });
321
+ assert.equal(code, 1);
322
+ assert.equal(submitCalled, false, 'a failing pre-check must never reach the network');
323
+ assert.ok(lines.some((l) => /REFUSING/.test(l)));
324
+ });
325
+
326
+ test('cmdSubmit: refuses (exit 1) when the pre-check passes but no sign-off is on file and none was given', async () => {
327
+ const lines = [];
328
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
329
+ let submitCalled = false;
330
+ const check = () => ({ key: 'widget', ok: true, findings: [], files: [], denied: [] });
331
+ const readSign = () => null;
332
+ const submit = async () => { submitCalled = true; return { ok: true, status: 201, data: {} }; };
333
+
334
+ const code = await mod.cmdSubmit(['widget'], { ...sink, check, readSign, submit });
335
+ assert.equal(code, 1);
336
+ assert.equal(submitCalled, false);
337
+ assert.ok(lines.some((l) => /REFUSING/.test(l) && /sign-off/.test(l)));
338
+ });
339
+
340
+ test('cmdSubmit: --sign-off records a NEW sign-off when none is on file, then submits', async () => {
341
+ const lines = [];
342
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
343
+ const check = () => ({ key: 'widget', ok: true, findings: [], files: [], denied: [] });
344
+ let signedCall = null;
345
+ const recorded = { signOff: { signedBy: 'Ada Lovelace <ada@example.com>', date: '2026-07-08T00:00:00.000Z' }, path: 'modules/widget/.upstream-signoff.json' };
346
+ const sign = (key, by) => { signedCall = { key, by }; return recorded; };
347
+ let readSignCalls = 0;
348
+ const readSign = () => { readSignCalls += 1; return readSignCalls === 1 ? null : recorded; };
349
+ let submitArgs = null;
350
+ const submit = async (key, body) => { submitArgs = { key, body }; return { ok: true, status: 201, data: { submission: { id: 7, status: 'pending' } } }; };
351
+
352
+ const code = await mod.cmdSubmit(['widget', '--sign-off', 'Ada Lovelace <ada@example.com>'], { ...sink, check, sign, readSign, submit });
353
+ assert.equal(code, 0);
354
+ assert.deepEqual(signedCall, { key: 'widget', by: 'Ada Lovelace <ada@example.com>' });
355
+ assert.deepEqual(submitArgs, { key: 'widget', body: { signed_by: 'Ada Lovelace <ada@example.com>' } });
356
+ assert.ok(lines.some((l) => /Filed — submission id 7/.test(l)));
357
+ });
358
+
359
+ test('cmdSubmit: an existing sign-off on file is reused — --sign-off is not required again', async () => {
360
+ const lines = [];
361
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
362
+ const check = () => ({ key: 'widget', ok: true, findings: [], files: [], denied: [] });
363
+ let signCalled = false;
364
+ const sign = () => { signCalled = true; };
365
+ const readSign = () => ({ signOff: { signedBy: 'Ada Lovelace <ada@example.com>', date: 'now' } });
366
+ const submit = async () => ({ ok: true, status: 201, data: { submission: { id: 9, status: 'pending' } } });
367
+
368
+ const code = await mod.cmdSubmit(['widget'], { ...sink, check, sign, readSign, submit });
369
+ assert.equal(code, 0);
370
+ assert.equal(signCalled, false, 'an existing sign-off is never re-recorded');
371
+ });
372
+
373
+ test('cmdSubmit: surfaces a rejected submission (e.g. the server-side pre-check disagrees) as exit 1', async () => {
374
+ const lines = [];
375
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
376
+ const check = () => ({ key: 'widget', ok: true, findings: [], files: [], denied: [] });
377
+ const readSign = () => ({ signOff: { signedBy: 'Ada Lovelace <ada@example.com>', date: 'now' } });
378
+ const submit = async () => ({ ok: false, status: 422, data: { error: 'precheck_failed', message: 'server disagrees' } });
379
+
380
+ const code = await mod.cmdSubmit(['widget'], { ...sink, check, readSign, submit });
381
+ assert.equal(code, 1);
382
+ assert.ok(lines.some((l) => /REFUSING/.test(l) && /server disagrees/.test(l)));
383
+ });
384
+
385
+ test('cmdSubmit: usage error (exit 2) when no key is given', async () => {
386
+ const lines = [];
387
+ const code = await mod.cmdSubmit([], { log: (s) => lines.push(s), errlog: (s) => lines.push(s) });
388
+ assert.equal(code, 2);
389
+ });
390
+
271
391
  // ---------------------------------------------------------------------------
272
392
  // dispatch wiring (bin/bongos.js)
273
393
  // ---------------------------------------------------------------------------
@@ -283,10 +403,18 @@ test('the `module` verb dispatches to module.js, `upgrade` to upgrade.js', () =>
283
403
  assert.ok(h.includes('upgrade'));
284
404
  });
285
405
 
286
- test('`bongos module check` is documented in help / dispatches to module.js check subcommand', () => {
406
+ test('`bongos module check` is documented in help / dispatches to module.js check subcommand', async () => {
287
407
  const lines = [];
288
408
  mod.printHelp((s) => lines.push(s));
289
409
  assert.ok(lines.some((l) => /module check/.test(l)));
290
410
  // module.js itself dispatches the "check" subcommand (bongos.js forwards args[1:] verbatim to module.js)
291
- assert.equal(mod.main(['node', 'module.js', 'check']), 2, 'no key => usage error');
411
+ // main() is async (bongos module submit makes a network call) — every subcommand's exit code resolves via the same Promise.
412
+ assert.equal(await mod.main(['node', 'module.js', 'check']), 2, 'no key => usage error');
413
+ });
414
+
415
+ test('`bongos module submit` is documented in help / dispatches to module.js submit subcommand', async () => {
416
+ const lines = [];
417
+ mod.printHelp((s) => lines.push(s));
418
+ assert.ok(lines.some((l) => /module submit/.test(l)));
419
+ assert.equal(await mod.main(['node', 'module.js', 'submit']), 2, 'no key => usage error');
292
420
  });
@@ -0,0 +1,135 @@
1
+ // tests/module_submissions.mjs
2
+ //
3
+ // Unit smoke for the ADR 0107 §1 upstreaming review queue (task 1770,
4
+ // migration core_191_module_submissions). Proves, with NO live Postgres:
5
+ //
6
+ // DB helpers (against a stubbed pool seam):
7
+ // - createModuleSubmission() INSERTs, jsonb-casting + JSON.stringify'ing
8
+ // manifest/files/sign_off, parameterized
9
+ // - listModuleSubmissions() SELECTs metadata only (no `files` — the file
10
+ // snapshot can run to hundreds of KB and no list consumer needs it),
11
+ // newest-first, optionally filtered by status and/or module_key
12
+ //
13
+ // We stub the pool by pre-seeding the require cache for src/bongos/pool.js
14
+ // BEFORE requiring src/bongos/module-submissions.js — the same monkeypatch
15
+ // seam tests/security_docs.mjs uses.
16
+ //
17
+ // Run: node tests/module_submissions.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const ROOT = path.resolve(__dirname, '..');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ return Promise.resolve()
32
+ .then(fn)
33
+ .then(() => { passed++; console.log(` ok ${name}`); })
34
+ .catch((e) => { failed++; console.error(` FAIL ${name}\n ${e.message}`); });
35
+ }
36
+
37
+ const calls = [];
38
+ let nextRows = [];
39
+ const fakePool = {
40
+ query: async (text, params) => {
41
+ calls.push({ text, params });
42
+ return { rows: nextRows };
43
+ },
44
+ };
45
+ const poolPath = require.resolve(path.join(ROOT, 'src', 'bongos', 'pool.js'));
46
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: fakePool, instanceDbName: () => undefined } };
47
+
48
+ const moduleSubmissions = require(path.join(ROOT, 'src', 'bongos', 'module-submissions.js'));
49
+
50
+ function lastCall() { return calls[calls.length - 1]; }
51
+
52
+ console.log('module_submissions — DB helpers (stubbed pool):');
53
+
54
+ await test('createModuleSubmission() INSERTs, jsonb-casts manifest/files/sign_off, parameterized', async () => {
55
+ calls.length = 0;
56
+ const manifest = { key: 'widget', title: 'Widget', license: 'AGPL-3.0' };
57
+ const files = [{ path: 'module.json', contentBase64: 'e30=', bytes: 2 }];
58
+ const signOff = { module: 'widget', signOff: { signedBy: 'Ada Lovelace <ada@example.com>', date: '2026-07-08T00:00:00.000Z' } };
59
+ const precheckPassedAt = new Date('2026-07-08T00:00:00.000Z');
60
+ nextRows = [{
61
+ id: 1, module_key: 'widget', submitted_by: 42, manifest, sign_off: signOff,
62
+ precheck_passed_at: precheckPassedAt, source_repo: 'lars589/amazonprimea',
63
+ status: 'pending', reviewed_by: null, reviewed_at: null, review_notes: null,
64
+ submitted_at: '2026-07-08T00:00:01.000Z',
65
+ }];
66
+
67
+ const row = await moduleSubmissions.createModuleSubmission({
68
+ moduleKey: 'widget', submittedBy: 42, manifest, files, signOff, precheckPassedAt,
69
+ sourceRepo: 'lars589/amazonprimea',
70
+ });
71
+
72
+ const { text, params } = lastCall();
73
+ assert.ok(/INSERT INTO module_submissions/i.test(text));
74
+ assert.ok(/RETURNING/i.test(text), 'must return the inserted row');
75
+ // files IS written (params[3] below) but is NOT in the RETURNING list — the
76
+ // write path never echoes the (possibly large) snapshot back.
77
+ const returningClause = text.slice(text.search(/RETURNING/i));
78
+ assert.ok(!/\bfiles\b/i.test(returningClause), 'RETURNING must not include files');
79
+
80
+ assert.equal(params[0], 'widget');
81
+ assert.equal(params[1], 42);
82
+ assert.equal(params[2], JSON.stringify(manifest));
83
+ assert.equal(params[3], JSON.stringify(files));
84
+ assert.equal(params[4], JSON.stringify(signOff));
85
+ assert.equal(params[5], precheckPassedAt);
86
+ assert.equal(params[6], 'lars589/amazonprimea');
87
+
88
+ assert.equal(row.id, 1);
89
+ assert.equal(row.status, 'pending');
90
+ });
91
+
92
+ await test('createModuleSubmission() defaults a missing sourceRepo to null (parameterized, not the string "undefined")', async () => {
93
+ calls.length = 0;
94
+ nextRows = [{ id: 2, module_key: 'widget', status: 'pending' }];
95
+ await moduleSubmissions.createModuleSubmission({
96
+ moduleKey: 'widget', submittedBy: 1, manifest: {}, files: [], signOff: {}, precheckPassedAt: new Date(),
97
+ });
98
+ const { params } = lastCall();
99
+ assert.equal(params[6], null);
100
+ });
101
+
102
+ await test('listModuleSubmissions(): no filters => SELECT with no WHERE, newest-first, no files column', async () => {
103
+ calls.length = 0;
104
+ nextRows = [{ id: 2, module_key: 'b', status: 'pending' }, { id: 1, module_key: 'a', status: 'pending' }];
105
+ const rows = await moduleSubmissions.listModuleSubmissions();
106
+ const { text, params } = lastCall();
107
+ assert.ok(/SELECT/i.test(text));
108
+ assert.ok(!/WHERE/i.test(text));
109
+ assert.ok(/ORDER BY\s+submitted_at\s+DESC/i.test(text));
110
+ const selectClause = text.slice(0, text.search(/FROM/i));
111
+ assert.ok(!/\bfiles\b/i.test(selectClause), 'list SELECT must not include the files column');
112
+ assert.deepEqual(params, []);
113
+ assert.deepEqual(rows, nextRows);
114
+ });
115
+
116
+ await test('listModuleSubmissions({status}): filters by status, parameterized', async () => {
117
+ calls.length = 0;
118
+ nextRows = [];
119
+ await moduleSubmissions.listModuleSubmissions({ status: 'pending' });
120
+ const { text, params } = lastCall();
121
+ assert.ok(/WHERE\s+status\s*=\s*\$1/i.test(text));
122
+ assert.deepEqual(params, ['pending']);
123
+ });
124
+
125
+ await test('listModuleSubmissions({status, moduleKey}): both filters AND together, parameterized in order', async () => {
126
+ calls.length = 0;
127
+ nextRows = [];
128
+ await moduleSubmissions.listModuleSubmissions({ status: 'accepted', moduleKey: 'widget' });
129
+ const { text, params } = lastCall();
130
+ assert.ok(/WHERE\s+status\s*=\s*\$1\s+AND\s+module_key\s*=\s*\$2/i.test(text));
131
+ assert.deepEqual(params, ['accepted', 'widget']);
132
+ });
133
+
134
+ console.log(`\n${passed} passed, ${failed} failed`);
135
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,105 @@
1
+ // tests/npmrc.mjs
2
+ //
3
+ // Unit tests for scripts/gds/npmrc.js (ADR 0134, task 2090) — the single source of the
4
+ // env-fed `.npmrc` used to install the PRIVATE @bongos/core from the npm registry, plus
5
+ // the opt-in / token gates that keep the vendored-tarball path the default/fallback.
6
+ //
7
+ // Run: node tests/npmrc.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const n = require('../scripts/gds/npmrc.js');
17
+
18
+ let passed = 0, failed = 0;
19
+ function test(name, fn) {
20
+ try { fn(); passed++; console.log(` ok ${name}`); }
21
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
22
+ }
23
+
24
+ // ---- npmrcContent ----------------------------------------------------------
25
+ test('npmrcContent: scopes @bongos to the registry + auth token EXPANDS from the env (literal ${NPM_TOKEN})', () => {
26
+ const body = n.npmrcContent();
27
+ assert.match(body, /@bongos:registry=https:\/\/registry\.npmjs\.org\//);
28
+ assert.match(body, /\/\/registry\.npmjs\.org\/:_authToken=\$\{NPM_TOKEN\}/);
29
+ });
30
+
31
+ test('npmrcContent: carries NO real secret — only the ${NPM_TOKEN} placeholder', () => {
32
+ const body = n.npmrcContent();
33
+ // the only _authToken= assignment must be the env placeholder, never a concrete value
34
+ const tokenLines = body.split('\n').filter((l) => /_authToken=/.test(l));
35
+ assert.equal(tokenLines.length, 1);
36
+ assert.equal(tokenLines[0].trim(), '//registry.npmjs.org/:_authToken=${NPM_TOKEN}');
37
+ });
38
+
39
+ test('npmrcContent: comment lines use the ini `;` comment char', () => {
40
+ const comments = n.npmrcContent().split('\n').filter((l) => l.startsWith(';'));
41
+ assert.ok(comments.length >= 3, 'has explanatory comments');
42
+ });
43
+
44
+ test('npmrcContent: honors custom scope/host/tokenEnv', () => {
45
+ const body = n.npmrcContent({ scope: '@acme', host: 'npm.acme.dev', tokenEnv: 'ACME_TOKEN' });
46
+ assert.match(body, /@acme:registry=https:\/\/npm\.acme\.dev\//);
47
+ assert.match(body, /\/\/npm\.acme\.dev\/:_authToken=\$\{ACME_TOKEN\}/);
48
+ });
49
+
50
+ // ---- gates -----------------------------------------------------------------
51
+ test('hasNpmToken: true only when NPM_TOKEN is a non-empty string', () => {
52
+ assert.equal(n.hasNpmToken({ NPM_TOKEN: 'npm_abc' }), true);
53
+ assert.equal(n.hasNpmToken({ NPM_TOKEN: '' }), false);
54
+ assert.equal(n.hasNpmToken({ NPM_TOKEN: ' ' }), false);
55
+ assert.equal(n.hasNpmToken({}), false);
56
+ });
57
+
58
+ test('registryCoreRequested: 1/true/yes/on → true; everything else → false', () => {
59
+ for (const v of ['1', 'true', 'TRUE', 'yes', 'on']) assert.equal(n.registryCoreRequested({ PROVISION_CORE_FROM_REGISTRY: v }), true, v);
60
+ for (const v of ['0', 'false', 'no', '', 'off', 'garbage']) assert.equal(n.registryCoreRequested({ PROVISION_CORE_FROM_REGISTRY: v }), false, v);
61
+ assert.equal(n.registryCoreRequested({}), false, 'unset');
62
+ });
63
+
64
+ // ---- writeNpmrc ------------------------------------------------------------
65
+ test('writeNpmrc: writes the env-fed .npmrc + returns its path', () => {
66
+ const dir = mkdtempSync(join(tmpdir(), 'npmrc-'));
67
+ try {
68
+ const p = n.writeNpmrc(dir, { log: () => {} });
69
+ assert.equal(p, join(dir, '.npmrc'));
70
+ assert.equal(readFileSync(p, 'utf8'), n.npmrcContent());
71
+ } finally { rmSync(dir, { recursive: true, force: true }); }
72
+ });
73
+
74
+ test('writeNpmrc: dry-run writes nothing but returns the intended path', () => {
75
+ const dir = mkdtempSync(join(tmpdir(), 'npmrc-'));
76
+ try {
77
+ const p = n.writeNpmrc(dir, { dryRun: true, log: () => {} });
78
+ assert.equal(p, join(dir, '.npmrc'));
79
+ assert.equal(existsSync(join(dir, '.npmrc')), false, 'dry-run must not write');
80
+ } finally { rmSync(dir, { recursive: true, force: true }); }
81
+ });
82
+
83
+ test('writeNpmrc: overwrite:false leaves a pre-existing .npmrc untouched (returns null)', () => {
84
+ const dir = mkdtempSync(join(tmpdir(), 'npmrc-'));
85
+ try {
86
+ const existing = '@other:registry=https://npm.other.dev/\n';
87
+ writeFileSync(join(dir, '.npmrc'), existing);
88
+ const p = n.writeNpmrc(dir, { overwrite: false, log: () => {} });
89
+ assert.equal(p, null, 'skipped');
90
+ assert.equal(readFileSync(join(dir, '.npmrc'), 'utf8'), existing, 'existing content preserved');
91
+ } finally { rmSync(dir, { recursive: true, force: true }); }
92
+ });
93
+
94
+ test('writeNpmrc: overwrite:true (default) replaces an existing .npmrc', () => {
95
+ const dir = mkdtempSync(join(tmpdir(), 'npmrc-'));
96
+ try {
97
+ writeFileSync(join(dir, '.npmrc'), 'stale\n');
98
+ n.writeNpmrc(dir, { log: () => {} });
99
+ assert.equal(readFileSync(join(dir, '.npmrc'), 'utf8'), n.npmrcContent());
100
+ } finally { rmSync(dir, { recursive: true, force: true }); }
101
+ });
102
+
103
+ console.log('');
104
+ console.log(`npmrc tests: ${passed} passed, ${failed} failed`);
105
+ process.exit(failed ? 1 : 0);
package/tests/onboard.mjs CHANGED
@@ -37,12 +37,30 @@ test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labe
37
37
  assert.doesNotMatch(label(green, 'init'), /--adopt/);
38
38
  });
39
39
 
40
+ test('onboardPlan lists seed-first-version as an explicit AUTO step right after provision (task 2119 anti-drift)', () => {
41
+ // The first-version seed used to be an unlisted afterthought bolted to the local
42
+ // `bongos init` leg (run --no-seed in every hosted flow, so it never fired). Making
43
+ // it a named step in the ONE canonical sequence is what stops it silently drifting.
44
+ const plan = ob.onboardPlan();
45
+ const keys = plan.map((s) => s.key);
46
+ const seed = plan.find((s) => s.key === 'seed-version');
47
+ assert.ok(seed, 'the plan carries a seed-version step');
48
+ assert.equal(seed.kind, 'auto', 'seeding is automated (the runner does it, no owner action)');
49
+ assert.equal(keys.indexOf('seed-version'), keys.indexOf('provision') + 1, 'seed immediately follows provision (the DB now exists)');
50
+ assert.ok(keys.indexOf('seed-version') < keys.indexOf('first-sign-in'), 'a version exists before the owner ever signs in');
51
+ // present in BOTH modes (not a greenfield/adopt branch).
52
+ assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'seed-version'), 'adopt gets it too');
53
+ });
54
+
40
55
  test('oauthAppInstructions pins the apex callback + homepage and points at ACCOUNT Developer settings', () => {
41
56
  const oa = ob.oauthAppInstructions('mercury.example.com');
42
57
  assert.equal(oa.homepageUrl, 'https://mercury.example.com');
43
58
  assert.equal(oa.callbackUrl, 'https://mercury.example.com/api/gds/auth/web/callback');
44
59
  assert.match(oa.location, /ACCOUNT/);
45
60
  assert.match(oa.location, /Developer settings/);
61
+ // task 2127 — must tell the owner to enable Device Flow (off by default; the CLI login
62
+ // uses it). Its absence was the 2026-07-08 demo login-500 root cause.
63
+ assert.match(oa.enableDeviceFlow, /Device Flow/i);
46
64
  });
47
65
 
48
66
  test('buildInitSpec maps answers to a STANDALONE init spec (domain → origin + provisioning shape)', () => {
@@ -68,3 +86,34 @@ test('buildInitSpec without a domain still defaults a standalone provisioning sh
68
86
  test('healthzUrl', () => {
69
87
  assert.equal(ob.healthzUrl('mercury.example.com'), 'https://mercury.example.com/healthz');
70
88
  });
89
+
90
+ // ── task 2085: ONE source for the onboarding sequence (anti-drift) ────────────
91
+
92
+ test('onboardPlan is the SAME function the provisioning module owns (single source, not a copy)', () => {
93
+ // The CLI must CONSUME modules/provisioning/onboard-plan.js, not carry its own copy —
94
+ // that is the whole point of task 2085. A re-declared duplicate would pass every other
95
+ // test yet silently drift; identity of the function reference proves there is one source.
96
+ const canonical = require('../modules/provisioning/onboard-plan.js').onboardPlan;
97
+ assert.equal(ob.onboardPlan, canonical, 'scripts/gds/onboard.js must re-export the module’s onboardPlan');
98
+ });
99
+
100
+ test('the web forms (/create + hall wizard) declare EXACTLY the canonical human steps — drift guard', () => {
101
+ // goal 35 / criterion 165: "the canonical step sequence is the source of truth;
102
+ // skill/CLI/hall are forms over it." Each web form tags its human-step <li>s with
103
+ // data-onboard-step="<key>"; this asserts that set equals the plan's human-step keys,
104
+ // so adding/removing/renaming a human step in the single source fails CI until every
105
+ // form is updated (and vice-versa) — the sequence can no longer drift between forms.
106
+ const { readFileSync } = require('node:fs');
107
+ const path = require('node:path');
108
+ const humanKeys = ob.onboardPlan().filter((s) => s.kind === 'human').map((s) => s.key).sort();
109
+ const surfaces = {
110
+ '/create landing page': 'public-landing/create.html',
111
+ 'hall new-project wizard': 'modules/hall-ui/public/new-project-wizard.js',
112
+ };
113
+ for (const [name, rel] of Object.entries(surfaces)) {
114
+ const src = readFileSync(path.join(import.meta.dirname, '..', rel), 'utf8');
115
+ const found = [...src.matchAll(/data-onboard-step="([^"]+)"/g)].map((m) => m[1]);
116
+ const uniq = [...new Set(found)].sort();
117
+ assert.deepEqual(uniq, humanKeys, `${name} (${rel}) must tag exactly the canonical human steps [${humanKeys}], found [${uniq}]`);
118
+ }
119
+ });
@@ -25,6 +25,8 @@ const {
25
25
  ARTIFACT_NAME, MANIFEST_SCHEMA, TAR_ROOT,
26
26
  // task 1824 — the consumer-core file-classification layer
27
27
  isDocPath, functionalSecretLeaks, processCoreFile,
28
+ // task 2121 — agent-doc (CLAUDE.md/AGENTS.md) stub
29
+ isAgentDoc, CORE_AGENT_DOC_STUB,
28
30
  // task 1882 (ADR 0108 §2) — the real installable npm package
29
31
  CORE_DEP_NAMES, buildPackageJson, resolveLockDep, lockfileClosure, prunePackageLock,
30
32
  // task 2089 (ADR 0134) — private-by-default npm publish
@@ -141,27 +143,27 @@ t('withTarballInfo adds the exact-bytes coordinate for the side-car pin', () =>
141
143
  version: '1.13.0', coreContract: '1.13.0', sourceCommit: 'abc', sourceRef: 'HEAD',
142
144
  builtAtIso: '2026-07-01T00:00:00.000Z', files: [], treeHash: 'x', redaction: null,
143
145
  });
144
- const side = withTarballInfo(inner, { name: 'cloudbongos-core-1.13.0.tgz', sha256: 'cafe', bytes: 42 });
146
+ const side = withTarballInfo(inner, { name: 'bongos-core-1.13.0.tgz', sha256: 'cafe', bytes: 42 });
145
147
  assert.equal(side.tarball.sha256, 'cafe');
146
148
  assert.equal(side.tarball.bytes, 42);
147
149
  assert.equal(inner.tarball, undefined); // withTarballInfo does not mutate the inner
148
150
  });
149
151
 
150
152
  t('artifact filenames', () => {
151
- assert.equal(tarballName('1.13.0'), 'cloudbongos-core-1.13.0.tgz');
152
- assert.equal(manifestName('1.13.0'), 'cloudbongos-core-1.13.0.manifest.json');
153
- assert.equal(stageDirName('1.13.0'), 'cloudbongos-core-1.13.0');
153
+ assert.equal(tarballName('1.13.0'), 'bongos-core-1.13.0.tgz');
154
+ assert.equal(manifestName('1.13.0'), 'bongos-core-1.13.0.manifest.json');
155
+ assert.equal(stageDirName('1.13.0'), 'bongos-core-1.13.0');
154
156
  });
155
157
 
156
158
  // ── ustar name/prefix split ──────────────────────────────────────────────────────────
157
159
  t('splitUstarName: short path → name only', () => {
158
- const r = splitUstarName('cloudbongos-core/a.js');
160
+ const r = splitUstarName('bongos-core/a.js');
159
161
  assert.equal(r.prefix, '');
160
- assert.equal(r.name, 'cloudbongos-core/a.js');
162
+ assert.equal(r.name, 'bongos-core/a.js');
161
163
  });
162
164
 
163
165
  t('splitUstarName: long path splits at a / into name<=100 + prefix<=155', () => {
164
- const long = 'cloudbongos-core/' + 'dir/'.repeat(20) + 'file.js'; // > 100 bytes
166
+ const long = 'bongos-core/' + 'dir/'.repeat(24) + 'file.js'; // > 100 bytes → forces a name/prefix split
165
167
  const r = splitUstarName(long);
166
168
  assert.ok(r, 'expected a split');
167
169
  assert.ok(Buffer.byteLength(r.name) <= 100);
@@ -177,18 +179,18 @@ t('splitUstarName: unsplittable (>100-byte tail) → null (longlink fallback)',
177
179
  // ── buildTar — produces a real, readable, deterministic archive ──────────────────────
178
180
  t('buildTar round-trips a small set (content + mode preserved)', () => {
179
181
  const entries = [
180
- { name: 'cloudbongos-core/a.txt', mode: 0o644, buf: Buffer.from('hello\n') },
181
- { name: 'cloudbongos-core/bin/run.sh', mode: 0o755, buf: Buffer.from('#!/bin/sh\necho hi\n') },
182
+ { name: 'bongos-core/a.txt', mode: 0o644, buf: Buffer.from('hello\n') },
183
+ { name: 'bongos-core/bin/run.sh', mode: 0o755, buf: Buffer.from('#!/bin/sh\necho hi\n') },
182
184
  ];
183
185
  const files = readTar(buildTar(entries));
184
- assert.equal(files.get('cloudbongos-core/a.txt').content.toString(), 'hello\n');
185
- assert.equal(files.get('cloudbongos-core/bin/run.sh').content.toString(), '#!/bin/sh\necho hi\n');
186
- assert.equal(files.get('cloudbongos-core/bin/run.sh').mode & 0o777, 0o755);
187
- assert.equal(files.get('cloudbongos-core/a.txt').mode & 0o777, 0o644);
186
+ assert.equal(files.get('bongos-core/a.txt').content.toString(), 'hello\n');
187
+ assert.equal(files.get('bongos-core/bin/run.sh').content.toString(), '#!/bin/sh\necho hi\n');
188
+ assert.equal(files.get('bongos-core/bin/run.sh').mode & 0o777, 0o755);
189
+ assert.equal(files.get('bongos-core/a.txt').mode & 0o777, 0o644);
188
190
  });
189
191
 
190
192
  t('buildTar round-trips a >100-byte path via the GNU longlink path', () => {
191
- const longName = 'cloudbongos-core/' + 'deep/'.repeat(25) + 'leaf.js'; // ~150 bytes, but splittable
193
+ const longName = 'bongos-core/' + 'deep/'.repeat(25) + 'leaf.js'; // ~150 bytes, but splittable
192
194
  const unsplittable = 'z'.repeat(130); // forces the longlink branch (no valid split)
193
195
  const entries = [
194
196
  { name: longName, mode: 0o644, buf: Buffer.from('deep\n') },
@@ -201,15 +203,15 @@ t('buildTar round-trips a >100-byte path via the GNU longlink path', () => {
201
203
 
202
204
  t('buildTar is byte-deterministic (same input → identical bytes; entry order irrelevant)', () => {
203
205
  const e1 = [
204
- { name: 'cloudbongos-core/b.txt', mode: 0o644, buf: Buffer.from('B') },
205
- { name: 'cloudbongos-core/a.txt', mode: 0o644, buf: Buffer.from('A') },
206
+ { name: 'bongos-core/b.txt', mode: 0o644, buf: Buffer.from('B') },
207
+ { name: 'bongos-core/a.txt', mode: 0o644, buf: Buffer.from('A') },
206
208
  ];
207
209
  const e2 = [...e1].reverse();
208
210
  assert.equal(sha256hex(buildTar(e1)), sha256hex(buildTar(e2)));
209
211
  });
210
212
 
211
213
  t('buildTar output gunzips and ends with two zero blocks', () => {
212
- const gz = buildTar([{ name: 'cloudbongos-core/a', mode: 0o644, buf: Buffer.from('x') }]);
214
+ const gz = buildTar([{ name: 'bongos-core/a', mode: 0o644, buf: Buffer.from('x') }]);
213
215
  const tar = zlib.gunzipSync(gz);
214
216
  assert.equal(tar.length % 512, 0);
215
217
  const tail = tar.subarray(tar.length - 1024);
@@ -298,6 +300,50 @@ t('DOCS are genericized: instance brand/owner redacted, output is clean', () =>
298
300
  assert.equal(r.leaks.length, 0); // clean after redaction → no abort
299
301
  });
300
302
 
303
+ // ── task 2121 — CLAUDE.md/AGENTS.md ship as a brand-free stub (demo-audit fix) ──────
304
+ t('isAgentDoc: matches CLAUDE.md/AGENTS.md at root + any depth, nothing else', () => {
305
+ assert.equal(isAgentDoc('CLAUDE.md'), true);
306
+ assert.equal(isAgentDoc('scripts/gds/CLAUDE.md'), true);
307
+ assert.equal(isAgentDoc('src/bongos/routes/CLAUDE.md'), true);
308
+ assert.equal(isAgentDoc('modules/lifecycle/CLAUDE.md'), true);
309
+ assert.equal(isAgentDoc('AGENTS.md'), true);
310
+ assert.equal(isAgentDoc('docs/CLAUDE.md'), true);
311
+ // NOT an agent doc: other markdown, or a filename that merely contains the word
312
+ assert.equal(isAgentDoc('README.md'), false);
313
+ assert.equal(isAgentDoc('docs/adr/0100.md'), false);
314
+ assert.equal(isAgentDoc('docs/CLAUDE-notes.md'), false);
315
+ assert.equal(isAgentDoc('src/claude.js'), false);
316
+ });
317
+
318
+ t('processCoreFile STUBS a CLAUDE.md — the core memory never ships (no auto-load pollution)', () => {
319
+ // A realistic core CLAUDE.md: the wrong project title + instance brand + a long body.
320
+ const coreClaude = '# Off the Boats — Project Memory\n' +
321
+ 'Visit https://acme.example (world Xanadu), owner zoltan. ' + 'lorem ipsum '.repeat(200);
322
+ const r = proc('CLAUDE.md', coreClaude);
323
+ assert.equal(r.kind, 'stub'); // classified as a stub, not a doc
324
+ assert.equal(r.leaks.length, 0); // brand-free by construction → never a leak
325
+ const out = r.buf.toString();
326
+ assert.equal(out, CORE_AGENT_DOC_STUB); // exactly the stub, verbatim
327
+ assert.ok(!/Off the Boats/i.test(out), 'wrong project title gone');
328
+ assert.ok(!/acme/i.test(out) && !/zoltan/i.test(out) && !/Xanadu/i.test(out), 'no instance brand');
329
+ assert.ok(!/lorem ipsum/.test(out), 'original 200-line body gone');
330
+ assert.ok(out.length < coreClaude.length, 'stub is far shorter than the real memory');
331
+ });
332
+
333
+ t('processCoreFile STUBS nested + module CLAUDE.md too (all auto-load sites)', () => {
334
+ for (const p of ['scripts/gds/CLAUDE.md', 'src/bongos/CLAUDE.md', 'modules/economy/CLAUDE.md']) {
335
+ const r = proc(p, '# nested map\nsecret https://acme.example detail');
336
+ assert.equal(r.kind, 'stub', `${p} stubbed`);
337
+ assert.equal(r.buf.toString(), CORE_AGENT_DOC_STUB, `${p} → stub body`);
338
+ }
339
+ });
340
+
341
+ t('the stub itself carries no instance brand and states it does not govern', () => {
342
+ assert.ok(!/acme|zoltan|Xanadu|Off the Boats/i.test(CORE_AGENT_DOC_STUB));
343
+ assert.ok(/does not govern/i.test(CORE_AGENT_DOC_STUB));
344
+ assert.ok(/@bongos\/core/.test(CORE_AGENT_DOC_STUB));
345
+ });
346
+
301
347
  // ── task 1882 (ADR 0108 §2) — the real installable npm package ─────────────────────
302
348
  t('TAR_ROOT is the npm-required "package/" (not the ARTIFACT_NAME dir)', () => {
303
349
  assert.equal(TAR_ROOT, 'package/');
@@ -309,7 +355,7 @@ t('buildPackageJson: prunes to express+pg, keeps the source ranges + the undici
309
355
  sourceDeps: { express: '^4.21.2', pg: '^8.20.0', phaser: '^3.87.0', 'discord.js': '^14.26.4' },
310
356
  sourceOverrides: { undici: '^6.27.0' },
311
357
  });
312
- assert.equal(pkg.name, '@cloudbongos/core');
358
+ assert.equal(pkg.name, '@bongos/core');
313
359
  assert.equal(pkg.version, '1.14.0');
314
360
  assert.deepEqual(pkg.dependencies, { express: '^4.21.2', pg: '^8.20.0' });
315
361
  assert.equal(pkg.dependencies.phaser, undefined);
@@ -325,7 +371,7 @@ t('buildPackageJson: prunes to express+pg, keeps the source ranges + the undici
325
371
  });
326
372
 
327
373
  t('publishArgs: private (restricted) publish, dry-run unless live (task 2089 / ADR 0134)', () => {
328
- const tgz = 'dist/cloudbongos-core-1.14.0.tgz';
374
+ const tgz = 'dist/bongos-core-1.14.0.tgz';
329
375
  // Default: dry-run, restricted (never public, never a real upload).
330
376
  assert.deepEqual(publishArgs(tgz, false), ['publish', tgz, '--access', 'restricted', '--dry-run']);
331
377
  // Live: still restricted (private) — the permanent publish is never public here.
@@ -408,8 +454,8 @@ t('lockfileClosure: matches the real repo lockfile — express+pg close over the
408
454
  });
409
455
 
410
456
  t('prunePackageLock: emits a self-consistent pruned lockfile (root ranges + only the kept packages)', () => {
411
- const pruned = prunePackageLock(FIXTURE_LOCK, { name: '@cloudbongos/core', version: '1.14.0', rootRanges: { a: '^1.0.0' }, rootDepNames: ['a'] });
412
- assert.equal(pruned.name, '@cloudbongos/core');
457
+ const pruned = prunePackageLock(FIXTURE_LOCK, { name: '@bongos/core', version: '1.14.0', rootRanges: { a: '^1.0.0' }, rootDepNames: ['a'] });
458
+ assert.equal(pruned.name, '@bongos/core');
413
459
  assert.equal(pruned.version, '1.14.0');
414
460
  assert.equal(pruned.lockfileVersion, 3);
415
461
  assert.equal(pruned.requires, true);
@@ -423,7 +469,7 @@ t('prunePackageLock: emits a self-consistent pruned lockfile (root ranges + only
423
469
  t('prunePackageLock: on the real repo lockfile, drops the vast majority of packages', () => {
424
470
  const realLock = require('../package-lock.json');
425
471
  const pruned = prunePackageLock(realLock, {
426
- name: '@cloudbongos/core', version: '1.14.0', rootRanges: { express: '^4.21.2', pg: '^8.20.0' },
472
+ name: '@bongos/core', version: '1.14.0', rootRanges: { express: '^4.21.2', pg: '^8.20.0' },
427
473
  });
428
474
  const keptCount = Object.keys(pruned.packages).length - 1; // exclude the root '' entry
429
475
  const sourceCount = Object.keys(realLock.packages).length - 1;
@@ -107,8 +107,11 @@ test('the apex host serves the landing page — not the game — on a game-less
107
107
  // it IS the landing, with the branding pack injected (look + identity from config)
108
108
  assert.match(res.body, /window\.__BRANDING__=/);
109
109
  assert.match(res.body, /Enter the Hall/);
110
- // the platform-level create door (goal 35, task 2084) — gated by sign-in client-side
111
- assert.match(res.body, /Create a project/);
110
+ // the per-project landing carries the top-right identity chip + hall entry
111
+ // (goal 35, task 2094). "Create a project" is an ORG-level action that lives
112
+ // only on the gate now — it must NOT appear on a project's own landing.
113
+ assert.match(res.body, /id="who"/);
114
+ assert.doesNotMatch(res.body, /Create a project/);
112
115
  });
113
116
 
114
117
  test('the apex host serves the branded /create page (goal 35, task 2081)', async () => {