Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/__tests__/login.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>

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()
})
})
})
22 changes: 22 additions & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <token>').description('Save API token to config file').action(loginWithToken)
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -30,5 +31,6 @@ registerThreadCommand(program)
registerMsgCommand(program)
registerSearchCommand(program)
registerReactCommand(program)
registerLoginCommand(program)

program.parse()
12 changes: 11 additions & 1 deletion src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getConfig, getConfigPath } from './config.js'
import { getConfig, getConfigPath, updateConfig } from './config.js'

export async function getApiToken(): Promise<string> {
const envToken = process.env.TWIST_API_TOKEN
Expand All @@ -15,3 +15,13 @@ export async function getApiToken(): Promise<string> {
`No API token found. Set TWIST_API_TOKEN environment variable or add "token" to ${getConfigPath()}`,
)
}

export async function saveApiToken(token: string): Promise<void> {
// 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() })
}