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>
133 lines
5.8 KiB
TypeScript
133 lines
5.8 KiB
TypeScript
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 };
|
|
}
|