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
14 changes: 13 additions & 1 deletion src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand All @@ -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`);
}
}
}

Expand Down
44 changes: 38 additions & 6 deletions test/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');
});
});
Loading