SpuntoSpunto/@spunto/build

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 message

zod 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 = 1
  • PROJECT_SPEC_KIND — what a file produced today announces itself as.
  • LEGACY_PROJECT_SPEC_KINDS — kinds still accepted on read. spunto-lite/project is 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 version ProjectSpecSchema accepts (z.literal(1)).

buildProjectSpec(project, now?)

function buildProjectSpec(project: SpecProject, now?: Date): ProjectSpec

Wraps 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): ProjectSpec

Parses 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:

InputMessage
Not JSONNot a valid JSON file
JSON with a kind key that fails validationUnsupported project spec: <first line of the zod error>
Any other JSONNot 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): string

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

Edit on GitHub

On this page