Skip to content

packaging

Provenance

  • Source: .spec/spexcode/spec-cli/footprint/packaging/spec.md
  • Source SHA-256: 75445e8b1d97da9e6edfba7d4409085664e752da747b7930871aa720e32f9c77

SpexCode's installation contract is a single installable npm package named spexcode: users install only that package, and its child packages distribute inside it. @spexcode/spec-core is also published separately for external projects to depend on directly. npm i -g spexcode puts one command on PATH — spex — and nothing else the user must wire. Installing spexcode requires Node >= 22. The package carries everything the tool needs on a machine that has never seen the source: the CLI, its spex init templates, the git/harness hooks, and the prebuilt dashboard. There is no build step on the user's machine — the launcher runs the TypeScript directly through tsx (a real dependency, not a dev-only tool), the dogfood's no-build stance. The full TypeScript compiler is deliberately not runtime cargo: it remains a development dependency for SpexCode's own typecheck and JS anchors, while an adopter's optional JS-anchor extractor resolves that adopter's TypeScript and fails loud when it is absent ([[code-anchor]]).

L0 is the adoption floor, not a daemon fallback. spex spec lint, spex graph, spex materialize, spex init, and spex guide need only Node and the ordinary tsx dependency, so a clean source checkout after npm install --omit=optional can start and use the spec/code asset without Hono or a native addon. hono, @hono/node-server, @hono/node-ws, and node-pty belong to the optional daemon tier only. A spex serve or spex dashboard without any required daemon package refuses before importing daemon code: it names the missing packages and prints the exact npm install ... repair command. It never substitutes a reduced server, hides the command, or leaks a module-resolution stack trace. The stricter CI-like install that also uses --ignore-scripts suppresses esbuild's own platform-binary repair, so its probe explicitly installs the matching @esbuild/<platform>-<arch> package with --no-save --no-package-lock; that is test scaffolding only, not an extra normal-adopter step.

