Skip to content

feat: Download migrations and emulate bundles at runtime - #203

Open
gjtorikian wants to merge 24 commits into
mainfrom
runtime-artifact-downloads
Open

feat: Download migrations and emulate bundles at runtime#203
gjtorikian wants to merge 24 commits into
mainfrom
runtime-artifact-downloads

Conversation

@gjtorikian

@gjtorikiangjtorikian commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Stacked on #195 (to-bun) — this diff shows only the runtime-download work.

Context

The CLI ships as a Bun-compiled standalone binary (#195), with @workos/migrations and @workos/emulate compiled in at build time. That couples their release cycles to ours: users only receive a new migrations or emulate version when a new CLI is released, even though those packages can move faster than the CLI.

This PR is the CLI half of a cross-repo effort to decouple them. Companion branches in workos-migrations and emulate make each package publish a self-contained single-file ESM bundle inside its normal npm tarball (exports subpath ./bundle, tarball path package/dist/bundle.js). This PR teaches the CLI to fetch and run those bundles at runtime — so a migrations fix reaches users on their next invocation instead of waiting for a CLI release.

How it works

  1. A generated manifest (scripts/gen-runtime-deps-manifest.ts, wired into bun run generate) bakes each dep's semver range from package.json at build time — ranges can't drift from what we compile against — plus the list of tarball files to extract (migrations ships a worker.js sidecar resolved via __dirname, so files are a list, entrypoint first).
  2. At runtime the compiled binary asks registry.npmjs.org (abbreviated metadata, 3s timeout) for the newest non-deprecated version satisfying the range; the answer is disk-cached for 24h.
  3. It downloads the tarball, verifies the registry's SRI sha512 over the raw bytes before extracting, then installs the files atomically under ~/.workos/cache/<name>/<version>/ — entrypoint written last, so its presence marks a complete install. Nothing that fails verification is ever imported.
  4. The bundle is await import()ed and shape-checked (program for migrations, createEmulator for emulate) by the calling commands.

Fallback chain — every failure point (offline, timeout, no in-range version, bad integrity, failed import, missing export) falls back to the newest previously verified download, then to the compiled-in module. The packages haven't published bundles yet, so today this PR is behavior-neutral: every path lands on the compiled-in code, which is exactly what ships now.

Escape hatchesWORKOS_RUNTIME_DEPS=0 forces compiled-in with zero network. From source (dev/tests), the mechanism is off unless WORKOS_RUNTIME_DEPS=1, keeping development and CI hermetic; it activates only in compiled binaries, mirroring the Agent SDK download.

gjtorikianand others added 20 commits July 16, 2026 11:28
Users previously needed a Node.js runtime to run the CLI, and every
release shipped transpiled JS through a single npm package. Compiling
with Bun produces one self-contained binary per platform, so the CLI
runs with no runtime prerequisite and distributes directly through
GitHub Releases.
A compiled binary cannot discover package assets on disk at runtime,
so integrations, bundled skills, and the Agent SDK executable move to
generated manifests: the first two are embedded statically, while the
Agent SDK is downloaded on first agent use and verified against a
sha256 pinned at build time. Every release binary is smoke tested on
native hardware for all five targets (including the new
`workos internal verify-assets` command) before the draft release
publishes, so a broken binary can never become `latest`.
npm remains a secondary channel: a thin launcher package plus one
platform package per binary (the esbuild pattern) preserves
`npm install -g workos` and `npx workos`.
BREAKING CHANGE: The npm package no longer exports a library API —
`main`/`exports` are gone and it only provides the `workos` binary.
Development now requires Bun >= 1.3.0 instead of Node >= 22.11.
The Bun standalone binaries only covered glibc Linux, so the CLI
could not run on Alpine and other musl systems, and Windows ARM
users were left running the x64 build under emulation. Runtime
musl detection mirrors the napi-rs loaders so the npm launcher
and the Agent SDK download both resolve the same target the
binary was compiled for, and musl artifacts are smoke tested in
real Alpine containers because no glibc host can prove they run.
A partial npm publish failure left the release permanently
half-published: re-running the job hit npm's cannot-publish-over-
existing-version error on the first already-live package and
aborted before reaching the unpublished ones, including the
launcher. Guarding each publish with `npm view` makes re-runs
converge, so the job comment's "re-run just this job" recovery
actually works.
On Alpine, `bun install` (postinstall runs generate) pinned the
glibc Agent SDK package while the runtime's musl detection demanded
the musl one, so every dev agent use threw a target mismatch. The
keyring-binding check in build.ts assumed glibc the same way. Both
scripts now mirror the runtime's isMuslRuntime() when
WORKOS_BUILD_TARGET is unset; explicit targets are unchanged.
The first-run download (~100MB from the npm registry) had no abort
signal, so a stalled connection hung `workos install` forever
mid-progress, and any transient network error failed the run
outright. A stall timer that resets on each received chunk catches
both connect hangs and mid-stream stalls without penalizing slow
links, and a single retry absorbs transient failures. Checksum
mismatches stay hard failures and are never retried.
After the first install the cached executable is only revalidated
by file size, so silent size-preserving corruption (disk fault,
antivirus quarantine/restore) passed unnoticed until runtime.
`internal verify-assets` is the diagnostic a user with a corrupted
cache gets pointed at, so it now re-hashes the executable against
the pinned manifest digest and fails with a distinct error code and
a delete-the-cache remedy.
The concurrent-extraction recovery in materializeFile (accept a
winner's byte-identical copy, re-throw on divergence) had no
coverage; only the happy path was exercised. A mocked renameSync
that plants the winner's file before failing forces both branches
and proves no temp files are orphaned either way.
Nothing in src/ imports it, so it reads as removable — but ink's
devtools.js statically imports it and `bun build --compile` cannot
prove the DEV-gated branch dead, so removing the dep fails the
compile. `--external` compiles but crashes the binary on every
command. Documenting the measured ~742 KiB cost and the failed
alternatives keeps a well-meaning cleanup from breaking the build.
Conflicts and semantic resolutions against #192:
- src/lib/validation/validator.ts: kept this branch's static JSON rule
imports (required for the compiled binary) alongside main's new
detectPort import; main's port-detection call sites auto-merged.
- src/bin.ts: took main's $0 default handler (JSON command tree via
buildCommandTree, else parser.showHelp()) — it supersedes this
branch's one-line scriptName fix because the parser already carries
.scriptName('workos').
- src/bin-default-command.integration.spec.ts (new on main): converted
the subprocess spawn from `node --import tsx` to `bun --preload`,
matching bin-command-telemetry.integration.spec.ts — tsx is no
longer a dependency on this branch.
- 12 new tests from #192 asserted the `npx workos@latest` hint form
when npm-exec variables are present; this branch always emits the
standalone `workos` form, so those tests now assert the hints are
invariant to npm env, matching recovery-hints.spec.ts.
The only npm-channel check was running the launcher script directly
with NODE_PATH, which bypasses everything that can actually break for
users: registry fetch, optionalDependencies platform selection, npx
cache and bin linking, and the launcher's no-binary error path. A
manual dress rehearsal against a local registry surfaced real gaps the
shortcut can't see (npm nests global deps inside the package; a
brew-installed `workos` shadows bare `npx workos` unless the prefix
and PATH are isolated).
Codifying it makes `npx workos` a gated guarantee: every PR runs it,
and the release pipeline runs it after generating the real packages —
so a packaging regression fails before anything touches npmjs.org.
The registry has no uplinks, proving the install is self-contained.
The smoke gates proved the binary starts (--version, --help,
verify-assets) but nothing executed real subcommands and asserted the
non-TTY contract that agents and CI pipelines script against: exit
codes (0 success, 1 error, 4 auth required), structured JSON errors on
stderr, and JSON output. Seven of the eight platform binaries never
ran a user-facing command before shipping.
command-smoke.sh is POSIX sh so the same checks run inside the
--network none debian container on PRs, inside the Alpine containers
for musl, under Git Bash on the Windows runners, and directly on the
mac/linux legs — every release binary now executes the contract on
native hardware before the draft release publishes. It sandboxes
HOME/USERPROFILE and uses --insecure-storage so host auth state can
never leak in, keeping the exit-4 assertion deterministic.
The smoke gates covered offline behavior only — no shipped binary ever
executed a command that talks to the WorkOS API before release. With a
dedicated staging-environment key the contract smoke now also runs an
authenticated section: organization list plus a create → get → delete
round-trip, exercising key resolution, real HTTP, and JSON output on
the write path.
The key is withheld from the offline checks so the exit-4 assertion
stays deterministic, a cleanup trap deletes the round-trip org even
when a mid-flight check fails, and the CI step skips itself when the
WORKOS_SMOKE_API_KEY secret is absent — fork PRs receive no secrets,
so this cannot fail there.
…ng API URL
The authenticated section discarded stderr, so a CI failure showed
exit codes with no cause. The CLI's structured errors are key-free by
design (keys are masked in all output), so printing them is safe and
turns a blind failure into a diagnosis. WORKOS_SMOKE_API_URL lets the
smoke key target a non-default API host, guarded so an unset secret
cannot inject an empty WORKOS_API_URL.
A keyring or file blob missing the required token fields — left by a
partial write or an older schema — was returned as-is by
getCredentials(). Consumers assume accessToken/expiresAt/userId exist,
so `new Date(undefined).toISOString()` threw on every authenticated
command AND on auth status, bricking the CLI until the entry was
deleted by hand. Found on a real machine while smoke testing this
branch: the entry held only the staging sub-object.
The bug predates the Bun migration (main has the same code), but the
binary upgrade makes stale keyring entries a mainstream path, so
validate required fields at both read sites and degrade to "not
logged in — run workos auth login", which also overwrites the bad
entry on the next login. Malformed file blobs are no longer migrated
into the keyring either.
When the version gate refused an install (Next.js < 15.3, React
Router < 6), the integration returned an empty summary, which the
runAgent wrapper mapped to success:true — so agents and CI scripting
`workos install --json` saw "Successfully installed WorkOS AuthKit!"
with exit 0 while nothing was installed. Found by running the compiled
binary against the bundled Next.js 14 fixture.
Gates now throw InstallDeclinedError, which rides the machine's
existing error path: exit 1, a structured stderr error and NDJSON
error/complete events carrying unsupported_framework_version, while
the human flow keeps its friendly guidance (adapters recognize the
decline code and skip the generic failure styling and AI-service
message rewrites).
The typescript-strict fixture pinned Next.js ^14.2.0 while the
installer's own version gate requires >= 15.3.0, so the bundled
fixture could never exercise the agent path — every eval or manual
run against it hit the gate instead. Next 15 pairs with React 19,
whose types drop the global JSX namespace, so the annotations move
to ReactElement. Verified with tsc --noEmit and next build.
The recent malformed-credentials fix made getCredentials() return
null for a blob missing required token fields, but hasCredentials()
still reported true from a bare file/entry probe. A caller that
gated on hasCredentials() alone would treat a malformed blob as
logged-in and then hit the very null the fix was meant to prevent.
Validating at both read sites keeps the two functions from ever
disagreeing, without triggering getCredentials()'s keyring
migration as a side effect of a boolean check.
Three robustness gaps surfaced while reviewing the first-run
download path:
- The stale-cache reap deleted every sibling version dir
unconditionally, so an upgrade run could remove the executable
out from under a concurrently running other-version CLI. A 24h
staleness guard (matching the skills reaper) leaves fresh dirs
alone.
- gunzip ran unbounded before the sha256 gate, so a compromised or
corrupt registry response could expand a gzip bomb in memory
before verification had a chance to reject it. Capping output at
the pinned executable size plus headroom bounds it.
- A retry restarted the byte count at zero, which froze the
progress readout until it re-passed the previous peak. An
onRetry hook resets the throttle and tells the user it is
retrying.
Move the minimal ustar reader out of agent-sdk-assets.ts into
npm-tarball.ts with a parameterized gzip-bomb cap, and share the spec
tarball fixtures via __test-helpers__. Also export isCompiledBinary so
the upcoming runtime bundle downloader can gate on the same detection.
No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalize the Agent SDK first-run download into a runtime bundle
mechanism (src/lib/runtime-assets.ts) so these packages can ship fixes
independently of CLI releases. A generated manifest bakes each dep's
semver range from package.json plus the tarball files to extract (the
ESM bundle entrypoint and any __dirname-resolved sidecars, e.g.
migrations' worker.js).
At runtime the compiled binary resolves the newest non-deprecated
version in range from the npm registry (24h disk-cached), downloads the
tarball, verifies its SRI sha512 integrity before extracting, installs
the files atomically under ~/.workos/cache/<name>/<version>/ (entrypoint
last so its presence marks a complete install), and dynamic-imports the
cached bundle. Every failure — resolution, download, verification,
import, or a missing export — falls back first to the newest previously
verified download and then to the compiled-in module, so the CLI is safe
to ship before the packages publish dist/bundle.js. WORKOS_RUNTIME_DEPS=0
forces compiled-in with no network; =1 enables the mechanism from source.
@greptile-apps

greptile-appsBot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR decouples @workos/migrations and @workos/emulate from the CLI release cycle by teaching the compiled binary to fetch, integrity-verify, and dynamically import self-contained ESM bundles from the npm registry at runtime. A build-time manifest bakes semver ranges from package.json so downloaded versions can never drift past what the compiled types were written against.

  • Runtime download engine (src/lib/runtime-assets.ts): fetches abbreviated npm packument (3 s timeout), disk-caches the resolution for 24 h, downloads the tarball, verifies the registry's sha512 SRI over raw bytes before extracting anything, installs files atomically (entrypoint written last to mark a complete install), and marks installFailed in the resolution cache on extract/import failure so the same broken tarball is not re-downloaded within the TTL.
  • Fallback chain: every failure (offline, bad integrity, missing bundle entry, failed import, wrong export shape) falls back to the newest previously verified cached version, then to the compiled-in module. Because neither companion package has published a bundle yet, today this PR is behavior-neutral — every path lands on the compiled-in code.
  • Extraction refactor: the shared ustar reader from agent-sdk-assets.ts is factored into npm-tarball.ts (with a parameterized gzip-bomb cap) and reused by both download paths.

Confidence Score: 5/5

  • Safe to merge. The runtime download path is behavior-neutral today (companion packages haven't published bundles yet), the fallback chain ensures compiled-in behavior on every failure, and the kill switch (WORKOS_RUNTIME_DEPS=0) gives a clean escape hatch.
  • The two issues flagged in earlier review rounds — repeated tarball downloads during the transition period and the overly-loose sha512 pre-filter — are both correctly addressed in this revision. The installFailed TTL guard prevents re-downloading a tarball that couldn't extract a bundle, and pickHighestSatisfying now requires sha512Digests(...).length > 0 before admitting a version as a candidate. The test suite explicitly covers both cases.
  • No files require special attention. src/lib/runtime-assets.ts is the most complex piece, and its behavior is thoroughly exercised by the 11-case spec.

Important Files Changed

FilenameOverview
src/lib/runtime-assets.tsCore runtime-download engine: metadata fetch + 24h disk cache, SRI sha512 verification before extraction, atomic install (entrypoint written last), installFailed TTL guard against repeated failed downloads, module-level memoization, and a thorough fallback chain. Implementation is sound.
src/lib/npm-tarball.tsShared ustar reader extracted from agent-sdk-assets.ts. Parameterized gzip-bomb cap, full pax/prefix name reconstruction, block-aligned offset walk — correct and now tested independently via npm-tarball.spec.ts.
src/lib/emulate-loader.tsThin adapter that resolves createEmulator from the runtime bundle or compiled-in module after a shape check. Clean and well-tested.
src/commands/migrations.tsUpdated to resolve the migrations program through loadRuntimeBundle with a shape-check fallback to the compiled-in module. Straightforward and covered by new tests.
scripts/gen-runtime-deps-manifest.tsBuild-time manifest generator that bakes semver ranges from package.json and lists of tarball files to extract. Throws on missing dependency, keeping the generated manifest consistent with what is compiled in.
src/lib/runtime-assets.spec.tsComprehensive test suite covering resolution TTL, offline fallback, SRI rejection, sidecar file installation, the installFailed guard against repeated failed downloads, and the WORKOS_RUNTIME_DEPS kill switch. Edge cases are well-covered.
src/lib/agent-sdk-assets.tsMinor refactoring: isCompiledBinary exported for sharing with runtime-assets.ts; extractTarEntry now delegates to the new shared npm-tarball.ts reader with the Agent SDK's size cap unchanged.
src/lib/test-helpers/npm-tarball-fixtures.tsShared test fixture builder factored out of agent-sdk-assets.spec.ts. Correct ustar checksum implementation with proper block padding.

Sequence Diagram

sequenceDiagram
participant CLI as CLI Command<br/>(migrations / emulate / dev)
participant Loader as emulate-loader.ts /<br/>migrations.ts
participant RA as runtime-assets.ts<br/>loadRuntimeBundle
participant Disk as ~/.workos/cache/<br/><name>/<version>/
participant NPM as registry.npmjs.org
CLI->>Loader: resolveCreateEmulator() / resolveMigrationsProgram()
Loader->>RA: loadRuntimeBundle(name)
alt module already memoized (same process)
RA-->>Loader: "cached module | null"
else first call
RA->>Disk: readResolutionCache (resolution.json)
alt "cache fresh & version satisfies range"
Disk-->>RA: ResolvedVersion + installFailed?
else stale or missing
RA->>NPM: GET abbreviated packument (3s timeout)
NPM-->>RA: AbbreviatedPackument
Note over RA: pickHighestSatisfying()<br/>sha512 required in candidates
RA->>Disk: writeResolutionCache(resolved)
end
alt bundle entrypoint exists on disk
RA->>Disk: import(pathToFileURL(bundlePath))
Disk-->>RA: module namespace
else "not yet downloaded & installFailed != true"
RA->>NPM: GET tarball (30s timeout)
NPM-->>RA: tarball bytes
Note over RA: verifySriIntegrity(tarball, sha512)<br/>BEFORE any extraction
Note over RA: extractTarEntry all files<br/>entrypoint written LAST
RA->>Disk: atomicWrite sidecars then entrypoint
RA->>Disk: import(pathToFileURL(bundlePath))
Disk-->>RA: module namespace
else "installFailed == true (within TTL)"
Note over RA: skip re-download
end
alt any failure above
RA->>Disk: newestDownloadedVersion (in-range fallback)
Disk-->>RA: "older verified bundle | null"
end
RA-->>Loader: "module | null"
end
alt module has expected export shape
Loader-->>CLI: runtime createEmulator / program
else null or wrong shape
Loader->>Loader: import compiled-in module
Loader-->>CLI: compiled-in createEmulator / program
end
Loading

Reviews (2): Last reviewed commit: "style: Collapse the agent-sdk-assets spe..." | Re-trigger Greptile

Comment threadsrc/lib/runtime-assets.ts
Comment threadsrc/lib/runtime-assets.ts
Base automatically changed from to-bun to mainJuly 26, 2026 15:16
…loads
# Conflicts:
#	.github/workflows/ci.yml
#	.github/workflows/release.yml
#	.gitignore
#	CLAUDE.md
#	README.md
#	bun.lock
#	package.json
#	src/bin.ts
#	src/commands/claim.spec.ts
#	src/commands/env.spec.ts
#	src/commands/install.ts
#	src/integrations/nextjs/index.ts
#	src/integrations/react-router/index.ts
#	src/integrations/version-gate.spec.ts
#	src/lib/adapters/cli-adapter.spec.ts
#	src/lib/adapters/cli-adapter.ts
#	src/lib/adapters/headless-adapter.spec.ts
#	src/lib/adapters/headless-adapter.ts
#	src/lib/agent-interface.ts
#	src/lib/agent-sdk-assets.spec.ts
#	src/lib/agent-sdk-assets.ts
#	src/lib/credential-store.ts
#	src/lib/installer-core.ts
#	src/lib/version-check.ts
#	src/utils/box.ts
…12 candidates
Address Greptile review on #203:
- A failed bundle install (e.g. the package has not published dist/bundle.js
yet) is now remembered in resolution.json, so invocations inside the 24h
TTL window no longer silently re-download the full tarball just to fail
extraction again.
- pickHighestSatisfying now actually requires a sha512 SRI hash, matching
its docstring; sha1-only versions can never verify, so they are no longer
resolved, cached, and downloaded only to be rejected.
Ponytail review of #203: _resetRuntimeAssetsForTesting had no callers,
the entry-last write order reads clearer as destructuring than a double
filter, and tarHeader needed no export.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gjtorikian