diff --git a/README.md b/README.md index 10f9bde..c0b21aa 100644 --- a/README.md +++ b/README.md @@ -661,7 +661,7 @@ linq webhooks listen --json Events are displayed in a structured log format: ``` -2024-01-15T10:30:45.123Z [message.received] message.id=msg_123 message.body="Hello world" message.chat_id=chat_456 +2024-01-15T10:30:45.123Z [message.received] data.id=msg_123 data.chat.id=chat_456 data.body="Hello world" ``` Press `Ctrl+C` to stop. The CLI automatically cleans up the webhook subscription. diff --git a/package.json b/package.json index 0de2089..1cbb53b 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,12 @@ }, "attachments": { "description": "Manage file attachments" + }, + "tokens": { + "description": "Manage API tokens" + }, + "contacts": { + "description": "Manage shared-line contacts" } } }, diff --git a/src/commands/attachments/get.ts b/src/commands/attachments/get.ts index d13ee42..3cb0cc8 100644 --- a/src/commands/attachments/get.ts +++ b/src/commands/attachments/get.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -49,7 +50,7 @@ export default class AttachmentsGet extends BaseCommand { this.log(formatAttachmentMeta(data)); } } catch (e) { - this.error(`Failed to get attachment: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/attachments/upload.ts b/src/commands/attachments/upload.ts index 343d2cc..6452454 100644 --- a/src/commands/attachments/upload.ts +++ b/src/commands/attachments/upload.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -59,7 +60,7 @@ export default class AttachmentsUpload extends BaseCommand { this.log(formatUploadUrl(data)); } } catch (e) { - this.error(`Failed to request upload: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/create.ts b/src/commands/chats/create.ts index 0c76069..92b6a62 100644 --- a/src/commands/chats/create.ts +++ b/src/commands/chats/create.ts @@ -1,7 +1,8 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; -import { loadConfig, requireToken, requireFromPhone } from '../../lib/config.js'; +import { loadConfig, requireToken, requireFromPhone, isSharedLine } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; import { formatChatCreated } from '../../lib/format.js'; import { addBreadcrumb } from '../../lib/telemetry.js'; @@ -97,7 +98,7 @@ export default class ChatsCreate extends BaseCommand { } else if (BUBBLE_EFFECTS.includes(flags.effect)) { effect = { type: 'bubble', name: flags.effect }; } else { - this.error(`Invalid effect: ${flags.effect}. Valid effects: ${ALL_EFFECTS.join(', ')}`); + bail(this, flags.json, `Invalid effect: ${flags.effect}. Valid effects: ${ALL_EFFECTS.join(', ')}`); } } @@ -120,9 +121,8 @@ export default class ChatsCreate extends BaseCommand { } } catch (e) { if (e instanceof Linq.PermissionDeniedError) { - const lineType = config.tier === 0 && config.tenantType === 'SINGLE' ? 'sandbox' : 'shared'; this.log(chalk.yellow(`\n Can't message this contact yet.\n`)); - if (lineType === 'shared') { + if (isSharedLine(config)) { this.log(chalk.dim(` On a shared line, you need to add the contact (${chalk.cyan('linq contacts add +1234567890')})`)); this.log(chalk.dim(` and they must text you (${chalk.bold(fromPhone)}) first before you can message them.\n`)); } else { @@ -130,7 +130,7 @@ export default class ChatsCreate extends BaseCommand { } this.exit(1); } - this.error(`Failed to create chat: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/get.ts b/src/commands/chats/get.ts index c0b3ce1..f396225 100644 --- a/src/commands/chats/get.ts +++ b/src/commands/chats/get.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -49,7 +50,7 @@ export default class ChatsGet extends BaseCommand { this.log(formatChatDetail(data)); } } catch (e) { - this.error(`Failed to get chat: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/list.ts b/src/commands/chats/list.ts index cf5a3cd..f8bd0bc 100644 --- a/src/commands/chats/list.ts +++ b/src/commands/chats/list.ts @@ -1,11 +1,12 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken, requireFromPhone } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; import { formatChatsList } from '../../lib/format.js'; export default class ChatsList extends BaseCommand { - static override description = 'List all chats for a phone number'; + static override description = 'List all chats for a Blue Number'; static override examples = [ '<%= config.bin %> <%= command.id %>', @@ -16,7 +17,7 @@ export default class ChatsList extends BaseCommand { static override flags = { from: Flags.string({ - description: 'Phone number to list chats for (E.164 format). Uses config fromPhone if not specified.', + description: 'Blue Number to list chats for (E.164 format). Uses config fromPhone if not specified.', }), limit: Flags.integer({ description: 'Maximum number of chats to return (default: 20, max: 100)', @@ -60,7 +61,7 @@ export default class ChatsList extends BaseCommand { this.log(formatChatsList(data)); } } catch (e) { - this.error(`Failed to list chats: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/participants/add.ts b/src/commands/chats/participants/add.ts index cea0aba..33a776c 100644 --- a/src/commands/chats/participants/add.ts +++ b/src/commands/chats/participants/add.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../../lib/errors.js'; import chalk from 'chalk'; import { BaseCommand } from '../../../lib/base-command.js'; import { loadConfig, requireToken } from '../../../lib/config.js'; @@ -55,7 +56,7 @@ export default class ParticipantsAdd extends BaseCommand { this.log(chalk.green(`\n \u2713 Added ${flags.handle} to chat.\n`)); } } catch (e) { - this.error(`Failed to add participant: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/participants/remove.ts b/src/commands/chats/participants/remove.ts index 4268aa7..b035a80 100644 --- a/src/commands/chats/participants/remove.ts +++ b/src/commands/chats/participants/remove.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../../lib/errors.js'; import chalk from 'chalk'; import { BaseCommand } from '../../../lib/base-command.js'; import { loadConfig, requireToken } from '../../../lib/config.js'; @@ -55,7 +56,7 @@ export default class ParticipantsRemove extends BaseCommand { this.log(chalk.green(`\n \u2713 Removed ${flags.handle} from chat.\n`)); } } catch (e) { - this.error(`Failed to remove participant: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/read.ts b/src/commands/chats/read.ts index ef72315..18d0041 100644 --- a/src/commands/chats/read.ts +++ b/src/commands/chats/read.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -26,6 +27,7 @@ export default class ChatsRead extends BaseCommand { char: 't', description: 'API token (overrides stored token)', }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -37,9 +39,13 @@ export default class ChatsRead extends BaseCommand { try { await client.chats.markAsRead(args.chatId); + if (flags.json) { + this.log(JSON.stringify({ success: true, chatId: args.chatId, action: 'read' }, null, 2)); + return; + } this.log(`Chat ${args.chatId} marked as read.`); } catch (e) { - this.error(`Failed to mark chat as read: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/share-contact.ts b/src/commands/chats/share-contact.ts index 8366f6e..08012af 100644 --- a/src/commands/chats/share-contact.ts +++ b/src/commands/chats/share-contact.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -26,6 +27,7 @@ export default class ChatsShareContact extends BaseCommand { char: 't', description: 'API token (overrides stored token)', }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -37,9 +39,13 @@ export default class ChatsShareContact extends BaseCommand { try { await client.chats.shareContactCard(args.chatId); + if (flags.json) { + this.log(JSON.stringify({ success: true, chatId: args.chatId, action: 'share_contact' }, null, 2)); + return; + } this.log('Contact card shared successfully.'); } catch (e) { - this.error(`Failed to share contact: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/typing.ts b/src/commands/chats/typing.ts index 0ae2f4a..e7a9206 100644 --- a/src/commands/chats/typing.ts +++ b/src/commands/chats/typing.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -31,6 +32,7 @@ export default class ChatsTyping extends BaseCommand { char: 't', description: 'API token (overrides stored token)', }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -41,15 +43,19 @@ export default class ChatsTyping extends BaseCommand { const client = createApiClient(token); try { + const action = flags.stop ? 'stop' : 'start'; if (flags.stop) { await client.chats.typing.stop(args.chatId); - this.log('Typing indicator stopped.'); } else { await client.chats.typing.start(args.chatId); - this.log('Typing indicator started.'); } + if (flags.json) { + this.log(JSON.stringify({ success: true, chatId: args.chatId, action: `typing.${action}` }, null, 2)); + return; + } + this.log(flags.stop ? 'Typing indicator stopped.' : 'Typing indicator started.'); } catch (e) { - this.error(`Failed to ${flags.stop ? 'stop' : 'start'} typing: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/update.ts b/src/commands/chats/update.ts index 76701d7..da1bb21 100644 --- a/src/commands/chats/update.ts +++ b/src/commands/chats/update.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -44,7 +45,7 @@ export default class ChatsUpdate extends BaseCommand { const { args, flags } = await this.parse(ChatsUpdate); if (!flags.name && !flags.icon) { - this.error('At least one of --name or --icon must be specified'); + bail(this, flags.json, 'At least one of --name or --icon must be specified'); } const config = await loadConfig(flags.profile); @@ -65,7 +66,7 @@ export default class ChatsUpdate extends BaseCommand { this.log(formatChatDetail(chat)); } } catch (e) { - this.error(`Failed to update chat: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/chats/voicememo.ts b/src/commands/chats/voicememo.ts index 8f9f27d..3f40489 100644 --- a/src/commands/chats/voicememo.ts +++ b/src/commands/chats/voicememo.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -30,6 +31,7 @@ export default class ChatsVoicememo extends BaseCommand { char: 't', description: 'API token (overrides stored token)', }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -44,9 +46,15 @@ export default class ChatsVoicememo extends BaseCommand { voice_memo_url: flags.url, }); - this.log(JSON.stringify(data, null, 2)); + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + return; + } + + const messageId = (data as { message?: { id?: string } })?.message?.id; + this.log(`Voice memo sent to chat ${args.chatId}${messageId ? ` (message ${messageId})` : ''}.`); } catch (e) { - this.error(`Failed to send voice memo: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/contacts/add.ts b/src/commands/contacts/add.ts index ae7adfd..7237c96 100644 --- a/src/commands/contacts/add.ts +++ b/src/commands/contacts/add.ts @@ -4,6 +4,7 @@ import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken, requireSharedLine } from '../../lib/config.js'; import { BACKEND_URL } from '../../lib/api-client.js'; import { addBreadcrumb } from '../../lib/telemetry.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class ContactsAdd extends BaseCommand { static override description = 'Add a contact to your shared line'; @@ -19,6 +20,7 @@ export default class ContactsAdd extends BaseCommand { static override flags = { profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -30,8 +32,7 @@ export default class ContactsAdd extends BaseCommand { const orgId = config.orgId; if (!orgId) { - this.log(chalk.yellow(`\n Not logged in. Run ${chalk.cyan('linq signup')} or ${chalk.cyan('linq login')}.\n`)); - this.exit(1); + bail(this, flags.json, 'Not logged in. Run `linq signup` or `linq login`.'); } try { @@ -41,25 +42,28 @@ export default class ContactsAdd extends BaseCommand { body: JSON.stringify({ orgId, contactPhone: args.phone }), }); - if (!res.ok) { - const err = await res.json() as { message?: string }; - const reason = err.message || 'Unknown error'; - this.log(chalk.red(`\n Failed to add contact: ${reason}\n`)); - if (/another partner/i.test(reason)) { - this.log(chalk.dim(' Upgrade to a dedicated line to message without limits —')); - this.log(chalk.dim(' email support@linqapp.com to get started.\n')); - } - this.exit(1); - } + if (!res.ok) await throwHttpError(res); const data = await res.json() as { contactPhone: string }; addBreadcrumb('Contact added'); - this.log(chalk.green(`\n \u2713 Contact ${data.contactPhone} added.\n`)); - this.log(chalk.dim(' Note: this contact must send a message to your Linq number first before you can reply.\n')); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); + + const blueNumber = config.fromPhone; + + if (flags.json) { + this.log(JSON.stringify({ + contactPhone: data.contactPhone, + blueNumber: blueNumber ?? null, + }, null, 2)); + return; + } + + this.log(chalk.green(`\n ✓ Contact ${data.contactPhone} added.\n`)); + this.log(chalk.yellow(' Inbound-first: this contact must text your Blue Number before you can text them.\n')); + if (blueNumber) { + this.log(` Text ${chalk.cyan(blueNumber)} from ${chalk.cyan(data.contactPhone)} to start the conversation.\n`); + } + } catch (e) { + bail(this, flags.json, e); } } } diff --git a/src/commands/contacts/list.ts b/src/commands/contacts/list.ts index e981c02..9d0e728 100644 --- a/src/commands/contacts/list.ts +++ b/src/commands/contacts/list.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken, requireSharedLine } from '../../lib/config.js'; import { BACKEND_URL } from '../../lib/api-client.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class ContactsList extends BaseCommand { static override description = 'List contacts on your shared line'; @@ -26,8 +27,7 @@ export default class ContactsList extends BaseCommand { const orgId = config.orgId; if (!orgId) { - this.log(chalk.yellow(`\n Not logged in. Run ${chalk.cyan('linq signup')} or ${chalk.cyan('linq login')}.\n`)); - this.exit(1); + bail(this, flags.json, 'Not logged in. Run `linq signup` or `linq login`.'); } try { @@ -36,11 +36,7 @@ export default class ContactsList extends BaseCommand { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, }); - if (!res.ok) { - const err = await res.json() as { message?: string }; - this.log(chalk.red(`\n ${err.message || 'Failed to list contacts'}\n`)); - this.exit(1); - } + if (!res.ok) await throwHttpError(res); const data = await res.json() as { contacts: { contactPhone: string; createdAt: string }[]; @@ -59,14 +55,12 @@ export default class ContactsList extends BaseCommand { this.log(`\n ${chalk.bold('Your contacts')} (${data.contacts.length})\n`); for (const contact of data.contacts) { - const added = new Date(contact.createdAt).toLocaleDateString(); + const added = new Date(contact.createdAt).toISOString().slice(0, 10); this.log(` ${contact.contactPhone} ${chalk.dim(`added ${added}`)}`); } this.log(''); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); + } catch (e) { + bail(this, flags.json, e); } } } diff --git a/src/commands/contacts/remove.ts b/src/commands/contacts/remove.ts index abc94ff..ab28439 100644 --- a/src/commands/contacts/remove.ts +++ b/src/commands/contacts/remove.ts @@ -4,6 +4,7 @@ import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken, requireSharedLine } from '../../lib/config.js'; import { BACKEND_URL } from '../../lib/api-client.js'; import { addBreadcrumb } from '../../lib/telemetry.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class ContactsRemove extends BaseCommand { static override description = 'Remove a contact from your shared line'; @@ -19,6 +20,7 @@ export default class ContactsRemove extends BaseCommand { static override flags = { profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), }; async run(): Promise { @@ -30,8 +32,7 @@ export default class ContactsRemove extends BaseCommand { const orgId = config.orgId; if (!orgId) { - this.log(chalk.yellow(`\n Not logged in. Run ${chalk.cyan('linq signup')} or ${chalk.cyan('linq login')}.\n`)); - this.exit(1); + bail(this, flags.json, 'Not logged in. Run `linq signup` or `linq login`.'); } try { @@ -41,18 +42,18 @@ export default class ContactsRemove extends BaseCommand { body: JSON.stringify({ orgId, contactPhone: args.phone }), }); - if (!res.ok) { - const err = await res.json() as { message?: string }; - this.log(chalk.red(`\n ${err.message || 'Failed to remove contact'}\n`)); - this.exit(1); - } + if (!res.ok) await throwHttpError(res); addBreadcrumb('Contact removed'); - this.log(chalk.green(`\n \u2713 Contact ${args.phone} removed.\n`)); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); + + if (flags.json) { + this.log(JSON.stringify({ contactPhone: args.phone, removed: true }, null, 2)); + return; + } + + this.log(chalk.green(`\n ✓ Contact ${args.phone} removed.\n`)); + } catch (e) { + bail(this, flags.json, e); } } } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b03b623..a0e1200 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -14,28 +14,50 @@ export default class Doctor extends BaseCommand { char: 'p', description: 'Config profile to use', }), + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), + strict: Flags.boolean({ + description: 'Treat warnings as failures (exit non-zero on any warning)', + default: false, + }), }; async run(): Promise { const { flags } = await this.parse(Doctor); + type Check = { name: string; status: 'ok' | 'fail' | 'warn'; message: string }; + const checks: Check[] = []; let passed = 0; let failed = 0; let warnings = 0; - const ok = (msg: string) => { this.log(chalk.green(' ✓ ') + msg); passed++; }; - const fail = (msg: string) => { this.log(chalk.red(' ✗ ') + msg); failed++; }; - const warn = (msg: string) => { this.log(chalk.yellow(' ! ') + msg); warnings++; }; - - this.log('\n Linq CLI Health Check\n'); + const ok = (name: string, message: string) => { + checks.push({ name, status: 'ok', message }); + if (!flags.json) this.log(chalk.green(' ✓ ') + message); + passed++; + }; + const fail = (name: string, message: string) => { + checks.push({ name, status: 'fail', message }); + if (!flags.json) this.log(chalk.red(' ✗ ') + message); + failed++; + }; + const warn = (name: string, message: string) => { + checks.push({ name, status: 'warn', message }); + if (!flags.json) this.log(chalk.yellow(' ! ') + message); + warnings++; + }; + + if (!flags.json) this.log('\n Linq CLI Health Check\n'); // Check 1: Config file const configFile = await loadConfigFile(); const profileCount = Object.keys(configFile.profiles).length; if (profileCount > 0) { - ok('Config file found'); + ok('config_file', 'Config file found'); } else { - fail('Config file not found — run `linq signup` or `linq login`'); + fail('config_file', 'Config file not found — run `linq signup` or `linq login`'); } // Load profile @@ -43,84 +65,96 @@ export default class Doctor extends BaseCommand { try { config = await loadConfig(flags.profile); } catch { - fail('Failed to load config profile'); - this.printSummary(passed, failed, warnings); + fail('profile_load', 'Failed to load config profile'); + this.emit(checks, passed, failed, warnings, flags.json, flags.strict); return; } - // Check 2: API token if (config.token) { - const masked = config.token.substring(0, 8) + '•'.repeat(8); - ok(`API token configured (${masked})`); + const masked = config.token.length > 12 ? `${config.token.slice(0, 12)}...` : config.token; + ok('api_token', `API token configured (${masked})`); } else { - fail('API token not configured — run `linq login` or `linq signup`'); + fail('api_token', 'API token not configured — run `linq login` or `linq signup`'); } - // Check 3: Phone number if (config.fromPhone) { - ok(`Phone number set (${config.fromPhone})`); + ok('blue_number', `Blue Number set (${config.fromPhone})`); } else { - fail('Phone number not set — run `linq phonenumbers set` to pick a default'); + fail('blue_number', 'Blue Number not set — run `linq phonenumbers set` to pick a default'); } - // Check 4: Session expiry (for the active profile) const sessionExpiry = config.sessionExpiresAt || config.expiresAt; if (sessionExpiry) { const expires = new Date(sessionExpiry); if (expires > new Date()) { const daysLeft = Math.ceil((expires.getTime() - Date.now()) / 86_400_000); - ok(`Session active (${daysLeft} day${daysLeft > 1 ? 's' : ''} remaining)`); + ok('session', `Session active (${daysLeft} day${daysLeft > 1 ? 's' : ''} remaining)`); } else { - fail(`Session expired — run \`linq login\` to re-authenticate`); + fail('session', 'Session expired — run `linq login` to re-authenticate'); } } - // Check 5: API connectivity if (config.token) { const client = createApiClient(config.token); const start = Date.now(); try { const phones = await client.phoneNumbers.list(); const latency = Date.now() - start; - const phoneCount = (phones as any).phone_numbers?.length || 0; + const phoneCount = (phones as { phone_numbers?: unknown[] }).phone_numbers?.length || 0; const phoneLabel = phoneCount > 0 ? `, ${phoneCount} phone${phoneCount !== 1 ? 's' : ''}` : ''; - ok(`API connected (${latency}ms${phoneLabel})`); + ok('api_connectivity', `API connected (${latency}ms${phoneLabel})`); - // Check 6: Webhooks try { const webhooks = await client.webhookSubscriptions.list(); - const subs = (webhooks as any).subscriptions || []; - const active = subs.filter((s: any) => s.is_active).length; + const subs = (webhooks as { subscriptions?: { is_active: boolean }[] }).subscriptions || []; + const active = subs.filter((s) => s.is_active).length; if (subs.length > 0) { - ok(`Webhooks: ${active} active, ${subs.length - active} inactive`); + ok('webhooks', `Webhooks: ${active} active, ${subs.length - active} inactive`); } else { - warn('No webhook subscriptions — run `linq webhooks create` or `linq webhooks listen`'); + warn('webhooks', 'No webhook subscriptions — run `linq webhooks create` or `linq webhooks listen`'); } } catch { - warn('Could not check webhooks'); + warn('webhooks', 'Could not check webhooks'); } } catch (error) { const latency = Date.now() - start; const msg = error instanceof Error ? error.message : String(error); if (msg.includes('401') || msg.includes('Unauthorized')) { - fail(`API auth failed (${latency}ms) — token may be invalid or expired`); + fail('api_connectivity', `API auth failed (${latency}ms) — token may be invalid or expired`); } else { - fail(`API unreachable (${latency}ms) — check your network`); + fail('api_connectivity', `API unreachable (${latency}ms) — check your network`); } } } else { - fail('API connectivity — skipped (no token)'); + fail('api_connectivity', 'API connectivity — skipped (no token)'); } - this.printSummary(passed, failed, warnings); + this.emit(checks, passed, failed, warnings, flags.json, flags.strict); } - private printSummary(passed: number, failed: number, warnings: number): void { - this.log(''); - const parts: string[] = []; - parts.push(chalk.green(`${passed} passed`)); - if (warnings > 0) parts.push(chalk.yellow(`${warnings} warning${warnings > 1 ? 's' : ''}`)); - if (failed > 0) parts.push(chalk.red(`${failed} failed`)); - this.log(` ${parts.join(', ')}\n`); + private emit( + checks: { name: string; status: 'ok' | 'fail' | 'warn'; message: string }[], + passed: number, + failed: number, + warnings: number, + json: boolean, + strict: boolean, + ): void { + if (json) { + this.log(JSON.stringify({ + ok: failed === 0 && (!strict || warnings === 0), + passed, + warnings, + failed, + checks, + }, null, 2)); + } else { + this.log(''); + const parts: string[] = [chalk.green(`${passed} passed`)]; + if (warnings > 0) parts.push(chalk.yellow(`${warnings} warning${warnings > 1 ? 's' : ''}`)); + if (failed > 0) parts.push(chalk.red(`${failed} failed`)); + this.log(` ${parts.join(', ')}\n`); + } + if (failed > 0 || (strict && warnings > 0)) this.exit(1); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index cda8a59..5418ff4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -7,8 +7,9 @@ import { getCurrentProfile, listProfiles, SANDBOX_PROFILE, + type AccountLabel, } from '../lib/config.js'; -import { createApiClient, BACKEND_URL } from '../lib/api-client.js'; +import { BACKEND_URL } from '../lib/api-client.js'; import { renderBanner } from '../lib/banner.js'; export default class Init extends BaseCommand { @@ -78,59 +79,54 @@ export default class Init extends BaseCommand { }, }); - // Validate token by calling the API this.log('\nValidating token...'); - const client = createApiClient(token.trim()); - - let data; - try { - data = await client.phoneNumbers.list(); - } catch { - this.error( - 'Invalid token or API error. Please check your token and try again.' - ); - } - - this.log('\u2713 Token is valid!\n'); - let orgId: string | undefined; - let tier: number | undefined; - let tenantType: string | undefined; let name: string | undefined; let partnerId: string | undefined; - let accountPhones: { phoneNumber: string; tenantType: string }[] = []; + let accountPhones: { phoneNumber: string }[] = []; + let accountLabel: AccountLabel | undefined; + try { const res = await fetch(`${BACKEND_URL}/cli/account-info`, { headers: { 'Authorization': `Bearer ${token.trim()}` }, }); - if (res.ok) { - const acc = await res.json() as { - partnerId?: string; - orgId?: string; - name?: string | null; - accountInfo?: { tier: number; phones: { phoneNumber: string; tenantType: string }[] } | null; - }; - partnerId = acc.partnerId; - orgId = acc.orgId; - name = acc.name ?? undefined; - tier = acc.accountInfo?.tier; - accountPhones = acc.accountInfo?.phones ?? []; + if (res.status === 401) { + this.error('Invalid or expired token. Generate a fresh one in the Dashboard or contact support@linqapp.com'); + } + if (!res.ok) { + this.error('Could not connect to Linq. Please try again later.'); } - } catch { - // pass + const acc = await res.json() as { + partnerId?: string; + orgId?: string; + name?: string | null; + accountInfo?: { + phones: { phoneNumber: string }[]; + accountLabel?: AccountLabel; + } | null; + }; + partnerId = acc.partnerId; + orgId = acc.orgId; + name = acc.name ?? undefined; + accountPhones = acc.accountInfo?.phones ?? []; + accountLabel = acc.accountInfo?.accountLabel; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); } + this.log('\u2713 Token is valid!\n'); + let fromPhone: string | undefined; - const synapsePhones = (data.phone_numbers || []).map(p => ({ phoneNumber: p.phone_number })); - const phones = accountPhones.length > 0 ? accountPhones : synapsePhones; + const phones = accountPhones; if (phones.length === 1) { fromPhone = phones[0].phoneNumber; - this.log(`Default phone number set to ${fromPhone} (only number on account)\n`); + this.log(`Default Blue Number set to ${fromPhone} (only number on account)\n`); } else if (phones.length > 1) { - if ((tier ?? 0) >= 1) { + if (accountLabel === 'Paid') { fromPhone = await select({ - message: 'Select a default phone number:', + message: 'Select a default Blue Number:', choices: phones.map((p) => ({ name: p.phoneNumber, value: p.phoneNumber, @@ -142,26 +138,19 @@ export default class Init extends BaseCommand { } } - if (accountPhones.length > 0) { - tenantType = (fromPhone && accountPhones.find(p => p.phoneNumber === fromPhone)?.tenantType) - ?? accountPhones[0].tenantType; - } - - // Save to profile await saveProfile(profileName, { token: token.trim(), ...(fromPhone && { fromPhone }), ...(partnerId && { partnerId }), ...(orgId && { orgId }), - ...(tier !== undefined && { tier }), - ...(tenantType && { tenantType }), ...(name && { name }), + accountLabel, }); await setCurrentProfile(profileName); this.log(`\n\u2713 Configuration saved to profile "${profileName}"\n`); this.log('Next steps:'); - this.log(' linq phonenumbers List your phone numbers'); + this.log(' linq phonenumbers List your Blue Numbers'); this.log( ' linq chats create --to +1XXXXXXXXXX -m "Hello!" Create a chat and send a message' ); diff --git a/src/commands/login.ts b/src/commands/login.ts index 152cb9e..4c9a7ff 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -8,8 +8,9 @@ import { getCurrentProfile, listProfiles, SANDBOX_PROFILE, + type AccountLabel, } from '../lib/config.js'; -import { createApiClient, BACKEND_URL } from '../lib/api-client.js'; +import { BACKEND_URL } from '../lib/api-client.js'; import { renderBanner } from '../lib/banner.js'; export default class Login extends BaseCommand { @@ -42,35 +43,45 @@ export default class Login extends BaseCommand { } if (!profileName) { - const current = await getCurrentProfile() || 'default'; - const profiles = (await listProfiles()).filter(p => p !== SANDBOX_PROFILE); - const choices = [ - ...profiles.map(p => ({ - name: p === current ? `${p} (active)` : p, - value: p, - })), - { name: 'Create new profile', value: '__new__' }, - ]; - try { - const chosen = await select({ - message: 'Which profile would you like to log in to?', - choices, - default: current !== SANDBOX_PROFILE ? current : undefined, - }); - profileName = chosen === '__new__' - ? await input({ message: 'Profile name:', validate: v => v.trim() ? true : 'Name cannot be empty' }) - : chosen; - } catch (error) { - if (error instanceof Error && error.name === 'ExitPromptError') { - profileName = 'default'; - } else { - throw error; + // If --token was supplied, assume non-interactive (script / AI + // agent) — pick the current/default profile silently. Only the + // bare `linq login` interactive flow gets the profile picker. + if (flags.token) { + profileName = (await getCurrentProfile()) || 'default'; + } else { + const current = await getCurrentProfile() || 'default'; + const profiles = (await listProfiles()).filter(p => p !== SANDBOX_PROFILE); + const choices = [ + ...profiles.map(p => ({ + name: p === current ? `${p} (active)` : p, + value: p, + })), + { name: 'Create new profile', value: '__new__' }, + ]; + try { + const chosen = await select({ + message: 'Which profile would you like to log in to?', + choices, + default: current !== SANDBOX_PROFILE ? current : undefined, + }); + profileName = chosen === '__new__' + ? await input({ message: 'Profile name:', validate: v => v.trim() ? true : 'Name cannot be empty' }) + : chosen; + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + profileName = 'default'; + } else { + throw error; + } } } } let token = flags.token; if (!token) { + if (!process.stdin.isTTY) { + this.error('linq login needs a terminal for the interactive token prompt. To run non-interactively, pass --token: linq login --token '); + } await renderBanner(); console.log('\n Welcome back to Linq CLI\n'); try { @@ -89,55 +100,54 @@ export default class Login extends BaseCommand { token = token.trim(); if (!token) this.error('Token cannot be empty'); - // Validate the token by hitting synapse this.log('\nValidating token...'); - const client = createApiClient(token); - let data; - try { - data = await client.phoneNumbers.list(); - } catch { - this.error('Invalid token or API error. Please check your token and try again.'); - } - this.log(`${chalk.green('✓')} Token is valid!\n`); - let orgId: string | undefined; - let tier: number | undefined; - let tenantType: string | undefined; let name: string | undefined; let partnerId: string | undefined; - let accountPhones: { phoneNumber: string; tenantType: string }[] = []; + let accountPhones: { phoneNumber: string }[] = []; + let accountLabel: AccountLabel | undefined; + try { const res = await fetch(`${BACKEND_URL}/cli/account-info`, { headers: { Authorization: `Bearer ${token}` }, }); - if (res.ok) { - const acc = await res.json() as { - partnerId?: string; - orgId?: string; - name?: string | null; - accountInfo?: { tier: number; phones: { phoneNumber: string; tenantType: string }[] } | null; - }; - partnerId = acc.partnerId; - orgId = acc.orgId; - name = acc.name ?? undefined; - tier = acc.accountInfo?.tier; - accountPhones = acc.accountInfo?.phones ?? []; + if (res.status === 401) { + this.error('Invalid or expired token. Generate a fresh one in the Dashboard or contact support@linqapp.com'); + } + if (!res.ok) { + this.error('Could not connect to Linq. Please try again later.'); } - } catch { - // pass + const acc = await res.json() as { + partnerId?: string; + orgId?: string; + name?: string | null; + accountInfo?: { + phones: { phoneNumber: string }[]; + accountLabel?: AccountLabel; + } | null; + }; + partnerId = acc.partnerId; + orgId = acc.orgId; + name = acc.name ?? undefined; + accountPhones = acc.accountInfo?.phones ?? []; + accountLabel = acc.accountInfo?.accountLabel; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); } + this.log(`${chalk.green('✓')} Token is valid!\n`); + let fromPhone: string | undefined; - const synapsePhones = (data.phone_numbers || []).map(p => ({ phoneNumber: p.phone_number })); - const phones = accountPhones.length > 0 ? accountPhones : synapsePhones; + const phones = accountPhones; if (phones.length === 1) { fromPhone = phones[0].phoneNumber; } else if (phones.length > 1) { - if ((tier ?? 0) >= 1) { + if (accountLabel === 'Paid') { try { fromPhone = await select({ - message: 'Select a default phone number:', + message: 'Select a default Blue Number:', choices: phones.map(p => ({ name: p.phoneNumber, value: p.phoneNumber })), }); } catch (error) { @@ -151,32 +161,21 @@ export default class Login extends BaseCommand { } } - if (accountPhones.length > 0) { - tenantType = (fromPhone && accountPhones.find(p => p.phoneNumber === fromPhone)?.tenantType) - ?? accountPhones[0].tenantType; - } - await saveProfile(profileName, { token, ...(fromPhone && { fromPhone }), ...(partnerId && { partnerId }), ...(orgId && { orgId }), - ...(tier !== undefined && { tier }), - ...(tenantType && { tenantType }), ...(name && { name }), + accountLabel, }); await setCurrentProfile(profileName); - let accountLabel = ''; - if (tier === 0 && tenantType === 'SINGLE') accountLabel = 'Sandbox Line'; - else if (tier === 0 && tenantType === 'MULTI') accountLabel = 'Shared Line'; - else if ((tier ?? 0) >= 1) accountLabel = 'Paid'; - this.log(chalk.green('✓ Welcome back!\n')); - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - if (fromPhone) this.log(` ${chalk.dim('Phone:')} ${chalk.bold(fromPhone)}`); - if (name) this.log(` ${chalk.dim('Name:')} ${name}`); - this.log(` ${chalk.dim('Profile:')} ${profileName}`); + if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); + if (fromPhone) this.log(` ${chalk.dim('Blue Number:')} ${chalk.bold(fromPhone)}`); + if (name) this.log(` ${chalk.dim('Name:')} ${name}`); + this.log(` ${chalk.dim('Profile:')} ${profileName}`); this.log(''); } } diff --git a/src/commands/messages/delete.ts b/src/commands/messages/delete.ts index f132774..ef5e343 100644 --- a/src/commands/messages/delete.ts +++ b/src/commands/messages/delete.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -49,7 +50,7 @@ export default class MessagesDelete extends BaseCommand { this.log(formatDeleted('Message', args.messageId)); } } catch (e) { - this.error(`Failed to delete message: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/messages/get.ts b/src/commands/messages/get.ts index a3a46bb..bd99e05 100644 --- a/src/commands/messages/get.ts +++ b/src/commands/messages/get.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -47,7 +48,7 @@ export default class MessagesGet extends BaseCommand { this.log(formatMessageDetail(data)); } } catch (e) { - this.error(`Failed to get message: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/messages/list.ts b/src/commands/messages/list.ts index 4d7eeb3..1bb5d73 100644 --- a/src/commands/messages/list.ts +++ b/src/commands/messages/list.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -65,7 +66,7 @@ export default class MessagesList extends BaseCommand { this.log(formatMessagesList(data, chat)); } } catch (e) { - this.error(`Failed to list messages: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/messages/react.ts b/src/commands/messages/react.ts index 6e57d67..f149254 100644 --- a/src/commands/messages/react.ts +++ b/src/commands/messages/react.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -69,14 +70,12 @@ export default class MessagesReact extends BaseCommand { // Validate reaction type if (!REACTION_TYPES.includes(flags.type as ReactionType)) { - this.error( - `Invalid reaction type: ${flags.type}. Valid types: ${REACTION_TYPES.join(', ')}` - ); + bail(this, flags.json, `Invalid reaction type: ${flags.type}. Valid types: ${REACTION_TYPES.join(', ')}`); } // Validate custom emoji is provided when type is custom if (flags.type === 'custom' && !flags.emoji) { - this.error('--emoji is required when using --type custom'); + bail(this, flags.json, '--emoji is required when using --type custom'); } const config = await loadConfig(flags.profile); @@ -97,7 +96,7 @@ export default class MessagesReact extends BaseCommand { this.log(formatReaction(flags.operation!, flags.type, args.messageId)); } } catch (e) { - this.error(`Failed to ${flags.operation} reaction: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/messages/send.ts b/src/commands/messages/send.ts index 46bd1b4..38b55f1 100644 --- a/src/commands/messages/send.ts +++ b/src/commands/messages/send.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -94,7 +95,7 @@ export default class MessagesSend extends BaseCommand { } else if (BUBBLE_EFFECTS.includes(flags.effect)) { effect = { type: 'bubble', name: flags.effect }; } else { - this.error(`Invalid effect: ${flags.effect}. Valid effects: ${ALL_EFFECTS.join(', ')}`); + bail(this, flags.json, `Invalid effect: ${flags.effect}. Valid effects: ${ALL_EFFECTS.join(', ')}`); } } @@ -115,7 +116,7 @@ export default class MessagesSend extends BaseCommand { this.log(formatMessageSent(data)); } } catch (e) { - this.error(`Failed to send message: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/messages/thread.ts b/src/commands/messages/thread.ts index 0197c4a..a3cbba4 100644 --- a/src/commands/messages/thread.ts +++ b/src/commands/messages/thread.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -64,7 +65,7 @@ export default class MessagesThread extends BaseCommand { this.log(formatMessagesList(data)); } } catch (e) { - this.error(`Failed to get thread: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/phonenumbers.ts b/src/commands/phonenumbers.ts index 4345e38..f4ef0a1 100644 --- a/src/commands/phonenumbers.ts +++ b/src/commands/phonenumbers.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../lib/errors.js'; import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; @@ -6,7 +7,7 @@ import { loadConfig, requireToken, saveProfile, saveSandboxProfile, getCurrentPr import { createApiClient } from '../lib/api-client.js'; export default class PhoneNumbers extends BaseCommand { - static override description = 'List your phone numbers or set a default'; + static override description = 'List your Blue Numbers or set a default'; static override examples = [ '<%= config.bin %> <%= command.id %>', @@ -15,7 +16,7 @@ export default class PhoneNumbers extends BaseCommand { static override args = { action: Args.string({ - description: 'Action: "set" to pick a default phone number', + description: 'Action: "set" to pick a default Blue Number', required: false, }), }; @@ -49,11 +50,11 @@ export default class PhoneNumbers extends BaseCommand { const data = await client.phoneNumbers.list(); phones = (data as any).phone_numbers || []; } catch (e) { - this.error(`Failed to list phone numbers: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } if (phones.length === 0) { - this.log('\n No phone numbers found.\n'); + this.log('\n No Blue Numbers found.\n'); return; } @@ -67,7 +68,7 @@ export default class PhoneNumbers extends BaseCommand { return; } - this.log(`\n ${chalk.bold('Your phone numbers')}\n`); + this.log(`\n ${chalk.bold('Your Blue Numbers')}\n`); for (const p of phones) { const isDefault = p.phone_number === config.fromPhone; this.log(` ${chalk.cyan(p.id)} ${this.formatPhone(p.phone_number)}${isDefault ? chalk.green(' ← default') : ''}`); @@ -88,7 +89,7 @@ export default class PhoneNumbers extends BaseCommand { try { const chosen = await select({ - message: 'Select your default phone number:', + message: 'Select your default Blue Number:', choices: phones.map(p => ({ name: p.phone_number === currentDefault ? `${this.formatPhone(p.phone_number)} (current default)` diff --git a/src/commands/profile/create.ts b/src/commands/profile/create.ts index a6869c9..f8f3d22 100644 --- a/src/commands/profile/create.ts +++ b/src/commands/profile/create.ts @@ -28,7 +28,7 @@ export default class ProfileCreate extends BaseCommand { }), 'from-phone': Flags.string({ char: 'f', - description: 'Default sender phone number', + description: 'Default sender Blue Number', }), }; diff --git a/src/commands/profile/get.ts b/src/commands/profile/get.ts index e71d492..7767595 100644 --- a/src/commands/profile/get.ts +++ b/src/commands/profile/get.ts @@ -87,9 +87,6 @@ export default class ProfileGet extends BaseCommand { } private maskToken(token: string): string { - if (token.length <= 8) { - return '****'; - } - return token.slice(0, 4) + '****' + token.slice(-4); + return token.length > 12 ? `${token.slice(0, 12)}...` : token; } } diff --git a/src/commands/signup.ts b/src/commands/signup.ts index be14218..9d42576 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -5,12 +5,15 @@ import { BaseCommand } from '../lib/base-command.js'; import { renderBanner } from '../lib/banner.js'; import { runAuthFlow, checkExistingSession } from '../lib/auth-flow.js'; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + export default class Signup extends BaseCommand { - static override description = 'Create a Linq developer account and get a shared phone line'; + static override description = 'Create a Linq developer account and get a shared Blue Number'; static override examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --email dev@example.com', + '<%= config.bin %> <%= command.id %> --email dev@example.com --code 123456 --name "Dev User"', ]; static override flags = { @@ -18,6 +21,14 @@ export default class Signup extends BaseCommand { char: 'e', description: 'Email address', }), + code: Flags.string({ + char: 'c', + description: 'OTP verification code from your email (skips interactive prompt; pair with --email and --name for non-interactive signup)', + }), + name: Flags.string({ + char: 'n', + description: 'Your name (skips interactive prompt during signup)', + }), }; async run(): Promise { @@ -30,17 +41,27 @@ export default class Signup extends BaseCommand { return; } - await renderBanner(); - console.log('\n Create your Linq developer account\n'); + // Skip the banner for AI agents / scripts (no TTY) or when all the + // signup flags are supplied (fully scripted run). + const nonInteractive = !!(flags.email && flags.code && flags.name); + const showBanner = !nonInteractive && process.stdout.isTTY; + if (showBanner) { + await renderBanner(); + console.log('\n Create your Linq developer account\n'); + } let email = flags.email; - if (!email) { + if (email) { + if (!EMAIL_REGEX.test(email.trim())) { + this.error(`Invalid email: ${email}`); + } + } else { try { email = await input({ message: 'Email address:', validate: (v) => { if (!v.trim()) return 'Email is required'; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())) return 'Enter a valid email'; + if (!EMAIL_REGEX.test(v.trim())) return 'Enter a valid email'; return true; }, }); @@ -53,8 +74,14 @@ export default class Signup extends BaseCommand { } email = email.trim().toLowerCase(); + if (flags.code && !/^\d{6}$/.test(flags.code.trim())) { + this.error(`Invalid --code: must be a 6-digit number`); + } + await runAuthFlow({ email, + code: flags.code?.trim(), + name: flags.name?.trim(), log: (msg) => this.log(msg), exit: (code) => this.exit(code), parseError: (res) => this.parseError(res), diff --git a/src/commands/tokens/create.ts b/src/commands/tokens/create.ts index 4109218..c455938 100644 --- a/src/commands/tokens/create.ts +++ b/src/commands/tokens/create.ts @@ -9,6 +9,7 @@ import { parseExpiresIn, formatExpiresAt, } from '../../lib/tokens-helpers.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; const EXPIRATION_CHOICES = [ { name: '7 days', value: '7d' }, @@ -79,7 +80,7 @@ export default class TokensCreate extends BaseCommand { } } else { if (!name) { - this.error('Missing --name. Required when running non-interactively.'); + bail(this, flags.json, 'Missing --name. Required when running non-interactively.'); } // expiresIn defaults to undefined → no expiration } @@ -88,7 +89,7 @@ export default class TokensCreate extends BaseCommand { try { expiresAt = parseExpiresIn(expiresIn); } catch (e) { - this.error(e instanceof Error ? e.message : String(e)); + bail(this, flags.json, e); } const body: { name: string; expiresAt?: string } = { name }; @@ -104,14 +105,10 @@ export default class TokensCreate extends BaseCommand { }, body: JSON.stringify(body), }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { message?: string }; - this.error(err.message || `Failed to create token (${res.status})`); - } + if (!res.ok) await throwHttpError(res); created = (await res.json()) as TokenSummaryWithSecret; } catch (e) { - if (e instanceof Error && 'oclif' in e) throw e; - this.error('Could not connect to Linq. Please try again later.'); + bail(this, flags.json, e); } if (flags.json) { diff --git a/src/commands/tokens/list.ts b/src/commands/tokens/list.ts index 973c396..64e760a 100644 --- a/src/commands/tokens/list.ts +++ b/src/commands/tokens/list.ts @@ -9,6 +9,7 @@ import { formatLastUsed, findActiveTokenId, } from '../../lib/tokens-helpers.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class TokensList extends BaseCommand { static override description = 'List API tokens for your account'; @@ -34,14 +35,10 @@ export default class TokensList extends BaseCommand { const res = await fetch(`${BACKEND_URL}/v3/api-tokens`, { headers: { Authorization: `Bearer ${token}` }, }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { message?: string }; - this.error(err.message || `Failed to list tokens (${res.status})`); - } + if (!res.ok) await throwHttpError(res); data = (await res.json()) as ListTokensResponse; } catch (e) { - if (e instanceof Error && 'oclif' in e) throw e; - this.error('Could not connect to Linq. Please try again later.'); + bail(this, flags.json, e); } if (flags.json) { diff --git a/src/commands/tokens/regenerate.ts b/src/commands/tokens/regenerate.ts index ececa6e..d480a3e 100644 --- a/src/commands/tokens/regenerate.ts +++ b/src/commands/tokens/regenerate.ts @@ -15,6 +15,7 @@ import { formatExpiresAt, findActiveTokenId, } from '../../lib/tokens-helpers.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class TokensRegenerate extends BaseCommand { static override description = @@ -48,7 +49,7 @@ export default class TokensRegenerate extends BaseCommand { try { expiresAt = parseExpiresIn(flags['expires-in']); } catch (e) { - this.error(e instanceof Error ? e.message : String(e)); + bail(this, flags.json, e); } // Detect whether the target is the active token (so we can auto-update @@ -83,14 +84,10 @@ export default class TokensRegenerate extends BaseCommand { body: JSON.stringify(body), } ); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { message?: string }; - this.error(err.message || `Failed to regenerate token (${res.status})`); - } + if (!res.ok) await throwHttpError(res); created = (await res.json()) as TokenSummaryWithSecret; } catch (e) { - if (e instanceof Error && 'oclif' in e) throw e; - this.error('Could not connect to Linq. Please try again later.'); + bail(this, flags.json, e); } let profileUpdated = false; diff --git a/src/commands/tokens/rename.ts b/src/commands/tokens/rename.ts index 7677861..e812df2 100644 --- a/src/commands/tokens/rename.ts +++ b/src/commands/tokens/rename.ts @@ -4,6 +4,7 @@ import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { BACKEND_URL } from '../../lib/api-client.js'; import { TokenSummary } from '../../lib/tokens-helpers.js'; +import { bail, throwHttpError } from '../../lib/errors.js'; export default class TokensRename extends BaseCommand { static override description = 'Rename an API token'; @@ -29,7 +30,7 @@ export default class TokensRename extends BaseCommand { const callerToken = requireToken(flags.token, config); const newName = flags.name.trim(); - if (!newName) this.error('Name cannot be empty'); + if (!newName) bail(this, flags.json, 'Name cannot be empty'); let updated: TokenSummary; try { @@ -41,14 +42,10 @@ export default class TokensRename extends BaseCommand { }, body: JSON.stringify({ name: newName }), }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { message?: string }; - this.error(err.message || `Failed to rename token (${res.status})`); - } + if (!res.ok) await throwHttpError(res); updated = (await res.json()) as TokenSummary; } catch (e) { - if (e instanceof Error && 'oclif' in e) throw e; - this.error('Could not connect to Linq. Please try again later.'); + bail(this, flags.json, e); } if (flags.json) { diff --git a/src/commands/tokens/show.ts b/src/commands/tokens/show.ts new file mode 100644 index 0000000..3895c3e --- /dev/null +++ b/src/commands/tokens/show.ts @@ -0,0 +1,52 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { loadConfig } from '../../lib/config.js'; +import { copyToClipboard } from '../../lib/clipboard.js'; + +export default class TokensShow extends BaseCommand { + static override description = 'Print the API token currently saved in your local config'; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --copy', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static override flags = { + profile: Flags.string({ char: 'p', description: 'Config profile to read from' }), + copy: Flags.boolean({ description: 'Copy the token to clipboard instead of printing it' }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(TokensShow); + const config = await loadConfig(flags.profile); + + if (!config.token) { + this.log(chalk.yellow(`\n No token saved. Run ${chalk.cyan('linq signup')} or ${chalk.cyan('linq login')}.\n`)); + return; + } + + if (flags.json) { + this.log(JSON.stringify({ token: config.token }, null, 2)); + return; + } + + if (flags.copy) { + const copied = await copyToClipboard(config.token); + if (copied) { + this.log(chalk.green('\n ✓ Token copied to clipboard.\n')); + } else { + this.log(chalk.yellow(`\n Could not access clipboard. Token:\n\n ${chalk.bold(config.token)}\n`)); + } + return; + } + + this.log(''); + this.log(` ${chalk.bold(config.token)}`); + this.log(''); + this.log(chalk.dim(` Tip: ${chalk.cyan('linq tokens show --copy')} copies it to your clipboard.`)); + this.log(''); + } +} diff --git a/src/commands/webhooks/create.ts b/src/commands/webhooks/create.ts index ffacca1..c8e1744 100644 --- a/src/commands/webhooks/create.ts +++ b/src/commands/webhooks/create.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -72,12 +73,12 @@ export default class WebhooksCreate extends BaseCommand { const eventList = flags.events.split(',').map((e) => e.trim()); for (const event of eventList) { if (!WEBHOOK_EVENTS.includes(event as WebhookEventType)) { - this.error(`Invalid event: ${event}. Valid events: ${WEBHOOK_EVENTS.join(', ')}`); + bail(this, flags.json, `Invalid event: ${event}. Valid events: ${WEBHOOK_EVENTS.join(', ')}`); } } subscribedEvents = eventList as WebhookEventType[]; } else { - this.error('Either --events or --all-events is required'); + bail(this, flags.json, 'Either --events or --all-events is required'); } const config = await loadConfig(flags.profile); @@ -96,7 +97,7 @@ export default class WebhooksCreate extends BaseCommand { this.log(formatWebhookDetail(data)); } } catch (e) { - this.error(`Failed to create webhook: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/webhooks/delete.ts b/src/commands/webhooks/delete.ts index fcb860a..c800a59 100644 --- a/src/commands/webhooks/delete.ts +++ b/src/commands/webhooks/delete.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -47,7 +48,7 @@ export default class WebhooksDelete extends BaseCommand { this.log(formatDeleted('Webhook', args.subscriptionId)); } } catch (e) { - this.error(`Failed to delete webhook: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/webhooks/events.ts b/src/commands/webhooks/events.ts index 4af8ab5..bf68d23 100644 --- a/src/commands/webhooks/events.ts +++ b/src/commands/webhooks/events.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; @@ -55,7 +56,7 @@ export default class WebhooksEvents extends BaseCommand { } this.log(''); } catch (e) { - this.error(`Failed to list webhook events: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/webhooks/get.ts b/src/commands/webhooks/get.ts index bb3d7ae..2b86c76 100644 --- a/src/commands/webhooks/get.ts +++ b/src/commands/webhooks/get.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -47,7 +48,7 @@ export default class WebhooksGet extends BaseCommand { this.log(formatWebhookDetail(data)); } } catch (e) { - this.error(`Failed to get webhook: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/webhooks/list.ts b/src/commands/webhooks/list.ts index 78dd109..6919959 100644 --- a/src/commands/webhooks/list.ts +++ b/src/commands/webhooks/list.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -40,7 +41,7 @@ export default class WebhooksList extends BaseCommand { this.log(formatWebhooksList(data)); } } catch (e) { - this.error(`Failed to list webhooks: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index 0f010ba..e740738 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import chalk from 'chalk'; import WebSocket from 'ws'; import { BaseCommand } from '../../lib/base-command.js'; @@ -40,13 +41,13 @@ const DEFAULT_RELAY_URL = 'https://webhook.linqapp.com'; const MAX_RECONNECT_DELAY = 30_000; export default class WebhooksListen extends BaseCommand { - static override description = 'Listen for webhook events and optionally forward to a local server'; + static override description = + 'Stream live webhook events to your terminal. Use --forward-to to also relay them to a local HTTP server.'; static override examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --events message.received,message.sent', '<%= config.bin %> <%= command.id %> --forward-to http://localhost:3000/webhook', - '<%= config.bin %> <%= command.id %> --forward-to http://localhost:3000/webhook --events message.received', '<%= config.bin %> <%= command.id %> --json', ]; @@ -79,9 +80,13 @@ export default class WebhooksListen extends BaseCommand { private eventCount = 0; private forwardCount = 0; private startedAt = Date.now(); + // Cached so private methods (which run outside the run() closure) can + // route errors through the right output mode. + private jsonMode = false; async run(): Promise { const { flags } = await this.parse(WebhooksListen); + this.jsonMode = flags.json; const config = await loadConfig(flags.profile); const token = requireToken(flags.token, config); @@ -373,7 +378,7 @@ export default class WebhooksListen extends BaseCommand { this.log(`Signing secret: ${chalk.dim(this.signingSecret)} ${chalk.dim('(this session only)')}`); } } catch (e) { - this.error(`Failed to create webhook: ${e instanceof Error ? e.message : String(e)}`); + bail(this, this.jsonMode, e); } } diff --git a/src/commands/webhooks/update.ts b/src/commands/webhooks/update.ts index 15cab00..81d620e 100644 --- a/src/commands/webhooks/update.ts +++ b/src/commands/webhooks/update.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import { bail } from '../../lib/errors.js'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -76,7 +77,7 @@ export default class WebhooksUpdate extends BaseCommand { const { args, flags } = await this.parse(WebhooksUpdate); if (!flags.url && !flags.events && !flags.activate && !flags.deactivate) { - this.error('At least one of --url, --events, --activate, or --deactivate is required'); + bail(this, flags.json, 'At least one of --url, --events, --activate, or --deactivate is required'); } // Validate events if provided @@ -85,7 +86,7 @@ export default class WebhooksUpdate extends BaseCommand { const eventList = flags.events.split(',').map((e) => e.trim()); for (const event of eventList) { if (!WEBHOOK_EVENTS.includes(event as WebhookEventType)) { - this.error(`Invalid event: ${event}. Valid events: ${WEBHOOK_EVENTS.join(', ')}`); + bail(this, flags.json, `Invalid event: ${event}. Valid events: ${WEBHOOK_EVENTS.join(', ')}`); } } subscribedEvents = eventList as WebhookEventType[]; @@ -116,7 +117,7 @@ export default class WebhooksUpdate extends BaseCommand { this.log(formatWebhookDetail(data)); } } catch (e) { - this.error(`Failed to update webhook: ${e instanceof Error ? e.message : String(e)}`); + bail(this, flags.json, e); } } } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 7434ff9..322cb8a 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -5,6 +5,7 @@ import { loadConfig, requireToken, isSessionExpired, + getAccountLabel, } from '../lib/config.js'; export default class Whoami extends BaseCommand { @@ -28,16 +29,16 @@ export default class Whoami extends BaseCommand { token = requireToken(flags.token, config); } catch { this.log(chalk.yellow(`\n Not logged in. Run ${chalk.cyan('linq signup')} or ${chalk.cyan('linq login')}.\n`)); - return; + this.exit(1); } - // Check session expiry (only for signup/login users who have sessionExpiresAt) if (config.sessionExpiresAt && isSessionExpired(config)) { this.log(chalk.yellow(`\n Your session has expired. Run ${chalk.cyan('linq login')} to re-authenticate.\n`)); return; } const maskedToken = token.length > 12 ? `${token.slice(0, 12)}...` : token; + const accountLabel = getAccountLabel(config); if (flags.json) { this.log(JSON.stringify({ @@ -45,34 +46,19 @@ export default class Whoami extends BaseCommand { name: config.name, phone: config.fromPhone, apiKey: maskedToken, - tier: config.tier, - tenantType: config.tenantType, + orgId: config.orgId, + partnerId: config.partnerId, + accountLabel, }, null, 2)); return; } this.log(''); - - // Signup/login users have tier set - if (config.tier !== undefined) { - let accountLabel = ''; - if (config.tier === 0 && config.tenantType === 'SINGLE') accountLabel = 'Sandbox Line'; - else if (config.tier === 0 && config.tenantType === 'MULTI') accountLabel = 'Shared Line'; - else if (config.tier >= 1) accountLabel = 'Paid'; - - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - if (config.name) this.log(` ${chalk.dim('Name:')} ${config.name}`); - if (config.email) this.log(` ${chalk.dim('Email:')} ${config.email}`); - if (config.fromPhone) this.log(` ${chalk.dim('Phone:')} ${config.fromPhone}`); - this.log(` ${chalk.dim('API Key:')} ${maskedToken}`); - } else { - // Token-only users (init / paid customers) - if (config.name) this.log(` ${chalk.dim('Name:')} ${config.name}`); - if (config.email) this.log(` ${chalk.dim('Email:')} ${config.email}`); - if (config.fromPhone) this.log(` ${chalk.dim('Phone:')} ${config.fromPhone}`); - this.log(` ${chalk.dim('API Key:')} ${maskedToken}`); - } - + if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); + if (config.name) this.log(` ${chalk.dim('Name:')} ${config.name}`); + if (config.email) this.log(` ${chalk.dim('Email:')} ${config.email} ${chalk.dim('(login)')}`); + if (config.fromPhone) this.log(` ${chalk.dim('Blue Number:')} ${config.fromPhone} ${chalk.dim('(your Linq line)')}`); + this.log(` ${chalk.dim('API Key:')} ${maskedToken}`); this.log(''); } } diff --git a/src/lib/auth-flow.ts b/src/lib/auth-flow.ts index fac2149..77ad98f 100644 --- a/src/lib/auth-flow.ts +++ b/src/lib/auth-flow.ts @@ -9,11 +9,16 @@ import { } from './config.js'; import { BACKEND_URL } from './api-client.js'; import { addBreadcrumb } from './telemetry.js'; +import { copyToClipboard } from './clipboard.js'; const SESSION_DURATION_DAYS = 7; interface AuthFlowOptions { email: string; + // When `code` and `name` are provided we skip the interactive prompts + // so `linq signup` is fully scriptable / AI-agent driven. + code?: string; + name?: string; log: (msg: string) => void; exit: (code: number) => never; parseError: (res: Response) => Promise; @@ -34,45 +39,53 @@ export async function checkExistingSession(): Promise { return null; } -/** - * Shared auth flow for both signup and login. - * 1. send-otp (public) - * 2. verify-code (public; server returns either auth token or a - * one-time signupToken if the email is new) - * 3. signup (gated by signupToken; only fires for new users) - */ export async function runAuthFlow(opts: AuthFlowOptions): Promise { - const { email, log, exit, parseError } = opts; + const { email, code: codeFlag, name: nameFlag, log, exit, parseError } = opts; - // Step 1: Send OTP - ux.action.start('Sending verification code'); + // Step 1: Send OTP. + // Skipped entirely when `--code` was passed — the caller already has a + // code from a prior `linq signup --email ` invocation. Re-sending + // here would mint a new OTP and invalidate the one the user is about + // to verify with. + let sessionId: string | undefined; + if (!codeFlag) { + ux.action.start('Sending verification code'); + try { + const otpRes = await fetch(`${BACKEND_URL}/cli/send-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); - let sessionId: string; - try { - const otpRes = await fetch(`${BACKEND_URL}/cli/send-otp`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }); + if (!otpRes.ok) { + ux.action.stop('failed'); + const err = await parseError(otpRes); + log(chalk.yellow(`\n ${err}\n`)); + exit(1); + } - if (!otpRes.ok) { + const data = (await otpRes.json()) as { sessionId: string }; + sessionId = data.sessionId; + } catch (error) { + if (error instanceof Error && 'oclif' in error) throw error; ux.action.stop('failed'); - const err = await parseError(otpRes); - log(chalk.yellow(`\n ${err}\n`)); + log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); exit(1); + return; } + ux.action.stop('sent!'); + log(` Check ${chalk.bold(email)} for your verification code.\n`); - const data = (await otpRes.json()) as { sessionId: string }; - sessionId = data.sessionId; - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - ux.action.stop('failed'); - log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - exit(1); - return; + // Non-TTY (AI agent, CI, piped stdin) can't drive the interactive + // OTP prompt. Bail cleanly with the exact next command so the caller + // can re-run with --code (and --name) once the user supplies them. + // Skip the bail in test contexts where inquirer prompts are mocked. + if (!process.stdin.isTTY && !process.env.VITEST) { + log(` To complete signup, run:`); + log(` ${chalk.cyan(`linq signup --email ${email} --code <6-digit-code> --name ""`)}\n`); + exit(0); + } } - ux.action.stop('sent!'); - log(` Check ${chalk.bold(email)} for your verification code.\n`); // Step 2: Verify OTP. Reprompt on a bad code instead of exiting. type VerifyResult = { @@ -84,41 +97,57 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { email: string; name?: string; accountInfo?: { - tier: number; - phones: { phoneNumber: string; tenantType: string }[]; + phones: { phoneNumber: string }[]; + accountLabel?: 'Shared' | 'Sandbox' | 'Paid'; } | null; }; let verifyResult: VerifyResult | undefined; while (!verifyResult) { let code: string; - try { - code = await input({ - message: 'Verification code:', - validate: (v) => { - if (!/^\d{6}$/.test(v.trim())) return 'Enter the 6-digit code from your email'; - return true; - }, - }); - } catch (error) { - if (error instanceof Error && error.name === 'ExitPromptError') { - exit(1); + if (codeFlag) { + code = codeFlag; + } else { + try { + code = await input({ + message: 'Verification code:', + validate: (v) => { + if (!/^\d{6}$/.test(v.trim())) return 'Enter the 6-digit code from your email'; + return true; + }, + }); + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + exit(1); + } + throw error; } - throw error; } ux.action.start('Verifying'); try { + // sessionId path when send-otp ran in this process; email path + // when --code was supplied (multi-process / non-interactive flow). + const verifyBody = sessionId + ? { sessionId, code: code.trim() } + : { email, code: code.trim() }; const verifyRes = await fetch(`${BACKEND_URL}/cli/verify-code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, code: code.trim() }), + body: JSON.stringify(verifyBody), }); if (!verifyRes.ok) { ux.action.stop('failed'); const err = await parseError(verifyRes); if (verifyRes.status === 400 || verifyRes.status === 401) { + // Don't loop in non-interactive mode — there's no one to + // re-prompt. Bail out so the caller sees the error and can + // run signup with a fresh --code. + if (codeFlag) { + log(chalk.red(`\n ${err}\n`)); + exit(1); + } log(chalk.yellow(`\n ${err}\n`)); continue; } @@ -154,17 +183,21 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { if (verifyResult.needsSignup) { isNewUser = true; let name: string; - try { - const entered = await input({ - message: 'Your name:', - validate: (v) => (v.trim() ? true : 'Name is required'), - }); - name = entered.trim(); - } catch (error) { - if (error instanceof Error && error.name === 'ExitPromptError') { - exit(1); + if (nameFlag) { + name = nameFlag; + } else { + try { + const entered = await input({ + message: 'Your name:', + validate: (v) => (v.trim() ? true : 'Name is required'), + }); + name = entered.trim(); + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + exit(1); + } + throw error; } - throw error; } ux.action.start('Creating your account'); @@ -206,18 +239,8 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { } const phones = verifyResult.accountInfo?.phones || []; - const tier = verifyResult.accountInfo?.tier ?? 0; - let phoneNumber = ''; - let accountLabel = ''; - - if (phones.length === 1) { - phoneNumber = phones[0].phoneNumber; - if (tier === 0 && phones[0].tenantType === 'SINGLE') accountLabel = 'Sandbox Line'; - else if (tier === 0 && phones[0].tenantType === 'MULTI') accountLabel = 'Shared Line'; - else if (tier >= 1) accountLabel = 'Paid'; - } else if (phones.length > 1) { - accountLabel = tier >= 1 ? 'Paid' : 'Shared Line'; - } + const phoneNumber = phones.length >= 1 ? phones[0].phoneNumber : ''; + const accountLabel = verifyResult.accountInfo?.accountLabel; const sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); await saveProfile('default', { @@ -227,8 +250,7 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { orgId: verifyResult.orgId, email: verifyResult.email, name: verifyResult.name, - tier, - tenantType: phones.length === 1 ? phones[0].tenantType : undefined, + accountLabel, sessionExpiresAt, }); await setCurrentProfile('default'); @@ -237,16 +259,27 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { addBreadcrumb('Account created', { phone: phoneNumber }); log(''); log(chalk.green(' ✓ Account created!\n')); - if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); - log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'pending')}`); - log(` ${chalk.dim('Email:')} ${verifyResult.email}`); - log(` ${chalk.dim('API Key:')} ${chalk.bold(verifyResult.token)}`); + if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); + log(` ${chalk.dim('Blue Number:')} ${chalk.bold(phoneNumber || 'pending')}`); + log(` ${chalk.dim('Email:')} ${verifyResult.email}`); + log(` ${chalk.dim('API Key:')} ${chalk.bold(verifyResult.token)}`); log(''); - log(chalk.yellow(' ⚠ Save this token securely — it will not be shown again.')); + + // Auto-copy on real terminals only — keeps AI-agent / CI logs clean + // and avoids spawning pbcopy/xclip in headless contexts. + const copied = process.stdout.isTTY && verifyResult.token + ? await copyToClipboard(verifyResult.token) + : false; + + if (copied) { + log(chalk.green(' ✓ Copied to clipboard — save it somewhere secure. It will not be shown again.')); + } else { + log(chalk.yellow(' ⚠ Save this token securely — it will not be shown again.')); + } log(''); - if (phoneNumber && accountLabel === 'Shared Line') { - log(' Your number is shared and allows a max of 20 contacts.'); - log(' Start by adding a contact. Your number is inbound-first:'); + if (phoneNumber && accountLabel === 'Shared') { + log(' Your Blue Number is shared and allows a max of 20 contacts.'); + log(' Start by adding a contact. Your Blue Number is inbound-first:'); log(' others text you first and then you can start the conversation.\n'); } log(' Get started:\n'); @@ -259,15 +292,15 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { addBreadcrumb('Login successful', { accountType: accountLabel || 'unknown' }); log(''); log(chalk.green(' ✓ Welcome back!\n')); - if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); + if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); if (phones.length > 1) { - log(` ${chalk.dim('Phone:')} ${chalk.yellow(`${phones.length} phones available`)}`); - log(` Run ${chalk.cyan('linq phonenumbers set')} to pick a default.`); + log(` ${chalk.dim('Blue Number:')} ${chalk.yellow(`${phones.length} Blue Numbers available`)}`); + log(` Run ${chalk.cyan('linq phonenumbers set')} to pick a default.`); } else { - log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'none')}`); + log(` ${chalk.dim('Blue Number:')} ${chalk.bold(phoneNumber || 'none')}`); } - log(` ${chalk.dim('Email:')} ${verifyResult.email}`); - log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); + log(` ${chalk.dim('Email:')} ${verifyResult.email}`); + log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); log(''); } } diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 7c104a5..61ecd03 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -34,10 +34,12 @@ export abstract class BaseCommand extends Command { } } - // All other CLI errors — clean single-line output + // All other CLI errors — clean single-line output. Honor the error's + // own exit code when it specified one (e.g. requireToken uses 1). if (err instanceof Errors.CLIError) { this.log(chalk.red(`\n Error: ${err.message}\n`)); - this.exit(2); + const code = (err as Error & { oclif?: { exit?: number } }).oclif?.exit ?? 2; + this.exit(code); return; } diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..75b2267 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,34 @@ +import { spawn } from 'node:child_process'; + +// Copy `text` to the OS clipboard. Returns true on success, false on +// any failure (binary missing, headless box, permission denied, etc.). +// Never throws — caller can branch on the boolean to adjust messaging. +export async function copyToClipboard(text: string): Promise { + const platform = process.platform; + let cmd: string; + let args: string[] = []; + + if (platform === 'darwin') { + cmd = 'pbcopy'; + } else if (platform === 'win32') { + cmd = 'clip'; + } else { + // Linux/BSD — try xclip first (most common). Wayland users on + // wl-copy will still get false here and fall back to the + // copy-it-yourself message; we keep this dep-free on purpose. + cmd = 'xclip'; + args = ['-selection', 'clipboard']; + } + + return new Promise((resolve) => { + try { + const proc = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] }); + proc.on('error', () => resolve(false)); + proc.on('exit', (code) => resolve(code === 0)); + proc.stdin.write(text); + proc.stdin.end(); + } catch { + resolve(false); + } + }); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index cf4e8fb..959fc4a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -8,6 +8,8 @@ const CONFIG_FILE = 'config.json'; export const SANDBOX_PROFILE = 'sandbox'; +export type AccountLabel = 'Shared' | 'Sandbox' | 'Paid'; + export interface Profile { token?: string; partnerId?: string; @@ -15,11 +17,8 @@ export interface Profile { orgId?: string; email?: string; name?: string; - tier?: number; - tenantType?: string; // SINGLE or MULTI - // Session expiry (local only — NOT token expiry) + accountLabel?: AccountLabel; sessionExpiresAt?: string; - // Legacy fields (migration) expiresAt?: string; githubLogin?: string; } @@ -259,7 +258,7 @@ export function requireToken( ): string { const token = flagToken || config.token; if (!token) { - throw new Errors.CLIError("Not logged in. Run linq signup or linq login first."); + throw new Errors.CLIError("Not logged in. Run linq signup or linq login first.", { exit: 1 }); } return token; } @@ -279,19 +278,19 @@ export function requireFromPhone( // ── Account type helpers ───────────────────────────────────────── +export function getAccountLabel(config: Profile): AccountLabel | undefined { + return config.accountLabel; +} + export const isSandbox = (config: Profile): boolean => - config.tier === 0 && config.tenantType === 'SINGLE'; + getAccountLabel(config) === 'Sandbox'; export const isSharedLine = (config: Profile): boolean => - config.tier === 0 && config.tenantType === 'MULTI'; + getAccountLabel(config) === 'Shared'; export const isPaid = (config: Profile): boolean => - (config.tier ?? 0) >= 1; + getAccountLabel(config) === 'Paid'; -/** - * Throws if the account is not a shared line. - * Used by contacts add/remove/list commands. - */ export function requireSharedLine(config: Profile): void { if (isSandbox(config)) { throw new Errors.CLIError( diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..600d095 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,90 @@ +// Error helpers used by every command. +// +// throwHttpError(res) — throw from a non-2xx Response so the catch +// block can treat it the same as an SDK throw. +// +// bail(cmd, json, input) — exit with a formatted error. Text mode by +// default; JSON when the --json flag is on. + +type CommandLike = { + log: (msg: string) => void; + error: (msg: string) => never; + exit: (code: number) => never; +}; + +interface ErrorFields { + status?: number; + code?: number | string; + message: string; + trace_id?: string; +} + +// Pull the human-readable message out of a server response body, +// regardless of which shape the server used. Returns null if nothing +// usable was found so callers can supply a fallback. +function extractServerMessage(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const b = body as Record; + const nested = b.error; + if (nested && typeof nested === 'object') { + const m = (nested as { message?: unknown }).message; + if (typeof m === 'string') return m; + } + if (typeof b.message === 'string') return b.message; + if (Array.isArray(b.message) && b.message.length > 0 && typeof b.message[0] === 'string') { + return b.message.join('; '); + } + return null; +} + +export async function throwHttpError(res: Response): Promise { + const body = await res.json().catch(() => ({} as unknown)); + const msg = extractServerMessage(body) ?? 'Request failed'; + throw { + status: res.status, + error: body, + headers: res.headers, + message: `${res.status} ${msg}`, + }; +} + +function extract(input: unknown): ErrorFields { + if (typeof input === 'string') return { message: input }; + + if (input && typeof input === 'object') { + const i = input as Record; + + if (typeof i.status === 'number' && i.error && typeof i.error === 'object') { + const body = i.error; + const nested = (body as { error?: { code?: number | string } }).error; + const code = nested && typeof nested === 'object' ? nested.code : undefined; + const trace_id = (body as { trace_id?: string }).trace_id; + return { + status: i.status, + code, + message: extractServerMessage(body) ?? 'Request failed', + trace_id, + }; + } + + const name = (i as { name?: string }).name; + if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') { + return { message: 'Could not reach Linq. Check your connection and try again.' }; + } + + // Generic Error fallback. + if (input instanceof Error) return { message: input.message }; + } + + return { message: String(input) }; +} + +export function bail(cmd: CommandLike, json: boolean | undefined, input: unknown): never { + const fields = extract(input); + if (json) { + cmd.log(JSON.stringify({ error: fields }, null, 2)); + cmd.exit(1); + } + const prefix = fields.status ? `${fields.status} ` : ''; + cmd.error(`${prefix}${fields.message}`); +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 4227983..ee8c602 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -87,9 +87,9 @@ interface PhoneNumberInfo { export function formatPhoneNumbers(data: { phone_numbers: PhoneNumberInfo[] | null }): string { const phones = data.phone_numbers || []; - if (phones.length === 0) return '\n No phone numbers found.\n'; + if (phones.length === 0) return '\n No Blue Numbers found.\n'; - const lines = ['\n ' + chalk.bold('Your phone numbers') + '\n']; + const lines = ['\n ' + chalk.bold('Your Blue Numbers') + '\n']; for (const p of phones) { lines.push(` ${chalk.cyan(p.id)} ${fmtPhone(p.phone_number)}`); } diff --git a/src/lib/webhook-format.ts b/src/lib/webhook-format.ts index 12da2d9..8f3505f 100644 --- a/src/lib/webhook-format.ts +++ b/src/lib/webhook-format.ts @@ -14,6 +14,23 @@ export function truncate(str: string, maxLen: number): string { return str.slice(0, maxLen - 3) + '...'; } +// Extract the readable body from a message `parts` array. Text parts get +// their value joined; non-text parts (media, etc.) get a type marker so +// the log line still conveys that something was attached. +function summarizeParts(parts: unknown[]): string { + const out: string[] = []; + for (const p of parts) { + if (!p || typeof p !== 'object') continue; + const part = p as { type?: string; value?: string }; + if (part.type === 'text' && typeof part.value === 'string') { + out.push(part.value); + } else if (part.type) { + out.push(`[${part.type}]`); + } + } + return out.join(' '); +} + export function flattenObject( obj: Record, prefix: string, @@ -29,6 +46,16 @@ export function flattenObject( } if (Array.isArray(value)) { + // Special-case message `parts` so the log shows the actual text the + // user/contact sent, not just the part count. + if (key === 'parts' && value.length > 0 && typeof value[0] === 'object') { + const body = summarizeParts(value); + if (body) { + const truncated = truncate(body, 80); + pairs.push(`${chalk.dim((prefix ? `${prefix}.body` : 'body') + '=')}"${truncated}"`); + } + continue; + } if (value.length === 0) { pairs.push(`${chalk.dim(fullKey + '=')}[]`); } else if (typeof value[0] === 'object') { diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts index c6057c8..b8ea383 100644 --- a/test/commands/doctor.test.ts +++ b/test/commands/doctor.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Config } from '@oclif/core'; +import { Config, Errors } from '@oclif/core'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import Doctor from '../../src/commands/doctor.js'; +// doctor now exits non-zero when checks fail. Tests here exercise both +// passing and failing scenarios but only assert on logged output, so +// swallow the ExitError that the failing paths raise. +async function runIgnoringExit(cmd: Doctor): Promise { + try { + await cmd.run(); + } catch (e) { + if (!(e instanceof Errors.ExitError)) throw e; + } +} + const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -70,12 +81,12 @@ describe('doctor', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new Doctor([], config); captureOutput(cmd); - await cmd.run(); + await runIgnoringExit(cmd); const output = logs.join('\n'); expect(output).toContain('\u2713 Config file found'); expect(output).toContain('\u2713 API token configured'); - expect(output).toContain('\u2713 Phone number set'); + expect(output).toContain('\u2713 Blue Number set'); expect(output).toContain('\u2713 API connected'); expect(output).toContain('passed'); }); @@ -84,11 +95,11 @@ describe('doctor', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new Doctor([], config); captureOutput(cmd); - await cmd.run(); + await runIgnoringExit(cmd); const output = logs.join('\n'); expect(output).toContain('\u2717 API token not configured'); - expect(output).toContain('\u2717 Phone number not set'); + expect(output).toContain('\u2717 Blue Number not set'); expect(output).not.toContain('0 issues found'); }); @@ -114,7 +125,7 @@ describe('doctor', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new Doctor([], config); captureOutput(cmd); - await cmd.run(); + await runIgnoringExit(cmd); const output = logs.join('\n'); expect(output).toContain('\u2713 API token configured'); @@ -151,10 +162,10 @@ describe('doctor', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new Doctor([], config); captureOutput(cmd); - await cmd.run(); + await runIgnoringExit(cmd); const output = logs.join('\n'); expect(output).not.toContain('my-secret-token-value'); - expect(output).toContain('my-secre••••••••'); + expect(output).toContain('my-secret-to...'); }); }); diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 36a5498..29b6ba1 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -54,8 +54,19 @@ describe('init', () => { mockSelect.mockResolvedValueOnce('default'); // profile selection mockPassword.mockResolvedValueOnce('test-token-123'); + // init now makes a single /cli/account-info call (the prior synapse + // phoneNumbers.list round-trip was removed). The endpoint returns + // the phones plus tier/org info in one shot. mockFetch.mockResolvedValue( - createMockResponse(200, { phone_numbers: [{ phone_number: '+12025551234' }] }) + createMockResponse(200, { + partnerId: 'p1', + orgId: '1', + name: 'Acme', + accountInfo: { + tier: 0, + phones: [{ phoneNumber: '+12025551234', tenantType: 'MULTI' }], + }, + }) ); const config = await Config.load({ root: process.cwd() }); @@ -72,26 +83,18 @@ describe('init', () => { mockPassword.mockResolvedValueOnce('test-token-123'); mockSelect.mockResolvedValueOnce('+18005551234'); // phone selection - mockFetch - .mockResolvedValueOnce(createMockResponse(200, { - phone_numbers: [ - { phone_number: '+12025551234' }, - { phone_number: '+18005551234' }, + mockFetch.mockResolvedValue(createMockResponse(200, { + partnerId: 'p1', + orgId: '1', + name: 'Acme', + accountInfo: { + accountLabel: 'Paid', + phones: [ + { phoneNumber: '+12025551234' }, + { phoneNumber: '+18005551234' }, ], - })) - .mockResolvedValueOnce(createMockResponse(200, { - partnerId: 'p1', - orgId: '1', - name: 'Acme', - accountInfo: { - tier: 1, - phones: [ - { phoneNumber: '+12025551234', tenantType: 'SINGLE' }, - { phoneNumber: '+18005551234', tenantType: 'SINGLE' }, - ], - }, - })) - .mockResolvedValue(createMockResponse(200, { partnerId: 'p1' })); + }, + })); const config = await Config.load({ root: process.cwd() }); const cmd = new Init([], config); @@ -121,7 +124,15 @@ describe('init', () => { mockPassword.mockResolvedValueOnce('work-token'); mockFetch.mockResolvedValue( - createMockResponse(200, { phone_numbers: [{ phone_number: '+18005551234' }] }) + createMockResponse(200, { + partnerId: 'p1', + orgId: '1', + name: 'Work', + accountInfo: { + tier: 0, + phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], + }, + }) ); const config = await Config.load({ root: process.cwd() }); diff --git a/test/commands/login.test.ts b/test/commands/login.test.ts index dd60949..56a180f 100644 --- a/test/commands/login.test.ts +++ b/test/commands/login.test.ts @@ -50,22 +50,19 @@ describe('login (token paste)', () => { } it('--token: validates, fetches account-info, saves full profile', async () => { - // Call order in login.ts: - // 1. client.phoneNumbers.list() — synapse - // 2. fetch /cli/account-info — zero-service (returns partnerId too) - mockFetch - .mockResolvedValueOnce(jsonResponse(200, { - phone_numbers: [{ id: 'pn-1', phone_number: '+18005551234' }], - })) - .mockResolvedValueOnce(jsonResponse(200, { - partnerId: 'partner-1', - orgId: '999', - name: 'Acme', - accountInfo: { - tier: 0, - phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], - }, - })); + // login.ts now makes a single call: /cli/account-info on zero-service. + // That endpoint validates the token (returns 401 if bad) AND returns + // partnerId, orgId, name, and the phone list — so the prior synapse + // phoneNumbers.list() round-trip is gone. + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + tier: 0, + phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], + }, + })); const config = await Config.load({ root: process.cwd() }); const cmd = new Login(['--token', 'linq_test_token', '--profile', 'default'], config); @@ -75,8 +72,6 @@ describe('login (token paste)', () => { expect(saved.profiles.default.token).toBe('linq_test_token'); expect(saved.profiles.default.fromPhone).toBe('+18005551234'); expect(saved.profiles.default.orgId).toBe('999'); - expect(saved.profiles.default.tier).toBe(0); - expect(saved.profiles.default.tenantType).toBe('MULTI'); expect(saved.profiles.default.name).toBe('Acme'); expect(saved.profile).toBe('default'); }); @@ -87,29 +82,22 @@ describe('login (token paste)', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new Login(['--token', 'bad-token', '--profile', 'default'], config); - await expect(cmd.run()).rejects.toThrow(/Invalid token|API error/); + await expect(cmd.run()).rejects.toThrow(/Invalid or expired token/); }); it('shared-line user with multiple phones auto-picks first (no prompt)', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(200, { - phone_numbers: [ - { id: 'pn-1', phone_number: '+18005551111' }, - { id: 'pn-2', phone_number: '+18005552222' }, + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + tier: 0, + phones: [ + { phoneNumber: '+18005551111', tenantType: 'MULTI' }, + { phoneNumber: '+18005552222', tenantType: 'MULTI' }, ], - })) - .mockResolvedValueOnce(jsonResponse(200, { - partnerId: 'partner-1', - orgId: '999', - name: 'Acme', - accountInfo: { - tier: 0, - phones: [ - { phoneNumber: '+18005551111', tenantType: 'MULTI' }, - { phoneNumber: '+18005552222', tenantType: 'MULTI' }, - ], - }, - })); + }, + })); const config = await Config.load({ root: process.cwd() }); const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); @@ -121,25 +109,18 @@ describe('login (token paste)', () => { }); it('paid user with multiple phones is prompted', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(200, { - phone_numbers: [ - { id: 'pn-1', phone_number: '+18005551111' }, - { id: 'pn-2', phone_number: '+18005552222' }, + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + accountLabel: 'Paid', + phones: [ + { phoneNumber: '+18005551111' }, + { phoneNumber: '+18005552222' }, ], - })) - .mockResolvedValueOnce(jsonResponse(200, { - partnerId: 'partner-1', - orgId: '999', - name: 'Acme', - accountInfo: { - tier: 1, - phones: [ - { phoneNumber: '+18005551111', tenantType: 'SINGLE' }, - { phoneNumber: '+18005552222', tenantType: 'SINGLE' }, - ], - }, - })); + }, + })); mockSelect.mockResolvedValueOnce('+18005552222'); @@ -150,6 +131,6 @@ describe('login (token paste)', () => { 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.tier).toBe(1); + expect(saved.profiles.default.accountLabel).toBe('Paid'); }); }); diff --git a/test/commands/signup.test.ts b/test/commands/signup.test.ts index 7857996..dbc6d43 100644 --- a/test/commands/signup.test.ts +++ b/test/commands/signup.test.ts @@ -68,8 +68,8 @@ describe('signup (email OTP flow)', () => { email: 'new@example.com', name: 'Test User', accountInfo: { - tier: 0, - phones: [{ phoneNumber: '+12025551234', tenantType: 'MULTI' }], + accountLabel: 'Shared', + phones: [{ phoneNumber: '+12025551234' }], }, }) ); @@ -89,8 +89,7 @@ describe('signup (email OTP flow)', () => { expect(saved.profiles.default.token).toBe('api-token-xyz'); expect(saved.profiles.default.email).toBe('new@example.com'); expect(saved.profiles.default.fromPhone).toBe('+12025551234'); - expect(saved.profiles.default.tier).toBe(0); - expect(saved.profiles.default.tenantType).toBe('MULTI'); + expect(saved.profiles.default.accountLabel).toBe('Shared'); }); it('refuses to run if already logged in', async () => {