SpuntoSpunto/@spunto/build

Scripts

Turning a project's spec into the shell a worker runs — image build, first boot, every boot, the container CMD, the agent-orchestrated plan, and organization skills.

Why it exists

A worker is a devcontainer image plus three moments: the image build (features, extensions), the first boot (credentials, dotfiles, clones, postCreateCommand), and every boot (code-server, feature entrypoints, postStartCommand). Each moment is a shell script, and every product that spawns workers has to generate the same ones — the same feature-install protocol, the same clone strategies, the same status file, the same markers.

So the generators live here: pure string generation — no filesystem, no Docker, no clock. What the scripts write (the status file, the extensions directory, the entrypoints list) is named in ./naming, and what they print so a control plane can follow along is in ./steps. The one install script the platform ships itself is embedded as a string, so generating a script never reads a file.

import { buildImageScript, IMAGE_RECIPE_VERSION } from "@spunto/build/script"

const { script, steps, hasDinD } = buildImageScript({ features, vscodeExtensions })

Four generators, one recipe:

GeneratorRunsDoes
buildImageScriptonce, during docker buildcurl bootstrap, common-utils + spunto-pack, the project's features, VS Code extensions, finalize
buildSetupScriptfirst container startownership, credentials, shell, dotfiles, skills, clones, postCreateCommand
buildStartScriptevery container startSSH server, feature entrypoints, extensions, code-server loop, postStartCommand
buildWorkerScriptthe container CMDstatus/timing helpers + setup (guarded by a marker file) + start

buildContainerScript and buildSetupPlan are shaped by one product's model — an agent that orchestrates setup phase by phase via docker exec instead of a container running one long CMD (Spunto Cloud, RFC 0017). They live here anyway because they share every helper in the file; splitting them out would fork the helpers, which is the duplication this package exists to remove.

The generated scripts target a vscode user with home /home/vscode and a /workspace directory. These are fixed in this version, not parameters.

Image

buildImageScript(params)

function buildImageScript(params: {
  features: { id: string; ociRef?: string; options?: Record<string, string> }[]
  vscodeExtensions?: string[]
  sshGatewayPublicKey?: string
  dind?: boolean
  extensionsGallery?: string | null
}): { script: string; hasDinD: boolean; resolvedFeatures: ResolvedFeature[]; steps: BuildStep[] }

The script baked into a project image (set -e, runs as root). Every block is bracketed with ::spunto:step: markers, and the step plan is returned from the same call that emits the script, so the list a UI shows up front can never list different blocks from the ones that run.

Prop

Type

What it builds, in order — each line is one step:

bootstrap — installs curl (apt, apk, dnf or yum) if missing. It cannot be a feature: the feature fetcher is a curl script.

feature:common-utils then feature:spunto-pack — the two features every worker image is made of. common-utils (ghcr.io/devcontainers/features/common-utils:2) creates vscode with zsh as login shell and no Oh My Zsh; if the project picked common-utils itself, its options are merged over these rather than installed twice. spunto-pack (ghcr.io/coderhammer/features/spunto-pack:1) installs code-server, tmux and dtach, and sshd only when sshGatewayPublicKey is set.

feature:<id> — one block per project feature, so a feature that takes four minutes is visibly the thing the build is doing.

extensions — only when vscodeExtensions is non-empty; detail is e.g. 3 extensions · Open VSX.

finalize — merges FEATURE_ENTRYPOINTS_STAGING_FILE into FEATURE_ENTRYPOINTS_FILE (sort -u), guarantees passwordless sudo for vscode, chowns /home/vscode.

const { script, steps, hasDinD, resolvedFeatures } = buildImageScript({
  features: [{ id: "node", options: { version: "22" } }, { id: "github-cli" }],
  vscodeExtensions: ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
  extensionsGallery: org.extensionsGallery,
})

steps.map((s) => s.id)
// → ["bootstrap", "feature:common-utils", "feature:spunto-pack", "feature:node",
//    "feature:github-cli", "extensions", "finalize"]

const tracker = new BuildStepTracker(steps) // see ./steps

ResolvedFeature

type ResolvedFeature = { id: string; script: string; ociRef?: string }

A feature after resolution: its install script (an OCI fetch-and-run, or an embedded local script), and the OCI ref it came from (absent for local scripts).

IMAGE_RECIPE_VERSION

const IMAGE_RECIPE_VERSION = 2

