@bongos/core 1.19.1031 → 1.19.1033
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 +167 -57
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grader-health/SKILL.md +1 -1
- package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +26 -0
- package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
- package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
- package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +119 -0
- package/docs/adr/README.md +4 -3
- package/docs/architecture.md +1 -1
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +3 -1
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/redteam-patrol.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +57 -9
- package/modules/hall-ui/records/builders-roster.md +10 -0
- package/modules/hall-ui/records/catalog.md +10 -0
- package/modules/hall-ui/records/collaboration.md +14 -0
- package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
- package/modules/hall-ui/records/oversight-family.md +19 -0
- package/modules/hall-ui/records/panel-family.md +19 -0
- package/modules/hall-ui/records/reading-rooms.md +12 -0
- package/modules/hall-ui/records/record-family.md +13 -0
- package/modules/hall-ui/records/reference-room.md +9 -0
- package/modules/hall-ui/records/roster-and-ledger.md +9 -0
- package/modules/hall-ui/records/sky.md +23 -0
- package/modules/lifecycle/db-claim-reads.js +8 -2
- package/modules/lifecycle/dependency-advisory.js +2 -1
- package/modules/lifecycle/ship-preflight.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +7411 -0
- package/scripts/gds/address-park-sweep.sh +3 -1
- package/scripts/gds/autobongos-run.js +81 -1
- package/scripts/gds/baseline-staleness.js +1 -1
- package/scripts/gds/db-backup-nightly.sh +9 -4
- package/scripts/gds/db-backup-offsite.sh +4 -2
- package/scripts/gds/package-core.js +72 -1
- package/scripts/gds/provision-core-upgrade.js +128 -77
- package/scripts/gds/redteam-patrol.js +4 -4
- package/scripts/gds/release-notes.js +271 -0
- package/scripts/gds/seed-board-room-tasks.js +1 -1
- package/scripts/gds/ship-flow.js +5 -3
- package/scripts/gds/ship-land.js +86 -7
- package/scripts/gds/ship-preflight-steps.js +56 -3
- package/scripts/gds/stray-unit-sweep.sh +3 -1
- package/scripts/gds/upgrade-migrate.js +67 -0
- package/scripts/gds/upgrade.js +6 -4
- package/src/module-api.js +1 -1
- package/tests/adr_0162_citation.mjs +184 -0
- package/tests/autobongos_loop.mjs +114 -0
- package/tests/autonomy_gauge.mjs +56 -0
- package/tests/core_upgrade_runner.mjs +89 -1
- package/tests/db_backup_nightly.mjs +21 -3
- package/tests/db_backup_offsite.mjs +81 -0
- package/tests/grade_diff_hash.mjs +77 -2
- package/tests/hall_records.mjs +85 -0
- package/tests/helpers.mjs +25 -0
- package/tests/instance_db_migrated_on_code_move.mjs +8 -1
- package/tests/plain_cards.mjs +1 -1
- package/tests/redteam_patrol.mjs +1 -1
- package/tests/release_notes.mjs +327 -0
- package/tests/shell_stat_portability.mjs +49 -0
- package/tests/ship_land_conflict.mjs +181 -0
- package/tests/ship_preflight.mjs +160 -3
- package/tests/sweep_app_account.mjs +15 -2
- package/tests/upgrade_migrate.mjs +195 -0
package/tests/ship_preflight.mjs
CHANGED
|
@@ -12,9 +12,12 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
|
-
const {
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const {
|
|
16
|
+
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan,
|
|
17
|
+
untrackedPaths, untrackedLeftBehind, untrackedWarningLines,
|
|
18
|
+
} = require('../modules/lifecycle/ship-preflight.js');
|
|
19
|
+
// task 1001436: the preflight step itself + the ship-input resolver it is handed.
|
|
20
|
+
const { preflight, shipInputPaths } = require('../scripts/gds/ship-preflight-steps.js');
|
|
18
21
|
|
|
19
22
|
const tests = [];
|
|
20
23
|
const test = (name, fn) => tests.push({ name, fn });
|
|
@@ -352,6 +355,160 @@ test('closeoutPlan: stashLabel embeds the task id', () => {
|
|
|
352
355
|
assert.equal(closeoutPlan('?? x\n', 1234).stashLabel, 'otb-postship-1234');
|
|
353
356
|
});
|
|
354
357
|
|
|
358
|
+
// ---- the residual phantom ship: untracked files left behind (task 1001436) ----
|
|
359
|
+
// A branch with REAL committed work ships; a new file beside it that was never
|
|
360
|
+
// `git add`ed silently does not. The preflight now names those files — a warning,
|
|
361
|
+
// never a refusal (trackedDirty must keep ignoring scratch).
|
|
362
|
+
|
|
363
|
+
test('untrackedPaths: only ?? lines, in order, unquoted; tracked changes are not listed', () => {
|
|
364
|
+
const out = ' M src/a.js\n?? tests/new_case.mjs\nA src/b.js\n?? "docs/with space.md"\n';
|
|
365
|
+
assert.deepEqual(untrackedPaths(out), ['tests/new_case.mjs', 'docs/with space.md']);
|
|
366
|
+
assert.deepEqual(untrackedPaths(''), []);
|
|
367
|
+
assert.deepEqual(untrackedPaths(null), []);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('untrackedLeftBehind: nothing committed → [] (the empty-diff guard already refuses and lists them)', () => {
|
|
371
|
+
const out = '?? tests/new_case.mjs\n';
|
|
372
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 0 }), []);
|
|
373
|
+
assert.deepEqual(untrackedLeftBehind(out), [], 'default opts behave as "nothing committed"');
|
|
374
|
+
// ...and that empty case is exactly the one classifyEmptyShip refuses on:
|
|
375
|
+
assert.equal(classifyEmptyShip(out).reason, 'uncommitted-work');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('untrackedLeftBehind: committed work + an untracked file → that file is named', () => {
|
|
379
|
+
const out = ' M src/unrelated.js\n?? tests/new_case.mjs\n?? docs/adr/0999-x.md\n';
|
|
380
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 3 }), ['tests/new_case.mjs', 'docs/adr/0999-x.md']);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('untrackedLeftBehind: the ship\'s own input files are excluded, whatever their spelling', () => {
|
|
384
|
+
const out = '?? notes.md\n?? summary.txt\n?? shots/after.png\n?? tests/new_case.mjs\n';
|
|
385
|
+
const inputs = ['./notes.md', 'summary.txt', 'shots\\after.png'];
|
|
386
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 1, inputs }), ['tests/new_case.mjs']);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('untrackedWarningLines: [] when there is nothing to say', () => {
|
|
390
|
+
assert.deepEqual(untrackedWarningLines([]), []);
|
|
391
|
+
assert.deepEqual(untrackedWarningLines(null), []);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('untrackedWarningLines: names the count, the task, each file, and says it does not block', () => {
|
|
395
|
+
const text = untrackedWarningLines(['tests/new_case.mjs', 'docs/x.md'], { taskId: 1001436 }).join('\n');
|
|
396
|
+
assert.match(text, /2 untracked file\(s\) in this worktree will NOT ship with task 1001436/);
|
|
397
|
+
assert.match(text, /- tests\/new_case\.mjs/);
|
|
398
|
+
assert.match(text, /- docs\/x\.md/);
|
|
399
|
+
assert.match(text, /Warning only — not blocking; the ship proceeds/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('untrackedWarningLines: a long list is capped, with the remainder counted', () => {
|
|
403
|
+
const files = Array.from({ length: 17 }, (_, i) => `scratch/f${i}.txt`);
|
|
404
|
+
const lines = untrackedWarningLines(files, { cap: 15 });
|
|
405
|
+
assert.equal(lines.filter((l) => l.startsWith(' - ')).length, 15);
|
|
406
|
+
assert.ok(lines.includes(' … and 2 more'));
|
|
407
|
+
assert.match(lines.join('\n'), /17 untracked file\(s\)/);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('shipInputPaths: prose/visual flags resolve to repo-relative paths; stdin and out-of-tree paths add nothing', () => {
|
|
411
|
+
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-shipinputs-')));
|
|
412
|
+
const outside = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-shipinputs-out-')));
|
|
413
|
+
try {
|
|
414
|
+
fs.mkdirSync(path.join(root, 'sub'));
|
|
415
|
+
for (const f of ['notes.md', 'summary.txt', 'sub/shot.png', 'resp.md']) fs.writeFileSync(path.join(root, f), 'x');
|
|
416
|
+
fs.writeFileSync(path.join(outside, 'notes.md'), 'x');
|
|
417
|
+
const args = [
|
|
418
|
+
'1001436', '--notes-file', 'notes.md', '--summary-file=summary.txt',
|
|
419
|
+
'--visual', path.join(root, 'sub', 'shot.png'), '--response-file', '-',
|
|
420
|
+
];
|
|
421
|
+
assert.deepEqual(shipInputPaths(args, { cwd: root, repoRoot: root }), ['notes.md', 'summary.txt', 'sub/shot.png']);
|
|
422
|
+
// Run from a subdirectory: the relative flag value resolves against cwd, the result against the root.
|
|
423
|
+
assert.deepEqual(shipInputPaths(['1', '--response-file', '../resp.md'], { cwd: path.join(root, 'sub'), repoRoot: root }), ['resp.md']);
|
|
424
|
+
// A file outside the checkout cannot be an untracked file in it.
|
|
425
|
+
assert.deepEqual(shipInputPaths(['1', '--notes-file', path.join(outside, 'notes.md')], { cwd: root, repoRoot: root }), []);
|
|
426
|
+
// A flag with no value (last token) is ignored rather than resolved to the root.
|
|
427
|
+
assert.deepEqual(shipInputPaths(['1', '--notes-file'], { cwd: root, repoRoot: root }), []);
|
|
428
|
+
assert.deepEqual(shipInputPaths([], { cwd: root, repoRoot: root }), []);
|
|
429
|
+
} finally {
|
|
430
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
431
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// The behaviour, not just the helpers: run the REAL preflight() in a throwaway
|
|
436
|
+
// repo with a local "origin", and read what it prints. Two worlds: a branch
|
|
437
|
+
// carrying committed work (the warning must fire, minus inputs and ignored
|
|
438
|
+
// files), and a branch with nothing committed (it must stay silent — the
|
|
439
|
+
// empty-diff guard owns that case).
|
|
440
|
+
function withPreflightRepo(fn) {
|
|
441
|
+
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-untracked-')));
|
|
442
|
+
const origin = path.join(tmp, 'origin.git');
|
|
443
|
+
const work = path.join(tmp, 'work');
|
|
444
|
+
const run = (cmd, cwd = work) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
445
|
+
const prevCwd = process.cwd();
|
|
446
|
+
try {
|
|
447
|
+
execSync(`git init -q --bare -b main "${origin}"`);
|
|
448
|
+
execSync(`git clone -q "${origin}" "${work}"`, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
449
|
+
run('git config user.email t@t.t && git config user.name t && git config commit.gpgsign false');
|
|
450
|
+
run('git checkout -q -b main');
|
|
451
|
+
fs.writeFileSync(path.join(work, '.gitignore'), '*.log\n');
|
|
452
|
+
fs.writeFileSync(path.join(work, 'a.txt'), 'x');
|
|
453
|
+
run('git add -A && git commit -q -m init && git push -q origin main');
|
|
454
|
+
process.chdir(work);
|
|
455
|
+
return fn({ work, run });
|
|
456
|
+
} finally {
|
|
457
|
+
process.chdir(prevCwd);
|
|
458
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function capturePreflight(claim, opts) {
|
|
463
|
+
const out = [];
|
|
464
|
+
const err = [];
|
|
465
|
+
const log = console.log;
|
|
466
|
+
const error = console.error;
|
|
467
|
+
console.log = (...a) => out.push(a.join(' '));
|
|
468
|
+
console.error = (...a) => err.push(a.join(' '));
|
|
469
|
+
try {
|
|
470
|
+
return { result: preflight(claim, opts), out: out.join('\n'), err: err.join('\n') };
|
|
471
|
+
} finally {
|
|
472
|
+
console.log = log;
|
|
473
|
+
console.error = error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
test('preflight (real git): committed work + a forgotten new file → warns, still OK, inputs + ignored files unlisted', () => {
|
|
478
|
+
withPreflightRepo(({ work, run }) => {
|
|
479
|
+
run('git checkout -q -b feature');
|
|
480
|
+
fs.mkdirSync(path.join(work, 'src'));
|
|
481
|
+
fs.writeFileSync(path.join(work, 'src', 'fix.js'), 'module.exports = 1;\n');
|
|
482
|
+
run('git add src/fix.js && git commit -q -m "the committed half"');
|
|
483
|
+
fs.mkdirSync(path.join(work, 'tests'));
|
|
484
|
+
fs.writeFileSync(path.join(work, 'tests', 'forgot.mjs'), '// never git-added\n');
|
|
485
|
+
fs.writeFileSync(path.join(work, 'notes.md'), 'handoff notes\n');
|
|
486
|
+
fs.writeFileSync(path.join(work, 'debug.log'), 'ignored by .gitignore\n');
|
|
487
|
+
|
|
488
|
+
const { result, out, err } = capturePreflight({ task_id: 4242, head_sha: null }, { inputFiles: ['notes.md'] });
|
|
489
|
+
assert.equal(result.ok, true, 'a warning must never refuse the ship');
|
|
490
|
+
// A wholly-untracked directory is still named file by file (--untracked-files=all).
|
|
491
|
+
assert.deepEqual(result.untrackedLeftBehind, ['tests/forgot.mjs']);
|
|
492
|
+
assert.match(err, /1 untracked file\(s\) in this worktree will NOT ship with task 4242/);
|
|
493
|
+
assert.match(err, /- tests\/forgot\.mjs/);
|
|
494
|
+
assert.doesNotMatch(err, /notes\.md/, 'the ship\'s own --notes-file is not a deliverable');
|
|
495
|
+
assert.doesNotMatch(err, /debug\.log/, 'an ignored file is never listed');
|
|
496
|
+
assert.match(out, /preflight: tracked files clean \(1 untracked file\(s\) will not ship/);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('preflight (real git): nothing committed on the branch → no untracked warning', () => {
|
|
501
|
+
withPreflightRepo(({ work, run }) => {
|
|
502
|
+
run('git checkout -q -b feature');
|
|
503
|
+
fs.writeFileSync(path.join(work, 'forgot.md'), 'never git-added\n');
|
|
504
|
+
const { result, out, err } = capturePreflight({ task_id: 4243, head_sha: null }, {});
|
|
505
|
+
assert.equal(result.ok, true);
|
|
506
|
+
assert.deepEqual(result.untrackedLeftBehind, []);
|
|
507
|
+
assert.doesNotMatch(err, /Untracked-file warning/);
|
|
508
|
+
assert.match(out, /preflight: working tree clean/);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
355
512
|
// ---- runner ----
|
|
356
513
|
|
|
357
514
|
let pass = 0;
|
|
@@ -17,13 +17,17 @@ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
17
17
|
const skip = spawnSync('bash', ['-c', 'true']).error ? 'bash not on PATH' : false;
|
|
18
18
|
const slash = (p) => p.replace(/\\/g, '/');
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// `bsdStat` swaps in macOS's stat, which refuses GNU's `-c` outright and names an owner
|
|
21
|
+
// with `-f %Su` instead (task 1004170).
|
|
22
|
+
function stubs({ bsdStat = false } = {}) {
|
|
21
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-stubs-'));
|
|
22
24
|
const sudoLog = slash(path.join(dir, 'sudo.log'));
|
|
23
25
|
const statLog = slash(path.join(dir, 'stat.log'));
|
|
24
26
|
const bin = (name, body) => fs.writeFileSync(path.join(dir, name), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
|
|
25
27
|
bin('sudo', `echo "$*" >> "${sudoLog}"; exit 0`);
|
|
26
|
-
bin('stat', `echo "$*" >> "${statLog}";
|
|
28
|
+
bin('stat', `echo "$*" >> "${statLog}"; ` + (bsdStat
|
|
29
|
+
? 'case "$1 $2" in "-c "*) echo "stat: illegal option -- c" >&2; exit 1 ;; "-f %Su") echo checkout-owner ;; *) exit 1 ;; esac'
|
|
30
|
+
: 'echo checkout-owner'));
|
|
27
31
|
bin('systemctl', 'exit 0');
|
|
28
32
|
const lines = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : []);
|
|
29
33
|
return { dir, sudo: () => lines(sudoLog), stat: () => lines(statLog) };
|
|
@@ -109,4 +113,13 @@ for (const [name, src] of [['address-park-sweep', PARK], ['stray-unit-sweep', ST
|
|
|
109
113
|
assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`), 'the instance root, not the package');
|
|
110
114
|
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
111
115
|
});
|
|
116
|
+
|
|
117
|
+
test(`${name}: on macOS, where stat refuses GNU's -c, the checkout's owner still acts (task 1004170)`, { skip }, () => {
|
|
118
|
+
// It used to read '' as the owner there and refuse with "cannot use '' as the app account".
|
|
119
|
+
const s = stubs({ bsdStat: true });
|
|
120
|
+
const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
|
|
121
|
+
run(src, s, { INSTANCE_DIR: slash(inst) });
|
|
122
|
+
assert.deepEqual(s.stat().map((l) => l.split(' ').slice(0, 2).join(' ')), ['-c %U', '-f %Su'], 'GNU is asked first and BSD answers');
|
|
123
|
+
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
124
|
+
});
|
|
112
125
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/upgrade_migrate.mjs — `bongos upgrade` migrates an ADOPTED project that declares
|
|
2
|
+
// no migrate script, instead of rolling itself back (task 1004274; found preparing the
|
|
3
|
+
// task 1004047 catch-up pass).
|
|
4
|
+
//
|
|
5
|
+
// The catch-up pass could not reach mercury: an adopted Python/Flask project whose
|
|
6
|
+
// package.json is exactly what the adopt layer mints — name, private, dependencies, no
|
|
7
|
+
// scripts. upgrade.js migrated with `npm run migrate` only, so every upgrade of it died
|
|
8
|
+
// at step 4, after the pin had moved and the new core was installed, and rolled back —
|
|
9
|
+
// restarting a live app to change nothing — while the dry run reported nothing wrong.
|
|
10
|
+
//
|
|
11
|
+
// Pinned here: the fallback is the core's own migrate.sh, byte-identical to what a
|
|
12
|
+
// scaffold's script runs; a declared script still wins; the dry run says which one will
|
|
13
|
+
// run, on a line the Deploy page's pre-flight parser picks up; and the real command
|
|
14
|
+
// resolves the instance even when whoever launched the upgrade left a stray INIT_CWD.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/upgrade_migrate.mjs
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { migratePlan, CORE_MIGRATE_SCRIPT } = require('../scripts/gds/upgrade-migrate.js');
|
|
29
|
+
const u = require('../scripts/gds/upgrade.js');
|
|
30
|
+
const init = require('../scripts/gds/init.js');
|
|
31
|
+
const { parsePreflight } = require('../scripts/gds/provision-core-upgrade.js');
|
|
32
|
+
|
|
33
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const fixture = (prefix) => realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
|
|
35
|
+
|
|
36
|
+
// A project exactly as the adopt layer leaves one that had no package.json of its own —
|
|
37
|
+
// built BY that layer, so this cannot drift from what adoption really writes.
|
|
38
|
+
function adoptedProject({ installed = '1.19.312' } = {}) {
|
|
39
|
+
const dir = fixture('upgrade-migrate-adopted-');
|
|
40
|
+
const { pkg, action } = init.mergeCoreDependency(null, { coreRange: `file:vendor/bongos-core-${installed}.tgz`, name: 'mercury' });
|
|
41
|
+
assert.equal(action, 'created', 'fixture: the adopt layer minted the package.json');
|
|
42
|
+
assert.deepEqual(Object.keys(pkg).sort(), ['dependencies', 'name', 'private'], 'fixture: mercury\'s real shape — no scripts at all');
|
|
43
|
+
writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
44
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
45
|
+
mkdirSync(core, { recursive: true });
|
|
46
|
+
writeFileSync(path.join(core, 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: installed })}\n`);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scaffoldedProject(scripts = { migrate: init.INSTANCE_MIGRATE_SCRIPT, start: init.INSTANCE_START_SCRIPT }) {
|
|
51
|
+
const dir = fixture('upgrade-migrate-scaffold-');
|
|
52
|
+
writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify({ name: 'charter', private: true, scripts, dependencies: { '@bongos/core': '1.19.871' } }, null, 2)}\n`);
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Records every spawn as "cmd args…" plus its options. The pre-flight's database
|
|
57
|
+
// resolver is answered with a name (as the real one answers on a provisioned box), and
|
|
58
|
+
// `npm install` flips the installed core to `installTo` — all a successful install needs
|
|
59
|
+
// to look like here.
|
|
60
|
+
function recordingRun(calls, { dir, installTo, db = 'mercury' } = {}) {
|
|
61
|
+
return (cmd, args = [], opts = {}) => {
|
|
62
|
+
calls.push({ line: `${cmd} ${args.join(' ')}`.trim(), opts });
|
|
63
|
+
if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
|
|
64
|
+
if (cmd === process.execPath && String(args[1] || '').includes('instanceDbName')) return { status: 0, stdout: db };
|
|
65
|
+
if (cmd === 'npm' && args[0] === 'install' && installTo) {
|
|
66
|
+
writeFileSync(path.join(dir, 'node_modules', '@bongos', 'core', 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: installTo })}\n`);
|
|
67
|
+
}
|
|
68
|
+
return { status: 0, stdout: '' };
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('the fallback IS the scaffold\'s own migrate script, byte for byte', () => {
|
|
73
|
+
assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, init.INSTANCE_MIGRATE_SCRIPT,
|
|
74
|
+
'an adopted project must be migrated by exactly the command a greenfield scaffold declares');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a project with no migrate script is migrated by the core\'s migrate.sh, from its own root', () => {
|
|
78
|
+
const dir = adoptedProject();
|
|
79
|
+
const plan = migratePlan(dir, { env: { PATH: '/usr/bin', PGDATABASE: 'mercury', INIT_CWD: '/somewhere/else' } });
|
|
80
|
+
assert.equal(plan.via, 'core-script');
|
|
81
|
+
assert.equal(plan.cmd, 'bash');
|
|
82
|
+
assert.deepEqual(plan.args, [CORE_MIGRATE_SCRIPT]);
|
|
83
|
+
assert.equal(plan.env.INIT_CWD, dir, 'INIT_CWD is what npm run would set — the project, never an inherited stray');
|
|
84
|
+
assert.equal(plan.env.PGDATABASE, 'mercury', 'the database the runner named still reaches migrate.sh');
|
|
85
|
+
assert.match(plan.note, /declares no migrate script/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('scripts without a migrate entry, or a blank one, also fall back', () => {
|
|
89
|
+
assert.equal(migratePlan(scaffoldedProject({ start: 'node app.js' })).via, 'core-script');
|
|
90
|
+
assert.equal(migratePlan(scaffoldedProject({ migrate: ' ' })).via, 'core-script');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a declared migrate script still wins, whatever it says', () => {
|
|
94
|
+
for (const migrate of [init.INSTANCE_MIGRATE_SCRIPT, 'node scripts/own-migrate.js']) {
|
|
95
|
+
const plan = migratePlan(scaffoldedProject({ migrate }));
|
|
96
|
+
assert.equal(plan.via, 'npm-script');
|
|
97
|
+
assert.deepEqual([plan.cmd, ...plan.args], ['npm', 'run', 'migrate']);
|
|
98
|
+
assert.equal(plan.env, null, 'npm sets INIT_CWD itself, so nothing is overridden');
|
|
99
|
+
assert.equal(plan.note, null, 'the ordinary case adds no pre-flight line');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a missing or unparseable package.json keeps the old command rather than guessing', () => {
|
|
104
|
+
assert.equal(migratePlan(fixture('upgrade-migrate-empty-')).via, 'npm-script');
|
|
105
|
+
const bad = fixture('upgrade-migrate-bad-');
|
|
106
|
+
writeFileSync(path.join(bad, 'package.json'), '{ not json');
|
|
107
|
+
assert.equal(migratePlan(bad).via, 'npm-script');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('runMigrate spawns the plan: npm on a scaffold, the core script on an adopted project', () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
u.runMigrate(scaffoldedProject(), recordingRun(calls));
|
|
113
|
+
const adopted = adoptedProject();
|
|
114
|
+
u.runMigrate(adopted, recordingRun(calls));
|
|
115
|
+
assert.equal(calls[0].line, 'npm run migrate');
|
|
116
|
+
assert.equal(calls[0].opts.env, undefined, 'the scaffold path inherits the environment exactly as before');
|
|
117
|
+
assert.equal(calls[1].line, `bash ${CORE_MIGRATE_SCRIPT}`);
|
|
118
|
+
assert.equal(calls[1].opts.cwd, adopted);
|
|
119
|
+
assert.equal(calls[1].opts.env.INIT_CWD, adopted);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a full registry upgrade of an adopted project migrates it instead of rolling back', async () => {
|
|
123
|
+
const dir = adoptedProject({ installed: '1.19.312' });
|
|
124
|
+
const calls = [];
|
|
125
|
+
const res = await u.runUpgrade(
|
|
126
|
+
{ to: '1.19.1029', registry: true, instance: dir, service: 'mercury', healthCheck: false },
|
|
127
|
+
{
|
|
128
|
+
log: () => {}, err: () => {},
|
|
129
|
+
run: recordingRun(calls, { dir, installTo: '1.19.1029' }),
|
|
130
|
+
materialize: () => ({ skills: 0 }),
|
|
131
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
132
|
+
databaseUrl: '', pgDatabase: '',
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
const lines = calls.map((c) => c.line);
|
|
136
|
+
assert.equal(res.ok, true, `the upgrade completes: ${res.error || ''}`);
|
|
137
|
+
assert.ok(!res.rolledBack, 'and was not rolled back');
|
|
138
|
+
assert.ok(lines.includes(`bash ${CORE_MIGRATE_SCRIPT}`), 'the schema moved with the code');
|
|
139
|
+
assert.ok(!lines.includes('npm run migrate'), 'the missing script was never asked for');
|
|
140
|
+
assert.ok(lines.indexOf(`bash ${CORE_MIGRATE_SCRIPT}`) < lines.indexOf('systemctl restart mercury'), 'and migrate still runs before the restart that makes the code live');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the dry run names the fallback on a line the Deploy page\'s pre-flight shows', async () => {
|
|
144
|
+
const dir = adoptedProject();
|
|
145
|
+
const out = [];
|
|
146
|
+
const res = await u.runUpgrade(
|
|
147
|
+
{ to: '1.19.1029', registry: true, instance: dir, dryRun: true },
|
|
148
|
+
{ log: (m) => out.push(m), err: () => {}, run: recordingRun([]) },
|
|
149
|
+
);
|
|
150
|
+
assert.equal(res.ok, true);
|
|
151
|
+
const text = out.join('\n');
|
|
152
|
+
assert.match(text, new RegExp(`would: npm install → bash ${CORE_MIGRATE_SCRIPT.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} →`));
|
|
153
|
+
const { checks } = parsePreflight(text);
|
|
154
|
+
assert.ok(checks.some((c) => /declares no migrate script/.test(c)), `the door's parser carries it: ${JSON.stringify(checks)}`);
|
|
155
|
+
assert.deepEqual(JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies,
|
|
156
|
+
{ '@bongos/core': 'file:vendor/bongos-core-1.19.312.tgz' }, 'and the dry run moved nothing');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the dry run of an ordinary scaffold is unchanged', async () => {
|
|
160
|
+
const dir = scaffoldedProject();
|
|
161
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
162
|
+
mkdirSync(core, { recursive: true });
|
|
163
|
+
writeFileSync(path.join(core, 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: '1.19.871' })}\n`);
|
|
164
|
+
const out = [];
|
|
165
|
+
await u.runUpgrade({ to: '1.19.1029', registry: true, instance: dir, dryRun: true }, { log: (m) => out.push(m), err: () => {}, run: recordingRun([]) });
|
|
166
|
+
const text = out.join('\n');
|
|
167
|
+
assert.match(text, /would: npm install → npm run migrate →/);
|
|
168
|
+
assert.doesNotMatch(text, /declares no migrate script/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The real migrate.sh, through its DB-free --print-roots seam (tests/migrate_root_resolution.mjs):
|
|
172
|
+
// the fallback must find the PROJECT, not the core package and not a stray INIT_CWD.
|
|
173
|
+
test('the real fallback command resolves the project as its instance root, despite a stray INIT_CWD', (t) => {
|
|
174
|
+
const dir = adoptedProject();
|
|
175
|
+
writeFileSync(path.join(dir, 'bongos.config.json'), JSON.stringify({ instanceRoot: '.' }));
|
|
176
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
177
|
+
// Swap the stub for the real core, so the script that runs is the one that ships.
|
|
178
|
+
execFileSync(process.execPath, ['-e', 'require("fs").rmSync(process.argv[1], { recursive: true, force: true })', core]);
|
|
179
|
+
try { symlinkSync(REPO_ROOT, core, 'dir'); } catch (e) {
|
|
180
|
+
if (e.code === 'EPERM' || e.code === 'EACCES') return t.skip('this platform refuses symlinks without elevation');
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
const stray = fixture('upgrade-migrate-stray-');
|
|
184
|
+
const base = { ...process.env, INIT_CWD: stray };
|
|
185
|
+
for (const k of Object.keys(base)) if (/_INSTANCE_ROOT$/.test(k)) delete base[k];
|
|
186
|
+
const rootsOf = (out) => Object.fromEntries(out.split('\n').map((l) => /^(INVOKE_CWD|CORE_ROOT|INSTANCE_ROOT)=(.*)$/.exec(l)).filter(Boolean).map((m) => [m[1], m[2]]));
|
|
187
|
+
|
|
188
|
+
const plan = migratePlan(dir, { env: base });
|
|
189
|
+
const roots = rootsOf(execFileSync(plan.cmd, [...plan.args, '--print-roots'], { cwd: dir, env: plan.env, encoding: 'utf8' }));
|
|
190
|
+
assert.equal(roots.INSTANCE_ROOT, dir, 'migrate.sh migrates THIS project');
|
|
191
|
+
|
|
192
|
+
// And the override is load-bearing: the same script with the inherited value is misaimed.
|
|
193
|
+
const blind = rootsOf(execFileSync('bash', [CORE_MIGRATE_SCRIPT, '--print-roots'], { cwd: dir, env: base, encoding: 'utf8' }));
|
|
194
|
+
assert.notEqual(blind.INSTANCE_ROOT, dir, 'without INIT_CWD pinned, a stray value decides which project is migrated');
|
|
195
|
+
});
|