Publishing to npm — tcm-ts

Publishing to npm

A codebase holds definitions, not files, so there's nothing to point npm publish at. tcm pack makes the package: the exported definitions and everything they use, compiled to JavaScript with type declarations, entry points that export them by name, and a package.json depending on the packages they import.

Packages

A definition named manifest makes its namespace a package. A codebase can hold any number:

// tcm: namespace greet
import escape from "escape-string-regexp";

/** Greetings, and patterns that find them. */
export const manifest = { name: "greeter", bin: { greeter: "main" } };

export interface Greeting {
  to: string;
  text: string;
}

/** A greeting, and a pattern that finds it. */
export function greet(to: string): Greeting {
  return { to, text: `hello, ${to}` };
}

export const finder = (g: Greeting): RegExp => new RegExp(escape(g.text));

export function main(...names: string[]): number {
  for (const n of names) console.log(greet(n).text);
  return names.length ? 0 : 2;
}
$ tcm release greeter
released greeter@0.1.0 as #pnlegqdu: its first release
$ tcm pack greeter@0.1.0
packed greeter@0.1.0 into .tcm/pack/greeter: 4 module(s), depends on escape-string-regexp
$ cd .tcm/pack/greeter && npm publish

Then anyone can use it:

import { greet, finder, type Greeting } from "greeter";
$ npx greeter Ada
hello, Ada
  • tcm pack <package>@<version> packs a release: its definitions as they were, at its version, with its CHANGELOG.md.
  • tcm pack <package> packs it as it is on the branch, at version 0.0.0.

The manifest is a definition like any other: hashed, in the history, diffed and reviewed in contributions. Names in it are relative to its namespace, so tcm mv greet hello takes the package along. A manifest at the root makes the whole codebase one package.

A package's own definitions are its namespace's, except nested packages (theirs), tests, tool configs and the manifest. Its release versions come from what changed in what it exports, and their notes are its changelog.

