@bongos/core 1.17.0 → 1.17.2

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 (230) hide show
  1. package/.bongos-core.json +363 -312
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/CLAUDE.md +11 -238
  15. package/GOVERNANCE.md +1 -1
  16. package/README.md +1 -1
  17. package/bin/bongos.js +1 -0
  18. package/clients/bongos-client/README.md +4 -4
  19. package/clients/bongos-client/bongos-client.global.js +14 -1
  20. package/clients/bongos-client/index.cjs +14 -1
  21. package/clients/bongos-client/index.d.ts +19 -2
  22. package/clients/bongos-client/index.mjs +11 -1
  23. package/clients/bongos-client/package.json +1 -1
  24. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  25. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  26. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  27. package/docs/adr/0020-security-model-public-stub.md +1 -1
  28. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  29. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  30. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  31. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  32. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  33. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  34. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  35. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  36. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  37. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  38. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  39. package/docs/adr/0057-container-cost-ledger.md +3 -3
  40. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  41. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  42. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  43. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  44. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  45. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  46. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  47. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  48. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  49. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  50. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  51. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -0
  52. package/docs/adr/0134-private-first-npm-distribution.md +20 -6
  53. package/docs/adr/0135-module-upstream-submission-interim-queue.md +34 -0
  54. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  55. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  56. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  57. package/docs/adr/README.md +14 -10
  58. package/docs/api/openapi.json +315 -6
  59. package/docs/api-reference.md +10 -5
  60. package/docs/architecture.md +3 -3
  61. package/docs/branding-contract.md +5 -4
  62. package/docs/canonical-permissions.md +1 -1
  63. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  64. package/docs/file-map.md +20 -19
  65. package/docs/handoff-template.md +2 -2
  66. package/docs/modules-contract.md +8 -1
  67. package/docs/onboarding/diagrams/README.md +7 -7
  68. package/docs/onboarding/drachmae-and-karma.md +18 -18
  69. package/docs/onboarding/primer.md +9 -9
  70. package/docs/recipes/audit-replay.md +2 -2
  71. package/docs/recipes/builder-box-lifecycle.md +2 -2
  72. package/docs/recipes/claude-design-loop.md +1 -1
  73. package/docs/recipes/instance-deploy.md +4 -4
  74. package/docs/recipes/instance-first-run.md +51 -0
  75. package/docs/recipes/ops-gotchas.md +1 -1
  76. package/docs/recipes/packaging-the-core.md +5 -5
  77. package/docs/recipes/private-npm-distribution.md +39 -12
  78. package/docs/recipes/self-host.md +1 -1
  79. package/docs/recipes/staging-environment.md +1 -1
  80. package/docs/recipes/upgrading-the-core.md +63 -8
  81. package/docs/research/cursor-competitive-analysis.md +1 -1
  82. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  83. package/migrations/core_191_module_submissions.sql +45 -0
  84. package/modules/autonomy/CLAUDE.md +11 -45
  85. package/modules/builder-settings/CLAUDE.md +11 -54
  86. package/modules/dev-box/CLAUDE.md +11 -34
  87. package/modules/discord/CLAUDE.md +11 -46
  88. package/modules/discord/module.json +2 -1
  89. package/modules/economy/CLAUDE.md +11 -44
  90. package/modules/economy/achievements.js +9 -1
  91. package/modules/economy/credits.js +10 -1
  92. package/modules/grading/CLAUDE.md +11 -38
  93. package/modules/hall-ui/CLAUDE.md +11 -57
  94. package/modules/hall-ui/public/builders.js +71 -4
  95. package/modules/hall-ui/public/goal-inbox.js +191 -0
  96. package/modules/hall-ui/public/goals-page.js +227 -22
  97. package/modules/hall-ui/public/goals.html +16 -0
  98. package/modules/hall-ui/public/hall-widgets.js +14 -1
  99. package/modules/hall-ui/public/index.html +3 -0
  100. package/modules/hall-ui/public/new-project-wizard.js +6 -4
  101. package/modules/hall-ui/public/style.css +43 -0
  102. package/modules/ideas/CLAUDE.md +11 -33
  103. package/modules/lifecycle/CLAUDE.md +11 -59
  104. package/modules/lifecycle/routes/goals.js +80 -0
  105. package/modules/memory/CLAUDE.md +11 -36
  106. package/modules/onboarding/CLAUDE.md +11 -51
  107. package/modules/provisioning/CLAUDE.md +15 -0
  108. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  109. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  110. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  111. package/modules/provisioning/module.json +17 -0
  112. package/modules/provisioning/onboard-plan.js +37 -0
  113. package/modules/provisioning/provisioning.js +539 -0
  114. package/modules/provisioning/rate-limit.js +35 -0
  115. package/modules/provisioning/routes/provisioning.js +607 -0
  116. package/modules/provisioning/tests/provisioning.mjs +232 -0
  117. package/modules/provisioning/ui/.gitkeep +0 -0
  118. package/modules/security/CLAUDE.md +11 -40
  119. package/modules/security/secret-scrub.js +10 -0
  120. package/modules/sessions/CLAUDE.md +11 -35
  121. package/modules/status-ui/CLAUDE.md +11 -43
  122. package/package-lock.json +2 -2
  123. package/package.json +7 -2
  124. package/public-landing/create.html +58 -12
  125. package/public-landing/index.html +40 -21
  126. package/scripts/discord/README.md +1 -1
  127. package/scripts/gds/CLAUDE.md +11 -140
  128. package/scripts/gds/claim.js +98 -48
  129. package/scripts/gds/claude-materialize.js +174 -20
  130. package/scripts/gds/claude-md-template.js +84 -0
  131. package/scripts/gds/cli-lib.js +112 -2
  132. package/scripts/gds/cutover-codemod.js +241 -0
  133. package/scripts/gds/fitness.js +54 -0
  134. package/scripts/gds/gen-api-client.js +10 -10
  135. package/scripts/gds/init.js +112 -16
  136. package/scripts/gds/lint-infra-exec.js +70 -0
  137. package/scripts/gds/lint-workflow-exec.js +63 -0
  138. package/scripts/gds/migration-namespace.js +1 -1
  139. package/scripts/gds/mirror-lineage-check.js +14 -5
  140. package/scripts/gds/mirror-redact.js +8 -0
  141. package/scripts/gds/module.js +118 -4
  142. package/scripts/gds/npmrc.js +93 -0
  143. package/scripts/gds/onboard.js +17 -24
  144. package/scripts/gds/package-core.js +74 -17
  145. package/scripts/gds/provision.js +102 -17
  146. package/scripts/gds/publish-manifest.js +60 -2
  147. package/scripts/gds/run-routine.js +51 -13
  148. package/scripts/gds/run-unit-tests.js +4 -0
  149. package/scripts/gds/seed-adopt-tasks.js +1 -1
  150. package/scripts/gds/status.js +6 -1
  151. package/scripts/gds/task.js +166 -0
  152. package/scripts/gds/update-channel.js +186 -0
  153. package/scripts/gds/upgrade.js +172 -25
  154. package/src/bongos/CLAUDE.md +11 -60
  155. package/src/bongos/auth.js +24 -0
  156. package/src/bongos/module-submissions.js +56 -0
  157. package/src/bongos/route-rank-check.js +6 -0
  158. package/src/bongos/routes/CLAUDE.md +11 -39
  159. package/src/bongos/routes/auth.js +6 -1
  160. package/src/bongos/routes/modules.js +123 -21
  161. package/src/bongos/serve-internal.js +10 -1
  162. package/src/branding.js +1 -0
  163. package/src/instance-config.js +1 -1
  164. package/src/module-api.js +24 -1
  165. package/src/module-loader/loader.js +36 -4
  166. package/src/module-loader/manifest-schema.js +16 -1
  167. package/tests/branding.mjs +11 -0
  168. package/tests/claim_error_surface.mjs +107 -0
  169. package/tests/claude_materialize.mjs +125 -0
  170. package/tests/consumer_layout_boot.mjs +302 -0
  171. package/tests/cutover_codemod.mjs +184 -0
  172. package/tests/device_flow_start.mjs +96 -0
  173. package/tests/first_run_engine_check.mjs +79 -0
  174. package/tests/goal_membership_requests.mjs +33 -3
  175. package/tests/hook_core_resolve.mjs +125 -0
  176. package/tests/hook_user_agents.mjs +62 -0
  177. package/tests/infra_exec_paths.mjs +104 -0
  178. package/tests/init.mjs +106 -6
  179. package/tests/instance_mismatch_guard.mjs +101 -0
  180. package/tests/mirror_lineage_proof.mjs +14 -0
  181. package/tests/module_cli.mjs +130 -2
  182. package/tests/module_loader.mjs +42 -0
  183. package/tests/module_manifest.mjs +19 -0
  184. package/tests/module_submissions.mjs +135 -0
  185. package/tests/npmrc.mjs +105 -0
  186. package/tests/onboard.mjs +62 -0
  187. package/tests/package_core.mjs +76 -22
  188. package/tests/platform_boot.mjs +5 -2
  189. package/tests/provision.mjs +107 -8
  190. package/tests/provisioning_github_app_routes.mjs +44 -2
  191. package/tests/publish_manifest.mjs +52 -1
  192. package/tests/run_routine.mjs +26 -1
  193. package/tests/run_routine_two_root.mjs +77 -0
  194. package/tests/scrubber_corpus.mjs +17 -0
  195. package/tests/served_paths_shipped.mjs +2 -2
  196. package/tests/task_cli.mjs +99 -0
  197. package/tests/update_channel.mjs +186 -0
  198. package/tests/upgrade.mjs +220 -25
  199. package/tests/workflow_exec_paths.mjs +98 -0
  200. package/docs/design/container-exterior-tiles.md +0 -49
  201. package/docs/design/container-exterior.md +0 -81
  202. package/docs/design/container-interior.md +0 -77
  203. package/docs/design/world-map-scroll.md +0 -41
  204. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  205. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  206. package/docs/recipes/cloudbongos-standup.md +0 -111
  207. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  208. package/docs/recipes/parallel-load-test.md +0 -32
  209. package/docs/recipes/pixel-art-pipeline.md +0 -411
  210. package/docs/recipes/staging-data.md +0 -50
  211. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  212. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  213. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  214. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  215. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  216. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  217. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  218. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  219. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  220. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  221. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  222. package/scripts/gds/art-stage.js +0 -152
  223. package/server.js +0 -212
  224. package/tests/art_key.mjs +0 -121
  225. package/tests/day_cycle.mjs +0 -197
  226. package/tests/snapshot_picker.mjs +0 -233
  227. package/tests/sprite_facing.mjs +0 -35
  228. package/tests/terrain_picker.mjs +0 -298
  229. package/tests/turn_in_place.mjs +0 -280
  230. package/tests/walkability.mjs +0 -188
