SpuntoSpunto/@spunto/build

Project spec

The portable project file (kind "spunto/project") — every field, a complete example, legacy compatibility, and what deliberately does not travel.

Both products describe the same thing: a devcontainer environment, made of an image, some features, some extensions, repositories to clone and lifecycle commands. Spunto Lite could already export that to JSON and import it back, but Spunto Cloud could not. Lite's format also 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 neutral format, spunto/project, is what turns "prototype it locally, then move it to the cloud" into a feature. Module reference: spec.

Envelope

export const PROJECT_SPEC_KIND = "spunto/project"
export const LEGACY_PROJECT_SPEC_KINDS = ["spunto-lite/project"] as const
export const PROJECT_SPEC_VERSION = 1

Prop

Type

project

The fields that every product understands come first. A field with a default may be omitted from a file, and parsing fills it in.

Prop

Type

Then come the product-specific fields. They are all optional. Absent is normal, and unsupported is not an error.

Prop

Type

SpecFeature

Prop

Type

SpecRepository

Prop

Type

A complete example

my-app.spunto-project.json
{
  "kind": "spunto/project",
  "version": 1,
  "exportedAt": "2026-10-07T09:12:44.512Z",
  "project": {
    "name": "My App",
    "description": "Next.js front-end and its API",
    "image": "mcr.microsoft.com/devcontainers/typescript-node:20",
    "features": [
      { "id": "docker-in-docker" },
      { "id": "my-tool", "ociRef": "ghcr.io/acme/features/my-tool:1", "options": { "version": "latest" } }
    ],
    "vscodeExtensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
    "prewarmImages": ["postgres:16"],
    "dind": true,
    "postCreateCommand": "npm ci",
    "postStartCommand": null,
    "repositories": [
      {
        "provider": "github",
        "project": "acme/my-app",
        "workspacePath": "my-app",
        "cloneUrl": "https://github.com/acme/my-app.git",
        "branch": "main"
      }
    ],
    "forwardPorts": [3000, 8787],
    "secretNames": ["DATABASE_URL", "STRIPE_SECRET_KEY"],
    "taskAgentCommand": "claude -p --dangerously-skip-permissions",
    "taskAgentProtocol": "claude-stream",
    "taskReviewMode": "stop"
  }
}

The filename comes from projectSpecFilename(name). The name is lowercased, every run of characters outside [a-z0-9] becomes -, leading and trailing dashes are trimmed, and .spunto-project.json is appended. An empty slug becomes project.

projectSpecFilename("My App")  // "my-app.spunto-project.json"
projectSpecFilename("!!!")     // "project.spunto-project.json"

Writing and reading

import { buildProjectSpec, parseProjectSpec } from "@spunto/build/spec"

// Export: wraps the project in a fresh envelope. Always the current kind, never a legacy one.
const file = JSON.stringify(buildProjectSpec(project), null, 2)

// Import: parse + validate. Throws an Error whose message is safe to show in a toast.
const spec = parseProjectSpec(await uploaded.text())

parseProjectSpec runs on a file that a human picked, so "why can't I import this?" has to be answerable without opening a console. It throws one of three messages:

InputMessage
Not JSONNot a valid JSON file
JSON, but with no kind key (someone picked the wrong file)Not a Spunto project spec
Has a kind, but fails validation (wrong kind, wrong version, missing image, a port above 65535…)Unsupported project spec: followed by the first line of z.prettifyError(...), i.e. the first issue. For a wrong kind, that issue's message is Expected "spunto/project".

Legacy spunto-lite/project files

Every file that Spunto Lite exported before this package carries "kind": "spunto-lite/project". Those files are on people's disks, and refusing them to tidy up a string would break the only half of the feature that had already shipped. So the legacy kind is accepted on read, and re-exporting such a file writes the neutral kind. A legacy file is upgraded the first time it goes through a product, and no new file is ever written in the old format.

What travels, and what does not

The spec holds what describes the environment, never what identifies an instance:

  • No instance identity. There is no project id, no version history, no deploy key and no favourite flag. A repository's id is optional, and the forge-app installationId that a product keeps on its own repositories is not part of the schema at all.
  • Secrets travel as names only. Values are write-only by design, and a spec is a file that people email each other. The names are there so that an import can lay out the rows left to fill in. Spunto Cloud's end-to-end test for this flow checks precisely that a secret's value appears nowhere in the exported file, and only its name does.

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, and whether a branch name is valid git are all left to the product doing the import, which validates its own create payload anyway. A format that enforced one product's policy would stop being portable on the day the other product's policy differed.

In Spunto Cloud, for instance, the file is parsed in the browser with parseProjectSpec, and it only pre-fills the new-project form. The API validates the creation as it would a hand-filled form, so the import is not a trust boundary.

Ignoring what you cannot honour

A product ignores the fields it has no feature for. Shared volumes are a Lite concept and the task settings are a Cloud one. Importing a Cloud spec into Lite is supposed to drop the task settings, not fail. Losing a field that the target cannot honour is the correct outcome. Refusing the file is not.

The schema does carry both products' optional fields through a parse, so a product that can hand them back (Lite re-exporting a Cloud spec, for example) can keep them intact. Keys the schema does not declare at all are dropped silently on parse, because zod objects strip unknown keys. They are not an error.

The spec's feature shape is narrower than the in-app ProjectFeature type (types). It has no localScript (the filename of an install script the platform ships itself), so that key does not survive a parse.

Compatibility

  • Adding an optional field is compatible in both directions. Old readers drop it, and new readers accept files without it.
  • Making a field required, or narrowing an enum, turns files that already exist into import errors. If it has to happen, bump PROJECT_SPEC_VERSION and keep reading version 1.
  • Changing PROJECT_SPEC_KIND works the way the Lite rename did: the old value moves into LEGACY_PROJECT_SPEC_KINDS and is read forever.
Edit on GitHub

On this page