@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.
@@ -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): write-if-absent so a --force re-init or an
576
- // owner's own README is never clobbered.
577
- if (!fs.existsSync(readmePath)) {
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,
@@ -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
  }
@@ -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
- // GitHub creates an App from a manifest with Device Flow unchecked and offers
278
- // no API to enable it (only a settings checkbox), so on a fresh wizard-born
279
- // instance the documented CLI sign-in is hard-broken until the owner ticks the
280
- // box. Detect the proxied refusal (POST /auth/device/start surfaces GitHub's
281
- // device_flow_disabled task 2126's envelope) and fall back to the hall's
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
- const COMPILED = PROTECTED_SURFACES.map((s) => ({ ...s, re: globToRegExp(s.glob) }));
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
- const hit = COMPILED.find((c) => c.re.test(f));
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 hit = f ? COMPILED.find((c) => c.re.test(f)) : null;
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,
@@ -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. Falls back to env vars (OTB_GITHUB_OWNER /
16
- // OTB_GITHUB_REPO / OTB_GITHUB_BRANCH) for environments where git isn't
17
- // available (e.g. a stripped-down container deploy). Returns { error } when
18
- // neither source resolves, so callers can degrade gracefully.
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) and <PREFIX>_GITHUB_OWNER/REPO unset',
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.724'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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);
@@ -1,11 +1,20 @@
1
- // tests/bongos_login.mjs — pure helpers behind `bongos login <instance>`
2
- // (V4.R83 / task 1250). DB-free + network-free. Picked up by run-unit-tests.js.
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);