mixdog 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  Notable changes are tracked per release, starting at 0.7.1.
4
4
 
5
+ ## 0.7.8 — 2026-06-18
6
+
7
+ - Add `mixdog install --demo`: runs the real setup-wizard UI (menus,
8
+ checkboxes, progress) in an isolated temp sandbox that is auto-cleaned, so
9
+ you can try the install experience without writing anything to your real
10
+ config.
11
+ - Honor `CLAUDE_CONFIG_DIR` in the first-install seed helpers
12
+ (`disable-claude-builtins`, user-data backups) instead of hardcoding
13
+ `~/.claude` — fixes a latent path bug for anyone using `CLAUDE_CONFIG_DIR`
14
+ and makes the demo sandbox fully isolated.
15
+
16
+ ## 0.7.7 — 2026-06-18
17
+
18
+ - Add in-app OAuth sign-in for the Codex (`openai-oauth`) and Claude
19
+ (`anthropic-oauth`) providers, mirroring the existing Grok login: the
20
+ Setup UI can sign in directly when no CLI credentials are detected.
21
+ - `npx mixdog` (no args) now runs the installer directly; `mixdog install`
22
+ and `mixdog install --dry-run` (a no-side-effect install preview) are the
23
+ install/test entry points. Any other args launch Claude Code.
24
+ - Add a Claude Code CLI preflight to setup: if `claude` is missing, offer to
25
+ run the official native installer (consent-prompted; never in CI).
26
+ - Polished terminal setup wizard: arrow-key menus, checkboxes, progress bars,
27
+ and spinners (zero-dependency, grapheme/CJK-aware) replace the plain prompts.
28
+ - Bridge workers stay visible in the statusline correctly: completed workers
29
+ persist as idle for 1h (was swept at 5 min), and worker scoping survives a
30
+ daemon restart (clientHostPid instead of the volatile owner-session id).
31
+ - Fix `bridge list includeClosed:true` so closed-session tombstones are
32
+ actually returned.
33
+
5
34
  ## 0.7.1 — 2026-06-12
6
35
 
7
36
  Initial versioned release.
package/README.md CHANGED
@@ -51,14 +51,18 @@ as JSON you can diff.
51
51
  **From npm (terminal):**
52
52
 
53
53
  ```bash
54
- npx mixdog setup # register plugin + run the setup wizard
54
+ npx mixdog # register plugin + run the setup wizard
55
+ mixdog install # same install + wizard (explicit subcommand)
56
+ mixdog install --dry-run # preview install steps (no writes, prompts, or installs)
55
57
  npm i -g mixdog # then launch Claude Code with mixdog pre-loaded:
56
- mixdog # → claude --dangerously-load-development-channels plugin:mixdog@trib-plugin
58
+ mixdog # no args same setup wizard as `npx mixdog`
59
+ mixdog --version # other args (e.g. `setup`) → claude --dangerously-load-development-channels plugin:mixdog@trib-plugin …
57
60
  mixdog --dangerously-skip-permissions # extra Claude flags pass through
58
61
  ```
59
62
 
60
- `mixdog` (no subcommand) starts Claude Code with mixdog pre-loaded; the `claude`
61
- CLI must be on your PATH.
63
+ With no arguments, or with `install` (optional `--dry-run`), `mixdog` runs the
64
+ install flow. Any other arguments start Claude Code with mixdog pre-loaded; the
65
+ `claude` CLI must be on your PATH.
62
66
 
63
67
  **Inside Claude Code (slash commands):**
64
68
 
@@ -508,6 +508,77 @@ function resolveTranscriptPath() {
508
508
  return '';
509
509
  }
510
510
 
