diff --git a/src/__tests__/login.test.ts b/src/__tests__/login.test.ts new file mode 100644 index 0000000..6dd68fc --- /dev/null +++ b/src/__tests__/login.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Command } from 'commander' + +// Mock the auth module +vi.mock('../lib/auth.js', () => ({ + saveApiToken: vi.fn(), +})) + +// Mock the config module +vi.mock('../lib/config.js', () => ({ + getConfigPath: vi.fn(() => '/home/user/.config/twist-cli/config.json'), +})) + +// Mock chalk to avoid colors in tests +vi.mock('chalk', () => ({ + default: { + green: vi.fn((text) => text), + dim: vi.fn((text) => text), + }, +})) + +import { saveApiToken } from '../lib/auth.js' +import { getConfigPath } from '../lib/config.js' +import { registerLoginCommand } from '../commands/login.js' + +const mockSaveApiToken = vi.mocked(saveApiToken) +const mockGetConfigPath = vi.mocked(getConfigPath) + +function createProgram() { + const program = new Command() + program.exitOverride() + registerLoginCommand(program) + return program +} + +describe('login command', () => { + let consoleSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + // Mock console.log to capture output + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleSpy.mockRestore() + }) + + describe('token subcommand', () => { + it('successfully saves a token', async () => { + const program = createProgram() + const token = 'some_token_123456789' + + // Mock successful token save + mockSaveApiToken.mockResolvedValue(undefined) + + await program.parseAsync(['node', 'tw', 'login', 'token', token]) + + // Verify token was saved + expect(mockSaveApiToken).toHaveBeenCalledWith(token) + + // Verify success message + expect(consoleSpy).toHaveBeenCalledWith('✓', 'API token saved successfully!') + expect(consoleSpy).toHaveBeenCalledWith( + 'Token saved to /home/user/.config/twist-cli/config.json', + ) + }) + + it('handles saveApiToken errors', async () => { + const program = createProgram() + const token = 'some_token_123456789' + + // Mock save failure + mockSaveApiToken.mockRejectedValue(new Error('Permission denied')) + + await expect(program.parseAsync(['node', 'tw', 'login', 'token', token])).rejects.toThrow( + 'Permission denied', + ) + + expect(mockSaveApiToken).toHaveBeenCalledWith(token) + }) + + it('trims whitespace from token', async () => { + const program = createProgram() + const tokenWithWhitespace = ' some_token_123456789 ' + const expectedToken = 'some_token_123456789' + + mockSaveApiToken.mockResolvedValue(undefined) + + await program.parseAsync(['node', 'tw', 'login', 'token', tokenWithWhitespace]) + + expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken) + }) + + it('shows help when no arguments provided', async () => { + const program = createProgram() + + // This should show help for the login command + await expect(program.parseAsync(['node', 'tw', 'login'])).rejects.toThrow() // Commander throws when required argument is missing + + expect(mockSaveApiToken).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..db26389 --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,22 @@ +import { Command } from 'commander' +import { saveApiToken } from '../lib/auth.js' +import { getConfigPath } from '../lib/config.js' +import chalk from 'chalk' + +async function loginWithToken(token: string): Promise { + try { + // Save token to config + await saveApiToken(token.trim()) + + console.log(chalk.green('✓'), 'API token saved successfully!') + console.log(chalk.dim(`Token saved to ${getConfigPath()}`)) + } catch (error: any) { + throw error + } +} + +export function registerLoginCommand(program: Command): void { + const login = program.command('login').description('Authenticate with Twist') + + login.command('token ').description('Save API token to config file').action(loginWithToken) +} diff --git a/src/index.ts b/src/index.ts index d8dcdfe..f668bf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { registerThreadCommand } from './commands/thread.js' import { registerMsgCommand } from './commands/msg.js' import { registerSearchCommand } from './commands/search.js' import { registerReactCommand } from './commands/react.js' +import { registerLoginCommand } from './commands/login.js' program .name('tw') @@ -30,5 +31,6 @@ registerThreadCommand(program) registerMsgCommand(program) registerSearchCommand(program) registerReactCommand(program) +registerLoginCommand(program) program.parse() diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 00ecdbe..7e08260 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { getConfig, getConfigPath } from './config.js' +import { getConfig, getConfigPath, updateConfig } from './config.js' export async function getApiToken(): Promise { const envToken = process.env.TWIST_API_TOKEN @@ -15,3 +15,13 @@ export async function getApiToken(): Promise { `No API token found. Set TWIST_API_TOKEN environment variable or add "token" to ${getConfigPath()}`, ) } + +export async function saveApiToken(token: string): Promise { + // Validate token (non-empty, reasonable length) + if (!token || token.trim().length < 10) { + throw new Error('Invalid token: Token must be at least 10 characters') + } + + // Update config with new token using the existing config system + await updateConfig({ token: token.trim() }) +}