storebound/cli/lib/runner.ts
CRT 36a8bf9625 CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
2026-10-13 04:03:28 +08:00

88 lines
4.4 KiB
TypeScript

// 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 { 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 <bundle>
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<string, string>;
/** 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<RunResult>; 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<string, unknown> | undefined): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> = {}, machine = false): Record<string, string> {
const base: Record<string, string> = {};
// 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<Uint8Array>, kind: 'stdout' | 'stderr', each: (line: string, kind: 'stdout' | 'stderr') => void): Promise<void> {
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[] = [];
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<RunResult> => {
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(); } };
}