diff --git a/docs/plans/state.json b/docs/plans/state.json index 9cc9b68..57de584 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -123,6 +123,18 @@ "test_status": "pnpm test:run src/cli/zai-auth.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing" }, + "tui-login-reauth-confirmation-all-providers": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Updated minimal TUI `/login` flows to prompt `Re-authenticate and replace it? (y/N)` when credentials already exist, instead of requiring manual auth.json deletion. Applied to OpenAI (OAuth + API key), Anthropic (API key + auth token), and Z.AI.", + "files_modified": [ + "src/frontends/tui/minimal.ts", + "src/frontends/tui/minimal.login.test.ts" + ], + "test_status": "pnpm test:run src/frontends/tui/minimal.login.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing" + }, + "deployment-port-env-override": { "status": "completed", "date": "2026-02-16", diff --git a/src/frontends/tui/minimal.login.test.ts b/src/frontends/tui/minimal.login.test.ts new file mode 100644 index 0000000..066f3ed --- /dev/null +++ b/src/frontends/tui/minimal.login.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockLoadStoredAnthropicAuth, + mockLoadStoredAnthropicAuthToken, + mockStoreAnthropicAuth, +} = vi.hoisted(() => ({ + mockLoadStoredAnthropicAuth: vi.fn(), + mockLoadStoredAnthropicAuthToken: vi.fn(), + mockStoreAnthropicAuth: vi.fn(), +})); + +const { mockCreateInterface } = vi.hoisted(() => ({ + mockCreateInterface: vi.fn(), +})); + +vi.mock('../../auth/index.js', () => ({ + loadStoredAnthropicAuth: mockLoadStoredAnthropicAuth, + loadStoredAnthropicAuthToken: mockLoadStoredAnthropicAuthToken, + loadStoredOpenAIApiKey: vi.fn(), + loadStoredOpenAIAuth: vi.fn(), + loadStoredZaiAuth: vi.fn(), + loginGitHub: vi.fn(), + loginOpenAI: vi.fn(), + storeAnthropicAuth: mockStoreAnthropicAuth, + storeAnthropicAuthToken: vi.fn(), + storeOpenAIApiKey: vi.fn(), + storeZaiAuth: vi.fn(), +})); + +vi.mock('node:readline', () => ({ + createInterface: mockCreateInterface, + emitKeypressEvents: vi.fn(), +})); + +describe('MinimalTui login re-auth confirmation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadStoredAnthropicAuth.mockReset(); + mockLoadStoredAnthropicAuthToken.mockReset(); + mockStoreAnthropicAuth.mockReset(); + mockCreateInterface.mockReset(); + }); + + it('cancels anthropic API-key re-auth when user answers no', async () => { + mockLoadStoredAnthropicAuth.mockReturnValue({ api_key: 'existing-key', created_at: '2026-02-16T00:00:00.000Z' }); + mockLoadStoredAnthropicAuthToken.mockReturnValue(null); + + const { MinimalTui } = await import('./minimal.js'); + + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: mockSession as any, + modelClient: {} as any, + modelRouter: {} as any, + systemPrompt: 'test', + }); + + (tui as any).rl = { pause: vi.fn(), resume: vi.fn() }; + const promptMock = vi.spyOn(tui as any, 'prompt') + .mockResolvedValueOnce('') // default -> API key path + .mockResolvedValueOnce('n'); // confirmation + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await (tui as any).handleLoginCommand('anthropic'); + + expect(promptMock).toHaveBeenCalled(); + expect(mockStoreAnthropicAuth).not.toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('Cancelled.')); + + consoleLog.mockRestore(); + }); + + it('overwrites anthropic API key when user answers yes', async () => { + mockLoadStoredAnthropicAuth.mockReturnValue({ api_key: 'existing-key', created_at: '2026-02-16T00:00:00.000Z' }); + mockLoadStoredAnthropicAuthToken.mockReturnValue(null); + mockCreateInterface.mockImplementation(() => ({ + question: (_q: string, cb: (ans: string) => void) => cb('new-anthropic-key'), + close: () => undefined, + })); + + const { MinimalTui } = await import('./minimal.js'); + + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: mockSession as any, + modelClient: {} as any, + modelRouter: {} as any, + systemPrompt: 'test', + }); + + const pause = vi.fn(); + const resume = vi.fn(); + (tui as any).rl = { pause, resume }; + vi.spyOn(tui as any, 'prompt') + .mockResolvedValueOnce('') // default -> API key path + .mockResolvedValueOnce('y'); // confirmation + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await (tui as any).handleLoginCommand('anthropic'); + + expect(mockStoreAnthropicAuth).toHaveBeenCalledWith('new-anthropic-key'); + expect(pause).toHaveBeenCalled(); + expect(resume).toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + + consoleLog.mockRestore(); + consoleError.mockRestore(); + }); +}); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index ed3bd08..e791472 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -437,6 +437,12 @@ export class MinimalTui { private async handleLoginCommand(provider?: string): Promise { const target = provider ?? 'github'; + const confirmReplace = async (): Promise => { + const answer = (await this.prompt( + `${colors.orange}Re-authenticate and replace it?${colors.reset} ${colors.gray}(y/N)${colors.reset} `, + )).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + }; const promptHidden = async (question: string): Promise => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); @@ -495,8 +501,10 @@ export class MinimalTui { const existing = loadStoredOpenAIApiKey(); if (existing) { console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`); - console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.api_key entry to re-authenticate.${colors.reset}\n`); - return; + if (!await confirmReplace()) { + console.log(`${colors.gray}Cancelled.${colors.reset}\n`); + return; + } } console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`); @@ -523,8 +531,10 @@ export class MinimalTui { const existing = loadStoredOpenAIAuth(); if (existing) { console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`); - console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.oauth entry (or legacy openai entry) to re-authenticate.${colors.reset}\n`); - return; + if (!await confirmReplace()) { + console.log(`${colors.gray}Cancelled.${colors.reset}\n`); + return; + } } console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); @@ -560,8 +570,10 @@ export class MinimalTui { if (choice === '2') { if (hasToken) { console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`); - console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.auth_token entry to re-authenticate.${colors.reset}\n`); - return; + if (!await confirmReplace()) { + console.log(`${colors.gray}Cancelled.${colors.reset}\n`); + return; + } } console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`); @@ -586,8 +598,10 @@ export class MinimalTui { // 1) API key (default) if (hasApiKey) { console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`); - console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.api_key entry to re-authenticate.${colors.reset}\n`); - return; + if (!await confirmReplace()) { + console.log(`${colors.gray}Cancelled.${colors.reset}\n`); + return; + } } console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`); @@ -614,8 +628,10 @@ export class MinimalTui { const existing = loadStoredZaiAuth(); if (existing) { console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`); - console.log(`${colors.gray}Delete ~/.config/flynn/auth.json zai/zhipuai entry to re-authenticate.${colors.reset}\n`); - return; + if (!await confirmReplace()) { + console.log(`${colors.gray}Cancelled.${colors.reset}\n`); + return; + } } console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`);