Project spec
A project as a file you can carry from one Spunto to another — the spunto/project format, its zod schemas, and the helpers that write and read it.
Why it exists
Spunto Cloud and Spunto Lite describe the same thing — a devcontainer environment: an image, some
features, some extensions, repositories to clone, lifecycle commands. Lite could already export
that to JSON and import it back; Cloud could not. And Lite's format announced itself as
spunto-lite/project, which put the producer's name in the format's signature and made the file
local by construction.
One format, spunto/project, is what turns "prototype it locally, then move it to the cloud" from
a slide into a feature. Two products validating the same file with two separately-written schemas
is exactly the duplication this package exists to remove, so the schemas live here.
The file format itself — every field, with examples — is documented in Formats → Project spec.
import { buildProjectSpec, parseProjectSpec, projectSpecFilename } from "@spunto/build/spec"
// Export
const file = JSON.stringify(buildProjectSpec(project), null, 2) // kind: "spunto/project"
download(projectSpecFilename(project.name), file) // my-app.spunto-project.json
// Import
const spec = parseProjectSpec(await uploaded.text()) // throws a toast-safe messagezod is an optional peer dependency
This is the only entry point that imports zod (^4). It is a peer so your own zod is the
one used — which matters, since apps extend these schemas with their own — and an optional
one so consumers of other entry points are not taxed. Import ./spec without zod installed and
module resolution fails loudly. See Installation.
Contracts
What travels and what does not. The spec holds what describes the environment, never what identifies an instance: no id, no version history, no deploy key, no favourite flag.
Secrets travel as names only
secretNames lists names, never values. Secret values are write-only by design, and a spec is a
file people mail each other. The names are there so an import can lay out the rows left to fill in.
Shape here, policy there. These schemas check that a file is well-formed, not that it is acceptable: whether a mount path is allowed, whether an extension id exists on the configured registry, whether a branch name is valid git — all of that stays with the product doing the import, which validates its own create payload anyway. A format that enforced one product's policy would stop being portable the day the other's policy differed.
A product ignores the fields it has no feature for. Shared volumes are a Lite concept, the task settings a Cloud one; both are optional. Importing a Cloud spec into Lite is supposed to drop the task settings rather than fail. Losing a field the target cannot honour is the correct outcome — refusing the file is not.
API
Constants
const PROJECT_SPEC_KIND = "spunto/project"
const LEGACY_PROJECT_SPEC_KINDS = ["spunto-lite/project"] as const
const PROJECT_SPEC_VERSION = 1PROJECT_SPEC_KIND— what a file produced today announces itself as.LEGACY_PROJECT_SPEC_KINDS— kinds still accepted on read.spunto-lite/projectis what every file exported by Spunto Lite before this package says, and those files are on people's disks. New files always carry the neutral kind.PROJECT_SPEC_VERSION— the only versionProjectSpecSchemaaccepts (z.literal(1)).
buildProjectSpec(project, now?)
function buildProjectSpec(project: SpecProject, now?: Date): ProjectSpecWraps a project in a fresh envelope: { kind: PROJECT_SPEC_KIND, version: PROJECT_SPEC_VERSION, exportedAt: now.toISOString(), project }.
Always writes the current kind, never a legacy one. now defaults to new Date().
const spec = buildProjectSpec({
name: "My App",
image: "mcr.microsoft.com/devcontainers/typescript-node:20",
features: [{ id: "node", options: { version: "22" } }],
vscodeExtensions: ["esbenp.prettier-vscode"],
prewarmImages: [],
dind: false,
postCreateCommand: "npm ci",
repositories: [{ provider: "github", project: "acme/app", workspacePath: "app" }],
forwardPorts: [3000],
secretNames: ["DATABASE_URL"],
})buildProjectSpec does not strip anything: mapping your database row to a SpecProject — and
leaving out ids, installation ids and secret values — is your job.
parseProjectSpec(text)
function parseProjectSpec(text: string): ProjectSpecParses the text of an uploaded file into a validated spec, with schema defaults applied. Throws an
Error whose message is safe to put straight in a toast — this runs on a file a human picked, so
"why can't I import this?" has to be answerable without opening a console:
| Input | Message |
|---|---|
| Not JSON | Not a valid JSON file |
JSON with a kind key that fails validation | Unsupported project spec: <first line of the zod error> |
| Any other JSON | Not a Spunto project spec |
try {
const { project } = parseProjectSpec(await file.text())
form.reset(toFormValues(project))
} catch (e) {
toast.error((e as Error).message)
}projectSpecFilename(name)
function projectSpecFilename(name: string): stringDownload filename for a project's spec: the name lowercased, non-alphanumeric runs turned into
-, edges trimmed, then .spunto-project.json. Falls back to project when nothing is left.
projectSpecFilename("My App") // → "my-app.spunto-project.json"
projectSpecFilename("!!!") // → "project.spunto-project.json"Schemas and types
All schemas are exported so an app can extend or compose them with its own zod.
const ProjectSpecSchema: z.ZodObject<…> // the envelope
const SpecProjectSchema: z.ZodObject<…> // the project
const SpecRepositorySchema: z.ZodObject<…>
const SpecFeatureSchema: z.ZodObject<…>
const SpecSharedVolumeSchema: z.ZodObject<…>
type ProjectSpec = z.infer<typeof ProjectSpecSchema>
type SpecProject = z.infer<typeof SpecProjectSchema>
type SpecRepository = z.infer<typeof SpecRepositorySchema>ProjectSpecSchema (envelope)
Prop
Type
SpecProjectSchema
Prop
Type
SpecRepositorySchema
Prop
Type
SpecFeatureSchema
Prop
Type
SpecSharedVolumeSchema
Prop
Type
SpecProject is the zod output type, so features, vscodeExtensions, repositories and
the other defaulted arrays are required when you call buildProjectSpec. Use
z.input<typeof SpecProjectSchema> if you need the lenient input shape.
Naming
The container, volume, network and image names, and the fixed paths, that a worker and every control plane have to pronounce the same way.
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.