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
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'bun:test';
import { parseResults } from './run-script';
// The line protocol between a setup script and the installer. Pure, so it is tested without spawning
// anything — and worth testing precisely, because a script's output is interleaved with `docker
// compose`'s and a loose parser would pick up things that merely look like results.
describe('parseResults', () => {
it('reads the values a script hands back', () => {
const out = parseResults(
['==> Starting', 'OFFICER_RESULT_URL=http://127.0.0.1:18091', 'OFFICER_RESULT_PATH=/transmission/rpc', '==> Done'].join('\n'),
);
expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' });
});
it('ignores everything that is not a result line', () => {
// The same stream is the user's live log. Narration must never be mistaken for a value.
const out = parseResults(
['Container officer-transmission Started', 'note: OFFICER_RESULT_URL is printed at the end', ''].join('\n'),
);
// The middle line MENTIONS the prefix but does not start with it, which is the realistic near-miss.
expect(out).toEqual({});
});
it('keeps an empty value, because blank is a real answer', () => {
// Transmission with no RPC auth returns exactly this, and it means "no username", which is
// different from the key being absent.
expect(parseResults('OFFICER_RESULT_USERNAME=')).toEqual({ username: '' });
});
it('keeps everything after the first `=`', () => {
// Tokens and URLs contain `=`; splitting on every one would silently truncate a credential.
expect(parseResults('OFFICER_RESULT_SECRET=abc=def==')).toEqual({ secret: 'abc=def==' });
});
it('lets a later line win', () => {
// A script that reports twice has changed its mind — a retry inside it that finally worked.
expect(parseResults(['OFFICER_RESULT_URL=http://first', 'OFFICER_RESULT_URL=http://second'].join('\n'))).toEqual({
url: 'http://second',
});
});
it('skips a prefix with no assignment rather than storing a blank key', () => {
// A script bug. Storing `{'': ''}` would look like a deliberate empty value downstream.
expect(parseResults('OFFICER_RESULT_')).toEqual({});
expect(parseResults('OFFICER_RESULT_=novalue')).toEqual({});
});
it('tolerates indentation, since stderr lines arrive prefixed', () => {
expect(parseResults(' OFFICER_RESULT_URL=http://x ')).toEqual({ url: 'http://x' });
});
});