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:
| Generator | Runs | Does |
|---|---|---|
buildImageScript | once, during docker build | curl bootstrap, common-utils + spunto-pack, the project's features, VS Code extensions, finalize |
buildSetupScript | first container start | ownership, credentials, shell, dotfiles, skills, clones, postCreateCommand |
buildStartScript | every container start | SSH server, feature entrypoints, extensions, code-server loop, postStartCommand |
buildWorkerScript | the container CMD | status/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 ./stepsResolvedFeature
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 = 2What 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 | undefinedInstall 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 }[]
}
}): stringAssembles 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 | undefinedThe 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) // → undefineddefaultVscodeUserSettings(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:
- clones each source into
~/.spunto/skills/<id>/(or fast-forwards an existing clone; a ref that changed means a fresh clone); - 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;
- removes the links it posed on an earlier run and no longer wants, and only those (a ledger at
~/.spunto/skills/.linksrecords 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 | nullThe 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` },
})