Add re-auth y/N confirmation to minimal TUI login flows
This commit is contained in:
@@ -123,6 +123,18 @@
|
|||||||
"test_status": "pnpm test:run src/cli/zai-auth.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing"
|
"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": {
|
"deployment-port-env-override": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-16",
|
"date": "2026-02-16",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -437,6 +437,12 @@ export class MinimalTui {
|
|||||||
|
|
||||||
private async handleLoginCommand(provider?: string): Promise<void> {
|
private async handleLoginCommand(provider?: string): Promise<void> {
|
||||||
const target = provider ?? 'github';
|
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 promptHidden = async (question: string): Promise<string> => {
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||||
@@ -495,8 +501,10 @@ export class MinimalTui {
|
|||||||
const existing = loadStoredOpenAIApiKey();
|
const existing = loadStoredOpenAIApiKey();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`);
|
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`);
|
if (!await confirmReplace()) {
|
||||||
return;
|
console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`);
|
console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`);
|
||||||
@@ -523,8 +531,10 @@ export class MinimalTui {
|
|||||||
const existing = loadStoredOpenAIAuth();
|
const existing = loadStoredOpenAIAuth();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`);
|
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`);
|
if (!await confirmReplace()) {
|
||||||
return;
|
console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
|
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
|
||||||
@@ -560,8 +570,10 @@ export class MinimalTui {
|
|||||||
if (choice === '2') {
|
if (choice === '2') {
|
||||||
if (hasToken) {
|
if (hasToken) {
|
||||||
console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
|
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`);
|
if (!await confirmReplace()) {
|
||||||
return;
|
console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`);
|
console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`);
|
||||||
@@ -586,8 +598,10 @@ export class MinimalTui {
|
|||||||
// 1) API key (default)
|
// 1) API key (default)
|
||||||
if (hasApiKey) {
|
if (hasApiKey) {
|
||||||
console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`);
|
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`);
|
if (!await confirmReplace()) {
|
||||||
return;
|
console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`);
|
console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`);
|
||||||
@@ -614,8 +628,10 @@ export class MinimalTui {
|
|||||||
const existing = loadStoredZaiAuth();
|
const existing = loadStoredZaiAuth();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`);
|
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`);
|
if (!await confirmReplace()) {
|
||||||
return;
|
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}`);
|
console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user