Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2a3ed1
feat!: Ship the CLI as a Bun standalone binary
gjtorikian Jul 16, 2026
01e6225
feat: Add Linux musl and Windows arm64 binary targets
gjtorikian Jul 16, 2026
b6253e7
ci(release): Skip already-published npm packages on re-run
gjtorikian Jul 20, 2026
0a0ede4
fix(build): Detect musl for host-default build targets
gjtorikian Jul 20, 2026
1a0abae
fix: Abort stalled Agent SDK downloads and retry once
gjtorikian Jul 20, 2026
689d15f
fix: Re-verify the Agent SDK sha256 in verify-assets
gjtorikian Jul 20, 2026
134766b
test: Cover the skills extraction rename-race recovery
gjtorikian Jul 20, 2026
37070d3
docs: Explain the react-devtools-core devDependency
gjtorikian Jul 20, 2026
ea053d1
Merge branch 'main' into to-bun
gjtorikian Jul 20, 2026
74b2baa
test: Smoke test the npm distribution against a local registry
gjtorikian Jul 20, 2026
ac363bd
test: Smoke the command contract on every release binary
gjtorikian Jul 20, 2026
d59f91f
test: Add authenticated command smoke against staging
gjtorikian Jul 20, 2026
f9ccfd0
test: Surface API errors in the authenticated smoke and allow a stagi…
gjtorikian Jul 20, 2026
3166781
fix: Treat malformed stored credentials as logged out
gjtorikian Jul 20, 2026
054ba88
fix: Report declined installs honestly to machine consumers
gjtorikian Jul 20, 2026
6618f4c
test: Bump the Next.js fixture above the installer minimum
gjtorikian Jul 20, 2026
7e21e24
fix: Validate stored blobs in hasCredentials
gjtorikian Jul 21, 2026
740251b
fix: Harden the Agent SDK first-run download
gjtorikian Jul 21, 2026
b259deb
refactor: Extract the npm tarball reader into a shared helper
gjtorikian Jul 22, 2026
dad7552
feat: Download @workos/migrations and @workos/emulate bundles at runtime
gjtorikian Jul 22, 2026
34a4215
Merge remote-tracking branch 'origin/main' into runtime-artifact-down…
gjtorikian Aug 19, 2026
3d06650
fix: Skip retried downloads after an install failure and require sha5…
gjtorikian Aug 19, 2026
c64fa67
refactor: Drop unused test hook and simplify entry-last install ordering
gjtorikian Aug 19, 2026
4a76386
style: Collapse the agent-sdk-assets spec import to one line
gjtorikian Aug 19, 2026
6d0299c
fix: Never run a runtime bundle older than the compiled-in module; qu…
gjtorikian Sep 3, 2026
b6f1bac
fix: Back off briefly after a transient tarball failure instead of qu…
gjtorikian Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ obj/
src/version.ts
src/generated/skills-manifest.ts
src/generated/agent-sdk-manifest.ts
src/generated/runtime-deps-manifest.ts

.eslintcache
.vscode/launch.json
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
## Tech Constraints

- **Bun** only; the shipped CLI is a Bun-compiled standalone binary
- Runtime assets must be statically imported or materialized from the compiled binary. Exception: the Agent SDK `claude` executable is downloaded on first agent use — pinned by version + sha256 in the generated manifest — and cached under `~/.workos/cache/agent-sdk/`
- Runtime assets must be statically imported or materialized from the compiled binary. Two exceptions: (1) the Agent SDK `claude` executable is downloaded on first agent use — pinned by version + sha256 in the generated manifest — and cached under `~/.workos/cache/agent-sdk/`; (2) runtime dep bundles (`@workos/migrations`, `@workos/emulate`) are resolved from the npm registry against semver ranges baked in the generated manifest (only versions strictly newer than the compiled-in one, also baked, are ever run; the cache is shared across CLI installs), integrity-verified, cached under `~/.workos/cache/<dep>/`, and always fall back to the compiled-in module (`WORKOS_RUNTIME_DEPS=0` kill switch) — see `src/lib/runtime-assets.ts`

## Commit Conventions

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"clean": "bun -e \"await import('node:fs/promises').then((fs) => fs.rm('./dist', { recursive: true, force: true }))\"",
"generate": "bun run ./scripts/gen-integration-manifest.ts && bun run ./scripts/gen-skills-manifest.ts && bun run ./scripts/gen-agent-sdk-manifest.ts",
"generate": "bun run ./scripts/gen-integration-manifest.ts && bun run ./scripts/gen-skills-manifest.ts && bun run ./scripts/gen-agent-sdk-manifest.ts && bun run ./scripts/gen-runtime-deps-manifest.ts",
"postinstall": "bun run generate",
"prebuild": "bun run clean && bun run generate",
"build": "bun run ./scripts/build.ts",
Expand Down
96 changes: 96 additions & 0 deletions scripts/gen-runtime-deps-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { satisfies, valid } from 'semver';

