diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts new file mode 100644 index 0000000..f5e4dad --- /dev/null +++ b/src/commands/auth.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'stream'; +import { promptForToken, readTokenFromStdin } from './auth.js'; + +describe('promptForToken', () => { + it('emits the prompt as part of readline (not a bare pre-write)', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let captured = ''; + output.on('data', (chunk) => { + captured += chunk.toString(); + }); + + const promise = promptForToken(input, output); + input.write('my-secret-token\n'); + const token = await promise; + + expect(token).toBe('my-secret-token'); + // Regression: prompt must reach output via readline so it isn't cleared off (Warp). + expect(captured).toContain('Enter YNAB Personal Access Token:'); + }); + + it('trims surrounding whitespace from the entered token', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const promise = promptForToken(input, output); + input.write(' padded-token \n'); + await expect(promise).resolves.toBe('padded-token'); + }); +}); + +describe('readTokenFromStdin', () => { + it('resolves with the trimmed token when piped in', async () => { + const stdin = new PassThrough(); + const promise = readTokenFromStdin(stdin); + stdin.write(' piped-token\n'); + stdin.end(); + await expect(promise).resolves.toBe('piped-token'); + }); + + it('resolves empty when stdin closes without data', async () => { + const stdin = new PassThrough(); + const promise = readTokenFromStdin(stdin); + stdin.end(); + await expect(promise).resolves.toBe(''); + }); + + it('rejects when the stream errors', async () => { + const stdin = new PassThrough(); + const promise = readTokenFromStdin(stdin); + const boom = new Error('stream boom'); + stdin.emit('error', boom); + await expect(promise).rejects.toBe(boom); + }); +}); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 6fadfb9..ada58a2 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,29 +1,36 @@ import { Command } from 'commander'; import { createInterface } from 'readline'; +import type { Readable, Writable } from 'stream'; import { auth } from '../lib/auth.js'; import { outputJson } from '../lib/output.js'; import { client } from '../lib/api-client.js'; import { withErrorHandling } from '../lib/command-utils.js'; import { YnabCliError } from '../lib/errors.js'; -function readTokenFromStdin(): Promise { +const TOKEN_PROMPT = 'Enter YNAB Personal Access Token: '; + +export function readTokenFromStdin(stdin: Readable = process.stdin): Promise { return new Promise((resolve, reject) => { let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk) => { data += chunk; }); - process.stdin.on('end', () => resolve(data.trim())); - process.stdin.on('error', reject); + stdin.setEncoding('utf8'); + stdin.on('data', (chunk) => { + data += chunk; + }); + stdin.on('end', () => resolve(data.trim())); + stdin.on('error', reject); }); } -function promptForToken(): Promise { +// Pass the prompt as readline's question() query, not a separate write: readline +// clears and reprints its line on refresh (ESC[1G ESC[0J), erasing any prompt +// written outside its knowledge — invisibly on terminals like Warp. +export function promptForToken( + input: Readable = process.stdin, + output: Writable = process.stderr +): Promise { return new Promise((resolve) => { - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - }); - process.stderr.write('Enter YNAB Personal Access Token: '); - rl.question('', (answer) => { + const rl = createInterface({ input, output }); + rl.question(TOKEN_PROMPT, (answer) => { rl.close(); resolve(answer.trim()); }); @@ -50,7 +57,11 @@ export function createAuthCommand(): Command { } if (!token) { - throw new YnabCliError('Access token cannot be empty', 400); + throw new YnabCliError( + 'Access token cannot be empty. Provide a token with ' + + '`ynab auth login --token ` or pipe one in via stdin.', + 400 + ); } await auth.setAccessToken(token); client.clearApi();