@bongos/core 1.19.120 → 1.19.122

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.
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/worktree.js — junction-safe worktree lifecycle (task 1002809).
3
+ //
4
+ // The trap this kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N — used to
5
+ // dodge OneDrive file locks) can't resolve node_modules by walking up, so
6
+ // Windows builders hand-made junctions with `cmd //c mklink` from Git Bash —
7
+ // which mangles quoted targets containing spaces (dead junction) — and then
8
+ // `git worktree remove` FOLLOWED the junction and deleted the SHARED
9
+ // node_modules of the main checkout. This helper creates junctions with
10
+ // fs.symlinkSync(..., 'junction') (no shell, no quoting) and always removes the
11
+ // junction BEFORE git worktree remove. Worktrees INSIDE the repo
12
+ // (.claude/worktrees/<name>, the project default) need no junction at all.
13
+ //
14
+ // Usage:
15
+ // node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]
16
+ // node scripts/gds/worktree.js remove <name|path> [--force]
17
+ // node scripts/gds/worktree.js list
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+ const { arg, hasFlag } = require('./cli-lib');
23
+
24
+ // -- pure helpers (exported for tests/worktree_helper.mjs) --------------------
25
+
26
+ // A numeric task id becomes the conventional task-<id>; an explicit name passes
27
+ // through; anything with path separators or dot-traversal is rejected (null).
28
+ function worktreeNameFor(raw) {
29
+ const s = String(raw == null ? '' : raw).trim();
30
+ if (!s || s === '.' || s === '..') return null;
31
+ if (/^\d+$/.test(s)) return `task-${s}`;
32
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return null;
33
+ return s;
34
+ }
35
+
36
+ // Is the worktree OUTSIDE the repo root? Inside the repo, node's resolution
37
+ // walks up to the main checkout's node_modules; outside it can't. Pure string
38
+ // compare (case-insensitive, both slash styles); callers pass absolute paths.
39
+ function isOutsideRepo({ wtPath, repoRoot }) {
40
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
41
+ const wt = norm(wtPath);
42
+ const root = norm(repoRoot);
43
+ return !(wt === root || wt.startsWith(root + '/'));
44
+ }
45
+
46
+ // Only a win32 worktree outside the repo root gets a junction (the OneDrive
47
+ // dodge); posix symlink semantics differ and aren't the trap this fixes.
48
+ function needsJunction({ wtPath, repoRoot, platform }) {
49
+ if (platform !== 'win32') return false;
50
+ return isOutsideRepo({ wtPath, repoRoot });
51
+ }
52
+
53
+ // Ordered removal steps. The junction MUST go before git worktree remove (git
54
+ // follows it into the shared node_modules); a REAL node_modules dir is left for
55
+ // git to refuse — this tool never deletes real directories.
56
+ function removalPlan({ nodeModulesState }) {
57
+ return nodeModulesState === 'junction'
58
+ ? ['unlink-junction', 'git-worktree-remove']
59
+ : ['git-worktree-remove'];
60
+ }
61
+
62
+ // -- CLI ----------------------------------------------------------------------
63
+
64
+ function git(args, opts = {}) {
65
+ return execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
66
+ }
67
+
68
+ // Main checkout root from any worktree — same derivation as merge-lock.js.
69
+ function mainWorktreeRoot() {
70
+ const common = git(['rev-parse', '--git-common-dir']);
71
+ const abs = path.isAbsolute(common) ? common : path.resolve(process.cwd(), common);
72
+ return path.dirname(abs);
73
+ }
74
+
75
+ function listWorktrees(root) {
76
+ const out = git(['worktree', 'list', '--porcelain'], { cwd: root });
77
+ return out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice('worktree '.length));
78
+ }
79
+
80
+ function nodeModulesState(wtPath) {
81
+ const p = path.join(wtPath, 'node_modules');
82
+ let st;
83
+ try { st = fs.lstatSync(p); } catch (_) { return 'absent'; }
84
+ return st.isSymbolicLink() ? 'junction' : 'real-dir';
85
+ }
86
+
87
+ function resolveWorktreePath(root, nameOrPath) {
88
+ const all = listWorktrees(root);
89
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
90
+ const byPath = all.find((w) => norm(w) === norm(nameOrPath));
91
+ if (byPath) return byPath;
92
+ // Case-insensitive, matching the filesystem (and the byPath compare above).
93
+ const byName = all.filter((w) => path.basename(w).toLowerCase() === String(nameOrPath).toLowerCase());
94
+ if (byName.length === 1) return byName[0];
95
+ if (byName.length > 1) {
96
+ console.error(`worktree: "${nameOrPath}" matches ${byName.length} worktrees — pass the full path:`);
97
+ for (const w of byName) console.error(` ${w}`);
98
+ process.exit(2);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function cmdAdd(root, argv) {
104
+ const name = worktreeNameFor(argv[1]);
105
+ if (!name) {
106
+ console.error('worktree add: pass a task id (→ task-<id>) or a simple name (letters/digits/._-).');
107
+ process.exit(2);
108
+ }
109
+ const explicitPath = arg('--path', argv);
110
+ const outside = hasFlag('--outside', argv);
111
+ // --outside exists to dodge OneDrive file locks — a Windows problem. On posix
112
+ // an outside-repo worktree would just lose node_modules resolution (no
113
+ // junction is created there), so refuse rather than hand back a silently
114
+ // broken tree; the in-repo default works everywhere.
115
+ if (outside && process.platform !== 'win32') {
116
+ console.error('worktree add: --outside is a Windows OneDrive dodge; use the default in-repo location here');
117
+ console.error(' (an outside-repo worktree on this platform cannot resolve the shared node_modules).');
118
+ process.exit(2);
119
+ }
120
+ const wtPath = path.resolve(explicitPath
121
+ || (outside ? path.join('C:/tmp', name)
122
+ : path.join(root, '.claude', 'worktrees', name)));
123
+ // Default base origin/main so the branch starts current; best-effort fetch
124
+ // first (offline still works off the local ref).
125
+ const base = arg('--base', argv) || 'origin/main';
126
+ try { execFileSync('git', ['fetch', 'origin', 'main'], { cwd: root, stdio: 'ignore', timeout: 15000 }); } catch (_) { /* offline is fine */ }
127
+
128
+ console.log(`worktree add: ${wtPath} (branch ${name}, base ${base})`);
129
+ try {
130
+ execFileSync('git', ['worktree', 'add', wtPath, '-b', name, base], { cwd: root, stdio: 'inherit' });
131
+ } catch (e) {
132
+ console.error(`worktree add failed — ${e.message}`);
133
+ console.error(' (a branch of that name may already exist: remove it or pick another name)');
134
+ process.exit(1);
135
+ }
136
+
137
+ if (needsJunction({ wtPath, repoRoot: root, platform: process.platform })) {
138
+ const target = path.join(root, 'node_modules');
139
+ if (!fs.existsSync(target)) {
140
+ console.error(`⚠ ${target} does not exist — run npm install in the MAIN checkout first`);
141
+ console.error(' (worktrees never get their own node_modules; they share the main one).');
142
+ } else {
143
+ // fs.symlinkSync with type 'junction' needs no shell and no elevation —
144
+ // this is the fix for the mangled `cmd //c mklink` quoting trap.
145
+ fs.symlinkSync(target, path.join(wtPath, 'node_modules'), 'junction');
146
+ console.log(` node_modules junction → ${target}`);
147
+ console.log(' (remove this worktree with `node scripts/gds/worktree.js remove` — it unlinks the junction first;');
148
+ console.log(' a bare `git worktree remove` would follow the junction into the SHARED node_modules.)');
149
+ }
150
+ } else if (isOutsideRepo({ wtPath, repoRoot: root })) {
151
+ // Explicit --path outside the repo on posix: no junction is created here,
152
+ // so say plainly that node_modules won't resolve rather than fail later.
153
+ console.log(' ⚠ this worktree is outside the repo — node_modules will NOT resolve from here on this platform.');
154
+ }
155
+ console.log('');
156
+ console.log('Next:');
157
+ console.log(` cd "${wtPath}"`);
158
+ console.log(' node scripts/gds/claim.js <task-id> (worktree auto-detected)');
159
+ }
160
+
161
+ function cmdRemove(root, argv) {
162
+ const target = argv[1];
163
+ if (!target) {
164
+ console.error('usage: node scripts/gds/worktree.js remove <name|path> [--force]');
165
+ process.exit(2);
166
+ }
167
+ const wtPath = resolveWorktreePath(root, target);
168
+ if (!wtPath) {
169
+ console.error(`worktree remove: "${target}" is not a registered worktree (see: git worktree list).`);
170
+ process.exit(1);
171
+ }
172
+ if (path.resolve(wtPath).toLowerCase() === path.resolve(root).toLowerCase()) {
173
+ console.error('worktree remove: refusing to remove the MAIN checkout.');
174
+ process.exit(2);
175
+ }
176
+ const state = nodeModulesState(wtPath);
177
+ for (const step of removalPlan({ nodeModulesState: state })) {
178
+ if (step === 'unlink-junction') {
179
+ // rmdir on a junction removes the LINK only, never the target's contents.
180
+ fs.rmdirSync(path.join(wtPath, 'node_modules'));
181
+ console.log(' removed node_modules junction (link only — the shared node_modules is untouched)');
182
+ } else {
183
+ const args = ['worktree', 'remove', ...(hasFlag('--force', argv) ? ['--force'] : []), wtPath];
184
+ try {
185
+ execFileSync('git', args, { cwd: root, stdio: 'inherit' });
186
+ console.log(`worktree removed: ${wtPath}`);
187
+ } catch (e) {
188
+ // OneDrive briefly locks the dir; the failed attempt usually still
189
+ // DEREGISTERS the worktree — verify instead of fighting it with retries.
190
+ const still = listWorktrees(root).some((w) => path.resolve(w).toLowerCase() === path.resolve(wtPath).toLowerCase());
191
+ if (!still) {
192
+ console.log(`worktree deregistered: ${wtPath}`);
193
+ console.log(' (the directory husk is briefly locked — OneDrive — and deletes on its own later)');
194
+ } else {
195
+ console.error(`worktree remove failed — ${e.message}`);
196
+ // The worktree is still live but its junction is already unlinked —
197
+ // restore it so the tree stays usable (node_modules keeps resolving)
198
+ // instead of stranding the builder back at hand-rolled mklink.
199
+ if (state === 'junction') {
200
+ try {
201
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(wtPath, 'node_modules'), 'junction');
202
+ console.error(' node_modules junction restored — the worktree remains fully usable.');
203
+ } catch (restoreErr) {
204
+ console.error(` could not restore the node_modules junction (${restoreErr.message}) —`);
205
+ console.error(' re-create it: node -e "require(\'fs\').symlinkSync(\'<main>/node_modules\', \'<wt>/node_modules\', \'junction\')"');
206
+ }
207
+ }
208
+ if (state === 'real-dir') {
209
+ console.error(' node_modules here is a REAL directory (not a junction) — if it and the other');
210
+ console.error(' untracked files are disposable, re-run with --force.');
211
+ } else {
212
+ console.error(' re-run with --force if the leftover untracked files are disposable.');
213
+ }
214
+ process.exit(1);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ console.log(` (branch "${path.basename(wtPath)}" left in place — the land/ship pipeline owns branch cleanup)`);
220
+ }
221
+
222
+ function cmdList(root) {
223
+ const all = listWorktrees(root);
224
+ for (const w of all) {
225
+ const isMain = path.resolve(w).toLowerCase() === path.resolve(root).toLowerCase();
226
+ const tag = isMain ? 'main checkout' : `node_modules: ${nodeModulesState(w)}`;
227
+ console.log(`${w} [${tag}]`);
228
+ }
229
+ }
230
+
231
+ function main() {
232
+ const argv = process.argv.slice(2);
233
+ const cmd = argv[0];
234
+ let root;
235
+ try {
236
+ root = mainWorktreeRoot();
237
+ } catch (e) {
238
+ console.error('worktree: not a git repo (cannot resolve the main checkout):', e.message);
239
+ process.exit(2);
240
+ }
241
+ if (cmd === 'add') return cmdAdd(root, argv);
242
+ if (cmd === 'remove') return cmdRemove(root, argv);
243
+ if (cmd === 'list') return cmdList(root);
244
+ console.error('usage: node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]');
245
+ console.error(' node scripts/gds/worktree.js remove <name|path> [--force]');
246
+ console.error(' node scripts/gds/worktree.js list');
247
+ process.exit(2);
248
+ }
249
+
250
+ if (require.main === module) main();
251
+
252
+ module.exports = { worktreeNameFor, needsJunction, removalPlan, nodeModulesState };
@@ -162,7 +162,7 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
162
162
  // fill cannot reach them, and a baked one would make every instance advertise
163
163
  // whichever instance happened to author the file (task 1002748, direction C5/C6).
164
164
  // Pure + exported for the test; both origins are injectable so it drives with zero I/O.
165
- function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl()) {
165
+ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl(), instanceBase = safeInstanceBase()) {
166
166
  const world = (brand.identity && brand.identity.worldName) || '';
167
167
  const product = (brand.identity && brand.identity.productName) || '';
168
168
  const tagline = (brand.copy && brand.copy.tagline) || '';
@@ -178,7 +178,8 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
178
178
  // a pack that names no hall of its own resolves to its OWN apex — never to
179
179
  // whichever instance authored the file
180
180
  .split('{{buildersOrigin}}').join(esc(hallOrigin || origin || ''))
181
- .split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''));
181
+ .split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''))
182
+ .split('{{instanceBase}}').join(esc(instanceBase || ''));
182
183
  }
