Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
- **Auth**: Exits code 4 instead of opening browser. Resource commands (organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config) use the dashboard session from a prior `workos auth login`; expired access tokens refresh silently while the stored refresh token is valid, so only a truly dead session exits 4. `WORKOS_API_KEY` applies only to `workos api` and the still-REST commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`, plus the workflow/debug commands `seed`, `setup-org`, `onboard-user`, `verify-login`, `debug-sso`, `debug-sync`, `migrations`).
- **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }`
- **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention)
- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag.
- **Headless flags**: `--no-branch`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag.
- **Installer Git policy**: Generated changes stay unstaged/uncommitted; pre-existing staging is preserved. No installer-controlled staging, commits, pushes, PR creation, or commit/PR text generation. `--commit`, `--no-commit`, and `--create-pr` are deprecated compatibility-only no-ops, with human-only notices. Branch prompts/`--no-branch` remain unchanged; branches do not isolate uncommitted work. Change reporting is scoped to `installDir` and may include pre-existing changes.

## JSON Output Conventions

Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,14 +546,26 @@ workos install [options]
--pm <manager> Package manager for the scaffolded app: npm, pnpm, yarn, bun
--no-validate Skip post-installation validation
--no-branch Skip branch creation (use current branch)
--no-commit Skip auto-commit after installation
--create-pr Auto-create pull request after installation
--commit / --no-commit Deprecated compatibility-only no-ops
--create-pr Deprecated compatibility-only no-op (never publishes)
--no-git-check Skip git dirty working tree check
--force-install Force install packages even if peer dependency checks fail
--no-tui Use plain line-by-line output instead of the full-screen installer
--debug Enable verbose logging
```

The installer leaves generated changes unstaged and uncommitted, preserving any
previously staged work. It never stages, commits, pushes, opens a pull request, or
generates commit/PR text. Review the project and commit independently when ready.
The deprecated Git flags above (including boolean/negated forms) are accepted but
ignored; human runs show a notice, while JSON runs keep machine streams clean.
Branch creation and `--no-branch` are unchanged: uncommitted changes are not
isolated by creating a branch. Reported changed files may include pre-existing
work; inspection is scoped to `--install-dir`. Each of its two Git commands is
limited to 5 seconds and 1 MiB of buffered output. If inspection hits either
limit, it reports unknown changed files (not “no changes”); review the project
manually. Partial output is never presented as a complete file list.

**Full-screen installer:** In an interactive terminal of at least 80×24,
`workos install` opens a full-screen view: a plain-English walkthrough of what
it's doing, a task list beside it that follows the dashboard's AuthKit
Expand Down Expand Up @@ -653,7 +665,7 @@ Mode resolution notes:
In non-TTY, the installer streams progress as NDJSON (one JSON object per line):

```bash
workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/null
workos install --api-key sk_test_xxx --client-id client_xxx 2>/dev/null
# → {"type":"detection:complete","integration":"nextjs","timestamp":"..."}
# → {"type":"agent:start","timestamp":"..."}
# → {"type":"agent:progress","message":"...","timestamp":"..."}
Expand Down
288 changes: 288 additions & 0 deletions src/bin-readonly-installer.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = fileURLToPath(new URL('..', import.meta.url));
const fixture = join(root, 'src/test/readonly-installer.fixture.ts');
let sandbox: string;
let project: string;
let env: NodeJS.ProcessEnv;
const git = (dir: string, ...args: string[]) => execFileSync('git', args, { cwd: dir, env, encoding: 'utf8' });

function repo(dir: string) {
mkdirSync(dir, { recursive: true });
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'package.json'), '{"name":"offline-project","scripts":{"dev":"vite"}}');
writeFileSync(join(dir, 'tracked.ts'), 'original\n');
writeFileSync(join(dir, 'staged.ts'), 'original\n');
writeFileSync(join(dir, '.gitignore'), '.env*\n');
git(dir, 'add', '.');
git(dir, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
writeFileSync(join(dir, 'staged.ts'), 'pre-existing staged work\n');
git(dir, 'add', 'staged.ts');
writeFileSync(join(dir, 'staged.ts'), 'pre-existing unstaged work\n');
writeFileSync(join(dir, '.env.local'), 'WORKOS_API_KEY=sk_test_offline\nWORKOS_CLIENT_ID=client_offline\n');
}

beforeEach(() => {
sandbox = mkdtempSync(join(root, '.auth6733-test-'));
const home = join(sandbox, 'home');
mkdirSync(home);
const gitConfig = join(home, 'gitconfig');
writeFileSync(gitConfig, '');
env = {
PATH: process.env.PATH,
HOME: home,
USERPROFILE: home,
TMPDIR: home,
TMP: home,
TEMP: home,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: gitConfig,
GIT_AUTHOR_NAME: 'Offline Test',
GIT_AUTHOR_EMAIL: 'offline@example.test',
GIT_COMMITTER_NAME: 'Offline Test',
GIT_COMMITTER_EMAIL: 'offline@example.test',
WORKOS_TELEMETRY: 'false',
NO_COLOR: '1',
TERM: 'dumb',
TEST_EVIDENCE: join(sandbox, 'evidence.ndjson'),
};
// Windows subprocess startup needs these OS paths; never inherit credentials.
for (const key of ['SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT']) {
if (process.env[key]) env[key] = process.env[key];
}
writeFileSync(env.TEST_EVIDENCE!, '');
project = join(sandbox, 'project');
repo(project);
});

