@bongos/core 1.21.123 → 1.21.125

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.
@@ -9720,5 +9720,21 @@
9720
9720
  "id": "1004631",
9721
9721
  "text": "Found why the AI refused to ship on Windows projects: the core update failed on Windows, so projects never got the permission that lets the AI run a ship. That is fixed. New projects also tell the AI to ship on its own in Auto"
9722
9722
  }
9723
+ ],
9724
+ "1.21.124": [
9725
+ {
9726
+ "id": "1004633",
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
+ }
9729
+ ],
9730
+ "1.21.125": [
9731
+ {
9732
+ "id": "1004632",
9733
+ "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."
9734
+ },
9735
+ {
9736
+ "id": "1004627",
9737
+ "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."
9738
+ }
9723
9739
  ]
9724
9740
  }
@@ -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 };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.123'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.125'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,237 @@
1
+ // tests/core_pin_sync.mjs — `bongos <verb>` runs the core the checkout pins (task 1004632)
2
+ //
3
+ // The bug: an instance's task worktree has no node_modules of its own, so after a
4
+ // core-update task lands the next task's `bongos ship` resolved the MAIN checkout's
5
+ // older core and failed on a bug the new pin had already fixed. These cases pin the
6
+ // decision (planCoreSync), the pin reader, and the install-then-re-run path with an
7
+ // injected runner — no npm, no network. The last case is end to end on a real temp
8
+ // git repo with a fake installed core.
9
+ //
10
+ // Run: node tests/core_pin_sync.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const sync = require('../scripts/gds/core-pin-sync.js');
21
+
22
+ let passed = 0;
23
+ let failed = 0;
24
+ function test(name, fn) {
25
+ try { fn(); passed++; console.log(` ok ${name}`); }
26
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
27
+ }
28
+
29
+ const CORE = { name: '@bongos/core', version: '1.21.20' };
30
+
31
+ test('versionFromSpec reads a vendored tarball and a registry semver', () => {
32
+ assert.equal(sync.versionFromSpec('file:vendor/bongos-core-1.21.71.tgz'), '1.21.71');
33
+ assert.equal(sync.versionFromSpec('^1.21.71'), '1.21.71');
34
+ assert.equal(sync.versionFromSpec('1.21.71'), '1.21.71');
35
+ assert.equal(sync.versionFromSpec('file:../somewhere'), null);
36
+ assert.equal(sync.versionFromSpec(undefined), null);
37
+ });
38
+
39
+ test('parseSpec: only the shapes the core writes itself are safe to auto-install', () => {
40
+ assert.equal(sync.parseSpec('file:vendor/bongos-core-1.21.71.tgz').safe, true);
41
+ assert.equal(sync.parseSpec('1.21.71').safe, true);
42
+ assert.equal(sync.parseSpec('^1.21.71').safe, false, 'a range is not an exact pin');
43
+ assert.equal(sync.parseSpec('file:../evil/bongos-core-1.21.71.tgz').safe, false);
44
+ assert.equal(sync.parseSpec('git+https://x/y.git').safe, false);
45
+ });
46
+
47
+ test('planCoreSync: a pin that has not landed on main refuses — a branch cannot choose what gets installed', () => {
48
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '9.9.9', specSafe: true, landed: false, env: {} });
49
+ assert.equal(p.action, 'refuse');
50
+ assert.equal(p.reason, 'pin-not-landed');
51
+ });
52
+
53
+ test('planCoreSync: an unrecognised spec refuses with the remedy, never installs', () => {
54
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', specSafe: false, env: {} });
55
+ assert.equal(p.action, 'refuse');
56
+ });
57
+
58
+ test('installArgs: never runs install scripts; ci with a lockfile, install without', () => {
59
+ assert.deepEqual(sync.installArgs(true).slice(0, 2), ['ci', '--ignore-scripts']);
60
+ assert.deepEqual(sync.installArgs(false).slice(0, 2), ['install', '--ignore-scripts']);
61
+ });
62
+
63
+ test('planCoreSync: the core repo itself is never touched', () => {
64
+ const p = sync.planCoreSync({ runningPkg: { name: 'cloud-bongos', version: '0.0.2' }, root: '/r', pinned: '1.21.71', env: {} });
65
+ assert.equal(p.action, 'none');
66
+ });
67
+
68
+ test('planCoreSync: in sync, no git, no pin → carry on', () => {
69
+ assert.equal(sync.planCoreSync({ runningPkg: { ...CORE, version: '1.21.71' }, root: '/r', pinned: '1.21.71', env: {} }).action, 'none');
70
+ assert.equal(sync.planCoreSync({ runningPkg: CORE, root: null, pinned: '1.21.71', env: {} }).action, 'none');
71
+ assert.equal(sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: null, env: {} }).action, 'none');
72
+ });
73
+
74
+ test('planCoreSync: the task-49 shape (pinned 1.21.71, running 1.21.20) syncs', () => {
75
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', specSafe: true, landed: true, env: {} });
76
+ assert.equal(p.action, 'sync');
77
+ assert.equal(p.pinned, '1.21.71');
78
+ assert.equal(p.running, '1.21.20');
79
+ });
80
+
81
+ test('planCoreSync: a re-run that is STILL mismatched refuses instead of looping', () => {
82
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', env: { [sync.SYNCED_ENV]: '1' } });
83
+ assert.equal(p.action, 'refuse');
84
+ assert.match(sync.remedyLine(p), /npm ci/);
85
+ });
86
+
87
+ function tmpCheckout({ pinSpec, lockVersion }) {
88
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-sync-'));
89
+ fs.writeFileSync(path.join(dir, 'package.json'), '' + JSON.stringify({ name: 'inst', dependencies: { '@bongos/core': pinSpec } }));
90
+ if (lockVersion) {
91
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@bongos/core': { version: lockVersion } } }));
92
+ }
93
+ return dir;
94
+ }
95
+
96
+ test('pinnedCoreVersion prefers the lockfile, falls back to package.json (BOM tolerated)', () => {
97
+ const a = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.72' });
98
+ assert.equal(sync.pinnedCoreVersion(a), '1.21.72');
99
+ const b = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz' });
100
+ assert.equal(fs.readFileSync(path.join(b, 'package.json'), 'utf8').charCodeAt(0), 0xFEFF, 'fixture really carries a BOM');
101
+ assert.equal(sync.pinnedCoreVersion(b), '1.21.71');
102
+ });
103
+
104
+ test('syncAndRerun: npm ci in the checkout, then re-run the same argv under its own core with the loop guard', () => {
105
+ const root = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.71' });
106
+ const calls = [];
107
+ const run = (cmd, args, opts) => {
108
+ calls.push({ cmd, args, opts });
109
+ if (cmd === 'npm') {
110
+ const d = path.join(root, 'node_modules', '@bongos', 'core', 'bin');
111
+ fs.mkdirSync(d, { recursive: true });
112
+ fs.writeFileSync(path.join(d, 'bongos.js'), '');
113
+ return { status: 0 };
114
+ }
115
+ return { status: 7 };
116
+ };
117
+ const code = sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship', '49'], run, log: () => {}, env: {}, platform: 'linux', npmCli: null });
118
+ assert.equal(code, 7, 'the re-run exit code is passed through');
119
+ assert.equal(calls[0].cmd, 'npm');
120
+ assert.equal(calls[0].args[0], 'ci');
121
+ assert.equal(path.resolve(calls[0].opts.cwd), path.resolve(root));
122
+ assert.equal(calls[1].cmd, process.execPath);
123
+ assert.deepEqual(calls[1].args.slice(1), ['ship', '49']);
124
+ assert.ok(calls[1].args[0].includes(path.join('node_modules', '@bongos', 'core', 'bin', 'bongos.js')));
125
+ assert.equal(calls[1].opts.env[sync.SYNCED_ENV], '1');
126
+ });
127
+
128
+ test('syncAndRerun: a failed install exits 1 with the one-line remedy, and never re-runs', () => {
129
+ const root = tmpCheckout({ pinSpec: '1.21.71' });
130
+ const logs = [];
131
+ const calls = [];
132
+ const run = (cmd, args) => { calls.push(cmd); return { status: 1 }; };
133
+ const code = sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship'], run, log: (s) => logs.push(s), env: {}, platform: 'linux', npmCli: null });
134
+ assert.equal(code, 1);
135
+ assert.deepEqual(calls, ['npm']);
136
+ assert.equal(calls.length, 1);
137
+ assert.ok(logs.some((l) => /Run `npm install` in /.test(l)), 'no lockfile → remedy names npm install: ' + logs.join('\n'));
138
+ });
139
+
140
+ test('syncAndRerun: a node_modules LINK is detached first, never installed through', () => {
141
+ const root = tmpCheckout({ pinSpec: '1.21.71', lockVersion: '1.21.71' });
142
+ const shared = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-shared-'));
143
+ fs.writeFileSync(path.join(shared, 'keep.txt'), 'shared');
144
+ fs.symlinkSync(shared, path.join(root, 'node_modules'), 'junction');
145
+ const run = (cmd) => {
146
+ if (cmd === 'npm') {
147
+ assert.ok(!fs.existsSync(path.join(root, 'node_modules')), 'link must be gone before npm runs');
148
+ return { status: 1 };
149
+ }
150
+ return { status: 0 };
151
+ };
152
+ sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship'], run, log: () => {}, env: {}, npmCli: null });
153
+ assert.equal(fs.readFileSync(path.join(shared, 'keep.txt'), 'utf8'), 'shared', 'the shared install is untouched');
154
+ });
155
+
156
+ function git(cwd, ...args) {
157
+ const r = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' });
158
+ assert.equal(r.status, 0, r.stderr);
159
+ return r.stdout;
160
+ }
161
+
162
+ // A real repo: main carries the 1.21.71 pin (the core update landed); the checkout is
163
+ // on a task branch. The running core is the older 1.21.20.
164
+ function landedRepo() {
165
+ const root = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.71' });
166
+ fs.mkdirSync(path.join(root, 'vendor'));
167
+ fs.writeFileSync(path.join(root, 'vendor', 'bongos-core-1.21.71.tgz'), 'tarball-bytes');
168
+ git(root, 'init', '-q', '-b', 'main');
169
+ git(root, 'add', '-A');
170
+ git(root, 'commit', '-qm', 'pin 1.21.71');
171
+ git(root, 'checkout', '-qb', 'task-49');
172
+ const coreRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-running-'));
173
+ fs.writeFileSync(path.join(coreRoot, 'package.json'), JSON.stringify(CORE));
174
+ const installs = [];
175
+ const run = (cmd, args, opts) => {
176
+ if (cmd === 'git') return spawnSync(cmd, args, opts);
177
+ if (cmd === 'npm' || String(args[0] || '').endsWith('npm-cli.js')) installs.push(args);
178
+ return { status: 1 };
179
+ };
180
+ return { root, coreRoot, installs, run };
181
+ }
182
+
183
+ test('ensurePinnedCore end to end: a pin that LANDED on main gets installed', () => {
184
+ const { root, coreRoot, installs, run } = landedRepo();
185
+ assert.equal(sync.pinLanded(root, { run }), true);
186
+ const code = sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} });
187
+ assert.equal(code, 1, 'the fake install fails, so the remedy exit');
188
+ assert.equal(installs.length, 1, 'npm ran once');
189
+ // in sync → null (carry on), no install
190
+ fs.writeFileSync(path.join(coreRoot, 'package.json'), JSON.stringify({ ...CORE, version: '1.21.71' }));
191
+ installs.length = 0;
192
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), null);
193
+ assert.equal(installs.length, 0);
194
+ });
195
+
196
+ test('ensurePinnedCore end to end: a branch-swapped tarball (committed or not) is refused, never installed', () => {
197
+ const { root, coreRoot, installs, run } = landedRepo();
198
+ fs.writeFileSync(path.join(root, 'vendor', 'bongos-core-1.21.71.tgz'), 'attacker-bytes');
199
+ assert.equal(sync.pinLanded(root, { run }), false, 'uncommitted swap');
200
+ git(root, 'commit', '-qam', 'swap');
201
+ assert.equal(sync.pinLanded(root, { run }), false, 'committed on the branch only');
202
+ const logs = [];
203
+ const code = sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: (l) => logs.push(l) });
204
+ assert.equal(code, 1);
205
+ assert.equal(installs.length, 0);
206
+ assert.ok(logs.some((l) => /Run `npm ci`/.test(l)), logs.join('\n'));
207
+ });
208
+
209
+ test('ensurePinnedCore end to end: a branch-only version bump is refused, never installed', () => {
210
+ const { root, coreRoot, installs, run } = landedRepo();
211
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'inst', dependencies: { '@bongos/core': '9.9.9' } }));
212
+ fs.rmSync(path.join(root, 'package-lock.json'));
213
+ git(root, 'commit', '-qam', 'bump');
214
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), 1);
215
+ assert.equal(installs.length, 0);
216
+ });
217
+
218
+ test('ensurePinnedCore end to end: deleting the lockfile or adding an .npmrc on the branch is refused', () => {
219
+ for (const tamper of [
220
+ (root) => fs.rmSync(path.join(root, 'package-lock.json')),
221
+ (root) => fs.writeFileSync(path.join(root, '.npmrc'), 'registry=https://evil.example/\n'),
222
+ ]) {
223
+ const { root, coreRoot, installs, run } = landedRepo();
224
+ tamper(root);
225
+ assert.equal(sync.pinLanded(root, { run }), false);
226
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), 1);
227
+ assert.equal(installs.length, 0);
228
+ }
229
+ });
230
+
231
+ test('the dispatcher runs the check for ship only', () => {
232
+ const src = fs.readFileSync(new URL('../bin/bongos.js', import.meta.url), 'utf8');
233
+ assert.match(src, /if \(!COMPILED && verb === 'ship'\) \{\s*const synced = require\('\.\.\/scripts\/gds\/core-pin-sync\.js'\)\.ensurePinnedCore/);
234
+ });
235
+
236
+ console.log(`\n${passed} passed, ${failed} failed`);
237
+ if (failed) process.exit(1);
@@ -0,0 +1,96 @@
1
+ // tests/howto_guide.mjs — the in-session how-to guide's shape + plain-language rule (task 1004627)
2
+ //
3
+ // The guide's step ids are stored as per-person read marks (task 1004634) and matched to
4
+ // moments (task 1004635), so they must be unique, stable-shaped, and every step must
5
+ // belong to a known moment and carry all three parts (what / when / try). It is written
6
+ // for people who may not be technical, so no step may use the accessibility scorer's
7
+ // jargon list (the same list tests/plain_cards.mjs holds the failure cards to).
8
+ //
9
+ // Run: node tests/howto_guide.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const guide = require('../modules/onboarding/howto-guide.js');
16
+ const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ function test(name, fn) {
21
+ try { fn(); passed++; console.log(` ok ${name}`); }
22
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
23
+ }
24
+
25
+ test('about 25 steps, each with a unique snake_case id', () => {
26
+ assert.ok(guide.STEPS.length >= 20 && guide.STEPS.length <= 32, `got ${guide.STEPS.length}`);
27
+ const ids = guide.STEPS.map((s) => s.id);
28
+ assert.equal(new Set(ids).size, ids.length, 'duplicate step id');
29
+ for (const id of ids) assert.match(id, /^[a-z][a-z0-9_]{2,39}$/, id);
30
+ });
31
+
32
+ test('every step has a known moment and all three parts', () => {
33
+ for (const s of guide.STEPS) {
34
+ assert.ok(guide.MOMENT_IDS.has(s.moment), `${s.id}: unknown moment ${s.moment}`);
35
+ for (const f of ['title', 'what', 'when', 'try']) assert.ok(String(s[f] || '').trim().length > 5, `${s.id}.${f} empty`);
36
+ }
37
+ });
38
+
39
+ test('every moment has at least one step and a trigger', () => {
40
+ for (const m of guide.MOMENTS) {
41
+ assert.ok(guide.stepsForMoment(m.id).length > 0, `${m.id} has no steps`);
42
+ assert.ok(m.trigger, `${m.id} has no trigger`);
43
+ }
44
+ });
45
+
46
+ test('the moments the owner named are covered', () => {
47
+ for (const m of ['first_open', 'setup', 'goal', 'task', 'find_work', 'first_ship', 'invite', 'settings']) {
48
+ assert.ok(guide.MOMENT_IDS.has(m), m);
49
+ }
50
+ assert.ok(guide.stepById('where_to_start'), 'the "where to start" step exists');
51
+ assert.ok(guide.stepById('ship_mode'), 'settings covers the ship mode');
52
+ });
53
+
54
+ test('steps are short and in plain language (no jargon)', () => {
55
+ const hits = [];
56
+ for (const s of guide.STEPS) {
57
+ for (const f of ['title', 'what', 'when', 'try']) {
58
+ const text = s[f];
59
+ if (f !== 'try') assert.ok(text.length <= 240, `${s.id}.${f} is ${text.length} chars — keep a step short`);
60
+ for (const term of JARGON_TERMS) {
61
+ const re = new RegExp(`(^|[^A-Za-z])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z]|$)`, /^[A-Z+-]+$/.test(term) ? '' : 'i');
62
+ if (re.test(text)) hits.push(`${s.id}.${f}: "${term}"`);
63
+ }
64
+ }
65
+ }
66
+ assert.deepEqual(hits, []);
67
+ });
68
+
69
+ test('every slash command a step names is a core skill an instance gets', async () => {
70
+ const fs = await import('node:fs');
71
+ const { isPublishable } = require('../scripts/gds/publish-manifest.js');
72
+ for (const s of guide.STEPS) {
73
+ for (const m of s.try.matchAll(/\/([a-z][a-z-]+)/g)) {
74
+ const skill = `.claude/skills/${m[1]}/SKILL.md`;
75
+ assert.ok(fs.existsSync(new URL(`../${skill}`, import.meta.url)), `${s.id}: /${m[1]} is not a skill`);
76
+ assert.ok(isPublishable(skill), `${s.id}: /${m[1]} does not ship to an instance`);
77
+ }
78
+ }
79
+ });
80
+
81
+ test('nextUnread walks the guide in order and ends at null', () => {
82
+ assert.equal(guide.nextUnread([]).id, guide.STEPS[0].id);
83
+ assert.equal(guide.nextUnread([guide.STEPS[0].id]).id, guide.STEPS[1].id);
84
+ assert.equal(guide.nextUnread(guide.STEPS.map((s) => s.id)), null);
85
+ });
86
+
87
+ test('formatGuide renders every step, and one moment on request', () => {
88
+ const all = guide.formatGuide();
89
+ for (const s of guide.STEPS) assert.ok(all.includes(`[${s.id}]`), s.id);
90
+ const one = guide.formatGuide({ moment: 'first_ship' });
91
+ assert.ok(one.includes('[what_ship_does]'));
92
+ assert.ok(!one.includes('[welcome]'));
93
+ });
94
+
95
+ console.log(`\n${passed} passed, ${failed} failed`);
96
+ if (failed) process.exit(1);