What the baked layer is made of, as a version. A project image (mp-proj-…:vN) is keyed by the project's version, which only moves when the project's own config does — so a change to the recipe alone would leave every already-built image in place, and new workers would keep spawning on the old base. Invisible until someone wonders why last week's fix is not in their container.

Stamp this on each build record and compare it at spawn: a build made by an older recipe is stale, and the image is rebuilt exactly as if the project had been bumped.

const build = await latestBuild(project.id, project.version)
if (!build || build.recipeVersion !== IMAGE_RECIPE_VERSION) await rebuild(project)

History: 1 = hand-written blocks (vscode user, code-server, tmux, dtach, sshd); 2 = common-utils + spunto-pack.

LOCAL_FEATURE_SCRIPTS / localFeatureScript(filename)

const LOCAL_FEATURE_SCRIPTS: Record<string, string>
function localFeatureScript(filename: string): string | undefined

Install scripts the platform ships itself, for features with no usable OCI release, keyed by the catalog's localScript filename. There is exactly one today, google-cloud-cli.sh (Google's own apt repository — the upstream feature pins a gcloud version and breaks whenever Google retires it). Embedded as strings: the package does no I/O, and a build script that inlines its own payload cannot fail on a missing file.

One is the right number. The bar for adding another is "the upstream feature does not exist or is broken", not "this was quicker".

First boot and every boot

SetupScriptParams

Prop

Type

buildSetupScript(params)

function buildSetupScript(params: SetupScriptParams): { script: string }

The first-boot body. Banners (SETUP: OWNERSHIP, SETUP: CREDENTIALS, SETUP: SHELL, SETUP: DOTFILES, SETUP: SKILLS (n), SETUP: CLONE REPOSITORIES (n), SETUP: POST CREATE) separate the phases in the log, and the script writes a SetupStatus to the status file at each transition. It ends by touching /home/vscode/.mp-setup-done.

Repository clone strategy, in order of preference: a generic git repo with the project deploy key; an org GitHub App installation token over HTTPS; the user's personal SSH key; plain HTTPS. A requested branch that does not exist stops setup in error rather than silently landing on the default branch.

StartScriptParams

Prop

Type

buildStartScript(params)

function buildStartScript(params: StartScriptParams): { script: string; hasDinD: boolean }

The every-boot body: SSH server, feature entrypoints, extensions, code-server settings and a restart loop running as vscode, then postStartCommand (after waiting for a Docker daemon if entrypoints started one). postStartCommand runs in /workspace/<path> when the project has exactly one repository, /workspace otherwise.

buildWorkerScript(params)

function buildWorkerScript(params: {
  setupScript: string
  startScript: string
  project: {
    postCreateCommand: string | null
    postStartCommand: string | null
    repositories: { project: string }[]
  }
}): string

Assembles the container CMD: the status and timing helpers (_mp, _mp_stamp, a failure trap that flips the status file to phase: "error"), the setup script guarded by /home/vscode/.mp-setup-done, then the start script.

const { script: setupScript } = buildSetupScript({ project, workerId, userInfo, dotfilesRepo, branch })
const { script: startScript, hasDinD } = buildStartScript({ project, extensionsGallery })
const cmd = buildWorkerScript({ setupScript, startScript, project })

await docker.createContainer({ name: workerContainerName(workerId), Cmd: ["bash", "-c", cmd], /* … */ })

buildSetupScript and buildStartScript call the _mp / _mp_stamp shell functions that buildWorkerScript defines. They are bodies, not standalone scripts — always run them through buildWorkerScript.

resolveRepoBranch(repo, workerBranch?)

function resolveRepoBranch(repo: { branch?: string | null }, workerBranch?: string | null): string | undefined

The branch actually checked out: the worker-level choice wins over the repository's own default. Blank on both sides → undefined, meaning "clone the remote's default branch".

resolveRepoBranch({ branch: "develop" }, "feat/x") // → "feat/x"
resolveRepoBranch({ branch: "develop" }, "  ")     // → "develop"
resolveRepoBranch({}, null)                        // → undefined

defaultVscodeUserSettings(opts?)

function defaultVscodeUserSettings(opts?: { projectName?: string; colorByProject?: boolean }): Record<string, unknown>

