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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var.
- **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. The installer never commits or opens PRs — changes are left uncommitted for review.

## Tech Constraints

Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,6 @@ 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
--no-git-check Skip git dirty working tree check
--force-install Force install packages even if peer dependency checks fail
--debug Enable verbose logging
Expand Down Expand Up @@ -574,7 +572,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
85 changes: 85 additions & 0 deletions src/bin-deprecated-flags.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Integration test for the deprecated --commit/--no-commit backward-compat shim.
*
* The installer never commits changes, but scripts written against older
* versions still pass --no-commit (or --commit). These flags must be accepted
* as no-ops: strict parsing must not reject them, and a deprecation warning
* must go to stderr (never stdout, so JSON streams stay clean).
*
* bin.ts runs runCli() at import and exposes no seams, so the only honest way
* to prove the shim is to drive the real CLI as a subprocess. With no
* credentials and an unroutable API base, a successful parse falls through to
* the auth-required exit (4); a strict-parser rejection exits 1 with
* "Unknown argument" instead.
*/
const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url));
const forceInsecureStorageImport = fileURLToPath(new URL('./test/force-insecure-storage.ts', import.meta.url));
const repoRoot = fileURLToPath(new URL('..', import.meta.url));

let sandboxTmp: string;

beforeEach(() => {
sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-deprecated-flags-it-'));
});

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

function runCli(args: string[]) {
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: sandboxTmp,
USERPROFILE: sandboxTmp,
TMPDIR: sandboxTmp,
TMP: sandboxTmp,
TEMP: sandboxTmp,
WORKOS_MODE: 'agent',
// Keep machine streams clean: no telemetry, no update check network calls.
WORKOS_TELEMETRY: 'false',
// Unroutable API base so provisioning fails fast and falls back to auth.
WORKOS_API_URL: 'http://127.0.0.1:59999',
};

return spawnSync('bun', ['--preload', forceInsecureStorageImport, binPath, ...args], {
cwd: repoRoot,
encoding: 'utf-8',
env,
});
}

describe('deprecated install flags (backward-compat shims)', () => {
it('--no-commit is accepted as a no-op and warns on stderr', () => {
const result = runCli(['install', '--no-commit']);

// Past strict parsing: auth-required (4), not a validation error (1).
expect(result.status).toBe(4);
expect(result.stderr).not.toContain('Unknown argument');
expect(result.stderr).toContain('Deprecated flag: --no-commit');
// JSON/machine stdout stays clean.
expect(result.stdout).not.toContain('Deprecated flag');
}, 30_000);

it('--commit is accepted as a no-op and warns on stderr', () => {
const result = runCli(['install', '--commit']);

expect(result.status).toBe(4);
expect(result.stderr).not.toContain('Unknown argument');
expect(result.stderr).toContain('Deprecated flag: --commit');
expect(result.stdout).not.toContain('Deprecated flag');
}, 30_000);

it('omitting the flag emits no deprecation warning', () => {
const result = runCli(['install']);

expect(result.status).toBe(4);
expect(result.stderr).not.toContain('Deprecated flag');
}, 30_000);
});
31 changes: 23 additions & 8 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import {
outputError,
exitWithError,
} from './utils/output.js';
import ui, { PromptUnavailableError } from './utils/ui.js';
import ui, { PromptUnavailableError, pill } from './utils/ui.js';
import { renderStderrNotice } from './utils/box.js';
import chalk from 'chalk';
import { registerSubcommand } from './utils/register-subcommand.js';
import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js';
import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js';
Expand Down Expand Up @@ -211,13 +213,10 @@ const installerOptions = {
type: 'boolean' as const,
},
commit: {
default: true,
describe: 'Auto-commit after installation (use --no-commit to skip)',
type: 'boolean' as const,
},
'create-pr': {
default: false,
describe: 'Auto-create pull request after installation',
// Deprecated no-op kept for backward compatibility: the installer never
// commits, but scripts that still pass --commit/--no-commit must not fail
// strict parsing. No default so usage is detectable (undefined = absent).
describe: 'Deprecated: no-op flag, the installer never commits changes',
type: 'boolean' as const,
},
'git-check': {
Expand All @@ -242,6 +241,20 @@ const installerOptions = {
},
};

/**
* Warn (stderr, so JSON stdout stays clean) when a script passes the removed
* --commit/--no-commit flags. They are accepted as no-ops for backward
* compatibility only.
*/
function warnIfDeprecatedCommitFlag(argv: { commit?: boolean }): void {
if (argv.commit === undefined) return;
const flag = argv.commit ? '--commit' : '--no-commit';
renderStderrNotice(
`${pill('WARN', 'warn')} ${chalk.bold(`Deprecated flag: ${flag}`)} ${chalk.dim('— accepted as a no-op.')}`,
chalk.dim('The installer never commits changes; review and commit manually when ready.'),
);
}

// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
if (!isJsonMode() && isPromptAllowed()) await checkForUpdates();

