@bongos/core 1.19.932 → 1.19.933
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 +14 -14
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +48 -5
- package/scripts/gds/upgrade.js +9 -4
- package/src/module-api.js +1 -1
- package/tests/update_subscription_engine.mjs +67 -5
- package/tests/upgrade.mjs +52 -0
- package/tests/upgrade_persist_pin.mjs +97 -3
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.933",
|
|
6
|
+
"core_contract": "1.19.933",
|
|
7
|
+
"source_commit": "3ad7b40118fcad3d4ac54b7c18e2ccc36d3e7a6d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-21T02:
|
|
9
|
+
"built_at": "2026-09-21T02:57:49.721Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 511,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2862,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "0c42a78bb3f237ab0facaf9fee094236c11c19543f7a3deb08d9616db322b27f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2677,7 +2677,7 @@
|
|
|
2677
2677
|
{
|
|
2678
2678
|
"path": "docs/module-api-changelog.md",
|
|
2679
2679
|
"mode": "0000644",
|
|
2680
|
-
"sha256": "
|
|
2680
|
+
"sha256": "5c21da8442aac7c6bc744635d1e082b89e321f25743bcc0b7d6dbc659a7b7f31"
|
|
2681
2681
|
},
|
|
2682
2682
|
{
|
|
2683
2683
|
"path": "docs/modules-contract.md",
|
|
@@ -7967,12 +7967,12 @@
|
|
|
7967
7967
|
{
|
|
7968
7968
|
"path": "package-lock.json",
|
|
7969
7969
|
"mode": "0000644",
|
|
7970
|
-
"sha256": "
|
|
7970
|
+
"sha256": "23de484d5a08f0dc84ce22fb8d7671a3efbd0273ce878c1a63d6848fc1796273"
|
|
7971
7971
|
},
|
|
7972
7972
|
{
|
|
7973
7973
|
"path": "package.json",
|
|
7974
7974
|
"mode": "0000644",
|
|
7975
|
-
"sha256": "
|
|
7975
|
+
"sha256": "0a74e65c4330c8d5b855b5bc751fea858b493130d5c205f49d213bfe674a46b7"
|
|
7976
7976
|
},
|
|
7977
7977
|
{
|
|
7978
7978
|
"path": "public-docs/index.html",
|
|
@@ -8322,7 +8322,7 @@
|
|
|
8322
8322
|
{
|
|
8323
8323
|
"path": "scripts/gds/claude-materialize.js",
|
|
8324
8324
|
"mode": "0000644",
|
|
8325
|
-
"sha256": "
|
|
8325
|
+
"sha256": "2209cc2ca1476efc0af464e6e58ae87c5a5dc8d5a70b2ad067867adbfe0aab06"
|
|
8326
8326
|
},
|
|
8327
8327
|
{
|
|
8328
8328
|
"path": "scripts/gds/claude-md-template.js",
|
|
@@ -9522,7 +9522,7 @@
|
|
|
9522
9522
|
{
|
|
9523
9523
|
"path": "scripts/gds/upgrade.js",
|
|
9524
9524
|
"mode": "0000644",
|
|
9525
|
-
"sha256": "
|
|
9525
|
+
"sha256": "d736a08f303447e3889c478b0a22d6e9ae96e326f75bd57bead6199ffc23f1c5"
|
|
9526
9526
|
},
|
|
9527
9527
|
{
|
|
9528
9528
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -9972,7 +9972,7 @@
|
|
|
9972
9972
|
{
|
|
9973
9973
|
"path": "src/module-api.js",
|
|
9974
9974
|
"mode": "0000644",
|
|
9975
|
-
"sha256": "
|
|
9975
|
+
"sha256": "3870b0f3400bed8195ac80fa450e8e1f10d5b4f559f46b131db7b633669fc95f"
|
|
9976
9976
|
},
|
|
9977
9977
|
{
|
|
9978
9978
|
"path": "src/module-loader/catalog.js",
|
|
@@ -14167,17 +14167,17 @@
|
|
|
14167
14167
|
{
|
|
14168
14168
|
"path": "tests/update_subscription_engine.mjs",
|
|
14169
14169
|
"mode": "0000644",
|
|
14170
|
-
"sha256": "
|
|
14170
|
+
"sha256": "a083174381f10d1cd19795b43580a3113c4f758e4b98da1f7c8e389429cb6a26"
|
|
14171
14171
|
},
|
|
14172
14172
|
{
|
|
14173
14173
|
"path": "tests/upgrade.mjs",
|
|
14174
14174
|
"mode": "0000644",
|
|
14175
|
-
"sha256": "
|
|
14175
|
+
"sha256": "8bd1c116b5459b54a847208c01669159414fed17b3704d621e1dc5acfb00f255"
|
|
14176
14176
|
},
|
|
14177
14177
|
{
|
|
14178
14178
|
"path": "tests/upgrade_persist_pin.mjs",
|
|
14179
14179
|
"mode": "0000644",
|
|
14180
|
-
"sha256": "
|
|
14180
|
+
"sha256": "47698d59e0f728fee112bc55989a2271293007e4fba2b3447a0d2e4a3a93401a"
|
|
14181
14181
|
},
|
|
14182
14182
|
{
|
|
14183
14183
|
"path": "tests/verify_task_guard.mjs",
|
|
@@ -2338,5 +2338,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2338
2338
|
landed since 1.19.930 with no explicit bump. run 35551146764. (task 1002620)
|
|
2339
2339
|
1.19.932 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2340
2340
|
landed since 1.19.931 with no explicit bump. run 35555080510. (task 1002620)
|
|
2341
|
+
1.19.933 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2342
|
+
landed since 1.19.932 with no explicit bump. run 35555853466. (task 1002620)
|
|
2341
2343
|
---------------------------------------------------------------------------
|
|
2342
2344
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.933",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.933",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.933",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -32,7 +32,7 @@ const path = require('node:path');
|
|
|
32
32
|
const { spawnSync } = require('node:child_process');
|
|
33
33
|
const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
|
|
34
34
|
const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
|
|
35
|
-
const { isCorePackageName } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
|
|
35
|
+
const { isCorePackageName, CORE_PACKAGE_NAMES } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
|
|
36
36
|
const { renderInstanceClaudeMd } = require('./claude-md-template'); // task 2104 — the starter CLAUDE.md
|
|
37
37
|
|
|
38
38
|
// script basename (e.g. "ship.js") → dedicated bongos verb, for verbs that map to a bare script with
|
|
@@ -630,11 +630,19 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
630
630
|
summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
|
|
631
631
|
}
|
|
632
632
|
// Record what landed, so the NEXT run can withdraw what this one placed.
|
|
633
|
-
|
|
633
|
+
//
|
|
634
|
+
// REPORTED on a dry run, written only on a real one — the same split the starter CLAUDE.md
|
|
635
|
+
// below already uses. writtenPaths is a "what this run rewrites" list, and a dry run's job is
|
|
636
|
+
// to preview exactly that: upgrade.js asks for one to decide which dirty paths its clean-tree
|
|
637
|
+
// pre-flight may ignore (task 1004122), so a generated file missing from the preview is a file
|
|
638
|
+
// the pre-flight halts on and the real run then rewrites anyway.
|
|
639
|
+
if (overwrite) {
|
|
634
640
|
const names = Object.keys(landedNow).sort();
|
|
635
641
|
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
|
|
636
|
-
|
|
637
|
-
|
|
642
|
+
if (!dryRun) {
|
|
643
|
+
fs.mkdirSync(skillsDest, { recursive: true });
|
|
644
|
+
fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
645
|
+
}
|
|
638
646
|
addWritten(path.join('.claude/skills', INSTANCE_SKILLS_MANIFEST));
|
|
639
647
|
}
|
|
640
648
|
|
|
@@ -741,6 +749,41 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
741
749
|
return summary;
|
|
742
750
|
}
|
|
743
751
|
|
|
752
|
+
// materializeWillRewrite — the instance-relative paths a materialize WOULD rewrite here, changing
|
|
753
|
+
// nothing. `bongos upgrade` asks before it starts, to decide which dirty paths its clean-tree
|
|
754
|
+
// pre-flight may waive.
|
|
755
|
+
//
|
|
756
|
+
// THE WEDGE THIS EXISTS FOR (task 1004122). The upgrade's materialize step rewrites `.claude/`
|
|
757
|
+
// from the new core on EVERY bump, so after an upgrade that did not commit it — one from before
|
|
758
|
+
// task 1003957, or any run without --commit-pin — the instance carries ~40 modified
|
|
759
|
+
// `.claude/skills/*` files that nobody edited. upgrade.js's clean-tree pre-flight then read them
|
|
760
|
+
// as a human's uncommitted work and refused, so the instance took exactly one upgrade and stuck:
|
|
761
|
+
// `test4` (instance 11) failed its Deploy-page upgrade this way, and provision-core-upgrade.js
|
|
762
|
+
// passes --commit-pin but NOT --force, so the button stayed dead until someone committed by hand.
|
|
763
|
+
//
|
|
764
|
+
// Asking materializeClaude what it WOULD write is a real provenance answer, not a path
|
|
765
|
+
// allow-list: the set is derived from the core actually installed here, and every path in it
|
|
766
|
+
// is one the materialize step overwrites unconditionally a few seconds later. Whatever those files hold right
|
|
767
|
+
// now is discarded either way, so refusing on them protects nothing and stops every future
|
|
768
|
+
// update. A path materialize would NOT touch — an owner's own skill, a dirty migration, a
|
|
769
|
+
// half-finished edit — is absent from the set and still halts the bump, which is the whole
|
|
770
|
+
// safety property. Under --commit-pin the same paths come back from that step as persistPin's
|
|
771
|
+
// artifactPaths and are committed, so the tree ends clean and the next upgrade needs none of this.
|
|
772
|
+
//
|
|
773
|
+
// Probed against the core on disk NOW, not the target: the target is not installed yet, and the
|
|
774
|
+
// two ship the same `.claude/` surface. Fail-soft — a materialize that throws (no core
|
|
775
|
+
// installed, an unreadable tree) returns nothing and the pre-flight is as strict as it ever was.
|
|
776
|
+
function materializeWillRewrite({ instanceDir, coreRoot, materialize = materializeClaude }, err = console.error) {
|
|
777
|
+
try {
|
|
778
|
+
const root = coreRoot || path.join(instanceDir, 'node_modules', CORE_PACKAGE_NAMES[0]);
|
|
779
|
+
const sum = materialize({ coreRoot: root, instanceDir, dryRun: true, log: () => {} });
|
|
780
|
+
return (sum && Array.isArray(sum.writtenPaths)) ? sum.writtenPaths : [];
|
|
781
|
+
} catch (e) {
|
|
782
|
+
err(` ! could not preview the .claude/ materialization (${e && e.message ? e.message : e}) — pre-flight will treat every dirty path as a human's work.`);
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
744
787
|
// ---- the dev checkout: module-owned skills materialised INTO the core (task 1003330, ADR 0224) ----
|
|
745
788
|
// materializeClaude's self-copy guard is right for init/upgrade (instance == core is a no-op),
|
|
746
789
|
// but Claude Code discovers only .claude/skills/ at the project root, so a builder working IN
|
|
@@ -850,4 +893,4 @@ if (require.main === module) {
|
|
|
850
893
|
process.exit(s.refused.length ? 1 : 0);
|
|
851
894
|
}
|
|
852
895
|
|
|
853
|
-
module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
896
|
+
module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeWillRewrite, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -129,11 +129,13 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
|
|
|
129
129
|
|
|
130
130
|
// ---- steps (each injectable for tests) --------------------------------------
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
// `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump.
|
|
133
|
+
function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
|
|
133
134
|
const r = run('git', ['status', '--porcelain'], { cwd: instanceDir, encoding: 'utf8' });
|
|
134
135
|
if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
|
|
135
|
-
const
|
|
136
|
-
|
|
136
|
+
const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
|
|
137
|
+
const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
|
|
138
|
+
return { ok: !kept.length, dirty: kept.join('\n'), ignored: all.length - kept.length };
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
// ---- persisting the pin (task 1002712) --------------------------------------
|
|
@@ -1114,8 +1116,11 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1114
1116
|
|
|
1115
1117
|
// 1. pre-flight: clean tree (so the pin change is reviewable + revertable) + module compatibility.
|
|
1116
1118
|
if (!opts.force) {
|
|
1117
|
-
|
|
1119
|
+
// Step 5's own output is not dirt (task 1004122) — but only when step 5 is going to run.
|
|
1120
|
+
const preview = opts.skipMaterialize ? () => [] : (deps.willRewrite || require('./claude-materialize').materializeWillRewrite);
|
|
1121
|
+
const tree = gitTreeClean(instanceDir, run, preview({ instanceDir, materialize }, err));
|
|
1118
1122
|
if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || tree.reason || ''}` };
|
|
1123
|
+
if (tree.ignored) log(` • pre-flight: ignored ${tree.ignored} dirty path(s) this upgrade rewrites from the core (.claude/ materialization)${opts.commitPin ? ' — they are committed with the pin' : ' — pass --commit-pin to commit them'}.`);
|
|
1119
1124
|
}
|
|
1120
1125
|
const mods = preflightModules({ instanceDir, targetVersion }, deps);
|
|
1121
1126
|
log(` ✓ pre-flight: ${mods.note}`);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.933'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -356,12 +356,74 @@ test('every commitPin call site in the lane decides about push', () => {
|
|
|
356
356
|
}
|
|
357
357
|
});
|
|
358
358
|
|
|
359
|
-
test('the direct-upgrade fallback
|
|
359
|
+
test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
|
|
360
360
|
const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
361
|
+
// The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
|
|
362
|
+
// the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
|
|
363
|
+
// wrong about how, and a test that quotes an expression can only ever confirm it is still there.
|
|
364
|
+
assert.match(src, /upArgs\.push\(\.\.\.pinFlagsFor\(\{ pinMode, canCommitPin, canPinNoPush \}\)\)/,
|
|
365
|
+
'the fallback decides its pin flags inline again, where no test can reach them');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// 8b. pinFlagsFor — the fallback's pin policy, and the cross-file contract (task 1004122)
|
|
370
|
+
//
|
|
371
|
+
// go-live.js is the PREFERRED engine and the fallback must not apply a weaker policy just
|
|
372
|
+
// because an instance's core could not support it. It did: the fallback withheld --commit-pin
|
|
373
|
+
// on `local-commit`, so upgrade.js left the `.claude/` it had just rewritten uncommitted, the
|
|
374
|
+
// lane's own net can stage nothing but PIN_FILES, and the next sweep halted on those files
|
|
375
|
+
// forever. The instance took exactly one upgrade and then needed a human.
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
test('pinFlagsFor: local-commit commits the bump WITHOUT pushing — the fix for the one-upgrade wedge', () => {
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: true }),
|
|
381
|
+
['--commit-pin', '--pin-no-push'],
|
|
382
|
+
'upgrade.js commits the pin AND the .claude/ it materialized, and pushes nothing');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('pinFlagsFor: push keeps --commit-pin alone, leave-dirty still commits nothing', () => {
|
|
386
|
+
assert.deepEqual(S.pinFlagsFor({ pinMode: 'push', canCommitPin: true, canPinNoPush: true }), ['--commit-pin']);
|
|
387
|
+
// --pin-no-push WITHOUT --commit-pin: nothing is committed, and upgrade.js's pull-deploy alarm
|
|
388
|
+
// drops to a note, because on this target no reset is coming to revert the bump.
|
|
389
|
+
assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: true }), ['--pin-no-push']);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('pinFlagsFor: an old core that lacks --pin-no-push gets NOTHING, never the lone --commit-pin', () => {
|
|
393
|
+
// parseArgs silently ignores an unknown flag, so the pair would degrade to a bare --commit-pin
|
|
394
|
+
// and push the bump into the CUSTOMER's repo. Fail closed: keep today's behaviour instead.
|
|
395
|
+
assert.deepEqual(S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: false }), []);
|
|
396
|
+
assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: false }), []);
|
|
397
|
+
// …and a core with no --commit-pin at all is never asked to commit.
|
|
398
|
+
for (const mode of ['push', 'local-commit', 'leave-dirty']) {
|
|
399
|
+
assert.ok(!S.pinFlagsFor({ pinMode: mode, canCommitPin: false, canPinNoPush: true }).includes('--commit-pin'), `mode ${mode}`);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('pinFlagsFor mirrors go-live.js upgradeCommand() mode for mode', () => {
|
|
404
|
+
// The cross-file contract, pinned at the source in the same way GO_LIVE_REQUIRED is: go-live
|
|
405
|
+
// builds a command STRING on a box, so the two can only be compared through its source.
|
|
406
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
|
|
407
|
+
const body = src.slice(src.indexOf('function upgradeCommand('));
|
|
408
|
+
const commitPinModes = /\.\.\.\(mode === 'leave-dirty' \? \[\] : \['--commit-pin'\]\)/.test(body);
|
|
409
|
+
const noPushModes = /\.\.\.\(mode === 'push' \? \[\] : \['--pin-no-push'\]\)/.test(body);
|
|
410
|
+
assert.ok(commitPinModes && noPushModes, 'go-live.js upgradeCommand() moved — re-derive pinFlagsFor from it');
|
|
411
|
+
for (const mode of ['push', 'local-commit', 'leave-dirty']) {
|
|
412
|
+
const goLive = [
|
|
413
|
+
...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
|
|
414
|
+
...(mode === 'push' ? [] : ['--pin-no-push']),
|
|
415
|
+
];
|
|
416
|
+
assert.deepEqual(
|
|
417
|
+
S.pinFlagsFor({ pinMode: mode, canCommitPin: true, canPinNoPush: true }), goLive,
|
|
418
|
+
`mode ${mode}: the fallback applies a different pin policy from the engine it stands in for`);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('supportsPinNoPush probes the file, and is fail-closed on anything it cannot read', () => {
|
|
423
|
+
assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin --pin-no-push'), true);
|
|
424
|
+
assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin'), false, 'a core that predates the flag');
|
|
425
|
+
assert.equal(S.supportsPinNoPush(null, () => '--pin-no-push'), false);
|
|
426
|
+
assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => { throw new Error('ENOENT'); }), false);
|
|
365
427
|
});
|
|
366
428
|
|
|
367
429
|
// ---------------------------------------------------------------------------
|
package/tests/upgrade.mjs
CHANGED
|
@@ -171,6 +171,58 @@ t('guard: dirty tree refuses (unless --force)', async () => {
|
|
|
171
171
|
assert.match(res.error, /not clean/);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
// task 1004122 — the pre-flight must not refuse on what step 5 is about to overwrite anyway.
|
|
175
|
+
// An instance upgraded before task 1003957 carries ~40 modified `.claude/skills/*` nobody
|
|
176
|
+
// edited, and provision-core-upgrade.js (the Deploy page) passes --commit-pin but NOT --force,
|
|
177
|
+
// so the button stayed dead forever. The waiver is scoped to what a dry-run materialize on THIS
|
|
178
|
+
// instance says it rewrites; anything else still halts.
|
|
179
|
+
t('guard: a dirty tree that is ONLY this upgrade\'s own .claude/ materialization proceeds', async () => {
|
|
180
|
+
const src = mkdtempSync(join(tmpdir(), 'src-'));
|
|
181
|
+
writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
|
|
182
|
+
const dir = scratchConsumer();
|
|
183
|
+
const dryRuns = [];
|
|
184
|
+
const res = await u.runUpgrade(
|
|
185
|
+
{ to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, healthCheck: false, skipMigrate: true },
|
|
186
|
+
{
|
|
187
|
+
log: () => {}, err: () => {},
|
|
188
|
+
run: fakeRun([], { dirty: ' M .claude/skills/builder-end/SKILL.md\n M .claude/settings.json', installTo: '1.15.0', dir }),
|
|
189
|
+
materialize: (a) => { if (a.dryRun) dryRuns.push(a); return { skills: 2, writtenPaths: ['.claude/skills/builder-end/SKILL.md', '.claude/settings.json'] }; },
|
|
190
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
191
|
+
databaseUrl: '', pgDatabase: '',
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
assert.equal(res.ok, true, 'the bump runs, and step 5 rewrites those same files');
|
|
195
|
+
assert.equal(dryRuns.length, 1, 'the pre-flight previewed the materialization exactly once');
|
|
196
|
+
assert.equal(dryRuns[0].instanceDir, dir);
|
|
197
|
+
assert.ok(dryRuns[0].coreRoot.endsWith(join('node_modules', '@bongos', 'core')), 'against the core the instance has NOW — the target is not installed yet');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
t('guard: a dirty tree with anything ELSE in it still refuses', async () => {
|
|
201
|
+
const dir = scratchConsumer();
|
|
202
|
+
const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
|
|
203
|
+
log: () => {}, err: () => {},
|
|
204
|
+
run: fakeRun([], { dirty: ' M .claude/settings.json\n M migrations/core_253_wip.sql' }),
|
|
205
|
+
materialize: () => ({ skills: 1, writtenPaths: ['.claude/settings.json'] }),
|
|
206
|
+
});
|
|
207
|
+
assert.equal(res.ok, false);
|
|
208
|
+
assert.match(res.error, /not clean/);
|
|
209
|
+
assert.match(res.error, /migrations\/core_253_wip\.sql/, 'and it names the path that is genuinely a human\'s');
|
|
210
|
+
assert.ok(!res.error.includes('.claude/settings.json'), 'without re-listing the half it waived');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
t('guard: --skip-materialize waives nothing — step 5 rewrites nothing, so every dirty path is real', async () => {
|
|
214
|
+
const dir = scratchConsumer();
|
|
215
|
+
let asked = 0;
|
|
216
|
+
const res = await u.runUpgrade({ to: '1.15.0', instance: dir, skipMaterialize: true }, {
|
|
217
|
+
log: () => {}, err: () => {},
|
|
218
|
+
run: fakeRun([], { dirty: ' M .claude/settings.json' }),
|
|
219
|
+
materialize: () => { asked += 1; return { skills: 1, writtenPaths: ['.claude/settings.json'] }; },
|
|
220
|
+
});
|
|
221
|
+
assert.equal(res.ok, false);
|
|
222
|
+
assert.match(res.error, /not clean/);
|
|
223
|
+
assert.equal(asked, 0, 'and materialize was never even asked');
|
|
224
|
+
});
|
|
225
|
+
|
|
174
226
|
t('guard: already on the target refuses (unless --force)', async () => {
|
|
175
227
|
const dir = scratchConsumer({ installed: '1.15.0' });
|
|
176
228
|
const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, { log: () => {}, err: () => {}, run: fakeRun([], { dirty: '' }) });
|
|
@@ -365,11 +365,10 @@ test('real git: an owner edit outside the generated files is never swept into th
|
|
|
365
365
|
// when anything but the pin is dirty, which is simultaneously the `failed=1` on a successful
|
|
366
366
|
// upgrade and the next sweep's permanent halt.
|
|
367
367
|
|
|
368
|
-
const { materializeClaude } = require('../scripts/gds/claude-materialize.js');
|
|
368
|
+
const { materializeClaude, materializeWillRewrite } = require('../scripts/gds/claude-materialize.js');
|
|
369
369
|
|
|
370
370
|
// A minimal core package: just the .claude/ tree materialize copies out of it.
|
|
371
|
-
function
|
|
372
|
-
const dir = mkdtempSync(join(tmpdir(), 'upgrade-core-'));
|
|
371
|
+
function coreAt(dir, version) {
|
|
373
372
|
put(dir, 'package.json', JSON.stringify({ name: '@bongos/core', version }));
|
|
374
373
|
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
375
374
|
put(dir, '.claude/skills/builder-end/SKILL.md', `# builder-end (core ${version})\n`);
|
|
@@ -378,6 +377,13 @@ function scratchCore(version) {
|
|
|
378
377
|
put(dir, '.claude/hooks/stop.js', `// hook for ${version}\n`);
|
|
379
378
|
return dir;
|
|
380
379
|
}
|
|
380
|
+
function scratchCore(version) {
|
|
381
|
+
return coreAt(mkdtempSync(join(tmpdir(), 'upgrade-core-')), version);
|
|
382
|
+
}
|
|
383
|
+
// The same tree where an instance really carries it — what materializeWillRewrite probes.
|
|
384
|
+
function installCore(instanceDir, version) {
|
|
385
|
+
return coreAt(join(instanceDir, 'node_modules', '@bongos', 'core'), version);
|
|
386
|
+
}
|
|
381
387
|
|
|
382
388
|
// A scaffolded co-tenant that already TRACKS the .claude/ a previous materialize landed.
|
|
383
389
|
function scratchCoTenant(version = '1.19.737') {
|
|
@@ -390,6 +396,7 @@ function scratchCoTenant(version = '1.19.737') {
|
|
|
390
396
|
put(dir, 'package-lock.json', `lock ${version}\n`);
|
|
391
397
|
put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
|
|
392
398
|
put(dir, 'CLAUDE.md', 'the instance owns this\n');
|
|
399
|
+
put(dir, '.gitignore', 'node_modules/\n');
|
|
393
400
|
materializeClaude({ coreRoot: scratchCore(version), instanceDir: dir, log: quiet });
|
|
394
401
|
git(dir, 'add', '-A');
|
|
395
402
|
git(dir, 'commit', '-q', '-m', 'scaffold');
|
|
@@ -477,3 +484,90 @@ test('adopt mode records nothing, so `init --adopt` never commits over what it r
|
|
|
477
484
|
assert.ok(!sum.writtenPaths.includes('.claude/settings.json'));
|
|
478
485
|
assert.equal(readFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'utf8'), 'the adopting project already had this\n');
|
|
479
486
|
});
|
|
487
|
+
|
|
488
|
+
// ── the fleet that was ALREADY wedged (task 1004122) ────────────────────────────
|
|
489
|
+
//
|
|
490
|
+
// Task 1003957 above stops an upgrade CREATING the dirt. It does nothing for the instances
|
|
491
|
+
// that were upgraded before it shipped: every one of them still carries ~40 modified
|
|
492
|
+
// `.claude/skills/*` files nobody edited, and the clean-tree pre-flight refuses on them. So
|
|
493
|
+
// `test4` (instance 11) failed its Deploy-page upgrade on 2026-09-20 — provision-core-upgrade.js
|
|
494
|
+
// passes --commit-pin but NOT --force, so the button stays dead until someone commits by hand.
|
|
495
|
+
//
|
|
496
|
+
// The fix is that the pre-flight stops counting a path THIS upgrade is about to overwrite as
|
|
497
|
+
// evidence of anything. The set is not an allow-list: it is what a dry-run materialize against
|
|
498
|
+
// the core actually installed here says it would write.
|
|
499
|
+
|
|
500
|
+
test('gitTreeClean: an ignored path stops counting, and is reported rather than hidden', () => {
|
|
501
|
+
const dir = scratchCoTenant();
|
|
502
|
+
writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
|
|
503
|
+
assert.equal(gitTreeClean(dir).ok, false, 'by default it is dirt like any other');
|
|
504
|
+
const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
|
|
505
|
+
assert.equal(t.ok, true);
|
|
506
|
+
assert.equal(t.ignored, 1, 'and the caller can say how many it waived');
|
|
507
|
+
assert.equal(t.dirty, '');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('gitTreeClean: ignoring the materialized paths never waives anything else', () => {
|
|
511
|
+
const dir = scratchCoTenant();
|
|
512
|
+
writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
|
|
513
|
+
put(dir, 'config/branding.json', 'the owner was here\n');
|
|
514
|
+
const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
|
|
515
|
+
assert.equal(t.ok, false, 'the owner edit still halts the bump');
|
|
516
|
+
assert.match(t.dirty, /config\/branding\.json/);
|
|
517
|
+
assert.ok(!t.dirty.includes('builder-end'), 'and only the materialized half was waived');
|
|
518
|
+
assert.equal(t.ignored, 1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('materializeWillRewrite: the set comes from the core the instance actually has', () => {
|
|
522
|
+
const dir = scratchCoTenant();
|
|
523
|
+
installCore(dir, '1.19.785');
|
|
524
|
+
const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
|
|
525
|
+
assert.ok(paths.includes('.claude/skills/builder-end/SKILL.md'));
|
|
526
|
+
assert.ok(paths.includes('.claude/settings.json'));
|
|
527
|
+
assert.ok(paths.includes('.claude/hooks/stop.js'));
|
|
528
|
+
assert.ok(paths.includes('.claude/skills/.instance-skills.json'), 'the provenance manifest is rewritten every run too');
|
|
529
|
+
assert.ok(!paths.includes('CLAUDE.md'), 'a file materialize writes only when ABSENT is not rewritten — the instance owns it');
|
|
530
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'and previewing it changed nothing on disk');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('materializeWillRewrite: no core installed → no skill is waived, and a throw waives nothing at all', () => {
|
|
534
|
+
const dir = scratchCoTenant();
|
|
535
|
+
// Nothing to copy out of: materialize reports only the provenance record it rewrites anyway.
|
|
536
|
+
const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
|
|
537
|
+
assert.ok(!paths.some((p) => p.startsWith('.claude/skills/builder-')), 'no skill file is waived on the strength of a core that is not there');
|
|
538
|
+
|
|
539
|
+
// And an outright failure is fail-SOFT in the strict direction — an empty set plus a note.
|
|
540
|
+
const notes = [];
|
|
541
|
+
const boom = () => { throw new Error('unreadable core tree'); };
|
|
542
|
+
assert.deepEqual(materializeWillRewrite({ instanceDir: dir, materialize: boom }, (m) => notes.push(m)), []);
|
|
543
|
+
assert.equal(notes.length, 1, 'it says so rather than failing silently');
|
|
544
|
+
assert.match(notes[0], /unreadable core tree/);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('real git: an instance wedged by a pre-1003957 upgrade unwedges itself on the next bump', () => {
|
|
548
|
+
const dir = scratchCoTenant();
|
|
549
|
+
sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] }); // the upgrade that left the mess
|
|
550
|
+
installCore(dir, '1.19.785'); // …and the core it installed
|
|
551
|
+
assert.equal(gitTreeClean(dir).ok, false, 'today: the next `bongos upgrade` refuses here');
|
|
552
|
+
|
|
553
|
+
// What runUpgrade's pre-flight now does.
|
|
554
|
+
const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
|
|
555
|
+
assert.equal(gitTreeClean(dir, spawnSync, rewritable).ok, true, 'the bump is allowed to proceed');
|
|
556
|
+
|
|
557
|
+
// …and proceeding is what cleans it up: step 5 rewrites those same paths and persistPin commits them.
|
|
558
|
+
const { pin } = sweep(dir, '1.19.785', '1.19.786');
|
|
559
|
+
assert.equal(pin.committed, true);
|
|
560
|
+
assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'one bump, no human hands, tree clean');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('real git: a wedged instance with an owner edit beside the mess still refuses', () => {
|
|
564
|
+
const dir = scratchCoTenant();
|
|
565
|
+
sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] });
|
|
566
|
+
installCore(dir, '1.19.785');
|
|
567
|
+
put(dir, 'core_999_half_written.sql', '-- not finished\n');
|
|
568
|
+
const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
|
|
569
|
+
const t = gitTreeClean(dir, spawnSync, rewritable);
|
|
570
|
+
assert.equal(t.ok, false, 'a human really does need to look');
|
|
571
|
+
assert.match(t.dirty, /core_999_half_written\.sql/);
|
|
572
|
+
assert.ok(!t.dirty.includes('.claude/skills/builder-end'), 'the materialization half is still waived');
|
|
573
|
+
});
|