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 allowed | Why |
|---|---|
| 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. |
| React | That is the design system's job, with the opposite typing discipline (see below). |
process.env | Configuration is a parameter (see below). |
| Docker, sockets, the filesystem | Whoever 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, nodes | Tenancy 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_STATUSESis anas consttuple, andWORKER_STATUS_TRANSITIONSis a totalRecordover 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:
- A public extension registry.
searchExtensionsandlookupExtensionquery Open VSX (https://open-vsx.org/apiby default, overridable) or the operator's configured gallery. - A model vendor's catalogue endpoint.
liveContextWindow/resolveContextWindowin./agent-streamaskhttps://api.anthropic.com/v1/modelsfor 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.