// Dependencies the compiled CLI can update independently of its own releases.
// Each package publishes a self-contained ESM bundle at dist/bundle.js
// (exports subpath `./bundle`), plus any sidecar files the bundle resolves
// relative to itself at runtime (migrations' worker.js is loaded via
// `path.join(__dirname, 'worker.js')`, so it must be co-located). The CLI
// downloads the newest registry version inside the range baked here and
// imports it in place of the compiled-in module (see src/lib/runtime-assets.ts).
// Ranges come straight from package.json so a runtime download can never
// drift past what the compiled-in types were written against, and the version
// installed in node_modules at build time — the one Bun compiles into the
// binary — is baked alongside so the CLI only ever runs versions strictly
// newer than what it already ships. The download cache is shared by every CLI
// install on a machine; without that floor an older cached bundle could
// silently downgrade a newer CLI.
//
// `files` are tarball paths inside `package/`; the FIRST one is the ESM
// entrypoint the CLI imports.
const RUNTIME_DEP_PACKAGES: Record<string, { npmPackage: string; files: string[] }> = {
migrations: { npmPackage: '@workos/migrations', files: ['dist/bundle.js', 'dist/worker.js'] },
emulate: { npmPackage: '@workos/emulate', files: ['dist/bundle.js'] },
};

const projectRoot = join(import.meta.dirname, '..');
const manifestPath = join(projectRoot, 'src', 'generated', 'runtime-deps-manifest.ts');

const packageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
};

const entries: string[] = [];
for (const [name, { npmPackage, files }] of Object.entries(RUNTIME_DEP_PACKAGES)) {
const range = packageJson.dependencies?.[npmPackage];
if (typeof range !== 'string') {
throw new Error(`${npmPackage} must be a package.json dependency to be a runtime dep`);
}
// Direct dependencies are hoisted to the top-level node_modules, and the
// package's own exports map may not expose ./package.json to import.meta.resolve.
const installedPackageJsonPath = join(projectRoot, 'node_modules', npmPackage, 'package.json');
const installed = JSON.parse(await readFile(installedPackageJsonPath, 'utf8')) as { version?: unknown };
const bundledVersion = installed.version;
if (typeof bundledVersion !== 'string' || valid(bundledVersion) === null) {
throw new Error(`Could not determine the installed ${npmPackage} version from ${installedPackageJsonPath}`);
}
if (!satisfies(bundledVersion, range)) {
throw new Error(
`Installed ${npmPackage}@${bundledVersion} does not satisfy the package.json range ${range}; run bun install`,
);
}
entries.push(
` ${JSON.stringify(name)}: {`,
` npmPackage: ${JSON.stringify(npmPackage)},`,
` range: ${JSON.stringify(range)},`,
` bundledVersion: ${JSON.stringify(bundledVersion)},`,
` files: [${files.map((file) => JSON.stringify(file)).join(', ')}],`,
` },`,
);
}

const manifest = `// Generated by scripts/gen-runtime-deps-manifest.ts. Do not edit manually.
// Runtime-downloadable dependency bundles: resolved against these baked semver
// ranges from the npm registry on use (only versions newer than the one
// compiled into this binary), integrity-verified, cached under
// ~/.workos/cache/<name>/, and imported in place of the compiled-in module.
// See src/lib/runtime-assets.ts.
export type RuntimeDepManifestEntry = {
/** npm package that publishes the bundle. */
npmPackage: string;
/** Semver range baked at CLI build time (mirrors package.json). */
range: string;
/**
* Version compiled into this binary (the one installed at build time). Only
* in-range versions strictly newer than this are ever downloaded or run, so
* a download cache shared with an older CLI can never downgrade this one.
*/
bundledVersion: string;
/**
* Files to extract from the tarball, as paths inside \`package/\`. The FIRST
* is the ESM entrypoint to import; the rest are sidecars the bundle resolves
* relative to itself and are installed alongside it.
*/
files: readonly string[];
};

export const RUNTIME_DEPS = {
${entries.join('\n')}
} as const satisfies Record<string, RuntimeDepManifestEntry>;

export type RuntimeDepName = keyof typeof RUNTIME_DEPS;
`;

await mkdir(dirname(manifestPath), { recursive: true });
await Bun.write(manifestPath, manifest);
5 changes: 5 additions & 0 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
// Development
{ name: 'WORKOS_DEV', effect: 'Enables dev mode — loads .env.local at startup' },
{ name: 'WORKOS_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' },
{
name: 'WORKOS_RUNTIME_DEPS',
effect:
'Set to "0" to disable runtime-downloaded dependency bundles (compiled-in modules only); "1" forces them on when running from source',
},
];

