@bongos/core 1.17.1 → 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 (164) hide show
  1. package/.bongos-core.json +244 -264
  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/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  17. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  18. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  19. package/docs/adr/0020-security-model-public-stub.md +1 -1
  20. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  21. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  22. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  23. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  24. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  25. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  26. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  27. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  28. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  29. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  31. package/docs/adr/0057-container-cost-ledger.md +3 -3
  32. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  33. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  34. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  35. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  36. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  37. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  38. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  39. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  40. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  41. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  42. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  43. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  44. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  45. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  46. package/docs/adr/README.md +9 -6
  47. package/docs/architecture.md +2 -2
  48. package/docs/branding-contract.md +5 -4
  49. package/docs/canonical-permissions.md +1 -1
  50. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  51. package/docs/file-map.md +18 -17
  52. package/docs/handoff-template.md +2 -2
  53. package/docs/modules-contract.md +6 -1
  54. package/docs/onboarding/diagrams/README.md +7 -7
  55. package/docs/onboarding/drachmae-and-karma.md +18 -18
  56. package/docs/onboarding/primer.md +9 -9
  57. package/docs/recipes/audit-replay.md +2 -2
  58. package/docs/recipes/builder-box-lifecycle.md +2 -2
  59. package/docs/recipes/claude-design-loop.md +1 -1
  60. package/docs/recipes/ops-gotchas.md +1 -1
  61. package/docs/recipes/private-npm-distribution.md +17 -0
  62. package/docs/recipes/staging-environment.md +1 -1
  63. package/docs/recipes/upgrading-the-core.md +55 -0
  64. package/docs/research/cursor-competitive-analysis.md +1 -1
  65. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  66. package/modules/discord/module.json +2 -1
  67. package/modules/economy/achievements.js +9 -1
  68. package/modules/economy/credits.js +10 -1
  69. package/modules/hall-ui/public/builders.js +71 -4
  70. package/modules/hall-ui/public/goal-inbox.js +191 -0
  71. package/modules/hall-ui/public/hall-widgets.js +14 -1
  72. package/modules/hall-ui/public/index.html +3 -0
  73. package/modules/hall-ui/public/new-project-wizard.js +1 -0
  74. package/modules/hall-ui/public/style.css +43 -0
  75. package/modules/provisioning/CLAUDE.md +15 -0
  76. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  77. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  78. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  79. package/modules/provisioning/module.json +17 -0
  80. package/modules/provisioning/onboard-plan.js +37 -0
  81. package/modules/provisioning/provisioning.js +539 -0
  82. package/modules/provisioning/rate-limit.js +35 -0
  83. package/modules/provisioning/routes/provisioning.js +607 -0
  84. package/modules/provisioning/tests/provisioning.mjs +232 -0
  85. package/modules/provisioning/ui/.gitkeep +0 -0
  86. package/modules/security/secret-scrub.js +10 -0
  87. package/package-lock.json +2 -2
  88. package/package.json +7 -2
  89. package/scripts/discord/README.md +1 -1
  90. package/scripts/gds/claim.js +98 -48
  91. package/scripts/gds/claude-materialize.js +30 -9
  92. package/scripts/gds/cutover-codemod.js +241 -0
  93. package/scripts/gds/fitness.js +54 -0
  94. package/scripts/gds/init.js +59 -3
  95. package/scripts/gds/lint-infra-exec.js +70 -0
  96. package/scripts/gds/lint-workflow-exec.js +63 -0
  97. package/scripts/gds/mirror-lineage-check.js +14 -5
  98. package/scripts/gds/mirror-redact.js +8 -0
  99. package/scripts/gds/onboard.js +4 -0
  100. package/scripts/gds/package-core.js +18 -3
  101. package/scripts/gds/publish-manifest.js +60 -2
  102. package/scripts/gds/run-routine.js +51 -13
  103. package/scripts/gds/run-unit-tests.js +4 -0
  104. package/scripts/gds/update-channel.js +186 -0
  105. package/scripts/gds/upgrade.js +116 -9
  106. package/src/bongos/auth.js +14 -1
  107. package/src/bongos/serve-internal.js +10 -1
  108. package/src/branding.js +1 -0
  109. package/src/module-api.js +12 -1
  110. package/src/module-loader/loader.js +36 -4
  111. package/src/module-loader/manifest-schema.js +16 -1
  112. package/tests/branding.mjs +11 -0
  113. package/tests/claim_error_surface.mjs +107 -0
  114. package/tests/claude_materialize.mjs +20 -0
  115. package/tests/consumer_layout_boot.mjs +302 -0
  116. package/tests/cutover_codemod.mjs +184 -0
  117. package/tests/device_flow_start.mjs +25 -2
  118. package/tests/hook_core_resolve.mjs +125 -0
  119. package/tests/infra_exec_paths.mjs +104 -0
  120. package/tests/init.mjs +47 -0
  121. package/tests/mirror_lineage_proof.mjs +14 -0
  122. package/tests/module_loader.mjs +42 -0
  123. package/tests/module_manifest.mjs +19 -0
  124. package/tests/onboard.mjs +13 -0
  125. package/tests/package_core.mjs +8 -0
  126. package/tests/provisioning_github_app_routes.mjs +15 -0
  127. package/tests/publish_manifest.mjs +52 -1
  128. package/tests/run_routine.mjs +26 -1
  129. package/tests/run_routine_two_root.mjs +77 -0
  130. package/tests/scrubber_corpus.mjs +17 -0
  131. package/tests/update_channel.mjs +186 -0
  132. package/tests/upgrade.mjs +131 -0
  133. package/tests/workflow_exec_paths.mjs +98 -0
  134. package/docs/design/container-exterior-tiles.md +0 -49
  135. package/docs/design/container-exterior.md +0 -81
  136. package/docs/design/container-interior.md +0 -77
  137. package/docs/design/world-map-scroll.md +0 -41
  138. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  139. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  140. package/docs/recipes/cloudbongos-standup.md +0 -111
  141. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  142. package/docs/recipes/parallel-load-test.md +0 -32
  143. package/docs/recipes/pixel-art-pipeline.md +0 -411
  144. package/docs/recipes/staging-data.md +0 -50
  145. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  146. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  147. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  148. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  149. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  150. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  151. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  152. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  153. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  154. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  155. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  156. package/scripts/gds/art-stage.js +0 -152
  157. package/server.js +0 -212
  158. package/tests/art_key.mjs +0 -121
  159. package/tests/day_cycle.mjs +0 -197
  160. package/tests/snapshot_picker.mjs +0 -233
  161. package/tests/sprite_facing.mjs +0 -35
  162. package/tests/terrain_picker.mjs +0 -298
  163. package/tests/turn_in_place.mjs +0 -280
  164. package/tests/walkability.mjs +0 -188
