From 5f156410b773ef43db858a2f126b70bc97767afe Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Fri, 5 Jun 2026 12:07:39 -0500 Subject: [PATCH] fix(auth): show the interactive login prompt instead of erasing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ynab auth login` on a TTY appeared to hang with no prompt. The cause was in promptForToken(): it wrote "Enter YNAB Personal Access Token: " to the output stream itself and then called readline's `question('')` with an empty query. readline owns the current line and, when it renders, emits `ESC[1G ESC[0J` (cursor-home + clear-to-end-of-screen) — which erases the pre-written prompt the instant it appears. On terminals that apply those sequences promptly (Warp is one), the user sees a blank line with a blinking cursor and no hint that a token is expected, so the command looks dead. (Input actually still worked — typing a token and pressing Enter authenticated — but nothing told the user to type.) Pass the prompt as readline's `question()` query instead. readline then reprints the prompt after its own clear, so it stays visible and the cursor parks right after it. Verified under a real PTY: the byte stream is now `ESC[1G ESC[0J Enter YNAB Personal Access Token: ESC[35G` — clear first, prompt second, surviving on screen. Also makes promptForToken/readTokenFromStdin accept injectable streams so the prompt-rendering and stdin-parsing behaviour can be unit-tested, and gives the empty-token error an actionable hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/auth.test.ts | 55 +++++++++++++++++++++++++++++++++++++++ src/commands/auth.ts | 37 +++++++++++++++++--------- 2 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 src/commands/auth.test.ts 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();