SpuntoSpunto/@spunto/build

Installation

Install @spunto/build, let your toolchain transpile its TypeScript source, and choose between subpath and root imports.

Install

npm install @spunto/build

If you import @spunto/build/spec, also install zod v4 (see The optional zod peer below):

npm install zod@^4

It ships raw TypeScript

There is no dist/. Every entry in exports points straight at a .ts file under src/:

package.json (excerpt)
{
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".":              { "types": "./src/index.ts",              "import": "./src/index.ts" },
    "./steps":        { "types": "./src/steps/index.ts",        "import": "./src/steps/index.ts" },
    "./naming":       { "types": "./src/naming/index.ts",       "import": "./src/naming/index.ts" },
    "./spec":         { "types": "./src/spec/index.ts",         "import": "./src/spec/index.ts" }
  }
}

The published tarball contains src (minus *.test.ts and snapshot fixtures) and the README. That means your toolchain transpiles it, the same way it transpiles your own code. Plain node will not run it; anything that already compiles TypeScript on the fly will.

Next.js does not compile files inside node_modules unless you tell it to. Add the package to transpilePackages. Both Spunto Cloud's apps/next and Spunto Lite do exactly this, next to the design system:

next.config.ts
const nextConfig = {
  transpilePackages: ["@spunto/design-system", "@spunto/build"],
}

export default nextConfig

Why source, not a build

Both consumers already compile TypeScript, so a build step in the package would only add an artifact that can disagree with the source. Shipping source also keeps the types exact. Generic signatures, as const tuples like WORKER_STATUSES and the zod-inferred ProjectSpec reach you as written, not as a .d.ts approximation.

ESM only

The package is "type": "module" and its exports only declare an import condition. There is no CommonJS build, so require("@spunto/build") will not resolve. Use import.

Subpath imports vs. the root import

Subpath imports are the normal way in:

import { workerContainerName } from "@spunto/build/naming"
import { BuildStepTracker } from "@spunto/build/steps"

The root (@spunto/build) re-exports every module for callers that would rather have one import:

import { workerContainerName, BuildStepTracker } from "@spunto/build"

The root re-exports ./spec, which imports zod. Importing from the root without zod installed fails to resolve, even if you only wanted a name from ./naming. Prefer subpaths. They load only what you use, and they keep the optional peer optional.

SubpathNeeds zod
@spunto/build/stepsno
@spunto/build/extensionsno
@spunto/build/namingno
@spunto/build/typesno
@spunto/build/catalogsno
@spunto/build/scriptno
@spunto/build/terminalno
@spunto/build/agent-streamno
@spunto/build/specyes
@spunto/build (root)yes, because it re-exports ./spec

The optional zod peer

package.json (excerpt)
{
  "peerDependencies": { "zod": "^4" },
  "peerDependenciesMeta": { "zod": { "optional": true } }
}

zod is the package's only runtime dependency. Each part of that declaration has a reason:

  • A dependency at all, because ./spec describes a file format that two products have to validate identically. If each side re-derived the schema, that would be the exact duplication this package exists to remove.
  • A peer, so that the zod instance in use is the consumer's own. That matters because apps extend these schemas with their own.
  • Optional, because a peer applies to the whole package while only ./spec needs it. Spunto Cloud's node agent imports ./naming and nothing else, and it used to get zod installed for nothing.

Import ./spec without zod and module resolution fails loudly. That is the intended trade-off. It is better than taxing every other entry point.

The schemas use zod 4 APIs (z.prettifyError, for instance), so zod 3 will not do.

Runtime and TypeScript expectations

The package declares no engines field. What it actually relies on is set in its own tsconfig.json:

tsconfig.json (excerpt)
{
  "compilerOptions": {
    "target": "ES2022",
    // No "dom.iterable", and above all no @types/node: this package must typecheck
    // against nothing but the platform.
    "lib": ["ES2022", "DOM"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}
  • No Node APIs. The source typechecks without @types/node. It uses only what every modern runtime has: fetch, URL, AbortSignal.timeout, TextEncoder/TextDecoder, console. For example, splitCompleteLines takes a Uint8Array rather than a Buffer (a Buffer is one, so Node callers compile unchanged). In practice that means a recent Node (the publish workflow uses Node 24), Bun, Deno, or a browser bundle. parseProjectSpec does run in the browser in Spunto Cloud's import flow.
  • ES2022 syntax in the source. Your transpiler handles that, but your target runtime has to provide ES2022 built-ins.
  • TypeScript 5 or later with isolatedModules-compatible settings. The source uses import type/export type throughout (verbatimModuleSyntax), so it is safe for single-file transpilers such as esbuild and SWC.
  • Bundler-style resolution. Internal imports are extensionless (./steps/index), which works under moduleResolution: "bundler" and under the transpilers above.
Edit on GitHub

On this page