Skip to content
Closed
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);
Comment on lines +51 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New spec drives the real CLI as a subprocess — slow and environment-sensitive

This spec is picked up by the default vitest run include glob (src/**/*.spec.ts in vitest.config.ts), so every bun run test spawns three real bun src/bin.ts install ... subprocesses (up to 30s each). The assertions depend on the CLI reaching the auth-required exit (4) after credential resolution fails against an unroutable WORKOS_API_URL; any change that makes credential resolution fail differently (e.g. a network error path that classifies as a general error) will flip the exit code to 1 and break these tests for reasons unrelated to flag parsing. Consider isolating it behind a separate integration test script/project, or asserting only that stderr lacks Unknown argument and contains the deprecation text rather than pinning the exit code.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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,
},
Comment on lines 213 to 221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Older automation scripts using the pull-request flag still crash

The backward-compatibility shim only accepts the two commit-related flags (commit option at src/bin.ts:213-221) while the previously documented pull-request flag was removed outright, so any existing script that still passes it is rejected outright instead of being ignored.
Impact: Automation that ran the installer with the old pull-request option now fails immediately with an "Unknown argument" usage error rather than continuing.

Strict yargs parsing rejects the removed --create-pr option

The base commit removed both --no-commit/--commit and --create-pr from installerOptions. This PR restores only commit as a deprecated no-op. Because the parser is configured with .strict() (src/bin.ts:2795), an unrecognized --create-pr triggers the .fail() path (src/bin.ts:257-273), which emits invalid_usage and exits with code 1. --create-pr was documented in README.md (removed in this PR) and in the machine-readable registry src/utils/help-json.ts (entry deleted here), so scripts and agents that discovered it earlier will break. If backward compatibility is the goal, create-pr should get the same deprecated no-op treatment as commit.

Prompt for agents
The PR reintroduces `--commit`/`--no-commit` as deprecated no-op flags so older scripts keep working under yargs `.strict()`, but the sibling flag `--create-pr` (removed in the same earlier change, and deleted from README.md and src/utils/help-json.ts in this PR) is not shimmed. Passing `--create-pr` now hits the `.fail()` handler in src/bin.ts and exits 1 with an "Unknown argument" usage error. Consider adding a `create-pr` boolean option to `installerOptions` in src/bin.ts with no default and a deprecated description, and extend the deprecation warning helper (`warnIfDeprecatedCommitFlag`) to cover it, so all removed post-install flags behave consistently as accepted no-ops.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

'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.'),
);
}
Comment on lines +249 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Deprecation notice is emitted even in JSON/agent runs

renderStderrNotice (src/utils/box.ts:9-13) unconditionally writes to stderr, unlike other startup notices (telemetry notice) which self-guard in JSON mode. The install/dashboard NDJSON stream on stdout stays clean, but agents that parse stderr expecting only {"error":{...}} objects (the documented contract in CLAUDE.md's Non-TTY Behavior section) will now see a free-form styled warning line. Worth confirming this is acceptable for the machine contract.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// 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