From 4b1767568489b92f5584a2f9c1767e74a8d7f39d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 19:29:09 -0500 Subject: [PATCH 1/2] chore(deps): upgrade @linqapp/sdk to ^0.31.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI pinned ^0.15.1, sixteen minor versions behind. That predates the webhook resource entirely, so `client.webhooks.unwrap()` — the SDK's Standard Webhooks verification helper — was not reachable from the CLI at all. This is the dependency bump only, no behaviour change. Verified: tsc --noEmit clean, build clean, 172 tests pass across 41 files, all 23 SDK call sites typecheck unchanged. Adopting Standard Webhooks in `webhooks listen --forward-to` follows separately so the version bump can be reviewed on its own. --- package-lock.json | 35 ++++++++++++++++++++++++++++++----- package.json | 2 +- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 43a25ee..a462f30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^8.2.0", - "@linqapp/sdk": "^0.15.1", + "@linqapp/sdk": "^0.31.0", "@oclif/core": "^4.8.0", "@oclif/plugin-autocomplete": "^3.2.40", "@oclif/plugin-help": "^6.2.37", @@ -2482,10 +2482,13 @@ "license": "MIT" }, "node_modules/@linqapp/sdk": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@linqapp/sdk/-/sdk-0.15.1.tgz", - "integrity": "sha512-5/P0xR12J93zQ5MOov9beHxbDE77PBPZkIicUh0CYyF0FJKiRmEVQx108Vub9I7THEQGKLwI4i2Hm0jljc3Qag==", - "license": "Apache-2.0" + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@linqapp/sdk/-/sdk-0.31.0.tgz", + "integrity": "sha512-skJ/xOOW0CLbOU82dbmO3ZS333De5vGsYia+WpPdV3IcJel2rv0XbvTRH4QBC28TUcw5EZn9sISMAZaZRKGC3g==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + } }, "node_modules/@oclif/core": { "version": "4.8.0", @@ -5554,6 +5557,12 @@ "node": ">=18.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7865,6 +7874,12 @@ "fastest-levenshtein": "^1.0.7" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -13983,6 +13998,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", diff --git a/package.json b/package.json index e99ee8d..84dff93 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.2.0", - "@linqapp/sdk": "^0.15.1", + "@linqapp/sdk": "^0.31.0", "@oclif/core": "^4.8.0", "@oclif/plugin-autocomplete": "^3.2.40", "@oclif/plugin-help": "^6.2.37", From 1aeab5be13c247e5aaf43a996224f2c7e669560c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 19:34:15 -0500 Subject: [PATCH 2/2] fix(login): do not prompt for a default number without a TTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `linq login --token ` is the documented non-interactive path, but on a paid account with more than one Linq Number it fell through to an interactive select() with no TTY guard. The prompt could never be answered, so the command blocked and exited before saveProfile() ran — no token was written, and every later command failed as unauthenticated with nothing explaining why. The token prompt earlier in the same command already guards on process.stdin.isTTY; the number picker did not. It now prompts only when stdin is a TTY, and otherwise defaults to the first number, saying which one it picked and pointing at `linq phonenumbers set`. Shared and sandbox accounts already auto-picked, so this only changes the paid multi-number path. This also clears the "Detected unsettled top-level await" warning on Node 26: bin/run.js awaits execute() at the top level, so a prompt that never resolves surfaces there rather than at the call site. The existing prompt test asserted behaviour that only holds with a terminal, so it now stubs isTTY explicitly, and a regression test covers the headless path. --- src/commands/login.ts | 14 +++++++++++- test/commands/login.test.ts | 44 ++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 7 deletions(-) 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'); + }); });