@bongos/core 1.19.724 → 1.19.726
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 +35 -30
- package/docs/module-api-changelog.md +4 -0
- package/modules/onboarding/approval-broadcast.js +27 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/adopt-preflight.js +68 -2
- package/scripts/gds/build-cli-package.js +1 -0
- package/scripts/gds/claude-md-template.js +18 -0
- package/scripts/gds/device-flow-help.js +72 -0
- package/scripts/gds/gate-review.js +14 -6
- package/scripts/gds/init.js +64 -8
- package/scripts/gds/login.js +18 -0
- package/scripts/gds/setup.js +5 -39
- package/src/bongos/permission-path-check.js +80 -3
- package/src/bongos/repo-info.js +37 -6
- package/src/module-api.js +8 -1
- package/tests/adopt_preflight.mjs +72 -0
- package/tests/bongos_login.mjs +69 -2
- package/tests/claude_materialize.mjs +9 -0
- package/tests/discord_approvals.mjs +45 -0
- package/tests/government_protected_surfaces.mjs +137 -0
- package/tests/init.mjs +52 -2
- package/tests/module_api.mjs +1 -0
- package/tests/repo_info_binding.mjs +82 -0
- package/tests/setup_device_flow_fallback.mjs +16 -0
package/scripts/gds/init.js
CHANGED
|
@@ -525,6 +525,62 @@ function buildReadme(branding = {}) {
|
|
|
525
525
|
].join('\n');
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
+
// The START-HERE content as an APPENDABLE section, for a repo that already has a
|
|
529
|
+
// README of its own. Fenced by a stable marker so re-running init is idempotent and so
|
|
530
|
+
// an owner can find (and delete) the block we added.
|
|
531
|
+
//
|
|
532
|
+
// WHY this is not just buildReadme: an ADOPTED repo always has a README, and the
|
|
533
|
+
// write-if-absent rule below therefore skipped the first-run guide on every single adopt
|
|
534
|
+
// — silently, logging "already present — left untouched" as though nothing were owed.
|
|
535
|
+
// That removed the one human-facing instruction to run `npm install`, without which the
|
|
536
|
+
// repo has no `bongos` command at all. Appending mirrors how the adopt path already
|
|
537
|
+
// treats .gitignore: keep what the owner wrote, add only what the instance needs.
|
|
538
|
+
const README_MARKER = '<!-- cloud-bongos:start-here -->';
|
|
539
|
+
|
|
540
|
+
function buildReadmeSection(branding = {}) {
|
|
541
|
+
const guide = buildReadme(branding);
|
|
542
|
+
// Reuse the canonical guide, demoted one heading level so it nests under the owner's
|
|
543
|
+
// own README, and drop its H1 title (their README already has one).
|
|
544
|
+
const body = guide
|
|
545
|
+
.split('\n')
|
|
546
|
+
.slice(1) // drop "# <product>"
|
|
547
|
+
.join('\n')
|
|
548
|
+
.replace(/^## /gm, '### ')
|
|
549
|
+
.trim();
|
|
550
|
+
return `${README_MARKER}\n\n## Working on this project (Cloud Bongos)\n\n${body}\n`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Append the START-HERE section to an existing README unless it is already there.
|
|
554
|
+
// PURE (text in → text out) so the idempotence is unit-testable without fs.
|
|
555
|
+
// Returns { text, action } where action is 'appended' | 'present'.
|
|
556
|
+
function appendReadmeSection(existing, section) {
|
|
557
|
+
const prev = String(existing == null ? '' : existing);
|
|
558
|
+
if (prev.includes(README_MARKER)) return { text: prev, action: 'present' };
|
|
559
|
+
return { text: `${prev.replace(/\n*$/, '\n')}\n---\n\n${section}`, action: 'appended' };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Put the first-run guide in the repo's README whichever state it is in: mint the whole
|
|
563
|
+
// guide when there is no README, else append the marked section. Shared by the greenfield
|
|
564
|
+
// and adopt paths so neither can silently owe it. Returns the action taken.
|
|
565
|
+
function layerReadme(branding, { dir, dryRun, log = console.log } = {}) {
|
|
566
|
+
const readmePath = path.join(dir, 'README.md');
|
|
567
|
+
if (!fs.existsSync(readmePath)) {
|
|
568
|
+
if (dryRun) { log(` [dry-run] would write README.md (first-run START-HERE)`); return 'created'; }
|
|
569
|
+
fs.writeFileSync(readmePath, buildReadme(branding));
|
|
570
|
+
log(` ✓ wrote README.md (first-run START-HERE)`);
|
|
571
|
+
return 'created';
|
|
572
|
+
}
|
|
573
|
+
const { text, action } = appendReadmeSection(fs.readFileSync(readmePath, 'utf8'), buildReadmeSection(branding));
|
|
574
|
+
if (action === 'present') {
|
|
575
|
+
log(' • README.md already carries the Cloud Bongos start-here section — left untouched.');
|
|
576
|
+
return 'present';
|
|
577
|
+
}
|
|
578
|
+
if (dryRun) { log(' [dry-run] would append the Cloud Bongos start-here section to the existing README.md'); return 'appended'; }
|
|
579
|
+
fs.writeFileSync(readmePath, text);
|
|
580
|
+
log(' ✓ appended the Cloud Bongos start-here section to README.md (your own content is untouched)');
|
|
581
|
+
return 'appended';
|
|
582
|
+
}
|
|
583
|
+
|
|
528
584
|
function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log }) {
|
|
529
585
|
const configDir = path.join(dir, 'config');
|
|
530
586
|
const brandingPath = path.join(configDir, 'branding.json');
|
|
@@ -572,14 +628,9 @@ function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log })
|
|
|
572
628
|
log(` ✓ wrote ${path.relative(dir, modulesPath)}`);
|
|
573
629
|
log(` ✓ wrote ${path.relative(dir, hierarchyPath)}`);
|
|
574
630
|
log(` ✓ wrote ${path.relative(dir, pkgPath)} (pins ${CORE_PKG} → ${pinNote})`);
|
|
575
|
-
// README START-HERE (task 1002404):
|
|
576
|
-
// owner's own README
|
|
577
|
-
|
|
578
|
-
fs.writeFileSync(readmePath, buildReadme(branding));
|
|
579
|
-
log(` ✓ wrote ${path.relative(dir, readmePath)} (first-run START-HERE)`);
|
|
580
|
-
} else {
|
|
581
|
-
log(` • ${path.relative(dir, readmePath)} already present — left untouched`);
|
|
582
|
-
}
|
|
631
|
+
// README START-HERE (task 1002404): mint it when absent, else APPEND the marked
|
|
632
|
+
// section. Never a blind overwrite — an owner's own README content always survives.
|
|
633
|
+
layerReadme(branding, { dir, dryRun: false, log });
|
|
583
634
|
return { brandingPath, modulesPath, hierarchyPath, pkgPath, readmePath, wrote: true };
|
|
584
635
|
}
|
|
585
636
|
|
|
@@ -816,6 +867,10 @@ async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, cor
|
|
|
816
867
|
|
|
817
868
|
const cfg = layerConfigs(spec, { dir, dryRun, log });
|
|
818
869
|
const pkg = layerPackageJson(spec, { dir, dryRun, log, ...(coreDep ? { coreRange: coreDep } : {}) });
|
|
870
|
+
// The first-run guide, which adopt used to skip entirely: an adopted repo always has a
|
|
871
|
+
// README, so the greenfield write-if-absent rule never fired and the one instruction to
|
|
872
|
+
// run `npm install` was silently owed on every adopt.
|
|
873
|
+
layerReadme(buildBrandingConfig(spec), { dir, dryRun, log });
|
|
819
874
|
|
|
820
875
|
// Vendor the pinned core tarball + generate the lockfile, mirroring the greenfield
|
|
821
876
|
// --vendor-core steps, so the adopted repo is `npm ci --omit=dev`-installable (ADR 0108).
|
|
@@ -1061,6 +1116,7 @@ module.exports = {
|
|
|
1061
1116
|
provisioningSlug, provisioningRequest, productionTopologyKickoff,
|
|
1062
1117
|
// --adopt (brownfield layering, ADR 0121 / task 2042)
|
|
1063
1118
|
mergeCoreDependency, coreDepRange, layerConfigs, layerPackageJson, runAdopt,
|
|
1119
|
+
buildReadmeSection, appendReadmeSection, layerReadme, README_MARKER,
|
|
1064
1120
|
KNOWN_MODULES, HOSTING_SHAPES, CORE_PKG,
|
|
1065
1121
|
// greenfield installability (ADR 0108 gap 1/4, task 2053)
|
|
1066
1122
|
buildInstancePackageJson, coreVersionSafe, INSTANCE_START_SCRIPT, INSTANCE_MIGRATE_SCRIPT,
|
package/scripts/gds/login.js
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
const readline = require('node:readline');
|
|
24
24
|
const { spawnSync } = require('node:child_process');
|
|
25
25
|
const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
|
|
26
|
+
// The device-flow refusal detector + its plain-language cure (task 1003400). A leaf
|
|
27
|
+
// module, NOT setup.js: setup requires this file for runDeviceFlow, so requiring it
|
|
28
|
+
// back would close a require cycle. Both callers depend on the leaf instead.
|
|
29
|
+
const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
|
|
26
30
|
const { branding } = require('../../src/branding');
|
|
27
31
|
|
|
28
32
|
// --- pure helpers (unit-tested) ---------------------------------------------
|
|
@@ -102,6 +106,20 @@ async function runDeviceFlow(base, { log, idp = null }) {
|
|
|
102
106
|
if (start.status === 503 || (start.data && (start.data.error === 'auth_not_configured' || (start.data.error && start.data.error.code === 'idp_signing_unconfigured')))) {
|
|
103
107
|
throw new Error('that instance has no sign-in configured yet — ask its Archon to set it up.');
|
|
104
108
|
}
|
|
109
|
+
// Device Flow is OFF on every GitHub App born from a manifest, and GitHub exposes no
|
|
110
|
+
// API to enable it — so terminal sign-in is broken from birth on a fresh instance,
|
|
111
|
+
// for everyone, until its owner ticks one checkbox. This threw a bare
|
|
112
|
+
// "could not start sign-in (HTTP 424)", which is where the CLI's OWN wrong-instance
|
|
113
|
+
// refusal sends every multi-project user: a dead end at the end of our own advice.
|
|
114
|
+
// In federated mode the device flow runs against the HUB, so the cure is on the hub's
|
|
115
|
+
// app — point the guidance at whichever origin actually refused.
|
|
116
|
+
if (isDeviceFlowDisabled(start)) {
|
|
117
|
+
const refusedBy = idp ? idp.origin : base;
|
|
118
|
+
throw new Error([
|
|
119
|
+
'GitHub refused the device-code request for this project.',
|
|
120
|
+
...deviceFlowFallbackLines(refusedBy, { finish: 'login' }),
|
|
121
|
+
].join('\n'));
|
|
122
|
+
}
|
|
105
123
|
if (!start.ok || !start.data || !start.data.device_code) {
|
|
106
124
|
throw new Error(`could not start sign-in (HTTP ${start.status}).`);
|
|
107
125
|
}
|
package/scripts/gds/setup.js
CHANGED
|
@@ -274,45 +274,11 @@ async function promptOnboardingConsent() {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
// ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
// CLI-token path so setup still completes. Pure; exported for tests.
|
|
283
|
-
function isDeviceFlowDisabled(start) {
|
|
284
|
-
if (!start || start.ok) return false;
|
|
285
|
-
const err = start.data && start.data.error;
|
|
286
|
-
const text = [
|
|
287
|
-
typeof err === 'string' ? err : '',
|
|
288
|
-
err && typeof err === 'object' ? `${err.code || ''} ${err.message || ''}` : '',
|
|
289
|
-
start.data && start.data.message ? String(start.data.message) : '',
|
|
290
|
-
].join(' ');
|
|
291
|
-
return /device_flow_disabled/i.test(text);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// The plain-language fallback guidance (task 1003051): what happened, the web
|
|
295
|
-
// path that works right now, and the one-checkbox owner fix. Pure; exported.
|
|
296
|
-
function deviceFlowFallbackLines(base) {
|
|
297
|
-
const settings = `${base}/builders/settings`;
|
|
298
|
-
return [
|
|
299
|
-
'',
|
|
300
|
-
"This project's GitHub App has Device Flow switched off, so terminal sign-in",
|
|
301
|
-
'cannot start. Apps created by the one-click setup begin this way — GitHub',
|
|
302
|
-
'only offers the switch as a checkbox in the App settings; no setup flow can',
|
|
303
|
-
'tick it automatically.',
|
|
304
|
-
'',
|
|
305
|
-
'Sign in through the web instead (works right now):',
|
|
306
|
-
` 1. Open ${settings} and sign in with GitHub.`,
|
|
307
|
-
' 2. Under "CLI Access", click "Re-issue CLI token".',
|
|
308
|
-
' 3. Copy the token it shows and paste it below (or run the printed',
|
|
309
|
-
' one-liner in another shell, then re-run setup).',
|
|
310
|
-
'',
|
|
311
|
-
'To repair terminal sign-in for everyone (the project owner, once):',
|
|
312
|
-
' GitHub → Settings → Developer settings → GitHub Apps → your app →',
|
|
313
|
-
' check "Enable Device Flow" → Save. Web sign-in is unaffected either way.',
|
|
314
|
-
];
|
|
315
|
-
}
|
|
277
|
+
// The detector + the guidance moved to ./device-flow-help (task 1003400) so
|
|
278
|
+
// `bongos login` can use the same words without closing a require cycle back
|
|
279
|
+
// through this file (setup requires login for runDeviceFlow). Re-exported here
|
|
280
|
+
// because both are part of setup.js's tested public surface.
|
|
281
|
+
const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
|
|
316
282
|
|
|
317
283
|
// Verify a hall-minted CLI token against /me and save the session (the
|
|
318
284
|
// paste-token.js contract: nothing is written unless the token verifies).
|
|
@@ -140,13 +140,80 @@ function escapeRe(s) {
|
|
|
140
140
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
// task 1002874: the glob is normalised ONCE here, at build, not per candidate —
|
|
144
|
+
// there are 51 surfaces and `matchProtected` runs over every changed file on the
|
|
145
|
+
// push/claim/grade path. `s.glob` itself is kept VERBATIM on the entry, because it
|
|
146
|
+
// is what gets reported back to a refused builder and in the registry's own docs;
|
|
147
|
+
// only the compiled matcher is folded.
|
|
148
|
+
const COMPILED = PROTECTED_SURFACES.map((s) => ({ ...s, re: globToRegExp(foldForMatch(s.glob)) }));
|
|
144
149
|
|
|
145
150
|
// Normalize a path the way git reports it (forward slashes, no leading ./).
|
|
151
|
+
// normalize — put a path in the ONE spelling matching happens in.
|
|
152
|
+
//
|
|
153
|
+
// task 1002874 (ADR 0151 §4, the last piece of R101's scope): this used to do
|
|
154
|
+
// only backslash→slash and strip a leading './', so matching was exact-BYTES. On
|
|
155
|
+
// a case-insensitive filesystem — macOS by default, and Windows, i.e. the two
|
|
156
|
+
// platforms most builders use — `Src/Bongos/Auth.js` and `src/bongos/auth.js` name
|
|
157
|
+
// the SAME file, and only one of them matched the protected-surface glob. Unicode
|
|
158
|
+
// composition does the same thing invisibly: NFD (`e` + U+0301) and NFC (`é`) are
|
|
159
|
+
// different byte strings for one filename, and a checkout can hand you either.
|
|
160
|
+
// A protected-path wall you can step over by spelling is not a wall.
|
|
161
|
+
//
|
|
162
|
+
// BOTH SIDES go through this — the candidate here, and every registry glob ONCE at
|
|
163
|
+
// COMPILED build — so the transform is applied symmetrically and matching cannot
|
|
164
|
+
// become narrower. It is a strict widening: any pair that matched byte-for-byte
|
|
165
|
+
// still matches after both are folded the same way. The only regression shape is
|
|
166
|
+
// OVER-protection, surfacing as an unexpected claim/push refusal, never a silent
|
|
167
|
+
// un-protection.
|
|
168
|
+
//
|
|
169
|
+
// CASE FOLD: `toLowerCase()`, and that IS the invariant fold here. The Turkish-I
|
|
170
|
+
// hazard the ADR warns about belongs to `toLocaleLowerCase('tr')`, which maps
|
|
171
|
+
// 'I' → 'ı' (dotless) — measured. Plain `toLowerCase()` is Unicode Default Case
|
|
172
|
+
// Conversion and is locale-independent by specification, so it is the correct
|
|
173
|
+
// primitive and `toLocaleLowerCase` is the one that must never appear on this
|
|
174
|
+
// path. A test asserts it does not.
|
|
175
|
+
//
|
|
176
|
+
// ORDER: NFC first, then fold. Measured equivalent to folding first for the
|
|
177
|
+
// composed-accent cases, and NFC-first keeps the input in one canonical form
|
|
178
|
+
// before any other transform reasons about it.
|
|
146
179
|
function normalize(p) {
|
|
147
180
|
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').trim();
|
|
148
181
|
}
|
|
149
182
|
|
|
183
|
+
// foldForMatch — the spelling MATCHING happens in. Separate from normalize() on
|
|
184
|
+
// purpose: normalize() still produces the path we REPORT, and that must keep its
|
|
185
|
+
// original case. A finding, a refusal message and a grader issue all carry
|
|
186
|
+
// `file`, and on a case-sensitive filesystem (Linux, i.e. CI) a lowercased path
|
|
187
|
+
// names nothing — a consumer that tried to read it would get ENOENT for a file
|
|
188
|
+
// that exists.
|
|
189
|
+
function foldForMatch(p) {
|
|
190
|
+
return normalize(p).normalize('NFC').toLowerCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// compileGlobMatcher(globs) → (file) => boolean, with the fold applied to BOTH
|
|
194
|
+
// sides: each glob once here, the candidate on every call.
|
|
195
|
+
//
|
|
196
|
+
// task 1002874: this exists because `globToRegExp` + the raw `PROTECTED_GLOBS`
|
|
197
|
+
// are BOTH exported, and a consumer that compiles them itself gets a byte-exact
|
|
198
|
+
// matcher — which is precisely the wall this task removed, rebuilt by accident.
|
|
199
|
+
// gate-review.js had done exactly that (its ESCALATE / HARD_FLOOR classifier),
|
|
200
|
+
// so an uppercased spelling still slipped past CI's auto-merge gate after the
|
|
201
|
+
// matcher here was fixed. Anything matching a PROTECTED-surface list should
|
|
202
|
+
// compile through this, not through globToRegExp directly.
|
|
203
|
+
//
|
|
204
|
+
// NOT a blanket replacement for globToRegExp, deliberately. Folding widens what
|
|
205
|
+
// matches, and that is only safe when matching more means protecting more. On an
|
|
206
|
+
// ALLOW-list — publish-manifest's PUBLISH_ALLOWLIST, which decides what reaches
|
|
207
|
+
// the public OSS mirror — widening would publish MORE files. Those lists keep
|
|
208
|
+
// the exact matcher on purpose.
|
|
209
|
+
function compileGlobMatcher(globs) {
|
|
210
|
+
const compiled = (globs || []).map((g) => globToRegExp(foldForMatch(g)));
|
|
211
|
+
return (file) => {
|
|
212
|
+
const folded = foldForMatch(file);
|
|
213
|
+
return folded ? compiled.some((re) => re.test(folded)) : false;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
150
217
|
// Which of `files` are permission-sensitive? Returns
|
|
151
218
|
// [{ file, glob, floor, permission }] — floor + permission come straight from the
|
|
152
219
|
// registry entry that matched, so a governance re-map flows through every caller.
|
|
@@ -155,7 +222,11 @@ function matchProtected(files) {
|
|
|
155
222
|
for (const raw of files || []) {
|
|
156
223
|
const f = normalize(raw);
|
|
157
224
|
if (!f) continue;
|
|
158
|
-
|
|
225
|
+
// Match on the FOLDED spelling, report the original one — see foldForMatch.
|
|
226
|
+
// Hoisted OUT of the predicate on purpose: inside it, the fold re-runs once
|
|
227
|
+
// per registry surface (51 today) for every candidate file.
|
|
228
|
+
const folded = foldForMatch(raw);
|
|
229
|
+
const hit = COMPILED.find((c) => c.re.test(folded));
|
|
159
230
|
if (hit) out.push({ file: f, glob: hit.glob, floor: hit.floor, permission: hit.permission });
|
|
160
231
|
}
|
|
161
232
|
return out;
|
|
@@ -164,7 +235,8 @@ function matchProtected(files) {
|
|
|
164
235
|
// The registry entry governing `file`, or null when the path is unprotected.
|
|
165
236
|
function surfaceFor(file) {
|
|
166
237
|
const f = normalize(file);
|
|
167
|
-
const
|
|
238
|
+
const folded = f ? foldForMatch(file) : '';
|
|
239
|
+
const hit = f ? COMPILED.find((c) => c.re.test(folded)) : null;
|
|
168
240
|
return hit ? { glob: hit.glob, floor: hit.floor, permission: hit.permission, group: hit.group, why: hit.why } : null;
|
|
169
241
|
}
|
|
170
242
|
|
|
@@ -271,6 +343,11 @@ module.exports = {
|
|
|
271
343
|
REGISTRY_PATH,
|
|
272
344
|
SPECIALTY,
|
|
273
345
|
globToRegExp,
|
|
346
|
+
compileGlobMatcher,
|
|
347
|
+
// foldForMatch is deliberately NOT exported: it is the internal spelling rule,
|
|
348
|
+
// and every caller should go through compileGlobMatcher so the fold is applied
|
|
349
|
+
// to BOTH sides. Handing it out invites a consumer to fold one side only, which
|
|
350
|
+
// is the asymmetry that breaks matching outright.
|
|
274
351
|
matchProtected,
|
|
275
352
|
surfaceFor,
|
|
276
353
|
floorFor,
|
package/src/bongos/repo-info.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
// transfers, the deploy's git remote changes and the served links follow,
|
|
13
13
|
// with zero code edits required.
|
|
14
14
|
//
|
|
15
|
-
// Cached on first call.
|
|
16
|
-
// OTB_GITHUB_REPO / OTB_GITHUB_BRANCH
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// Cached on first call. Three sources, in order: env vars (OTB_GITHUB_OWNER /
|
|
16
|
+
// OTB_GITHUB_REPO / OTB_GITHUB_BRANCH — the operator's explicit override), the git
|
|
17
|
+
// remote when this package IS the repo, then the instance's committed branding pack
|
|
18
|
+
// (config/branding.json `repo`) — the source a standalone instance actually has.
|
|
19
|
+
// Returns { error } when none resolves, so callers can degrade gracefully.
|
|
19
20
|
|
|
20
21
|
const { execSync } = require('node:child_process');
|
|
21
22
|
const path = require('node:path');
|
|
@@ -44,6 +45,34 @@ function loadFromEnv() {
|
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// The instance's OWN declared GitHub binding, read from the committed branding pack
|
|
49
|
+
// (`config/branding.json` -> `repo: { owner, name }` — a first-class key in the
|
|
50
|
+
// branding contract). `bongos init` writes it at scaffold time, so a standalone
|
|
51
|
+
// instance carries the correct answer on disk from birth.
|
|
52
|
+
//
|
|
53
|
+
// WHY it exists: an installed core (ADR 0140) lives in node_modules/@bongos/core, so
|
|
54
|
+
// loadFromGit's anchor rule correctly returns null — and nothing sets
|
|
55
|
+
// <PREFIX>_GITHUB_OWNER/REPO on a provisioned instance. EVERY standalone instance
|
|
56
|
+
// therefore resolved no_repo_info and served a 503 from /public/repo-info, with public
|
|
57
|
+
// build status and every repo-linked surface dead. The identity was one file away the
|
|
58
|
+
// whole time, simply never read.
|
|
59
|
+
//
|
|
60
|
+
// Placed LAST in the chain on purpose: the env pin stays the operator's explicit
|
|
61
|
+
// override and git stays authoritative for the core dev checkout, so this source can
|
|
62
|
+
// only ever turn an ERROR into an answer — it can never change one that already
|
|
63
|
+
// resolved. Required lazily so module load order never depends on branding, and
|
|
64
|
+
// fail-soft: a malformed or absent pack must degrade to no_repo_info, not crash boot.
|
|
65
|
+
function loadFromBranding(deps = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const read = deps.branding || require('../branding').branding;
|
|
68
|
+
const pack = read() || {};
|
|
69
|
+
const owner = pack.repo && String(pack.repo.owner || '').trim();
|
|
70
|
+
const name = pack.repo && String(pack.repo.name || '').trim();
|
|
71
|
+
if (owner && name) return { owner, repo: name };
|
|
72
|
+
} catch (_) { /* fall through — an unreadable pack is not an answer */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
// The directory this file's package occupies: <root>/src/bongos -> <root>. In the core
|
|
48
77
|
// dev checkout that is the repo root; installed, it is node_modules/@bongos/core.
|
|
49
78
|
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -92,12 +121,13 @@ function loadFromGit(deps = {}) {
|
|
|
92
121
|
function loadRepoInfo(deps = {}) {
|
|
93
122
|
if (cached) return cached;
|
|
94
123
|
const branch = ic.resolveEnv('GITHUB_BRANCH') || 'main';
|
|
95
|
-
const parsed = loadFromEnv() || loadFromGit(deps);
|
|
124
|
+
const parsed = loadFromEnv() || loadFromGit(deps) || loadFromBranding(deps);
|
|
96
125
|
if (!parsed) {
|
|
97
126
|
cached = {
|
|
98
127
|
error: 'no_repo_info',
|
|
99
128
|
detail:
|
|
100
|
-
'git remote origin not resolvable from this package (an installed core does not sit inside the repo it publishes to)
|
|
129
|
+
'git remote origin not resolvable from this package (an installed core does not sit inside the repo it publishes to), '
|
|
130
|
+
+ 'config/branding.json has no repo.owner/repo.name, and <PREFIX>_GITHUB_OWNER/REPO unset',
|
|
101
131
|
};
|
|
102
132
|
return cached;
|
|
103
133
|
}
|
|
@@ -123,5 +153,6 @@ module.exports = {
|
|
|
123
153
|
loadRepoInfo,
|
|
124
154
|
parseRemote,
|
|
125
155
|
loadFromGit, // exported for unit tests — the anchor rule is the whole fix
|
|
156
|
+
loadFromBranding, // exported for unit tests — the standalone-instance source
|
|
126
157
|
_resetCacheForTests,
|
|
127
158
|
};
|
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.726'; // 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');
|
|
@@ -427,6 +427,13 @@ module.exports = {
|
|
|
427
427
|
// directly. Resolved lazily so requiring the doorway never forces a config
|
|
428
428
|
// read at module-load (BV1.R81 / ADR 0093 §1).
|
|
429
429
|
enabledDisciplines: (...a) => require('./modules').enabledDisciplines(...a),
|
|
430
|
+
// isModuleEnabled(name) — is THIS module on for this instance? A module needs
|
|
431
|
+
// it when its own behaviour depends on ANOTHER module being present: onboarding
|
|
432
|
+
// posts a Discord approve/decline control whose 👍 is only ever applied by the
|
|
433
|
+
// `discord` module's reaction handler, so with discord off the control is a lie
|
|
434
|
+
// and must not be posted at all. Throws on an unknown name (a typo is a bug, not
|
|
435
|
+
// a silent false). Lazy, like its sibling above.
|
|
436
|
+
isModuleEnabled: (...a) => require('./modules').isModuleEnabled(...a),
|
|
430
437
|
|
|
431
438
|
// --- the seam registry — how modules cooperate without importing each other.
|
|
432
439
|
// Three primitives: PORTS (one required provider), EVENTS (many optional
|
|
@@ -101,5 +101,77 @@ t('preflightAdopt: a clean repo → no blocking; every category reports', () =>
|
|
|
101
101
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
// ---- ignored-incoming: the check that was missing (task 1003400) ----------
|
|
105
|
+
//
|
|
106
|
+
// The failure it exists to stop: adopt onto a repo whose .gitignore already carries
|
|
107
|
+
// `.claude/`. The skills/hooks/settings are written to disk, `git add` drops them, the
|
|
108
|
+
// commit looks clean, and every clone of that repo is missing its /builder-* commands
|
|
109
|
+
// with nothing anywhere saying why.
|
|
110
|
+
|
|
111
|
+
t('checkIgnoredIncoming: an ignored incoming file BLOCKS and names the rule to change', () => {
|
|
112
|
+
const f = pf.checkIgnoredIncoming([{ path: '.claude/skills', rule: '.gitignore:7:.claude/' }]);
|
|
113
|
+
assert.equal(f.length, 1);
|
|
114
|
+
assert.equal(f[0].severity, pf.SEV.BLOCK, 'silent data loss at git add is not a warning');
|
|
115
|
+
assert.match(f[0].message, /\.claude\/skills/, 'names the swallowed path');
|
|
116
|
+
assert.match(f[0].message, /\.gitignore:7/, 'names the exact rule line so the owner need not hunt');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
t('checkIgnoredIncoming: nothing ignored → INFO, not blocking', () => {
|
|
120
|
+
const f = pf.checkIgnoredIncoming([]);
|
|
121
|
+
assert.equal(f[0].severity, pf.SEV.INFO);
|
|
122
|
+
assert.equal(f.filter((x) => x.severity === pf.SEV.BLOCK).length, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
t('checkIgnoredIncoming: probe could not run → INFO that says so, never a false all-clear', () => {
|
|
126
|
+
const f = pf.checkIgnoredIncoming([], { checked: false });
|
|
127
|
+
assert.equal(f[0].severity, pf.SEV.INFO);
|
|
128
|
+
assert.match(f[0].message, /could not run/, 'unknown must not be reported as clean');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
t('probeIgnoredIncoming: parses git check-ignore -v into path + rule', () => {
|
|
132
|
+
const execFileSync = () => '.gitignore:7:.claude/\t.claude/skills\n.gitignore:7:.claude/\t.claude/hooks\n';
|
|
133
|
+
const { ignored, checked } = pf.probeIgnoredIncoming({ dir: '/x', execFileSync });
|
|
134
|
+
assert.equal(checked, true);
|
|
135
|
+
assert.deepEqual(ignored, [
|
|
136
|
+
{ path: '.claude/skills', rule: '.gitignore:7:.claude/' },
|
|
137
|
+
{ path: '.claude/hooks', rule: '.gitignore:7:.claude/' },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
t('probeIgnoredIncoming: git exit 1 means NOTHING ignored — a real answer, not a failure', () => {
|
|
142
|
+
const execFileSync = () => { const e = new Error('exit 1'); e.status = 1; throw e; };
|
|
143
|
+
assert.deepEqual(pf.probeIgnoredIncoming({ dir: '/x', execFileSync }), { ignored: [], checked: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
t('probeIgnoredIncoming: no git / not a repo → checked:false, distinct from "clean"', () => {
|
|
147
|
+
const execFileSync = () => { const e = new Error('not a git repository'); e.status = 128; throw e; };
|
|
148
|
+
assert.deepEqual(pf.probeIgnoredIncoming({ dir: '/x', execFileSync }), { ignored: [], checked: false });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
t('probeIgnoredIncoming: strips our trailing dir slashes before handing paths to git', () => {
|
|
152
|
+
let input = null;
|
|
153
|
+
const execFileSync = (_cmd, _args, opts) => { input = opts.input; return ''; };
|
|
154
|
+
pf.probeIgnoredIncoming({ dir: '/x', incoming: ['.claude/hooks/', 'package.json'], execFileSync });
|
|
155
|
+
assert.equal(input, '.claude/hooks\npackage.json', 'git check-ignore wants plain pathnames');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
t('preflightAdopt (end to end): mercury\'s .gitignore would have BLOCKED the adopt', () => {
|
|
159
|
+
const dir = mkdtempSync(join(tmpdir(), 'pf-ignored-'));
|
|
160
|
+
try {
|
|
161
|
+
// The exact rule that swallowed the methodology surface on the real repo.
|
|
162
|
+
writeFileSync(join(dir, '.gitignore'), '.env\nvenv/\n\n#claude code\n.claude/\n');
|
|
163
|
+
const report = pf.preflightAdopt({
|
|
164
|
+
dir,
|
|
165
|
+
stack: { migrationsDirs: [], topLevelDirs: [], ci: [] },
|
|
166
|
+
// Stand in for git so the case is hermetic (no repo needed in tmp).
|
|
167
|
+
execFileSync: () => '.gitignore:5:.claude/\t.claude/settings.json\n.gitignore:5:.claude/\t.claude/skills\n',
|
|
168
|
+
});
|
|
169
|
+
assert.ok(report.hasBlocking, 'adopt must stop rather than produce a silently broken instance');
|
|
170
|
+
const found = cats(report.findings, 'ignored-incoming');
|
|
171
|
+
assert.equal(found.length, 1);
|
|
172
|
+
assert.equal(found[0].severity, pf.SEV.BLOCK);
|
|
173
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
174
|
+
});
|
|
175
|
+
|
|
104
176
|
console.log(`\nadopt_preflight: ${passed} passed, ${failed} failed`);
|
|
105
177
|
process.exit(failed ? 1 : 0);
|
package/tests/bongos_login.mjs
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
// tests/bongos_login.mjs —
|
|
2
|
-
// (V4.R83 / task 1250). DB-free
|
|
1
|
+
// tests/bongos_login.mjs — the helpers and the failure surface behind
|
|
2
|
+
// `bongos login <instance>` (V4.R83 / task 1250). DB-free; the one end-to-end case
|
|
3
|
+
// drives the real CLI against a 127.0.0.1 stub, so it needs no network either.
|
|
4
|
+
// Picked up by run-unit-tests.js.
|
|
3
5
|
import assert from 'node:assert/strict';
|
|
4
6
|
import { test } from 'node:test';
|
|
5
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
6
14
|
|
|
7
15
|
const require = createRequire(import.meta.url);
|
|
8
16
|
const { normalizeInstanceBase, pollDecision } = require('../scripts/gds/login.js');
|
|
17
|
+
const LOGIN_JS = path.join(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), 'scripts', 'gds', 'login.js');
|
|
9
18
|
|
|
10
19
|
test('normalizeInstanceBase adds https, strips trailing slashes + a pasted /api/gds', () => {
|
|
11
20
|
assert.equal(normalizeInstanceBase('amazonprimea.com'), 'https://amazonprimea.com');
|
|
@@ -32,3 +41,61 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
|
|
|
32
41
|
assert.equal(pollDecision(null), 'wait');
|
|
33
42
|
assert.equal(pollDecision({}), 'wait');
|
|
34
43
|
});
|
|
44
|
+
|
|
45
|
+
// ---- the Device-Flow dead end (task 1003400) -------------------------------
|
|
46
|
+
//
|
|
47
|
+
// Device Flow is OFF on every GitHub App created from a manifest and GitHub offers no
|
|
48
|
+
// API to switch it on, so terminal sign-in is broken from birth on a fresh instance —
|
|
49
|
+
// for every builder. `bongos login` used to answer that with a bare
|
|
50
|
+
// "could not start sign-in (HTTP 424)": no cause, no cure, and sitting directly
|
|
51
|
+
// downstream of the CLI's own wrong-instance advice to run `bongos login`.
|
|
52
|
+
//
|
|
53
|
+
// Drives the REAL CLI against a stub replaying the exact body a live instance returned.
|
|
54
|
+
|
|
55
|
+
test('login on a Device-Flow-disabled instance explains the cause and the cure', async () => {
|
|
56
|
+
const server = http.createServer((req, res) => {
|
|
57
|
+
const send = (code, body) => {
|
|
58
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
59
|
+
res.end(JSON.stringify(body));
|
|
60
|
+
};
|
|
61
|
+
if (req.url.endsWith('/api/gds/instance')) {
|
|
62
|
+
return send(200, { schema: 'instance-manifest/v1', name: 'Stub', admission: { policy: 'invite-only' } });
|
|
63
|
+
}
|
|
64
|
+
if (req.url.endsWith('/api/gds/auth/device/start')) {
|
|
65
|
+
// Byte-for-byte what a real instance returned when its App had the box unticked.
|
|
66
|
+
return send(424, { error: { code: 'device_start_failed', message: 'device/code failed (400): {"error":"device_flow_disabled","error_description":"Device Flow must be explicitly enabled for this App"}' } });
|
|
67
|
+
}
|
|
68
|
+
return send(404, { error: { code: 'not_found' } });
|
|
69
|
+
});
|
|
70
|
+
server.listen(0, '127.0.0.1');
|
|
71
|
+
await new Promise((r) => server.once('listening', r));
|
|
72
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
73
|
+
// A throwaway HOME so the run can never read or write the real session.
|
|
74
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'login-df-'));
|
|
75
|
+
|
|
76
|
+
// spawn, not execFileSync: the stub server lives in THIS process, so a synchronous
|
|
77
|
+
// child would block the event loop that has to answer it — a deadlock, not a failure.
|
|
78
|
+
let out = '';
|
|
79
|
+
try {
|
|
80
|
+
out = await new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn(process.execPath, [LOGIN_JS, base], {
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
84
|
+
});
|
|
85
|
+
let buf = '';
|
|
86
|
+
child.stdout.on('data', (d) => { buf += d; });
|
|
87
|
+
child.stderr.on('data', (d) => { buf += d; });
|
|
88
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('login did not exit')); }, 30000);
|
|
89
|
+
child.on('close', () => { clearTimeout(timer); resolve(buf); });
|
|
90
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
server.close();
|
|
94
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
assert.doesNotMatch(out, /could not start sign-in \(HTTP 424\)/, 'the bare dead end is gone');
|
|
98
|
+
assert.match(out, /Device Flow switched off/, 'names the actual cause');
|
|
99
|
+
assert.match(out, /Enable Device Flow/, 'names the owner\'s one-checkbox repair');
|
|
100
|
+
assert.match(out, /works right now/, 'names a route that works while the owner fixes it');
|
|
101
|
+
});
|
|
@@ -510,6 +510,15 @@ t('renderInstanceClaudeMd: brand-substituted — names the instance + its hall/c
|
|
|
510
510
|
assert.ok(!md.toLowerCase().includes('amazonprimea') && !md.includes('~/.config/otb'), 'no founder literal in the starter');
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
t('renderInstanceClaudeMd: the first-run bootstrap precedes every bongos instruction', () => {
|
|
514
|
+
const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } });
|
|
515
|
+
const iNpm = md.indexOf('npm install');
|
|
516
|
+
const iStart = md.indexOf('bongos start');
|
|
517
|
+
assert.ok(iNpm > 0, 'names npm install at all — without it a fresh clone has no bongos command');
|
|
518
|
+
assert.ok(iStart > iNpm, 'npm install comes BEFORE the first bongos instruction, or the agent hits command not found');
|
|
519
|
+
assert.ok(/@bongos\/core/.test(md), 'explains WHY: the CLI ships inside the dependency');
|
|
520
|
+
});
|
|
521
|
+
|
|
513
522
|
t('renderInstanceClaudeMd: degrades cleanly on a minimal branding (no world dup, defaults currency)', () => {
|
|
514
523
|
const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Solo', worldName: 'Solo' } });
|
|
515
524
|
assert.ok(md.startsWith('# Solo — Project Memory'));
|
|
@@ -170,5 +170,50 @@ await t('a DB error returns {error}, never throws', async () => {
|
|
|
170
170
|
assert.equal(r.outcome, 'error');
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
// ---- the outbound half must not outlive the inbound half (task 1003400) ----
|
|
174
|
+
//
|
|
175
|
+
// The 👍 this line invites is applied ONLY by discord-approvals.js, which lives in the
|
|
176
|
+
// `discord` module. onboarding (which posts) is default-ON; discord is default-OFF; and
|
|
177
|
+
// nothing gated one on the other. A default instance therefore posted a working-looking
|
|
178
|
+
// approve/decline control into a channel where no handler was listening — an Archon
|
|
179
|
+
// reacts, sees it land, and believes they admitted a builder who is still locked out.
|
|
180
|
+
|
|
181
|
+
await t('discord module OFF → the control is NOT posted', async () => {
|
|
182
|
+
let posted = false;
|
|
183
|
+
const r = await bcast.postMemberApprovalRequest(
|
|
184
|
+
{ id: 7, github_login: 'carol' },
|
|
185
|
+
{ postWebhook: async () => { posted = true; }, log: () => {}, isModuleEnabled: () => false },
|
|
186
|
+
);
|
|
187
|
+
assert.equal(posted, false, 'a 👍 nobody applies must never be invited');
|
|
188
|
+
assert.equal(r.skipped, 'discord_module_off');
|
|
189
|
+
assert.equal(r.posted, false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await t('discord module ON → the control posts exactly as before', async () => {
|
|
193
|
+
let line = null;
|
|
194
|
+
const r = await bcast.postMemberApprovalRequest(
|
|
195
|
+
{ id: 7, github_login: 'carol' },
|
|
196
|
+
{ postWebhook: async (_ch, l) => { line = l; }, log: () => {}, isModuleEnabled: () => true },
|
|
197
|
+
);
|
|
198
|
+
assert.equal(r.posted, true, 'the working path is untouched');
|
|
199
|
+
assert.deepEqual(ap.parseApprovalMarker(line), { kind: 'member', id: 7 }, 'and still round-trips');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await t('enablement unresolvable → fails CLOSED (no post)', async () => {
|
|
203
|
+
let posted = false;
|
|
204
|
+
const r = await bcast.postMemberApprovalRequest(
|
|
205
|
+
{ id: 7, github_login: 'carol' },
|
|
206
|
+
{ postWebhook: async () => { posted = true; }, log: () => {}, isModuleEnabled: () => { throw new Error('config unreadable'); } },
|
|
207
|
+
);
|
|
208
|
+
assert.equal(posted, false, 'a state we cannot vouch for is not a state we post into');
|
|
209
|
+
assert.equal(r.skipped, 'discord_module_off');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await t('the gate is reachable through the published doorway, not a direct core require', async () => {
|
|
213
|
+
const doorway = require('../src/module-api.js');
|
|
214
|
+
assert.equal(typeof doorway.isModuleEnabled, 'function', 'ADR 0083: modules read enablement here');
|
|
215
|
+
assert.throws(() => doorway.isModuleEnabled('no-such-module'), /unknown module/, 'a typo must be a bug, not a silent false');
|
|
216
|
+
});
|
|
217
|
+
|
|
173
218
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
174
219
|
process.exit(failed ? 1 : 0);
|