@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.
- package/.bongos-core.json +244 -264
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-exit/SKILL.md +2 -2
- package/.claude/skills/builder-redteam/SKILL.md +3 -3
- package/.claude/skills/builder-setup/SKILL.md +2 -2
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/dev/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +2 -2
- package/.claude/skills/ideate/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +3 -0
- package/.claude/skills/paint/SKILL.md +1 -1
- package/GOVERNANCE.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0001-stack-choice-single-vps.md +1 -1
- package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
- package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
- package/docs/adr/0030-builders-hall-live-updates.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
- package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
- package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
- package/docs/adr/0057-container-cost-ledger.md +3 -3
- package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
- package/docs/adr/0096-require-reward-before-workable.md +9 -9
- package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
- package/docs/adr/0100-two-repo-instance-model.md +1 -1
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
- package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
- package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
- package/docs/adr/README.md +9 -6
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +5 -4
- package/docs/canonical-permissions.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +18 -17
- package/docs/handoff-template.md +2 -2
- package/docs/modules-contract.md +6 -1
- package/docs/onboarding/diagrams/README.md +7 -7
- package/docs/onboarding/drachmae-and-karma.md +18 -18
- package/docs/onboarding/primer.md +9 -9
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/builder-box-lifecycle.md +2 -2
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/ops-gotchas.md +1 -1
- package/docs/recipes/private-npm-distribution.md +17 -0
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/upgrading-the-core.md +55 -0
- package/docs/research/cursor-competitive-analysis.md +1 -1
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
- package/modules/discord/module.json +2 -1
- package/modules/economy/achievements.js +9 -1
- package/modules/economy/credits.js +10 -1
- package/modules/hall-ui/public/builders.js +71 -4
- package/modules/hall-ui/public/goal-inbox.js +191 -0
- package/modules/hall-ui/public/hall-widgets.js +14 -1
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/public/new-project-wizard.js +1 -0
- package/modules/hall-ui/public/style.css +43 -0
- package/modules/provisioning/CLAUDE.md +15 -0
- package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
- package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
- package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
- package/modules/provisioning/module.json +17 -0
- package/modules/provisioning/onboard-plan.js +37 -0
- package/modules/provisioning/provisioning.js +539 -0
- package/modules/provisioning/rate-limit.js +35 -0
- package/modules/provisioning/routes/provisioning.js +607 -0
- package/modules/provisioning/tests/provisioning.mjs +232 -0
- package/modules/provisioning/ui/.gitkeep +0 -0
- package/modules/security/secret-scrub.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +7 -2
- package/scripts/discord/README.md +1 -1
- package/scripts/gds/claim.js +98 -48
- package/scripts/gds/claude-materialize.js +30 -9
- package/scripts/gds/cutover-codemod.js +241 -0
- package/scripts/gds/fitness.js +54 -0
- package/scripts/gds/init.js +59 -3
- package/scripts/gds/lint-infra-exec.js +70 -0
- package/scripts/gds/lint-workflow-exec.js +63 -0
- package/scripts/gds/mirror-lineage-check.js +14 -5
- package/scripts/gds/mirror-redact.js +8 -0
- package/scripts/gds/onboard.js +4 -0
- package/scripts/gds/package-core.js +18 -3
- package/scripts/gds/publish-manifest.js +60 -2
- package/scripts/gds/run-routine.js +51 -13
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/update-channel.js +186 -0
- package/scripts/gds/upgrade.js +116 -9
- package/src/bongos/auth.js +14 -1
- package/src/bongos/serve-internal.js +10 -1
- package/src/branding.js +1 -0
- package/src/module-api.js +12 -1
- package/src/module-loader/loader.js +36 -4
- package/src/module-loader/manifest-schema.js +16 -1
- package/tests/branding.mjs +11 -0
- package/tests/claim_error_surface.mjs +107 -0
- package/tests/claude_materialize.mjs +20 -0
- package/tests/consumer_layout_boot.mjs +302 -0
- package/tests/cutover_codemod.mjs +184 -0
- package/tests/device_flow_start.mjs +25 -2
- package/tests/hook_core_resolve.mjs +125 -0
- package/tests/infra_exec_paths.mjs +104 -0
- package/tests/init.mjs +47 -0
- package/tests/mirror_lineage_proof.mjs +14 -0
- package/tests/module_loader.mjs +42 -0
- package/tests/module_manifest.mjs +19 -0
- package/tests/onboard.mjs +13 -0
- package/tests/package_core.mjs +8 -0
- package/tests/provisioning_github_app_routes.mjs +15 -0
- package/tests/publish_manifest.mjs +52 -1
- package/tests/run_routine.mjs +26 -1
- package/tests/run_routine_two_root.mjs +77 -0
- package/tests/scrubber_corpus.mjs +17 -0
- package/tests/update_channel.mjs +186 -0
- package/tests/upgrade.mjs +131 -0
- package/tests/workflow_exec_paths.mjs +98 -0
- package/docs/design/container-exterior-tiles.md +0 -49
- package/docs/design/container-exterior.md +0 -81
- package/docs/design/container-interior.md +0 -77
- package/docs/design/world-map-scroll.md +0 -41
- package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
- package/docs/recipes/cloudbongos-staging-gate.md +0 -139
- package/docs/recipes/cloudbongos-standup.md +0 -111
- package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
- package/docs/recipes/parallel-load-test.md +0 -32
- package/docs/recipes/pixel-art-pipeline.md +0 -411
- package/docs/recipes/staging-data.md +0 -50
- package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
- package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
- package/migrations/instance/110_medusa_planning_seed.sql +0 -70
- package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
- package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
- package/scripts/gds/art-stage.js +0 -152
- package/server.js +0 -212
- package/tests/art_key.mjs +0 -121
- package/tests/day_cycle.mjs +0 -197
- package/tests/snapshot_picker.mjs +0 -233
- package/tests/sprite_facing.mjs +0 -35
- package/tests/terrain_picker.mjs +0 -298
- package/tests/turn_in_place.mjs +0 -280
- 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);
|
package/tests/run_routine.mjs
CHANGED
|
@@ -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);
|