Skip to content
Open
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
55 changes: 55 additions & 0 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 24 additions & 13 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const TOKEN_PROMPT = 'Enter YNAB Personal Access Token: ';

export function readTokenFromStdin(stdin: Readable = process.stdin): Promise<string> {
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<string> {
// 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<string> {
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());
});
Expand All @@ -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 <token>` or pipe one in via stdin.',
400
);
}
await auth.setAccessToken(token);
client.clearApi();
Expand Down