// 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 { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { RESULT_MARK } from './log'; import { mergeQuota, QUOTA_MARK, type QuotaInfo } from './quota'; import { toolPath } from './tools'; import * as rt from './rt'; /** 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(rt.mainPath()); /** Command prefix that starts this storebound: the compiled binary itself, or bun + cli/index.ts. */ export function selfCommand(): string[] { if (compiled()) return [process.execPath]; // from the sources (cli/lib/runner.ts): bun cli/index.ts; from the npm bundle (dist/storebound.mjs): node const self = fileURLToPath(import.meta.url); return [process.execPath, /\.ts$/.test(self) ? join(dirname(self), '..', 'index.ts') : self]; } 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; } /** `err`: the stderr lines only (for the command's own error line). */ export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean; /** Store quota the run saw (cli/lib/quota.ts). */ quota?: QuotaInfo } 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 = rt.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) }); let cancelled = false; const lines: string[] = []; const errs: string[] = []; let data: unknown; let quota: QuotaInfo | undefined; 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; } if (kind === 'stderr' && line.startsWith(QUOTA_MARK)) { try { quota = mergeQuota(quota, JSON.parse(line.slice(QUOTA_MARK.length)) as QuotaInfo); } catch { /* a broken quota line is dropped */ } return; } lines.push(line); if (kind === 'stderr') errs.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(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), ...(quota ? { quota } : {}), cancelled }; })(); return { result, cancel: () => { cancelled = true; p.kill(); } }; }