@bongos/core 1.19.902 → 1.19.904

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.
Files changed (37) hide show
  1. package/.bongos-core.json +37 -67
  2. package/bin/bongos.js +7 -13
  3. package/docs/copy-inventory.md +6 -7
  4. package/docs/copy-registry.json +9 -18
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/recipes/bongos-cli-release.md +3 -2
  7. package/docs/recipes/instance-first-run.md +1 -2
  8. package/docs/recipes/local-dev.md +0 -2
  9. package/modules/hall-ui/public/collab-lib.js +22 -0
  10. package/modules/hall-ui/public/collab.js +17 -2
  11. package/modules/hall-ui/public/hall-kit.js +11 -5
  12. package/modules/hall-ui/public/profile-charts.js +1 -1
  13. package/package-lock.json +2 -2
  14. package/package.json +1 -1
  15. package/scripts/gds/api.js +1 -25
  16. package/scripts/gds/build-cli-package.js +17 -35
  17. package/scripts/gds/claude-materialize.js +1 -1
  18. package/scripts/gds/cli-lib.js +5 -127
  19. package/scripts/gds/go-live.js +1 -1
  20. package/scripts/gds/login.js +1 -1
  21. package/scripts/gds/plain-cards.js +0 -8
  22. package/scripts/gds/ship-land.js +2 -18
  23. package/src/module-api.js +1 -1
  24. package/tests/bongos_dispatch.mjs +4 -4
  25. package/tests/claude_materialize.mjs +1 -1
  26. package/tests/cli_package.mjs +1 -4
  27. package/tests/cli_surface.mjs +1 -88
  28. package/tests/collab_page.mjs +44 -0
  29. package/tests/hall_kit_charts.mjs +38 -0
  30. package/tests/plain_cards.mjs +2 -2
  31. package/tests/rank_tier_single_source.mjs +3 -5
  32. package/scripts/gds/box-connect-lib.js +0 -550
  33. package/scripts/gds/code.js +0 -105
  34. package/scripts/gds/shell.js +0 -102
  35. package/tests/bongos_box_connect.mjs +0 -166
  36. package/tests/box_scope_predicate.mjs +0 -117
  37. package/tests/cli_box_scope.mjs +0 -92
