diff --git a/src/servers/app-store/run-script.test.ts b/src/servers/app-store/run-script.test.ts new file mode 100644 index 00000000..f1ddcfe9 --- /dev/null +++ b/src/servers/app-store/run-script.test.ts @@ -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' }); + }); +}); diff --git a/src/servers/app-store/run-script.ts b/src/servers/app-store/run-script.ts new file mode 100644 index 00000000..986fa24e --- /dev/null +++ b/src/servers/app-store/run-script.ts @@ -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_=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 { + const results: Record = {}; + 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/