Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
72510c1
feat: Add Homebrew channel and install-aware updates
gjtorikian Jul 21, 2026
e0a561f
fix: Classify Windows npm paths in install detection
gjtorikian Jul 22, 2026
ae5c7d7
ci: Push the Homebrew formula via the SDK Automation Bot
gjtorikian Jul 22, 2026
89a81fc
Merge branch 'main' into homebrew-distribution
gjtorikian Jul 26, 2026
237a414
ci: Restore id-token permission and inherit secrets in the release call
gjtorikian Jul 26, 2026
a04935c
ci: Split the Bun CI workflow into lint and test workflows
gjtorikian Jul 26, 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
29 changes: 29 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Lint

on:
push:
branches: [main]
pull_request:

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0

- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # 2.0.2
with:
bun-version: 1.3.14

- name: Install
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint

- name: Format
run: bun run format:check

- name: Typecheck
run: bun run typecheck
8 changes: 8 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ on:
branches:
- main

# id-token: write is not used by this workflow directly, but the caller's
# token permissions cap what the called release.yml may request — its
# publish-npm job needs id-token: write for npm trusted publishing (OIDC).
# Without it here, the whole run fails at startup with a workflow-file error.
permissions:
contents: write
pull-requests: write
id-token: write

jobs:
release-please:
Expand All @@ -27,3 +32,6 @@ jobs:
uses: ./.github/workflows/release.yml
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
# Called workflows receive no secrets by default; publish-homebrew needs
# secrets.SDK_BOT_PRIVATE_KEY to push the formula to workos/homebrew-tap.
secrets: inherit
76 changes: 76 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,79 @@ jobs:
fi
(cd "$dir" && npm publish --access public --provenance --tag ${{ steps.npm-tag.outputs.tag }})
done

# Homebrew is a tertiary channel: regenerate the tap formula from the same
# release binaries and push it to workos/homebrew-tap so
# `brew install workos/tap/workos` and `brew upgrade workos` track each
# release. Stable versions only — brew users don't track prereleases. Runs
# after the GitHub release publishes so the download URLs it pins already
# resolve. Re-run just this job if it fails; the push is a no-op when the
# formula is already current.
#
# GITHUB_TOKEN cannot push to another repo, so the cross-repo write uses the
# SDK Automation Bot (a GitHub App installed on workos/homebrew-tap) — the
# same credentials the SDK repos publish with. Requires vars.SDK_BOT_APP_ID
# and secrets.SDK_BOT_PRIVATE_KEY to be available to this repo.
publish-homebrew:
name: Publish Homebrew formula
needs: [smoke, publish]
if: ${{ !contains(inputs.tag_name, '-') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0
with:
ref: ${{ inputs.tag_name }}

- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # 2.0.2
with:
bun-version: 1.3.14

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1
with:
path: dist
merge-multiple: true

# Computes the sha256 for each platform binary from the downloaded
# artifacts, so the tap can never drift from the published bits.
- name: Generate the Homebrew formula
env:
TAG_NAME: ${{ inputs.tag_name }}
run: WORKOS_HOMEBREW_VERSION="${TAG_NAME#v}" bun run ./scripts/gen-homebrew-formula.ts

# Mints an installation token scoped to the tap repo; owner/repositories
# target homebrew-tap rather than this repo, and contents:write is all a
# formula push needs.
- name: Generate a tap-scoped token
id: tap-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # 3.2.0
with:
app-id: ${{ vars.SDK_BOT_APP_ID }}
private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }}
owner: workos
repositories: homebrew-tap
permission-contents: write

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0
with:
repository: workos/homebrew-tap
token: ${{ steps.tap-token.outputs.token }}
path: tap

- name: Commit and push the formula
working-directory: tap
env:
TAG_NAME: ${{ inputs.tag_name }}
run: |
cp ../dist/homebrew/workos.rb workos.rb
# --porcelain reports untracked (first release) and modified alike.
if [ -z "$(git status --porcelain -- workos.rb)" ]; then
echo "workos.rb already up to date for $TAG_NAME"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add workos.rb
git commit -m "workos ${TAG_NAME#v}"
git push
17 changes: 5 additions & 12 deletions .github/workflows/ci.yml → .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
name: CI
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
ci:
name: Bun
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0
Expand All @@ -19,18 +19,11 @@ jobs:
- name: Install
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint

- name: Format
run: bun run format:check

- name: Typecheck
run: bun run typecheck

- name: Test
run: bun run test

# `bun run test` regenerates the manifests via its pre-hook, so a stale
# committed manifest shows up as a diff here.
- name: Check committed integration manifest is fresh
run: git diff --exit-code src/integrations/_manifest.ts