export async function runDebugEnv(): Promise<void> {
Expand Down
6 changes: 5 additions & 1 deletion src/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createEmulator, type EmulatorSeedConfig } from '@workos/emulate';
// Type-only: the compiled-in package stays the compile-time contract even when
// a runtime-downloaded bundle provides the implementation (emulate-loader.ts).
import type { EmulatorSeedConfig } from '@workos/emulate';
import { resolveDevCommand } from '../lib/dev-command.js';
import { resolveCreateEmulator } from '../lib/emulate-loader.js';
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
Expand Down Expand Up @@ -89,6 +92,7 @@ export async function runDev(argv: DevArgs): Promise<void> {
const seedConfig = userSeed ?? DEFAULT_DEV_SEED;

// 1. Start emulator
const createEmulator = await resolveCreateEmulator();
const emulator = await createEmulator({
port: argv.port,
seed: seedConfig,
Expand Down
6 changes: 5 additions & 1 deletion src/commands/emulate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createEmulator, type Emulator, type EmulatorSeedConfig } from '@workos/emulate';
// Type-only: the compiled-in package stays the compile-time contract even when
// a runtime-downloaded bundle provides the implementation (emulate-loader.ts).
import type { Emulator, EmulatorSeedConfig } from '@workos/emulate';
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { parse as parseYaml } from 'yaml';
import chalk from 'chalk';
import { resolveCreateEmulator } from '../lib/emulate-loader.js';
import { IS_WINDOWS } from '../utils/platform.js';
import { exitWithError } from '../utils/output.js';

Expand Down Expand Up @@ -53,6 +56,7 @@ function printBanner(emulator: Pick<Emulator, 'url' | 'apiKey'>): void {
export async function runEmulate(argv: EmulateArgs): Promise<void> {
const seedConfig = argv.seed ? loadSeedFile(argv.seed) : autoDetectSeedFile();

const createEmulator = await resolveCreateEmulator();
const emulator = await createEmulator({
port: argv.port,
seed: seedConfig ?? undefined,
Expand Down
27 changes: 27 additions & 0 deletions src/commands/migrations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

const mockParseAsync = vi.fn();
const mockName = vi.fn();
const mockLoadRuntimeBundle = vi.fn();

vi.mock('@workos/migrations/dist/cli/index.js', () => ({
program: { parseAsync: mockParseAsync, name: mockName },
}));

vi.mock('../lib/runtime-assets.js', () => ({
loadRuntimeBundle: mockLoadRuntimeBundle,
}));

const { getMigrationsPassthroughArgs, runMigrations } = await import('./migrations.js');

describe('runMigrations', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadRuntimeBundle.mockResolvedValue(null);
delete process.env.WORKOS_SECRET_KEY;
delete process.env.WORKOS_API_URL;
});
Expand Down Expand Up @@ -74,6 +80,27 @@ describe('runMigrations', () => {
});
});

it('uses the runtime bundle program when it exposes the commander surface', async () => {
const bundleParseAsync = vi.fn();
const bundleName = vi.fn();
mockLoadRuntimeBundle.mockResolvedValue({ program: { parseAsync: bundleParseAsync, name: bundleName } });

await runMigrations(['wizard']);

expect(mockLoadRuntimeBundle).toHaveBeenCalledWith('migrations');
expect(bundleName).toHaveBeenCalledTimes(1);
expect(bundleParseAsync).toHaveBeenCalledWith(['wizard'], { from: 'user' });
expect(mockParseAsync).not.toHaveBeenCalled();
});

it('falls back to the compiled-in program when the bundle lacks the expected export', async () => {
mockLoadRuntimeBundle.mockResolvedValue({ program: { notCommander: true } });

await runMigrations(['wizard']);

expect(mockParseAsync).toHaveBeenCalledWith(['wizard'], { from: 'user' });
});

it('sets WORKOS_API_URL when apiBaseUrl is provided', async () => {
await runMigrations(['import', '--csv', 'users.csv'], 'sk_test_123', 'https://api.staging.workos.com');
expect(process.env.WORKOS_API_URL).toBe('https://api.staging.workos.com');
Expand Down
29 changes: 23 additions & 6 deletions src/commands/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { loadRuntimeBundle } from '../lib/runtime-assets.js';
import { getWorkOSCommand } from '../utils/command-invocation.js';

/** The commander surface the CLI drives; the compiled-in package is the compile-time contract. */
type MigrationsProgram = {
name(str: string): unknown;
parseAsync(argv: string[], options?: { from: 'user' }): Promise<unknown>;
};

/**
* Prefer the runtime-downloaded @workos/migrations bundle (exports `program`;
* see lib/runtime-assets.ts), falling back to the compiled-in package when no
* bundle is available or it lacks the expected export shape.
*/
async function resolveMigrationsProgram(): Promise<MigrationsProgram> {
const bundle = await loadRuntimeBundle('migrations');
const candidate = bundle?.program as Partial<MigrationsProgram> | undefined;
if (typeof candidate?.name === 'function' && typeof candidate?.parseAsync === 'function') {
return candidate as MigrationsProgram;
}
const compiledIn = (await import('@workos/migrations/dist/cli/index.js')) as { program: MigrationsProgram };
return compiledIn.program;
}

const workosOnlyMigrationsFlags = new Map([
['--api-key', true],
['--insecure-storage', false],
Expand Down Expand Up @@ -53,12 +75,7 @@ export async function runMigrations(args: string[], apiKey?: string, apiBaseUrl?
process.env.WORKOS_API_URL = apiBaseUrl;
}

const { program } = (await import('@workos/migrations/dist/cli/index.js')) as {
program: {
name(str: string): unknown;
parseAsync(argv: string[], options?: { from: 'user' }): Promise<unknown>;
};
};
const program = await resolveMigrationsProgram();

program.name(`${getWorkOSCommand()} migrations`);
await program.parseAsync(args, { from: 'user' });
Expand Down
26 changes: 26 additions & 0 deletions src/lib/__test-helpers__/npm-tarball-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { gzipSync } from 'node:zlib';

/** Build a minimal ustar header for a regular-file entry. */
function tarHeader(name: string, size: number): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 'utf8');
header.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'utf8');
header.write('0', 156, 'utf8'); // typeflag: regular file
header.write('ustar\0', 257, 'utf8');
header.write('00', 263, 'utf8');
header.fill(' ', 148, 156); // checksum field counts as spaces while summing
let sum = 0;
for (const byte of header) sum += byte;
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'utf8');
return header;
}

/** Build a gzipped npm-style tarball from `[entryName, content]` pairs. */
export function makeTarball(entries: Array<[string, Buffer]>): Buffer {
const parts: Buffer[] = [];
for (const [name, content] of entries) {
parts.push(tarHeader(name, content.length), content, Buffer.alloc((512 - (content.length % 512)) % 512));
}
parts.push(Buffer.alloc(1024)); // end-of-archive
return gzipSync(Buffer.concat(parts));
}
41 changes: 3 additions & 38 deletions src/lib/agent-sdk-assets.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,9 @@
import { existsSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AGENT_SDK_TARGET, AGENT_SDK_VERSION } from '../generated/agent-sdk-manifest.js';
import { makeTarball } from './__test-helpers__/npm-tarball-fixtures.js';
import { downloadTarball, ensureClaudeCodeExecutable, extractTarEntry, isBunVirtualFsUrl } from './agent-sdk-assets.js';

function tarHeader(name: string, size: number): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 'utf8');
header.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'utf8');
header.write('0', 156, 'utf8'); // typeflag: regular file
header.write('ustar\0', 257, 'utf8');
header.write('00', 263, 'utf8');
header.fill(' ', 148, 156); // checksum field counts as spaces while summing
let sum = 0;
for (const byte of header) sum += byte;
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'utf8');
return header;
}

