npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spunto/build

v0.10.0

Published

Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.

Readme

@spunto/build

The parts of Spunto's Build pillar that are the same whoever runs the control plane — Spunto Cloud and Spunto Lite both ship them.

Built on the same idea as @spunto/design-system, one layer down: that package owns what the two products look like, this one owns what they agree on.

npm install @spunto/build

What's in it

@spunto/build/steps — the build-log protocol

A docker build for a devcontainer image does a dozen things in one opaque stream. Instead of a new channel, the build script prints a marker around each block and the control plane parses it back out of the log it already forwards:

import { BuildStepTracker, stepStartLine, stepEndLine, type BuildStep } from "@spunto/build/steps"

// …in the script generator, next to the block it brackets:
const lines = [stepStartLine("feature:node"), installNode(), stepEndLine("feature:node")]

// …in whatever forwards the build log:
const tracker = new BuildStepTracker(plan)
const { text, changed } = tracker.ingest(chunk) // markers become banners, steps become live

Also here: stepMarker (same bytes, for emitters that aren't a shell), renderStoredBuildLog (replay a finished build with the same banners), and EXTENSION_FAILED_MARKER / parseFailedExtensions — the second marker, for extensions code-server couldn't install.

@spunto/build/extensions — which registry, and how to ask it

code-server resolves --install-extension <id> against one registry. If your picker searches a different one, users pick extensions the build cannot install. One entry point in front of both supported protocols keeps that from happening:

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

const gallery = parseGallery(rawBlob) // null = Open VSX, the default
const hits = await searchExtensions(gallery, "prettier")
const forCodeServer = codeServerGallery(rawBlob) // the same choice, handed to the worker

parseGallery takes the operator's raw value from wherever you keep it — a database column, an environment variable — and every other function takes its result. Nothing is memoized at import time, so one process can serve several tenants on several registries.

@spunto/build/naming — the names both sides have to pronounce the same way

A worker is a container called mp-worker-<id>, with volumes and a network named after it, running an image called mp-proj-<slug>:v<n>. None of that is an implementation detail — each name is a contract between the script that writes it, the Docker client that looks it up and the UI that shows it:

import { workerContainerName, workerVolumePrefix, projectImageRef } from "@spunto/build/naming"

docker.getContainer(workerContainerName(workerId))
const ref = projectImageRef(projectId, version)   // mp-proj-my-app:v7

Also here: workerNetworkName, workerVolumeName, isProjectImageRef, SHARED_NETWORK, and the paths a worker and its control plane agree on — WORKER_STATUS_FILE / workerStatusPath, EXTENSIONS_DIR, FEATURE_ENTRYPOINTS_DIR, EXTENSION_FAILURES_FILE.

These are formats, not preferences. Changing a value renames things that already exist on running machines: a live worker's container and volumes keep the name they were created with. Read a diff in that file as a migration, not a rename.

@spunto/build/spec — a project as a file you can carry between Spuntos

Both products describe the same thing — an image, some features, some extensions, repositories, lifecycle commands. Lite could already export that to JSON; Cloud could not. And Lite's format announced itself as spunto-lite/project, putting the producer's name in the signature and making the file local by construction.

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

const file = JSON.stringify(buildProjectSpec(project), null, 2)  // kind: "spunto/project"
const spec = parseProjectSpec(await uploaded.text())             // throws a toast-safe message

Files exported by Lite before this package are still accepted on read; new ones always carry the neutral kind.

Secrets travel as names only — values are write-only by design, and a spec is a file people mail each other. Instance identity (id, version history, deploy key) never travels either.

Shape here, policy there. These schemas check that a file is well-formed, not that it is acceptable: mount-path rules, extension-id existence, git-ref validity all stay with the product doing the import, which validates its own create payload anyway. And a product ignores the fields it has no feature for — shared volumes are a Lite concept, the task settings a Cloud one, both optional. Dropping a field the target cannot honour is the correct outcome; refusing the file is not.

@spunto/build/script — turning a project spec into the shell a worker runs

Four generators, one recipe: buildImageScript bakes the project image, buildSetupScript is the first boot, buildStartScript is every boot, buildWorkerScript assembles the container CMD.

import { buildImageScript, IMAGE_RECIPE_VERSION } from "@spunto/build/script"

const { script, steps, hasDinD } = buildImageScript({ features, vscodeExtensions })

No filesystem, no clock: the one install script the platform ships itself is embedded as a string (local-features.ts), and every path a generated script writes to comes from ./naming rather than a literal.

buildContainerScript and buildSetupPlan are shaped by one product's model — an agent that orchestrates setup phase by phase instead of a container running one long CMD. They live here because they share every helper in the file; splitting them out would fork the helpers, which is the duplication this package exists to remove.

Skills (0.10.0): buildSkillsScript({ sources, discoveryDirs }) clones git skill sources into ~/.spunto/skills/<id>/ and links each retained skill (a directory with a SKILL.md) into the harness's discovery directories — one symlink per skill, never over an existing entry, and a refresh only removes the links it posed itself (a ledger records them). The same script is the skills setup phase (skills param of buildSetupScript / buildSetupPlan, after dotfiles, before clone) and a refresh a control plane can run later. Which skills are retained is decided by the caller (linkByDefault + include / exclude); the script only applies it against what the ref actually holds. It never fails the setup: problems are printed with a [skills] prefix.

@spunto/build/catalogs — what a project picker offers

AVAILABLE_IMAGES, AVAILABLE_FEATURES, SUGGESTED_EXTENSIONS. Data, but shared data: the two hand-maintained copies had already drifted, one of them advertising Oh My Zsh in a feature the image recipe explicitly disables.

Project templates are deliberately absent — one product's clone a starter repository and carry onboarding-gallery presentation, the other's only configure an environment. They answer different questions.

@spunto/build/types — the shapes both schemas point at

ProjectFeature, Repository, SetupStatus. Declared here so each product's ORM can point its JSON columns at them with $type<…>(), instead of a generator having to import a database schema. SetupStatus is what a shell script writes into a container and a control plane reads back minutes later: changing it is a migration, not an edit.

@spunto/build/agent-stream — reading an agent session as it happens

One adapter per harness dialect, one vocabulary out. A CLI's JSON output is an output format, not an API; adapters normalise into a small set of event types so stored history doesn't date the first time a vendor reshuffles a field.

Here for the reason that inverts the rest of the package: nothing has forked this yet. Putting it in the shared package now costs nothing and means the second product never writes its own.

@spunto/build/terminal — the half of a persistent terminal that is a string

A worker's terminal is an exec attached to a session that outlives it, so closing a browser tab doesn't kill a build. The attaching is I/O and stays with whoever owns the Docker socket; the shell that gets attached, the layout it writes into, and the parsing of what it prints back are here.

import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"

const name = sanitizeSessionName(fromQuery)        // every name MUST come through this
await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })

