diff --git a/src/commands/login.ts b/src/commands/login.ts index 29af2e5..f0c444a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -155,7 +155,12 @@ export default class Login extends BaseCommand { if (phones.length === 1) { fromPhone = phones[0].phoneNumber; } else if (phones.length > 1) { - if (accountLabel === 'Paid') { + // The picker runs only when there is a terminal to drive it. `--token` is + // the non-interactive entry point, and it is reached from agent shells and + // CI where stdin is not a TTY — prompting there hung the process before it + // ever got to saveProfile(), so the token was never persisted and every + // later command failed as unauthenticated. + if (accountLabel === 'Paid' && process.stdin.isTTY) { try { fromPhone = await select({ message: 'Select a default Linq Number:', @@ -169,6 +174,13 @@ export default class Login extends BaseCommand { } } else { fromPhone = phones[0].phoneNumber; + if (accountLabel === 'Paid') { + this.log( + `Defaulted to ${chalk.cyan(fromPhone)} of ${phones.length} Linq Numbers ` + + `${chalk.dim('(no terminal for the picker)')}.` + ); + this.log(`Change it with ${chalk.cyan('linq phonenumbers set')}.\n`); + } } } diff --git a/test/commands/login.test.ts b/test/commands/login.test.ts index 56a180f..897c8d3 100644 --- a/test/commands/login.test.ts +++ b/test/commands/login.test.ts @@ -108,8 +108,8 @@ describe('login (token paste)', () => { expect(saved.profiles.default.fromPhone).toBe('+18005551111'); }); - it('paid user with multiple phones is prompted', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse(200, { + function paidMultiPhoneResponse() { + return jsonResponse(200, { partnerId: 'partner-1', orgId: '999', name: 'Acme', @@ -120,17 +120,49 @@ describe('login (token paste)', () => { { phoneNumber: '+18005552222' }, ], }, - })); + }); + } + it('paid user with multiple phones is prompted when stdin is a TTY', async () => { + mockFetch.mockResolvedValueOnce(paidMultiPhoneResponse()); mockSelect.mockResolvedValueOnce('+18005552222'); - const config = await Config.load({ root: process.cwd() }); - const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); - await cmd.run(); + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + const config = await Config.load({ root: process.cwd() }); + const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); + await cmd.run(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + } expect(mockSelect).toHaveBeenCalledTimes(1); const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); expect(saved.profiles.default.fromPhone).toBe('+18005552222'); expect(saved.profiles.default.accountLabel).toBe('Paid'); }); + + // Regression: the picker used to run unguarded, so `--token` from an agent + // shell or CI blocked on a prompt that could never be answered and exited + // before saveProfile() — leaving no credential behind at all. + it('paid user with multiple phones saves without prompting when stdin is not a TTY', async () => { + mockFetch.mockResolvedValueOnce(paidMultiPhoneResponse()); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + try { + const config = await Config.load({ root: process.cwd() }); + const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); + await cmd.run(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + } + + expect(mockSelect).not.toHaveBeenCalled(); + const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(saved.profiles.default.token).toBe('linq_test'); + expect(saved.profiles.default.fromPhone).toBe('+18005551111'); + expect(saved.profiles.default.accountLabel).toBe('Paid'); + }); });