Add re-auth y/N confirmation to minimal TUI login flows

This commit is contained in:
William Valentin
2026-02-15 20:12:31 -08:00
parent 99ad53a1ee
commit 42c526bce9
3 changed files with 165 additions and 10 deletions
+12
View File
@@ -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",
+127
View File
@@ -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();
});
});
+26 -10
View File
@@ -437,6 +437,12 @@ export class MinimalTui {
private async handleLoginCommand(provider?: string): Promise<void> {
const target = provider ?? 'github';
const confirmReplace = async (): Promise<boolean> => {
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<string> => {
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}`);