afterEach(() => rmSync(sandbox, { recursive: true, force: true }));

function run(args: string[], overrides: NodeJS.ProcessEnv = {}, cwd = project, expectedForbidden: string[] = []) {
const head = git(project, 'rev-parse', 'HEAD');
const index = git(project, 'ls-files', '--stage');
const result = spawnSync('bun', [fixture, ...args], {
cwd,
env: { ...env, ...overrides },
encoding: 'utf8',
timeout: 20_000,
});
expect(result.error).toBeUndefined();
const evidence = readFileSync(env.TEST_EVIDENCE!, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((s) => JSON.parse(s));
expect(evidence.filter((e) => e.kind === 'forbidden').map((e) => e.value)).toEqual(expectedForbidden);
expect(git(project, 'rev-parse', 'HEAD')).toBe(head);
// Git status can refresh stat metadata, but never the staged entries.
expect(git(project, 'ls-files', '--stage')).toBe(index);
expect(git(project, 'show', ':staged.ts')).toBe('pre-existing staged work\n');
expect(git(project, 'diff', '--cached', '--name-only')).toBe('staged.ts\n');
return { ...result, evidence, index };
}

const install = [
'install',
'--skip-auth',
'--api-key',
'sk_test_offline',
'--client-id',
'client_offline',
'--no-git-check',
'--no-branch',
];
const events = (stdout: string) =>
stdout
.trim()
.split('\n')
.map((s) => JSON.parse(s));

function expectSuccess(result: ReturnType<typeof run>) {
expect(result.status, result.stderr + result.stdout).toBe(0);
const complete = events(result.stdout).find((e) => e.type === 'complete');
expect(complete.success).toBe(true);
expect(complete.files).toEqual(expect.arrayContaining(['generated.ts', 'tracked.ts', 'staged.ts']));
expect(complete.changeDetection.state).toBe('changed');
expect(complete.applicationSetup.reason).toContain('Offline fixture');
expect(complete.nextSteps.join(' ')).toContain('uncommitted');
expect(events(result.stdout).some((e) => e.type === 'validation:complete')).toBe(true);
expect(events(result.stdout).some((e) => /commit:|pr:|push:/.test(e.type))).toBe(false);
expect(git(project, 'status', '--porcelain')).toContain('?? generated.ts');
expect(git(project, 'diff', '--name-only')).toContain('tracked.ts');
}

describe('installer leaves changes uncommitted through the real parser and orchestrator', () => {
it('records forbidden process calls even when swallowed, without relying on executable shims', () => {
const result = run([], { TEST_ENTRY: 'guard-probe' }, project, [
'execFileSync git add -A',
'execFileSync git commit -m forbidden',
'execFileSync git push',
'execFileSync gh pr create',
'execSync git status --porcelain=v1 && git push',
'exec',
'execFile',
'spawn',
'spawnSync',
'fork',
]);
expect(result.status, result.stderr).toBe(0);
expect(result.evidence.filter((e) => e.kind === 'command').length).toBe(4);
});

it.each(
[
[],
['--commit'],
['--no-commit'],
['--commit=true'],
['--commit=false'],
['--create-pr'],
['--create-pr=true'],
['--create-pr=false'],
['--no-create-pr'],
['--commit', 'false', '--create-pr', 'false'],
['--direct', '--commit', '--create-pr'],
['--commit', '--no-commit', '--create-pr'],
['--no-commit', '--commit', '--no-create-pr'],
].map((flags) => ({ flags })),
)('accepts legacy forms $flags without publication or model calls (JSON)', ({ flags }) => {
const result = run([...install, ...flags, '--json']);
expectSuccess(result);
expect(result.stderr).toBe('');
// Normalization drops obsolete controls entirely, including omitted flags.
expect(result.evidence.find((e) => e.kind === 'agent-options').value).toEqual({ installDir: project });
expect(git(project, 'branch', '--show-current')).toBe('main\n');
});

it('documents compatibility-only flags without active defaults in human and machine help', () => {
const human = run(['install', '--help'], { TEST_HUMAN: '1' });
expect(human.status).toBe(0);
expect(human.stdout).toContain('Deprecated no-op');
expect(human.stdout).not.toContain('Auto-commit');
const machine = run(['install', '--help', '--json']);
expect(machine.status).toBe(0);
const options = JSON.parse(machine.stdout).options;
for (const name of ['commit', 'create-pr']) {
const option = options.find((o: { name: string }) => o.name === name);
expect(option.description).toContain('Deprecated no-op');
expect(option).not.toHaveProperty('default');
}
expect(machine.stderr).toBe('');
});

it('CI completes on a dirty tree and preserves automatic branch creation', () => {
const result = run(
[
'install',
'--api-key',
'sk_test_offline',
'--client-id',
'client_offline',
'--install-dir',
project,
'--create-pr',
],
{ WORKOS_MODE: 'ci' },
);
expectSuccess(result);
expect(result.stderr).toBe('');
expect(git(project, 'branch', '--show-current')).toBe('feat/add-workos-authkit\n');
});

it('runtime/programmatic legacy values cannot revive removed actions', () => {
expectSuccess(run([], { TEST_ENTRY: 'programmatic' }));
});

it.each(['install', 'default'])('human %s path finishes with only pre-install prompts', (entry) => {
const result = run(entry === 'install' ? ['install', '--commit', '--create-pr'] : ['--no-commit', '--create-pr'], {
TEST_HUMAN: '1',
});
expect(result.status, result.stderr + result.stdout).toBe(0);
expect(result.stdout + result.stderr).toContain('Deprecated installer Git flags are ignored');
expect(result.stdout).toContain('The installer leaves changes uncommitted');
expect(result.evidence.filter((e) => e.kind === 'prompt').map((e) => e.value)).toEqual(
entry === 'default'
? ['Run the AuthKit installer?', 'Continue anyway?', 'You are on main. Create a feature branch?']
: ['Continue anyway?', 'You are on main. Create a feature branch?'],
);
expect(git(project, 'branch', '--show-current')).toBe('feat/add-workos-authkit\n');
});

it('does not warn when legacy options are omitted in human mode', () => {
const result = run(install, { TEST_HUMAN: '1' });
expect(result.status, result.stderr + result.stdout).toBe(0);
expect(result.stdout + result.stderr).not.toContain('Deprecated installer Git flags');
// Preserve the existing limitation: human CLI still asks the branch question
// with --no-branch; only the headless adapter consumes that option today.
expect(result.evidence.filter((e) => e.kind === 'prompt').map((e) => e.value)).toContain(
'You are on main. Create a feature branch?',
);
expect(git(project, 'branch', '--show-current')).toBe('feat/add-workos-authkit\n');
});

it('reports installDir instead of the different repository in process cwd', () => {
const other = join(sandbox, 'other');
repo(other);
writeFileSync(join(other, 'wrong-repository.txt'), 'not the target');
const result = run([...install, '--install-dir', project, '--json'], {}, other);
expectSuccess(result);
const output = events(result.stdout);
expect(output.find((e) => e.type === 'postinstall:changes').files).not.toContain('wrong-repository.txt');
expect(output.find((e) => e.type === 'complete').files).not.toContain('wrong-repository.txt');
// Existing pre-install policy is intentionally unchanged in this ticket:
// its dirty-tree check still inspects process cwd, unlike post-install.
expect(output.find((e) => e.type === 'git:status').files).toContain('- wrong-repository.txt');
});

it.each([
{ probe: 'timeout', detail: 'timed out after 5000 ms' },
{ probe: 'overflow', detail: 'exceeded the 1048576-byte output limit' },
])(
'reports unknown changed files after a real Bun $probe, never unchanged or a partial list',
({ probe, detail }) => {
const result = run([...install, '--json'], { TEST_INSPECTION: probe });
expect(result.status, result.stderr + result.stdout).toBe(0);
expect(result.stderr).toBe('');
const output = events(result.stdout);
expect(output.filter((e) => e.type.startsWith('postinstall:'))).toEqual([
expect.objectContaining({
type: 'postinstall:unavailable',
reason: 'error',
error: expect.stringContaining(detail),
}),
]);
const complete = output.find((e) => e.type === 'complete');
// Installation succeeded; inspection did not. Neither outcome hides the other.
expect(complete.success).toBe(true);
expect(complete.files).toEqual([]);
expect(complete.changeDetection).toEqual({ state: 'error', files: [], error: expect.stringContaining(detail) });
expect(complete.changeDetection.error).toContain('changed files are unknown');
expect(git(project, 'status', '--porcelain')).toContain('?? generated.ts');
},
15_000,
);

it('reports fake agent failure without post-install success or publication', () => {
const result = run([...install, '--create-pr', '--json'], { TEST_AGENT: 'fail' });
expect(result.status).toBe(1);
expect(
events(result.stdout)
.filter((e) => e.type === 'complete')
.every((e) => e.success === false),
).toBe(true);
expect(result.stdout).not.toContain('postinstall:');
expect(JSON.parse(result.stderr).error.message).toContain('Fake installation failed');
});

it('cancels before the agent without asking any post-install questions', () => {
git(project, 'checkout', '-qb', 'existing-feature');
const result = run(['install', '--no-branch', '--create-pr'], { TEST_HUMAN: '1', TEST_CANCEL: '1' });
expect(result.stdout).toContain('cancelled');
expect(result.evidence.some((e) => e.kind === 'agent-options')).toBe(false);
expect(result.evidence.filter((e) => e.kind === 'prompt').map((e) => e.value)).toEqual(['Continue anyway?']);
});
});
Loading
Loading