The installable unit remains the monorepo root, now with one real workspace package: @spexcode/spec-core. Its source lives at packages/spec-core/src; the root manifest declares packages/*, spec-cli, spec-eval, and spec-forge as workspaces, depends on the local L0 package, and ships packages/spec-core in its explicit files allowlist. @spexcode/spec-core has three deliberately narrow package exports. . is the Node-side core entry and owns the root-explicit readSpecs(root) reader. ./review is the browser-safe review domain only: its filter, query, and session presentation functions have no Node, React, store, endpoint, or service dependency. ./identity is the same kind of browser-safe identity registry shared by validation and rendering. The dashboard imports only those named pure-domain entries; it never imports . and therefore cannot pull Node-only graph/store modules into Vite. No source-file subpaths are exported. The package is intentionally not private, so this workspace has the same npm resolution boundary an adopter receives; this refactor does not publish it or prepare any registry action.

The root tarball otherwise preserves the runtime layout: spec-cli/{src,bin,templates,hooks}, the siblings spec-eval/src and spec-forge/src, and spec-dashboard/dist. The dist is the one shipped artifact not in git, so it is built by the prepack lifecycle hook — the point npm runs whenever it builds a tarball, on both npm pack and npm publish (but never on a plain npm install). That makes tarball-completeness the contract of producing a tarball at all, not a publish-only afterthought: pack and publish emit the identical complete package, and npm pack self-corrects a stale or missing dist instead of silently shipping one. The remaining spec-cli, spec-eval, and spec-forge cross-imports retain their in-package relative layout; L0 is the explicit exception and resolves through the root package dependency. The bin and all entry source stay under spec-cli/src, so each module's pkgRoot still lands at spec-cli/ and its asset lookups (templates, hooks, dist) are unchanged. The one thing that moves is tsx: spec-cli is now a subdir, and a real npm install may hoist the dependency outside the spexcode package into the consuming project's node_modules. So the launcher and every baked tsx + cli.ts callback resolve it by one shared rule: use Node's own package resolver from spec-cli to find tsx's JS entry (tsx/dist/cli.mjs), then run it through the current Node binary (process.execPath). That covers the dev monorepo, a global install, and a project-local install without hardcoded consumer paths — and stays cross-platform ([[platform-support]]): it never spawns the .bin/tsx shim (an extensionless sh script child_process.spawn cannot execute on Windows) nor a .mjs by its shebang, the crash that broke spex init on native Windows. The repo-root README.md ships too, so the npm page reads the same as GitHub. The internal spec-cli package stays private — the one public name belongs to the tool a user installs.

The launcher also owns the earliest process-identity boundary for project/host control planes. Before loading tsx for serve or dashboard, it removes the invoking session's adapter-declared identity variables from the child environment; doing so later in cli.ts would leave tsx's already-spawned compiler helper falsely owned by that session. Both the installed spex serve and the source tree's canonical npm run api / private npm run serve route through this same launcher; no supported package script invokes tsx src/cli.ts serve directly. Ordinary session/read/write verbs keep their identity unchanged.

Release identity advances in lockstep across the public root manifest and the private spec-cli manifest, with each lockfile's root package metadata matching its manifest. The private manifest carries the same version so source-tree and installed CLI diagnostics name one release.

The installed terminal follows the same artifact rule. node-pty is pinned to an upstream release whose Darwin prebuilds publish spawn-helper as an executable, and a narrow dependency-artifact test verifies both shipped Darwin helpers retain an execute bit. That prevention is deliberately not the only line of defence: the live-terminal helper checks the exact native addon's sibling spawn-helper before first spawn and restores missing execute bits idempotently. Thus an older installed dependency or a permission-losing package copy self-repairs without asking the user to find and mutate node_modules; an unrepairable helper fails visibly through [[live-view]]. This remains independent of global versus project-local placement and of host architecture because the runtime follows node-pty's loaded addon rather than constructing a prebuild path. A package that exposes spex but leaves the terminal's native helper unspawnable is still not a complete installation, so the supply-chain test stays valuable rather than being replaced by the runtime guard.

The natural way to run the installed tool is two commands on two ports, deliberately kept apart — starting the backend never drags the UI along:

  • spex serve — the backend (API + sessions). --port N sets its listen port (sugar over the PORT env).
  • spex serve ui — the UI on its own port, serving the bundled dist and proxying /api + the terminal socket to a running spex serve (--api-port N names that backend). The post-install replacement for the dogfood-only npm run web (a vite dev server against a source tree an installed user has no copy of). Loopback by default; --host H widens the bind for private-network viewing (a LAN or tailnet), still plain HTTP with no gate — the trust call is the network's, and a non-loopback bind is announced at startup, never silent. The internet face stays spex serve --public.

Both ports are explicit flags, which is what lets several projects coexist on one host: spex serve --port 8788 beside spex serve ui --port 5174 --api-port 8788 runs a second instance next to the dogfood's 8787/5173, with cwd choosing which project's .spec each serves — no shared default silently collides two projects. (The pairing is the explicit multi-project story; the zero-pairing one — one spex dashboard reaching every backend the user runs — is [[host-gateway]]'s contract.)

spex serve ui shares the serve-the-built-dashboard engine with [[public-mode]] — local serve is that same gateway with no TLS and no password, on loopback unless --host widens it. The dogfood monorepo is unaffected: its root keeps the npm run api/npm run web dev loop, and the dist resolver falls back to the sibling spec-dashboard/dist whenever no bundled copy is present. Those root scripts delegate into a sibling package with cd spec-cli && npm run …, never npm --prefix spec-cli run …: npm's --prefix is overloaded — it also sets the global install prefix, which npm exports as npm_config_prefix to the backend and every agent it launches, silently redirecting those agents' own npm i -g self-updates into the repo tree instead of the real global root.

The packaging contract is verified as the user would meet it, not by inspecting files: CI builds the tarball, installs that tarball into a clean consumer project, runs npx spex --help, spex --version, and spex graph --json, then runs spex init inside a fresh git repo and checks that the seed .spec tree and spexcode.json landed. A tarball that contains the right files but cannot start from an npm install is a packaging failure.