@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
@@ -46,6 +46,33 @@ t('dbCreateCmd / migrateCmd use the slug db + GDS-only migrate', () => {
46
46
  assert.match(P.migrateCmd(coTenant), /PGDATABASE=mercury MEDUSA_GDS_ONLY=1 \.\/scripts\/migrate\.sh/);
47
47
  });
48
48
 
49
+ t('seedFirstVersionCmd: idempotent (only-if-empty) V1 seed against the slug db, track explicit, one criterion — the fix for the empty-versions bug (task 2119)', () => {
50
+ const cmd = P.seedFirstVersionCmd(coTenant);
51
+ // targets the instance's OWN db, matching createdb/migrate
52
+ assert.match(cmd, /psql .*-d mercury /, 'seeds the instance db');
53
+ assert.match(cmd, /ON_ERROR_STOP=1/, 'aborts on any error (atomic, no half-seed)');
54
+ // the version the greenfield init spec defines — ONE source of truth
55
+ const v = P.instanceInitSpec(coTenant).version;
56
+ assert.match(cmd, /INSERT INTO versions \(id, name, track, status, done_when\)/);
57
+ assert.match(cmd, new RegExp(`\\$v\\$${reEsc(v.id)}\\$v\\$`), 'seeds the spec version id (V1)');
58
+ assert.match(cmd, /\$v\$product\$v\$/, 'track is set EXPLICITLY (versions.track is NOT NULL, no default since mig 112)');
59
+ assert.match(cmd, /\$v\$building\$v\$/, 'status building, mirroring seedViaApi');
60
+ assert.ok(cmd.includes(v.done_when), 'carries the spec done_when verbatim (incl. the → arrows)');
61
+ // the IDEMPOTENT guard — only seeds when there is NO version yet, so it never disturbs an existing project
62
+ assert.match(cmd, /WHERE NOT EXISTS \(SELECT 1 FROM versions\)/, 'only-if-empty guard');
63
+ // one done-when criterion so /status has a finish line (matches seedViaApi's 'first-goal')
64
+ assert.match(cmd, /INSERT INTO done_when_criteria/);
65
+ assert.match(cmd, /\$v\$first-goal\$v\$/);
66
+ // dollar-quoted literals ⇒ the whole -c arg is single-quoted (safe inline / SSH / cloud-init)
67
+ assert.match(cmd, /-c '.*'/, 'the SQL is a single-quoted -c argument');
68
+ assert.doesNotMatch(cmd, /-c ".*"/, 'never double-quoted (would break cloud-init bash -lc "…")');
69
+ });
70
+
71
+ t('seedFirstVersionCmd: privileged runs psql as the postgres role; default is bare (peer auth) — mirrors createdb/migrate', () => {
72
+ assert.match(P.seedFirstVersionCmd(coTenant, { privileged: false }), /^psql /);
73
+ assert.match(P.seedFirstVersionCmd(coTenant, { privileged: true }), /^sudo -u postgres psql /);
74
+ });
75
+
49
76
  t('serviceUnit binds the instance port + DB + per-instance env file, runs platform-server', () => {
50
77
  const u = P.serviceUnit(coTenant, { etcBase: '/etc' });
51
78
  assert.match(u, /Environment=PORT=3003/);
@@ -60,7 +87,7 @@ t('serviceUnit(standalone) runs the CORE PACKAGE server from the instance\'s OWN
60
87
  const u = P.serviceUnit(inst, { etcBase: '/etc' });
61
88
  const root = P.standaloneRoot(inst);
62
89
  assert.match(u, new RegExp('WorkingDirectory=' + reEsc(root)), 'runs from the instance\'s own repo dir');
63
- assert.match(u, /ExecStart=\/usr\/bin\/node node_modules\/@cloudbongos\/core\/src\/platform-server\.js/, 'runs the pinned core package server');
90
+ assert.match(u, /ExecStart=\/usr\/bin\/node node_modules\/@bongos\/core\/src\/platform-server\.js/, 'runs the pinned core package server');
64
91
  assert.match(u, /Environment=PORT=3003/);
65
92
  assert.match(u, /EnvironmentFile=\/etc\/mercury\/web\.env/);
66
93
  });
@@ -70,7 +97,7 @@ t('standalone step generators: root under standaloneBase + pull / npm-ci / insta
70
97
  assert.match(P.standaloneRoot(inst), /\/mercury$/);
71
98
  assert.equal(P.standalonePullCmd(), 'git pull --ff-only origin main');
72
99
  assert.equal(P.standaloneInstallCmd(), 'npm ci --omit=dev');
73
- assert.match(P.standaloneMigrateCmd(inst), /PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules\/@cloudbongos\/core\/scripts\/migrate\.sh/);
100
+ assert.match(P.standaloneMigrateCmd(inst), /PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules\/@bongos\/core\/scripts\/migrate\.sh/);
74
101
  });
75
102
 
