How the cosyte CLI (and its twin cosyte-mcp server, one package, two bins) gets to npm, and the
gotchas worth not rediscovering. The suite-wide mechanics live in the umbrella
config/RELEASING.md; this file is the CLI-specific overlay.
- Flipping the repo public (
PUB-FLIP). - The real
npm publish.
Both are standing human gates. Everything up to them (the changeset, the version PR, the publish dry-run, provenance/OIDC config, this doc) is agent-shippable. The publish itself is not.
@cosyte/cli is the only package in the suite that hard-depends on its siblings: an npx-invoked
bin cannot peer-depend on something the user pre-installed. Those deps used to be vendored
pnpm pack tarballs (file:vendor/*.tgz, ADR 0021/0023). A published package cannot ship a
file:vendor/…tgz dependency, and two releases went out doing exactly that.
They are now real npm ranges, with the single exception of @cosyte/fhir, which is not on the
registry and therefore is not declared at all. vendor/ survives only to supply @cosyte/fhir to
this repo's own test run, as a devDependency; pnpm vendor:refresh still refreshes the tarballs,
and the other nine are no longer wired to anything.
Both published with all ten file:vendor/*.tgz specifiers intact. vendor/ is not in files,
and there is no bundledDependencies, so those tarballs ship none of them. Every install route
(npm i, npm i -g, npx) dies on the first one:
npm error code ENOENT
npm error path node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz
A published version is immutable (ADR 0001), so neither can be repaired; the fix ships as a later version. The lesson, and it is now a step the pipeline runs:
npm publish --dry-run cannot catch this, and the checklist implied it could. A dry-run packs a
tarball; it never resolves that tarball's dependencies from a registry. The gate that catches it is
npm install of the packed tarball from a directory outside this repo. A green dry-run is not
install-proof.
That install is no longer something a human has to remember. It is the install-gate job in
.github/workflows/release.yml, and the release job that publishes needs: it, so a red gate
stops the publish. See "The install gate" below for exactly what it does and does not cover.
Verified against the live registry on 2026-08-03, and by installing the packed tarball in a clean directory, not assumed:
| dep | shipped as | resolves? |
|---|---|---|
@cosyte/hl7 |
dependencies, ^0.0.7 |
yes |
@cosyte/terminology |
dependencies, ^0.0.9 |
yes |
| six breadth parsers | optionalDependencies, real ranges |
yes |
@modelcontextprotocol/sdk |
optionalDependencies, 1.29.0 |
yes |
@cosyte/transform |
optionalDependencies, ^0.0.4 |
no |
@cosyte/fhir |
not declared at all | no |
On the 0.0.x ladder ^0.0.7 is an exact pin (caret on a 0.0.z version allows no other
version), which is what we want pre-alpha: the CLI is tested against exactly those sibling releases,
and Dependabot now proposes each bump as its own reviewable PR.
Why @cosyte/fhir is not declared, rather than declared optional. It is not on the registry
(FHIR-NPM-NAME, a persistent npm E403; the earlier "name similarity" reading was retracted
ecosystem-wide on 2026-08-03, so treat the cause as unexplained and do not assert one). Declaring
it at all breaks the install, which was measured here rather than reasoned about:
| root manifest declares | npm install |
|---|---|
optional @cosyte/fhir alone |
exit 0 |
optional @cosyte/transform alone |
exit 0 |
| both | ERESOLVE, exit 1 |
optional @cosyte/fhir as an optional peer + transform |
ERESOLVE, exit 1 |
So the two cannot both be named. @cosyte/transform is declared (it is real, and it starts
installing by itself the day @cosyte/fhir publishes, with no release needed here) and @cosyte/fhir
is not. Do not attribute this to a missing peerDependenciesMeta.optional flag: measured across
the suite, that flag does not decide the outcome (synth marks all seven peers optional and still
fails ERESOLVE; deid declares the same optional @cosyte/fhir peer and installs cleanly). The
mechanism is not yet explained. Record measurements, not theories.
@cosyte/fhir is kept as a devDependency on the vendored tarball, so this repo's own FHIR and
convert tests still run, and so it satisfies @cosyte/transform's peer in the dev tree.
Say this precisely, because the obvious shorter sentence is false. devDependencies are
published: they stay in the published package.json (npm view @cosyte/hl7@0.0.7 devDependencies
returns a full list), and so the fix release's manifest still carries one file:vendor/*.tgz
specifier. What makes that harmless is that a consumer never installs a dependency's
devDependencies, so npm never resolves the path. That was verified, not assumed: installing the
packed tarball in a clean directory exits 0 with the file: devDependency present in the manifest.
The runtime closure is what had to be clean, and it is.
With @cosyte/fhir and @cosyte/transform both absent, every code path that reaches for them
degrades to a value-free CLI_PARSER_UNAVAILABLE (exit 69). That required a code change, and
shipping the manifest swap without it would have been worse than the install break: both were
imported with a bare await import(), so an install without them raised a raw resolver error and a
stack frame, which the CLI's value-free posture forbids.
loadOptional() could not be reused as-is: its signature is loadOptional<T>(format: CosyteFormat, …)
and "transform" is not a CosyteFormat, and its diagnostic hardcodes the word "parser", which is
wrong for a conversion library. So src/core/parsers.ts now has loadOptionalPackage(detail, load)
underneath it, plus a loadFhir() whose diagnostic says the package is not on the registry (rather
than loadOptional's "install it", which would be false). @cosyte/hl7 and @cosyte/terminology
stay hard deps and keep their bare imports, correctly. test/absent-sibling.test.ts holds this shut,
including a static guard over src/.
What that guard does and does not catch, because "any new call site" would overstate it. It flags
a single-line await import("@cosyte/fhir") or import("@cosyte/transform") that is not wrapped by
one of the loaders, which is the shape the defect actually took, and it carries negative controls so
it cannot pass by matching nothing. It does not catch a thunk assigned to a variable and awaited
elsewhere, an import split across lines, or a static import … from "@cosyte/fhir". That last one
matters: this repo now has its first static reference to that package (src/core/parsers.ts, as
import type, which is erased at build time and emits no runtime load, verified in dist/). Dropping
the word type would load it eagerly and break every command in an installed copy, and the guard
would not see it.
Separate, pre-existing, and measured on both the published 0.0.2 and the fixed tarball:
$ npx @cosyte/cli --help
npm error could not determine executable to run
npx runs the executable whose name matches the package name's last segment, which would be cli;
this package ships cosyte and cosyte-mcp. npx --package @cosyte/cli cosyte --help works and is
what the docs tell people to run. A cli bin alias would fix the short form and is deliberately not
added, because npm install -g would then put a command named cli on every user's PATH. If that
trade is ever revisited, it is a founder call, not a packaging tidy-up.
Releases run on Changesets:
- A change lands with a changeset (
pnpm changeset): apatchon the0.0.x-until-first-alpha ladder (a published version is never moved back). The parsers publish at0.0.1; the CLI begins its public history at0.0.1. - On push to
mainwith pending changesets,.github/workflows/release.yml(a thin caller of the sharedcosyte/.githubrelease pipeline) opens/updates a "Version Packages" PR that consumes the changesets and bumpsversion+CHANGELOG.md. - Merging that PR runs the workflow again; with no pending changesets it runs
pnpm run release(changeset publish) inside the protectedreleaseenvironment: the approval gate. Nothing reaches npm without a deliberate human ack.
NPM_TOKEN must be an npm Automation token (a classic Publish token demands a 2FA OTP CI
cannot supply, and the publish dies at the very last step with EOTP).
.github/workflows/release.yml runs node scripts/release-install-gate.mjs in an install-gate job
that the publishing job needs:. It is the automated form of what used to be checklist step 6, and
it runs on every push to main, which includes the push that opens or updates the "Version
Packages" PR. Locally: pnpm build && node scripts/release-install-gate.mjs (add --json for the
full report, --manifest-only for the offline specifier lint alone).
What it does. Packs this tree with pnpm pack, installs the packed tarball with npm install
into a fresh directory outside this repository's working tree (and refuses if that directory is
inside it), then executes every bin the manifest declares from the installed copy: cosyte must
exit 0, and cosyte-mcp, a stdio server that never exits on its own, must not exit non-zero inside
a bounded window. Before any of that it refuses a specifier naming a local path (file:, link:, a
relative or absolute path) in dependencies, optionalDependencies or peerDependencies.
It fails closed. Every outcome that is not a clean pass exits non-zero with its own reason, and
those reasons are deliberately distinct: install-failed (the install exited non-zero),
bin-missing (a packaging defect: the tarball has no file for a declared bin), bin-failed (the bin
ran and failed), local-path-specifier, and gate-timeout / gate-registry-unreachable /
gate-error for a run that reached no verdict at all. A transient registry fault therefore delays a
correct release until the run is repeated; the alternative, warning and continuing, is how 0.0.1
and 0.0.2 happened.
What it deliberately does NOT cover.
devDependencies. A consumer never installs them, so thefile:vendor/*.tgzspecifier this manifest carries on purpose is reported and passed over. The gate names it in its report rather than ignoring the field silently.@cosyte/fhirand@cosyte/transformbeing absent from an installed copy. That is the designed state, not a defect; those paths degrade to a value-freeCLI_PARSER_UNAVAILABLE(exit69). The gate asserts the install exited zero and the bins ran, never that some named package is present.- A real parse from the installed copy. Each bin is invoked with
--version, which is the load path: it proves the command starts and its module graph resolves, not that a message parses. That stays the job of the suite, ofpnpm smoke, and of step 6 below. - The version string the installed bin prints.
cosyte --versionprinting0.0.0is the skipped-sync-versiondefect, which is a different failure from "cannot be installed". That stays a human step, and it is step 6 below. - The published artifact. The gate reads the tarball this tree packs, not the registry. The
shared pipeline's own install check runs after
changesets/actionhas published and fail-opens, so it can report a dead version but cannot prevent one.
package.json#publishConfigsets"provenance": true, andrelease.ymlgrantsid-token: write, so provenance auto-attaches once the repo is public (the shared pipeline wiresNPM_CONFIG_PROVENANCEto public visibility; no workflow edit needed at flip time).- OIDC trusted publishing (token-free) is the later step: configure the Trusted Publisher on npm
for
@cosyte/cli(orgcosyte, repocli, workflowrelease.yml, environmentrelease), then dropNPM_TOKEN. Keepid-token: write.
The publish path is exercised without uploading anything:
pnpm build # dist/ must exist first
pnpm attw # per-condition types resolve (node16 import + require, bundler)
pnpm smoke # built dual ESM/CJS `.` + `./mcp`, and BOTH bins run under node, IN dist/
npm publish --dry-run # assembles the tarball (dist + README/LICENSE/CHANGELOG), no upload
node scripts/release-install-gate.mjs # packs, INSTALLS the tarball outside this repo, runs both binspnpm smoke and the install gate look similar and answer different questions. smoke exercises
dist/ in place, so every path it walks is a path inside this repo; the gate installs the packed
tarball somewhere else entirely, which is the only way the file:vendor/*.tgz shape ever surfaces.
scripts/verify.sh cli runs test:coverage (per-dir ≥ 90 on core + commands), build, attw,
and smoke as its gate. The nightly Fuzz workflow (.github/workflows/fuzz.yml, pnpm test:fuzz)
scales the argv+stdin+MCP fuzz far past the per-PR count. A red in any of these means a real release
would fail.
binname availability. Confirm@cosyte/cliis publishable and thatcosyteandcosyte-mcpare free as global bin names on npm beforePUB-FLIP. A collision surfaces only at publish time.
Steps 1, 2 and 3 are already done. Step 2 is the one that was skipped twice.
The install itself is not on this list any more. install-gate in .github/workflows/release.yml
packs this tree, installs the tarball outside the repo and runs both bins on every push to main,
and the publishing job needs: it. What is left for a human is the one check that gate does not
make: the version string.
-
Done; the repo is public.PUB-FLIPthe repo public (founder stop 1). -
Swap the vendoredDone; see "The dependency swap, as actually done" above for what could and could not be swapped, and for the measurements behind it.file:deps for real@cosyte/*npm ranges.@cosyte/fhirstays undeclared until it is on the registry; when it publishes, declare it and@cosyte/transformstarts resolving too. -
Confirm theDone;cosyte/cosyte-mcpbin names are free on npm.0.0.1owns both. -
Land the release changeset; approve the "Version Packages" PR.
-
Approve the protected
releaseenvironment to publish (founder stop 2). Provenance attaches automatically. -
A HUMAN STEP, AND STILL ONE: check the version string the published bin prints. The install gate proves the published version can be installed and run; it deliberately does not assert WHICH version the bin reports, because a wrong version string and an uninstallable package are different failures. Nothing automated catches this one:
cd "$(mktemp -d)" && npm init -y >/dev/null npm install @cosyte/cli@<version> # the gate already proved this shape; expect exit 0 node_modules/.bin/cosyte --version # must print <version>, NOT 0.0.0 node_modules/.bin/cosyte parse some.hl7 # must exit 0; the gate runs the bin, not a real parse node -e 'import("@cosyte/cli").then(m=>console.log(m.VERSION))'
cosyte --versionis the check that catches a skippedscripts/sync-version.mjs:0.0.1and0.0.2both printed0.0.0.