Expand Down Expand Up @@ -2542,6 +2555,7 @@ async function runCli(): Promise<void> {
(yargs) => yargs.options(installerOptions),
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
warnIfDeprecatedCommitFlag(argv);
await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated);
const { handleInstall } = await import('./commands/install.js');
await handleInstall(argv);
Expand Down Expand Up @@ -2754,6 +2768,7 @@ async function runCli(): Promise<void> {
(yargs) => yargs.options(installerOptions),
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
warnIfDeprecatedCommitFlag(argv);
await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated);
const { handleInstall } = await import('./commands/install.js');
await handleInstall({ ...argv, dashboard: true });
Expand Down
88 changes: 1 addition & 87 deletions src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,6 @@ export class CLIAdapter implements InstallerAdapter {

// Post-install events
this.subscribe('postinstall:changes', this.handlePostInstallChanges);
this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt);
this.subscribe('postinstall:commit:generating', this.handleCommitGenerating);
this.subscribe('postinstall:commit:success', this.handleCommitSuccess);
this.subscribe('postinstall:commit:failed', this.handleCommitFailed);
this.subscribe('postinstall:pr:prompt', this.handlePrPrompt);
this.subscribe('postinstall:pr:generating', this.handlePrGenerating);
this.subscribe('postinstall:pr:pushing', this.handlePrPushing);
this.subscribe('postinstall:pr:success', this.handlePrSuccess);
this.subscribe('postinstall:pr:failed', this.handlePrFailed);
this.subscribe('postinstall:push:failed', this.handlePushFailed);
this.subscribe('postinstall:manual', this.handleManualInstructions);
}

async stop(): Promise<void> {
Expand Down Expand Up @@ -666,81 +655,6 @@ export class CLIAdapter implements InstallerAdapter {
// ===== Post-install Event Handlers =====

private handlePostInstallChanges = ({ files }: InstallerEvents['postinstall:changes']): void => {
this.debugLog(`Post-install: ${files.length} changed files detected`);
};

private handleCommitPrompt = async (): Promise<void> => {
const confirmed = await this.withPromptActive(() =>
ui.confirm({
message: 'Commit the changes?',
initialValue: true,
}),
);

this.sendEvent({
type: ui.isCancel(confirmed) || !confirmed ? 'COMMIT_DECLINED' : 'COMMIT_APPROVED',
});
};

private handleCommitGenerating = (): void => {
this.spinner = ui.spinner();
this.spinner.start('Generating commit message...');
};

private handleCommitSuccess = ({ message }: InstallerEvents['postinstall:commit:success']): void => {
this.stopSpinner('Committed');
ui.log.success(`Committed: ${chalk.dim(message)}`);
};

private handleCommitFailed = ({ error }: InstallerEvents['postinstall:commit:failed']): void => {
this.stopSpinner('Commit failed');
ui.log.error(`Commit failed: ${error}`);
};

private handlePrPrompt = async (): Promise<void> => {
const confirmed = await this.withPromptActive(() =>
ui.confirm({
message: 'Create a pull request?',
initialValue: true,
}),
);

this.sendEvent({
type: ui.isCancel(confirmed) || !confirmed ? 'PR_DECLINED' : 'PR_APPROVED',
});
};

private handlePrGenerating = (): void => {
this.spinner = ui.spinner();
this.spinner.start('Generating PR description...');
};

private handlePrPushing = (): void => {
if (this.spinner) {
this.spinner.message('Pushing to remote...');
} else {
this.spinner = ui.spinner();
this.spinner.start('Pushing to remote...');
}
};

private handlePrSuccess = ({ url }: InstallerEvents['postinstall:pr:success']): void => {
this.stopSpinner('PR created');
ui.log.success(`Pull request created: ${chalk.cyan(url)}`);
};

private handlePrFailed = ({ error }: InstallerEvents['postinstall:pr:failed']): void => {
this.stopSpinner('PR creation failed');
ui.log.error(`PR creation failed: ${error}`);
};

private handlePushFailed = ({ error }: InstallerEvents['postinstall:push:failed']): void => {
this.stopSpinner('Push failed');
ui.log.error(`Push failed: ${error}`);
};

private handleManualInstructions = ({ instructions }: InstallerEvents['postinstall:manual']): void => {
ui.log.info('GitHub CLI not found. Manual steps:');
console.log(chalk.dim(instructions));
this.debugLog(`Post-install: ${files.length} changed files detected (left uncommitted)`);
};
}
4 changes: 0 additions & 4 deletions src/lib/adapters/dashboard-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,6 @@ export class DashboardAdapter implements InstallerAdapter {
} else if (id === 'branch-check') {
// For dashboard, confirmed=true means create branch, false means continue on current
this.sendEvent({ type: confirmed ? 'BRANCH_CREATE' : 'BRANCH_CONTINUE' });
} else if (id === 'commit') {
this.sendEvent({ type: confirmed ? 'COMMIT_APPROVED' : 'COMMIT_DECLINED' });
} else if (id === 'pr') {
this.sendEvent({ type: confirmed ? 'PR_APPROVED' : 'PR_DECLINED' });
}
};

Expand Down
54 changes: 0 additions & 54 deletions src/lib/adapters/headless-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,60 +254,6 @@ describe('HeadlessAdapter', () => {
});
});

describe('commit auto-resolution', () => {
it('auto-commits by default', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('postinstall:commit:prompt', {});

expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'commit:auto' });
expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_APPROVED' });
await adapter.stop();
});

it('skips commit with --no-commit flag', async () => {
const adapter = createAdapter({ noCommit: true });
await adapter.start();

emitter.emit('postinstall:commit:prompt', {});

expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'commit:skipped',
reason: '--no-commit flag',
});
expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_DECLINED' });
await adapter.stop();
});
});

describe('PR auto-resolution', () => {
it('skips PR by default', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('postinstall:pr:prompt', {});

expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'pr:skipped',
reason: '--create-pr not set',
});
expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_DECLINED' });
await adapter.stop();
});

it('creates PR with --create-pr flag', async () => {
const adapter = createAdapter({ createPr: true });
await adapter.start();

emitter.emit('postinstall:pr:prompt', {});

expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'pr:creating' });
expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_APPROVED' });
await adapter.stop();
});
});

describe('scaffold events', () => {
it('streams scaffold:* and flags the completion as scaffolded', async () => {
const adapter = createAdapter();
Expand Down
Loading