@spunto/build
v0.10.0
Published
Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.
Maintainers
Readme
@spunto/build
The parts of Spunto's Build pillar that are the same whoever runs the control plane — Spunto Cloud and Spunto Lite both ship them.
Built on the same idea as @spunto/design-system,
one layer down: that package owns what the two products look like, this one owns what they agree
on.
npm install @spunto/buildWhat's in it
@spunto/build/steps — the build-log protocol
A docker build for a devcontainer image does a dozen things in one opaque stream. Instead of a new
channel, the build script prints a marker around each block and the control plane parses it back out
of the log it already forwards:
import { BuildStepTracker, stepStartLine, stepEndLine, type BuildStep } from "@spunto/build/steps"
// …in the script generator, next to the block it brackets:
const lines = [stepStartLine("feature:node"), installNode(), stepEndLine("feature:node")]
// …in whatever forwards the build log:
const tracker = new BuildStepTracker(plan)
const { text, changed } = tracker.ingest(chunk) // markers become banners, steps become liveAlso here: stepMarker (same bytes, for emitters that aren't a shell), renderStoredBuildLog
(replay a finished build with the same banners), and EXTENSION_FAILED_MARKER /
parseFailedExtensions — the second marker, for extensions code-server couldn't install.
@spunto/build/extensions — which registry, and how to ask it
code-server resolves --install-extension <id> against one registry. If your picker searches a
different one, users pick extensions the build cannot install. One entry point in front of both
supported protocols keeps that from happening:
import { parseGallery, searchExtensions, codeServerGallery } from "@spunto/build/extensions"
const gallery = parseGallery(rawBlob) // null = Open VSX, the default
const hits = await searchExtensions(gallery, "prettier")
const forCodeServer = codeServerGallery(rawBlob) // the same choice, handed to the workerparseGallery takes the operator's raw value from wherever you keep it — a database column, an
environment variable — and every other function takes its result. Nothing is memoized at import
time, so one process can serve several tenants on several registries.
@spunto/build/naming — the names both sides have to pronounce the same way
A worker is a container called mp-worker-<id>, with volumes and a network named after it, running
an image called mp-proj-<slug>:v<n>. None of that is an implementation detail — each name is a
contract between the script that writes it, the Docker client that looks it up and the UI that
shows it:
import { workerContainerName, workerVolumePrefix, projectImageRef } from "@spunto/build/naming"
docker.getContainer(workerContainerName(workerId))
const ref = projectImageRef(projectId, version) // mp-proj-my-app:v7Also here: workerNetworkName, workerVolumeName, isProjectImageRef, SHARED_NETWORK, and the
paths a worker and its control plane agree on — WORKER_STATUS_FILE / workerStatusPath,
EXTENSIONS_DIR, FEATURE_ENTRYPOINTS_DIR, EXTENSION_FAILURES_FILE.
These are formats, not preferences. Changing a value renames things that already exist on running machines: a live worker's container and volumes keep the name they were created with. Read a diff in that file as a migration, not a rename.
@spunto/build/spec — a project as a file you can carry between Spuntos
Both products describe the same thing — an image, some features, some extensions, repositories,
lifecycle commands. Lite could already export that to JSON; Cloud could not. And Lite's format
announced itself as spunto-lite/project, putting the producer's name in the signature and making
the file local by construction.
import { buildProjectSpec, parseProjectSpec, projectSpecFilename } from "@spunto/build/spec"
const file = JSON.stringify(buildProjectSpec(project), null, 2) // kind: "spunto/project"
const spec = parseProjectSpec(await uploaded.text()) // throws a toast-safe messageFiles exported by Lite before this package are still accepted on read; new ones always carry the neutral kind.
Secrets travel as names only — values are write-only by design, and a spec is a file people mail each other. Instance identity (id, version history, deploy key) never travels either.
Shape here, policy there. These schemas check that a file is well-formed, not that it is acceptable: mount-path rules, extension-id existence, git-ref validity all stay with the product doing the import, which validates its own create payload anyway. And a product ignores the fields it has no feature for — shared volumes are a Lite concept, the task settings a Cloud one, both optional. Dropping a field the target cannot honour is the correct outcome; refusing the file is not.
@spunto/build/script — turning a project spec into the shell a worker runs
Four generators, one recipe: buildImageScript bakes the project image, buildSetupScript is the
first boot, buildStartScript is every boot, buildWorkerScript assembles the container CMD.
import { buildImageScript, IMAGE_RECIPE_VERSION } from "@spunto/build/script"
const { script, steps, hasDinD } = buildImageScript({ features, vscodeExtensions })No filesystem, no clock: the one install script the platform ships itself is embedded as a string
(local-features.ts), and every path a generated script writes to comes from ./naming rather
than a literal.
buildContainerScript and buildSetupPlan are shaped by one product's model — an agent that
orchestrates setup phase by phase instead of a container running one long CMD. They live here
because they share every helper in the file; splitting them out would fork the helpers, which is
the duplication this package exists to remove.
Skills (0.10.0): buildSkillsScript({ sources, discoveryDirs }) clones git skill sources
into ~/.spunto/skills/<id>/ and links each retained skill (a directory with a SKILL.md) into
the harness's discovery directories — one symlink per skill, never over an existing entry, and a
refresh only removes the links it posed itself (a ledger records them). The same script is the
skills setup phase (skills param of buildSetupScript / buildSetupPlan, after dotfiles,
before clone) and a refresh a control plane can run later. Which skills are retained is decided
by the caller (linkByDefault + include / exclude); the script only applies it against what
the ref actually holds. It never fails the setup: problems are printed with a [skills] prefix.
@spunto/build/catalogs — what a project picker offers
AVAILABLE_IMAGES, AVAILABLE_FEATURES, SUGGESTED_EXTENSIONS. Data, but shared data: the two
hand-maintained copies had already drifted, one of them advertising Oh My Zsh in a feature the
image recipe explicitly disables.
Project templates are deliberately absent — one product's clone a starter repository and carry onboarding-gallery presentation, the other's only configure an environment. They answer different questions.
@spunto/build/types — the shapes both schemas point at
ProjectFeature, Repository, SetupStatus. Declared here so each product's ORM can point its
JSON columns at them with $type<…>(), instead of a generator having to import a database schema.
SetupStatus is what a shell script writes into a container and a control plane reads back minutes
later: changing it is a migration, not an edit.
@spunto/build/agent-stream — reading an agent session as it happens
One adapter per harness dialect, one vocabulary out. A CLI's JSON output is an output format, not an API; adapters normalise into a small set of event types so stored history doesn't date the first time a vendor reshuffles a field.
Here for the reason that inverts the rest of the package: nothing has forked this yet. Putting it in the shared package now costs nothing and means the second product never writes its own.
@spunto/build/terminal — the half of a persistent terminal that is a string
A worker's terminal is an exec attached to a session that outlives it, so closing a browser tab doesn't kill a build. The attaching is I/O and stays with whoever owns the Docker socket; the shell that gets attached, the layout it writes into, and the parsing of what it prints back are here.
import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"
const name = sanitizeSessionName(fromQuery) // every name MUST come through this
await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })dtach, not tmux. tmux's only knob for "the wheel scrolls history" is mouse on, which also
makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection
back. dtach does only persistence, so the emulator keeps the mouse, its scrollback and its search.
What dtach doesn't do is remember the screen, so the session is recorded inside the container with
script(1) and the tail replayed on attach.
sanitizeSessionName is a security boundary, not tidiness: names are interpolated into the shell
scripts this module builds, so anything that doesn't come through it is a command injection into
someone's container.
The rule
A module belongs in this package only if it imports no ORM schema, no HTTP framework, no
React, no process.env, and knows nothing about organizations, users or compute nodes.
zod is the one exception, and a deliberate one: ./spec describes a file format that two
products have to validate identically, and re-deriving the same schema on each side is exactly the
duplication this package exists to remove. It is a peer dependency (^4), so a consumer's own
zod is the one used — which matters, since apps extend these schemas with their own. And an
optional one, because a peer applies to the whole package while only ./spec needs it: a
consumer that imports ./naming or ./steps and nothing else should not have a schema library
installed on its behalf. Import ./spec without zod and resolution fails loudly, which is the
right trade for not taxing every other entry point.
Concretely: no platform I/O. No database, no Docker socket, no WebSocket. This package produces
strings and parses strings. Its only network calls are outbound HTTP on the caller's behalf — a
public extension registry, and a model vendor's catalogue endpoint when you hand
resolveContextWindow a credential — and it holds no credential of its own. Configuration arrives
as function parameters, never read from the environment, so that one control plane can scope a
setting per organization and another per process without either shape leaking in here.
That rule is what makes the package testable, safe to import from a node agent as well as from an API, and the reason it can't simply be folded into the design system: a script generator needs strict, canonical shapes, while the design system deliberately models everything as optional so a UI degrades instead of throwing.
What's deliberately not in it
Image script generation (buildImageScript & co.), the devcontainer catalogs, the Docker client and
the terminal bridge — all still duplicated between the two products, all staged for later. The
reasoning, the measurements and the plan are in
RFC 0021.
Development
npm install
npm test # vitest
npm run typecheckPublished to npm from main by .github/workflows/publish-build.yml; bump the version in
package.json to cut a release.
License
MIT
