@bongos/core 1.21.124 → 1.21.126
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 +91 -41
- package/bin/bongos.js +9 -0
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/onboarding/howto-guide.js +259 -0
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/core-pin-sync.js +233 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/src/module-api.js +1 -1
- package/tests/core_pin_sync.mjs +237 -0
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/howto_guide.mjs +96 -0
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -67,10 +67,12 @@ const { callbackPage } = require('./callback-page'); const githubSetup = require
|
|
|
67
67
|
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
|
|
68
68
|
const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
|
|
69
69
|
const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
|
|
70
|
+
const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers'); // the owner-or-archon gate (task 1004159, ADR 0328)
|
|
70
71
|
// the kernel ports this module provides (moved out at the size ratchet, task 1003578)
|
|
71
72
|
const { registerProvisioningSeams } = require('../seams');
|
|
72
73
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
73
74
|
const log = api.logger('provisioning');
|
|
75
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
74
76
|
|
|
75
77
|
// How long a GitHub-App-Manifest `state` token is valid — the owner must click the
|
|
76
78
|
// pre-filled link + approve on GitHub within this window (task 2080). Comfortably
|
|
@@ -634,27 +636,18 @@ module.exports = function provisioningRoutes() {
|
|
|
634
636
|
}
|
|
635
637
|
});
|
|
636
638
|
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
// settings routes (task 1003040).
|
|
641
|
-
function instanceAccessDenied(inst, builder) {
|
|
642
|
-
if (!inst) return true;
|
|
643
|
-
if (builder.rank === 'archon') return false;
|
|
644
|
-
return String(inst.owner_builder_id) !== String(builder.id);
|
|
645
|
-
}
|
|
639
|
+
// Every own-scoped /provisioning/instances/:id route below mounts requireProjectOwner
|
|
640
|
+
// (../route-helpers.js): the owner, or an Archon on any project (fleet support), and the
|
|
641
|
+
// same 404 for everyone else — no existence leak. The row rides on req.project.
|
|
646
642
|
|
|
647
643
|
// GET /provisioning/instances/:id — read one instance (the manage page's read).
|
|
648
644
|
// rank: any authenticated builder (own resource; archon may read any — task
|
|
649
645
|
// 1003041). 404 otherwise — does not leak another owner's instance existence.
|
|
650
|
-
router.get('/provisioning/instances/:id', auth.requireBuilder, async (req, res) => {
|
|
646
|
+
router.get('/provisioning/instances/:id', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
651
647
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
652
648
|
if (!id) return;
|
|
653
649
|
try {
|
|
654
|
-
const inst =
|
|
655
|
-
if (instanceAccessDenied(inst, req.builder)) {
|
|
656
|
-
return res.fail('instance_not_found', 404);
|
|
657
|
-
}
|
|
650
|
+
const inst = req.project;
|
|
658
651
|
// SELF-HEAL THE CATALOG PROJECTION (task 1003581). ADR 0246's projection is
|
|
659
652
|
// write-triggered, so an instance older than it has no catalog row and its owner's
|
|
660
653
|
// invites 404 until they happen to save something. This own-scoped read — the manage
|
|
@@ -730,7 +723,7 @@ module.exports = function provisioningRoutes() {
|
|
|
730
723
|
// the answer moved moves back. Only null does it — an empty string or any other
|
|
731
724
|
// value is still refused as off-vocabulary, and `detail: {}` still changes nothing,
|
|
732
725
|
// because a form that collected no answers is not asking for any to be erased.
|
|
733
|
-
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
726
|
+
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
734
727
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
735
728
|
if (!id) return;
|
|
736
729
|
if (validateOrRespond(req, res, {
|
|
@@ -757,9 +750,8 @@ module.exports = function provisioningRoutes() {
|
|
|
757
750
|
message: 'The body carries nothing to change — send description, detail, modules, type, or any mix.' });
|
|
758
751
|
}
|
|
759
752
|
try {
|
|
760
|
-
const inst =
|
|
761
|
-
|
|
762
|
-
const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
753
|
+
const inst = req.project;
|
|
754
|
+
const actor = projectActor(req);
|
|
763
755
|
// An empty description is CLEARING it, not a no-op string: normalize here so
|
|
764
756
|
// the column never holds '' — a blank string would read as answered to a
|
|
765
757
|
// `description IS NOT NULL` publish check and put an empty husk on the map.
|
|
@@ -810,7 +802,7 @@ module.exports = function provisioningRoutes() {
|
|
|
810
802
|
// purpose: ranking builders is a directory read the pure engine may not run.
|
|
811
803
|
// This is the resolution, and ADR 0251 is why it looks like this.
|
|
812
804
|
//
|
|
813
|
-
// rank: any authenticated builder, own resource — `
|
|
805
|
+
// rank: any authenticated builder, own resource — `requireProjectOwner`, the
|
|
814
806
|
// same gate and the same 404 as the manage read above. That is deliberate and
|
|
815
807
|
// not a new authorization idea: REACHING this route IS the ownership answer
|
|
816
808
|
// (ADR 0249 §4), so the surface never re-derives "is this theirs" and can never
|
|
@@ -821,12 +813,9 @@ module.exports = function provisioningRoutes() {
|
|
|
821
813
|
// nobody on purpose (ADR 0239 §6), which is a different statement from an empty
|
|
822
814
|
// list nobody filled — so that answer returns its own reason and never touches
|
|
823
815
|
// the directory at all.
|
|
824
|
-
router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, async (req, res) => {
|
|
825
|
-
const id = parseId(req, res);
|
|
826
|
-
if (!id) return;
|
|
816
|
+
router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
827
817
|
try {
|
|
828
|
-
const inst =
|
|
829
|
-
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
818
|
+
const inst = req.project;
|
|
830
819
|
|
|
831
820
|
const detail = provisioning.projectDetail(inst) || {};
|
|
832
821
|
const { invites } = recommendationsForDetails({ type: inst.type, ...detail });
|
|
@@ -862,18 +851,15 @@ module.exports = function provisioningRoutes() {
|
|
|
862
851
|
// ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
|
|
863
852
|
// The foundation the manage-page wave hangs off: effective settings are the
|
|
864
853
|
// DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
|
|
865
|
-
// the instance's stored keys. Access:
|
|
854
|
+
// the instance's stored keys. Access: requireProjectOwner (own-scoped,
|
|
866
855
|
// Archon override, 404 otherwise — no existence leak).
|
|
867
856
|
|
|
868
857
|
// GET /provisioning/instances/:id/settings — read a project's effective
|
|
869
858
|
// settings + the allowed values (so a form can render its choices).
|
|
870
859
|
// rank: any authenticated builder (own resource; archon may read any).
|
|
871
|
-
router.get('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
872
|
-
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
873
|
-
if (!id) return;
|
|
860
|
+
router.get('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
874
861
|
try {
|
|
875
|
-
const inst =
|
|
876
|
-
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
862
|
+
const inst = req.project;
|
|
877
863
|
// `planet` rides beside `settings` (task 1003347): the same row, read by a
|
|
878
864
|
// different rule — no defaults, null = the platform's own pick.
|
|
879
865
|
res.json({ ok: true, settings: provisioning.effectiveSettings(inst), planet: provisioning.planetChoice(inst),
|
|
@@ -902,7 +888,7 @@ module.exports = function provisioningRoutes() {
|
|
|
902
888
|
// did not get carried (that 409, or an enqueue that threw) marks the push OWED,
|
|
903
889
|
// which is what makes the "send it again" both of those name actually re-queue
|
|
904
890
|
// instead of hitting the no-op "No change" branch (task 1003524).
|
|
905
|
-
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
891
|
+
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
906
892
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
907
893
|
if (!id) return;
|
|
908
894
|
// The schema IS the vocabularies, not a copy of them (task 1003352): strict
|
|
@@ -927,11 +913,10 @@ module.exports = function provisioningRoutes() {
|
|
|
927
913
|
message: `The body carries no settings to change — send any of: ${settingKeys.join(', ')}.` });
|
|
928
914
|
}
|
|
929
915
|
try {
|
|
930
|
-
const inst =
|
|
931
|
-
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
916
|
+
const inst = req.project;
|
|
932
917
|
// The archon override carries WHICH archon (the force-teardown convention) on
|
|
933
918
|
// both the event and the intent it may enqueue below.
|
|
934
|
-
const actor =
|
|
919
|
+
const actor = projectActor(req);
|
|
935
920
|
// A save that changes nothing writes nothing and restarts nothing — a
|
|
936
921
|
// whole-form save or a retried request must not bounce a live project. The
|
|
937
922
|
// one exception is the owner re-kick (the domain route's F19 shape): the same
|
|
@@ -983,32 +968,32 @@ module.exports = function provisioningRoutes() {
|
|
|
983
968
|
});
|
|
984
969
|
|
|
985
970
|
// POST /provisioning/instances/:id/teardown — request teardown of the caller's
|
|
986
|
-
// OWN instance. rank: any authenticated builder (own resource
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
971
|
+
// OWN instance. rank: any authenticated builder (own resource; archon may tear down
|
|
972
|
+
// any — the override is audited via the actor). Enqueues a 'teardown' intent; the
|
|
973
|
+
// runner executes it (web tier holds no tokens). 404 when the id isn't the caller's;
|
|
974
|
+
// 409 when the instance isn't in a teardownable state.
|
|
975
|
+
router.post('/provisioning/instances/:id/teardown', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
990
976
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
991
977
|
if (!id) return;
|
|
992
978
|
try {
|
|
993
|
-
const inst =
|
|
994
|
-
|
|
995
|
-
return res.fail('instance_not_found', 404);
|
|
996
|
-
}
|
|
979
|
+
const inst = req.project;
|
|
980
|
+
const actor = projectActor(req);
|
|
997
981
|
if (refusedAsTornDown(inst, res)) return;
|
|
998
982
|
// enqueueTeardown supersedes a stale pending provision intent and reports each
|
|
999
983
|
// outcome distinctly, so "already queued" is only ever said when it is TRUE
|
|
1000
984
|
// (task 1002691 — the old blind enqueue lied here and the instance leaked).
|
|
1001
|
-
const r = await provisioning.enqueueTeardown(pool, id,
|
|
985
|
+
const r = await provisioning.enqueueTeardown(pool, id, actor);
|
|
1002
986
|
if (r.conflict) {
|
|
1003
987
|
return res.fail('intent_conflict', { status: 409,
|
|
1004
988
|
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
1005
989
|
details: { action: r.intent.action, state: r.intent.state } });
|
|
1006
990
|
}
|
|
1007
991
|
if (r.created) {
|
|
992
|
+
const who = isProjectOwner(inst, req.builder) ? 'owner' : `archon ${req.builder.github_login || req.builder.id}`;
|
|
1008
993
|
await provisioning.recordEvent(pool, {
|
|
1009
|
-
instanceId: id, ownerBuilderId:
|
|
1010
|
-
detail: r.superseded ? '
|
|
1011
|
-
actor
|
|
994
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
|
|
995
|
+
detail: `teardown requested by ${who}${r.superseded ? ' (superseded a stale provision intent)' : ''}`,
|
|
996
|
+
actor,
|
|
1012
997
|
}).catch(() => {});
|
|
1013
998
|
}
|
|
1014
999
|
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
@@ -1034,14 +1019,13 @@ module.exports = function provisioningRoutes() {
|
|
|
1034
1019
|
// Every refusal names the action that IS available in that state rather than
|
|
1035
1020
|
// hiding behind a code, and no branch promises a duration — a restart's downtime
|
|
1036
1021
|
// is exactly what this route cannot know (the F16 rule, ADR 0190 §7).
|
|
1037
|
-
router.post('/provisioning/instances/:id/restart', auth.requireBuilder, async (req, res) => {
|
|
1022
|
+
router.post('/provisioning/instances/:id/restart', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
1038
1023
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
1039
1024
|
if (!id) return;
|
|
1040
1025
|
try {
|
|
1041
|
-
const inst =
|
|
1042
|
-
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
1026
|
+
const inst = req.project;
|
|
1043
1027
|
// The archon override carries WHICH archon (the force-teardown convention).
|
|
1044
|
-
const actor =
|
|
1028
|
+
const actor = projectActor(req);
|
|
1045
1029
|
if (!provisioning.RESTART_FROM.has(inst.status)) {
|
|
1046
1030
|
const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
|
|
1047
1031
|
return res.fail('not_restartable', { status: 409, details: { status: inst.status }, message: offline
|
|
@@ -1086,8 +1070,9 @@ module.exports = function provisioningRoutes() {
|
|
|
1086
1070
|
// (re)provision run wires the domain itself. A bring-your-own address may be
|
|
1087
1071
|
// RE-POINTED (task 1003237, ADR 0200): the same guards the first attach applies
|
|
1088
1072
|
// decide it, and the address it moved off is queued for release.
|
|
1089
|
-
// Body: { domain (required) }.
|
|
1090
|
-
|
|
1073
|
+
// Body: { domain (required) }. An archon may set any project's address; the
|
|
1074
|
+
// override is audited via the actor.
|
|
1075
|
+
router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, requireProjectOwner(loadInstance), async (req, res) => {
|
|
1091
1076
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
1092
1077
|
if (!id) return;
|
|
1093
1078
|
if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
|
|
@@ -1104,10 +1089,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1104
1089
|
message: `${domain} is a platform-reserved address — bring a domain of your own instead.`,
|
|
1105
1090
|
});
|
|
1106
1091
|
}
|
|
1107
|
-
const inst =
|
|
1108
|
-
|
|
1109
|
-
return res.fail('instance_not_found', 404);
|
|
1110
|
-
}
|
|
1092
|
+
const inst = req.project;
|
|
1093
|
+
const actor = projectActor(req);
|
|
1111
1094
|
// Same rule as create: inside the platform apex the only address an instance
|
|
1112
1095
|
// can hold is its own <slug>.<apex> — anything else is a squat (task 1002704).
|
|
1113
1096
|
const apexZone = provisioning.normalizeHostname(instanceApex());
|
|
@@ -1129,11 +1112,11 @@ module.exports = function provisioningRoutes() {
|
|
|
1129
1112
|
// idempotent no-op below.
|
|
1130
1113
|
const needsRewire = provisioning.ownsErrorNote(inst, 'domain-attach') || inst.last_verified_up === false;
|
|
1131
1114
|
if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
|
|
1132
|
-
const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach',
|
|
1115
|
+
const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', actor);
|
|
1133
1116
|
if (created) {
|
|
1134
1117
|
await provisioning.recordEvent(pool, {
|
|
1135
|
-
instanceId: id, ownerBuilderId:
|
|
1136
|
-
detail: `domain ${domain} re-attach queued (owner re-kick)`, actor
|
|
1118
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
|
|
1119
|
+
detail: `domain ${domain} re-attach queued (${isProjectOwner(inst, req.builder) ? 'owner' : 'archon'} re-kick)`, actor,
|
|
1137
1120
|
}).catch(() => {});
|
|
1138
1121
|
}
|
|
1139
1122
|
// created:false = a racing enqueue got there first — still truthful.
|
|
@@ -1176,7 +1159,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1176
1159
|
await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
|
|
1177
1160
|
let queued = false;
|
|
1178
1161
|
if (inst.status === 'active') {
|
|
1179
|
-
const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach',
|
|
1162
|
+
const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', actor);
|
|
1180
1163
|
if (!created && intent.action !== 'domain-attach') {
|
|
1181
1164
|
// The open slot is held by other work (e.g. a teardown). The address IS
|
|
1182
1165
|
// recorded — say exactly what happened and what to do.
|
|
@@ -1188,8 +1171,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1188
1171
|
if (!created) {
|
|
1189
1172
|
// an identical attach is already open — idempotent
|
|
1190
1173
|
await provisioning.recordEvent(pool, {
|
|
1191
|
-
instanceId: id, ownerBuilderId:
|
|
1192
|
-
detail: `domain ${domain} recorded (attach already queued)`, actor
|
|
1174
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
|
|
1175
|
+
detail: `domain ${domain} recorded (attach already queued)`, actor,
|
|
1193
1176
|
}).catch(() => {});
|
|
1194
1177
|
return res.json({ ok: true, changed: true, queued: false, domain, replaced: releasing,
|
|
1195
1178
|
message: 'That address is already being wired for this instance.' });
|
|
@@ -1197,9 +1180,9 @@ module.exports = function provisioningRoutes() {
|
|
|
1197
1180
|
}
|
|
1198
1181
|
const moved = releasing ? ` (moved off ${releasing}, queued for release)` : '';
|
|
1199
1182
|
await provisioning.recordEvent(pool, {
|
|
1200
|
-
instanceId: id, ownerBuilderId:
|
|
1183
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'domain',
|
|
1201
1184
|
detail: (queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`) + moved,
|
|
1202
|
-
actor
|
|
1185
|
+
actor,
|
|
1203
1186
|
}).catch(() => {});
|
|
1204
1187
|
// State-only sentences (task 1002720 / F16): the web tier holds no cloud
|
|
1205
1188
|
// tokens and cannot know whether a DNS hook exists on the runner, so "it
|
|
@@ -1225,7 +1208,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1225
1208
|
|
|
1226
1209
|
// POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding
|
|
1227
1210
|
// link for the caller's OWN instance (task 2080, ADR 0133). rank: any authenticated
|
|
1228
|
-
// builder (own resource). Returns the pre-filled manifest + the GitHub form-POST target
|
|
1211
|
+
// builder (own resource; archon may mint for any — audited via the actor). Returns the pre-filled manifest + the GitHub form-POST target
|
|
1229
1212
|
// (personal + org variants) + a one-time `state`; the browser POSTs the manifest to
|
|
1230
1213
|
// GitHub, the owner approves, and GitHub redirects back to the public callback below.
|
|
1231
1214
|
// The web tier only RECORDS the attempt (state token) — the control-plane runner does
|
|
@@ -1237,7 +1220,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1237
1220
|
// whether the founder may make an app there is GitHub's to enforce on its own page
|
|
1238
1221
|
// (this module holds no GitHub token to ask with, ADR 0111 §2). Anything other than
|
|
1239
1222
|
// account 'org' is the founder's personal account, exactly as before.
|
|
1240
|
-
router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, async (req, res) => {
|
|
1223
|
+
router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
1241
1224
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
1242
1225
|
if (!id) return;
|
|
1243
1226
|
if (validateOrRespond(req, res, { account: { type: 'string', maxLength: 12 }, org: { type: 'string', maxLength: 39 } })) return;
|
|
@@ -1247,10 +1230,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1247
1230
|
return res.fail('bad_org', { status: 400, message: 'That isn’t a GitHub organisation name: letters, numbers and single dashes, up to 39.' });
|
|
1248
1231
|
}
|
|
1249
1232
|
try {
|
|
1250
|
-
const inst =
|
|
1251
|
-
|
|
1252
|
-
return res.fail('instance_not_found', 404);
|
|
1253
|
-
}
|
|
1233
|
+
const inst = req.project;
|
|
1234
|
+
const actor = projectActor(req);
|
|
1254
1235
|
// A domain is required: it is the App's homepage + the sign-in callback host. No
|
|
1255
1236
|
// domain ⇒ there is nothing to point "Login with GitHub" at yet.
|
|
1256
1237
|
if (!inst.domain) {
|
|
@@ -1274,17 +1255,19 @@ module.exports = function provisioningRoutes() {
|
|
|
1274
1255
|
if (guard && guard.refuse) return res.fail(guard.refuse, { status: 409, message: guard.message });
|
|
1275
1256
|
const state = guard ? guard.reuse : crypto.randomBytes(32).toString('hex');
|
|
1276
1257
|
const expiresAt = guard ? guard.expiresAt : new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
|
|
1277
|
-
|
|
1258
|
+
// requested_by is read downstream as the project's owner (every later manifest event's
|
|
1259
|
+
// ownerBuilderId); an archon's mint is named by the actor on the event below instead
|
|
1260
|
+
if (!guard) await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: inst.owner_builder_id, expiresAt });
|
|
1278
1261
|
await provisioning.recordEvent(pool, {
|
|
1279
|
-
instanceId: id, ownerBuilderId:
|
|
1280
|
-
detail: guard ? 'GitHub App Manifest link reopened (same attempt)' : 'GitHub App Manifest link minted', actor
|
|
1262
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'oauth-manifest-start',
|
|
1263
|
+
detail: guard ? 'GitHub App Manifest link reopened (same attempt)' : 'GitHub App Manifest link minted', actor,
|
|
1281
1264
|
}).catch(() => {});
|
|
1282
1265
|
// which account owns this attempt's app, so the page can link its settings there
|
|
1283
1266
|
// from any tab or device (github-setup.js reads it back as app_org)
|
|
1284
1267
|
if (account === 'org') {
|
|
1285
1268
|
await provisioning.recordEvent(pool, {
|
|
1286
|
-
instanceId: id, ownerBuilderId:
|
|
1287
|
-
detail: orgLogin, actor
|
|
1269
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: githubSetup.SETUP_EVENTS.appOrg,
|
|
1270
|
+
detail: orgLogin, actor,
|
|
1288
1271
|
}).catch(() => {});
|
|
1289
1272
|
}
|
|
1290
1273
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.126",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.126",
|
|
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.21.
|
|
3
|
+
"version": "1.21.126",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9726,5 +9726,29 @@
|
|
|
9726
9726
|
"id": "1004633",
|
|
9727
9727
|
"text": "Found why a merged task on your own project stayed at 'confirmed': with no GitHub key set up on the project's server, Bongos could not prove the merge happened, and its background checker switched itself off. It now confirms t"
|
|
9728
9728
|
}
|
|
9729
|
+
],
|
|
9730
|
+
"1.21.125": [
|
|
9731
|
+
{
|
|
9732
|
+
"id": "1004460",
|
|
9733
|
+
"text": "Store modules' own tests can now run on GitHub's free test machines, away from the server holding our secrets. It switches on once the owner does three short GitHub setup steps."
|
|
9734
|
+
},
|
|
9735
|
+
{
|
|
9736
|
+
"id": "1004159",
|
|
9737
|
+
"text": "Project owners' controls now share one access check, and Archons can help with teardown, address and GitHub setup on any project — every such action is logged under the Archon's name."
|
|
9738
|
+
},
|
|
9739
|
+
{
|
|
9740
|
+
"id": "1004632",
|
|
9741
|
+
"text": "When a project updates its Bongos core, the next task's ship now uses the new core automatically instead of the old one, so it no longer fails for no visible reason."
|
|
9742
|
+
},
|
|
9743
|
+
{
|
|
9744
|
+
"id": "1004627",
|
|
9745
|
+
"text": "Bongos now has a 27-step plain-language how-to guide that an AI can walk a new person through inside their session, from where to start to their first ship."
|
|
9746
|
+
}
|
|
9747
|
+
],
|
|
9748
|
+
"1.21.126": [
|
|
9749
|
+
{
|
|
9750
|
+
"id": "1004738",
|
|
9751
|
+
"text": "A goal's page is much shorter now: task dependencies sit in a small card at the top where Conflicts used to be, and the full dependency picture and conflict list are one click away."
|
|
9752
|
+
}
|
|
9729
9753
|
]
|
|
9730
9754
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// scripts/gds/core-pin-sync.js — make `bongos ship` run the core the checkout PINS (task 1004632)
|
|
2
|
+
//
|
|
3
|
+
// WHY. An instance's task worktrees (`.claude/worktrees/task-N`) have no `node_modules`
|
|
4
|
+
// of their own: Node and `npx` walk up to the MAIN checkout's, and so does every
|
|
5
|
+
// `bongos ship`. After a core-update task lands, the next task's branch pins the new
|
|
6
|
+
// core in package.json — but its worktree still resolves the main checkout's install,
|
|
7
|
+
// which is whatever version was there before. On the owner's project that meant a
|
|
8
|
+
// ship ran 1.21.20 against a branch pinned to 1.21.71 and failed on a bug 1.21.71 had
|
|
9
|
+
// already fixed; the AI had to `npm ci` by hand in both places.
|
|
10
|
+
//
|
|
11
|
+
// THE FIX. Before `bongos ship` runs, the dispatcher asks `planCoreSync()` whether the
|
|
12
|
+
// core it is running is the one the checkout it stands in pins. When it is not, it
|
|
13
|
+
// installs the pinned core INTO that checkout (`npm ci`, or `npm install` with no
|
|
14
|
+
// lockfile) and re-runs the same command under it. A worktree that gets its own
|
|
15
|
+
// install keeps resolving it from then on. If the install fails, the builder gets one
|
|
16
|
+
// line that names the command to run — never a silent run on the wrong core. Only
|
|
17
|
+
// `ship` checks: it is the verb that runs the core's land/smoke code against the
|
|
18
|
+
// branch, and it is slow anyway — the many quick verbs (status, recall, exec) pay no
|
|
19
|
+
// git spawn for it.
|
|
20
|
+
//
|
|
21
|
+
// Safety. The pin comes from the branch, and installing it then RUNS it — so a branch
|
|
22
|
+
// must never be able to choose what gets installed. Two gates, both required:
|
|
23
|
+
// 1. the spec is a shape the core itself writes — a vendored
|
|
24
|
+
// `file:vendor/bongos-core-<v>.tgz` or an exact registry semver;
|
|
25
|
+
// 2. the pin has already LANDED: package.json, package-lock.json and the vendored
|
|
26
|
+
// tarball in this checkout are byte-identical (same git blob, no local edits) to
|
|
27
|
+
// the ones on `main` / `origin/main`. That is exactly the case this fixes — a
|
|
28
|
+
// core update merged, the next task's branch carries it — and a branch that pins
|
|
29
|
+
// something main does not get the remedy line, never an automatic install.
|
|
30
|
+
// The install also runs with `--ignore-scripts` (the core's dependencies need no
|
|
31
|
+
// install hook), through npm's own JS entry under this node where it can be found, so
|
|
32
|
+
// no shell is involved; only a Windows box without it falls back to `npm.cmd` + shell,
|
|
33
|
+
// with fixed arguments. A `node_modules` that
|
|
34
|
+
// is a LINK (the junction worktree.js gives an outside-repo worktree) is unlinked
|
|
35
|
+
// first — installing through it would rewrite the MAIN checkout's install. It is never
|
|
36
|
+
// followed and a real directory is never removed. The re-run sets BONGOS_CORE_SYNCED
|
|
37
|
+
// so a pin the install still cannot satisfy cannot loop.
|
|
38
|
+
//
|
|
39
|
+
// It does nothing in the core repo itself (the running package is not `@bongos/core`),
|
|
40
|
+
// outside git, or when the checkout pins no core. Pure planner + injectable runner, so
|
|
41
|
+
// tests/core_pin_sync.mjs drives every branch without npm or a network.
|
|
42
|
+
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
const fs = require('node:fs');
|
|
46
|
+
const path = require('node:path');
|
|
47
|
+
const { spawnSync } = require('node:child_process');
|
|
48
|
+
|
|
49
|
+
const CORE_PKG = '@bongos/core';
|
|
50
|
+
const SYNCED_ENV = 'BONGOS_CORE_SYNCED';
|
|
51
|
+
|
|
52
|
+
function readJson(file, fsImpl = fs) {
|
|
53
|
+
try { return JSON.parse(String(fsImpl.readFileSync(file, 'utf8')).replace(/^/, '')); } catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// { version, safe } for a dependency spec. `version` reads the same shapes upgrade.js
|
|
57
|
+
// does (kept local so the dispatcher never loads upgrade.js's tree); `safe` is true
|
|
58
|
+
// only for the two shapes the core writes itself — a tarball under vendor/ named for
|
|
59
|
+
// its version, or an EXACT registry semver.
|
|
60
|
+
function parseSpec(spec) {
|
|
61
|
+
const s = String(spec || '').trim();
|
|
62
|
+
const tgz = s.match(/bongos-core-(\d+\.\d+\.\d+[^/\\]*)\.tgz$/);
|
|
63
|
+
if (tgz) return { version: tgz[1], safe: /^file:(\.\/)?vendor\/bongos-core-[^/\\]+\.tgz$/.test(s) };
|
|
64
|
+
if (/^file:/.test(s)) return { version: null, safe: false };
|
|
65
|
+
const sem = s.match(/^[\^~>=<\s]*v?(\d+\.\d+\.\d+[A-Za-z0-9.+-]*)$/);
|
|
66
|
+
if (!sem) return { version: null, safe: false };
|
|
67
|
+
return { version: sem[1], safe: /^\d+\.\d+\.\d+(-[A-Za-z0-9.]+)?$/.test(s) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function versionFromSpec(spec) { return parseSpec(spec).version; }
|
|
71
|
+
|
|
72
|
+
function coreSpec(root, fsImpl = fs) {
|
|
73
|
+
const pkg = readJson(path.join(root, 'package.json'), fsImpl);
|
|
74
|
+
return pkg ? ((pkg.dependencies || {})[CORE_PKG] || (pkg.devDependencies || {})[CORE_PKG] || null) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// What the checkout at `root` pins: the lockfile's resolved version first (exact), then
|
|
78
|
+
// the package.json spec.
|
|
79
|
+
function pinnedCoreVersion(root, fsImpl = fs) {
|
|
80
|
+
const lock = readJson(path.join(root, 'package-lock.json'), fsImpl);
|
|
81
|
+
const locked = lock && lock.packages && lock.packages[`node_modules/${CORE_PKG}`];
|
|
82
|
+
if (locked && locked.version) return locked.version;
|
|
83
|
+
return versionFromSpec(coreSpec(root, fsImpl));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The refs that count as LANDED for gate 2: a pin is trusted only once it is on one of them.
|
|
87
|
+
const LANDED_REFS = ['main', 'origin/main'];
|
|
88
|
+
|
|
89
|
+
function gitOut(args, cwd, run) {
|
|
90
|
+
try {
|
|
91
|
+
const r = run('git', args, { cwd, encoding: 'utf8' });
|
|
92
|
+
return r && r.status === 0 ? String(r.stdout || '').trim() : null;
|
|
93
|
+
} catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Gate 2: every file that decides what `npm ci` installs — package.json, the lockfile,
|
|
97
|
+
// the project .npmrc (it can redirect the registry), and the vendored tarball the spec
|
|
98
|
+
// names — is in the SAME state as on a landed ref: the same blob as it sits on disk (so
|
|
99
|
+
// an uncommitted edit counts), or absent on both. Deleting a file the landed ref has
|
|
100
|
+
// is a difference too. True when one landed ref matches all of them. The lockfile is
|
|
101
|
+
// also required outright: the install is `npm ci`, whose integrity check is the point.
|
|
102
|
+
const PIN_FILES = ['package.json', 'package-lock.json', '.npmrc'];
|
|
103
|
+
|
|
104
|
+
function pinLanded(root, { run = spawnSync, fsImpl = fs } = {}) {
|
|
105
|
+
if (!fsImpl.existsSync(path.join(root, 'package-lock.json'))) return false;
|
|
106
|
+
const spec = coreSpec(root, fsImpl);
|
|
107
|
+
const files = [...PIN_FILES];
|
|
108
|
+
const vendored = String(spec || '').match(/^file:(?:\.\/)?(vendor\/bongos-core-[^/\\]+\.tgz)$/);
|
|
109
|
+
if (vendored) files.push(vendored[1]);
|
|
110
|
+
const local = files.map((f) => (fsImpl.existsSync(path.join(root, f)) ? (gitOut(['hash-object', '--', f], root, run) || 'unreadable') : null));
|
|
111
|
+
return LANDED_REFS.some((ref) => files.every((f, i) => gitOut(['rev-parse', '--verify', '--quiet', `${ref}:${f}`], root, run) === local[i]));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function gitTopLevel(cwd, run = spawnSync) {
|
|
115
|
+
try {
|
|
116
|
+
const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
117
|
+
if (r.status !== 0) return null;
|
|
118
|
+
const top = String(r.stdout || '').trim();
|
|
119
|
+
return top ? path.resolve(top) : null;
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pure decision. Inputs are facts; output says whether to sync and why.
|
|
124
|
+
// runningPkg the running core's package.json ({ name, version })
|
|
125
|
+
// root the git top-level of the cwd (null outside git)
|
|
126
|
+
// pinned what that checkout pins (null when it pins no core)
|
|
127
|
+
// specSafe gate 1: the spec is a shape the core writes itself (parseSpec)
|
|
128
|
+
// landed gate 2: the pin is byte-identical to a landed ref's (pinLanded)
|
|
129
|
+
// env process env (the re-run guard)
|
|
130
|
+
function planCoreSync({ runningPkg, root, pinned, specSafe = false, landed = false, env = process.env }) {
|
|
131
|
+
if (!runningPkg || runningPkg.name !== CORE_PKG) return { action: 'none', reason: 'not-installed-core' };
|
|
132
|
+
if (!root) return { action: 'none', reason: 'no-git' };
|
|
133
|
+
if (!pinned) return { action: 'none', reason: 'no-pin' };
|
|
134
|
+
const running = runningPkg.version || null;
|
|
135
|
+
if (running === pinned) return { action: 'none', reason: 'in-sync', running, pinned };
|
|
136
|
+
if (env && env[SYNCED_ENV]) return { action: 'refuse', reason: 'still-mismatched', running, pinned, root };
|
|
137
|
+
if (!specSafe) return { action: 'refuse', reason: 'unrecognised-spec', running, pinned, root };
|
|
138
|
+
if (!landed) return { action: 'refuse', reason: 'pin-not-landed', running, pinned, root };
|
|
139
|
+
return { action: 'sync', reason: 'mismatch', running, pinned, root };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function installArgs(hasLock) {
|
|
143
|
+
return [hasLock ? 'ci' : 'install', '--ignore-scripts', '--no-audit', '--no-fund'];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// npm's own JS entry beside this node (Windows: <dir>/node_modules/npm; POSIX:
|
|
147
|
+
// <prefix>/lib/node_modules/npm), so the install needs no shell. null when absent.
|
|
148
|
+
function npmCliPath(execPath = process.execPath, fsImpl = fs) {
|
|
149
|
+
const dir = path.dirname(execPath);
|
|
150
|
+
const candidates = [
|
|
151
|
+
path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
152
|
+
path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
153
|
+
];
|
|
154
|
+
return candidates.find((c) => fsImpl.existsSync(c)) || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function remedyLine({ root, pinned, running, hasLock = true }) {
|
|
158
|
+
return `bongos: this checkout pins core ${pinned} but ${running || 'another version'} is running. ` +
|
|
159
|
+
`Run \`npm ${hasLock ? 'ci' : 'install'}\` in ${root}, then run the command again.`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Remove a node_modules LINK without following it — the same move as worktree.js's
|
|
163
|
+
// unlinkNodeModulesLink, kept here rather than required: bin/bongos.js is in the
|
|
164
|
+
// executed closure (tests/executed_closure.mjs), and worktree.js would pull its
|
|
165
|
+
// computed require in with it. unlink takes a symlink and (via libuv) a junction; a
|
|
166
|
+
// Windows reparse point unlink refuses falls back to rmdir, which removes the link only.
|
|
167
|
+
function unlinkLink(linkPath, { platform = process.platform, fsImpl = fs } = {}) {
|
|
168
|
+
if (!fsImpl.lstatSync(linkPath).isSymbolicLink()) throw new Error(`${linkPath} is not a link`);
|
|
169
|
+
try { fsImpl.unlinkSync(linkPath); } catch (e) {
|
|
170
|
+
if (platform !== 'win32' || (e.code !== 'EPERM' && e.code !== 'EISDIR')) throw e;
|
|
171
|
+
fsImpl.rmdirSync(linkPath);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Install the pinned core into `root` and re-run the same bongos command under it.
|
|
176
|
+
// Returns the exit code to use.
|
|
177
|
+
function syncAndRerun(plan, { argv, run = spawnSync, fsImpl = fs, log = (s) => process.stderr.write(s + '\n'), env = process.env, platform = process.platform, npmCli = npmCliPath(process.execPath, fsImpl) } = {}) {
|
|
178
|
+
const { root, pinned, running } = plan;
|
|
179
|
+
const hasLock = fsImpl.existsSync(path.join(root, 'package-lock.json'));
|
|
180
|
+
const remedy = () => log(remedyLine({ root, pinned, running, hasLock }));
|
|
181
|
+
log(`bongos: this checkout pins core ${pinned}, but ${running} is running — installing ${pinned} here first.`);
|
|
182
|
+
const nm = path.join(root, 'node_modules');
|
|
183
|
+
let st = null;
|
|
184
|
+
try { st = fsImpl.lstatSync(nm); } catch { /* absent */ }
|
|
185
|
+
if (st && st.isSymbolicLink()) {
|
|
186
|
+
try { unlinkLink(nm, { platform, fsImpl }); } catch (e) {
|
|
187
|
+
log(`bongos: could not detach the shared node_modules link (${e.message}).`);
|
|
188
|
+
remedy();
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// npm's JS entry under this node — no shell. Fallback: `npm`, which on win32 is
|
|
193
|
+
// npm.cmd and needs a shell (Node refuses a .cmd without one); the args are fixed
|
|
194
|
+
// strings, never branch data.
|
|
195
|
+
const cli = npmCli;
|
|
196
|
+
const inst = cli
|
|
197
|
+
? run(process.execPath, [cli, ...installArgs(hasLock)], { cwd: root, stdio: 'inherit', env })
|
|
198
|
+
: run('npm', installArgs(hasLock), { cwd: root, stdio: 'inherit', env, ...(platform === 'win32' ? { shell: true } : {}) });
|
|
199
|
+
if (!inst || inst.error || inst.status !== 0) {
|
|
200
|
+
log(`bongos: installing core ${pinned} failed.`);
|
|
201
|
+
remedy();
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
const bin = path.join(nm, '@bongos', 'core', 'bin', 'bongos.js');
|
|
205
|
+
if (!fsImpl.existsSync(bin)) { remedy(); return 1; }
|
|
206
|
+
const res = run(process.execPath, [bin, ...argv.slice(2)], { stdio: 'inherit', env: { ...env, [SYNCED_ENV]: '1' } });
|
|
207
|
+
if (!res || res.error) return 1;
|
|
208
|
+
return res.status == null ? 1 : res.status;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The dispatcher's one call. Returns an exit code when it handled the command (synced
|
|
212
|
+
// and re-ran, or refused), or null to run the verb on the current core.
|
|
213
|
+
function ensurePinnedCore({ coreRoot, argv, cwd = process.cwd(), env = process.env, run = spawnSync, fsImpl = fs, log } = {}) {
|
|
214
|
+
if (env && env.BONGOS_SKIP_CORE_SYNC) return null;
|
|
215
|
+
const runningPkg = readJson(path.join(coreRoot, 'package.json'), fsImpl);
|
|
216
|
+
if (!runningPkg || runningPkg.name !== CORE_PKG) return null; // fast path: the core repo itself
|
|
217
|
+
const root = gitTopLevel(cwd, run);
|
|
218
|
+
const pinned = root ? pinnedCoreVersion(root, fsImpl) : null;
|
|
219
|
+
const specSafe = root ? parseSpec(coreSpec(root, fsImpl)).safe : false;
|
|
220
|
+
const out = { runningPkg, root, pinned, specSafe, env };
|
|
221
|
+
// gate 2 costs git spawns — only pay it when there is a mismatch to act on
|
|
222
|
+
const pre = planCoreSync({ ...out, landed: true });
|
|
223
|
+
const plan = pre.action === 'sync' ? planCoreSync({ ...out, landed: pinLanded(root, { run, fsImpl }) }) : pre;
|
|
224
|
+
if (plan.action === 'none') return null;
|
|
225
|
+
const say = log || ((s) => process.stderr.write(s + '\n'));
|
|
226
|
+
if (plan.action === 'refuse') {
|
|
227
|
+
say(remedyLine({ ...plan, hasLock: fsImpl.existsSync(path.join(root, 'package-lock.json')) }));
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
return syncAndRerun(plan, { argv, run, fsImpl, log: say, env });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { CORE_PKG, SYNCED_ENV, LANDED_REFS, pinLanded, npmCliPath, parseSpec, versionFromSpec, installArgs, pinnedCoreVersion, planCoreSync, remedyLine, syncAndRerun, ensurePinnedCore };
|