183
184
  // Every token lands in HTML — as element text, or inside a double-quoted
184
185
  // attribute (href/content). Escaping is defence in depth, not a live fix: the
@@ -206,6 +207,14 @@ function safePublicOrigin() {
206
207
  function safeBuildersOrigin() {
207
208
  try { return branding().domains.buildersOrigin || ''; } catch { return ''; }
208
209
  }
210
+ // The apex new projects get their auto-assigned <slug>.<apex> address under
211
+ // (task 1002704) — same server-only `domains` source. Stamped into markup (a
212
+ // <meta> content attribute, never a <script>) so the create wizard's address
213
+ // preview matches what the server will actually assign instead of guessing
214
+ // from location.hostname. Empty = no auto-assign; the wizard falls back.
215
+ function safeInstanceBase() {
216
+ try { return branding().domains.instanceBase || ''; } catch { return ''; }
217
+ }
209
218
  // The instance's own source repo, from the pack's `repo: {owner, name}` binding —
210
219
  // another server-only field (the branding contract keeps repo off clientBranding
211
220
  // alongside domains). A page that wants to link "read the source" must take it
package/src/module-api.js CHANGED
@@ -667,8 +667,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
667
667
  // landed since 1.19.118 with no explicit bump. run 31835957309. (task 1002620)
668
668
  // 1.19.120 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
669
669
  // landed since 1.19.119 with no explicit bump. run 31836959112. (task 1002620)
670
+ // 1.19.121 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
671
+ // landed since 1.19.120 with no explicit bump. run 31838955115. (task 1002620)
672
+ // 1.19.122 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
673
+ // landed since 1.19.121 with no explicit bump. run 31840810826. (task 1002620)
670
674
  // ---------------------------------------------------------------------------
671
- const CORE_VERSION = '1.19.120'; // CI auto-patch carrier (ADR 0161); see changelog above
675
+ const CORE_VERSION = '1.19.122'; // CI auto-patch carrier (ADR 0161); see changelog above
672
676
 
673
677
  // A namespaced logger so a module's log lines are attributable + consistent.
674
678
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,104 @@
1
+ // tests/api_msys_path_repair.mjs
2
+ //
3
+ // Unit tests for repairMsysMangledPath (task 1002809). Git Bash (MSYS) rewrites
4
+ // a leading-slash argument into a Windows path under the Git install root before
5
+ // node ever sees it — `/tasks/123` arrives as `C:/Program Files/Git/tasks/123` —
6
+ // unless the builder remembers MSYS_NO_PATHCONV=1. api.js undoes that mangling
7
+ // itself so no builder needs the env prefix.
8
+ //
9
+ // Contract proved here:
10
+ // 1. A path that already starts with '/' is returned untouched (the repair is
11
+ // a no-op everywhere the shell behaved).
12
+ // 2. A mangled path is repaired by stripping the LONGEST ancestor of EXEPATH
13
+ // that prefixes it — EXEPATH is `<git-root>\bin` while the msys root that
14
+ // prefixes mangled args is `<git-root>`, so ancestor-walking is required.
15
+ // 3. The drive root alone is never treated as the msys root (stripping `C:/`
16
+ // would "repair" any Windows path into nonsense).
17
+ // 4. Non-win32, missing EXEPATH, and unrelated Windows paths pass through.
18
+ //
19
+ // Run: node tests/api_msys_path_repair.mjs
20
+
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { repairMsysMangledPath } = require('../scripts/gds/cli-lib.js');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ try {
32
+ fn();
33
+ passed++;
34
+ console.log(` ok ${name}`);
35
+ } catch (err) {
36
+ failed++;
37
+ console.log(` FAIL ${name}`);
38
+ console.log(` ${err.message}`);
39
+ }
40
+ }
41
+ function eq(actual, expected, msg) {
42
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
43
+ }
44
+
45
+ const GIT_BASH = { exepath: 'C:\\Program Files\\Git\\bin', platform: 'win32' };
46
+
47
+ test('already-correct path is untouched', () => {
48
+ eq(repairMsysMangledPath('/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
49
+ eq(repairMsysMangledPath('/tasks/1002809', GIT_BASH), '/tasks/1002809');
50
+ });
51
+
52
+ test('mangled path is repaired via EXEPATH ancestor (forward slashes)', () => {
53
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1002809', GIT_BASH), '/tasks/1002809');
54
+ eq(repairMsysMangledPath('C:/Program Files/Git/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
55
+ });
56
+
57
+ test('mangled path with backslashes is repaired and normalized', () => {
58
+ eq(repairMsysMangledPath('C:\\Program Files\\Git\\claims', GIT_BASH), '/claims');
59
+ });
60
+
61
+ test('prefix match is case-insensitive (Windows paths are)', () => {
62
+ eq(repairMsysMangledPath('c:/program files/git/tasks/5', GIT_BASH), '/tasks/5');
63
+ });
64
+
65
+ test('longest matching ancestor wins (a legit /Git/... path survives)', () => {
66
+ // Original arg /Git/tasks/9 mangles to <root>/Git/tasks/9 — stripping the
67
+ // git root must give back /Git/tasks/9, not /tasks/9.
68
+ eq(repairMsysMangledPath('C:/Program Files/Git/Git/tasks/9', GIT_BASH), '/Git/tasks/9');
69
+ });
70
+
71
+ test('drive root alone is never stripped', () => {
72
+ // Root-level install: EXEPATH C:\Git\bin → msys root C:/Git is strippable...
73
+ eq(repairMsysMangledPath('C:/Git/tasks/7', { exepath: 'C:\\Git\\bin', platform: 'win32' }), '/tasks/7');
74
+ // ...but C:/ by itself is not a valid msys root — an unrelated Windows path
75
+ // must pass through unchanged, not get "repaired" into /tmp/x.json.
76
+ eq(repairMsysMangledPath('C:/tmp/x.json', GIT_BASH), 'C:/tmp/x.json');
77
+ });
78
+
79
+ test('non-win32 platform passes through', () => {
80
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: 'C:\\Program Files\\Git\\bin', platform: 'linux' }), 'C:/Program Files/Git/tasks/1');
81
+ });
82
+
83
+ test('missing EXEPATH passes through (empty = not a Git Bash shell)', () => {
84
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: '', platform: 'win32' }), 'C:/Program Files/Git/tasks/1');
85
+ });
86
+
87
+ test('empty/undefined input passes through', () => {
88
+ eq(repairMsysMangledPath('', GIT_BASH), '');
89
+ eq(repairMsysMangledPath(undefined, GIT_BASH), undefined);
90
+ });
91
+
92
+ // Static wiring check (the cli_exit_no_abort.mjs pattern): the pure helper being
93
+ // correct is worthless if api.js stops applying it to its positional path arg —
94
+ // that exact mutation survived every behavioural test here.
95
+ test('api.js applies the repair to process.argv[3] (static)', () => {
96
+ const src = fs.readFileSync(fileURLToPath(new URL('../scripts/gds/api.js', import.meta.url)), 'utf8');
97
+ if (!/repairMsysMangledPath\(\s*process\.argv\[3\]\s*\)/.test(src)) {
98
+ throw new Error('api.js no longer wraps process.argv[3] in repairMsysMangledPath(...)');
99
+ }
100
+ });
101
+
102
+ console.log('');
103
+ console.log(`api_msys_path_repair: ${passed} passed, ${failed} failed`);
104
+ process.exit(failed > 0 ? 1 : 0);