The code-server user settings seeded into every worker (no-clobber, so they stay user-overridable): AI chat features disabled, workspace trust off, secondary sidebar hidden, and a window.title that leads with the project name so parallel tabs stay tellable apart. With colorByProject and a projectName, adds workbench.colorCustomizations with a deterministic per-project hue.

defaultVscodeUserSettings({ projectName: "api" })["window.title"]
// → "api${separator}${activeEditorShort}"

Agent-orchestrated setup

buildContainerScript(params)

function buildContainerScript(params: StartScriptParams): { script: string; hasDinD: boolean }

The passive container CMD for the orchestrated model: the same serve-and-stay-alive blocks as buildStartScript (SSH, entrypoints, extensions, code-server loop) minus the status machinery, lifecycle commands and timing emission — the agent owns those. Ends by touching /home/vscode/.mp-container-up and waiting.

SetupPhase

Prop

Type

buildSetupPlan(params)

function buildSetupPlan(params: SetupScriptParams): {
  phases: SetupPhase[]
  initialStatus: SetupStatus
  readyStatus: SetupStatus
}

The ordered phases an agent execs one by one, each in its own docker exec (and, in Spunto Cloud, its own OTel span). Reuses the exact bash blocks of buildSetupScript / buildStartScript — clone strategies, dotfiles, lifecycle — so both models produce the same worker. credentials (which also handles ownership) and shell are always present; dotfiles, skills, clone, postCreate and postStart are omitted when there is nothing to do.

const { phases, initialStatus, readyStatus } = buildSetupPlan({ project, workerId, dotfilesRepo, skills })
// phases.map((p) => p.id) → ["credentials", "shell", "dotfiles", "skills", "clone", …]

await writeStatus(initialStatus)
for (const phase of phases) {
  if (isRestart && !phase.runsOnRestart) continue
  await writeStatus(phase.statusStart)
  await exec(container, ["bash", "-c", phase.script])
  await writeStatus(phase.statusEnd)
}
await writeStatus(readyStatus)

Organization skills

A skill is a directory holding a SKILL.md. A source is a git repository holding several of them under one path. buildSkillsScript turns a list of sources — already resolved by the control plane — into one idempotent shell script that:

  1. clones each source into ~/.spunto/skills/<id>/ (or fast-forwards an existing clone; a ref that changed means a fresh clone);
  2. links every retained skill into each discovery directory the agent harness reads — one symlink per skill, never the whole directory, never over an entry that already exists;
  3. removes the links it posed on an earlier run and no longer wants, and only those (a ledger at ~/.spunto/skills/.links records them), plus the clones of sources that were removed.

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. The on-disk layout is documented in Formats → Skills layout.

The script never fails the setup. A skill that could not be fetched is a worse worker, not a broken one: every problem is printed with a [skills] prefix and the script exits 0.

SkillSource

Prop

Type

SkillsScriptParams

Prop

Type

buildSkillsScript(params)

function buildSkillsScript(params: SkillsScriptParams): string | null

The script, to run as the workspace user (never root — every file it creates is theirs). Returns null only when there are no discovery directories. With an empty sources list it still returns a script, because it cannot know without running whether there are links left to take down — a caller that had sources before should still run it.

Throws on input it would have to escape: an invalid id, a path with .. or unsafe characters, a ref that could be read as a git option, a URL starting with -, or an include/exclude name outside [A-Za-z0-9][A-Za-z0-9._-]*.

const script = buildSkillsScript({
  sources: [
    {
      id: "src_default",
      label: "acme/skills",
      fetchUrl: null, // credential passed at run time instead, see below
      originUrl: "https://github.com/acme/skills.git",
      path: "skills",
      ref: "main",
      linkByDefault: true,
      exclude: ["picnic"],
    },
  ],
  discoveryDirs: ["${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"],
})

SKILLS_DIR / skillFetchUrlEnv(index)

const SKILLS_DIR = ".spunto/skills"
function skillFetchUrlEnv(index: number): string // → "SPUNTO_SKILL_FETCH_URL_<index>"

SKILLS_DIR is where clones live, relative to the user's home — harness-neutral on purpose. skillFetchUrlEnv(i) names the environment variable that overrides sources[i].fetchUrl at run time, for a caller that must not write a credential into the command it runs (a command history is kept):

await exec(container, ["bash", "-c", script], {
  user: "vscode",
  env: { [skillFetchUrlEnv(0)]: `https://x-access-token:${token}@github.com/acme/skills.git` },
})
Edit on GitHub

On this page