Expand Down
114 changes: 114 additions & 0 deletions scripts/gen-homebrew-formula.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Generates the Homebrew formula (dist/homebrew/workos.rb) from the already-built
// platform binaries in dist/ (release asset names). The release workflow pushes
// the result to the workos/homebrew-tap repo, so `brew install workos/tap/workos`
// tracks each GitHub release.
//
// Homebrew downloads the raw release binary (no tarball) and installs it as
// `workos`; the sha256 for each platform is computed here from the same
// artifacts the GitHub release ships, so the tap can never drift from the bits.
//
// WORKOS_HOMEBREW_VERSION version to stamp; defaults to the repo package.json
// version. Prerelease versions (containing `-`) are
// rejected — brew users track stable only.
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const projectRoot = join(import.meta.dirname, '..');
const distDir = join(projectRoot, 'dist');
const outDir = join(distDir, 'homebrew');

const rootPackageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as {
version: string;
license?: string;
};
const version = process.env.WORKOS_HOMEBREW_VERSION ?? rootPackageJson.version;
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Refusing to generate a Homebrew formula for non-stable version: ${version}`);
}

// Hardcoded rather than reused from package.json: Homebrew's audit wants a desc
// with no leading article and without the formula name, unlike the npm-facing
// package description.
const DESCRIPTION = 'Install AuthKit integrations and manage WorkOS resources';
const LICENSE = rootPackageJson.license ?? 'MIT';
const HOMEPAGE = 'https://github.com/workos/cli';
const DOWNLOAD_BASE = `https://github.com/workos/cli/releases/download/v${version}`;

// Homebrew macOS + Linuxbrew (glibc) only — no musl or Windows. `on_arm`/
// `on_intel` blocks map straight onto these four release assets.
const PLATFORMS: Array<{ os: 'macos' | 'linux'; cpu: 'arm' | 'intel'; asset: string }> = [
{ os: 'macos', cpu: 'arm', asset: 'workos-darwin-arm64' },
{ os: 'macos', cpu: 'intel', asset: 'workos-darwin-x64' },
{ os: 'linux', cpu: 'arm', asset: 'workos-linux-arm64' },
{ os: 'linux', cpu: 'intel', asset: 'workos-linux-x64' },
];

const missing = PLATFORMS.filter((p) => !existsSync(join(distDir, p.asset)));
if (missing.length > 0) {
throw new Error(`Missing platform binaries in dist/: ${missing.map((p) => p.asset).join(', ')}`);
}

async function sha256(asset: string): Promise<string> {
return createHash('sha256')
.update(await readFile(join(distDir, asset)))
.digest('hex');
}

const shas = new Map<string, string>();
for (const platform of PLATFORMS) {
shas.set(platform.asset, await sha256(platform.asset));
}

// One `on_arm`/`on_intel` stanza; each pins the URL + sha and installs the raw
// binary (named after the URL's basename) as `workos`.
function stanza(cpu: 'arm' | 'intel', asset: string): string {
return [
` on_${cpu} do`,
` url "${DOWNLOAD_BASE}/${asset}"`,
` sha256 "${shas.get(asset)}"`,
'',
' def install',
` bin.install "${asset}" => "workos"`,
' end',
' end',
].join('\n');
}

const macos = PLATFORMS.filter((p) => p.os === 'macos');
const linux = PLATFORMS.filter((p) => p.os === 'linux');

const formula = `# typed: false
# frozen_string_literal: true

# Generated by workos/cli release automation (scripts/gen-homebrew-formula.ts).
# DO NOT EDIT — changes are overwritten on the next release.
class Workos < Formula
desc "${DESCRIPTION}"
homepage "${HOMEPAGE}"
version "${version}"
license "${LICENSE}"

on_macos do
${macos.map((p) => stanza(p.cpu, p.asset)).join('\n')}
end

on_linux do
${linux.map((p) => stanza(p.cpu, p.asset)).join('\n')}
end

test do
assert_match version.to_s, shell_output("#{bin}/workos --version")
end
end
`;

rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
await writeFile(join(outDir, 'workos.rb'), formula);

console.log(`Generated Homebrew formula (v${version}) at ${join(outDir, 'workos.rb')}:`);
for (const platform of PLATFORMS) {
console.log(` ${platform.os}/${platform.cpu} ${platform.asset} ${shas.get(platform.asset)}`);
}
44 changes: 44 additions & 0 deletions src/lib/install-method.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { detectInstallMethod, upgradeNotice } from './install-method.js';