name
the npm package's name
exports
subpath (".", "./ui") → names under the namespace whose definitions it exports ("" is all of it). Default { ".": "" }
bin
command → the definition it runs
with
packages the code needs but doesn't import, like @types/node@^24
files
files to copy in: paths relative to the codebase root (a README, a licence), or the codebase's own files relative to the package, written out under their last name: a namespace of them as a directory (docs is docs/), one as itself (files.README is README.md)
packageJson
a JSON file of the codebase (relative to the package's namespace) that package.json starts from, for fields tcm doesn't write, like a VS Code extension's contributes, publisher and engines. tcm's own fields win (type, exports, bin, version); main is the main entry if it gives one; its dependencies are dropped except ones shipped in the package (file:./plugin); its scripts and dev dependencies are dropped
suites
command → namespaces of test definitions that run it, relative to the package: tcm test runs them as programs (suites). Not packed
apps
name → an app the package ships, relative to the package, packed as a codebase of its own (below)
libraries
tcm libraries of the codebase the package ships, by package name: each one's newest release, packed as a codebase of their own (below)
description
defaults to the first paragraph of the manifest's doc comment
npm
false for a tcm library, added with tcm lib add rather than from npm. It isn't packed; a package of the codebase that uses it carries its code instead of depending on it; and its release is refused while it uses definitions outside its namespace
host
the version of tcm's host protocol the code speaks: what a library reaching tcm's runtime is written against (the page's globals, for React's routes and data; #tcm/host, for the server API's tables, jobs and request). An app using it is built only by a tcm serving that version; this one serves 1
roles
name (relative) → its role: what tcm's build treats it as, known by hash so every version of it counts. stylesheet: a component given a stylesheet file as css={…}, sent to the browser as a linked file, not text (the react library's <Stylesheet>). table: a function making a typed table, so a top-level const users = table<User>("users") is one, its row type's schema kept with it (the server library's table; code received from elsewhere stays a table if it was one). every: a function making a recurring job, so a definition beside an app whose value it makes runs on its schedule (the server library's every)

Apps

A package can ship the codebase's apps (apps: { share: "share.App" }). tcm pack puts each in as a codebase of its own, apps/<name>/codebase.sqlite, holding only the app (named app) and what it uses, pinned to the package versions it runs on here. With it go:

  • its recurring jobs (each every(...) in its namespace), named as there (ingest);
  • its plain HTTP routes, the functions in its namespace's http namespace, as http.… (registry.http.oauth.client becomes http.oauth.client, which tcm start app --http http=/api serves at /api/oauth/client). Tests are left out.

tcm start app in that directory runs it anywhere, building it the first time. Code running from the package finds its apps beside it: that's how tcm ships its web UI. Suites, which run the command from the codebase rather than a package, find them where tcm test packed them (TCM_APPS).

Libraries it ships

A package can ship the codebase's tcm libraries (libraries: ["react"]). tcm pack puts each one's newest release in one codebase, libraries/codebase.sqlite, each library's namespace named as it was and its release recorded, so it has the same hash there. A command run from the package reads them as the source tcm: that's how tcm lib add tcm react gets tcm's React library with no network. Shipping a library with no release, or one that isn't a tcm library, is refused. Suites find them where tcm test packed them (TCM_LIBRARIES).

Packages that use each other

When one package uses definitions another exports, it depends on the other's oldest release that has those exact definitions (the widest range that works) and imports them from it: "geo": "^0.2.0" in its package.json, import { distance } from "geo" in its modules. It doesn't carry a copy, which would duplicate code and break instanceof across the two. If no release has them as they are (changed since, or never released), packing is refused: release the other first.

What the other keeps to itself (not exported) is carried instead, like the package's own code, and so is all of a tcm library it uses (npm: false), which isn't on npm. That way two packages of one codebase can share code without either publishing it.

Packing definitions directly

Without a manifest, tcm pack takes definitions on the command line: tcm pack greet ui=ui --name greeter@1.2.0 --bin greeter=greet.main --files README.md.

What goes in

In a manifest, or on the command line:

  • Entries are namespaces or definitions. A namespace exports every definition beneath it under its last name (greet.greet as greet). Several entries can share an entry point. Two definitions exported under one name are refused (give them separate subpaths); a type and a value can share a name.
  • Subpaths: subpath=<entry> exports from <package>/<subpath>: tcm pack greet ui=ui --name greeter makes greeter and greeter/ui.
  • Bins: --bin cmd=<name> makes a command that runs the definition as tcm run does. A function is called with the command's arguments and awaited; a returned number is the exit code, anything else is printed. Separate several with commas (--bin tcm=cli.run,tcm-ts=cli.run).
  • Modules: every component the entries and bins need, under its hash (modules/<hash>.js, with a .d.ts beside it). Doc comments go into the declarations, so editors show them on hover. A file the code imports (an .svg, a .png) is a module too, exporting its content: text as a string, bytes as a Uint8Array.
  • Dependencies: the packages the code imports, at the codebase's locked versions, plus the DefinitelyTyped types tcm added for packages without their own (@types/react beside react). A package pinned at two ranges (code on React 18 beside code on 19) is installed twice under aliases ("npm__react__18": "npm:react@18.3.1"), and each module imports the one it was checked against.
  • --with pkg[@version],... adds packages the code needs but doesn't import: ones loaded at runtime by name, or @types/node when the declarations mention Node's types. Without a version: the locked one, else *.
  • --files a,b copies in files or directories (a README, a licence). They keep their path under the codebase root; files from outside it go in by name.
  • --name <pkg>@<version> sets the name and version (0.0.0 without one). --description fills that field. --out defaults to .tcm/pack/<name>; it's replaced each time, so it must be empty or an earlier pack.

The package is ESM ("type": "module") and needs Node 22.18 or newer, as tcm does.

Code for an editor

Some modules come from where the code runs rather than being installed: vscode, the API VS Code gives extensions. Import it as usual (import * as vscode from "vscode"). tcm checks it against @types/vscode (provided like any package) and records it unversioned, like Node's builtins; a package never depends on it. Say which VS Code it needs as engines.vscode in the packageJson. tcm's own VS Code extension is packed this way: pnpm tcm pack tcm-ts-vscode.

What a package can hold

A packed package runs on its own, without tcm. Code that only makes sense under tcm dev or tcm start (server functions the browser calls, typed tables, background jobs) packs, but has no app server to talk to. Use an app for those (apps, deploying).

tcm itself is packed this way: pnpm verify packs tcm from its own codebase and runs the test suite against it (developing).

Built from c482a14e