feat: Download migrations and emulate bundles at runtime#203
feat: Download migrations and emulate bundles at runtime#203gjtorikian wants to merge 20 commits into
Conversation
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 SummaryThis PR decouples
Confidence Score: 3/5The download pipeline is safe to ship once the repeated-tarball-download issue is resolved; until then, every migrations, emulate, and dev invocation from a compiled binary will silently download a full npm tarball on startup. There is a concrete defect in the caching flow: writeResolutionCache is called after a successful metadata fetch but before a successful install. When the tarball exists on npm but does not yet contain dist/bundle.js — exactly the stated state of both companion packages today — the resolution is cached for 24 hours yet the entry path never appears on disk. Every subsequent process invocation will hit the cached resolution, find no entry path, and re-download the full tarball before failing and falling back to the compiled-in module. The failure is silent to users because logWarn only writes to the log file and to the console in debug mode. src/lib/runtime-assets.ts — specifically the ordering of writeResolutionCache relative to the downloadBundle call, and the pickHighestSatisfying candidate filter versus its docstring. Important Files Changed
|
| if (!resolved) { | ||
| try { | ||
| resolved = await fetchResolution(dep); | ||
| writeResolutionCache(cacheDir, resolved); | ||
| } catch (error) { | ||
| logWarn(`Version resolution for runtime dep ${dep.npmPackage} failed:`, error); | ||
| resolved = null; | ||
| } | ||
| } | ||
|
|
||
| if (resolved) { | ||
| try { | ||
| const bundlePath = entryPath(cacheDir, resolved.version, dep); | ||
| if (!existsSync(bundlePath)) { | ||
| await downloadBundle(dep, resolved, join(cacheDir, resolved.version)); | ||
| cleanupStaleVersions(cacheDir, resolved.version); | ||
| } | ||
| return await importBundle(bundlePath); | ||
| } catch (error) { | ||
| logWarn(`Runtime bundle install for ${dep.npmPackage}@${resolved.version} failed:`, error); | ||
| } | ||
| } |
There was a problem hiding this comment.
Repeated tarball download on every invocation during the transition period
writeResolutionCache is called immediately after a successful metadata fetch, before the bundle install. When downloadBundle fails — for example because the currently-published tarball doesn't include dist/bundle.js (exactly the situation the PR describes for both @workos/migrations and @workos/emulate today) — the entrypoint path is never written to disk. On every subsequent CLI invocation within the 24-hour TTL window, readResolutionCache returns the cached version, existsSync(bundlePath) evaluates to false, and a full tarball download (up to TARBALL_TIMEOUT_MS = 30 s) is attempted again. Since logWarn is only visible in debug mode, users see no indication of what is happening. Every workos migrations, workos emulate, and workos dev invocation silently downloads the full npm tarball, fails extraction, then falls back to the compiled-in module until either the companion packages ship their bundles or the 24-hour cache expires.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/runtime-assets.ts
Line: 302-323
Comment:
**Repeated tarball download on every invocation during the transition period**
`writeResolutionCache` is called immediately after a successful metadata fetch, before the bundle install. When `downloadBundle` fails — for example because the currently-published tarball doesn't include `dist/bundle.js` (exactly the situation the PR describes for both `@workos/migrations` and `@workos/emulate` today) — the entrypoint path is never written to disk. On every subsequent CLI invocation within the 24-hour TTL window, `readResolutionCache` returns the cached version, `existsSync(bundlePath)` evaluates to false, and a full tarball download (up to `TARBALL_TIMEOUT_MS = 30 s`) is attempted again. Since `logWarn` is only visible in debug mode, users see no indication of what is happening. Every `workos migrations`, `workos emulate`, and `workos dev` invocation silently downloads the full npm tarball, fails extraction, then falls back to the compiled-in module until either the companion packages ship their bundles or the 24-hour cache expires.
How can I resolve this? If you propose a fix, please make it concise.| export function pickHighestSatisfying(metadata: AbbreviatedPackument, range: string): ResolvedVersion | null { | ||
| const versions = metadata.versions ?? {}; | ||
| const candidates = Object.keys(versions).filter((version) => { | ||
| const info = versions[version]; | ||
| return ( | ||
| valid(version) !== null && | ||
| !info?.deprecated && | ||
| typeof info?.dist?.tarball === 'string' && | ||
| typeof info?.dist?.integrity === 'string' | ||
| ); | ||
| }); | ||
| const version = maxSatisfying(candidates, range); | ||
| if (!version) return null; | ||
| const dist = versions[version]?.dist as { tarball: string; integrity: string }; | ||
| return { version, tarballUrl: dist.tarball, integrity: dist.integrity }; | ||
| } |
There was a problem hiding this comment.
pickHighestSatisfying docstring overstates sha512 pre-filtering
The JSDoc says it returns null for "versions missing a tarball URL or sha512 integrity" but the actual filter only requires typeof info?.dist?.integrity === 'string'. A version carrying only a sha1- or sha256- integrity string passes the candidate filter, gets written to resolution.json, triggers a tarball download on each invocation, then throws inside verifySriIntegrity (No sha512 hash…). The security contract is still upheld (verification always runs), but the candidate filter is looser than advertised, and such a version being cached in resolution.json feeds directly into the repeated-download issue described above. The filter should also check for the sha512- prefix, or the docstring should be corrected to accurately describe what is checked.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/runtime-assets.ts
Line: 84-99
Comment:
**`pickHighestSatisfying` docstring overstates sha512 pre-filtering**
The JSDoc says it returns null for "versions missing a tarball URL or sha512 integrity" but the actual filter only requires `typeof info?.dist?.integrity === 'string'`. A version carrying only a `sha1-` or `sha256-` integrity string passes the candidate filter, gets written to `resolution.json`, triggers a tarball download on each invocation, then throws inside `verifySriIntegrity` (`No sha512 hash…`). The security contract is still upheld (verification always runs), but the candidate filter is looser than advertised, and such a version being cached in `resolution.json` feeds directly into the repeated-download issue described above. The filter should also check for the `sha512-` prefix, or the docstring should be corrected to accurately describe what is checked.
How can I resolve this? If you propose a fix, please make it concise.
Context
The CLI ships as a Bun-compiled standalone binary (#195), with
@workos/migrationsand@workos/emulatecompiled 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-migrationsandemulatemake each package publish a self-contained single-file ESM bundle inside its normal npm tarball (exports subpath./bundle, tarball pathpackage/dist/bundle.js). This PR teaches the CLI to fetch and run those bundles at runtime — so amigrationsfix reaches users on their next invocation instead of waiting for a CLI release.How it works
scripts/gen-runtime-deps-manifest.ts, wired intobun run generate) bakes each dep's semver range frompackage.jsonat build time — ranges can't drift from what we compile against — plus the list of tarball files to extract (migrations ships aworker.jssidecar resolved via__dirname, so files are a list, entrypoint first).registry.npmjs.org(abbreviated metadata, 3s timeout) for the newest non-deprecated version satisfying the range; the answer is disk-cached for 24h.~/.workos/cache/<name>/<version>/— entrypoint written last, so its presence marks a complete install. Nothing that fails verification is ever imported.await import()ed and shape-checked (programfor migrations,createEmulatorfor 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 hatches —
WORKOS_RUNTIME_DEPS=0forces compiled-in with zero network. From source (dev/tests), the mechanism is off unlessWORKOS_RUNTIME_DEPS=1, keeping development and CI hermetic; it activates only in compiled binaries, mirroring the Agent SDK download.