@@ -0,0 +1,302 @@
1
+ // tests/consumer_layout_boot.mjs
2
+ //
3
+ // R89-prereq CI proof (task 2142): boot @bongos/core from a REAL consumer layout —
4
+ // the packaged .tgz installed into node_modules, with the instance's OWN content
5
+ // (config / modules / .claude) sitting at a DIFFERENT root — and prove the two-root
6
+ // module loader (task 2133) discovers modules from BOTH roots.
7
+ //
8
+ // WHY THIS EXISTS. Every existing gate runs the SOURCE tree with all deps present:
9
+ // - tests/platform_boot.mjs imports src/platform-server.js straight from the repo;
10
+ // - tests/fitness_two_root.mjs diverges resolveInstanceRoot() to a temp dir but
11
+ // keeps cwd/core = the real repo (resolveCoreRoot() is still __dirname/..).
12
+ // NONE installs the packaged core into a tree whose instance root is elsewhere —
13
+ // which is exactly why the pre-2133 single-root loader gap was invisible: a
14
+ // core-only scan boots a split instance with none of its host modules, and no test
15
+ // ever exercised that shape. This harness closes the blind spot end to end.
16
+ //
17
+ // WHAT IT PROVES (the two-root checks all go RED against the pre-2133 loader):
18
+ // 1. package — scripts/gds/package-core.js builds an installable .tgz.
19
+ // 2. install — `npm install <tgz>` lands it at node_modules/@bongos/core.
20
+ // 3. roots — resolveCoreRoot() is UNDER node_modules/@bongos/core while
21
+ // resolveInstanceRoot() is the consumer tree: two DIFFERENT dirs, so
22
+ // a green result can never be a same-directory coincidence.
23
+ // 4. boot — `node node_modules/@bongos/core/src/platform-server.js` serves
24
+ // /healthz 200 (the game-less core boots from the package).
25
+ // 5. two-root — discoverModules() loads a module resolved from the CORE modules
26
+ // dir AND the consumer-probe module resolved from the INSTANCE
27
+ // modules dir, with zero errors. A single-root loader physically
28
+ // cannot find consumer-probe → this IS the task-2133 done-when.
29
+ // 6. fitness — the packaged fitness.js discoveredModuleKeys() spans both roots.
30
+ // 7. gen-file-map — reads the INSTANCE root (lists the consumer's .claude/skills).
31
+ // 8. gen-repo-map — maps CORE code (scripts/gds/fitness.js) from node_modules.
32
+ //
33
+ // This is an INTEGRATION harness (a package build + `npm install` + a live boot), so
34
+ // it is EXCLUDED from the DB-free unit gate (scripts/gds/run-unit-tests.js INTEGRATION
35
+ // set) and driven by .github/workflows/consumer-layout-boot.yml. It needs NO Postgres
36
+ // (/healthz, the loader, fitness, and the generators are all DB-free). Re-runnable:
37
+ // node tests/consumer_layout_boot.mjs # builds a fresh core .tgz
38
+ // CORE_TGZ=/abs/path/bongos-core-x.y.z.tgz node tests/consumer_layout_boot.mjs # reuse a prebuilt one
39
+
40
+ import assert from 'node:assert/strict';
41
+ import http from 'node:http';
42
+ import net from 'node:net';
43
+ import { spawn, spawnSync } from 'node:child_process';
44
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { join, dirname } from 'node:path';
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
50
+ const NPM_BIN = process.platform === 'win32' ? 'npm.cmd' : 'npm';
51
+ const CORE_VERSION_RANGE = '^1.0.0'; // consumer-probe's coreVersion — satisfied by any 1.x core.
52
+
53
+ let passed = 0;
54
+ let failed = 0;
55
+ function check(name, fn) {
56
+ try { fn(); passed++; console.log(` PASS ${name}`); }
57
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.message ? e.message : e}`); }
58
+ }
59
+
60
+ // Run a node snippet in `cwd` and parse its stdout as JSON. Used to introspect the
61
+ // INSTALLED core (its loader / fitness / generators) exactly as a consumer process
62
+ // would resolve them — absolute paths are injected so the snippet never depends on
63
+ // the snippet file's own location (mirrors tests/fitness_two_root.mjs).
64
+ function runNodeJSON(snippet, cwd) {
65
+ const r = spawnSync(process.execPath, ['-e', snippet], { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
66
+ if (r.status !== 0) {
67
+ throw new Error(`node snippet exited ${r.status}\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}`);
68
+ }
69
+ try { return JSON.parse(r.stdout); }
70
+ catch { throw new Error(`snippet stdout was not JSON:\n${r.stdout}\n--- stderr ---\n${r.stderr}`); }
71
+ }
72
+
73
+ function freePort() {
74
+ return new Promise((resolve, reject) => {
75
+ const srv = net.createServer();
76
+ srv.on('error', reject);
77
+ srv.listen(0, '127.0.0.1', () => {
78
+ const { port } = srv.address();
79
+ srv.close(() => resolve(port));
80
+ });
81
+ });
82
+ }
83
+
84
+ function getHealthz(port) {
85
+ return new Promise((resolve) => {
86
+ const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
87
+ let body = '';
88
+ res.on('data', (c) => (body += c));
89
+ res.on('end', () => resolve({ status: res.statusCode, body }));
90
+ });
91
+ req.on('error', () => resolve(null));
92
+ req.on('timeout', () => { req.destroy(); resolve(null); });
93
+ });
94
+ }
95
+
96
+ async function waitForHealthz(port, timeoutMs = 20000) {
97
+ const deadline = Date.now() + timeoutMs;
98
+ while (Date.now() < deadline) {
99
+ const res = await getHealthz(port);
100
+ if (res) return res;
101
+ await new Promise((r) => setTimeout(r, 300));
102
+ }
103
+ return null;
104
+ }
105
+
106
+ async function main() {
107
+ console.log('=== consumer-layout boot proof (task 2142): install @bongos/core, boot it, assert two-root module load ===');
108
+
109
+ const workRoot = mkdtempSync(join(tmpdir(), 'consumer-layout-'));
110
+ const consumer = join(workRoot, 'consumer');
111
+ let child = null;
112
+
113
+ try {
114
+ // --- 1. package -----------------------------------------------------------
115
+ // Build the installable core .tgz from HEAD (the committed tree — so on a PR the
116
+ // job packages exactly the code under review). Reuse a prebuilt one via CORE_TGZ.
117
+ let tgz = process.env.CORE_TGZ || '';
118
+ check('package: scripts/gds/package-core.js builds an installable .tgz', () => {
119
+ if (tgz) { assert.ok(existsSync(tgz), `CORE_TGZ does not exist: ${tgz}`); return; }
120
+ const outDir = join(workRoot, 'dist');
121
+ const r = spawnSync(process.execPath, [join(REPO_ROOT, 'scripts/gds/package-core.js'), '--out-dir', outDir],
122
+ { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
123
+ assert.equal(r.status, 0, `package-core exited ${r.status}\n${r.stderr}`);
124
+ const pinLine = r.stdout.trim().split('\n').filter(Boolean).pop();
125
+ const pin = JSON.parse(pinLine);
126
+ tgz = join(outDir, pin.tarball.name);
127
+ assert.ok(existsSync(tgz), `packaged tarball missing: ${tgz}`);
128
+ });
129
+ if (!tgz || !existsSync(tgz)) { throw new Error('cannot continue without a core tarball'); }
130
+
131
+ // --- build the consumer tree: instance content at its OWN root -------------
132
+ // A minimal-but-real Cloud Bongos consumer: a bongos.config.json pointing the
133
+ // instance root at itself, a HOST module (consumer-probe) that is NOT part of
134
+ // @bongos/core, and an instance-owned .claude/skills dir. The host module is the
135
+ // crux: it can only be discovered via resolveInstanceRoot(), so finding it proves
136
+ // the second root is scanned.
137
+ mkdirSync(consumer, { recursive: true });
138
+ writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
139
+ name: 'consumer-layout-probe',
140
+ version: '0.0.0',
141
+ private: true,
142
+ description: 'Ephemeral @bongos/core consumer used by the R89 two-root CI proof (task 2142).',
143
+ dependencies: { '@bongos/core': `file:${tgz}` },
144
+ }, null, 2)}\n`);
145
+ writeFileSync(join(consumer, 'bongos.config.json'), `${JSON.stringify({ instanceRoot: '.' }, null, 2)}\n`);
146
+ // A tiny instance CLAUDE.md (the charter heading keeps fitness's budget check happy
147
+ // if anything reads it) — makes the tree a realistic instance, not load-bearing here.
148
+ writeFileSync(join(consumer, 'CLAUDE.md'),
149
+ '# Consumer probe instance\n\n## Working with this file (content charter)\n\nEphemeral fixture for the task-2142 two-root CI proof.\n');
150
+ mkdirSync(join(consumer, 'config'), { recursive: true });
151
+ writeFileSync(join(consumer, 'config', 'branding.json'), `${JSON.stringify({
152
+ configDir: 'cloudbongos', envPrefix: 'CLOUDBONGOS',
153
+ identity: { productName: 'Consumer Probe' },
154
+ }, null, 2)}\n`);
155
+ // The host module — a valid manifest, key === dir name, coreVersion any 1.x.
156
+ mkdirSync(join(consumer, 'modules', 'consumer-probe'), { recursive: true });
157
+ writeFileSync(join(consumer, 'modules', 'consumer-probe', 'module.json'), `${JSON.stringify({
158
+ key: 'consumer-probe',
159
+ title: 'Consumer Probe',
160
+ description: 'A host module that ships only in the instance repo, never in @bongos/core.',
161
+ coreVersion: CORE_VERSION_RANGE,
162
+ contributes: {},
163
+ }, null, 2)}\n`);
164
+ // Instance-owned .claude/skills (gen-file-map lists the instance root's skills).
165
+ mkdirSync(join(consumer, '.claude', 'skills', 'consumer-probe-skill'), { recursive: true });
166
+ writeFileSync(join(consumer, '.claude', 'skills', 'consumer-probe-skill', 'SKILL.md'),
167
+ '# consumer-probe-skill\n\nInstance-owned skill fixture for the task-2142 proof.\n');
168
+ mkdirSync(join(consumer, '.claude', 'scheduled-tasks'), { recursive: true });
169
+
170
+ // --- 2. install -----------------------------------------------------------
171
+ const coreDir = join(consumer, 'node_modules', '@bongos', 'core');
172
+ check('install: npm install lands the core at node_modules/@bongos/core', () => {
173
+ const r = spawnSync(NPM_BIN, ['install', '--no-audit', '--no-fund', '--loglevel=error'],
174
+ { cwd: consumer, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
175
+ assert.equal(r.status, 0, `npm install exited ${r.status}\n${r.stderr}`);
176
+ assert.ok(existsSync(join(coreDir, 'package.json')), 'node_modules/@bongos/core/package.json missing');
177
+ assert.ok(existsSync(join(coreDir, 'src', 'platform-server.js')), 'installed core has no src/platform-server.js');
178
+ assert.ok(existsSync(join(coreDir, 'modules')), 'installed core ships no modules/ dir');
179
+ });
180
+ if (!existsSync(coreDir)) { throw new Error('cannot continue without an installed core'); }
181
+
182
+ const A = (p) => JSON.stringify(join(coreDir, p)); // absolute path into the installed core, JSON-quoted
183
+
184
+ // --- 3. roots diverge -----------------------------------------------------
185
+ // The whole proof is meaningless if the two roots secretly coincide, so pin that
186
+ // down first: core resolves UNDER node_modules/@bongos/core, instance is the
187
+ // consumer tree, and they are different directories.
188
+ check('roots: resolveCoreRoot() (node_modules) and resolveInstanceRoot() (consumer) are distinct', () => {
189
+ const r = runNodeJSON(`
190
+ const ic = require(${A('src/instance-config.js')});
191
+ process.stdout.write(JSON.stringify({ core: ic.resolveCoreRoot(), inst: ic.resolveInstanceRoot() }));
192
+ `, consumer);
193
+ // Compare realpaths — the child's process.cwd() resolves symlinks (e.g. macOS
194
+ // /var → /private/var), so match that rather than the raw tmp path we created.
195
+ assert.ok(r.core.includes(join('node_modules', '@bongos', 'core')), `core root not under node_modules: ${r.core}`);
196
+ assert.equal(r.inst, realpathSync(consumer), `instance root should be the consumer tree, got ${r.inst}`);
197
+ assert.notEqual(r.core, r.inst, 'core root and instance root must be different directories');
198
+ });
199
+
200
+ // --- 4. boot + /healthz ---------------------------------------------------
201
+ const port = await freePort();
202
+ child = spawn(process.execPath, [join(coreDir, 'src', 'platform-server.js')], {
203
+ cwd: consumer,
204
+ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1' },
205
+ stdio: ['ignore', 'pipe', 'pipe'],
206
+ });
207
+ let childErr = '';
208
+ child.stderr.on('data', (c) => { childErr += c; });
209
+ child.stdout.on('data', () => {});
210
+ const health = await waitForHealthz(port);
211
+ check('boot: node node_modules/@bongos/core/src/platform-server.js serves /healthz 200', () => {
212
+ assert.ok(health, `/healthz never came up on port ${port}\n--- server stderr ---\n${childErr.slice(-2000)}`);
213
+ assert.equal(health.status, 200, `/healthz status ${health.status}`);
214
+ assert.equal(health.body.trim(), 'ok', `/healthz body was "${health.body}"`);
215
+ });
216
+
217
+ // --- 5. two-root module load (the task-2133 done-when) --------------------
218
+ // The real default scan (DEFAULT_ROOTS) PLUS an explicit single-root scan that
219
+ // reproduces the pre-2133 loader shape — so the job doesn't just PASS today, it
220
+ // proves it would go RED if the loader regressed to one root ("done when: green
221
+ // AND would RED against the pre-B1 loader").
222
+ const disc = runNodeJSON(`
223
+ const path = require('node:path');
224
+ const loader = require(${A('src/module-loader/loader.js')});
225
+ const ic = require(${A('src/instance-config.js')});
226
+ const coreModulesDir = path.join(ic.resolveCoreRoot(), 'modules');
227
+ const instModulesDir = path.join(ic.resolveInstanceRoot(), 'modules');
228
+ const dflt = loader.discoverModules(); // real DEFAULT_ROOTS
229
+ const single = loader.discoverModules({ roots: [coreModulesDir] }); // pre-2133: core root only
230
+ const two = loader.discoverModules({ roots: [coreModulesDir, instModulesDir] });
231
+ const hasProbe = (r) => r.loaded.some((m) => m.key === 'consumer-probe');
232
+ process.stdout.write(JSON.stringify({
233
+ coreModulesDir, instModulesDir,
234
+ loaded: dflt.loaded.map((m) => ({ key: m.key, dir: m.dir })),
235
+ errors: dflt.errors,
236
+ singleRootHasProbe: hasProbe(single),
237
+ twoRootHasProbe: hasProbe(two),
238
+ }));
239
+ `, consumer);
240
+ check('two-root: discoverModules() loads modules from BOTH the core AND the instance root, zero errors', () => {
241
+ assert.deepEqual(disc.errors, [], `loader reported errors: ${JSON.stringify(disc.errors)}`);
242
+ const fromCore = disc.loaded.filter((m) => m.dir.startsWith(disc.coreModulesDir));
243
+ const fromInstance = disc.loaded.filter((m) => m.dir.startsWith(disc.instModulesDir));
244
+ assert.ok(fromCore.length > 0,
245
+ `no module resolved from the CORE root (${disc.coreModulesDir}) — the package shipped no modules?`);
246
+ const probe = fromInstance.find((m) => m.key === 'consumer-probe');
247
+ assert.ok(probe,
248
+ `consumer-probe was NOT loaded from the INSTANCE root (${disc.instModulesDir}) — a single-root (pre-2133) loader would miss it. loaded=${JSON.stringify(disc.loaded.map((m) => m.key))}`);
249
+ });
250
+ check('counterfactual: a single-root (pre-2133) scan MISSES consumer-probe — proves this job reds on regression', () => {
251
+ assert.equal(disc.twoRootHasProbe, true, 'two-root scan must find consumer-probe (sanity)');
252
+ assert.equal(disc.singleRootHasProbe, false,
253
+ 'a core-only scan found consumer-probe — the instance module must be invisible to a single root, or this job could not RED against the pre-B1 loader');
254
+ });
255
+
256
+ // --- 6. fitness spans both roots -----------------------------------------
257
+ check('fitness: discoveredModuleKeys() unions the core + instance module roots', () => {
258
+ const r = runNodeJSON(`
259
+ const f = require(${A('scripts/gds/fitness.js')});
260
+ process.stdout.write(JSON.stringify({ keys: f.discoveredModuleKeys() }));
261
+ `, consumer);
262
+ assert.ok(r.keys.includes('consumer-probe'),
263
+ `fitness.discoveredModuleKeys() missing the instance module 'consumer-probe': ${JSON.stringify(r.keys)}`);
264
+ // At least one KNOWN core module must also be present (core root scanned too).
265
+ const coreKeys = disc.loaded.filter((m) => m.dir.startsWith(disc.coreModulesDir)).map((m) => m.key);
266
+ assert.ok(coreKeys.some((k) => r.keys.includes(k)),
267
+ `fitness keys carry no core module (expected some of ${JSON.stringify(coreKeys)}): ${JSON.stringify(r.keys)}`);
268
+ });
269
+
270
+ // --- 7. gen-file-map reads the INSTANCE root ------------------------------
271
+ check('gen-file-map: buildModel() lists the INSTANCE root .claude/skills (consumer-probe-skill)', () => {
272
+ const model = runNodeJSON(`
273
+ const g = require(${A('scripts/gds/gen-file-map.js')});
274
+ process.stdout.write(JSON.stringify(g.buildModel()));
275
+ `, consumer);
276
+ const skills = model.find((s) => s.id === 'skills');
277
+ assert.ok(skills, 'gen-file-map produced no skills section');
278
+ const names = skills.entries.map((e) => e.name);
279
+ assert.deepEqual(names, ['consumer-probe-skill'],
280
+ `gen-file-map read the wrong .claude/skills (expected only the consumer's): ${JSON.stringify(names)}`);
281
+ });
282
+
283
+ // --- 8. gen-repo-map reads the CORE root ----------------------------------
284
+ check('gen-repo-map: buildModel() maps CORE code (scripts/gds/fitness.js) from node_modules', () => {
285
+ const model = runNodeJSON(`
286
+ const g = require(${A('scripts/gds/gen-repo-map.js')});
287
+ const m = g.buildModel();
288
+ process.stdout.write(JSON.stringify({ files: m.files.length, hasFitness: m.byFile.some((f) => f.rel === 'scripts/gds/fitness.js') }));
289
+ `, consumer);
290
+ assert.ok(model.files > 50, `core scan found too few files (${model.files}) — core root not resolved to the package?`);
291
+ assert.equal(model.hasFitness, true, 'gen-repo-map did not map the core file scripts/gds/fitness.js');
292
+ });
293
+ } finally {
294
+ if (child && child.exitCode === null) { try { child.kill('SIGTERM'); } catch { /* already gone */ } }
295
+ try { rmSync(workRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
296
+ }
297
+
298
+ console.log(`\nconsumer_layout_boot: ${passed} passed, ${failed} failed`);
299
+ process.exit(failed ? 1 : 0);
300
+ }
301
+
302
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,184 @@
1
+ // tests/cutover_codemod.mjs
2
+ //
3
+ // R89 cutover codemod (task 2159 [F1], task 2161 [F3]): rewrite HOST files' core-internal
4
+ // require()s to @bongos/core/* subpaths at cutover. Guards the pure transform (quote-preserving,
5
+ // idempotent, table-driven), the host-only scope oracle, the post-split precondition, and the
6
+ // end-to-end plan over a simulated consumer tree.
7
+ //
8
+ // Run: node tests/cutover_codemod.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const cm = require('../scripts/gds/cutover-codemod.js');
15
+ const { rewriteCoreRequires, isHostFile, looksLikeConsumer, planCodemod, CORE_INTERNAL_MAP } = cm;
16
+
17
+ let passed = 0;
18
+ let failed = 0;
19
+ function t(name, fn) {
20
+ try { fn(); passed++; console.log(` PASS ${name}`); }
21
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
22
+ }
23
+
24
+ // ---- rewriteCoreRequires: the pure transform (F1 — module-api) --------------
25
+
26
+ t('rewrites ../../src/module-api → @bongos/core/module-api', () => {
27
+ const r = rewriteCoreRequires(`const { pool } = require('../../src/module-api');`);
28
+ assert.equal(r.text, `const { pool } = require('@bongos/core/module-api');`);
29
+ assert.equal(r.changes.length, 1);
30
+ assert.equal(r.changes[0].key, 'module-api');
31
+ });
32
+
33
+ t('rewrites deeper ../../../src/module-api (routes depth) too', () => {
34
+ const r = rewriteCoreRequires(`const { registerProvider } = require('../../../src/module-api');`);
35
+ assert.equal(r.text, `const { registerProvider } = require('@bongos/core/module-api');`);
36
+ });
37
+
38
+ t('preserves double quotes', () => {
39
+ const r = rewriteCoreRequires(`const api = require("../../src/module-api");`);
40
+ assert.equal(r.text, `const api = require("@bongos/core/module-api");`);
41
+ });
42
+
43
+ t('is idempotent — a rewritten @bongos/core require is not matched again', () => {
44
+ const once = rewriteCoreRequires(`const api = require('../../src/module-api');`).text;
45
+ const twice = rewriteCoreRequires(once);
46
+ assert.equal(twice.text, once);
47
+ assert.equal(twice.changes.length, 0);
48
+ });
49
+
50
+ t('leaves an npm/sibling require untouched', () => {
51
+ const src = `const express = require('express');\nconst helper = require('./helper');\n`;
52
+ const r = rewriteCoreRequires(src);
53
+ assert.equal(r.text, src);
54
+ assert.equal(r.changes.length, 0);
55
+ assert.equal(r.unmapped.length, 0);
56
+ });
57
+
58
+ t('trailing member access is preserved (only the require() call is replaced)', () => {
59
+ const r = rewriteCoreRequires(`const pool = require('../../src/module-api').pool;`);
60
+ assert.equal(r.text, `const pool = require('@bongos/core/module-api').pool;`);
61
+ });
62
+
63
+ t('an UNMAPPED core-internal require is left unchanged AND reported', () => {
64
+ // A core internal NOT in the table (host code should never import it raw) — flagged, not guessed.
65
+ const src = `const brand = require('../../src/branding');`;
66
+ const r = rewriteCoreRequires(src);
67
+ assert.equal(r.text, src, 'must not guess a specifier for an unmapped internal');
68
+ assert.equal(r.changes.length, 0);
69
+ assert.equal(r.unmapped.length, 1);
70
+ assert.equal(r.unmapped[0].key, 'branding');
71
+ });
72
+
73
+ t('rewrites every mapped entry in the table', () => {
74
+ for (const [key, mapped] of Object.entries(CORE_INTERNAL_MAP)) {
75
+ const r = rewriteCoreRequires(`require('../../src/${key}')`);
76
+ assert.equal(r.text, `require('@bongos/core/${mapped}')`, `key ${key}`);
77
+ }
78
+ });
79
+
80
+ // ---- F3 (task 2161): server.js core internals --------------------------------
81
+
82
+ t('F3: rewrites server.js core internals from ./src/* form', () => {
83
+ assert.equal(rewriteCoreRequires(`const p = require('./src/bongos/api-path-404');`).text,
84
+ `const p = require('@bongos/core/bongos/api-path-404');`);
85
+ assert.equal(rewriteCoreRequires(`const u = require('./src/bongos/uptime-poller');`).text,
86
+ `const u = require('@bongos/core/bongos/uptime-poller');`);
87
+ assert.equal(rewriteCoreRequires(`const l = require('./src/module-loader/loader');`).text,
88
+ `const l = require('@bongos/core/module-loader/loader');`);
89
+ assert.equal(rewriteCoreRequires(`const m = require('./src/modules');`).text,
90
+ `const m = require('@bongos/core/modules');`);
91
+ assert.equal(rewriteCoreRequires(`const s = require('./src/module-seams');`).text,
92
+ `const s = require('@bongos/core/module-seams');`);
93
+ });
94
+
95
+ t('F3: serve-internal maps to its curated export name, NOT ./bongos/serve-internal', () => {
96
+ // The file is src/bongos/serve-internal.js but the package exports it as ./serve-internal.
97
+ const r = rewriteCoreRequires(`const { mountInternalSurfaces } = require('./src/bongos/serve-internal');`);
98
+ assert.equal(r.text, `const { mountInternalSurfaces } = require('@bongos/core/serve-internal');`);
99
+ });
100
+
101
+ // ---- scope oracle -----------------------------------------------------------
102
+
103
+ t('isHostFile is true only when isPublishable(rel) === false', () => {
104
+ const fakePub = (rel) => rel.startsWith('src/') || rel.startsWith('modules/lifecycle/');
105
+ assert.equal(isHostFile('modules/game/db.js', fakePub), true);
106
+ assert.equal(isHostFile('server.js', fakePub), true);
107
+ assert.equal(isHostFile('modules/lifecycle/db.js', fakePub), false);
108
+ assert.equal(isHostFile('src/module-api.js', fakePub), false);
109
+ });
110
+
111
+ // ---- precondition -----------------------------------------------------------
112
+
113
+ t('looksLikeConsumer: false in the monolith (src/module-api.js present), true post-split', () => {
114
+ const monolith = looksLikeConsumer('/repo', { existsFn: (p) => p.endsWith('/src/module-api.js') });
115
+ const consumer = looksLikeConsumer('/repo', { existsFn: () => false });
116
+ assert.equal(monolith, false);
117
+ assert.equal(consumer, true);
118
+ });
119
+
120
+ // ---- planCodemod over a simulated consumer tree -----------------------------
121
+
122
+ t('planCodemod rewrites host files, skips core files, reports unmapped', () => {
123
+ // A fake post-split consumer: host modules + server.js kept; a core module also present
124
+ // (as if partition had not yet run) to prove the scope oracle skips it regardless.
125
+ const tree = {
126
+ 'modules/game/db.js': `const { pool } = require('../../src/module-api');`,
127
+ 'modules/art-pipeline/routes/art-key.js': `const api = require('../../../src/module-api');`,
128
+ 'server.js': `const seams = require('./src/module-seams');`, // F3 — now mapped → rewritten
129
+ 'modules/game/experimental.js': `const b = require('../../src/branding');`, // unmapped → reported, not rewritten
130
+ 'modules/lifecycle/db.js': `const api = require('../../src/module-api');`, // CORE — must be skipped
131
+ 'README.md': 'not a js file',
132
+ };
133
+ const fsImpl = {
134
+ readdirSync: (dir, opts) => {
135
+ // minimal shim: list immediate children of `dir` within the flat `tree`
136
+ const rootPrefix = '/consumer';
137
+ const rel = dir === rootPrefix ? '' : dir.slice(rootPrefix.length + 1);
138
+ const names = new Set();
139
+ for (const p of Object.keys(tree)) {
140
+ if (rel && !p.startsWith(rel + '/')) continue;
141
+ const rest = rel ? p.slice(rel.length + 1) : p;
142
+ const seg = rest.split('/')[0];
143
+ const isDir = rest.includes('/');
144
+ names.add(seg + (isDir ? '/' : ''));
145
+ }
146
+ return [...names].map((n) => {
147
+ const isDir = n.endsWith('/');
148
+ const name = isDir ? n.slice(0, -1) : n;
149
+ return { name, isDirectory: () => isDir, isFile: () => !isDir };
150
+ });
151
+ },
152
+ readFileSync: (abs) => tree[abs.slice('/consumer/'.length)],
153
+ };
154
+ const isPub = (rel) => rel.startsWith('modules/lifecycle/') || rel.startsWith('src/');
155
+ // Force the filesystem-walk path (no git) so the injected fake fs is exercised.
156
+ const runGit = () => { throw new Error('no git in test'); };
157
+ const plan = planCodemod({ root: '/consumer', fsImpl, runGit, isPublishableFn: isPub });
158
+
159
+ const changed = plan.files.map((f) => f.rel).sort();
160
+ assert.deepEqual(changed, ['modules/art-pipeline/routes/art-key.js', 'modules/game/db.js', 'server.js']);
161
+ // core module skipped even though it has the same relative require
162
+ assert.ok(!changed.includes('modules/lifecycle/db.js'), 'core module must be skipped');
163
+ // a host file with an UNMAPPED core internal is reported, never rewritten
164
+ assert.ok(!changed.includes('modules/game/experimental.js'), 'unmapped internal must not be rewritten');
165
+ assert.ok(plan.unmapped.some((u) => u.rel === 'modules/game/experimental.js' && u.key === 'branding'));
166
+ // the rewritten text is correct for both a host module (F1) and server.js (F3)
167
+ const game = plan.files.find((f) => f.rel === 'modules/game/db.js');
168
+ assert.equal(game.newText, `const { pool } = require('@bongos/core/module-api');`);
169
+ const srv = plan.files.find((f) => f.rel === 'server.js');
170
+ assert.equal(srv.newText, `const seams = require('@bongos/core/module-seams');`);
171
+ });
172
+
173
+ // ---- invariant: every mapped target is an actual @bongos/core export ---------
174
+
175
+ t('every CORE_INTERNAL_MAP target is exported by the core package (a rewrite never outruns an export)', () => {
176
+ const { buildPackageJson } = require('../scripts/gds/package-core.js');
177
+ const pkg = buildPackageJson({ version: '0.0.0-test', sourceDeps: { express: '^4.0.0', pg: '^8.0.0' } });
178
+ for (const mapped of Object.values(CORE_INTERNAL_MAP)) {
179
+ assert.ok(pkg.exports[`./${mapped}`], `@bongos/core must export ./${mapped} (targeted by the codemod)`);
180
+ }
181
+ });
182
+
183
+ console.log(`\ncutover_codemod: ${passed} passed, ${failed} failed`);
184
+ if (failed) process.exit(1);
@@ -0,0 +1,96 @@
1
+ // tests/device_flow_start.mjs
2
+ //
3
+ // task 2126 (demo-audit fix C3) — startDeviceFlow() must surface a GitHub OAuth-App
4
+ // misconfig (the HTTP-200-with-{error} body: device_flow_disabled / bad client) as an
5
+ // ACTIONABLE thrown error, not silently drop the missing device_code into a broken flow
6
+ // (which the route then reported as an opaque 500 — the 2026-07-08 demo login failure).
7
+ //
8
+ // Hermetic: stubs globalThis.fetch (no network) + sets creds env so loadGithubCreds()
9
+ // succeeds. auth.js requires pool but never QUERIES it here (startDeviceFlow only fetches),
10
+ // so no DB. Run: node tests/device_flow_start.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ // Creds must resolve before startDeviceFlow runs loadGithubCreds() — set legacy + prefixed.
16
+ for (const p of ['GDS', 'OTB', 'CLOUDBONGOS']) {
17
+ process.env[`${p}_GITHUB_CLIENT_ID`] = 'Iv1.testclientid00';
18
+ process.env[`${p}_GITHUB_CLIENT_SECRET`] = 'testsecret000000000000000000000000000000';
19
+ }
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const auth = require('../src/bongos/auth.js');
23
+
24
+ let passed = 0, failed = 0;
25
+ const realFetch = globalThis.fetch;
26
+ function stubFetch(status, jsonBody) {
27
+ globalThis.fetch = async () => ({
28
+ ok: status >= 200 && status < 300,
29
+ status,
30
+ json: async () => jsonBody,
31
+ text: async () => JSON.stringify(jsonBody),
32
+ });
33
+ }
34
+ async function t(name, fn) {
35
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
37
+ finally { globalThis.fetch = realFetch; }
38
+ }
39
+
40
+ await t('device_flow_disabled (HTTP 200 + error body) → throws an actionable error deep-linking the GitHub-App settings (task 2129)', async () => {
41
+ stubFetch(200, { error: 'device_flow_disabled', error_description: 'Device Flow is not enabled for this app.' });
42
+ await assert.rejects(
43
+ () => auth.startDeviceFlow(),
44
+ (err) => /device_flow_disabled/.test(err.message)
45
+ && /device-flow start/i.test(err.message)
46
+ // creds client_id is Iv1.* (a GitHub App) → point at the Apps listing + the exact toggle.
47
+ && /github\.com\/settings\/apps/.test(err.message)
48
+ && /Enable Device Flow/i.test(err.message),
49
+ );
50
+ });
51
+
52
+ await t('device_flow_disabled on an OAuth App (Ov* client_id) → deep-links OAuth developer settings, not /settings/apps (task 2129)', async () => {
53
+ const saved = {};
54
+ for (const p of ['GDS', 'OTB', 'CLOUDBONGOS']) {
55
+ saved[p] = process.env[`${p}_GITHUB_CLIENT_ID`];
56
+ process.env[`${p}_GITHUB_CLIENT_ID`] = 'Ov23liOAuthClientId0';
57
+ }
58
+ try {
59
+ stubFetch(200, { error: 'device_flow_disabled', error_description: 'Device Flow is not enabled for this app.' });
60
+ await assert.rejects(
61
+ () => auth.startDeviceFlow(),
62
+ (err) => /github\.com\/settings\/developers/.test(err.message)
63
+ && !/settings\/apps/.test(err.message)
64
+ && /Enable Device Flow/i.test(err.message),
65
+ );
66
+ } finally {
67
+ for (const p of ['GDS', 'OTB', 'CLOUDBONGOS']) process.env[`${p}_GITHUB_CLIENT_ID`] = saved[p];
68
+ }
69
+ });
70
+
71
+ await t('incorrect_client_credentials (HTTP 200 + error) → throws, names the error', async () => {
72
+ stubFetch(200, { error: 'incorrect_client_credentials', error_description: 'The client_id is not valid.' });
73
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /incorrect_client_credentials/.test(err.message));
74
+ });
75
+
76
+ await t('happy path (valid device/code body) → returns the flow fields', async () => {
77
+ stubFetch(200, { device_code: 'dc_abc', user_code: 'WXYZ-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 });
78
+ const out = await auth.startDeviceFlow();
79
+ assert.equal(out.device_code, 'dc_abc');
80
+ assert.equal(out.user_code, 'WXYZ-1234');
81
+ assert.equal(out.verification_uri, 'https://github.com/login/device');
82
+ assert.equal(out.interval, 5);
83
+ });
84
+
85
+ await t('malformed 200 (no device_code, no error) → throws rather than a broken flow', async () => {
86
+ stubFetch(200, {});
87
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /device_code/.test(err.message));
88
+ });
89
+
90
+ await t('non-2xx from GitHub still throws (pre-existing guard preserved)', async () => {
91
+ stubFetch(404, { message: 'Not Found' });
92
+ await assert.rejects(() => auth.startDeviceFlow(), (err) => /device\/code failed \(404\)/.test(err.message));
93
+ });
94
+
95
+ console.log(`\ndevice-flow start (task 2126) tests: ${passed} passed, ${failed} failed`);
96
+ process.exit(failed ? 1 : 0);