SpuntoSpunto/@spunto/build

Design rules

What may enter @spunto/build, what may not, and how to decide whether a new module belongs.

The rule

A module belongs in this package only if it imports no ORM schema, no HTTP framework, no React and no process.env, and if it knows nothing about organizations, users or compute nodes.

Put concretely: no platform I/O. No database, no Docker socket, no WebSocket. The package produces strings and parses strings.

Not allowedWhy
An ORM schema (Drizzle, …)Cloud runs on Postgres, Lite on SQLite. A generator that imported a table would import one product. The dependency points the other way: each ORM points its JSON columns at the package's types with $type<…>().
An HTTP framework (Hono, …)Routes are a product decision. The package gives the route something to return.
ReactThat is the design system's job, with the opposite typing discipline (see below).
process.envConfiguration is a parameter (see below).
Docker, sockets, the filesystemWhoever owns the Docker socket does the I/O. Running a script, attaching a terminal and streaming a log all stay in the product.
Organizations, users, nodesTenancy is exactly where the two products differ. Cloud is multi-tenant across compute nodes, Lite is one process on one machine.

That rule is what makes the package testable without mocks. It is also what makes it safe to import from a node agent as well as from an API or a browser bundle.

"No filesystem" includes the package's own files. The one install script the platform ships itself (google-cloud-cli.sh) is embedded as a string constant in script/local-features.ts rather than read from disk, and no generator reads the clock.

Configuration arrives as parameters

Nothing is read from the environment and nothing is memoized at import time. Each value is passed in by the caller, at the call site:

import { parseGallery, searchExtensions } from "@spunto/build/extensions"

// Spunto Cloud: per organization, from a database column
const gallery = parseGallery(org.extensionsGallery)

// Spunto Lite: per process, from its own environment, read at its own boundary
const gallery = parseGallery(process.env.EXTENSIONS_GALLERY)

const hits = await searchExtensions(gallery, "prettier")

That is what allows one control plane to scope a setting per organization and another per process without either shape leaking into the package. The same goes for the Open VSX endpoint: each app reads its own env and passes { openVsxApiUrl }. And because nothing is captured at import, one process can serve several tenants on several registries.

Strict, canonical types

This is the half of the rule that explains why the package cannot simply be part of @spunto/design-system:

  • The design system models everything as optional. A status pill has to survive a value it has never seen, and a card has to degrade instead of throwing on an unexpected payload. Its types are structural and lax, and it never uses an app's OpenAPI type.
  • This package models things canonically. A shell-script generator has to refuse a shape it does not know, not guess. SetupStatus["phase"] is a closed union. WORKER_STATUSES is an as const tuple, and WORKER_STATUS_TRANSITIONS is a total Record over it.

The two packages stay independent. TypeScript's structural typing joins them without any import: a design-system component that accepts { status?: string } accepts a WorkerStatus.

The zod exception

zod is the one runtime dependency, and it is deliberate. ./spec describes a file format that two products have to validate identically, and re-deriving the same schema on each side would be the duplication this package exists to remove.

It is a peer (^4), so the consumer's own zod is used. Apps extend these schemas with their own. It is also optional, because only ./spec needs it. See Installation.

The only outbound network calls

"No platform I/O" does not mean "no network". The package makes outbound HTTP calls on the caller's behalf, through fetch, in two places only:

  1. A public extension registry. searchExtensions and lookupExtension query Open VSX (https://open-vsx.org/api by default, overridable) or the operator's configured gallery.
  2. A model vendor's catalogue endpoint. liveContextWindow / resolveContextWindow in ./agent-stream ask https://api.anthropic.com/v1/models for a model's context window, and only when you hand them a credential.

The package holds no credential of its own. A key arrives as a parameter, is used for that call, and is never read from the environment. The model-window lookup is also the one place that memoizes. Hits are kept per process because a window is immutable, and misses are retried after ten minutes. resetModelWindowCache exists for tests.

Does a new module belong here?

Ask the question in this order:

Does a control plane other than Spunto Cloud need it?

If only one product will ever use it, it stays in that product's apps/. A shared package is not a place to tidy code into. ./agent-stream is the one deliberate exception: only Cloud uses it today, but it is pure parsing of third-party output, and putting it here early costs nothing and means the second product never writes its own.

Does it pass the rule?

No ORM, no HTTP framework, no React, no process.env, no platform I/O, no knowledge of organizations, users or nodes. If it needs one of these, split it: the string-producing half can come here, and the I/O half stays in the product. ./terminal is built this way. Attaching to a container is I/O and stays with whoever owns the Docker socket. The shell that gets attached and the parsing of its output are strings, so they live here.

Is it a contract or a preference?

If one side writes it and another reads it back (a marker in a log, a file in a container, a container name), it is a format. It goes here, it gets a test that pins its exact bytes, and it gets a page in Formats. If it is a product preference (onboarding templates, a gallery's presentation), it probably belongs to that product, even if the two products currently look alike. Project templates were left out for that reason: one product's templates cloned a starter repository, the other's only configured an environment. They answered different questions.

Is it shared data with a single truth?

Catalogs are data, but shared data. The two hand-maintained copies had already drifted: one of them advertised Oh My Zsh in a feature that the image recipe explicitly disables. That makes them a contract too.

Do not confuse this package with the devcontainer feature spunto-pack. That feature shares what gets installed into the image (code-server, tmux, dtach, sshd), is published as an OCI artifact, and has its own test: "is it useful to someone who doesn't have Spunto?". This package shares what generates and reads.

Edit on GitHub

On this page