SpuntoSpunto/@spunto/build

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): string

The 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 (▸ label on start, ✓ label — 1.2s on 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.
  • finish(state, now = new Date()) — close the list when the build itself ends. On "ready" everything still running or pending becomes done. On "error" the running step becomes error (the culprit) and every pending step becomes skipped (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): string

Re-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): string

The 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.

Edit on GitHub

On this page