function makeTarball(entries: Array<[string, Buffer]>): Buffer {
const parts: Buffer[] = [];
for (const [name, content] of entries) {
parts.push(tarHeader(name, content.length), content, Buffer.alloc((512 - (content.length % 512)) % 512));
}
parts.push(Buffer.alloc(1024)); // end-of-archive
return gzipSync(Buffer.concat(parts));
}

describe('isBunVirtualFsUrl', () => {
it('detects the POSIX compiled-binary virtual filesystem', () => {
expect(isBunVirtualFsUrl('file:///$bunfs/root/workos')).toBe(true);
Expand Down Expand Up @@ -57,6 +34,8 @@ describe('ensureClaudeCodeExecutable', () => {
});

describe('extractTarEntry', () => {
// Extraction itself is covered by npm-tarball.spec.ts; this pins the
// size-capped wrapper the Agent SDK download path uses.
it('extracts the named entry from a gzipped tarball', () => {
const content = Buffer.from('#!/bin/sh\necho claude\n');
const tarball = makeTarball([
Expand All @@ -65,20 +44,6 @@ describe('extractTarEntry', () => {
]);
expect(extractTarEntry(tarball, 'package/claude').equals(content)).toBe(true);
});

it('handles entries whose size is an exact block multiple', () => {
const content = Buffer.alloc(1024, 7);
const tarball = makeTarball([
['package/claude', content],
['package/LICENSE.md', Buffer.from('license')],
]);
expect(extractTarEntry(tarball, 'package/LICENSE.md').toString()).toBe('license');
});

it('throws when the entry is missing', () => {
const tarball = makeTarball([['package/package.json', Buffer.from('{}')]]);
expect(() => extractTarEntry(tarball, 'package/claude')).toThrow(/not found/);
});
});

describe('downloadTarball', () => {
Expand Down
Loading