// Runs one storebound command as a child process: the shared core of `storebound mcp` and `storebound serve --stdio`. // Works from source (`bun cli/index.ts ...`) and from a `bun build --compile` binary (the desktop sidecar), where the // binary re-executes itself. Output is streamed line by line (progress) and the `--json` result line is picked out. import { join } from 'node:path'; import { RESULT_MARK } from './log'; import { toolPath } from './tools'; /** True inside a `bun build --compile` executable (the entry module lives in Bun's embedded file system). */ export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(Bun.main); /** Command prefix that starts this storebound: the compiled binary itself, or bun + cli/index.ts. */ export function selfCommand(): string[] { return compiled() ? [process.execPath] : [process.execPath, join(import.meta.dir, '..', 'index.ts')]; } export interface RunOptions { cwd: string; /** Extra environment for this call only (credentials from the desktop keychain, never written anywhere). */ env?: Record; /** Every output line as it arrives (stdout and stderr), without the machine result line. */ onLine?: (line: string, stream: 'stdout' | 'stderr') => void; /** Ask for the marked `--json` result line (STOREBOUND_MACHINE=1). */ machine?: boolean; } export interface RunResult { code: number; out: string; data?: unknown; cancelled: boolean } export interface Running { result: Promise; cancel(): void } /** Env names a caller may pass per call; anything else in `env` is ignored. */ export const CALL_ENV = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] as const; export function pickCallEnv(env: Record | undefined): Record { const out: Record = {}; for (const k of CALL_ENV) { const v = env?.[k]; if (typeof v === 'string' && v) out[k] = v; } return out; } /** Environment of a child: the parent's minus anything secret to the session, plus the per-call values. */ export function childEnv(extra: Record = {}, machine = false): Record { const base: Record = {}; // never inherited: the session secret, and the owner's confirmation (serve passes it per call, after a verified token) for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !k.startsWith('STOREBOUND_SESSION') && k !== 'STOREBOUND_OWNER_CONFIRMED') base[k] = v; // app-managed tools (desktop data folder) come first on PATH base.PATH = toolPath(base); return { ...base, ...extra, NO_COLOR: '1', ...(machine ? { STOREBOUND_MACHINE: '1', STOREBOUND_SERVE: '1' } : {}) }; } export async function pump(stream: ReadableStream, kind: 'stdout' | 'stderr', each: (line: string, kind: 'stdout' | 'stderr') => void): Promise { const dec = new TextDecoder(); let buf = ''; const reader = stream.getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf('\n')) >= 0) { each(buf.slice(0, nl).replace(/\r$/, ''), kind); buf = buf.slice(nl + 1); } } buf += dec.decode(); if (buf) each(buf, kind); } export function runCommand(argv: string[], o: RunOptions): Running { const p = Bun.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) }); let cancelled = false; const lines: string[] = []; let data: unknown; const each = (line: string, kind: 'stdout' | 'stderr'): void => { if (kind === 'stdout' && line.startsWith(RESULT_MARK)) { try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); } return; } lines.push(line); o.onLine?.(line, kind); }; const result = (async (): Promise => { await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); const code = await p.exited; return { code, out: lines.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled }; })(); return { result, cancel: () => { cancelled = true; p.kill(); } }; }