diff --git a/CLAUDE.md b/CLAUDE.md index b8c0c405..64d7d1e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 3df20538..60b09578 100644 --- a/README.md +++ b/README.md @@ -480,8 +480,6 @@ workos install [options] --pm 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 @@ -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":"..."} diff --git a/src/bin-deprecated-flags.integration.spec.ts b/src/bin-deprecated-flags.integration.spec.ts new file mode 100644 index 00000000..c52e3053 --- /dev/null +++ b/src/bin-deprecated-flags.integration.spec.ts @@ -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); +}); diff --git a/src/bin.ts b/src/bin.ts index 0e206108..7e0cb1b1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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'; @@ -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': { @@ -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(); @@ -2542,6 +2555,7 @@ async function runCli(): Promise { (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); @@ -2754,6 +2768,7 @@ async function runCli(): Promise { (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 }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index b61df430..1910912d 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -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 { @@ -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 => { - 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 => { - 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)`); }; } diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index 90c832c2..2bb2ce2c 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -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' }); } }; diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index d2596519..5f3b5f8f 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -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(); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 0de00ffd..185ca2e6 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -11,8 +11,6 @@ export interface HeadlessOptions { apiKey?: string; clientId?: string; noBranch?: boolean; - noCommit?: boolean; - createPr?: boolean; noGitCheck?: boolean; /** CI mode (WORKOS_MODE=ci, or --ci when headless): pipelines never stop for a dirty tree. */ ci?: boolean; @@ -98,16 +96,8 @@ export class HeadlessAdapter implements InstallerAdapter { this.subscribe('branch:prompt', this.handleBranchPrompt); this.subscribe('branch:created', this.handleBranchCreated); - // Post-install — auto-resolve + // Post-install this.subscribe('postinstall:changes', this.handlePostInstallChanges); - this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt); - 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:success', this.handlePrSuccess); - this.subscribe('postinstall:pr:failed', this.handlePrFailed); - this.subscribe('postinstall:push:failed', this.handlePushFailed); - this.subscribe('postinstall:manual', this.handleManualInstructions); // Terminal events this.subscribe('complete', this.handleComplete); @@ -339,56 +329,12 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'branch:created', name: branch }); }; - // ===== Post-install (auto-resolve) ===== + // ===== Post-install ===== private handlePostInstallChanges = ({ files }: InstallerEvents['postinstall:changes']): void => { writeNDJSON({ type: 'postinstall:changes', files, count: files.length }); }; - private handleCommitPrompt = (): void => { - if (this.options.noCommit) { - writeNDJSON({ type: 'commit:skipped', reason: '--no-commit flag' }); - this.sendEvent({ type: 'COMMIT_DECLINED' }); - } else { - writeNDJSON({ type: 'commit:auto' }); - this.sendEvent({ type: 'COMMIT_APPROVED' }); - } - }; - - private handleCommitSuccess = ({ message }: InstallerEvents['postinstall:commit:success']): void => { - writeNDJSON({ type: 'commit:created', message }); - }; - - private handleCommitFailed = ({ error }: InstallerEvents['postinstall:commit:failed']): void => { - writeNDJSON({ type: 'commit:failed', error }); - }; - - private handlePrPrompt = (): void => { - if (this.options.createPr) { - writeNDJSON({ type: 'pr:creating' }); - this.sendEvent({ type: 'PR_APPROVED' }); - } else { - writeNDJSON({ type: 'pr:skipped', reason: '--create-pr not set' }); - this.sendEvent({ type: 'PR_DECLINED' }); - } - }; - - private handlePrSuccess = ({ url }: InstallerEvents['postinstall:pr:success']): void => { - writeNDJSON({ type: 'pr:created', url }); - }; - - private handlePrFailed = ({ error }: InstallerEvents['postinstall:pr:failed']): void => { - writeNDJSON({ type: 'pr:failed', error }); - }; - - private handlePushFailed = ({ error }: InstallerEvents['postinstall:push:failed']): void => { - writeNDJSON({ type: 'push:failed', error }); - }; - - private handleManualInstructions = ({ instructions }: InstallerEvents['postinstall:manual']): void => { - writeNDJSON({ type: 'postinstall:manual', instructions }); - }; - // ===== Terminal Events ===== private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index bcbf95c4..485ea34d 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -194,8 +194,8 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal // Block success: an error-severity security finding that survived the // self-correction retries fails the install rather than shipping silently. // Throwing routes through the state machine's error state (success: false, - // non-zero exit) and skips the commit/PR steps, leaving the insecure code - // uncommitted for the user to inspect. + // non-zero exit), leaving the insecure code uncommitted for the user to + // inspect. if (security.blocking.length > 0) { analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { action: 'security gate blocked install', diff --git a/src/lib/ai-content.ts b/src/lib/ai-content.ts deleted file mode 100644 index ec6b6eb2..00000000 --- a/src/lib/ai-content.ts +++ /dev/null @@ -1,153 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { startCredentialProxy } from './credential-proxy.js'; -import { getAuthkitDomain, getCliAuthClientId, getConfig } from './settings.js'; -import { getLlmGatewayUrl } from '../utils/urls.js'; -import { getCredentials } from './credentials.js'; -import { logInfo, logError } from '../utils/debug.js'; - -export interface AiContentOptions { - /** Use direct Anthropic API instead of llm-gateway */ - direct?: boolean; -} - -/** - * Execute an API call through a short-lived credential proxy. - * Handles proxy lifecycle automatically. - */ -async function withProxy(fn: (client: Anthropic) => Promise): Promise { - const gatewayUrl = getLlmGatewayUrl(); - const creds = getCredentials(); - - if (!creds?.refreshToken) { - // No refresh token - use credentials directly (legacy mode) - logInfo('[ai-content] No refresh token, using credentials directly'); - const client = new Anthropic({ - baseURL: gatewayUrl, - apiKey: 'gateway', // SDK requires something, gateway uses Authorization header - defaultHeaders: creds?.accessToken ? { Authorization: `Bearer ${creds.accessToken}` } : undefined, - }); - return fn(client); - } - - // Start short-lived proxy - const proxy = await startCredentialProxy({ - upstreamUrl: gatewayUrl, - refresh: { - authkitDomain: getAuthkitDomain(), - clientId: getCliAuthClientId(), - refreshThresholdMs: getConfig().proxy.refreshThresholdMs, - }, - }); - - logInfo(`[ai-content] Started proxy at ${proxy.url}`); - - try { - const client = new Anthropic({ - baseURL: proxy.url, - apiKey: 'proxy', // SDK requires something, proxy handles real auth - }); - return await fn(client); - } finally { - await proxy.stop(); - logInfo('[ai-content] Stopped proxy'); - } -} - -/** - * Execute an API call directly to Anthropic (--direct mode). - */ -async function withDirect(fn: (client: Anthropic) => Promise): Promise { - // SDK reads ANTHROPIC_API_KEY from env automatically - const client = new Anthropic(); - return fn(client); -} - -/** - * Generate a concise commit message for the AuthKit integration. - * Falls back to a default message if AI generation fails. - */ -export async function generateCommitMessage( - integration: string, - files: string[], - options: AiContentOptions = {}, -): Promise { - const executor = options.direct ? withDirect : withProxy; - - try { - return await executor(async (client) => { - const response = await client.messages.create({ - model: 'claude-sonnet-4-20250514', - max_tokens: 100, - messages: [ - { - role: 'user', - content: `Generate a concise git commit message for adding WorkOS AuthKit to a ${integration} project. Changed files: ${files.slice(0, 10).join(', ')}. Use conventional commit format (feat:). One line only, under 72 chars.`, - }, - ], - }); - - const text = response.content[0]; - if (text.type === 'text') { - return text.text.trim(); - } - throw new Error('Unexpected response format'); - }); - } catch (error) { - logError('[ai-content] Failed to generate commit message:', error); - return `feat: add WorkOS AuthKit integration for ${integration}`; - } -} - -/** - * Generate a PR description for the AuthKit integration. - * Falls back to a default template if AI generation fails. - */ -export async function generatePrDescription( - integration: string, - files: string[], - commitMessage: string, - options: AiContentOptions = {}, -): Promise { - const executor = options.direct ? withDirect : withProxy; - - try { - return await executor(async (client) => { - const response = await client.messages.create({ - model: 'claude-sonnet-4-20250514', - max_tokens: 500, - messages: [ - { - role: 'user', - content: `Generate a GitHub PR description for: "${commitMessage}" - -Framework: ${integration} -Files changed: ${files.join(', ')} - -Include: -- Brief summary (2-3 sentences) -- Key changes bullet list -- Link to WorkOS AuthKit docs: https://workos.com/docs/user-management - -Keep it concise. Markdown format.`, - }, - ], - }); - - const text = response.content[0]; - if (text.type === 'text') { - return text.text.trim(); - } - throw new Error('Unexpected response format'); - }); - } catch (error) { - logError('[ai-content] Failed to generate PR description:', error); - return `## Summary -Added WorkOS AuthKit integration for ${integration}. - -## Changes -${files.map((f) => `- ${f}`).join('\n')} - -## Documentation -https://workos.com/docs/user-management`; - } -} diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts index 1bbe9796..4dc7b549 100644 --- a/src/lib/completion-data.spec.ts +++ b/src/lib/completion-data.spec.ts @@ -46,6 +46,7 @@ describe('buildCompletionData', () => { expect(data.files).toHaveLength(2); expect(data.nextSteps[0]).toContain('pnpm run dev'); expect(data.nextSteps[1]).toContain('http://localhost:3000'); + expect(data.nextSteps.at(-1)).toContain('git status'); expect(data.docsUrl).toBe('https://d'); expect(data.dashboardUrl).toBe('https://dash'); expect(data.integration).toBe('nextjs'); @@ -60,7 +61,7 @@ describe('buildCompletionData', () => { expect(data.url).toBe('http://localhost:8080'); }); - it('handles empty changedFiles (--no-commit shape) without throwing', async () => { + it('handles empty changedFiles without throwing (and adds no review step)', async () => { writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); @@ -68,6 +69,7 @@ describe('buildCompletionData', () => { expect(data.files).toEqual([]); expect(data.nextSteps[0]).toContain('start your dev server'); expect(data.nextSteps[1]).toContain('test authentication'); + expect(data.nextSteps.some((s) => /git status/.test(s))).toBe(false); }); it('drops the generic "start dev server" framework step but keeps others', async () => { diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts index 929ccf73..63eac38e 100644 --- a/src/lib/completion-data.ts +++ b/src/lib/completion-data.ts @@ -54,7 +54,12 @@ export async function buildCompletionData(ctx: CompletionContext, deps: Completi devCommand, url, files, - nextSteps: [...concrete, ...framework], + nextSteps: [ + ...concrete, + ...framework, + // The installer never commits — leave the review/commit step explicit. + ...(files.length > 0 ? ['Review the changes (`git status`) and commit when ready'] : []), + ], docsUrl: deps.docsUrl, dashboardUrl: deps.dashboardUrl, signInSnippet: deps.signInSnippet, diff --git a/src/lib/events.ts b/src/lib/events.ts index bb28d476..38692586 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -111,19 +111,6 @@ export interface InstallerEvents { // Post-install events 'postinstall:changes': { files: string[] }; 'postinstall:nochanges': Record; - 'postinstall:commit:prompt': Record; - 'postinstall:commit:generating': Record; - 'postinstall:commit:committing': { message: string }; - 'postinstall:commit:success': { message: string }; - 'postinstall:commit:failed': { error: string }; - 'postinstall:pr:prompt': Record; - 'postinstall:pr:generating': Record; - 'postinstall:pr:pushing': Record; - 'postinstall:pr:creating': Record; - 'postinstall:pr:success': { url: string }; - 'postinstall:pr:failed': { error: string }; - 'postinstall:push:failed': { error: string }; - 'postinstall:manual': { instructions: string }; } export type InstallerEventName = keyof InstallerEvents; diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 948ac27f..709769ff 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -17,8 +17,6 @@ import type { CompletionData } from './events.js'; import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; import type { StagingCredentials } from './staging-api.js'; import { InstallDeclinedError } from './installer-errors.js'; -import { getManualPrInstructions } from './post-install.js'; -import { hasGhCli } from '../utils/git-utils.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; export const installerMachine = setup({ @@ -258,68 +256,6 @@ export const installerMachine = setup({ emitNoChanges: ({ context }) => { context.emitter.emit('postinstall:nochanges', {}); }, - emitCommitPrompt: ({ context }) => { - context.emitter.emit('postinstall:commit:prompt', {}); - }, - emitGeneratingCommitMessage: ({ context }) => { - context.emitter.emit('postinstall:commit:generating', {}); - }, - assignCommitMessage: assign({ - commitMessage: ({ event }) => { - const doneEvent = event as unknown as { output: string }; - return doneEvent.output; - }, - }), - emitCommitting: ({ context }) => { - context.emitter.emit('postinstall:commit:committing', { message: context.commitMessage ?? '' }); - }, - emitCommitSuccess: ({ context }) => { - context.emitter.emit('postinstall:commit:success', { message: context.commitMessage ?? '' }); - }, - emitCommitFailed: ({ context }) => { - const message = context.error?.message ?? 'Commit failed'; - context.emitter.emit('postinstall:commit:failed', { error: message }); - }, - emitPrPrompt: ({ context }) => { - context.emitter.emit('postinstall:pr:prompt', {}); - }, - emitGeneratingPrDescription: ({ context }) => { - context.emitter.emit('postinstall:pr:generating', {}); - }, - assignPrDescription: assign({ - prDescription: ({ event }) => { - const doneEvent = event as unknown as { output: string }; - return doneEvent.output; - }, - }), - emitPushing: ({ context }) => { - context.emitter.emit('postinstall:pr:pushing', {}); - }, - emitPushFailed: ({ context }) => { - const message = context.error?.message ?? 'Push failed'; - context.emitter.emit('postinstall:push:failed', { error: message }); - }, - emitCreatingPr: ({ context }) => { - context.emitter.emit('postinstall:pr:creating', {}); - }, - assignPrUrl: assign({ - prUrl: ({ event }) => { - const doneEvent = event as unknown as { output: string }; - return doneEvent.output; - }, - }), - emitPrCreated: ({ context }) => { - context.emitter.emit('postinstall:pr:success', { url: context.prUrl ?? '' }); - }, - emitPrFailed: ({ context }) => { - const message = context.error?.message ?? 'PR creation failed'; - context.emitter.emit('postinstall:pr:failed', { error: message }); - }, - emitManualInstructions: ({ context }) => { - const branch = context.currentBranch ?? 'HEAD'; - const instructions = getManualPrInstructions(branch); - context.emitter.emit('postinstall:manual', { instructions }); - }, emitComplete: ({ context }) => { const summary = context.agentSummary ?? 'WorkOS AuthKit installed successfully!'; context.emitter.emit('complete', { success: true, summary, completion: context.completion }); @@ -334,8 +270,6 @@ export const installerMachine = setup({ gitIsClean: ({ context }) => context.gitIsClean === true, hasCredentials: ({ context }) => context.options.apiKey !== undefined && context.options.clientId !== undefined, hasIntegration: ({ context }) => context.integration !== undefined, - shouldSkipPostInstall: ({ context }) => context.options.noCommit === true, - hasGhCli: () => hasGhCli(), // Read from the actor's done event (output), not context: the // assignWorkspaceResult action has not run yet when guards are evaluated. notScaffoldable: ({ event }) => !(event as unknown as { output: WorkspaceCheckOutput }).output?.scaffoldable, @@ -400,24 +334,6 @@ export const installerMachine = setup({ detectChanges: fromPromise<{ hasChanges: boolean; files: string[] }, void>(async () => { throw new Error('detectChanges not implemented - provide via machine.provide()'); }), - generateCommitMessage: fromPromise(async () => { - throw new Error('generateCommitMessage not implemented - provide via machine.provide()'); - }), - commitChanges: fromPromise(async () => { - throw new Error('commitChanges not implemented - provide via machine.provide()'); - }), - generatePrDescription: fromPromise< - string, - { integration: string; files: string[]; commitMessage: string; direct?: boolean } - >(async () => { - throw new Error('generatePrDescription not implemented - provide via machine.provide()'); - }), - pushBranch: fromPromise(async () => { - throw new Error('pushBranch not implemented - provide via machine.provide()'); - }), - createPr: fromPromise(async () => { - throw new Error('createPr not implemented - provide via machine.provide()'); - }), }, }).createMachine({ id: 'installer', @@ -1009,27 +925,20 @@ export const installerMachine = setup({ }, }, + // Post-install: record what changed so the completion summary can list the + // files. Changes are deliberately left uncommitted for the user to review — + // the installer never commits or opens PRs on its own. postInstall: { - initial: 'checking', + initial: 'detectingChanges', entry: [{ type: 'emitStateEnter', params: { state: 'postInstall' } }], states: { - checking: { - always: [ - { - target: '#installer.buildingCompletion', - guard: 'shouldSkipPostInstall', - }, - { target: 'detectingChanges' }, - ], - }, - detectingChanges: { invoke: { id: 'detectChanges', src: 'detectChanges', onDone: [ { - target: 'promptingCommit', + target: 'done', guard: ({ event }) => (event.output as { hasChanges: boolean; files: string[] }).hasChanges, actions: ['assignChangedFiles', 'emitChangesDetected'], }, @@ -1042,131 +951,6 @@ export const installerMachine = setup({ }, }, - promptingCommit: { - entry: ['emitCommitPrompt'], - on: { - COMMIT_APPROVED: { target: 'generatingCommitMessage' }, - COMMIT_DECLINED: { target: 'done' }, - CANCEL: { target: '#installer.cancelled' }, - }, - }, - - generatingCommitMessage: { - entry: ['emitGeneratingCommitMessage'], - invoke: { - id: 'generateCommitMessage', - src: 'generateCommitMessage', - input: ({ context }) => ({ - integration: context.integration ?? 'project', - files: context.changedFiles ?? [], - direct: context.options.direct, - }), - onDone: { - target: 'committing', - actions: ['assignCommitMessage'], - }, - }, - }, - - committing: { - entry: ['emitCommitting'], - invoke: { - id: 'commitChanges', - src: 'commitChanges', - input: ({ context }) => ({ - message: context.commitMessage ?? '', - cwd: context.options.installDir, - }), - onDone: { - target: 'checkingGhCli', - actions: ['emitCommitSuccess'], - }, - onError: { - target: 'done', - actions: ['assignError', 'emitCommitFailed'], - }, - }, - }, - - checkingGhCli: { - always: [ - { - target: 'promptingPr', - guard: 'hasGhCli', - }, - { - target: 'showingManualInstructions', - }, - ], - }, - - promptingPr: { - entry: ['emitPrPrompt'], - on: { - PR_APPROVED: { target: 'generatingPrDescription' }, - PR_DECLINED: { target: 'done' }, - CANCEL: { target: '#installer.cancelled' }, - }, - }, - - generatingPrDescription: { - entry: ['emitGeneratingPrDescription'], - invoke: { - id: 'generatePrDescription', - src: 'generatePrDescription', - input: ({ context }) => ({ - integration: context.integration ?? 'project', - files: context.changedFiles ?? [], - commitMessage: context.commitMessage ?? '', - direct: context.options.direct, - }), - onDone: { - target: 'pushing', - actions: ['assignPrDescription'], - }, - }, - }, - - pushing: { - entry: ['emitPushing'], - invoke: { - id: 'pushBranch', - src: 'pushBranch', - input: ({ context }) => ({ cwd: context.options.installDir }), - onDone: { target: 'creatingPr' }, - onError: { - target: 'showingManualInstructions', - actions: ['assignError', 'emitPushFailed'], - }, - }, - }, - - creatingPr: { - entry: ['emitCreatingPr'], - invoke: { - id: 'createPr', - src: 'createPr', - input: ({ context }) => ({ - title: context.commitMessage ?? '', - body: context.prDescription ?? '', - cwd: context.options.installDir, - }), - onDone: { - target: 'done', - actions: ['assignPrUrl', 'emitPrCreated'], - }, - onError: { - target: 'done', - actions: ['assignError', 'emitPrFailed'], - }, - }, - }, - - showingManualInstructions: { - entry: ['emitManualInstructions'], - always: { target: 'done' }, - }, - done: { type: 'final', }, diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index 46aec300..f2feff8c 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -50,14 +50,8 @@ export interface InstallerMachineContext { currentBranch?: string; /** Whether current branch is protected */ isProtectedBranch?: boolean; - /** Files changed during agent execution (for post-install) */ + /** Files changed during agent execution (listed in the completion summary; left uncommitted) */ changedFiles?: string[]; - /** AI-generated commit message */ - commitMessage?: string; - /** AI-generated PR description */ - prDescription?: string; - /** URL of created PR */ - prUrl?: string; /** Summary message from agent execution */ agentSummary?: string; /** Whether the install directory is empty and can be scaffolded into */ @@ -101,12 +95,7 @@ export type InstallerMachineEvent = // Branch check events | { type: 'BRANCH_CREATE' } | { type: 'BRANCH_CONTINUE' } - | { type: 'BRANCH_CANCEL' } - // Post-install events - | { type: 'COMMIT_APPROVED' } - | { type: 'COMMIT_DECLINED' } - | { type: 'PR_APPROVED' } - | { type: 'PR_DECLINED' }; + | { type: 'BRANCH_CANCEL' }; /** * Output from the detection actor. diff --git a/src/lib/post-install.ts b/src/lib/post-install.ts index d2cc9784..20abc552 100644 --- a/src/lib/post-install.ts +++ b/src/lib/post-install.ts @@ -1,54 +1,6 @@ -import { execFileSync } from 'node:child_process'; -import { writeFileSync, unlinkSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { getDefaultBranch, getUncommittedFiles } from '../utils/git-utils.js'; +import { getUncommittedFiles } from '../utils/git-utils.js'; export function detectChanges(): { hasChanges: boolean; files: string[] } { const files = getUncommittedFiles(); return { hasChanges: files.length > 0, files }; } - -export function stageAndCommit(message: string, cwd: string): void { - execFileSync('git', ['add', '-A'], { cwd, stdio: 'ignore' }); - execFileSync('git', ['commit', '-m', message], { cwd, stdio: 'ignore' }); -} - -export function pushBranch(cwd: string): void { - execFileSync('git', ['push', '-u', 'origin', 'HEAD'], { cwd, stdio: 'pipe' }); -} - -export function createPullRequest(title: string, body: string, cwd: string): string { - const baseBranch = getDefaultBranch(); - const tmpFile = join(tmpdir(), `pr-body-${Date.now()}.md`); - writeFileSync(tmpFile, body, 'utf-8'); - - try { - return execFileSync('gh', ['pr', 'create', '--title', title, '--body-file', tmpFile, '--base', baseBranch], { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - }) - .toString() - .trim(); - } finally { - try { - unlinkSync(tmpFile); - } catch {} - } -} - -export function getManualPrInstructions(branch: string): string { - const baseBranch = getDefaultBranch(); - return ` -To create a PR manually: - -1. Push your branch: - git push -u origin ${branch} - -2. Create PR via GitHub: - https://github.com///compare/${baseBranch}...${branch} - -Or install the GitHub CLI: - https://cli.github.com/ -`.trim(); -} diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 1d8e2f3c..ff86c876 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -43,11 +43,7 @@ import { createBranch as createGitBranch, branchExists, } from '../utils/git-utils.js'; -import { detectChanges, stageAndCommit, pushBranch as pushGitBranch, createPullRequest } from './post-install.js'; -import { - generateCommitMessage as generateCommitMessageAi, - generatePrDescription as generatePrDescriptionAi, -} from './ai-content.js'; +import { detectChanges } from './post-install.js'; import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; @@ -234,8 +230,6 @@ export async function runWithCore(options: InstallerOptions): Promise { apiKey: augmentedOptions.apiKey, clientId: augmentedOptions.clientId, noBranch: augmentedOptions.noBranch, - noCommit: augmentedOptions.noCommit, - createPr: augmentedOptions.createPr, noGitCheck: augmentedOptions.noGitCheck, ci: augmentedOptions.ci, }, @@ -518,31 +512,6 @@ export async function runWithCore(options: InstallerOptions): Promise { detectChanges: fromPromise<{ hasChanges: boolean; files: string[] }, void>(async () => { return detectChanges(); }), - - generateCommitMessage: fromPromise( - async ({ input }) => { - return generateCommitMessageAi(input.integration, input.files, { direct: input.direct }); - }, - ), - - commitChanges: fromPromise(async ({ input }) => { - stageAndCommit(input.message, input.cwd); - }), - - generatePrDescription: fromPromise< - string, - { integration: string; files: string[]; commitMessage: string; direct?: boolean } - >(async ({ input }) => { - return generatePrDescriptionAi(input.integration, input.files, input.commitMessage, { direct: input.direct }); - }), - - pushBranch: fromPromise(async ({ input }) => { - pushGitBranch(input.cwd); - }), - - createPr: fromPromise(async ({ input }) => { - return createPullRequest(input.title, input.body, input.cwd); - }), }, }); diff --git a/src/run.ts b/src/run.ts index 91d0195f..cb1d7454 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,11 +24,8 @@ export type InstallerArgs = { inspect?: boolean; noValidate?: boolean; validate?: boolean; - noCommit?: boolean; - commit?: boolean; noBranch?: boolean; branch?: boolean; - createPr?: boolean; noGitCheck?: boolean; gitCheck?: boolean; direct?: boolean; @@ -71,9 +68,7 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { dashboard: merged.dashboard ?? false, inspect: merged.inspect ?? false, noValidate: merged.noValidate ?? merged.validate === false, - noCommit: merged.noCommit ?? merged.commit === false, noBranch: merged.noBranch ?? merged.branch === false, - createPr: merged.createPr ?? false, noGitCheck: merged.noGitCheck ?? merged.gitCheck === false, direct: merged.direct ?? false, scaffold: merged.scaffold ?? false, diff --git a/src/utils/git-utils.ts b/src/utils/git-utils.ts index 79e1b36c..a9fea980 100644 --- a/src/utils/git-utils.ts +++ b/src/utils/git-utils.ts @@ -38,36 +38,6 @@ export function branchExists(name: string): boolean { } } -/** - * Get the default branch from origin, falling back to main/master detection. - */ -export function getDefaultBranch(): string { - try { - const ref = execSync('git symbolic-ref refs/remotes/origin/HEAD', { - stdio: ['ignore', 'pipe', 'ignore'], - }) - .toString() - .trim(); - return ref.replace('refs/remotes/origin/', ''); - } catch { - if (branchExists('main')) return 'main'; - if (branchExists('master')) return 'master'; - return 'main'; - } -} - -/** - * Check if the GitHub CLI (gh) is available. - */ -export function hasGhCli(): boolean { - try { - execSync('gh --version', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - /** * Get list of uncommitted/untracked files from git status. */ diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index ae30d1b3..0b973478 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1444,17 +1444,8 @@ const commands: CommandSchema[] = [ { name: 'commit', type: 'boolean', - description: 'Auto-commit after installation (use --no-commit to skip)', + description: 'Deprecated: no-op flag, the installer never commits changes', required: false, - default: true, - hidden: false, - }, - { - name: 'create-pr', - type: 'boolean', - description: 'Auto-create pull request after installation', - required: false, - default: false, hidden: false, }, { diff --git a/src/utils/types.ts b/src/utils/types.ts index 6ed7f00c..1114ac7e 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -86,21 +86,11 @@ export type InstallerOptions = { */ noValidate?: boolean; - /** - * Skip post-install commit and PR workflow - */ - noCommit?: boolean; - /** * Skip branch creation (continue on current branch) */ noBranch?: boolean; - /** - * Auto-create pull request after installation - */ - createPr?: boolean; - /** * Skip git dirty working tree check */