dtach, not tmux. tmux's only knob for "the wheel scrolls history" is mouse on, which also makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection back. dtach does only persistence, so the emulator keeps the mouse, its scrollback and its search. What dtach doesn't do is remember the screen, so the session is recorded inside the container with script(1) and the tail replayed on attach.

sanitizeSessionName is a security boundary, not tidiness: names are interpolated into the shell scripts this module builds, so anything that doesn't come through it is a command injection into someone's container.

The rule

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

zod is the one exception, and a deliberate one: ./spec describes a file format that two products have to validate identically, and re-deriving the same schema on each side is exactly the duplication this package exists to remove. It is a peer dependency (^4), so a consumer's own zod is the one used — which matters, since apps extend these schemas with their own. And an optional one, because a peer applies to the whole package while only ./spec needs it: a consumer that imports ./naming or ./steps and nothing else should not have a schema library installed on its behalf. Import ./spec without zod and resolution fails loudly, which is the right trade for not taxing every other entry point.

Concretely: no platform I/O. No database, no Docker socket, no WebSocket. This package produces strings and parses strings. Its only network calls are outbound HTTP on the caller's behalf — a public extension registry, and a model vendor's catalogue endpoint when you hand resolveContextWindow a credential — and it holds no credential of its own. Configuration arrives as function parameters, never read from the environment, so that one control plane can scope a setting per organization and another per process without either shape leaking in here.

That rule is what makes the package testable, safe to import from a node agent as well as from an API, and the reason it can't simply be folded into the design system: a script generator needs strict, canonical shapes, while the design system deliberately models everything as optional so a UI degrades instead of throwing.

What's deliberately not in it

Image script generation (buildImageScript & co.), the devcontainer catalogs, the Docker client and the terminal bridge — all still duplicated between the two products, all staged for later. The reasoning, the measurements and the plan are in RFC 0021.

Development

npm install
npm test        # vitest
npm run typecheck

Published to npm from main by .github/workflows/publish-build.yml; bump the version in package.json to cut a release.

License

MIT