Build steps
The build-log protocol — markers a build script prints so a control plane can turn one opaque docker build into a list of live blocks.
Why it exists
A project image build does a lot in one docker build: install curl, run each devcontainer
feature, install each VS Code extension, finalize ownership. Without help, the only thing a UI can
show is the raw log — it tells you something is happening, not what.
The mechanism is deliberately the cheapest one that works end to end: the generated build script
echoes a marker line around each block, and the control plane parses those markers back out of
the log stream it already forwards. No extra channel, no protocol change on the wire — the hook is
a line of output. Anything that can print to stdout inside or beside the build can declare a step.
A marker is a contract between three programs that cannot import each other: the shell script that emits it, the control plane that parses it, and the UI that renders the result. Before this package, the emitter lived in one app and the same literal was re-typed by hand elsewhere. One copy, here, keeps them from drifting.
The second marker in this module, [build] EXTENSION FAILED:, exists because code-server does not
reliably exit non-zero on an unknown extension id: "must not fail the build" had degenerated into
"is never reported". Emitter and parser live in the same file on purpose — a marker whose emitter
and parser live apart breaks silently.
The exact byte format of both markers is documented in Formats → Build log and Formats → Extension failures.
import {
BuildStepTracker,
renderStoredBuildLog,
stepStartLine,
stepEndLine,
stepMarker,
EXTENSION_FAILED_MARKER,
extensionFailedLine,
parseFailedExtensions,
type BuildStep,
} from "@spunto/build/steps"The step plan
Build the plan at the same place you generate the script, so the list a UI shows greyed out up
front cannot drift from the blocks the script actually runs. buildImageScript from
./script does exactly that: it returns steps
alongside script. Persist the plan next to the build and a finished build replays with its
blocks and timings, exactly like the live one.
API
BuildStep
type BuildStepState = "pending" | "running" | "done" | "error" | "skipped"
/** Drives the icon in the UI — a family of block, not a status. */
type BuildStepKind = "image" | "runtime" | "feature" | "extensions" | "finalize"Prop
Type
BuildStep, BuildStepKind and BuildStepState are also re-exported from
@spunto/build/types.
stepStartLine(id) / stepEndLine(id)
function stepStartLine(id: string): string
function stepEndLine(id: string): stringThe shell lines a build script emits to declare that a block starts, or finished successfully.
stepStartLine("feature:node") // → echo '::spunto:step:start:feature:node'
stepEndLine("feature:node") // → echo '::spunto:step:end:feature:node'
const lines = [stepStartLine("feature:node"), installNode(), stepEndLine("feature:node")]The id is embedded inside single quotes. Use fixed, script-controlled ids (bootstrap,
feature:<id>, extensions, finalize), never raw user input.
stepMarker
const stepMarker: {
start: (id: string) => string // "::spunto:step:start:<id>\n"
end: (id: string) => string // "::spunto:step:end:<id>\n"
}The same two markers as plain strings, newline included — for emitters that are not a shell. A
node agent that pulls the base image before docker build starts can write
stepMarker.start("base-image") straight into the log stream it forwards.
BuildStepTracker
class BuildStepTracker {
constructor(public steps: BuildStep[])
ingest(chunk: string, now?: Date): { text: string; changed: boolean }
finish(state: "ready" | "error", now?: Date): { text: string; changed: boolean }
}Consumes a build's log stream, keeps steps up to date, and hands back the text to show.
ingest(chunk, now = new Date())— feed a raw chunk. Returns the text to forward and whether the step list changed (so you only persist / broadcast it when it did).- A marker line for a known step becomes a coloured banner (
▸ labelon start,✓ label — 1.2son end). A marker for an unknown id is swallowed rather than leaked to the terminal. - A start marker implicitly closes whatever step is still
running— phases with no end marker of their own (like a base image pull) end when the script speaks. - Chunk boundaries are respected: a trailing partial line is held back only if it could still become a marker. Pull-progress redraws (cursor-control sequences, no marker) keep flowing through untouched.
- A marker line for a known step becomes a coloured banner (
finish(state, now = new Date())— close the list when the build itself ends. On"ready"everything still running or pending becomesdone. On"error"the running step becomeserror(the culprit) and every pending step becomesskipped(never reached). Also flushes any held-back partial line.
const { script, steps } = buildImageScript({ features, vscodeExtensions })
const tracker = new BuildStepTracker(steps)
stream.on("data", (chunk: Buffer) => {
const { text, changed } = tracker.ingest(chunk.toString("utf8"))
sendToTerminal(text)
if (changed) saveSteps(buildId, tracker.steps)
})
stream.on("end", () => {
const { text } = tracker.finish(exitCode === 0 ? "ready" : "error")
sendToTerminal(text)
saveSteps(buildId, tracker.steps)
})Banners use bare \n. The caller is expected to run the text through the same xterm
normalisation it already applies to the rest of the log stream (which turns them into \r\n);
emitting \r\n here would double up for callers that normalise.
renderStoredBuildLog(logs, steps)
function renderStoredBuildLog(logs: string, steps: BuildStep[] | null | undefined): stringRe-renders a stored log for replay, with the same banners as the live stream and the timings
already recorded on the persisted steps. This keeps "watch a build" and "reopen a finished build"
identical. With no steps (null, undefined or empty), marker lines are simply stripped. Markers
for ids not in steps are dropped.
const build = await db.builds.find(id)
res.send(renderStoredBuildLog(build.logs, build.steps))EXTENSION_FAILED_MARKER
const EXTENSION_FAILED_MARKER = "[build] EXTENSION FAILED:"The prefix every line reporting an extension the build could not install carries.
extensionFailedLine(id, reason?)
function extensionFailedLine(id: string, reason?: string): stringThe text a build script prints for one extension it failed to install. Unlike stepStartLine, it
is the line itself, not an echo command — wrap it in whatever prints in your script:
extensionFailedLine("acme.missing")
// → "[build] EXTENSION FAILED: acme.missing"
extensionFailedLine('"$_ext"', "not found on Open VSX")
// → '[build] EXTENSION FAILED: "$_ext" — not found on Open VSX'
const line = `echo ${extensionFailedLine('"$_ext"')}`
// → echo [build] EXTENSION FAILED: "$_ext"As of 0.10.0, the package's own buildImageScript reports a failed install with a different,
human-oriented line ([build] Extension <id> failed (exit N) — …) and does not emit this
marker. Emitting it is up to the build script that wants failures parsed back.
id is interpolated as written. Pass a literal id or a shell expansion ("$_ext"), never
unvalidated user input on a path the caller has not already quoted.
parseFailedExtensions(logs)
function parseFailedExtensions(logs: string): string[]Extension ids a build log reports as failed, in script order, deduplicated. Only the first whitespace-delimited token after the marker is taken — the optional reason is ignored.
parseFailedExtensions(build.logs) // → ["acme.missing", "other.ext"]This module owns the wire format and nothing else: whether to also check that an extension landed
on disk, what to write into the image (see EXTENSION_FAILURES_FILE in
./naming), and when to give up are the build
script's business.