511
+ // Prior-session transcript for the ingest-watermark barrier on `/clear`.
512
+ // SessionStart carries the NEW session_id; rules-part rebind records the
513
+ // outgoing path in active-instance.json `priorTranscriptPath` before updating
514
+ // `transcriptPath`. Resolution is deterministic (no mtime scan).
515
+ function resolvePriorTranscriptPath() {
516
+ const sessionId = String(_event.session_id || _event.sessionId || '').trim();
517
+ const active = readJson(ACTIVE_INSTANCE_FILE);
518
+ const tp = active && active.transcriptPath;
519
+ if (typeof tp === 'string' && tp && fs.existsSync(tp)) {
520
+ const base = path.basename(tp, '.jsonl');
521
+ if (sessionId && base !== sessionId) return tp;
522
+ }
523
+ const prior = active && active.priorTranscriptPath;
524
+ if (typeof prior === 'string' && prior && fs.existsSync(prior)) {
525
+ const base = path.basename(prior, '.jsonl');
526
+ if (sessionId && base !== sessionId) return prior;
527
+ }
528
+ return '';
529
+ }
530
+
531
+ // Poll memory-service until offsetBytes >= fileSize for the prior transcript,
532
+ // bounded by a dedicated sub-budget so cycle1 keeps a floor of the grace window.
533
+ async function awaitPriorTranscriptIngested(deadline, opts = {}) {
534
+ const slot = opts.slot || 'unknown';
535
+ const start = Date.now();
536
+ const windowMs = Math.max(0, deadline - start);
537
+ // ~4s cap on the 8s graceMs case; smaller windows get half — cycle1 retains the rest.
538
+ const BARRIER_BUDGET_MS = Math.min(4000, windowMs / 2);
539
+ const barrierDeadline = Math.min(deadline, start + BARRIER_BUDGET_MS);
540
+ const priorPath = resolvePriorTranscriptPath();
541
+ if (!priorPath) {
542
+ teeStderr(`[session-start] ingest-barrier slot=${slot} skip reason=no-prior-transcript source=${_event.source || ''}\n`);
543
+ return;
544
+ }
545
+ teeStderr(`[session-start] ingest-barrier slot=${slot} priorPath=${priorPath}\n`);
546
+ const pollMs = 200;
547
+ while (Date.now() < barrierDeadline) {
548
+ const remaining = barrierDeadline - Date.now();
549
+ if (remaining <= 0) break;
550
+ const port = await getLiveMemoryServicePort(Math.min(200, remaining));
551
+ if (!port) {
552
+ await sleepMs(Math.min(pollMs, remaining));
553
+ continue;
554
+ }
555
+ try {
556
+ const res = await httpPostJson({
557
+ hostname: '127.0.0.1',
558
+ port,
559
+ path: '/transcript/ingest-sync',
560
+ timeoutMs: Math.min(5000, remaining),
561
+ body: { path: priorPath, cwd: _event.cwd || process.cwd() },
562
+ });
563
+ if (res.statusCode === 200) {
564
+ let parsed;
565
+ try { parsed = JSON.parse(res.body); } catch { parsed = null; }
566
+ if (parsed && parsed.ok === true && parsed.complete === true) {
567
+ teeStderr(`[session-start] ingest-barrier slot=${slot} complete offsetBytes=${parsed.offsetBytes} fileSize=${parsed.fileSize} elapsed=${Date.now() - start}ms\n`);
568
+ return;
569
+ }
570
+ if (parsed && parsed.ok === true) {
571
+ teeStderr(`[session-start] ingest-barrier slot=${slot} pending offsetBytes=${parsed.offsetBytes} fileSize=${parsed.fileSize}\n`);
572
+ }
573
+ }
574
+ } catch (e) {
575
+ teeStderr(`[session-start] ingest-barrier slot=${slot} err=${(e && e.message) || e}\n`);
576
+ }
577
+ await sleepMs(Math.min(pollMs, Math.max(0, barrierDeadline - Date.now())));
578
+ }
579
+ teeStderr(`[session-start] ingest-barrier slot=${slot} deadline-reached proceed elapsed=${Date.now() - start}ms\n`);
580
+ }
581
+
511
582
  function rebindActiveInstance() {
512
583
  try {
513
584
  const activePath = ACTIVE_INSTANCE_FILE;
@@ -1071,6 +1142,7 @@ async function requestCycle1(timeoutMs, opts = {}) {
1071
1142
  const transientDeadline = start + TRANSIENT_RETRY_BUDGET_MS;
1072
1143
 
1073
1144
  try {
1145
+ await awaitPriorTranscriptIngested(deadline, opts);
1074
1146
  let r1 = await requestCycle1Once(deadline, opts);
1075
1147
  let transientAttempt = 0;
1076
1148
  while (
@@ -1085,8 +1157,7 @@ async function requestCycle1(timeoutMs, opts = {}) {
1085
1157
  }
1086
1158
  if (!r1.ok) return r1;
1087
1159
  if (r1.processed != null && r1.processed > 0) return r1;
1088
- // Genuine empty (no pending raw rows AND no in-flight dedup hit) — retry
1089
- // would do nothing useful, skip the second pass.
1160
+ // After ingest-barrier, pendingRows===0 with no in-flight dedup is terminal.
1090
1161
  if (r1.pendingRows === 0 && r1.skippedInFlight === false) return r1;
1091
1162
  const RETRY_DELAY_MS = 800;
1092
1163
  const remaining = deadline - Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
5
5
  "author": "mixdog contributors <dev@tribgames.com>",
6
6
  "license": "MIT",
@@ -8,80 +8,26 @@
8
8
  * Deps: node built-ins only (node:child_process, node:fs, node:path, node:url, node:os).
9
9
  */
10
10
  import { spawnSync, spawn } from 'node:child_process';
11
- import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { join, dirname } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { prepShim } from './prep-shim.mjs';
16
16
  import { prepPatch } from './prep-patch.mjs';
17
+ import { resolveBun, installBunViaNpm } from './resolve-bun.mjs';
17
18
 
18
19
  const scriptDir = dirname(fileURLToPath(import.meta.url));
19
20
  const pluginRoot = dirname(scriptDir);
20
21
  const isWin = process.platform === 'win32';
21
22
 
22
- // ---------------------------------------------------------------------------
23
- // Locate bun — returns absolute path or null.
24
- // ---------------------------------------------------------------------------
25
- function findSystemBun() {
26
- const cmd = isWin ? 'where.exe' : 'which';
27
- const r = spawnSync(cmd, ['bun'], { encoding: 'utf8', windowsHide: true });
28
- if (r.status !== 0 || !r.stdout) return null;
29
- const lines = r.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
30
- // On Windows `where bun` can list a .cmd/.ps1 shim before the real .exe;
31
- // the downstream spawn is no-shell, so prefer an actual .exe when present.
32
- const pick = isWin ? (lines.find(l => l.toLowerCase().endsWith('.exe')) ?? lines[0]) : lines[0];
33
- if (!pick) return null;
34
- return existsSync(pick) ? pick : null;
35
- }
36
-
37
- function findLocalBun() {
38
- // After `npm install bun`, the .bin entry on Windows is a .cmd/.sh shim
39
- // (not bun.exe), while the real binary lands in node_modules/bun/bin/. Check
40
- // the package's own bin dir too — otherwise a clean-machine npm fallback can
41
- // succeed yet resolveBun() still returns null and we exit "still not found".
42
- const candidates = [
43
- join(pluginRoot, 'node_modules', '.bin', isWin ? 'bun.exe' : 'bun'),
44
- join(pluginRoot, 'node_modules', 'bun', 'bin', isWin ? 'bun.exe' : 'bun'),
45
- ];
46
- for (const p of candidates) if (existsSync(p)) return p;
47
- return null;
48
- }
49
-
50
- function resolveBun() {
51
- return findSystemBun() ?? findLocalBun();
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // npm-install bun locally if nothing found.
56
- // ---------------------------------------------------------------------------
57
- function installBunViaNpm() {
58
- process.stderr.write(
59
- '[bootstrap] bun not found on PATH — installing via npm (npm install --no-save --silent bun)...\n'
60
- );
61
- const r = spawnSync('npm', ['install', '--no-save', '--silent', 'bun'], {
62
- cwd: pluginRoot,
63
- stdio: 'inherit',
64
- shell: false,
65
- windowsHide: true,
66
- });
67
- if (r.status !== 0 || r.error) {
68
- const hint = r.error ? ` (${r.error.message})` : '';
69
- process.stderr.write(
70
- `[bootstrap] npm install failed${hint}.\n` +
71
- '[bootstrap] Please install bun manually: https://bun.sh\n'
72
- );
73
- process.exit(1);
74
- }
75
- }
76
-
77
23
  // ---------------------------------------------------------------------------
78
24
  // Main
79
25
  // ---------------------------------------------------------------------------
80
- let bunPath = resolveBun();
26
+ let bunPath = resolveBun(pluginRoot);
81
27
 
82
28
  if (!bunPath) {
83
- installBunViaNpm();
84
- bunPath = resolveBun();
29
+ installBunViaNpm(pluginRoot);
30
+ bunPath = resolveBun(pluginRoot);
85
31
  if (!bunPath) {
86
32
  process.stderr.write(
87
33
  '[bootstrap] bun still not found after npm install.\n' +
@@ -0,0 +1,259 @@
1
+ /**
2
+ * ensure-deps.mjs — install or seed runtime deps into <dataDir>/.deps and stamp.
3
+ */
4
+ import { createHash } from 'crypto';
5
+ import { spawnSync } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import { dirname, join } from 'path';
9
+ import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
10
+
11
+ const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
12
+ const RENAME_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
13
+
14
+ function sleepSync(ms) {
15
+ try {
16
+ const buf = new SharedArrayBuffer(4);
17
+ Atomics.wait(new Int32Array(buf), 0, 0, Math.max(1, Number(ms) || 1));
18
+ } catch {}
19
+ }
20
+
21
+ export function renameWithRetrySync(src, dst) {
22
+ let lastErr = null;
23
+ for (let attempt = 0; attempt <= RENAME_BACKOFFS_MS.length; attempt++) {
24
+ try {
25
+ fs.renameSync(src, dst);
26
+ return true;
27
+ } catch (err) {
28
+ lastErr = err;
29
+ if (!RENAME_RETRY_CODES.has(err?.code) || attempt >= RENAME_BACKOFFS_MS.length) break;
30
+ sleepSync(RENAME_BACKOFFS_MS[attempt] + Math.floor(Math.random() * 50));
31
+ }
32
+ }
33
+ throw lastErr;
34
+ }
35
+
36
+ const requiredDepNames = [
37
+ ['@modelcontextprotocol', 'sdk', 'package.json'],
38
+ ['zod', 'package.json'],
39
+ ['zod-to-json-schema', 'package.json'],
40
+ ['openai', 'package.json'],
41
+ ];
42
+
43
+ export function hasRequiredDeps(nmDir) {
44
+ return requiredDepNames.every((parts) => fs.existsSync(join(nmDir, ...parts)));
45
+ }
46
+
47
+ /** Find hoisted node_modules (npx) that satisfies hasRequiredDeps. */
48
+ export function resolveNmWithRequiredDeps(pluginRoot) {
49
+ let dir = pluginRoot;
50
+ for (let depth = 0; depth < 16; depth++) {
51
+ const nm = join(dir, 'node_modules');
52
+ if (hasRequiredDeps(nm)) return nm;
53
+ const parent = dirname(dir);
54
+ if (parent === dir) break;
55
+ dir = parent;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ const LOCK_POLL_MS = 250;
61
+ const LOCK_MAX_MS = 15 * 60 * 1000;
62
+ const LOCK_XHOST_MS = 10 * 60 * 1000;
63
+
64
+ function acquireLock(lockFile) {
65
+ const start = Date.now();
66
+ while (Date.now() - start < LOCK_MAX_MS) {
67
+ try {
68
+ const body = JSON.stringify({
69
+ pid: process.pid,
70
+ hostname: os.hostname(),
71
+ startedAt: Date.now(),
72
+ });
73
+ fs.writeFileSync(lockFile, body, { flag: 'wx' });
74
+ return;
75
+ } catch (e) {
76
+ if (e.code !== 'EEXIST') throw e;
77
+ try {
78
+ const raw = fs.readFileSync(lockFile, 'utf8');
79
+ const body = JSON.parse(raw);
80
+ const st = fs.statSync(lockFile);
81
+ const sameHost = body.hostname === os.hostname();
82
+ let dead = false;
83
+ if (sameHost) {
84
+ try { process.kill(body.pid, 0); }
85
+ catch (ke) { if (ke.code === 'ESRCH') dead = true; }
86
+ } else if (Date.now() - st.mtimeMs > LOCK_XHOST_MS) {
87
+ dead = true;
88
+ }
89
+ if (dead) fs.unlinkSync(lockFile);
90
+ } catch { /* lock may have been released — retry */ }
91
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
92
+ }
93
+ }
94
+ throw new Error(
95
+ `timed out waiting for dependency install lock after ${LOCK_MAX_MS / 60000} minutes`,
96
+ );
97
+ }
98
+
99
+ function releaseLock(lockFile) {
100
+ try { fs.unlinkSync(lockFile); } catch {}
101
+ }
102
+
103
+ function sha256(buf) {
104
+ return createHash('sha256').update(buf).digest('hex');
105
+ }
106
+
107
+ export function computeDepHash(pkgJsonPath, pkgLockPath) {
108
+ if (fs.existsSync(pkgLockPath)) {
109
+ return sha256(fs.readFileSync(pkgLockPath));
110
+ }
111
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
112
+ const depKeys = ['dependencies', 'optionalDependencies', 'peerDependencies'];
113
+ const depObj = {};
114
+ for (const k of depKeys) {
115
+ if (pkg[k]) {
116
+ depObj[k] = Object.fromEntries(
117
+ Object.entries(pkg[k]).sort(([a], [b]) => a.localeCompare(b)),
118
+ );
119
+ }
120
+ }
121
+ return sha256(Buffer.from(JSON.stringify(depObj)));
122
+ }
123
+
124
+ const INSTALL_TIMEOUT_MS = 180_000;
125
+
126
+ function resolveBunExec(bunPath) {
127
+ for (const p of [bunPath, process.env.BUN_EXEC_PATH]) {
128
+ if (p && typeof p === 'string' && fs.existsSync(p)) return p;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function depsAlreadySatisfied(stampPath, sharedNm, currentHash) {
134
+ let storedHash = '';
135
+ try { storedHash = fs.readFileSync(stampPath, 'utf8').trim(); } catch {}
136
+ return currentHash === storedHash && hasRequiredDeps(sharedNm);
137
+ }
138
+
139
+ function runFrozenLockfileInstall(bunExec, depsDir, stdio = 'inherit') {
140
+ return spawnSync(bunExec, ['install', '--frozen-lockfile'], {
141
+ cwd: depsDir,
142
+ stdio,
143
+ timeout: INSTALL_TIMEOUT_MS,
144
+ windowsHide: true,
145
+ encoding: stdio === 'pipe' ? 'utf8' : undefined,
146
+ });
147
+ }
148
+
149
+ /**
150
+ * @returns {{ satisfied: boolean, skipped?: boolean, method?: 'seed'|'install', reason?: string }}
151
+ */
152
+ export function ensureRuntimeDeps({
153
+ dataDir,
154
+ pluginRoot,
155
+ bunPath,
156
+ seedNm,
157
+ logPrefix = '[run-mcp]',
158
+ /** @type {'inherit' | 'pipe' | 'ignore'} */
159
+ installStdio = 'inherit',
160
+ }) {
161
+ const pluginPkg = join(pluginRoot, 'package.json');
162
+ const pluginLock = join(pluginRoot, 'bun.lock');
163
+
164
+ const depsDir = join(dataDir, '.deps');
165
+ const sharedPkg = join(depsDir, 'package.json');
166
+ const sharedLock = join(depsDir, 'bun.lock');
167
+ const sharedNm = join(depsDir, 'node_modules');
168
+ const stamp = join(depsDir, '.deps-stamp');
169
+ const stampTmp = join(depsDir, '.deps-stamp.tmp');
170
+ const lockFile = join(depsDir, '.install.lock');
171
+
172
+ const currentHash = computeDepHash(pluginPkg, pluginLock);
173
+ if (depsAlreadySatisfied(stamp, sharedNm, currentHash)) {
174
+ return { satisfied: true, skipped: true };
175
+ }
176
+
177
+ assertSafeOwnedDir(depsDir, dataDir, 'bun install');
178
+ fs.mkdirSync(depsDir, { recursive: true });
179
+ acquireLock(lockFile);
180
+ try {
181
+ if (depsAlreadySatisfied(stamp, sharedNm, currentHash)) {
182
+ return { satisfied: true, skipped: true };
183
+ }
184
+
185
+ fs.copyFileSync(pluginPkg, sharedPkg);
186
+ if (fs.existsSync(pluginLock)) fs.copyFileSync(pluginLock, sharedLock);
187
+
188
+ const bunExec = resolveBunExec(bunPath);
189
+
190
+ const canSeed = seedNm && hasRequiredDeps(seedNm);
191
+ if (canSeed) {
192
+ process.stderr.write(
193
+ `${logPrefix} seeding shared deps from ${seedNm} (skipping bun install)\n`,
194
+ );
195
+ fs.cpSync(seedNm, sharedNm, { recursive: true, force: true });
196
+ if (!bunExec) {
197
+ process.stderr.write(
198
+ `${logPrefix} bun not available — cannot validate seeded tree against lock\n`,
199
+ );
200
+ return { satisfied: false, reason: 'bun unavailable' };
201
+ }
202
+ process.stderr.write(
203
+ `${logPrefix} validating seeded deps: bun install --frozen-lockfile\n`,
204
+ );
205
+ const validate = runFrozenLockfileInstall(bunExec, depsDir, installStdio);
206
+ if (validate.status !== 0) {
207
+ const detail = validate.status ?? validate.signal ?? 'unknown';
208
+ process.stderr.write(
209
+ `${logPrefix} WARN: seeded tree failed frozen-lockfile (${detail}) — ` +
210
+ 'not stamping (runtime will install normally)\n',
211
+ );
212
+ return { satisfied: false, reason: 'frozen-lockfile validation failed' };
213
+ }
214
+ fs.writeFileSync(stampTmp, currentHash);
215
+ renameWithRetrySync(stampTmp, stamp);
216
+ return { satisfied: true, method: 'seed' };
217
+ } else {
218
+ if (!bunExec) {
219
+ process.stderr.write(
220
+ `${logPrefix} bun not available — skipping install (will retry on launch)\n`,
221
+ );
222
+ return { satisfied: false, reason: 'bun unavailable' };
223
+ }
224
+ const args = fs.existsSync(sharedLock)
225
+ ? ['install', '--frozen-lockfile']
226
+ : ['install'];
227
+ process.stderr.write(`${logPrefix} installing shared deps: bun ${args.join(' ')}\n`);
228
+
229
+ const result = spawnSync(bunExec, args, {
230
+ cwd: depsDir,
231
+ stdio: installStdio,
232
+ timeout: INSTALL_TIMEOUT_MS,
233
+ windowsHide: true,
234
+ encoding: installStdio === 'pipe' ? 'utf8' : undefined,
235
+ });
236
+ if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
237
+ process.stderr.write(
238
+ `${logPrefix} WARN: bun install timed out after ${INSTALL_TIMEOUT_MS}ms — ` +
239
+ 'continuing with existing node_modules (stale lock removed)\n',
240
+ );
241
+ try { fs.unlinkSync(lockFile); } catch {}
242
+ return { satisfied: false, reason: 'install timed out' };
243
+ } else if (result.status !== 0) {
244
+ const detail = result.status ?? result.signal ?? 'unknown';
245
+ process.stderr.write(
246
+ `${logPrefix} WARN: bun install exited with status ${detail} — ` +
247
+ 'continuing with existing node_modules if available\n',
248
+ );
249
+ return { satisfied: false, reason: 'install failed' };
250
+ } else {
251
+ fs.writeFileSync(stampTmp, currentHash);
252
+ renameWithRetrySync(stampTmp, stamp);
253
+ return { satisfied: true, method: 'install' };
254
+ }
255
+ }
256
+ } finally {
257
+ releaseLock(lockFile);
258
+ }
259
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * resolve-bun.mjs — locate or npm-install the bun binary for mixdog boot/setup.
3
+ */
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const isWin = process.platform === 'win32';
9
+
10
+ export function findSystemBun() {
11
+ const cmd = isWin ? 'where.exe' : 'which';
12
+ const r = spawnSync(cmd, ['bun'], { encoding: 'utf8', windowsHide: true });
13
+ if (r.status !== 0 || !r.stdout) return null;
14
+ const lines = r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
15
+ const pick = isWin
16
+ ? (lines.find((l) => l.toLowerCase().endsWith('.exe')) ?? lines[0])
17
+ : lines[0];
18
+ if (!pick) return null;
19
+ return existsSync(pick) ? pick : null;
20
+ }
21
+
22
+ export function findLocalBun(pluginRoot) {
23
+ const candidates = [
24
+ join(pluginRoot, 'node_modules', '.bin', isWin ? 'bun.exe' : 'bun'),
25
+ join(pluginRoot, 'node_modules', 'bun', 'bin', isWin ? 'bun.exe' : 'bun'),
26
+ ];
27
+ for (const p of candidates) if (existsSync(p)) return p;
28
+ return null;
29
+ }
30
+
31
+ export function resolveBun(pluginRoot) {
32
+ return findSystemBun() ?? findLocalBun(pluginRoot);
33
+ }
34
+
35
+ /**
36
+ * @param {string} pluginRoot
37
+ * @param {{ fatal?: boolean }} [opts] — fatal:true (default) exits the process on failure
38
+ * @returns {boolean} success
39
+ */
40
+ export function installBunViaNpm(pluginRoot, { fatal = true } = {}) {
41
+ process.stderr.write(
42
+ '[bootstrap] bun not found on PATH — installing via npm (npm install --no-save --silent bun)...\n',
43
+ );
44
+ const r = spawnSync('npm', ['install', '--no-save', '--silent', 'bun'], {
45
+ cwd: pluginRoot,
46
+ stdio: 'inherit',
47
+ shell: false,
48
+ windowsHide: true,
49
+ });
50
+ if (r.status !== 0 || r.error) {
51
+ const hint = r.error ? ` (${r.error.message})` : '';
52
+ process.stderr.write(
53
+ `[bootstrap] npm install failed${hint}.\n` +
54
+ '[bootstrap] Please install bun manually: https://bun.sh\n',
55
+ );
56
+ if (fatal) process.exit(1);
57
+ return false;
58
+ }
59
+ return true;
60
+ }