76
103
  t('webEnvBody: OAuth creds use the LOADED pack\'s envPrefix (not the slug) + brand/port/db', () => {
@@ -98,6 +125,18 @@ t('webEnvBody: a domain writes the single-host origin overrides so the hall load
98
125
  assert.match(b, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_OAUTH_ORIGIN=${O}`));
99
126
  });
100
127
 
128
+ t('webEnvBody: the repo target_ref seats the founding owner via <prefix>_FIRST_ADMIN (goal 35 / criterion 165)', () => {
129
+ // Regression (caught live on demo.cloudbongos.com, 2026-07-07): a provisioned instance
130
+ // MUST convey its founding owner, or src/bongos/auth.js maybeSeatFirstAdmin can never
131
+ // seat an Archon on first sign-in and the owner can't claim their own instance. Derived
132
+ // from the repo owner in target_ref — the SAME source instanceInitSpec uses for the
133
+ // scaffold's branding.firstAdmin, so the env override and config/branding.json agree.
134
+ const b = P.webEnvBody({ ...coTenant, hosting_shape: 'standalone', domain: 'mercury.example.com', target_ref: 'octocat/mercury' });
135
+ assert.match(b, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_FIRST_ADMIN=octocat`));
136
+ // No target_ref ⇒ the line is omitted (org-owned or unknown owner sets it by hand).
137
+ assert.doesNotMatch(P.webEnvBody(coTenant), /_FIRST_ADMIN=/);
138
+ });
139
+
101
140
  t('caddyBlock: a domain → on-demand-TLS reverse_proxy to the local port; no domain → a comment', () => {
102
141
  const withDomain = P.caddyBlock({ ...coTenant, domain: 'mercury.example.com' });
103
142
  assert.match(withDomain, /mercury\.example\.com \{/);
@@ -142,11 +181,22 @@ t('healthzCmd hits the instance port /healthz', () => {
142
181
  assert.match(P.healthzCmd(coTenant), /127\.0\.0\.1:3003\/healthz/);
143
182
  });
144
183
 
145
- t('dedicatedUserData bakes createdb + migrate into cloud-init', () => {
184
+ t('dedicatedUserData bakes createdb + migrate + first-version seed into cloud-init (task 2119: dedicated must not diverge)', () => {
146
185
  const ud = P.dedicatedUserData(coTenant);
147
186
  assert.match(ud, /^#cloud-config/);
148
187
  assert.match(ud, /createdb mercury/);
149
188
  assert.match(ud, /MEDUSA_GDS_ONLY=1/);
189
+ // the seed rides cloud-init too, so a dedicated droplet also comes up able to take tasks
190
+ assert.match(ud, /INSERT INTO versions/, 'seed baked into cloud-init');
191
+ // it must be its OWN runcmd line AFTER migrate (the schema must exist first)
192
+ const lines = ud.split('\n');
193
+ const iMig = lines.findIndex((l) => /migrate\.sh/.test(l));
194
+ const iSeed = lines.findIndex((l) => /INSERT INTO versions/.test(l));
195
+ assert.ok(iMig >= 0 && iSeed > iMig, 'seed line comes after the migrate line');
196
+ // the seed line is a valid bash -lc "…" item: single-quoted -c arg, NO double quotes to break the YAML scalar
197
+ const seedLine = lines[iSeed];
198
+ assert.match(seedLine, /- \[ bash, -lc, "psql .*" \]/, 'well-formed cloud-init runcmd item');
199
+ assert.doesNotMatch(seedLine.replace(/^.*-lc, "/, '').replace(/" \]$/, ''), /"/, 'the seed command carries no inner double quotes');
150
200
  });
151
201
 
152
202
  console.log('\nport allocation:');
@@ -215,12 +265,15 @@ await ta('provisionInstance(co-tenant) composes db → migrate → service → h
215
265
  const joined = cmds.join('\n');
216
266
  assert.match(joined, /createdb mercury/);
217
267
  assert.match(joined, /MEDUSA_GDS_ONLY=1/);
268
+ assert.match(joined, /INSERT INTO versions/, 'seeds the first version (task 2119)');
218
269
  assert.match(joined, /systemctl daemon-reload/);
219
270
  assert.match(joined, /systemctl enable --now mercury/);
220
271
  assert.match(joined, /healthz/);
221
- // ordering: createdb before migrate before enable before healthz
272
+ // ordering: createdb before migrate before SEED before enable before healthz
222
273
  const idx = (re) => cmds.findIndex((c) => re.test(c));
223
274
  assert.ok(idx(/createdb/) < idx(/migrate\.sh/), 'createdb before migrate');
275
+ assert.ok(idx(/migrate\.sh/) < idx(/INSERT INTO versions/), 'migrate before the version seed (schema must exist)');
276
+ assert.ok(idx(/INSERT INTO versions/) < idx(/enable --now/), 'seed before service enable');
224
277
  assert.ok(idx(/migrate\.sh/) < idx(/enable --now/), 'migrate before service enable');
225
278
  assert.ok(idx(/enable --now/) < idx(/healthz/), 'service before healthz');
226
279
  // writes: the per-instance env file + the systemd unit
@@ -243,8 +296,9 @@ await ta('provisionInstance(standalone) SCAFFOLDS its repo, then syncs (pull + n
243
296
  assert.match(joined, /git pull --ff-only origin main/, 'pulls the instance repo');
244
297
  assert.match(joined, /npm ci --omit=dev/, 'installs the pinned core');
245
298
  assert.match(joined, /createdb mercury/);
246
- assert.match(joined, /node_modules\/@cloudbongos\/core\/scripts\/migrate\.sh/, 'migrates from the INSTANCE root\'s core');
299
+ assert.match(joined, /node_modules\/@bongos\/core\/scripts\/migrate\.sh/, 'migrates from the INSTANCE root\'s core');
247
300
  assert.ok(!cmds.some((c) => /\.\/scripts\/migrate\.sh/.test(c)), 'standalone does NOT use the control-plane checkout migrate');
301
+ assert.match(joined, /INSERT INTO versions/, 'standalone seeds its first version too (task 2119)');
248
302
  // ordering: clone → package-core → init → push → pull → npm ci → migrate → service
249
303
  const idx = (re) => cmds.findIndex((c) => re.test(c));
250
304
  assert.ok(idx(/git clone/) < idx(/package-core/), 'clone before tarball build');
@@ -253,6 +307,8 @@ await ta('provisionInstance(standalone) SCAFFOLDS its repo, then syncs (pull + n
253
307
  assert.ok(idx(/push -u origin main/) < idx(/git pull/), 'scaffold push before the deploy pull');
254
308
  assert.ok(idx(/git pull/) < idx(/npm ci/), 'pull before npm ci');
255
309
  assert.ok(idx(/npm ci/) < idx(/migrate\.sh/), 'npm ci before migrate');
310
+ assert.ok(idx(/migrate\.sh/) < idx(/INSERT INTO versions/), 'migrate before the version seed');
311
+ assert.ok(idx(/INSERT INTO versions/) < idx(/enable --now/), 'seed before service enable');
256
312
  assert.ok(idx(/migrate\.sh/) < idx(/enable --now/), 'migrate before service enable');
257
313
  assert.ok(writes.some((w) => /\/mercury\.service$/.test(w)), 'wrote the (standalone) systemd unit');
258
314
  });
@@ -316,6 +372,47 @@ await ta('scaffoldStandaloneRepo: idempotent — a repo that already carries pac
316
372
  }
317
373
  });
318
374
 
375
+ // ── ADR 0134 (task 2090): registry core-install path + fail-soft to vendored ──
376
+ // Runs the scaffold against a FRESH standaloneBase (no package.json → it proceeds) with
377
+ // an injected env, and asserts which core-resolution shape it chose from the commands.
378
+ async function scaffoldWithEnv(env) {
379
+ const tmp = mkdtempSync(join(tmpdir(), 'scaffold-'));
380
+ const origBase = P.CONFIG.standaloneBase;
381
+ P.CONFIG.standaloneBase = tmp;
382
+ try {
383
+ const { deps, cmds } = recorder({ apply: true });
384
+ await P.scaffoldStandaloneRepo(
385
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo' },
386
+ { ...deps, env },
387
+ );
388
+ return cmds.join('\n');
389
+ } finally {
390
+ P.CONFIG.standaloneBase = origBase;
391
+ rmSync(tmp, { recursive: true, force: true });
392
+ }
393
+ }
394
+
395
+ await ta('scaffoldStandaloneRepo: PROVISION_CORE_FROM_REGISTRY=1 + NPM_TOKEN → registry path (init --registry-core, no tarball build)', async () => {
396
+ const joined = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1', NPM_TOKEN: 'npm_readonly' });
397
+ assert.match(joined, /init\.js --from .* --dir .* --no-seed --force --registry-core/, 'materializes via --registry-core');
398
+ assert.ok(!/package-core\.js/.test(joined), 'does NOT build a core tarball on the registry path');
399
+ assert.ok(!/--vendor-core/.test(joined), 'does NOT vendor a tarball on the registry path');
400
+ });
401
+
402
+ await ta('scaffoldStandaloneRepo: registry requested but NO NPM_TOKEN → FAILS SOFT to the vendored tarball', async () => {
403
+ const joined = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1' });
404
+ assert.match(joined, /node scripts\/gds\/package-core\.js/, 'builds the core tarball (vendored fallback)');
405
+ assert.match(joined, /init\.js --from .* --dir .* --no-seed --force --vendor-core /, 'file:-pins the vendored tarball');
406
+ assert.ok(!/--registry-core/.test(joined), 'does NOT take the registry path without a token');
407
+ });
408
+
409
+ await ta('scaffoldStandaloneRepo: default (no registry env) stays on the vendored tarball path', async () => {
410
+ const joined = await scaffoldWithEnv({});
411
+ assert.match(joined, /node scripts\/gds\/package-core\.js/, 'vendored is the default');
412
+ assert.match(joined, /--vendor-core /);
413
+ assert.ok(!/--registry-core/.test(joined));
414
+ });
415
+
319
416
  await ta('provisionInstance is idempotent: an already-active instance is a no-op', async () => {
320
417
  const { deps, cmds } = recorder();
321
418
  const r = await P.provisionInstance({ ...coTenant, status: 'active' }, deps);
@@ -323,10 +420,11 @@ await ta('provisionInstance is idempotent: an already-active instance is a no-op
323
420
  assert.equal(cmds.length, 0, 'no commands run for an active instance');
324
421
  });
325
422
 
326
- await ta('provisionInstance(dedicated) does NOT run local createdb/migrate (they ride cloud-init)', async () => {
423
+ await ta('provisionInstance(dedicated) does NOT run local createdb/migrate/seed (they ride cloud-init)', async () => {
327
424
  const { deps, cmds } = recorder();
328
425
  await P.provisionInstance({ ...coTenant, hosting_shape: 'dedicated', tier: 's-2vcpu-4gb' }, deps);
329
426
  assert.ok(!cmds.some((c) => /createdb/.test(c)), 'dedicated does not run createdb locally');
427
+ assert.ok(!cmds.some((c) => /INSERT INTO versions/.test(c)), 'dedicated does not run the version seed locally (it rides cloud-init)');
330
428
  });
331
429
 
332
430
  console.log('\nDNS UPSERT composition (P4, CONFIG override):');
@@ -552,8 +650,8 @@ t('migrateCmd: privileged runs migrate as postgres via env+bash (survives sudo e
552
650
 
553
651
  t('standaloneMigrateCmd: privileged runs the instance-root migrate as postgres; default unchanged', () => {
554
652
  const inst = { ...coTenant, hosting_shape: 'standalone' };
555
- assert.equal(P.standaloneMigrateCmd(inst), 'PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules/@cloudbongos/core/scripts/migrate.sh');
556
- assert.equal(P.standaloneMigrateCmd(inst, { privileged: true }), 'sudo -u postgres env PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules/@cloudbongos/core/scripts/migrate.sh');
653
+ assert.equal(P.standaloneMigrateCmd(inst), 'PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules/@bongos/core/scripts/migrate.sh');
654
+ assert.equal(P.standaloneMigrateCmd(inst, { privileged: true }), 'sudo -u postgres env PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules/@bongos/core/scripts/migrate.sh');
557
655
  });
558
656
 
559
657
  t('makeWriteFile(privileged): a { sudo:true } write stages a temp file + `sudo install -D -m <mode>` (never writes /etc directly)', () => {
@@ -615,6 +713,7 @@ await ta('provisionInstance(co-tenant, privileged): createdb/migrate run as post
615
713
  const joined = cmds.join('\n');
616
714
  assert.match(joined, /sudo -u postgres createdb mercury/, 'createdb as postgres');
617
715
  assert.match(joined, /sudo -u postgres env PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash \.\/scripts\/migrate\.sh/, 'migrate as postgres');
716
+ assert.match(joined, /sudo -u postgres psql .*INSERT INTO versions/, 'first-version seed runs as postgres too (task 2119)');
618
717
  assert.match(joined, /sudo systemctl daemon-reload/, 'daemon-reload sudo-prefixed');
619
718
  assert.match(joined, /sudo systemctl enable --now mercury/, 'enable sudo-prefixed');
620
719
  assert.match(joined, /sudo systemctl reload caddy/, 'reload caddy sudo-prefixed');
@@ -30,7 +30,7 @@ provisioning.attachManifestCode = async (_db, id, code) => ({ id, status: 'code_
30
30
  // A branding with a known publicOrigin so mothershipOrigin() resolves deterministically.
31
31
  const branding = require(`${ROOT}/src/branding.js`);
32
32
  const realBranding = branding.branding;
33
- branding.branding = () => ({ ...realBranding(), domains: { publicOrigin: 'https://cloudbongos.com' } });
33
+ branding.branding = () => ({ ...realBranding(), identity: { ...(realBranding().identity || {}), productName: 'Cloud Bongos' }, domains: { publicOrigin: 'https://cloudbongos.com' } });
34
34
 
35
35
  // Inject the signed-in owner (the real requireBuilder needs a session).
36
36
  const api = require(`${ROOT}/src/module-api.js`);
@@ -66,6 +66,7 @@ console.log('POST /provisioning/instances/:id/github-app (mint the one-click lin
66
66
  t('post_url targets GitHub personal apps/new carrying the state', j.post_url === `https://github.com/settings/apps/new?state=${j.state}`, j.post_url);
67
67
  t('manifest.redirect_url is the MOTHERSHIP callback', j.manifest && j.manifest.redirect_url === 'https://cloudbongos.com/api/bongos/provisioning/github-app/callback', j.manifest && j.manifest.redirect_url);
68
68
  t('manifest.callback_urls are the INSTANCE sign-in (both prefixes)', j.manifest && j.manifest.callback_urls[0] === 'https://mercury.example.com/api/gds/auth/web/callback' && j.manifest.callback_urls[1] === 'https://mercury.example.com/api/bongos/auth/web/callback');
69
+ t('manifest.name is the instance domain (unique + matches the consent-screen redirect) within 34 chars', j.manifest && j.manifest.name === 'mercury.example.com' && j.manifest.name.length <= 34, j.manifest && j.manifest.name);
69
70
  t('org_url derived from the target_ref owner (acme)', j.org_url === `https://github.com/organizations/acme/settings/apps/new?state=${j.state}`, j.org_url);
70
71
  t('the attempt row was persisted with the minted state', !!(stored && stored.instanceId === 42 && stored.state === j.state && stored.requestedBy === 7));
71
72
  }
@@ -74,7 +75,7 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
74
75
  {
75
76
  const res = await fetch(`${base}/provisioning/github-app/callback?code=GHCODE123&state=GOODSTATE`);
76
77
  const html = await res.text();
77
- t('good state → 200 text/html finishing-setup page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub App created/.test(html) && /no secret/i.test(html), `status=${res.status}`);
78
+ t('good state → 200 text/html progress page wired to auto-open', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /Setting up your instance/.test(html) && /Placing credentials/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
78
79
  t('the page names the instance host', /mercury\.example\.com/.test(html));
79
80
  }
80
81
  {
@@ -87,6 +88,32 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
87
88
  t('missing code/state → 400', res.status === 400, `status=${res.status}`);
88
89
  }
89
90
 
91
+ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open signal):');
92
+ {
93
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
94
+ const j = await res.json();
95
+ t('pending → 200 ok, not ready, names the domain', res.status === 200 && j.ok === true && j.status === 'pending' && j.ready === false && j.domain === 'mercury.example.com', `status=${res.status} ${JSON.stringify(j)}`);
96
+ }
97
+ {
98
+ // Swap the stub to a placed-credentials ('done') attempt to prove ready flips true.
99
+ const prev = provisioning.getOAuthManifestByState;
100
+ provisioning.getOAuthManifestByState = async (_db, state) => (state === 'GOODSTATE'
101
+ ? { id: 99, instance_id: 42, requested_by: 7, status: 'done', expires_at: new Date(Date.now() + 600000).toISOString() }
102
+ : null);
103
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
104
+ const j = await res.json();
105
+ t("status 'done' → ready:true + terminal:true", res.status === 200 && j.ready === true && j.terminal === true, JSON.stringify(j));
106
+ provisioning.getOAuthManifestByState = prev;
107
+ }
108
+ {
109
+ const res = await fetch(`${base}/provisioning/github-app/status?state=NOPE`);
110
+ t('unknown state → 404 (no oracle)', res.status === 404, `status=${res.status}`);
111
+ }
112
+ {
113
+ const res = await fetch(`${base}/provisioning/github-app/status`);
114
+ t('missing state → 400', res.status === 400, `status=${res.status}`);
115
+ }
116
+
90
117
  console.log('\nparseId contract (the (req,res) signature — a bad id is a clean 400, not a 500):');
91
118
  {
92
119
  const res = await fetch(`${base}/provisioning/instances/not-a-number/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
@@ -3,7 +3,7 @@
3
3
  // GUARD (task 2078): every static asset serve-internal.js reads at runtime from the
4
4
  // core tree MUST be included in the packaged core — i.e. isPublishable() per
5
5
  // scripts/gds/publish-manifest.js. A standalone instance runs the PACKAGED core
6
- // (@cloudbongos/core, ADR 0108), so a served path the publish boundary EXCLUDES is
6
+ // (@bongos/core, ADR 0108), so a served path the publish boundary EXCLUDES is
7
7
  // simply absent on the box and 404s.
8
8
  //
9
9
  // This is exactly how demo.cloudbongos.com rendered a blank page: the browser client
@@ -101,7 +101,7 @@ test('every ROOT-served asset ships in the core package (isPublishable)', () =>
101
101
  offenders,
102
102
  [],
103
103
  `serve-internal.js reads these from ROOT but the publish boundary EXCLUDES them, so the ` +
104
- `packaged core (@cloudbongos/core) omits them and a standalone 404s (task 2078): ${offenders.join(', ')}. ` +
104
+ `packaged core (@bongos/core) omits them and a standalone 404s (task 2078): ${offenders.join(', ')}. ` +
105
105
  `Fix: add each to PUBLISH_ALLOWLIST in scripts/gds/publish-manifest.js (or, if truly host-only, ` +
106
106
  `document it in INTENTIONALLY_UNSHIPPED in this test).`
107
107
  );
@@ -0,0 +1,99 @@
1
+ // tests/task_cli.mjs
2
+ //
3
+ // task 2123 (demo-audit fix C1) — locks the PURE argv→body mapping of the new
4
+ // `bongos task new` command (scripts/gds/task.js). Hermetic: no session/DB/network
5
+ // (buildTaskBody never touches the API), so the pr-unit-tests gate auto-includes it.
6
+ //
7
+ // Run: node tests/task_cli.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { buildTaskBody, VALID_KINDS, VALID_DISCIPLINES, csv, hallLink } = require('../scripts/gds/task.js');
14
+
15
+ let passed = 0, failed = 0;
16
+ function t(name, fn) {
17
+ try { fn(); passed++; console.log(` PASS ${name}`); }
18
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
19
+ }
20
+
21
+ t('requires --title and --description', () => {
22
+ const { errors } = buildTaskBody([]);
23
+ assert.ok(errors.some((e) => /--title/.test(e)));
24
+ assert.ok(errors.some((e) => /--description/.test(e)));
25
+ });
26
+
27
+ t('minimal valid: defaults kind=feature, discipline=engineer, status=backlog', () => {
28
+ const { body, errors } = buildTaskBody(['--title', 'T', '--description', 'D']);
29
+ assert.equal(errors.length, 0);
30
+ assert.equal(body.title, 'T');
31
+ assert.equal(body.description, 'D');
32
+ assert.equal(body.kind, 'feature');
33
+ assert.equal(body.discipline, 'engineer');
34
+ assert.equal(body.status, 'backlog');
35
+ assert.equal(body.source, 'bongos-task-cli');
36
+ // optional fields absent unless provided
37
+ assert.equal('priority' in body, false);
38
+ assert.equal('version_id' in body, false);
39
+ assert.equal('goal_id' in body, false);
40
+ });
41
+
42
+ t('--ready flips status to ready', () => {
43
+ const { body } = buildTaskBody(['--title', 'T', '--description', 'D', '--ready']);
44
+ assert.equal(body.status, 'ready');
45
+ });
46
+
47
+ t('maps all optional flags → body', () => {
48
+ const { body, criteria, errors } = buildTaskBody([
49
+ '--title', 'Add X', '--description', 'Because Y',
50
+ '--kind', 'bug', '--discipline', 'ui', '--priority', '2',
51
+ '--version', 'BONGOS-V1', '--goal', '26', '--criteria', 'C1,gcarry-prod-vanilla',
52
+ '--touches', 'src/a.js,tests/', '--est-minutes', '45', '--manual-degree', '3', '--credits', '15',
53
+ ]);
54
+ assert.equal(errors.length, 0);
55
+ assert.equal(body.kind, 'bug');
56
+ assert.equal(body.discipline, 'ui');
57
+ assert.equal(body.priority, 2);
58
+ assert.equal(body.version_id, 'BONGOS-V1');
59
+ assert.equal(body.goal_id, 26);
60
+ assert.deepEqual(body.touches, ['src/a.js', 'tests/']);
61
+ assert.deepEqual(body.criterion_ids, ['C1', 'gcarry-prod-vanilla']);
62
+ assert.deepEqual(criteria, ['C1', 'gcarry-prod-vanilla']);
63
+ assert.equal(body.est_minutes, 45);
64
+ assert.equal(body.manual_degree, 3);
65
+ assert.equal(body.credits_reward, 15);
66
+ });
67
+
68
+ t('rejects an invalid kind', () => {
69
+ const { errors } = buildTaskBody(['--title', 'T', '--description', 'D', '--kind', 'nonsense']);
70
+ assert.ok(errors.some((e) => /--kind/.test(e)));
71
+ });
72
+
73
+ t('rejects a priority out of 1–5', () => {
74
+ const { errors } = buildTaskBody(['--title', 'T', '--description', 'D', '--priority', '9']);
75
+ assert.ok(errors.some((e) => /--priority/.test(e)));
76
+ });
77
+
78
+ t('rejects a non-positive --goal', () => {
79
+ const { errors } = buildTaskBody(['--title', 'T', '--description', 'D', '--goal', '0']);
80
+ assert.ok(errors.some((e) => /--goal/.test(e)));
81
+ });
82
+
83
+ t('csv trims + drops blanks', () => {
84
+ assert.deepEqual(csv(' a , b ,,c '), ['a', 'b', 'c']);
85
+ assert.deepEqual(csv(''), []);
86
+ });
87
+
88
+ t('hallLink builds a hall URL from the API base origin, falls back cleanly', () => {
89
+ assert.equal(hallLink('https://demo.cloudbongos.com/api/bongos', 7), 'https://demo.cloudbongos.com/builders#/task/7');
90
+ assert.equal(hallLink('not-a-url', 7), 'task #7');
91
+ });
92
+
93
+ t('enum vocab is the expected shape', () => {
94
+ assert.ok(VALID_KINDS.includes('feature') && VALID_KINDS.includes('bug'));
95
+ assert.deepEqual(VALID_DISCIPLINES, ['engineer', 'artist', 'ideator', 'ui']);
96
+ });
97
+
98
+ console.log(`\n${passed} passed, ${failed} failed`);
99
+ process.exit(failed ? 1 : 0);
package/tests/upgrade.mjs CHANGED
@@ -34,13 +34,13 @@ function scratchConsumer({ pinned = '1.14.1', installed = '1.14.1' } = {}) {
34
34
  const dir = mkdtempSync(join(tmpdir(), 'otb-upgrade-'));
35
35
  writeFileSync(join(dir, 'package.json'), JSON.stringify({
36
36
  name: 'test-consumer', version: '0.1.0',
37
- dependencies: { '@cloudbongos/core': `file:vendor/cloudbongos-core-${pinned}.tgz` },
37
+ dependencies: { '@bongos/core': `file:vendor/bongos-core-${pinned}.tgz` },
38
38
  scripts: { migrate: 'true', start: 'true' },
39
39
  }, null, 2) + '\n');
40
40
  if (installed) {
41
- const cd = join(dir, 'node_modules', '@cloudbongos', 'core');
41
+ const cd = join(dir, 'node_modules', '@bongos', 'core');
42
42
  mkdirSync(cd, { recursive: true });
43
- writeFileSync(join(cd, 'package.json'), JSON.stringify({ name: '@cloudbongos/core', version: installed }) + '\n');
43
+ writeFileSync(join(cd, 'package.json'), JSON.stringify({ name: '@bongos/core', version: installed }) + '\n');
44
44
  }
45
45
  return dir;
46
46
  }
@@ -52,8 +52,8 @@ function fakeRun(calls, { dirty = '', installTo = null, dir = null } = {}) {
52
52
  calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
53
53
  if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: dirty };
54
54
  if (cmd === 'npm' && args[0] === 'install' && installTo && dir) {
55
- writeFileSync(join(dir, 'node_modules', '@cloudbongos', 'core', 'package.json'),
56
- JSON.stringify({ name: '@cloudbongos/core', version: installTo }) + '\n');
55
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
56
+ JSON.stringify({ name: '@bongos/core', version: installTo }) + '\n');
57
57
  }
58
58
  return { status: 0 };
59
59
  };
@@ -61,20 +61,20 @@ function fakeRun(calls, { dirty = '', installTo = null, dir = null } = {}) {
61
61
 
62
62
  // ---- 1. pure pin helpers ----------------------------------------------------
63
63
  t('versionFromTgzPath: extracts the version from a vendored tarball path', () => {
64
- assert.equal(u.versionFromTgzPath('file:vendor/cloudbongos-core-1.14.1.tgz'), '1.14.1');
65
- assert.equal(u.versionFromTgzPath('vendor/cloudbongos-core-1.15.0.tgz'), '1.15.0');
64
+ assert.equal(u.versionFromTgzPath('file:vendor/bongos-core-1.14.1.tgz'), '1.14.1');
65
+ assert.equal(u.versionFromTgzPath('vendor/bongos-core-1.15.0.tgz'), '1.15.0');
66
66
  assert.equal(u.versionFromTgzPath('^1.2.3'), null);
67
67
  });
68
68
 
69
69
  t('readPinnedCoreVersion / writePin: round-trip the file: dependency', () => {
70
70
  const dir = scratchConsumer({ pinned: '1.14.1' });
71
71
  assert.equal(u.readPinnedCoreVersion(dir), '1.14.1');
72
- const prev = u.writePin(dir, 'vendor/cloudbongos-core-1.15.0.tgz');
73
- assert.equal(prev, 'file:vendor/cloudbongos-core-1.14.1.tgz');
72
+ const prev = u.writePin(dir, 'vendor/bongos-core-1.15.0.tgz');
73
+ assert.equal(prev, 'file:vendor/bongos-core-1.14.1.tgz');
74
74
  assert.equal(u.readPinnedCoreVersion(dir), '1.15.0');
75
75
  });
76
76
 
77
- t('readInstalledCoreVersion: reads node_modules/@cloudbongos/core', () => {
77
+ t('readInstalledCoreVersion: reads node_modules/@bongos/core', () => {
78
78
  const dir = scratchConsumer({ installed: '1.14.1' });
79
79
  assert.equal(u.readInstalledCoreVersion(dir), '1.14.1');
80
80
  assert.equal(u.readInstalledCoreVersion(mkdtempSync(join(tmpdir(), 'empty-'))), null);
@@ -83,13 +83,13 @@ t('readInstalledCoreVersion: reads node_modules/@cloudbongos/core', () => {
83
83
  // ---- 2. vendorTarball -------------------------------------------------------
84
84
  t('vendorTarball: copies the .tgz + side-car manifest under the canonical name', () => {
85
85
  const src = mkdtempSync(join(tmpdir(), 'src-'));
86
- writeFileSync(join(src, 'cloudbongos-core-1.15.0.tgz'), 'TGZBYTES');
87
- writeFileSync(join(src, 'cloudbongos-core-1.15.0.manifest.json'), '{"v":1}');
86
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZBYTES');
87
+ writeFileSync(join(src, 'bongos-core-1.15.0.manifest.json'), '{"v":1}');
88
88
  const dir = scratchConsumer();
89
- const rel = u.vendorTarball({ instanceDir: dir, fromTgz: join(src, 'cloudbongos-core-1.15.0.tgz'), targetVersion: '1.15.0' });
90
- assert.equal(rel, join('vendor', 'cloudbongos-core-1.15.0.tgz'));
89
+ const rel = u.vendorTarball({ instanceDir: dir, fromTgz: join(src, 'bongos-core-1.15.0.tgz'), targetVersion: '1.15.0' });
90
+ assert.equal(rel, join('vendor', 'bongos-core-1.15.0.tgz'));
91
91
  assert.equal(readFileSync(join(dir, rel), 'utf8'), 'TGZBYTES');
92
- assert.ok(existsSync(join(dir, 'vendor', 'cloudbongos-core-1.15.0.manifest.json')), 'manifest copied too');
92
+ assert.ok(existsSync(join(dir, 'vendor', 'bongos-core-1.15.0.manifest.json')), 'manifest copied too');
93
93
  });
94
94
 
95
95
  // ---- 3. step shims issue the right argv ------------------------------------
@@ -137,11 +137,11 @@ t('guard: already on the target refuses (unless --force)', async () => {
137
137
  // ---- 5. dry-run mutates nothing --------------------------------------------
138
138
  t('dry-run: plans without rewriting package.json or spawning npm', async () => {
139
139
  const src = mkdtempSync(join(tmpdir(), 'src-'));
140
- writeFileSync(join(src, 'cloudbongos-core-1.15.0.tgz'), 'X');
140
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'X');
141
141
  const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
142
142
  const calls = [];
143
143
  const res = await u.runUpgrade(
144
- { to: '1.15.0', from: join(src, 'cloudbongos-core-1.15.0.tgz'), instance: dir, dryRun: true },
144
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, dryRun: true },
145
145
  { log: () => {}, err: () => {}, run: fakeRun(calls, { dirty: '' }) }
146
146
  );
147
147
  assert.equal(res.ok, true);
@@ -153,13 +153,13 @@ t('dry-run: plans without rewriting package.json or spawning npm', async () => {
153
153
  // ---- 6. full happy-path (every side effect injected; ledger skipped, DB-free) ----
154
154
  t('full: pre-flight → pin → install → migrate → materialize → restart → verify', async () => {
155
155
  const src = mkdtempSync(join(tmpdir(), 'src-'));
156
- writeFileSync(join(src, 'cloudbongos-core-1.15.0.tgz'), 'TGZ');
156
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
157
157
  const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
158
158
  const calls = [];
159
159
  let materialized = null;
160
160
  let regenArgs = null;
161
161
  const res = await u.runUpgrade(
162
- { to: '1.15.0', from: join(src, 'cloudbongos-core-1.15.0.tgz'), instance: dir, service: 'emersonian.service' },
162
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'emersonian.service' },
163
163
  {
164
164
  log: () => {}, err: () => {},
165
165
  run: fakeRun(calls, { dirty: '', installTo: '1.15.0', dir }),
@@ -174,26 +174,26 @@ t('full: pre-flight → pin → install → migrate → materialize → restart
174
174
  assert.equal(res.versionOk, true, 'installed version must equal the target after install');
175
175
  // pin rewritten, tarball vendored
176
176
  assert.equal(u.readPinnedCoreVersion(dir), '1.15.0');
177
- assert.equal(readFileSync(join(dir, 'vendor', 'cloudbongos-core-1.15.0.tgz'), 'utf8'), 'TGZ');
177
+ assert.equal(readFileSync(join(dir, 'vendor', 'bongos-core-1.15.0.tgz'), 'utf8'), 'TGZ');
178
178
  // steps ran, in the expected order
179
179
  assert.deepEqual(
180
180
  calls.filter((c) => c.startsWith('npm') || c.startsWith('git') || c.startsWith('systemctl')),
181
181
  ['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service']
182
182
  );
183
183
  // materialize pointed at the freshly-installed core
184
- assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@cloudbongos', 'core')));
184
+ assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
185
185
  assert.equal(materialized.instanceDir, dir);
186
186
  // task 2051: the API docs+client regen ran too, pointed at the same new core + instance
187
187
  assert.ok(regenArgs, 'regenerateApiArtifacts step invoked');
188
188
  assert.equal(regenArgs.instanceDir, dir);
189
- assert.ok(regenArgs.coreRoot.endsWith(join('node_modules', '@cloudbongos', 'core')));
189
+ assert.ok(regenArgs.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
190
190
  });
191
191
 
192
192
  // ---- 6b. regenerate the API artifacts on upgrade (task 2051) ----------------
193
193
  t('regenerateApiArtifacts: spawns docs then client from the new core, targeting the instance', () => {
194
194
  const calls = [];
195
195
  const run = (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { status: 0 }; };
196
- const res = u.regenerateApiArtifacts({ instanceDir: '/inst', coreRoot: '/inst/node_modules/@cloudbongos/core' }, run, { existsSync: () => true });
196
+ const res = u.regenerateApiArtifacts({ instanceDir: '/inst', coreRoot: '/inst/node_modules/@bongos/core' }, run, { existsSync: () => true });
197
197
  assert.equal(res.ok, true);
198
198
  assert.deepEqual(res.ran, ['gen-api-docs.js', 'gen-api-client.js']);
199
199
  assert.equal(calls.length, 2);
@@ -225,11 +225,11 @@ t('regenerateApiArtifacts: a generator non-zero exit stops + reports (docs ran,
225
225
 
226
226
  t('runUpgrade: --skip-regen-docs skips the regen step', async () => {
227
227
  const src = mkdtempSync(join(tmpdir(), 'src-'));
228
- writeFileSync(join(src, 'cloudbongos-core-1.15.0.tgz'), 'TGZ');
228
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
229
229
  const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
230
230
  let regenCalled = false;
231
231
  const res = await u.runUpgrade(
232
- { to: '1.15.0', from: join(src, 'cloudbongos-core-1.15.0.tgz'), instance: dir, skipRegenDocs: true },
232
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, skipRegenDocs: true },
233
233
  {
234
234
  log: () => {}, err: () => {},
235
235
  run: fakeRun([], { dirty: '', installTo: '1.15.0', dir }),
@@ -352,6 +352,70 @@ t('pollHealth: a persistent non-2xx status is reported (not treated as ready)',
352
352
  assert.equal(res.timedOut, true);
353
353
  });
354
354
 
355
+ // ---- 7. registry update channel (ADR 0134, task 2090) -----------------------
356
+ t('versionFromDep: parses bare/caret/tilde semver; file: spec → null (tgz path is versionFromTgzPath\'s job)', () => {
357
+ assert.equal(u.versionFromDep('1.17.0'), '1.17.0');
358
+ assert.equal(u.versionFromDep('^1.17.0'), '1.17.0');
359
+ assert.equal(u.versionFromDep('~1.17.0'), '1.17.0');
360
+ assert.equal(u.versionFromDep('file:vendor/bongos-core-1.17.0.tgz'), null);
361
+ assert.equal(u.versionFromDep(''), null);
362
+ assert.equal(u.versionFromDep(null), null);
363
+ });
364
+
365
+ t('writeRegistryPin / readPinnedCoreVersion: round-trip an exact registry version', () => {
366
+ const dir = scratchConsumer({ pinned: '1.16.0', installed: '1.16.0' });
367
+ const prev = u.writeRegistryPin(dir, '1.17.0');
368
+ assert.equal(prev, 'file:vendor/bongos-core-1.16.0.tgz', 'returns the previous pin');
369
+ assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'], '1.17.0', 'pins the exact version (not file:)');
370
+ assert.equal(u.readPinnedCoreVersion(dir), '1.17.0', 'reads the registry pin back');
371
+ });
372
+
373
+ t('runUpgrade --registry (dry-run): plans a registry pin, no npm, pin unchanged', async () => {
374
+ const dir = scratchConsumer({ pinned: '1.16.0', installed: '1.16.0' });
375
+ const calls = [];
376
+ const res = await u.runUpgrade(
377
+ { to: '1.17.0', registry: true, instance: dir, dryRun: true },
378
+ { log: () => {}, err: () => {}, run: fakeRun(calls, { dirty: '' }) }
379
+ );
380
+ assert.equal(res.ok, true);
381
+ assert.equal(res.dryRun, true);
382
+ assert.equal(u.readPinnedCoreVersion(dir), '1.16.0', 'pin unchanged in dry-run');
383
+ assert.ok(!existsSync(join(dir, '.npmrc')), 'dry-run writes no .npmrc');
384
+ assert.ok(!calls.some((c) => c.startsWith('npm')), 'no npm spawned in dry-run');
385
+ });
386
+
387
+ t('runUpgrade --registry + --from: refuses (mutually exclusive core sources)', async () => {
388
+ const dir = scratchConsumer({ pinned: '1.16.0', installed: '1.16.0' });
389
+ const res = await u.runUpgrade(
390
+ { to: '1.17.0', registry: true, from: '/tmp/x.tgz', instance: dir },
391
+ { log: () => {}, err: () => {}, run: fakeRun([], { dirty: '' }) }
392
+ );
393
+ assert.equal(res.ok, false);
394
+ assert.match(res.error, /mutually exclusive/);
395
+ });
396
+
397
+ t('runUpgrade --registry (full): pins the exact version + writes the env-fed .npmrc, then install→migrate→verify', async () => {
398
+ const dir = scratchConsumer({ pinned: '1.16.0', installed: '1.16.0' });
399
+ const calls = [];
400
+ const res = await u.runUpgrade(
401
+ { to: '1.17.0', registry: true, instance: dir, service: 'demo.service' },
402
+ {
403
+ log: () => {}, err: () => {},
404
+ run: fakeRun(calls, { dirty: '', installTo: '1.17.0', dir }),
405
+ materialize: () => ({ skills: 1, settings: true, hooks: 1 }),
406
+ regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
407
+ databaseUrl: '', pgDatabase: '',
408
+ }
409
+ );
410
+ assert.equal(res.ok, true, 'registry upgrade succeeds');
411
+ assert.equal(res.toVersion, '1.17.0');
412
+ assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'], '1.17.0', 'exact registry pin (no file:)');
413
+ const npmrc = readFileSync(join(dir, '.npmrc'), 'utf8');
414
+ assert.match(npmrc, /_authToken=\$\{NPM_TOKEN\}/, 'wrote the env-fed .npmrc');
415
+ assert.ok(calls.some((c) => /^npm install/.test(c)), 'ran npm install (resolves from the registry)');
416
+ assert.ok(!calls.some((c) => /vendor/.test(c)), 'did not vendor a tarball');
417
+ });
418
+
355
419
  await Promise.all(pending);
356
420
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
357
421
  process.exit(failed ? 1 : 0);