app store: run a sidecar's setup.sh, streaming it and collecting what it returns

Implements the half of the template contract that faces the platform: answers go in as environment and
never as prompts, and results come back as OFFICER_RESULT_<KEY>= lines on stdout.

A line protocol rather than JSON because the same stream is the user's live log — it goes to a terminal
panel while the install runs. A script that must emit clean JSON cannot also narrate, and one that emits
both needs a framing convention anyway. This mirrors the @@officer:progress@@ sentinel the job runner
already uses, with the same rule: marker lines are plucked out, everything else passes through.

parseResults is pure and tested against the realistic near-misses: a line that MENTIONS the prefix
without starting with it, an empty value (Transmission with no RPC auth returns exactly that, and blank
is a real answer), a value containing `=` (splitting on every one would truncate a credential), and a
prefix with no assignment (a script bug — skipped rather than stored as a blank key).

Verified end to end against a real script: environment reaches it, stderr is forwarded (docker compose
writes its progress there, so dropping it would hide most of what a user watches), OFFICER_NONINTERACTIVE
is set so a script that would block fails loudly instead of hanging behind a web form, and a non-zero
exit is reported with the tail.

Notes an artifact rather than hiding it: the two streams are pumped concurrently, so the error tail can
interleave differently from real time. The live log is correctly ordered; only the summary can read out
of order. Serialising the pumps would make a script that writes heavily to one stream block on the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent 4a03b9f84e
commit 4b9b98efda
2 changed files with 184 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { join } from 'node:path';
import { serviceDir } from './paths';
// Running a sidecar's setup.sh, streaming what it prints, and collecting what it hands back.
//
// The script contract lives in `templates/README.md`. Two halves of it are implemented here: answers go
// in as environment (never as prompts, which behind a web form is a hang with no output), and results
// come back as `OFFICER_RESULT_<KEY>=value` lines on stdout.
//
// ── Why results are a line protocol and not JSON on stdout ──
//
// The same stream is the user's live log — it goes to a terminal panel while the install runs. A script
// that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention
// anyway. A prefixed line is that convention, it survives being interleaved with `docker compose`
// output, and a human reading the log can see exactly what was handed back.
//
// This mirrors the progress sentinel the job runner already uses (`@@officer:progress@@`), for the same
// reason and with the same rule: the marker lines are plucked out of the stream, and everything else is
// passed through as narration.
const RESULT_PREFIX = 'OFFICER_RESULT_';
/**
* Pull `OFFICER_RESULT_*` assignments out of a script's output.
*
* Pure, so the protocol is testable without spawning anything.
*
* Later lines win. A script that reports a value twice has changed its mind — a retry inside the script
* that finally succeeded, say — and the last word is the one that reflects reality.
*/
export function parseResults(output: string): Record<string, string> {
const results: Record<string, string> = {};
for (const raw of output.split('\n')) {
const line = raw.trim();
if (!line.startsWith(RESULT_PREFIX)) continue;
const eq = line.indexOf('=');
// A prefix with no `=` is a script bug, not a value. Skipping beats storing a key with an empty
// string, which would look like a deliberate blank later.
if (eq <= RESULT_PREFIX.length) continue;
const key = line.slice(RESULT_PREFIX.length, eq).toLowerCase();
results[key] = line.slice(eq + 1);
}
return results;
}
export type RunScriptParams = {
sidecarId: string;
/** Directory holding the template's `setup.sh`, i.e. `app-store/templates/<template>`. */
templateDir: string;
/** Answers from the install form. Keys are used verbatim as environment variable names. */
env: Record<string, string>;
/** Called per line, as it happens — this is what the terminal panel renders. */
log: (line: string) => void;
/** Guard against a script that hangs rather than fails. */
timeoutMs?: number;
};
export type RunScriptOutcome =
| { ok: true; results: Record<string, string>; serviceDir: string }
| { ok: false; error: string };
/** Ten minutes: long enough to pull a large image on a slow line, short enough to not hang a UI forever. */
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
export async function runSetupScript(params: RunScriptParams): Promise<RunScriptOutcome> {
const dir = serviceDir(params.sidecarId);
const script = join(params.templateDir, 'setup.sh');
const proc = Bun.spawn(['bash', script], {
// The script's own working directory is its template dir; where it WRITES is OFFICER_SERVICE_DIR.
// Keeping those separate is what stops a script from accidentally writing into the repo.
cwd: params.templateDir,
env: {
...process.env,
...params.env,
OFFICER_SERVICE_DIR: dir,
// A form cannot answer a prompt, so a script that would block must fail loudly instead. This is
// the flag that turns a hang into a diagnosable error.
OFFICER_NONINTERACTIVE: '1',
OFFICER_UID: String(process.getuid?.() ?? 1000),
OFFICER_GID: String(process.getgid?.() ?? 1000),
},
stdout: 'pipe',
stderr: 'pipe',
});
const timeout = setTimeout(() => {
params.log(`✗ timed out after ${(params.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s — killing`);
proc.kill();
}, params.timeoutMs ?? DEFAULT_TIMEOUT_MS);
// Line-buffered so the panel updates as things happen rather than in one dump at the end — the whole
// reason for streaming. Both streams are forwarded: `docker compose` writes its progress to stderr,
// so dropping it would hide most of what a user wants to watch.
const collected: string[] = [];
const pump = async (stream: ReadableStream<Uint8Array>, prefix = '') => {
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
buffer += decoder.decode(chunk, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
collected.push(line);
params.log(prefix + line);
}
}
if (buffer) {
collected.push(buffer);
params.log(prefix + buffer);
}
};
await Promise.all([pump(proc.stdout), pump(proc.stderr, ' ')]);
const code = await proc.exited;
clearTimeout(timeout);
if (code !== 0) {
// The last few lines are almost always the reason; the whole log is already in the panel above.
//
// `collected` can interleave differently from real time: the two streams are pumped concurrently and
// append as they arrive, so a stderr line can land before a stdout line that was printed first. The
// LIVE log is correctly ordered — each `log()` fires as its line arrives — and only this summary can
// read out of order. Kept concurrent deliberately: serialising the pumps would make a script that
// writes a lot to one stream block on the other.
const tail = collected.slice(-5).join('\n').trim();
return { ok: false, error: `setup.sh exited ${code}${tail ? `: ${tail}` : ''}` };
}
return { ok: true, results: parseResults(collected.join('\n')), serviceDir: dir };
}