@@ -1,550 +0,0 @@
1
- // scripts/gds/box-connect-lib.js — shared machinery for `bongos shell` / `bongos code`
2
- // (GDS-V4 · V4.R80 · task 1247; Phase 2 of the bongos toolkit, epic 1245).
3
- //
4
- // `bongos shell` opens a terminal on the builder's cloud dev box; `bongos code`
5
- // attaches VS Code over Remote-SSH at /workspace. Both reduce to the same four
6
- // steps, which live here so the two verbs stay thin and the string-building is
7
- // unit-testable without a network or a box:
8
- //
9
- // 1. Ensure the local ed25519 key exists (~/.ssh/otb_builder) and is registered
10
- // with the GDS — so a FRESH laptop with no prior setup still connects.
11
- // 2. Wake/provision the box (POST /box/ensure) and poll (GET /box/me) until it
12
- // is active and has a hostname (DNS is repointed on every wake/provision, so
13
- // we always connect by hostname, never a cached IP).
14
- // 3. Pin the box's reported host keys into ~/.ssh/known_hosts (authoritative,
15
- // survives a re-provision) and write a marker-delimited Host alias into
16
- // ~/.ssh/config (User root + the box key) so the system OpenSSH client — the
17
- // one VS Code Remote-SSH drives — resolves the box the same way every time.
18
- // 4. Hand off to `ssh` / `code`.
19
- //
20
- // No bash-isms: node is the one guaranteed runtime (#686). The pure helpers below
21
- // (buildSshAliasBlock, upsertSshConfigText, buildKnownHostsUpsert, rankAtLeast,
22
- // buildRemoteShellCommand, parseEnsure, remoteAuthority) take no I/O and are
23
- // covered by tests/bongos_box_connect.mjs.
24
-
25
- 'use strict';
26
-
27
- const fs = require('node:fs');
28
- const os = require('node:os');
29
- const path = require('node:path');
30
- const crypto = require('node:crypto');
31
- const { spawnSync } = require('node:child_process');
32
- const ic = require('../../src/instance-config');
33
- const { cliClient, surfaceAction, assertDevBoxAvailable } = require('./cli-lib');
34
-
35
- // The box always runs as root and the repo is cloned to /workspace (the devcontainer
36
- // standardizes the same path). NOT ~/workspace — root's ~ is /root, which has no clone.
37
- const BOX_USER = 'root';
38
- const WORKSPACE = '/workspace';
39
- // VS Code extension id for Claude Code (mirrors .devcontainer/devcontainer.json).
40
- const CLAUDE_VSCODE_EXTENSION = 'anthropic.claude-code';
41
-
42
- // The rank ladder, entry→top. `--local` is a Metic+ convenience gate (a nudge to
43
- // keep newcomers on the standard cloud box, NOT a security boundary — real
44
- // authority is server-side and unaffected). thetes sits between xenos and metic
45
- // even though requires_rank only enumerates xenos/metic/archon.
46
- const RANK_ORDER = { xenos: 0, thetes: 1, metic: 2, archon: 3 };
47
-
48
- function rankAtLeast(rank, min) {
49
- const r = RANK_ORDER[rank];
50
- const m = RANK_ORDER[min];
51
- if (r == null || m == null) return false;
52
- return r >= m;
53
- }
54
-
55
- function escapeRegExp(s) {
56
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
- }
58
-
59
- // -------------------------------------------------------------------------
60
- // Pure string builders (no I/O — unit tested)
61
- // -------------------------------------------------------------------------
62
-
63
- // A marker-delimited, idempotent Host block for ONE box. Markers carry the
64
- // hostname so re-running upserts THIS box's block without touching any other
65
- // (a builder may connect to more than one instance's box over time).
66
- function buildSshAliasBlock({ hostname, identityFile }) {
67
- return [
68
- `# >>> bongos-managed dev-box: ${hostname} >>>`,
69
- `Host ${hostname}`,
70
- ` HostName ${hostname}`,
71
- ` User ${BOX_USER}`,
72
- ` IdentityFile ${identityFile}`,
73
- ` IdentitiesOnly yes`,
74
- ` StrictHostKeyChecking accept-new`,
75
- `# <<< bongos-managed dev-box: ${hostname} <<<`,
76
- ].join('\n');
77
- }
78
-
79
- // Upsert the alias block into an existing ~/.ssh/config body. Replaces this
80
- // host's block in place if present, else appends it with a blank-line gap.
81
- function upsertSshConfigText(existing, { hostname, identityFile }) {
82
- const block = buildSshAliasBlock({ hostname, identityFile });
83
- const h = escapeRegExp(hostname);
84
- const re = new RegExp(
85
- `# >>> bongos-managed dev-box: ${h} >>>[\\s\\S]*?# <<< bongos-managed dev-box: ${h} <<<`,
86
- 'm'
87
- );
88
- const base = existing || '';
89
- if (re.test(base)) return base.replace(re, block);
90
- const gap = base ? (base.endsWith('\n') ? '\n' : '\n\n') : '';
91
- return `${base}${gap}${block}\n`;
92
- }
93
-
94
- // Does a known_hosts line name any of `names`? Field 1 is either a plain
95
- // `host` / `host,ip` LIST, or — with HashKnownHosts on (the Debian/Ubuntu default)
96
- // — the opaque `|1|<b64 salt>|<b64 hash>` form, hash = HMAC-SHA1(salt) of the name.
97
- //
98
- // (This duplicates ssh-setup.knownHostsLineMatchesHost on purpose: the Dev Box app
99
- // under modules/dev-box/app/ is a separately-packaged subproject with ZERO requires
100
- // into the repo — see modules/dev-box/CLAUDE.md — so the two cannot share a module.)
101
- function knownHostsLineNamesAny(line, names) {
102
- const t = String(line || '').trim();
103
- if (!t || t.startsWith('#')) return false;
104
- const field = t.split(/\s+/)[0] || '';
105
- const wanted = names.filter(Boolean).map(String);
106
- if (!wanted.length) return false;
107
- if (!field.startsWith('|1|')) {
108
- const listed = field.split(',');
109
- return wanted.some((n) => listed.includes(n));
110
- }
111
- const [, , salt, hash] = field.split('|');
112
- if (!salt || !hash) return false;
113
- try {
114
- const key = Buffer.from(salt, 'base64');
115
- return wanted.some((n) => crypto.createHmac('sha1', key).update(n).digest('base64') === hash);
116
- } catch (_) {
117
- return false; // unparseable salt → leave the line alone rather than guess
118
- }
119
- }
120
-
121
- // Authoritative known_hosts upsert: drop EVERY stale line for this box, keep every
122
- // other host, then append the box's reported keys. hostKeysRaw is the newline-joined
123
- // `<keytype> <base64>` string from /box/me; `ip` is optional and drops an IP-keyed
124
- // line for the same box.
125
- //
126
- // It used to compare field 1 to the hostname with `!==`, which missed the two forms
127
- // OpenSSH actually writes — `host,192.0.2.4` (what `StrictHostKeyChecking=accept-new`
128
- // records) and the hashed `|1|salt|hash`. Those lines survived the "replace", so
129
- // every re-pin ACCUMULATED another entry and the client kept verifying against the
130
- // first, now-wrong one: "Host denied (verification failed)" on a box that had just
131
- // re-reported valid keys. Comments and blank lines are preserved.
132
- function buildKnownHostsUpsert(existing, hostname, hostKeysRaw, ip = null) {
133
- const names = [hostname, ip];
134
- const keep = (existing || '')
135
- .split('\n')
136
- .filter((line) => line.trim() && !knownHostsLineNamesAny(line, names));
137
- const added = (hostKeysRaw || '')
138
- .split('\n')
139
- .map((k) => k.trim())
140
- .filter(Boolean)
141
- .map((k) => `${hostname} ${k}`);
142
- const all = [...keep, ...added];
143
- return all.length ? all.join('\n') + '\n' : '';
144
- }
145
-
146
- // The on-box re-scope STEP (BV1.R101 / goal 1000051) — no `cd`, the caller
147
- // positions cwd. Re-runs the box source-fetch so /workspace holds exactly the
148
- // builder's ACTIVE-claim scope: "claim → open → the scoped folder." box-sync.js
149
- // re-runs infra/box-source-fetch.sh, which calls GET /box/source-access —
150
- // claim-driven since R94 — so a fresh claim (or a switch) re-scopes the ONE
151
- // working folder in place (the owner's one-folder decision). Best-effort (`|| true`):
152
- // a sync hiccup must never block the connection.
153
- const RESYNC_STEP = `echo '↪ Syncing your box to your active claim…'; node scripts/gds/box-sync.js || true`;
154
-
155
- // The remote command `ssh -t root@host …` runs. Lands the builder in /workspace;
156
- // with `sync` (BV1.R101) it re-scopes that folder to the active claim first, so a
157
- // builder who just claimed/switched arrives in a folder holding exactly that task's
158
- // code. On the first connection to this box it then runs an interactive `claude` so
159
- // the Trusted-Devices /login enrollment (interactive-only by design — ADR 0038) is
160
- // completed once, then drops to a login shell. Silent on every later connect.
161
- function buildRemoteShellCommand({ firstRun = false, sync = false, workdir = WORKSPACE } = {}) {
162
- const cd = `cd ${workdir} 2>/dev/null || true`;
163
- const resync = sync ? `${RESYNC_STEP}; ` : '';
164
- const login = firstRun
165
- ? `echo '↪ First time on this box — complete /login in claude once (browser device flow), then exit to a shell.'; claude || true; `
166
- : '';
167
- return `${cd}; ${resync}${login}exec bash -l`;
168
- }
169
-
170
- // The full one-shot remote command for `bongos code` (VS Code Remote-SSH opens the
171
- // folder directly rather than running a login shell, so the re-scope rides a
172
- // separate one-shot ssh before the window opens): cd + the re-scope step, no shell.
173
- function buildRemoteResyncCommand({ workdir = WORKSPACE } = {}) {
174
- return `cd ${workdir} 2>/dev/null || true; ${RESYNC_STEP}`;
175
- }
176
-
177
- // VS Code Remote-SSH authority for a host (VS Code resolves it via the ~/.ssh
178
- // Host alias we wrote, so User/IdentityFile/host-key policy all apply).
179
- function remoteAuthority(hostname) {
180
- return `ssh-remote+${hostname}`;
181
- }
182
-
183
- // Normalize the POST /box/ensure body into { action, state, message }.
184
- function parseEnsure(data) {
185
- const d = data || {};
186
- return { action: d.action || null, state: d.state || null, message: d.message || null };
187
- }
188
-
189
- // -------------------------------------------------------------------------
190
- // Local toolchain + paths (thin I/O)
191
- // -------------------------------------------------------------------------
192
-
193
- function sshKeyPath() {
194
- // OTB_SSH_KEY (via the instance env prefix) is the advanced/test override; the
195
- // Dev Box app + connect.sh both pin the fixed ~/.ssh/otb_builder default.
196
- return ic.resolveEnv('SSH_KEY') || path.join(os.homedir(), '.ssh', 'otb_builder');
197
- }
198
-
199
- function sshDir() {
200
- return path.join(os.homedir(), '.ssh');
201
- }
202
-
203
- // Is a CLI launchable? (shell:true on win32 so a .cmd/.exe shim resolves — same
204
- // posture as doctor.js's claude/gh checks.)
205
- function binPresent(bin, versionArgs = ['--version']) {
206
- try {
207
- const r = spawnSync(bin, versionArgs, {
208
- encoding: 'utf8',
209
- shell: process.platform === 'win32',
210
- windowsHide: true,
211
- timeout: 15000,
212
- });
213
- return !!r && !r.error && (r.status === 0 || r.status === null);
214
- } catch (_) {
215
- return false;
216
- }
217
- }
218
-
219
- // Local SHA256 fingerprint of a public key, or null if ssh-keygen can't read it.
220
- function localKeyFingerprint(pubPath) {
221
- try {
222
- const r = spawnSync('ssh-keygen', ['-lf', pubPath], {
223
- encoding: 'utf8',
224
- shell: process.platform === 'win32',
225
- windowsHide: true,
226
- timeout: 15000,
227
- });
228
- if (!r || r.error || r.status !== 0) return null;
229
- const m = (r.stdout || '').match(/SHA256:[A-Za-z0-9+/=]+/);
230
- return m ? m[0] : null;
231
- } catch (_) {
232
- return null;
233
- }
234
- }
235
-
236
- // -------------------------------------------------------------------------
237
- // SSH key: ensure it exists locally + is registered with the GDS
238
- // -------------------------------------------------------------------------
239
-
240
- // Ensure ~/.ssh/otb_builder exists (generate an ed25519 pair if not) and return
241
- // { keyPath, pubKey, created }. Throws with actionable guidance if ssh-keygen is
242
- // missing or the key can't be read.
243
- function ensureSshKey({ log = console.log } = {}) {
244
- const keyPath = sshKeyPath();
245
- const pubPath = `${keyPath}.pub`;
246
- let created = false;
247
- if (!fs.existsSync(keyPath)) {
248
- fs.mkdirSync(sshDir(), { recursive: true });
249
- try {
250
- fs.chmodSync(sshDir(), 0o700);
251
- } catch (_) {
252
- /* best-effort on non-POSIX */
253
- }
254
- log(` Creating an SSH key at ${keyPath.replace(os.homedir(), '~')} (private half stays on this machine)…`);
255
- const comment = `bongos ${os.hostname() || 'device'}`.slice(0, 80);
256
- // No separate presence probe — ssh-keygen has no side-effect-free version
257
- // flag, and a missing binary surfaces here as ENOENT with a clear message.
258
- const r = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', comment, '-f', keyPath, '-q'], {
259
- stdio: 'inherit',
260
- shell: process.platform === 'win32',
261
- windowsHide: true,
262
- });
263
- if (r && r.error && r.error.code === 'ENOENT') {
264
- throw new Error(
265
- 'ssh-keygen was not found. Install OpenSSH (macOS/Linux ship it; on Windows enable the "OpenSSH Client" optional feature) and re-run.'
266
- );
267
- }
268
- if (!r || r.error || r.status !== 0) {
269
- throw new Error(`ssh-keygen failed to create ${keyPath}${r && r.error ? `: ${r.error.message}` : ''}`);
270
- }
271
- created = true;
272
- }
273
- let pubKey = null;
274
- try {
275
- // Trim to "<type> <base64>" — matches what connect.sh/the server keep.
276
- const parts = fs.readFileSync(pubPath, 'utf8').trim().split(/\s+/);
277
- if (parts.length >= 2) pubKey = `${parts[0]} ${parts[1]}`;
278
- } catch (_) {
279
- /* handled below */
280
- }
281
- if (!pubKey) throw new Error(`could not read a valid public key from ${pubPath}`);
282
- return { keyPath, pubKey, created };
283
- }
284
-
285
- // PURE (unit tested): should we POST this key? The old inline version FAILED OPEN —
286
- // when the fingerprint couldn't be computed (ssh-keygen missing from a leaner PATH)
287
- // or the key list couldn't be fetched, it decided "don't register" and connected
288
- // anyway, so a builder whose key was never on the server got a bare "Permission
289
- // denied" with nothing to act on. Uncertainty now means REGISTER.
290
- //
291
- // A redundant POST costs nothing: POST /box/ssh-key upserts by fingerprint
292
- // (modules/dev-box/routes/box.js — an existing fingerprint is explicitly exempt from
293
- // MAX_SSH_KEYS_PER_BUILDER), so re-registering can neither add a row nor trip the
294
- // cap. Do not "optimize" the skip back in.
295
- function decideKeyRegistration({ created, fingerprint, listedKeys, listFailed } = {}) {
296
- if (created) return { register: true, why: 'created' };
297
- if (listFailed || !Array.isArray(listedKeys)) return { register: true, why: 'list_unavailable' };
298
- if (!fingerprint) return { register: true, why: 'fingerprint_unknown' };
299
- if (listedKeys.some((k) => k && k.fingerprint === fingerprint)) return { register: false, why: 'already_registered' };
300
- return { register: true, why: 'not_listed' };
301
- }
302
-
303
- // Register the public key with the GDS unless the server already demonstrably has
304
- // it. Best-effort: a failure here is surfaced as a warning, not fatal (SSH will
305
- // report a genuine auth problem clearly).
306
- async function ensureKeyRegistered({ created, keyPath, pubKey, log = console.log } = {}) {
307
- const api = await cliClient();
308
- let listedKeys = null;
309
- let listFailed = false;
310
- if (!created) {
311
- try {
312
- const res = await api.box.getBoxSshKey();
313
- if (res.ok && res.data && Array.isArray(res.data.keys)) listedKeys = res.data.keys;
314
- else listFailed = true;
315
- } catch (_) {
316
- listFailed = true;
317
- }
318
- }
319
- const { register } = decideKeyRegistration({
320
- created,
321
- fingerprint: created ? null : localKeyFingerprint(`${keyPath}.pub`),
322
- listedKeys,
323
- listFailed,
324
- });
325
- if (!register) return { registered: false };
326
- try {
327
- const label = `bongos ${process.platform}`.slice(0, 80);
328
- const res = await api.box.postBoxSshKey({ body: { public_key: pubKey, label } });
329
- if (res.ok) {
330
- log(' ✓ Registered your public key with the dev system.');
331
- return { registered: true };
332
- }
333
- log(` ! Could not register your key (${res.data?.error || res.status}); if SSH is refused, add it via ${surfaceAction('box')}.`);
334
- } catch (err) {
335
- log(` ! Could not register your key (${err.message}); if SSH is refused, add it via ${surfaceAction('box')}.`);
336
- }
337
- return { registered: false };
338
- }
339
-
340
- // -------------------------------------------------------------------------
341
- // Box lifecycle: ensure active + read connection details
342
- // -------------------------------------------------------------------------
343
-
344
- function sleep(ms) {
345
- return new Promise((resolve) => setTimeout(resolve, ms));
346
- }
347
-
348
- // Wake/provision the box and poll until it is active with a hostname. Returns the
349
- // public box object from /box/me ({ state, hostname, ip, host_keys, … }).
350
- // Throws on a provision denial (rank/blocked) or a timeout.
351
- async function ensureBoxActive({ log = console.log, timeoutMs = 6 * 60 * 1000, intervalMs = 5000, sleepFn = sleep } = {}) {
352
- await assertDevBoxAvailable();
353
- const api = await cliClient();
354
- const ensureRes = await api.box.postBoxEnsure();
355
- if (ensureRes.status === 403) {
356
- const msg = ensureRes.data?.message || ensureRes.data?.reason || 'dev-box access denied';
357
- throw new Error(`Box could not be started: ${msg}`);
358
- }
359
- if (!ensureRes.ok) {
360
- throw new Error(`POST /box/ensure failed (HTTP ${ensureRes.status}${ensureRes.data?.error ? `: ${ensureRes.data.error}` : ''})`);
361
- }
362
- const { action, message } = parseEnsure(ensureRes.data);
363
- if (action === 'ready') {
364
- log(' ✓ Box is already active.');
365
- } else {
366
- log(` Box is being ${action === 'wake' ? 'woken' : action === 'provision' ? 'created' : 'prepared'} — ${message || 'this can take a minute or two'}.`);
367
- }
368
-
369
- const deadline = Date.now() + timeoutMs;
370
- let lastState = null;
371
- let announced = 0;
372
- for (;;) {
373
- const me = await api.box.getBoxMe();
374
- const box = me.ok && me.data ? me.data.box : null;
375
- if (box && box.state === 'active' && box.hostname) {
376
- log(` ✓ Box active: ${box.hostname}`);
377
- return box;
378
- }
379
- if (box && box.state !== lastState) {
380
- lastState = box.state;
381
- }
382
- if (Date.now() >= deadline) {
383
- throw new Error(
384
- `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the box to become active (last state: ${box ? box.state : 'unknown'}). ` +
385
- 'It may still be provisioning — re-run in a minute, or check `bongos box` / the hall.'
386
- );
387
- }
388
- // A quiet progress line every ~15s so the wait doesn't look hung.
389
- if (Date.now() - announced >= 15000) {
390
- log(` …still ${box && box.state ? box.state : 'starting'} (waiting for the box to come up)`);
391
- announced = Date.now();
392
- }
393
- await sleepFn(intervalMs);
394
- }
395
- }
396
-
397
- // Fetch the caller's rank (GET /me). Returns the rank string, or null if it
398
- // can't be determined.
399
- async function fetchRank() {
400
- try {
401
- const api = await cliClient({ allowUnauthenticated: true });
402
- const res = await api.me.getMe();
403
- if (res.ok && res.data && res.data.builder) return res.data.builder.rank || null;
404
- } catch (_) {
405
- /* fall through */
406
- }
407
- return null;
408
- }
409
-
410
- // -------------------------------------------------------------------------
411
- // Write ~/.ssh/config alias + pin known_hosts (idempotent, best-effort)
412
- // -------------------------------------------------------------------------
413
-
414
- function writeSshAlias({ hostname, keyPath, log = console.log }) {
415
- const cfgPath = path.join(sshDir(), 'config');
416
- // IdentityFile keeps the ~ literal so it reads cleanly and works cross-machine.
417
- const identityFile = keyPath.startsWith(os.homedir())
418
- ? keyPath.replace(os.homedir(), '~')
419
- : keyPath;
420
- let existing = '';
421
- try {
422
- existing = fs.readFileSync(cfgPath, 'utf8');
423
- } catch (_) {
424
- /* no config yet */
425
- }
426
- const next = upsertSshConfigText(existing, { hostname, identityFile });
427
- if (next !== existing) {
428
- fs.mkdirSync(sshDir(), { recursive: true });
429
- fs.writeFileSync(cfgPath, next, { mode: 0o600 });
430
- try {
431
- fs.chmodSync(cfgPath, 0o600);
432
- } catch (_) {
433
- /* best-effort */
434
- }
435
- log(` ✓ Wrote SSH host alias for ${hostname} into ~/.ssh/config`);
436
- }
437
- }
438
-
439
- function pinKnownHosts({ hostname, hostKeys, ip = null, log = console.log }) {
440
- if (!hostKeys) return; // box hasn't reported keys yet; accept-new covers first connect
441
- const khPath = path.join(sshDir(), 'known_hosts');
442
- let existing = '';
443
- try {
444
- existing = fs.readFileSync(khPath, 'utf8');
445
- } catch (_) {
446
- /* none yet */
447
- }
448
- // DNS is repointed on every wake, so an old line keyed by the PREVIOUS box's IP
449
- // is stale too — pass it so the upsert drops it instead of leaving it to match.
450
- const next = buildKnownHostsUpsert(existing, hostname, hostKeys, ip);
451
- if (next !== existing) {
452
- fs.mkdirSync(sshDir(), { recursive: true });
453
- fs.writeFileSync(khPath, next, { mode: 0o600 });
454
- try {
455
- fs.chmodSync(khPath, 0o600);
456
- } catch (_) {
457
- /* best-effort */
458
- }
459
- log(` ✓ Pinned ${hostname}'s verified host key(s) into ~/.ssh/known_hosts`);
460
- }
461
- }
462
-
463
- // The one-shot "get the box ready + local SSH set up" used by both verbs.
464
- // Returns { box, keyPath }.
465
- async function prepareConnection({ log = console.log } = {}) {
466
- // Before touching keys: key registration hits the box API too, so without this the reader gets
467
- // a puzzling "Could not register your key (not_found)" line before the real explanation.
468
- await assertDevBoxAvailable();
469
- const { keyPath, pubKey, created } = ensureSshKey({ log });
470
- await ensureKeyRegistered({ created, keyPath, pubKey, log });
471
- const box = await ensureBoxActive({ log });
472
- pinKnownHosts({ hostname: box.hostname, hostKeys: box.host_keys, ip: box.ip, log });
473
- writeSshAlias({ hostname: box.hostname, keyPath, log });
474
- return { box, keyPath };
475
- }
476
-
477
- // Re-scope /workspace to the builder's active claim over a one-shot ssh, BEFORE
478
- // `bongos code` opens the VS Code window (BV1.R101). Best-effort: a failure just
479
- // means the folder holds its last-synced scope — the builder can /builder-sync
480
- // inside the VS Code terminal — so it never blocks the open. Returns true on a
481
- // clean run. Mirrors shell.js's ssh args (explicit key + host-key policy).
482
- function resyncBoxOverSsh({ host, keyPath, log = console.log }) {
483
- const args = [
484
- '-i', keyPath, '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new',
485
- `${BOX_USER}@${host}`, buildRemoteResyncCommand(),
486
- ];
487
- log(` Scoping ${WORKSPACE} to your active claim…`);
488
- const r = spawnSync('ssh', args, { stdio: 'inherit' });
489
- return !!r && !r.error && (r.status === 0 || r.status == null);
490
- }
491
-
492
- // First-run marker (per box hostname). /login enrollment persists on the box, so
493
- // we only force the interactive claude handoff the first time we connect to a
494
- // given box from this laptop.
495
- function loginMarkerPath(hostname) {
496
- const safe = String(hostname).replace(/[^A-Za-z0-9._-]/g, '_');
497
- return ic.configPath(`box-login-${safe}.done`);
498
- }
499
-
500
- function isFirstLogin(hostname) {
501
- return !fs.existsSync(loginMarkerPath(hostname));
502
- }
503
-
504
- function markLoginDone(hostname) {
505
- const p = loginMarkerPath(hostname);
506
- try {
507
- fs.mkdirSync(path.dirname(p), { recursive: true });
508
- fs.writeFileSync(p, `${new Date().toISOString()}\n`);
509
- } catch (_) {
510
- /* best-effort — a missed marker just re-runs the harmless handoff */
511
- }
512
- }
513
-
514
- module.exports = {
515
- // constants
516
- BOX_USER,
517
- WORKSPACE,
518
- CLAUDE_VSCODE_EXTENSION,
519
- RANK_ORDER,
520
- // pure helpers (tested)
521
- rankAtLeast,
522
- buildSshAliasBlock,
523
- upsertSshConfigText,
524
- buildKnownHostsUpsert,
525
- knownHostsLineNamesAny,
526
- decideKeyRegistration,
527
- buildRemoteShellCommand,
528
- buildRemoteResyncCommand, // BV1.R101 — one-shot re-scope for `bongos code`
529
- RESYNC_STEP,
530
- remoteAuthority,
531
- parseEnsure,
532
- // toolchain / paths
533
- sshKeyPath,
534
- binPresent,
535
- localKeyFingerprint,
536
- // key + box
537
- ensureSshKey,
538
- ensureKeyRegistered,
539
- ensureBoxActive,
540
- fetchRank,
541
- // local ssh wiring
542
- writeSshAlias,
543
- pinKnownHosts,
544
- prepareConnection,
545
- resyncBoxOverSsh, // BV1.R101 — re-scope /workspace before `bongos code` opens
546
- // first-run login handoff
547
- loginMarkerPath,
548
- isFirstLogin,
549
- markLoginDone,
550
- };
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/gds/code.js — `bongos code` (GDS-V4 · V4.R80 · task 1247).
3
- //
4
- // Attaches VS Code to the builder's cloud dev box over Remote-SSH at /workspace,
5
- // so the laptop is a thin client and the box is the full environment. Wakes the
6
- // box (POST /box/ensure), ensures the local SSH key + host alias, best-effort
7
- // installs Claude's VS Code extension ON THE BOX (the cloud box isn't a
8
- // devcontainer, so the extension isn't pre-baked — VS Code installs it into the
9
- // remote server on connect), then opens the folder.
10
- //
11
- // bongos code open VS Code Remote-SSH on the cloud box
12
- // bongos code --local open VS Code on the LOCAL clone (Metic+ only)
13
- // bongos code --no-extension skip the best-effort remote extension install
14
-
15
- 'use strict';
16
-
17
- const os = require('node:os');
18
- const fs = require('node:fs');
19
- const path = require('node:path');
20
- const { spawnSync, execFileSync } = require('node:child_process');
21
- const lib = require('./box-connect-lib');
22
-
23
- const WIN = process.platform === 'win32';
24
-
25
- function repoRoot() {
26
- try {
27
- return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
28
- } catch (_) {
29
- return process.cwd();
30
- }
31
- }
32
-
33
- function runCode(args, { inherit = true } = {}) {
34
- return spawnSync('code', args, { stdio: inherit ? 'inherit' : 'ignore', shell: WIN, windowsHide: true });
35
- }
36
-
37
- async function runLocal({ log }) {
38
- const rank = await lib.fetchRank();
39
- if (!lib.rankAtLeast(rank, 'metic')) {
40
- console.error(
41
- `✖ \`--local\` is a Metic+ convenience (your rank: ${rank || 'unknown'}). ` +
42
- 'The standard environment is the cloud box: run `bongos code` without `--local`.'
43
- );
44
- process.exit(1);
45
- }
46
- const root = repoRoot();
47
- log(`▸ Opening VS Code on the local clone: ${root.replace(os.homedir(), '~')}`);
48
- if (fs.existsSync(path.join(root, '.devcontainer', 'devcontainer.json'))) {
49
- log(' (a .devcontainer is present — use "Reopen in Container" in VS Code for the full boxed environment.)');
50
- }
51
- const r = runCode([root]);
52
- process.exit(r.status == null ? 1 : r.status);
53
- }
54
-
55
- async function main() {
56
- const argv = process.argv.slice(2);
57
- const local = argv.includes('--local');
58
- const noExtension = argv.includes('--no-extension');
59
- const log = console.log;
60
-
61
- if (!lib.binPresent('code')) {
62
- console.error('✖ The VS Code `code` command was not found on your PATH.');
63
- console.error(' Install VS Code (https://code.visualstudio.com), then run the command');
64
- console.error(' palette action "Shell Command: Install \'code\' command in PATH", and re-run.');
65
- process.exit(1);
66
- }
67
-
68
- if (local) return runLocal({ log });
69
-
70
- console.log(`▸ Connecting VS Code to your ${require('./cli-lib').productName()} dev box…`);
71
- const { box, keyPath } = await lib.prepareConnection({ log });
72
- const host = box.hostname;
73
- const authority = lib.remoteAuthority(host);
74
-
75
- // BV1.R101: re-scope /workspace to the active claim before the window opens, so
76
- // VS Code shows exactly that task's code. Best-effort — VS Code opens regardless.
77
- lib.resyncBoxOverSsh({ host, keyPath, log });
78
-
79
- if (!noExtension) {
80
- log(` Installing Claude's VS Code extension on the box (${lib.CLAUDE_VSCODE_EXTENSION})…`);
81
- const ext = runCode(['--remote', authority, '--install-extension', lib.CLAUDE_VSCODE_EXTENSION]);
82
- if (!ext || ext.error || (ext.status != null && ext.status !== 0)) {
83
- log(' ! Could not auto-install the extension (non-fatal). VS Code will offer to install it once connected.');
84
- }
85
- }
86
-
87
- console.log(`▸ Opening ${lib.WORKSPACE} on ${host} in VS Code…`);
88
- const r = runCode(['--remote', authority, lib.WORKSPACE]);
89
- if (r.error) {
90
- console.error(`✖ Could not launch VS Code: ${r.error.message}`);
91
- process.exit(1);
92
- }
93
- if (lib.isFirstLogin(host)) {
94
- console.log(
95
- '\nℹ First time on this box? If Claude in VS Code asks you to enroll this device (/login),\n' +
96
- ' run `bongos shell` once to complete it in a terminal — then reload the VS Code window.'
97
- );
98
- }
99
- process.exit(r.status == null ? 1 : r.status);
100
- }
101
-
102
- main().catch((err) => {
103
- console.error(`✖ ${err.message}`);
104
- process.exit(1);
105
- });