describe('detectInstallMethod', () => {
it.each([
['/opt/homebrew/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Apple Silicon
['/usr/local/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Intel mac
['/home/linuxbrew/.linuxbrew/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Linuxbrew
['/opt/homebrew/bin/workos', 'homebrew'], // unresolved symlink on PATH
] as const)('detects homebrew from %s', (execPath, expected) => {
expect(detectInstallMethod(execPath)).toBe(expected);
});

it.each([
['/usr/local/lib/node_modules/@workos/cli-darwin-arm64/bin/workos', 'npm'], // global prefix
['/Users/dev/.nvm/versions/node/v20.11.0/lib/node_modules/@workos/cli-linux-x64/bin/workos', 'npm'], // nvm
['C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@workos\\cli-win32-x64\\bin\\workos.exe', 'npm'], // Windows
] as const)('detects npm from %s', (execPath, expected) => {
expect(detectInstallMethod(execPath)).toBe(expected);
});

it.each([
['/usr/local/bin/workos', 'download'], // manual copy onto PATH
['/Users/dev/Downloads/workos-darwin-arm64', 'download'], // ran from Downloads
['/Users/dev/.local/bin/workos', 'download'],
['C:\\Users\\me\\bin\\workos.exe', 'download'], // Windows manual copy
] as const)('falls back to download from %s', (execPath, expected) => {
expect(detectInstallMethod(execPath)).toBe(expected);
});
});

describe('upgradeNotice', () => {
it('suggests brew for homebrew installs', () => {
expect(upgradeNotice('homebrew')).toBe('Upgrade: brew upgrade workos');
});

it('suggests npm for npm installs', () => {
expect(upgradeNotice('npm')).toBe('Upgrade: npm install -g workos@latest');
});

it('links to GitHub Releases for downloaded binaries', () => {
expect(upgradeNotice('download')).toBe('Download: https://github.com/workos/cli/releases/latest');
});
});
50 changes: 50 additions & 0 deletions src/lib/install-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Detects how this CLI binary was installed so the "update available" notice
// can suggest the matching upgrade command instead of a one-size-fits-all
// GitHub link. The only runtime signal is process.execPath — for a Bun
// standalone binary that is the path to the compiled executable itself (not a
// runtime + script), which is exactly the file a package manager laid down.
export type InstallMethod = 'homebrew' | 'npm' | 'download';

const RELEASES_URL = 'https://github.com/workos/cli/releases/latest';

/**
* Infer the install channel from the running binary's path.
*
* - `homebrew`: Homebrew stages every formula under a versioned Cellar dir and
* symlinks it onto PATH. execPath resolves to the real Cellar path on macOS
* (`/opt/homebrew`, `/usr/local`) and Linuxbrew (`/home/linuxbrew/.linuxbrew`);
* the prefix checks also catch an unresolved `/opt/homebrew/bin` symlink.
* - `npm`: the npm launcher spawns the platform package's binary, which lives
* under `node_modules/@workos/cli-<platform>/bin/workos`.
* - `download`: a binary pulled straight from GitHub Releases onto PATH.
*
* @param execPath overridable for testing; defaults to the running binary.
*/
export function detectInstallMethod(execPath: string = process.execPath): InstallMethod {
// Normalize Windows separators first: the npm launcher's binary lives at
// …\npm\node_modules\@workos\cli-win32-x64\bin\workos.exe, which the
// POSIX-style markers below would otherwise miss.
const path = execPath.replaceAll('\\', '/');
if (path.includes('/Cellar/') || path.includes('/opt/homebrew/') || path.includes('/.linuxbrew/')) {
return 'homebrew';
}
if (path.includes('/node_modules/')) {
return 'npm';
}
return 'download';
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* The upgrade line shown under an "Update available" notice, matched to how the
* running binary was installed.
*/
export function upgradeNotice(method: InstallMethod = detectInstallMethod()): string {
switch (method) {
case 'homebrew':
return 'Upgrade: brew upgrade workos';
case 'npm':
return 'Upgrade: npm install -g workos@latest';
case 'download':
return `Download: ${RELEASES_URL}`;
}
}
8 changes: 8 additions & 0 deletions src/lib/version-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ vi.mock('./settings.js', () => ({
getVersion: vi.fn(() => '0.3.0'),
}));

// Pin the upgrade line so the assertion below doesn't depend on the ambient
// execPath of whatever runs the tests (which may itself live under Homebrew's
// Cellar or a node_modules dir). detectInstallMethod is exercised directly in
// install-method.spec.ts.
vi.mock('./install-method.js', () => ({
upgradeNotice: vi.fn(() => 'Download: https://github.com/workos/cli/releases/latest'),
}));

const { checkForUpdates, _resetWarningState } = await import('./version-check.js');
const { yellow, dim } = await import('../utils/logging.js');

Expand Down
Loading
Loading