@@ -71,6 +71,21 @@ console.log('POST /provisioning/instances/:id/github-app (mint the one-click lin
71
71
  t('the attempt row was persisted with the minted state', !!(stored && stored.instanceId === 42 && stored.state === j.state && stored.requestedBy === 7));
72
72
  }
73
73
 
74
+ console.log('\nprovisioningOrigin override (task 2101 — customer App carries the PLATFORM brand, not the game domain):');
75
+ {
76
+ // A control plane that co-hosts a game (OTB = customer 0) has a GAME-branded publicOrigin.
77
+ // provisioningOrigin is the dedicated front door the customer's GitHub App must point at,
78
+ // so mothershipOrigin() must prefer it over publicOrigin. mothershipOrigin() calls
79
+ // api.branding() (module-api captured the fn ref at require-time), so override THAT.
80
+ const prevBranding = api.branding;
81
+ api.branding = () => ({ ...realBranding(), identity: { productName: 'Cloud Bongos' }, domains: { publicOrigin: 'https://amazonprimea.com', provisioningOrigin: 'https://get.cloudbongos.com' } });
82
+ const res = await fetch(`${base}/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
83
+ const j = await res.json();
84
+ t('redirect_url uses provisioningOrigin, not the game publicOrigin', j.manifest && j.manifest.redirect_url === 'https://get.cloudbongos.com/api/bongos/provisioning/github-app/callback', j.manifest && j.manifest.redirect_url);
85
+ t('webhook hook_attributes.url also uses provisioningOrigin', j.manifest && j.manifest.hook_attributes && j.manifest.hook_attributes.url === 'https://get.cloudbongos.com/api/bongos/provisioning/github-app/webhook', j.manifest && j.manifest.hook_attributes && j.manifest.hook_attributes.url);
86
+ api.branding = prevBranding;
87
+ }
88
+
74
89
  console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect):');
75
90
  {
76
91
  const res = await fetch(`${base}/provisioning/github-app/callback?code=GHCODE123&state=GOODSTATE`);
@@ -44,13 +44,13 @@ const PUBLISH = [
44
44
  'scripts/gds/ship.js', // the ship/grade pipeline publishes (sans secrets)
45
45
  'scripts/gds/publish-manifest.js',
46
46
  'scripts/migrate.sh',
47
- 'server.js',
48
47
  'migrations/182_some_change.sql',
49
48
  'tests/publish_manifest.mjs',
50
49
  'modules/lifecycle/db.js',
51
50
  'modules/grading/grader.js',
52
51
  'modules/hall-ui/public/work.js',
53
52
  'modules/dev-box/boxes.js',
53
+ 'modules/provisioning/provisioning.js', // instance-hosting control plane — CORE like its sibling dev-box (task 2135)
54
54
  // served-at-runtime surfaces a standalone must carry (task 2078) — their absence
55
55
  // from the packaged core 404'd the hall UI + /docs on demo.cloudbongos.com
56
56
  'clients/bongos-client/bongos-client.global.js', // /lib/bongos-client.js (window.BongosClient)
@@ -91,8 +91,19 @@ const EXCLUDE = [
91
91
  'src/world/terrain.js',
92
92
  'src/rooms/WorldRoom.js',
93
93
  'src/preview-server.js',
94
+ 'server.js', // repo-root game+platform entry (top-level require('colyseus')); the core boots via src/platform-server.js (task 2134)
94
95
  'scripts/art/orchestrate.js', // art-feature tooling under scripts/ (legacy location)
96
+ 'scripts/gds/art-stage.js', // art sandbox-staging CLI (requires modules/art-pipeline); excluded with the art feature (task 2135)
95
97
  'scripts/tile-add.js',
98
+ // host-feature tests that statically import an excluded module's source: shipped in the
99
+ // core package they'd MODULE_NOT_FOUND; excluded with the feature they cover (task 2147)
100
+ 'tests/walkability.mjs',
101
+ 'tests/terrain_picker.mjs',
102
+ 'tests/snapshot_picker.mjs',
103
+ 'tests/sprite_facing.mjs',
104
+ 'tests/turn_in_place.mjs',
105
+ 'tests/day_cycle.mjs',
106
+ 'tests/art_key.mjs',
96
107
  // instance identity + history
97
108
  'config/branding.json',
98
109
  'config/modules.json',
@@ -106,6 +117,7 @@ const EXCLUDE = [
106
117
  'blockers/blockers.md',
107
118
  'limitations/v1-shipped.md',
108
119
  'generative-ideas.md',
120
+ 'migrations/instance/018_pms_v3_planning_seed.sql', // instance seed migrations = host scope-truth (ADR 0105, task 2140)
109
121
  '.env.local',
110
122
  // topology / deploy / CI / local gate hooks / secrets — read-sensitive docs
111
123
  'infra/Caddyfile',
@@ -180,5 +192,44 @@ t('allowlist + denylist are non-empty arrays of strings', () => {
180
192
  for (const g of [...PUBLISH_ALLOWLIST, ...PUBLISH_DENYLIST]) assert.equal(typeof g, 'string');
181
193
  });
182
194
 
195
+ // ── R89 split guard: no PUBLISHABLE test may statically import an EXCLUDED module ─────
196
+ // tests/ is allowlisted, but a test that imports host-module source (game / art-pipeline)
197
+ // would ship inside the @bongos/core package WITHOUT that module → MODULE_NOT_FOUND when a
198
+ // core CI/consumer runs it, and the cutover would delete OTB's own host tests. Every such
199
+ // test must be host-excluded (see CONTENT_EXCLUDE). This DERIVES the set from disk so a NEW
200
+ // host-module test can't slip in unlisted (the enumerated assertions above would miss it).
201
+ // Static relative-literal imports only — temp-dir fixtures (modules/alpha, …) use path.join,
202
+ // so they never match; a module name appearing only as string DATA (this file's own arrays)
203
+ // isn't in a require()/import statement, so it never matches either. (task 2147)
204
+ t('no publishable test statically imports an excluded module (R89 boundary)', () => {
205
+ const fs = require('node:fs');
206
+ const path = require('node:path');
207
+ const { fileURLToPath } = require('node:url');
208
+ const testsDir = path.dirname(fileURLToPath(import.meta.url));
209
+ const IMPORT_RE = /(?:require|import)\s*\(\s*["'](\.\.?\/[^"']*?)modules\/([a-z0-9][a-z0-9-]*)\/[^"']*["']|from\s+["'](\.\.?\/[^"']*?)modules\/([a-z0-9][a-z0-9-]*)\/[^"']*["']/g;
210
+ const detected = []; // test files that import ANY excluded module (non-vacuous check)
211
+ const offenders = []; // …and are still publishable (the actual failure)
212
+ for (const name of fs.readdirSync(testsDir)) {
213
+ if (!/\.(mjs|js|cjs)$/.test(name)) continue;
214
+ const rel = `tests/${name}`;
215
+ const bad = new Set();
216
+ let m; IMPORT_RE.lastIndex = 0;
217
+ const src = fs.readFileSync(path.join(testsDir, name), 'utf8');
218
+ while ((m = IMPORT_RE.exec(src))) {
219
+ const key = m[2] || m[4];
220
+ if (key && !isPublishable(`modules/${key}/module.json`)) bad.add(key);
221
+ }
222
+ if (bad.size === 0) continue;
223
+ detected.push(rel);
224
+ if (isPublishable(rel)) {
225
+ offenders.push(`${rel} imports excluded module(s) [${[...bad].sort().join(', ')}] but is still PUBLISHABLE — add it to CONTENT_EXCLUDE (task 2147)`);
226
+ }
227
+ }
228
+ // non-vacuous: the scan must actually SEE the known host tests (a broken regex that
229
+ // matched nothing would otherwise pass silently). The 7 game/art tests are the floor.
230
+ assert.ok(detected.length >= 7, `expected ≥7 host-module tests detected, saw ${detected.length} — the import scan may be broken`);
231
+ assert.equal(offenders.length, 0, '\n ' + offenders.join('\n '));
232
+ });
233
+
183
234
  console.log(`\n${passed} passed, ${failed} failed`);
184
235
  process.exit(failed ? 1 : 0);
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
+ import path from 'node:path';
7
8
  const require = createRequire(import.meta.url);
8
- const { parseRoutineFrontmatter, tokenize } = require('../scripts/gds/run-routine.js');
9
+ const { parseRoutineFrontmatter, tokenize, resolveStepArgv } = require('../scripts/gds/run-routine.js');
9
10
 
10
11
  let passed = 0, failed = 0;
11
12
  function test(name, fn) {
@@ -65,5 +66,29 @@ test('tokenize splits argv and honors quotes', () => {
65
66
  assert.deepEqual(tokenize(''), []);
66
67
  });
67
68
 
69
+ // resolveStepArgv — two-root step resolution (ADR 0108; task 2138). Explicit roots (pure).
70
+ const ROOTS = { coreRoot: '/core', instanceRoot: '/inst' };
71
+
72
+ test('resolveStepArgv: `bongos exec <rel>` resolves under the CORE root', () => {
73
+ assert.deepEqual(
74
+ resolveStepArgv(['bongos', 'exec', 'scripts/gds/box.js', 'sweep-idle', '--apply'], ROOTS),
75
+ [process.execPath, path.join('/core', 'scripts/gds/box.js'), 'sweep-idle', '--apply']);
76
+ });
77
+
78
+ test('resolveStepArgv: `node scripts/...` → CORE root; `node .claude/...` companion → INSTANCE root', () => {
79
+ assert.deepEqual(
80
+ resolveStepArgv(['node', 'scripts/gds/x.js', '--flag'], ROOTS),
81
+ [process.execPath, path.join('/core', 'scripts/gds/x.js'), '--flag']);
82
+ assert.deepEqual(
83
+ resolveStepArgv(['node', '.claude/scheduled-tasks/demo/run.js'], ROOTS),
84
+ [process.execPath, path.join('/inst', '.claude/scheduled-tasks/demo/run.js')]);
85
+ });
86
+
87
+ test('resolveStepArgv: absolute paths + non-node commands + empty pass through untouched', () => {
88
+ assert.deepEqual(resolveStepArgv(['node', '/abs/x.js'], ROOTS), ['node', '/abs/x.js']);
89
+ assert.deepEqual(resolveStepArgv(['bash', 'x.sh'], ROOTS), ['bash', 'x.sh']);
90
+ assert.deepEqual(resolveStepArgv([], ROOTS), []);
91
+ });
92
+
68
93
  console.log(`\nrun_routine: ${passed} passed, ${failed} failed`);
69
94
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,77 @@
1
+ // tests/run_routine_two_root.mjs — task 2138 (ADR 0108 configurable-root).
2
+ //
3
+ // Proves the deterministic cron dispatcher works in a real CONSUMER layout: it FINDS a routine
4
+ // under resolveInstanceRoot() (the host repo — NOT the core root) and RUNS its companion step,
5
+ // whose core require() resolves into node_modules/@bongos/core via the materialized
6
+ // _core-resolve.js sibling. This is task 2138's "a consumer-layout dispatch finds + runs the
7
+ // routines" done-when, exercised in a synthetic two-root tree.
8
+ //
9
+ // Counterfactual built in: the routine exists ONLY under the synthetic instance root, so against
10
+ // the pre-2138 dispatcher (TASKS_DIR hardcoded to __dirname/../..) run-routine would look in THIS
11
+ // repo, not find `demo`, and exit 2 — reddening this test. And the companion asserts its core lib
12
+ // resolved from node_modules/@bongos/core (not the instance root), so a single-root resolve fails it.
13
+ //
14
+ // DB-free, no model. Run: node tests/run_routine_two_root.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { execFileSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const REPO = path.resolve(__dirname, '..'); // the core root (this repo), fixed by resolveCoreRoot()
25
+ const RUN_ROUTINE = path.join(REPO, 'scripts', 'gds', 'run-routine.js');
26
+ const CORE_RESOLVE = path.join(REPO, '.claude', 'hooks', '_core-resolve.js');
27
+
28
+ let passed = 0, failed = 0;
29
+ function test(name, fn) {
30
+ try { fn(); console.log(` ok ${name}`); passed++; }
31
+ catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
32
+ }
33
+
34
+ // Build a synthetic CONSUMER tree: instance content (.claude/) at its OWN root; the core installed
35
+ // under node_modules/@bongos/core carrying just the one lib the companion needs.
36
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rr-two-root-'));
37
+ const inst = path.join(tmp, 'instance');
38
+ try {
39
+ const coreLibDir = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
40
+ fs.mkdirSync(coreLibDir, { recursive: true });
41
+ fs.writeFileSync(path.join(coreLibDir, 'fake-core-lib.js'), 'module.exports = { ping: () => "CORE_OK" };\n');
42
+
43
+ // the materialized _core-resolve.js sibling (the real one — its resolution is what we exercise)
44
+ const hooksDir = path.join(inst, '.claude', 'hooks');
45
+ fs.mkdirSync(hooksDir, { recursive: true });
46
+ fs.copyFileSync(CORE_RESOLVE, path.join(hooksDir, '_core-resolve.js'));
47
+
48
+ // a materialized deterministic routine + its instance-path companion
49
+ const rtDir = path.join(inst, '.claude', 'scheduled-tasks', 'demo');
50
+ fs.mkdirSync(rtDir, { recursive: true });
51
+ fs.writeFileSync(path.join(rtDir, 'SKILL.md'),
52
+ ['---', 'name: demo', 'mode: deterministic', 'steps:',
53
+ ' - command: node .claude/scheduled-tasks/demo/run.js', ' sink: none', '---', 'body', ''].join('\n'));
54
+ fs.writeFileSync(path.join(rtDir, 'run.js'),
55
+ "const { requireCore, coreModulePath } = require('../../hooks/_core-resolve.js');\n" +
56
+ "const { ping } = requireCore('scripts/gds/fake-core-lib.js');\n" +
57
+ "console.log('RESOLVED=' + coreModulePath('scripts/gds/fake-core-lib.js'));\n" +
58
+ "console.log(ping());\n");
59
+
60
+ test('dispatch finds the routine under resolveInstanceRoot() and runs it; companion resolves core from node_modules', () => {
61
+ const out = execFileSync(process.execPath, [RUN_ROUTINE, 'demo'], {
62
+ encoding: 'utf8',
63
+ // Diverge the roots: OTB_INSTANCE_ROOT points resolveInstanceRoot() at the synthetic tree
64
+ // (OTB_ is an accepted legacy prefix; ADR 0108 §1); resolveCoreRoot() stays this repo.
65
+ // CLAUDE_PROJECT_DIR pins _core-resolve's own instance root to the synthetic tree too.
66
+ env: { ...process.env, OTB_INSTANCE_ROOT: inst, CLAUDE_PROJECT_DIR: inst },
67
+ });
68
+ assert.match(out, /CORE_OK/, 'companion step should have run and printed its core lib output');
69
+ assert.match(out, /RESOLVED=.*node_modules[/\\]@bongos[/\\]core[/\\]scripts[/\\]gds[/\\]fake-core-lib\.js/,
70
+ 'the core lib must resolve from node_modules/@bongos/core, proving two-root (not single-root) resolution');
71
+ });
72
+ } finally {
73
+ fs.rmSync(tmp, { recursive: true, force: true });
74
+ }
75
+
76
+ console.log(`\nrun_routine_two_root: ${passed} passed, ${failed} failed`);
77
+ process.exit(failed ? 1 : 0);
@@ -242,6 +242,23 @@ test('collectEnvSecrets: gathers secret-shaped env VALUES, skips benign & short'
242
242
  assert.ok(!out.includes(env.MAC_INSTALLER_CERT_P12) && !out.includes(env.APPLE_API_ISSUER_ID), 'env literals scrubbed');
243
243
  });
244
244
 
245
+ test('collectEnvSecrets: benign shell PWD/OLDPWD are skipped, real *_PWD password vars still collected (task 2130)', () => {
246
+ // Regression: PWD/OLDPWD contain "pwd" (the password alternative in ENV_SECRET_KEY_RE)
247
+ // and hold a directory PATH ≥ 8 chars, so pre-fix they were collected as "secrets" — then
248
+ // the package-core no-leak gate aborted a legit publish whenever that path appeared verbatim
249
+ // in a functional file (a /Users/<name> anti-pattern comment), passing or failing purely on
250
+ // the operator's last `cd`. Exact-match exclusion, so genuine password vars still match.
251
+ const env = {
252
+ PWD: '/Users/larskristensen/dev/Claude-OTB',
253
+ OLDPWD: '/Users/larskristensen',
254
+ MYSQL_PWD: randStr(20, 40, B64URL), // a real password var — MUST still be collected
255
+ };
256
+ const got = collectEnvSecrets(env);
257
+ assert.ok(!got.includes(env.PWD), 'PWD (a directory path) must not be collected');
258
+ assert.ok(!got.includes(env.OLDPWD), 'OLDPWD (a directory path) must not be collected');
259
+ assert.ok(got.includes(env.MYSQL_PWD), 'MYSQL_PWD (a real password var) must still be collected');
260
+ });
261
+
245
262
  test('self-reference `key = key` (SQL SET col = col) survives — zero recall loss', () => {
246
263
  // task 1688: the core packager redacts live SQL, so a `_token` COLUMN self-increment
247
264
  // must NOT be corrupted — while a real `key = <secret>` assignment is STILL redacted.
@@ -0,0 +1,186 @@
1
+ // tests/update_channel.mjs
2
+ //
3
+ // task 2150 / ADR 0136 — the update-subscription policy core (scripts/gds/update-channel.js).
4
+ // Pins the channel math + manifest loading the `core-update-subscription` routine relies on:
5
+ // 1. semver — parse (incl. prerelease), compare precedence.
6
+ // 2. channelAllows — the pinned/patch/minor blast-radius matrix; majors + prereleases never auto.
7
+ // 3. resolveChannelTarget — highest permitted target, or null (pinned / nothing newer / unknown).
8
+ // 4. parseNpmViewVersions — the array / bare-string / empty shapes npm view emits.
9
+ // 5. listAvailableVersions — ok + fail-soft with an injected runner.
10
+ // 6. loadSubscriptions — missing/malformed → empty; channel default + validate; env kept; dropless.
11
+ //
12
+ // DB-free + network-free (every external is injected). Run: node tests/update_channel.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import { mkdtempSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const uc = require('../scripts/gds/update-channel.js');
22
+
23
+ let passed = 0, failed = 0;
24
+ function t(name, fn) {
25
+ try { fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+
29
+ // ---- semver ----------------------------------------------------------------
30
+
31
+ t('parseSemver: plain, v-prefixed, prerelease, junk', () => {
32
+ assert.deepEqual(uc.parseSemver('1.17.2'), { major: 1, minor: 17, patch: 2, pre: '' });
33
+ assert.deepEqual(uc.parseSemver('v2.0.0'), { major: 2, minor: 0, patch: 0, pre: '' });
34
+ assert.deepEqual(uc.parseSemver('1.17.2-rc.1'), { major: 1, minor: 17, patch: 2, pre: 'rc.1' });
35
+ assert.equal(uc.parseSemver('1.17'), null);
36
+ assert.equal(uc.parseSemver('latest'), null);
37
+ assert.equal(uc.parseSemver(''), null);
38
+ });
39
+
40
+ t('compareSemver: numeric precedence + prerelease below stable', () => {
41
+ assert.equal(uc.compareSemver('1.17.2', '1.17.1'), 1);
42
+ assert.equal(uc.compareSemver('1.17.1', '1.18.0'), -1);
43
+ assert.equal(uc.compareSemver('1.17.1', '2.0.0'), -1);
44
+ assert.equal(uc.compareSemver('1.17.2', '1.17.2'), 0);
45
+ assert.equal(uc.compareSemver('1.17.2-rc.1', '1.17.2'), -1); // prerelease < stable
46
+ assert.equal(uc.compareSemver('bogus', '1.0.0'), -1); // unparseable sorts lowest
47
+ });
48
+
49
+ t('normalizeChannel: valid pass through, unknown/blank → patch default', () => {
50
+ assert.equal(uc.normalizeChannel('pinned'), 'pinned');
51
+ assert.equal(uc.normalizeChannel('MINOR'), 'minor');
52
+ assert.equal(uc.normalizeChannel('major'), 'patch'); // major is not a valid auto-channel
53
+ assert.equal(uc.normalizeChannel(''), 'patch');
54
+ assert.equal(uc.normalizeChannel(undefined), 'patch');
55
+ });
56
+
57
+ // ---- channelAllows ---------------------------------------------------------
58
+
59
+ t('channelAllows: patch stays within x.y', () => {
60
+ assert.equal(uc.channelAllows('1.17.1', '1.17.2', 'patch'), true);
61
+ assert.equal(uc.channelAllows('1.17.1', '1.17.10', 'patch'), true);
62
+ assert.equal(uc.channelAllows('1.17.1', '1.18.0', 'patch'), false); // minor bump not allowed on patch
63
+ assert.equal(uc.channelAllows('1.17.1', '2.0.0', 'patch'), false);
64
+ });
65
+
66
+ t('channelAllows: minor allows higher minor within the major, not a major', () => {
67
+ assert.equal(uc.channelAllows('1.17.1', '1.18.0', 'minor'), true);
68
+ assert.equal(uc.channelAllows('1.17.1', '1.17.5', 'minor'), true);
69
+ assert.equal(uc.channelAllows('1.17.1', '2.0.0', 'minor'), false); // major never automatic
70
+ });
71
+
72
+ t('channelAllows: pinned never; not-newer never; prerelease never; unknown installed never', () => {
73
+ assert.equal(uc.channelAllows('1.17.1', '1.99.99', 'pinned'), false);
74
+ assert.equal(uc.channelAllows('1.17.2', '1.17.1', 'patch'), false); // older
75
+ assert.equal(uc.channelAllows('1.17.1', '1.17.1', 'patch'), false); // same
76
+ assert.equal(uc.channelAllows('1.17.1', '1.17.2-rc.1', 'patch'), false); // prerelease target
77
+ assert.equal(uc.channelAllows(null, '1.17.2', 'patch'), false); // unknown installed
78
+ assert.equal(uc.channelAllows('1.17.1', 'garbage', 'patch'), false);
79
+ });
80
+
81
+ // ---- resolveChannelTarget --------------------------------------------------
82
+
83
+ const AVAIL = ['1.16.9', '1.17.0', '1.17.1', '1.17.2', '1.17.3', '1.18.0', '1.18.1', '2.0.0', '1.17.4-rc.1'];
84
+
85
+ t('resolveChannelTarget: patch → highest patch on the same minor', () => {
86
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'patch', available: AVAIL }), '1.17.3');
87
+ });
88
+
89
+ t('resolveChannelTarget: minor → highest minor within the major (not 2.0.0)', () => {
90
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'minor', available: AVAIL }), '1.18.1');
91
+ });
92
+
93
+ t('resolveChannelTarget: pinned → null', () => {
94
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'pinned', available: AVAIL }), null);
95
+ });
96
+
97
+ t('resolveChannelTarget: already newest → null; prerelease never chosen', () => {
98
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.3', channel: 'patch', available: AVAIL }), null);
99
+ // A prerelease ahead (1.17.4-rc.1) is NOT a target even though it's numerically newer.
100
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.3', channel: 'minor', available: ['1.17.4-rc.1'] }), null);
101
+ });
102
+
103
+ t('resolveChannelTarget: unknown installed / empty list → null (never guesses)', () => {
104
+ assert.equal(uc.resolveChannelTarget({ installed: null, channel: 'patch', available: AVAIL }), null);
105
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'patch', available: [] }), null);
106
+ assert.equal(uc.resolveChannelTarget({}), null);
107
+ });
108
+
109
+ // ---- parseNpmViewVersions --------------------------------------------------
110
+
111
+ t('parseNpmViewVersions: array, bare string, empty, malformed', () => {
112
+ assert.deepEqual(uc.parseNpmViewVersions('["1.0.0","1.1.0"]'), { ok: true, versions: ['1.0.0', '1.1.0'] });
113
+ assert.deepEqual(uc.parseNpmViewVersions('"1.0.0"'), { ok: true, versions: ['1.0.0'] });
114
+ assert.deepEqual(uc.parseNpmViewVersions(' '), { ok: true, versions: [] });
115
+ const bad = uc.parseNpmViewVersions('not json');
116
+ assert.equal(bad.ok, false);
117
+ });
118
+
119
+ // ---- listAvailableVersions (injected runner) -------------------------------
120
+
121
+ t('listAvailableVersions: ok path parses stdout; non-zero exit is fail-soft', () => {
122
+ const ok = uc.listAvailableVersions({ run: () => ({ status: 0, stdout: '["1.17.1","1.17.2"]' }) });
123
+ assert.deepEqual(ok, { ok: true, versions: ['1.17.1', '1.17.2'] });
124
+
125
+ const fail = uc.listAvailableVersions({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) });
126
+ assert.equal(fail.ok, false);
127
+ assert.match(fail.error, /E404/);
128
+
129
+ const threw = uc.listAvailableVersions({ run: () => { throw new Error('spawn ENOENT'); } });
130
+ assert.equal(threw.ok, false);
131
+ assert.match(threw.error, /ENOENT/);
132
+ });
133
+
134
+ // ---- loadSubscriptions -----------------------------------------------------
135
+
136
+ function scratchWith(obj) {
137
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-'));
138
+ const cfgDir = join(dir, 'config');
139
+ require('node:fs').mkdirSync(cfgDir, { recursive: true });
140
+ writeFileSync(join(cfgDir, 'update-subscriptions.json'), typeof obj === 'string' ? obj : JSON.stringify(obj));
141
+ return dir;
142
+ }
143
+
144
+ t('loadSubscriptions: missing file → empty roster, present:false, no throw', () => {
145
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-none-'));
146
+ const r = uc.loadSubscriptions({ instanceDir: dir });
147
+ assert.deepEqual(r.instances, []);
148
+ assert.equal(r.present, false);
149
+ assert.equal(r.defaultChannel, 'patch');
150
+ });
151
+
152
+ t('loadSubscriptions: malformed JSON → empty + error, no throw', () => {
153
+ const dir = scratchWith('{ not valid');
154
+ const r = uc.loadSubscriptions({ instanceDir: dir });
155
+ assert.deepEqual(r.instances, []);
156
+ assert.match(r.error, /malformed JSON/);
157
+ });
158
+
159
+ t('loadSubscriptions: normalizes channel, keeps env, defaults from defaultChannel', () => {
160
+ const dir = scratchWith({
161
+ defaultChannel: 'minor',
162
+ instances: [
163
+ { slug: 'demo', dir: '/srv/demo', service: 'demo.service', healthUrl: 'https://demo/healthz', env: { PGDATABASE: 'demo', BAD: 5 } },
164
+ { slug: 'pinnedone', dir: '/srv/p', channel: 'PINNED' },
165
+ { slug: 'nochan', dir: '/srv/n' },
166
+ ],
167
+ });
168
+ const r = uc.loadSubscriptions({ instanceDir: dir });
169
+ assert.equal(r.defaultChannel, 'minor');
170
+ assert.equal(r.instances.length, 3);
171
+ const demo = r.instances.find((i) => i.slug === 'demo');
172
+ assert.equal(demo.channel, 'minor'); // inherited from defaultChannel
173
+ assert.deepEqual(demo.env, { PGDATABASE: 'demo' }); // non-string env value dropped
174
+ assert.equal(r.instances.find((i) => i.slug === 'pinnedone').channel, 'pinned');
175
+ assert.equal(r.instances.find((i) => i.slug === 'nochan').channel, 'minor');
176
+ });
177
+
178
+ t('loadSubscriptions: an entry without a dir is dropped (cannot be upgraded)', () => {
179
+ const dir = scratchWith({ instances: [{ slug: 'ghost' }, { slug: 'real', dir: '/srv/real' }] });
180
+ const r = uc.loadSubscriptions({ instanceDir: dir });
181
+ assert.equal(r.instances.length, 1);
182
+ assert.equal(r.instances[0].slug, 'real');
183
+ });
184
+
185
+ console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
186
+ process.exit(failed ? 1 : 0);
package/tests/upgrade.mjs CHANGED
@@ -416,6 +416,137 @@ t('runUpgrade --registry (full): pins the exact version + writes the env-fed .np
416
416
  assert.ok(!calls.some((c) => /vendor/.test(c)), 'did not vendor a tarball');
417
417
  });
418
418
 
419
+ // ---- 9. auto-rollback on failure (task 2149) --------------------------------
420
+
421
+ // A pin-aware spawn shim: `npm install` installs whatever the CURRENT pin resolves to, so the
422
+ // forward install lands the target and a post-rollback install restores the previous core.
423
+ // Knobs simulate a failing target install or a failing migrate.
424
+ function pinAwareRun(calls, { dir, failMigrate = false, failInstallForVersion = null } = {}) {
425
+ return (cmd, args) => {
426
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
427
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
428
+ if (cmd === 'npm' && args[0] === 'install') {
429
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
430
+ const dep = pkg.dependencies['@bongos/core'];
431
+ const v = u.versionFromTgzPath(dep) || u.versionFromDep(dep);
432
+ if (failInstallForVersion && v === failInstallForVersion) return { status: 1 };
433
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
434
+ JSON.stringify({ name: '@bongos/core', version: v }) + '\n');
435
+ return { status: 0 };
436
+ }
437
+ if (cmd === 'npm' && args[0] === 'run' && args[1] === 'migrate') return { status: failMigrate ? 1 : 0 };
438
+ return { status: 0 }; // systemctl / restart-cmd / anything else
439
+ };
440
+ }
441
+
442
+ // A fake pg that records every ledger INSERT's params (params[3] = note).
443
+ function recordingPg(queries) {
444
+ return { Pool: class { async query(sql, params) { queries.push({ sql, params }); } async end() {} } };
445
+ }
446
+
447
+ t('rollback on health failure: reverts pin, restarts again, re-checks health, writes ONE rolled_back ledger row', async () => {
448
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
449
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
450
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
451
+ const calls = [], queries = [];
452
+ let fetchN = 0;
453
+ const res = await u.runUpgrade(
454
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
455
+ {
456
+ log: () => {}, err: () => {},
457
+ run: pinAwareRun(calls, { dir }),
458
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
459
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
460
+ // forward health fails (1st fetch); the post-rollback re-check recovers (2nd fetch)
461
+ fetch: async () => { fetchN++; return fetchN === 1 ? { ok: false, status: 503 } : { ok: true, status: 200 }; },
462
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
463
+ }
464
+ );
465
+ assert.equal(res.ok, false, 'a failed health check is not a success');
466
+ assert.equal(res.rolledBack, true, 'the upgrade rolled back');
467
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted to the previous core');
468
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
469
+ assert.equal(res.rollback.healthOk, true, 'health recovered after the rollback restart');
470
+ assert.equal(calls.filter((c) => c === 'systemctl restart demo.service').length, 2, 'restarted twice: forward + rollback');
471
+ assert.equal(queries.length, 1, 'only the rolled_back row is written (no phantom upgraded row)');
472
+ assert.equal(queries[0].params[1], '1.15.0', 'to_version = the attempted target');
473
+ assert.match(queries[0].params[3], /^rolled_back:/, 'note marks the reversal');
474
+ });
475
+
476
+ t('rollback on migrate failure: reverts pin + reinstalls previous core + rolled_back ledger', async () => {
477
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
478
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
479
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
480
+ const calls = [], queries = [];
481
+ const res = await u.runUpgrade(
482
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service' },
483
+ {
484
+ log: () => {}, err: () => {},
485
+ run: pinAwareRun(calls, { dir, failMigrate: true }),
486
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
487
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
488
+ }
489
+ );
490
+ assert.equal(res.ok, false);
491
+ assert.equal(res.rolledBack, true);
492
+ assert.match(res.rollback.reason, /migrate failed/);
493
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted');
494
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
495
+ assert.equal(queries.length, 1);
496
+ assert.match(queries[0].params[3], /^rolled_back: migrate failed/);
497
+ });
498
+
499
+ t('rollback on npm-install failure: reverts pin + reinstalls previous core', async () => {
500
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
501
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
502
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
503
+ const calls = [], queries = [];
504
+ const res = await u.runUpgrade(
505
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service' },
506
+ {
507
+ log: () => {}, err: () => {},
508
+ run: pinAwareRun(calls, { dir, failInstallForVersion: '1.15.0' }),
509
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
510
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
511
+ }
512
+ );
513
+ assert.equal(res.ok, false);
514
+ assert.equal(res.rolledBack, true);
515
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted after the failed install');
516
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
517
+ assert.match(queries[0].params[3], /^rolled_back: npm install failed/);
518
+ });
519
+
520
+ t('--no-rollback-on-failure: a failed health check does NOT revert (records the attempt, leaves the new pin)', async () => {
521
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
522
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
523
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
524
+ const queries = [];
525
+ const res = await u.runUpgrade(
526
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz', rollbackOnFailure: false },
527
+ {
528
+ log: () => {}, err: () => {},
529
+ run: pinAwareRun([], { dir }),
530
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
531
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
532
+ fetch: async () => ({ ok: false, status: 503 }), sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
533
+ }
534
+ );
535
+ assert.equal(res.ok, false);
536
+ assert.ok(!res.rolledBack, 'no rollback when opted out');
537
+ assert.equal(u.readPinnedCoreVersion(dir), '1.15.0', 'the new pin is left in place for inspection');
538
+ assert.equal(queries.length, 1, 'the attempt is still recorded');
539
+ assert.ok(!/^rolled_back/.test(queries[0].params[3] || ''), 'not marked as a rollback');
540
+ });
541
+
542
+ t('restorePin: writes a previous dep string back verbatim (file: or registry)', () => {
543
+ const dir = scratchConsumer({ pinned: '1.15.0' });
544
+ u.restorePin(dir, 'file:vendor/bongos-core-1.14.1.tgz');
545
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1');
546
+ u.restorePin(dir, '^1.16.0');
547
+ assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'], '^1.16.0');
548
+ });
549
+
419
550
  await Promise.all(pending);
420
551
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
421
552
  process.exit(failed ? 1 : 0);