From c5590b20ebe82ae102eb91864fff2a0e86b0aaec Mon Sep 17 00:00:00 2001 From: Abhiram Date: Tue, 14 Apr 2026 11:18:33 -0500 Subject: [PATCH 1/6] feat: webhooks listen/logs, phonenumbers set, voicememo updates - webhooks listen: WebSocket relay with --forward-to for local dev - webhooks logs: view recent webhook delivery logs - webhooks events: improved output formatting - phonenumbers set: pick default phone for MULTI lines - voicememo: added from flag support --- src/commands/chats/voicememo.ts | 14 +- src/commands/phonenumbers.ts | 105 +++++++++++-- src/commands/webhooks/events.ts | 27 +++- src/commands/webhooks/listen.ts | 147 ++++++++++++++++--- src/commands/webhooks/logs.ts | 251 ++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 33 deletions(-) create mode 100644 src/commands/webhooks/logs.ts diff --git a/src/commands/chats/voicememo.ts b/src/commands/chats/voicememo.ts index 8f9f27d..ed5227e 100644 --- a/src/commands/chats/voicememo.ts +++ b/src/commands/chats/voicememo.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -22,6 +23,13 @@ export default class ChatsVoicememo extends BaseCommand { description: 'URL of the voice memo audio file', required: true, }), + from: Flags.string({ + description: 'Sender phone number (E.164 format). Uses config fromPhone if not specified.', + }), + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), profile: Flags.string({ char: 'p', description: 'Config profile to use', @@ -44,7 +52,11 @@ 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)); + } else { + this.log(chalk.green(`\n \u2713 Voice memo sent to chat ${args.chatId}\n`)); + } } catch (e) { this.error(`Failed to send voice memo: ${e instanceof Error ? e.message : String(e)}`); } diff --git a/src/commands/phonenumbers.ts b/src/commands/phonenumbers.ts index d8b620b..ba8d265 100644 --- a/src/commands/phonenumbers.ts +++ b/src/commands/phonenumbers.ts @@ -1,13 +1,24 @@ -import { Flags } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; +import { select } from '@inquirer/prompts'; +import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; -import { loadConfig, requireToken } from '../lib/config.js'; +import { loadConfig, requireToken, saveProfile, saveSandboxProfile, getCurrentProfile, SANDBOX_PROFILE } from '../lib/config.js'; import { createApiClient } from '../lib/api-client.js'; -import { formatPhoneNumbers } from '../lib/format.js'; export default class PhoneNumbers extends BaseCommand { - static override description = 'List your available phone numbers'; + static override description = 'List your phone numbers or set a default'; - static override examples = ['<%= config.bin %> <%= command.id %>']; + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> set', + ]; + + static override args = { + action: Args.string({ + description: 'Action: "set" to pick a default phone number', + required: false, + }), + }; static override flags = { json: Flags.boolean({ @@ -17,31 +28,99 @@ export default class PhoneNumbers extends BaseCommand { profile: Flags.string({ char: 'p', description: 'Config profile to use', + hidden: true, }), token: Flags.string({ char: 't', description: 'API token (overrides stored token)', + hidden: true, }), }; async run(): Promise { - const { flags } = await this.parse(PhoneNumbers); + const { args, flags } = await this.parse(PhoneNumbers); const config = await loadConfig(flags.profile); const token = requireToken(flags.token, config); - const client = createApiClient(token); + let phones: { phone_number: string }[]; try { const data = await client.phoneNumbers.list(); - - if (flags.json) { - this.log(JSON.stringify(data, null, 2)); - } else { - this.log(formatPhoneNumbers(data)); - } + phones = (data as any).phone_numbers || []; } catch (e) { this.error(`Failed to list phone numbers: ${e instanceof Error ? e.message : String(e)}`); } + + if (phones.length === 0) { + this.log('\n No phone numbers found.\n'); + return; + } + + if (args.action === 'set') { + await this.setDefault(phones, config.fromPhone, flags.profile); + return; + } + + if (flags.json) { + this.log(JSON.stringify({ phone_numbers: phones }, null, 2)); + return; + } + + this.log(`\n ${chalk.bold('Your phone numbers')}\n`); + for (const p of phones) { + const isDefault = p.phone_number === config.fromPhone; + this.log(` ${this.formatPhone(p.phone_number)}${isDefault ? chalk.green(' ← default') : ''}`); + } + if (!config.fromPhone && phones.length > 1) { + this.log(`\n ${chalk.dim('Tip: run')} ${chalk.cyan('linq phonenumbers set')} ${chalk.dim('to pick a default.')}`); + } + this.log(''); + } + + private async setDefault(phones: { phone_number: string }[], currentDefault: string | undefined, profileFlag: string | undefined): Promise { + if (phones.length === 1) { + const phone = phones[0].phone_number; + await this.saveFromPhone(phone, profileFlag); + this.log(chalk.green(`\n ✓ Default set to ${this.formatPhone(phone)}\n`)); + return; + } + + try { + const chosen = await select({ + message: 'Select your default phone number:', + choices: phones.map(p => ({ + name: p.phone_number === currentDefault + ? `${this.formatPhone(p.phone_number)} (current default)` + : this.formatPhone(p.phone_number), + value: p.phone_number, + })), + }); + + await this.saveFromPhone(chosen, profileFlag); + this.log(chalk.green(`\n ✓ Default set to ${this.formatPhone(chosen)}\n`)); + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + return; + } + throw error; + } + } + + private async saveFromPhone(phone: string, profileFlag: string | undefined): Promise { + const profileName = profileFlag || await getCurrentProfile() || 'default'; + const existing = await loadConfig(profileFlag); + const merged = { ...existing, fromPhone: phone }; + if (profileName === SANDBOX_PROFILE) { + await saveSandboxProfile(merged); + } else { + await saveProfile(profileName, merged); + } + } + + private formatPhone(phone: string): string { + const match = phone.match(/^\+1(\d{3})(\d{3})(\d{4})$/); + if (match) return `+1 (${match[1]}) ${match[2]}-${match[3]}`; + return phone; } } diff --git a/src/commands/webhooks/events.ts b/src/commands/webhooks/events.ts index f88f2d0..4af8ab5 100644 --- a/src/commands/webhooks/events.ts +++ b/src/commands/webhooks/events.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -9,6 +10,10 @@ export default class WebhooksEvents extends BaseCommand { static override examples = ['<%= config.bin %> <%= command.id %>']; static override flags = { + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), profile: Flags.string({ char: 'p', description: 'Config profile to use', @@ -28,7 +33,27 @@ export default class WebhooksEvents extends BaseCommand { try { const data = await client.webhookEvents.list(); - this.log(JSON.stringify(data, null, 2)); + + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + return; + } + + const events = (data as any).events || []; + if (events.length === 0) { + this.log('\n No webhook event types available.\n'); + return; + } + + this.log(`\n ${chalk.bold('Available webhook events')}\n`); + for (const event of events) { + if (typeof event === 'string') { + this.log(` ${event}`); + } else { + this.log(` ${event.name || event.type || event}`); + } + } + this.log(''); } catch (e) { this.error(`Failed to list webhook events: ${e instanceof Error ? e.message : String(e)}`); } diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index 431acc1..b1b7bc3 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,8 +1,10 @@ import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; import { formatLogLine } from '../../lib/webhook-format.js'; +import * as crypto from 'node:crypto'; import type Linq from '@linqapp/sdk'; type WebhookEventType = Linq.WebhookSubscriptions.WebhookSubscriptionCreateParams['subscribed_events'][number]; @@ -37,19 +39,23 @@ 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'; + static override description = 'Listen for webhook events and optionally forward to a local 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', ]; static override flags = { events: Flags.string({ - description: - 'Comma-separated list of events to subscribe to (default: all)', + description: 'Comma-separated list of events to subscribe to (default: all). Run `linq webhooks events` to see available events.', + }), + 'forward-to': Flags.string({ + char: 'f', + description: 'Forward events to a local URL (e.g. http://localhost:3000/webhook)', }), profile: Flags.string({ description: 'Config profile to use', @@ -65,9 +71,13 @@ export default class WebhooksListen extends BaseCommand { }; private webhookId: string | null = null; + private signingSecret: string | null = null; private client: ReturnType | null = null; private ws: WebSocket | null = null; private shuttingDown = false; + private eventCount = 0; + private forwardCount = 0; + private startedAt = Date.now(); async run(): Promise { const { flags } = await this.parse(WebhooksListen); @@ -76,16 +86,17 @@ export default class WebhooksListen extends BaseCommand { const token = requireToken(flags.token, config); this.client = createApiClient(token); + const forwardTo = flags['forward-to']; + // Validate events if specified let subscribedEvents: WebhookEventType[]; if (flags.events) { 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(', ')}` - ); - } + const invalid = eventList.filter((e) => !WEBHOOK_EVENTS.includes(e as WebhookEventType)); + if (invalid.length > 0) { + this.log(chalk.red(`\n Unknown event${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}\n`)); + this.log(` Run ${chalk.cyan('linq webhooks events')} to see available event types.\n`); + this.exit(1); } subscribedEvents = eventList as WebhookEventType[]; } else { @@ -94,6 +105,17 @@ export default class WebhooksListen extends BaseCommand { const eventFilter = flags.events?.split(',').map((e) => e.trim()) || null; + // Validate forward URL if specified + if (forwardTo) { + try { + new URL(forwardTo); + } catch { + this.log(chalk.red(`\n Invalid forward URL: ${forwardTo}\n`)); + this.log(` Example: ${chalk.cyan('--forward-to http://localhost:3000/webhook')}\n`); + this.exit(1); + } + } + const wsUrl = process.env.LINQ_RELAY_WS_URL || DEFAULT_WS_URL; const relayUrl = process.env.LINQ_RELAY_URL || DEFAULT_RELAY_URL; @@ -129,11 +151,15 @@ export default class WebhooksListen extends BaseCommand { await this.createWebhook(webhookTarget, subscribedEvents); this.log(''); - this.log('Listening for events... (Ctrl+C to stop)'); + this.log(`Listening for events... ${chalk.dim('(Ctrl+C to stop)')}`); + this.log(`Webhook ID: ${chalk.dim(this.webhookId || '–')}`); + if (forwardTo) { + this.log(`Forwarding to: ${chalk.cyan(forwardTo)}`); + } this.log(''); // Listen for events with auto-reconnect - await this.listenLoop(wsUrl, token, relayUrl, subscribedEvents, eventFilter, flags.json); + await this.listenLoop(wsUrl, token, relayUrl, subscribedEvents, eventFilter, flags.json, forwardTo); } catch (error) { if (this.shuttingDown) return; await this.cleanup(); @@ -188,11 +214,12 @@ export default class WebhooksListen extends BaseCommand { relayUrl: string, subscribedEvents: WebhookEventType[], eventFilter: string[] | null, - jsonOutput: boolean + jsonOutput: boolean, + forwardTo?: string, ): Promise { let reconnectDelay = 1000; - this.setupMessageHandler(eventFilter, jsonOutput); + this.setupMessageHandler(eventFilter, jsonOutput, forwardTo); while (!this.shuttingDown) { // Wait for WebSocket to close @@ -221,7 +248,7 @@ export default class WebhooksListen extends BaseCommand { const webhookTarget = `${relayUrl}/relay/${connectionId}`; await this.updateWebhookTarget(webhookTarget); - this.setupMessageHandler(eventFilter, jsonOutput); + this.setupMessageHandler(eventFilter, jsonOutput, forwardTo); } catch (error) { if (this.shuttingDown) return; this.log(`Reconnect failed: ${(error as Error).message}`); @@ -229,7 +256,11 @@ export default class WebhooksListen extends BaseCommand { } } - private setupMessageHandler(eventFilter: string[] | null, jsonOutput: boolean): void { + private setupMessageHandler( + eventFilter: string[] | null, + jsonOutput: boolean, + forwardTo?: string, + ): void { if (!this.ws) return; this.ws.addEventListener('message', (event) => { @@ -245,7 +276,12 @@ export default class WebhooksListen extends BaseCommand { return; } - if (jsonOutput) { + this.eventCount++; + + // Forward to local server if configured + if (forwardTo) { + this.forwardEvent(forwardTo, raw, data, jsonOutput); + } else if (jsonOutput) { this.log(JSON.stringify(data, null, 2)); } else { this.log(formatLogLine(data)); @@ -256,6 +292,68 @@ export default class WebhooksListen extends BaseCommand { }); } + private async forwardEvent( + forwardTo: string, + rawPayload: string, + data: WebhookEvent, + jsonOutput: boolean, + ): Promise { + const timestamp = String(Math.floor(Date.now() / 1000)); + const eventType = data.event_type || 'unknown'; + + // Compute signature if we have a signing secret + let signature = ''; + if (this.signingSecret) { + const signedData = `${timestamp}.${rawPayload}`; + signature = crypto + .createHmac('sha256', this.signingSecret) + .update(signedData) + .digest('hex'); + } + + const start = Date.now(); + try { + const res = await fetch(forwardTo, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Event': eventType, + 'X-Webhook-Timestamp': timestamp, + ...(signature && { 'X-Webhook-Signature': signature }), + ...(this.webhookId && { 'X-Webhook-Subscription-ID': this.webhookId }), + }, + body: rawPayload, + }); + + const duration = Date.now() - start; + this.forwardCount++; + + const statusColor = res.ok ? chalk.green : chalk.red; + const statusText = `${res.status} ${res.statusText || (res.ok ? 'OK' : 'Error')}`; + + if (jsonOutput) { + this.log(JSON.stringify({ ...data, _forward: { status: res.status, duration_ms: duration } }, null, 2)); + } else { + const time = chalk.dim(new Date().toLocaleTimeString()); + this.log( + `${time} ${chalk.dim(eventType.padEnd(30))} ${chalk.dim('→')} ${chalk.dim(forwardTo)} [${statusColor(statusText)}] ${chalk.dim(`(${duration}ms`)})` + ); + } + } catch (error) { + const duration = Date.now() - start; + const errMsg = error instanceof Error ? error.message : String(error); + + if (jsonOutput) { + this.log(JSON.stringify({ ...data, _forward: { error: errMsg, duration_ms: duration } }, null, 2)); + } else { + const time = chalk.dim(new Date().toLocaleTimeString()); + this.log( + `${time} ${chalk.dim(eventType.padEnd(30))} ${chalk.dim('→')} ${chalk.dim(forwardTo)} [${chalk.red('FAILED')}] ${chalk.dim(errMsg)}` + ); + } + } + } + private async createWebhook( webhookUrl: string, subscribedEvents: WebhookEventType[] @@ -267,9 +365,12 @@ export default class WebhooksListen extends BaseCommand { }); this.webhookId = data.id; + this.signingSecret = (data as any).signing_secret || null; this.log(`Webhook created: ${data.id}`); - this.log(`URL: ${data.target_url}`); this.log(`Events: ${data.subscribed_events.join(', ')}`); + if (this.signingSecret) { + 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)}`); } @@ -299,6 +400,16 @@ export default class WebhooksListen extends BaseCommand { this.ws.close(); } + // Show session stats + const uptime = Math.round((Date.now() - this.startedAt) / 1000); + const mins = Math.floor(uptime / 60); + const secs = uptime % 60; + const stats = [`${this.eventCount} events received`]; + if (this.forwardCount > 0) { + stats.push(`${this.forwardCount} forwarded`); + } + this.log(`\n${chalk.dim(`Session: ${stats.join(', ')} in ${mins}m ${secs}s`)}`); + // Delete webhook subscription if (this.webhookId && this.client) { try { diff --git a/src/commands/webhooks/logs.ts b/src/commands/webhooks/logs.ts new file mode 100644 index 0000000..d9e49b3 --- /dev/null +++ b/src/commands/webhooks/logs.ts @@ -0,0 +1,251 @@ +import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { loadConfig, requireToken } from '../../lib/config.js'; + +const V3_LOGS_URL = process.env.LINQ_LOGS_URL || 'https://v3-logs.linqapp.com/api'; +const POLL_INTERVAL_MS = 3000; + +interface WebhookLog { + event_id: string; + webhook_event_type: string; + target_url: string; + http_status_code: number; + duration_ms: number; + timestamp: string; + status: string; + attempt: number; + max_attempts: number; + error_type: string; + error_message: string; + trace_id: string; + subscription_id: string; +} + +interface WebhookLogsResponse { + webhook_logs: WebhookLog[]; + pagination: { + has_more: boolean; + next_cursor: string | null; + per_page: number; + }; +} + +export default class WebhooksLogs extends BaseCommand { + static override description = 'View delivery logs for a webhook subscription'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> SUBSCRIPTION_ID', + '<%= config.bin %> <%= command.id %> SUBSCRIPTION_ID --tail', + '<%= config.bin %> <%= command.id %> SUBSCRIPTION_ID --status failed', + '<%= config.bin %> <%= command.id %> SUBSCRIPTION_ID --last 50', + ]; + + static override args = { + subscriptionId: Args.string({ + description: 'Webhook subscription ID', + required: true, + }), + }; + + static override flags = { + tail: Flags.boolean({ + description: 'Continuously poll for new logs', + default: false, + }), + last: Flags.integer({ + description: 'Number of recent logs to show (default: 20)', + default: 20, + }), + status: Flags.string({ + description: 'Filter by status', + options: ['success', 'failed', 'retrying', 'all'], + default: 'all', + }), + event: Flags.string({ + description: 'Filter by event type (e.g. message.received)', + }), + profile: Flags.string({ + char: 'p', + description: 'Config profile to use', + }), + token: Flags.string({ + char: 't', + description: 'API token', + }), + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), + }; + + private stopped = false; + + async run(): Promise { + const { args, flags } = await this.parse(WebhooksLogs); + + const config = await loadConfig(flags.profile); + const token = requireToken(flags.token, config); + + const orgId = config.orgId; + if (!orgId) { + this.log(chalk.yellow('\n Organization ID not found in your profile.')); + this.log(` Run ${chalk.cyan('linq login')} to refresh your credentials.\n`); + this.exit(1); + } + + if (flags.tail) { + await this.tailLogs(args.subscriptionId, orgId, token, flags); + } else { + await this.fetchLogs(args.subscriptionId, orgId, token, flags); + } + } + + private async fetchLogs( + subscriptionId: string, + orgId: string, + token: string, + flags: { last: number; status: string; event?: string; json: boolean }, + ): Promise { + const data = await this.queryLogs(subscriptionId, orgId, token, flags.last, flags.status, flags.event); + + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + return; + } + + if (data.webhook_logs.length === 0) { + this.log('\n No delivery logs found for this subscription.\n'); + return; + } + + this.log(''); + for (const log of data.webhook_logs) { + this.printLog(log); + } + this.log(''); + } + + private async tailLogs( + subscriptionId: string, + orgId: string, + token: string, + flags: { status: string; event?: string; json: boolean }, + ): Promise { + const shutdown = () => { + this.stopped = true; + this.log(chalk.dim('\nStopped.')); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + this.log(`\n Tailing logs for subscription ${chalk.dim(subscriptionId)}... ${chalk.dim('(Ctrl+C to stop)')}\n`); + + const seenIds = new Set(); + + // Fetch initial batch to seed seenIds + const initial = await this.queryLogs(subscriptionId, orgId, token, 10, flags.status, flags.event); + for (const log of initial.webhook_logs) { + seenIds.add(log.event_id + ':' + log.attempt); + if (!flags.json) { + this.printLog(log); + } else { + this.log(JSON.stringify(log)); + } + } + + // Poll for new logs + while (!this.stopped) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + if (this.stopped) break; + + try { + const data = await this.queryLogs(subscriptionId, orgId, token, 20, flags.status, flags.event); + + for (const log of data.webhook_logs) { + const key = log.event_id + ':' + log.attempt; + if (seenIds.has(key)) continue; + seenIds.add(key); + + if (flags.json) { + this.log(JSON.stringify(log)); + } else { + this.printLog(log); + } + } + + // Prevent seenIds from growing unbounded + if (seenIds.size > 5000) { + const entries = [...seenIds]; + for (let i = 0; i < 2500; i++) { + seenIds.delete(entries[i]); + } + } + } catch { + // Silently skip failed polls, will retry on next interval + } + } + } + + private async queryLogs( + subscriptionId: string, + orgId: string, + token: string, + limit: number, + status: string, + event?: string, + ): Promise { + const params = new URLSearchParams(); + params.set('partner_id', orgId); + params.set('org_id', orgId); + params.set('subscription_id', subscriptionId); + params.set('per_page', String(Math.min(limit, 50))); + if (status && status !== 'all') params.set('status', status); + if (event) params.set('event_type', event); + + const res = await fetch(`${V3_LOGS_URL}/v1/webhook-logs?${params}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`${res.status}: ${err}`); + } + + return (await res.json()) as WebhookLogsResponse; + } + + private printLog(log: WebhookLog): void { + const time = chalk.dim(new Date(log.timestamp).toLocaleString()); + const event = log.webhook_event_type.padEnd(30); + + let statusStr: string; + switch (log.status) { + case 'success': + statusStr = chalk.green(`${log.http_status_code} OK`); + break; + case 'failed': + statusStr = chalk.red(`${log.http_status_code || 'ERR'} Failed`); + break; + case 'retrying': + statusStr = chalk.yellow(`${log.http_status_code || '---'} Retry ${log.attempt}/${log.max_attempts}`); + break; + default: + statusStr = String(log.http_status_code || log.status); + } + + const duration = chalk.dim(`${log.duration_ms}ms`); + + this.log(` ${time} ${event} ${statusStr} ${duration}`); + + if (log.error_message && log.status !== 'success') { + this.log(` ${chalk.dim(' ' + log.error_message)}`); + } + } +} From ffaa75ad0ae9b4cb4eadeeb0072c48e47b5a9649 Mon Sep 17 00:00:00 2001 From: Abhiram Date: Tue, 14 Apr 2026 11:24:47 -0500 Subject: [PATCH 2/6] fix: update listen test for new error handling --- test/commands/webhooks/listen.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/webhooks/listen.test.ts b/test/commands/webhooks/listen.test.ts index cf824c5..a3ea1dc 100644 --- a/test/commands/webhooks/listen.test.ts +++ b/test/commands/webhooks/listen.test.ts @@ -296,7 +296,7 @@ describe('webhooks listen', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new WebhooksListen(['--events', 'invalid.event'], config); captureOutput(cmd); - await expect(cmd.run()).rejects.toThrow('Invalid event: invalid.event'); + await expect(cmd.run()).rejects.toThrow('EEXIT: 1'); }); it('requires authentication token', async () => { From 520f624f72579cef9a1a0fd023e17322aa7de4dd Mon Sep 17 00:00:00 2001 From: Abhiram Date: Tue, 14 Apr 2026 14:18:52 -0500 Subject: [PATCH 3/6] fix: import ws for Node 20 WebSocket compatibility --- src/commands/webhooks/listen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index b1b7bc3..0f010ba 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,5 +1,6 @@ import { Flags } from '@oclif/core'; import chalk from 'chalk'; +import WebSocket from 'ws'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; From 9a5b8992919717cd37b46962fecabbb1841d9fe9 Mon Sep 17 00:00:00 2001 From: Abhiram Date: Tue, 14 Apr 2026 14:25:28 -0500 Subject: [PATCH 4/6] revert: remove voicememo from flag change (separate PR) --- src/commands/chats/voicememo.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/commands/chats/voicememo.ts b/src/commands/chats/voicememo.ts index ed5227e..8f9f27d 100644 --- a/src/commands/chats/voicememo.ts +++ b/src/commands/chats/voicememo.ts @@ -1,5 +1,4 @@ import { Args, Flags } from '@oclif/core'; -import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; @@ -23,13 +22,6 @@ export default class ChatsVoicememo extends BaseCommand { description: 'URL of the voice memo audio file', required: true, }), - from: Flags.string({ - description: 'Sender phone number (E.164 format). Uses config fromPhone if not specified.', - }), - json: Flags.boolean({ - description: 'Output as JSON', - default: false, - }), profile: Flags.string({ char: 'p', description: 'Config profile to use', @@ -52,11 +44,7 @@ export default class ChatsVoicememo extends BaseCommand { voice_memo_url: flags.url, }); - if (flags.json) { - this.log(JSON.stringify(data, null, 2)); - } else { - this.log(chalk.green(`\n \u2713 Voice memo sent to chat ${args.chatId}\n`)); - } + this.log(JSON.stringify(data, null, 2)); } catch (e) { this.error(`Failed to send voice memo: ${e instanceof Error ? e.message : String(e)}`); } From de62ea7a61fd78f1667548491c058780e0dcbc2d Mon Sep 17 00:00:00 2001 From: Abhiram Date: Tue, 14 Apr 2026 15:27:25 -0500 Subject: [PATCH 5/6] fix: use rolling eviction for dedup set instead of bulk clear --- src/commands/webhooks/logs.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/commands/webhooks/logs.ts b/src/commands/webhooks/logs.ts index d9e49b3..696b380 100644 --- a/src/commands/webhooks/logs.ts +++ b/src/commands/webhooks/logs.ts @@ -169,20 +169,16 @@ export default class WebhooksLogs extends BaseCommand { if (seenIds.has(key)) continue; seenIds.add(key); + if (seenIds.size > 5000) { + seenIds.delete(seenIds.values().next().value!); + } + if (flags.json) { this.log(JSON.stringify(log)); } else { this.printLog(log); } } - - // Prevent seenIds from growing unbounded - if (seenIds.size > 5000) { - const entries = [...seenIds]; - for (let i = 0; i < 2500; i++) { - seenIds.delete(entries[i]); - } - } } catch { // Silently skip failed polls, will retry on next interval } From 7abe9c9fe35ad8c5120f32c5da6b951f73be9973 Mon Sep 17 00:00:00 2001 From: abhiramkast Date: Tue, 14 Apr 2026 23:47:49 -0500 Subject: [PATCH 6/6] chore: command audit, error handling cleanup, and test updates (#30) * chore: command audit, error handling cleanup, and test updates - chats create: dynamic error messages based on account type, telemetry - format.ts: improved output formatting across commands - doctor.ts: updated diagnostics - init.ts: cleanup - All commands: consistent error handling, clean flag descriptions - Test updates for new error messages and SDK methods - Removed dead signup tests (replaced by OTP flow) * refactor: extract shared auth flow into auth-flow.ts Login and signup now call the same runAuthFlow() function. Both handle existing users (welcome back) and new users (provision) identically. Removes duplicate code. * feat: session check, whoami cleanup, unhide init/profile, attachment-url - auth-flow: check existing session before signup/login, block if logged in - auth-flow: remove phone number prompt (unused for shared lines) - whoami: only show fields that exist, no misleading "phone not set" - unhide init and all profile commands - config: remove dead sandbox profile check from saveProfile - chats create + messages send: add --attachment-url flag --- linqapp-cli-1.3.0.tgz | Bin 0 -> 79084 bytes src/commands/chats/create.ts | 34 ++- src/commands/chats/participants/add.ts | 11 +- src/commands/chats/participants/remove.ts | 11 +- src/commands/doctor.ts | 120 +++++---- src/commands/init.ts | 26 +- src/commands/login.ts | 227 +--------------- src/commands/messages/send.ts | 25 +- src/commands/profile/create.ts | 2 +- src/commands/profile/list.ts | 5 +- src/commands/signup.ts | 309 +--------------------- src/commands/whoami.ts | 56 ++-- src/lib/auth-flow.ts | 277 +++++++++++++++++++ src/lib/config.ts | 4 - src/lib/format.ts | 144 ++++++++-- test/commands/doctor.test.ts | 20 +- test/commands/messages/send.test.ts | 8 +- 17 files changed, 615 insertions(+), 664 deletions(-) create mode 100644 linqapp-cli-1.3.0.tgz create mode 100644 src/lib/auth-flow.ts diff --git a/linqapp-cli-1.3.0.tgz b/linqapp-cli-1.3.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..1304b88e57127b1840373afe789fe0a47ae9e712 GIT binary patch literal 79084 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

_SqLiI4JIxX<5~Mtm_NA<;B6F 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%0A=-5GEr_ks+u28Fcu&+MzB+jkSH+%AO$y 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 <%= command.id %> --to +19876543210 --from +12025551234 --message "Hello"', '<%= config.bin %> <%= command.id %> --to +19876543210 --message "Party!" --effect confetti', '<%= config.bin %> <%= command.id %> --to +1111111111 --to +2222222222 --message "Group chat"', - '<%= config.bin %> <%= command.id %> --to +19876543210 --message "Hello" --profile work', ]; static override flags = { @@ -51,6 +52,9 @@ export default class ChatsCreate extends BaseCommand { description: 'Message text to send', required: true, }), + 'attachment-url': Flags.string({ + description: 'URL of an uploaded attachment (from linq attachments upload)', + }), effect: Flags.string({ description: `iMessage effect (${ALL_EFFECTS.join(', ')})`, }), @@ -77,10 +81,13 @@ export default class ChatsCreate extends BaseCommand { const client = createApiClient(token); // Build message parts - const textPart: MessagePart = { - type: 'text', - value: flags.message, - }; + const parts: MessagePart[] = [ + { type: 'text', value: flags.message }, + ]; + + if (flags['attachment-url']) { + parts.push({ type: 'media', url: flags['attachment-url'] } as MessagePart); + } // Build effect if specified let effect: MessageEffect | undefined; @@ -99,17 +106,30 @@ export default class ChatsCreate extends BaseCommand { from: fromPhone, to: flags.to, message: { - parts: [textPart], + parts, effect, }, }); + addBreadcrumb('Message sent', { isGroup: flags.to.length > 1 }); + if (flags.json) { this.log(JSON.stringify(data, null, 2)); } else { this.log(formatChatCreated(data)); } } 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') { + 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 { + this.log(chalk.dim(` On a sandbox line, the contact must text you (${chalk.bold(fromPhone)}) first before you can message them.\n`)); + } + this.exit(1); + } this.error(`Failed to create chat: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/src/commands/chats/participants/add.ts b/src/commands/chats/participants/add.ts index 2e29b3a..cea0aba 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 chalk from 'chalk'; import { BaseCommand } from '../../../lib/base-command.js'; import { loadConfig, requireToken } from '../../../lib/config.js'; import { createApiClient } from '../../../lib/api-client.js'; @@ -22,6 +23,10 @@ export default class ParticipantsAdd extends BaseCommand { description: 'Phone number or email of participant to add', required: true, }), + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), profile: Flags.string({ char: 'p', description: 'Config profile to use', @@ -44,7 +49,11 @@ export default class ParticipantsAdd extends BaseCommand { handle: flags.handle, }); - this.log(JSON.stringify(data, null, 2)); + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + } else { + 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)}`); } diff --git a/src/commands/chats/participants/remove.ts b/src/commands/chats/participants/remove.ts index 8ef17ba..4268aa7 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 chalk from 'chalk'; import { BaseCommand } from '../../../lib/base-command.js'; import { loadConfig, requireToken } from '../../../lib/config.js'; import { createApiClient } from '../../../lib/api-client.js'; @@ -22,6 +23,10 @@ export default class ParticipantsRemove extends BaseCommand { description: 'Phone number or email of participant to remove', required: true, }), + json: Flags.boolean({ + description: 'Output as JSON', + default: false, + }), profile: Flags.string({ char: 'p', description: 'Config profile to use', @@ -44,7 +49,11 @@ export default class ParticipantsRemove extends BaseCommand { handle: flags.handle, }); - this.log(JSON.stringify(data, null, 2)); + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + } else { + 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)}`); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 390d729..b03b623 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; import { loadConfig, loadConfigFile } from '../lib/config.js'; import { createApiClient } from '../lib/api-client.js'; @@ -20,85 +21,106 @@ export default class Doctor extends BaseCommand { let passed = 0; let failed = 0; + let warnings = 0; - // Check 1: Config file exists + 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'); + + // Check 1: Config file const configFile = await loadConfigFile(); - const hasProfiles = Object.keys(configFile.profiles).length > 0; - if (hasProfiles && configFile.profiles.default?.token) { - this.log('\u2713 Config file exists at ~/.linq/config.json'); - passed++; - } else if (hasProfiles) { - this.log('\u2713 Config file exists at ~/.linq/config.json'); - passed++; + const profileCount = Object.keys(configFile.profiles).length; + if (profileCount > 0) { + ok('Config file found'); } else { - this.log('\u2717 Config file not found at ~/.linq/config.json'); - failed++; + fail('Config file not found — run `linq signup` or `linq login`'); } - // Load the profile config + // Load profile let config; try { config = await loadConfig(flags.profile); } catch { - this.log('\u2717 Failed to load config profile'); - failed++; - this.log(`\n${passed} check${passed !== 1 ? 's' : ''} passed, ${failed} issue${failed !== 1 ? 's' : ''} found`); + fail('Failed to load config profile'); + this.printSummary(passed, failed, warnings); return; } - // Check 2: API token configured + // Check 2: API token if (config.token) { - const masked = config.token.slice(0, 4) + '****' + config.token.slice(-4); - this.log(`\u2713 API token is configured (${masked})`); - passed++; + const masked = config.token.substring(0, 8) + '•'.repeat(8); + ok(`API token configured (${masked})`); } else { - this.log('\u2717 API token is not configured — run `linq login` or `linq init`'); - failed++; + fail('API token not configured — run `linq login` or `linq signup`'); } - // Check 3: Default phone number + // Check 3: Phone number if (config.fromPhone) { - this.log(`\u2713 Default phone number is set (${config.fromPhone})`); - passed++; + ok(`Phone number set (${config.fromPhone})`); } else { - this.log('\u2717 Default phone number is not set — run `linq profile set fromPhone +1234567890`'); - failed++; + fail('Phone 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)`); + } else { + fail(`Session expired — run \`linq login\` to re-authenticate`); + } } - // Check 4: API connectivity + // Check 5: API connectivity if (config.token) { const client = createApiClient(config.token); const start = Date.now(); try { - await client.phoneNumbers.list(); + const phones = await client.phoneNumbers.list(); const latency = Date.now() - start; - this.log(`\u2713 API connection successful (${latency}ms)`); - passed++; - } catch { + const phoneCount = (phones as any).phone_numbers?.length || 0; + const phoneLabel = phoneCount > 0 ? `, ${phoneCount} phone${phoneCount !== 1 ? 's' : ''}` : ''; + ok(`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; + if (subs.length > 0) { + ok(`Webhooks: ${active} active, ${subs.length - active} inactive`); + } else { + warn('No webhook subscriptions — run `linq webhooks create` or `linq webhooks listen`'); + } + } catch { + warn('Could not check webhooks'); + } + } catch (error) { const latency = Date.now() - start; - this.log(`\u2717 API connection failed (${latency}ms) — check your token or network`); - failed++; + 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`); + } else { + fail(`API unreachable (${latency}ms) — check your network`); + } } } else { - this.log('\u2717 API connectivity — skipped (no token)'); - failed++; + fail('API connectivity — skipped (no token)'); } - // Check 5: Sandbox status - const sandboxProfile = configFile.profiles.sandbox; - if (sandboxProfile?.fromPhone && sandboxProfile?.expiresAt) { - const expires = new Date(sandboxProfile.expiresAt); - if (expires > new Date()) { - this.log(`\u2713 Sandbox active (${sandboxProfile.fromPhone}, expires ${expires.toLocaleTimeString()})`); - passed++; - } else { - this.log(`\u2717 Sandbox expired (${sandboxProfile.fromPhone}, expired ${expires.toLocaleTimeString()})`); - failed++; - } - } + this.printSummary(passed, failed, warnings); + } - this.log( - `\n${passed} check${passed !== 1 ? 's' : ''} passed, ${failed} issue${failed !== 1 ? 's' : ''} found` - ); + 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`); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index ab588b8..411f510 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -48,14 +48,22 @@ export default class Init extends BaseCommand { })), { name: 'Create new profile', value: '__new__' }, ]; - const chosen = await select({ - message: 'Which profile would you like to set up?', - 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; + try { + const chosen = await select({ + message: 'Which profile would you like to set up?', + 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; + } + } } console.log(INIT_BANNER); @@ -94,7 +102,7 @@ export default class Init extends BaseCommand { // Select default phone number let fromPhone: string | undefined; - const phones = data.phone_numbers; + const phones = data.phone_numbers || []; if (phones.length === 1) { fromPhone = phones[0].phone_number; diff --git a/src/commands/login.ts b/src/commands/login.ts index 4409f18..c6501f9 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,21 +1,9 @@ -import { Flags, ux } from '@oclif/core'; -import { input, select } from '@inquirer/prompts'; +import { Flags } from '@oclif/core'; +import { input } from '@inquirer/prompts'; import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; -import { - saveProfile, - saveSandboxProfile, - setCurrentProfile, - getCurrentProfile, - listProfiles, - SANDBOX_PROFILE, - Profile, -} from '../lib/config.js'; import { LOGO } from '../lib/banner.js'; -import { BACKEND_URL } from '../lib/api-client.js'; -import { addBreadcrumb } from '../lib/telemetry.js'; - -const SESSION_DURATION_DAYS = 7; +import { runAuthFlow, checkExistingSession } from '../lib/auth-flow.js'; const LOGIN_BANNER = LOGO + '\n Welcome back to Linq CLI\n'; @@ -28,14 +16,6 @@ export default class Login extends BaseCommand { ]; static override flags = { - profile: Flags.string({ - char: 'p', - description: 'Profile to save credentials to', - }), - token: Flags.string({ - char: 't', - description: 'API token (skip email verification)', - }), email: Flags.string({ char: 'e', description: 'Email address for OTP login', @@ -45,15 +25,15 @@ export default class Login extends BaseCommand { async run(): Promise { const { flags } = await this.parse(Login); - // Token login (power user / existing paid customer) - if (flags.token) { - await this.tokenLogin(flags.token, flags.profile); + const existing = await checkExistingSession(); + if (existing) { + this.log(chalk.yellow(`\n You're already logged in as ${chalk.bold(existing)}.`)); + this.log(chalk.dim(` Run ${chalk.cyan('linq logout')} to switch accounts.\n`)); return; } console.log(LOGIN_BANNER); - // Email + OTP login let email = flags.email; if (!email) { try { @@ -74,193 +54,12 @@ export default class Login extends BaseCommand { } email = email.trim().toLowerCase(); - // Step 1: Send OTP - ux.action.start('Sending verification code'); - - 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 this.parseError(otpRes); - this.log(chalk.red(`\n ${err}\n`)); - this.exit(1); - } - - 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'); - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); - return; - } - ux.action.stop('sent!'); - this.log(` Check ${chalk.bold(email)} for your verification code.\n`); - - // Step 2: Collect + verify OTP - 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') { - this.exit(1); - } - throw error; - } - - ux.action.start('Logging in'); - - try { - const verifyRes = await fetch(`${BACKEND_URL}/cli/verify-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, code: code.trim() }), - }); - - if (!verifyRes.ok) { - ux.action.stop('failed'); - const err = await this.parseError(verifyRes); - this.log(chalk.red(`\n ${err}\n`)); - this.exit(1); - } - - const verifyResult = (await verifyRes.json()) as { - status: 'existing' | 'new'; - token?: string; - orgId?: string; - email: string; - name?: string; - accountInfo?: { - tier: number; - phones: { phoneNumber: string; tenantType: string }[]; - } | null; - }; - ux.action.stop('done!'); - - if (verifyResult.status === 'new') { - this.log(chalk.yellow(`\n No account found for ${email}.`)); - this.log(` Run ${chalk.cyan('linq signup')} to create one and get a shared line.\n`); - this.exit(1); - return; - } - - // Existing user — save credentials - const phones = verifyResult.accountInfo?.phones || []; - const tier = verifyResult.accountInfo?.tier ?? 0; - let phoneNumber = ''; - let multiplePhones = false; - 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) { - multiplePhones = true; - accountLabel = tier >= 1 ? 'Paid' : 'Shared Line'; - } - - const targetProfile = flags.profile || await getCurrentProfile() || 'default'; - const sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); - const profileData: Profile = { - token: verifyResult.token, - fromPhone: phoneNumber, - orgId: verifyResult.orgId, - email: verifyResult.email, - name: verifyResult.name, - tier, - tenantType: phones.length === 1 ? phones[0].tenantType : phones.length > 1 ? 'SINGLE' : undefined, - sessionExpiresAt, - }; - - if (targetProfile === SANDBOX_PROFILE) { - await saveSandboxProfile(profileData); - } else { - await saveProfile(targetProfile, profileData); - } - await setCurrentProfile(targetProfile); - - addBreadcrumb('Login successful', { accountType: accountLabel || 'unknown' }); - this.log(''); - this.log(chalk.green(' \u2713 Logged in!\n')); - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - if (multiplePhones) { - this.log(` ${chalk.dim('Phone:')} ${chalk.yellow(`${phones.length} phones available`)}`); - this.log(` Run ${chalk.cyan('linq phonenumbers set')} to pick a default.`); - } else { - this.log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'none')}`); - } - this.log(` ${chalk.dim('Email:')} ${verifyResult.email}`); - this.log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); - this.log(''); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - ux.action.stop('failed'); - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); - } - } - - /** - * Token-based login for power users / paid customers. - */ - private async tokenLogin(token: string, profileName?: string): Promise { - token = token.trim(); - if (!token) { - this.error('Token cannot be empty'); - } - - 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 (profileName === SANDBOX_PROFILE) { - this.error(`The "${SANDBOX_PROFILE}" profile is reserved. Use --profile .`); - } - - await saveProfile(profileName, { token }); - await setCurrentProfile(profileName); - - this.log(chalk.green(`\n \u2713 Token saved to profile "${profileName}"\n`)); + await runAuthFlow({ + email, + log: (msg) => this.log(msg), + exit: (code) => this.exit(code), + parseError: (res) => this.parseError(res), + }); } private async parseError(res: Response): Promise { diff --git a/src/commands/messages/send.ts b/src/commands/messages/send.ts index 6fb94db..46bd1b4 100644 --- a/src/commands/messages/send.ts +++ b/src/commands/messages/send.ts @@ -1,6 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { BaseCommand } from '../../lib/base-command.js'; -import { loadConfig, requireToken, requireFromPhone } from '../../lib/config.js'; +import { loadConfig, requireToken } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; import { formatMessageSent } from '../../lib/format.js'; import type Linq from '@linqapp/sdk'; @@ -30,10 +30,8 @@ export default class MessagesSend extends BaseCommand { static override examples = [ '<%= config.bin %> <%= command.id %> CHAT_ID --message "Hello"', - '<%= config.bin %> <%= command.id %> CHAT_ID --from +12025551234 --message "Hello"', '<%= config.bin %> <%= command.id %> CHAT_ID --message "Wow!" --effect fireworks', '<%= config.bin %> <%= command.id %> CHAT_ID --message "Reply" --reply-to MSG_ID', - '<%= config.bin %> <%= command.id %> CHAT_ID --message "Hello" --profile work', ]; static override args = { @@ -44,14 +42,14 @@ export default class MessagesSend extends BaseCommand { }; static override flags = { - from: Flags.string({ - description: 'Sender phone number (E.164 format). Uses config fromPhone if not specified.', - }), message: Flags.string({ char: 'm', description: 'Message text to send', required: true, }), + 'attachment-url': Flags.string({ + description: 'URL of an uploaded attachment (from linq attachments upload)', + }), effect: Flags.string({ description: `iMessage effect (${ALL_EFFECTS.join(', ')})`, }), @@ -77,15 +75,16 @@ export default class MessagesSend extends BaseCommand { const config = await loadConfig(flags.profile); const token = requireToken(flags.token, config); - // fromPhone resolved but not sent — SDK's MessageSendParams doesn't include `from` - requireFromPhone(flags.from, config); const client = createApiClient(token); // Build message parts - const textPart: MessagePart = { - type: 'text', - value: flags.message, - }; + const parts: MessagePart[] = [ + { type: 'text', value: flags.message }, + ]; + + if (flags['attachment-url']) { + parts.push({ type: 'media', url: flags['attachment-url'] } as MessagePart); + } // Build effect if specified let effect: MessageEffect | undefined; @@ -102,7 +101,7 @@ export default class MessagesSend extends BaseCommand { try { const data = await client.chats.messages.send(args.chatId, { message: { - parts: [textPart], + parts, effect, reply_to: flags['reply-to'] ? { message_id: flags['reply-to'], part_index: 0 } diff --git a/src/commands/profile/create.ts b/src/commands/profile/create.ts index 20c636a..a6869c9 100644 --- a/src/commands/profile/create.ts +++ b/src/commands/profile/create.ts @@ -24,7 +24,7 @@ export default class ProfileCreate extends BaseCommand { static override flags = { token: Flags.string({ char: 't', - description: 'API token', + description: 'API token', hidden: true, }), 'from-phone': Flags.string({ char: 'f', diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 7c7e073..0f1c63c 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -26,8 +26,9 @@ export default class ProfileList extends BaseCommand { const profileData = configFile.profiles[profile]; if (profile === SANDBOX_PROFILE) { - if (profileData?.fromPhone && profileData?.expiresAt) { - const expires = new Date(profileData.expiresAt); + const sessionExpiry = profileData?.sessionExpiresAt || profileData?.expiresAt; + if (profileData?.fromPhone && sessionExpiry) { + const expires = new Date(sessionExpiry); if (expires > new Date()) { markers.push(`${profileData.fromPhone}, expires ${expires.toLocaleTimeString()}`); } else { diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 2b098a7..2fe09bd 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -1,29 +1,14 @@ -import { Flags, ux } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { input } from '@inquirer/prompts'; import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; -import { - saveSandboxProfile, - setCurrentProfile, - getSandboxProfile, - isSessionExpired, - SANDBOX_PROFILE, -} from '../lib/config.js'; import { LOGO } from '../lib/banner.js'; -import { BACKEND_URL } from '../lib/api-client.js'; -import { - addBreadcrumb, - finishChildSpan, - setTag, - startChildSpan, -} from '../lib/telemetry.js'; - -const SESSION_DURATION_DAYS = 7; +import { runAuthFlow, checkExistingSession } from '../lib/auth-flow.js'; const SIGNUP_BANNER = LOGO + '\n Create your Linq developer account\n'; export default class Signup extends BaseCommand { - static override description = 'Create a Linq developer account and get a phone number'; + static override description = 'Create a Linq developer account and get a shared phone line'; static override examples = [ '<%= config.bin %> <%= command.id %>', @@ -33,25 +18,22 @@ export default class Signup extends BaseCommand { static override flags = { email: Flags.string({ char: 'e', - description: 'Your email address', + description: 'Email address', }), }; async run(): Promise { const { flags } = await this.parse(Signup); - let funnelStage = 'started'; - // Check for existing active session - const existing = await getSandboxProfile(); - if (existing?.fromPhone && existing?.token && !isSessionExpired(existing)) { - this.log(`\n You already have an active account.`); - this.log(` Phone: ${chalk.bold(existing.fromPhone)}\n`); + const existing = await checkExistingSession(); + if (existing) { + this.log(chalk.yellow(`\n You're already logged in as ${chalk.bold(existing)}.`)); + this.log(chalk.dim(` Run ${chalk.cyan('linq logout')} to switch accounts.\n`)); return; } console.log(SIGNUP_BANNER); - // Step 1: Collect email let email = flags.email; if (!email) { try { @@ -71,276 +53,13 @@ export default class Signup extends BaseCommand { } } email = email.trim().toLowerCase(); - funnelStage = 'email_collected'; - addBreadcrumb('Email collected'); - - // Step 2: Send OTP - const otpSpan = startChildSpan('signup.otp', 'signup.stage'); - ux.action.start('Sending verification code'); - - 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'); - finishChildSpan(otpSpan, 'error'); - const err = await this.parseError(otpRes); - this.log(chalk.red(`\n ${err}\n`)); - this.exit(1); - } - - 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'); - finishChildSpan(otpSpan, 'error'); - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); - return; - } - ux.action.stop('sent!'); - this.log(` Check ${chalk.bold(email)} for your verification code.\n`); - - // Step 3: Collect + verify OTP code - 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) { - finishChildSpan(otpSpan, 'error'); - if (error instanceof Error && error.name === 'ExitPromptError') { - this.exit(1); - } - throw error; - } - - ux.action.start('Verifying'); - - let verifyResult: { - status: 'existing' | 'new'; - token?: string; - orgId?: string; - email: string; - name?: string; - accountInfo?: { - tier: number; - phones: { phoneNumber: string; tenantType: string }[]; - } | null; - }; - - try { - const verifyRes = await fetch(`${BACKEND_URL}/cli/verify-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, code: code.trim() }), - }); - - if (!verifyRes.ok) { - ux.action.stop('failed'); - finishChildSpan(otpSpan, 'error'); - const err = await this.parseError(verifyRes); - this.log(chalk.red(`\n ${err}\n`)); - this.exit(1); - } - - verifyResult = await verifyRes.json() as typeof verifyResult; - ux.action.stop('done!'); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - ux.action.stop('failed'); - finishChildSpan(otpSpan, 'error'); - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); - return; - } - - // Handle verify result - if (verifyResult.status === 'existing') { - // Existing Blue customer — welcome back, no name/phone needed - funnelStage = 'existing_login'; - finishChildSpan(otpSpan, 'ok'); - - 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 sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); - await saveSandboxProfile({ - token: verifyResult.token, - fromPhone: phoneNumber, - orgId: verifyResult.orgId, - email: verifyResult.email, - name: verifyResult.name, - tier, - tenantType: phones.length === 1 ? phones[0].tenantType : undefined, - sessionExpiresAt, - }); - await setCurrentProfile(SANDBOX_PROFILE); - - this.log(''); - this.log(chalk.green(' \u2713 Welcome back!\n')); - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - if (phones.length > 1) { - this.log(` ${chalk.dim('Phone:')} ${chalk.yellow(`${phones.length} phones available`)}`); - this.log(` Run ${chalk.cyan('linq phonenumbers set')} to pick a default.`); - } else { - this.log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'none')}`); - } - this.log(` ${chalk.dim('Email:')} ${verifyResult.email}`); - this.log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); - this.log(`\n You already have an account. Your existing API key and integrations are unchanged.`); - this.log(''); - return; - } - - // New user — ask for name and phone - let name: string; - try { - name = await input({ - message: 'Your name:', - validate: (v) => (v.trim() ? true : 'Name is required'), - }); - } catch (error) { - if (error instanceof Error && error.name === 'ExitPromptError') { - this.exit(1); - } - throw error; - } - name = name.trim(); - - let phone: string | undefined; - try { - phone = await input({ - message: 'Phone number (optional, press enter to skip):', - }); - } catch (error) { - if (error instanceof Error && error.name === 'ExitPromptError') { - phone = ''; - } else { - throw error; - } - } - phone = phone?.trim() || undefined; - if (phone) { - const digits = phone.replace(/\D/g, ''); - if (digits.length === 10) phone = `+1${digits}`; - else if (digits.length === 11 && digits.startsWith('1')) phone = `+${digits}`; - else if (digits.length >= 11) phone = `+${digits}`; - } - - // Step 4: Provision new account - ux.action.start('Creating your account'); - - try { - const provisionRes = await fetch(`${BACKEND_URL}/cli/provision`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: verifyResult.email, name, phone }), - }); - - if (!provisionRes.ok) { - ux.action.stop('failed'); - finishChildSpan(otpSpan, 'error'); - const err = await this.parseError(provisionRes); - - if (err.includes('No shared lines')) { - this.log(chalk.yellow('\n All phone lines are currently full. Please try again later.\n')); - this.exit(1); - } - - this.log(chalk.red(`\n ${err}\n`)); - this.exit(1); - } - - const data = (await provisionRes.json()) as { - token: string; - orgId: string; - email: string; - name: string; - accountInfo: { - tier: number; - phones: { phoneNumber: string; tenantType: string }[]; - } | null; - }; - ux.action.stop('done!'); - funnelStage = 'account_created'; - finishChildSpan(otpSpan, 'ok'); - - const phones = data.accountInfo?.phones || []; - const tier = data.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'; - } - - addBreadcrumb('Account created', { phone: phoneNumber }); - - const sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); - await saveSandboxProfile({ - token: data.token, - fromPhone: phoneNumber, - orgId: data.orgId, - email: data.email, - name: data.name, - tier, - tenantType: phones.length === 1 ? phones[0].tenantType : undefined, - sessionExpiresAt, - }); - await setCurrentProfile(SANDBOX_PROFILE); - - this.log(''); - this.log(chalk.green(' \u2713 Account created!\n')); - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - this.log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'pending')}`); - this.log(` ${chalk.dim('Email:')} ${data.email}`); - this.log(` ${chalk.dim('API Key:')} ${data.token}`); - this.log(''); - if (phoneNumber && accountLabel === 'Shared Line') { - this.log(' Your number is shared and allows a max of 100 contacts.'); - this.log(' Start by adding a contact. Your number is inbound-first:'); - this.log(' others text you first and then you can start the conversation.\n'); - } - this.log(' Get started:\n'); - this.log(` ${chalk.cyan('linq contacts add +1234567890')} ${chalk.dim('# Add a contact')}`); - this.log(` ${chalk.cyan('linq webhooks listen')} ${chalk.dim('# Watch for incoming events')}`); - this.log(''); - this.log(` ${chalk.dim('Full API docs:')} https://apidocs.linqapp.com`); - this.log(''); - } catch (error) { - if (error instanceof Error && 'oclif' in error) throw error; - ux.action.stop('failed'); - finishChildSpan(otpSpan, 'error'); - this.log(chalk.red('\n Could not connect to Linq. Please try again later.\n')); - this.exit(1); - } - setTag('signup.funnel_stage', funnelStage); + await runAuthFlow({ + email, + log: (msg) => this.log(msg), + exit: (code) => this.exit(code), + parseError: (res) => this.parseError(res), + }); } private async parseError(res: Response): Promise { diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index ee368ab..ca8f053 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -31,42 +31,46 @@ export default class Whoami extends BaseCommand { return; } - // Check session expiry - const expired = isSessionExpired(config); - if (expired) { + // 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 info: Record = { - email: config.email, - name: config.name, - phone: config.fromPhone, - apiKey: token, - }; - if (flags.json) { - this.log(JSON.stringify(info, null, 2)); + this.log(JSON.stringify({ + email: config.email, + name: config.name, + phone: config.fromPhone, + apiKey: token, + tier: config.tier, + tenantType: config.tenantType, + }, null, 2)); return; } - // Derive account label - let accountLabel = ''; - const tier = config.tier; - const tenantType = config.tenantType; - if (tier === 0 && tenantType === 'SINGLE') accountLabel = 'Sandbox Line'; - else if (tier === 0 && tenantType === 'MULTI') accountLabel = 'Shared Line'; - else if (tier !== undefined && tier >= 1) accountLabel = 'Paid'; - this.log(''); - if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); - if (info.name) this.log(` ${chalk.dim('Name:')} ${info.name}`); - if (info.email) this.log(` ${chalk.dim('Email:')} ${info.email}`); - this.log(` ${chalk.dim('Phone:')} ${info.phone || chalk.dim('not set')}`); - this.log(` ${chalk.dim('API Key:')} ${info.apiKey}`); - if (!info.phone) { - this.log(`\n ${chalk.dim('You have multiple phone numbers. Run')} ${chalk.cyan('linq phonenumbers set')} ${chalk.dim('to pick a default.')}`); + + // 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:')} ${token}`); + } 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:')} ${token}`); } + this.log(''); } } diff --git a/src/lib/auth-flow.ts b/src/lib/auth-flow.ts new file mode 100644 index 0000000..b9b59fc --- /dev/null +++ b/src/lib/auth-flow.ts @@ -0,0 +1,277 @@ +import { ux } from '@oclif/core'; +import { input } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { + saveProfile, + setCurrentProfile, + loadConfig, + isSessionExpired, +} from './config.js'; +import { BACKEND_URL } from './api-client.js'; +import { addBreadcrumb } from './telemetry.js'; + +const SESSION_DURATION_DAYS = 7; + +interface AuthFlowOptions { + email: string; + log: (msg: string) => void; + exit: (code: number) => never; + parseError: (res: Response) => Promise; +} + +/** + * Check if there's an active session. Returns identity string if logged in, null if not. + */ +export async function checkExistingSession(): Promise { + try { + const current = await loadConfig(); + if (current.token && !isSessionExpired(current)) { + return current.email || current.fromPhone || 'another account'; + } + } catch { + // No config + } + return null; +} + +/** + * Shared auth flow for both signup and login. + * 1. Send OTP + * 2. Verify code + * 3. If existing → save profile, show account + * 4. If new → ask name, provision, show account + */ +export async function runAuthFlow(opts: AuthFlowOptions): Promise { + const { email, log, exit, parseError } = opts; + + // Step 1: Send OTP + ux.action.start('Sending verification code'); + + 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.red(`\n ${err}\n`)); + exit(1); + } + + 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; + } + ux.action.stop('sent!'); + log(` Check ${chalk.bold(email)} for your verification code.\n`); + + // Step 2: Verify OTP + 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); + } + throw error; + } + + ux.action.start('Verifying'); + + let verifyResult: { + status: 'existing' | 'new'; + token?: string; + orgId?: string; + email: string; + name?: string; + accountInfo?: { + tier: number; + phones: { phoneNumber: string; tenantType: string }[]; + } | null; + }; + + try { + const verifyRes = await fetch(`${BACKEND_URL}/cli/verify-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, code: code.trim() }), + }); + + if (!verifyRes.ok) { + ux.action.stop('failed'); + const err = await parseError(verifyRes); + log(chalk.red(`\n ${err}\n`)); + exit(1); + } + + verifyResult = await verifyRes.json() as typeof verifyResult; + ux.action.stop('done!'); + } 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; + } + + // Existing user → save and show + if (verifyResult.status === 'existing') { + 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 sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); + await saveProfile('default', { + token: verifyResult.token, + fromPhone: phoneNumber, + orgId: verifyResult.orgId, + email: verifyResult.email, + name: verifyResult.name, + tier, + tenantType: phones.length === 1 ? phones[0].tenantType : undefined, + sessionExpiresAt, + }); + await setCurrentProfile('default'); + + addBreadcrumb('Login successful', { accountType: accountLabel || 'unknown' }); + log(''); + log(chalk.green(' \u2713 Welcome back!\n')); + 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.`); + } else { + log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'none')}`); + } + log(` ${chalk.dim('Email:')} ${verifyResult.email}`); + log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); + log(''); + return; + } + + // New user → ask name, provision + let name: string; + try { + name = await input({ + message: 'Your name:', + validate: (v) => (v.trim() ? true : 'Name is required'), + }); + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + exit(1); + } + throw error; + } + name = name.trim(); + + ux.action.start('Creating your account'); + + try { + const provisionRes = await fetch(`${BACKEND_URL}/cli/provision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: verifyResult.email, name }), + }); + + if (!provisionRes.ok) { + ux.action.stop('failed'); + const err = await parseError(provisionRes); + + if (err.includes('No shared lines')) { + log(chalk.yellow('\n All phone lines are currently full. Please try again later.\n')); + exit(1); + } + + log(chalk.red(`\n ${err}\n`)); + exit(1); + } + + const data = (await provisionRes.json()) as { + token: string; + orgId: string; + email: string; + name: string; + accountInfo: { + tier: number; + phones: { phoneNumber: string; tenantType: string }[]; + } | null; + }; + ux.action.stop('done!'); + + const phones = data.accountInfo?.phones || []; + const tier = data.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'; + } + + addBreadcrumb('Account created', { phone: phoneNumber }); + + const sessionExpiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000).toISOString(); + await saveProfile('default', { + token: data.token, + fromPhone: phoneNumber, + orgId: data.orgId, + email: data.email, + name: data.name, + tier, + tenantType: phones.length === 1 ? phones[0].tenantType : undefined, + sessionExpiresAt, + }); + await setCurrentProfile('default'); + + log(''); + log(chalk.green(' \u2713 Account created!\n')); + if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); + log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'pending')}`); + log(` ${chalk.dim('Email:')} ${data.email}`); + log(` ${chalk.dim('API Key:')} ${data.token}`); + log(''); + if (phoneNumber && accountLabel === 'Shared Line') { + log(' Your number is shared and allows a max of 100 contacts.'); + log(' Start by adding a contact. Your number is inbound-first:'); + log(' others text you first and then you can start the conversation.\n'); + } + log(' Get started:\n'); + log(` ${chalk.cyan('linq contacts add +1234567890')} ${chalk.dim('# Add a contact')}`); + log(` ${chalk.cyan('linq webhooks listen')} ${chalk.dim('# Watch for incoming events')}`); + log(''); + log(` ${chalk.dim('Full API docs:')} https://apidocs.linqapp.com`); + log(''); + } 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); + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 0397bfc..70b6e82 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -199,10 +199,6 @@ export async function saveProfile( profileName: string, profile: Profile ): Promise { - if (profileName === SANDBOX_PROFILE) { - throw new Error(`The "${SANDBOX_PROFILE}" profile is reserved for \`linq signup\``); - } - const configFile = await loadConfigFile(); const existing = configFile.profiles[profileName] || {}; diff --git a/src/lib/format.ts b/src/lib/format.ts index 4788bf9..430581c 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -12,16 +12,58 @@ function truncate(str: string, max: number): string { + function fmtDate(iso: string | null | undefined): string { if (!iso) return '–'; const d = new Date(iso); + const now = Date.now(); + const diff = now - d.getTime(); + + // Relative time for recent timestamps + if (diff >= 0 && diff < 60_000) return 'just now'; + if (diff >= 0 && diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff >= 0 && diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff >= 0 && diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return d.toLocaleString(); } +function fmtDateAbsolute(iso: string | null | undefined): string { + if (!iso) return '–'; + return new Date(iso).toLocaleString(); +} + +function fmtPhone(phone: string): string { + // Format US numbers as +1 (XXX) XXX-XXXX + const match = phone.match(/^\+1(\d{3})(\d{3})(\d{4})$/); + if (match) return `+1 (${match[1]}) ${match[2]}-${match[3]}`; + return phone; +} + +function statusColor(status: string): string { + switch (status.toLowerCase()) { + case 'delivered': + case 'read': + case 'active': + case 'sent': + return chalk.green(status); + case 'pending': + case 'sending': + return chalk.yellow(status); + case 'failed': + case 'error': + case 'inactive': + return chalk.red(status); + default: + return status; + } +} + function kvLine(key: string, value: string | undefined | null): string { - return ` ${chalk.bold(key + ':')} ${value ?? '–'}`; + return ` ${chalk.dim(key + ':')} ${value ?? '–'}`; } + // ── phone numbers ──────────────────────────────────────────────────── interface PhoneNumberInfo { @@ -29,15 +71,16 @@ interface PhoneNumberInfo { phone_number: string; } -export function formatPhoneNumbers(data: { phone_numbers: PhoneNumberInfo[] }): string { - const phones = data.phone_numbers; - if (phones.length === 0) return 'No phone numbers found.'; +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'; - const header = `${pad('ID', 38)} PHONE NUMBER`; - const rows = phones.map( - (p) => `${pad(p.id, 38)} ${p.phone_number}` - ); - return [chalk.dim(header), ...rows].join('\n'); + const lines = ['\n ' + chalk.bold('Your phone numbers') + '\n']; + for (const p of phones) { + lines.push(` ${fmtPhone(p.phone_number)}`); + } + lines.push(''); + return lines.join('\n'); } // ── chats ──────────────────────────────────────────────────────────── @@ -63,7 +106,7 @@ export function formatChatsList(data: { chats: Chat[]; next_cursor?: string | nu const rows = chats.map((c) => { const participants = (c.handles || []) .filter((h) => !h.is_me) - .map((h) => h.handle) + .map((h) => fmtPhone(h.handle)) .join(', '); return `${pad(c.id, 38)} ${pad(truncate(c.display_name || participants, 34), 36)} ${pad(c.service || '–', 12)} ${fmtDate(c.updated_at ?? null)}`; }); @@ -85,15 +128,19 @@ interface CreateChatResult { export function formatChatCreated(data: CreateChatResult): string { const recipients = (data.chat.handles || []) .filter((h) => !h.is_me) - .map((h) => h.handle) + .map((h) => fmtPhone(h.handle)) .join(', '); - return chalk.green('✓') + ` Message sent${recipients ? ` to ${recipients}` : ''} (chat ${data.chat.id})`; + return [ + chalk.green('✓') + ` Message sent${recipients ? ` to ${recipients}` : ''}`, + kvLine('Chat ID', data.chat.id), + kvLine('Message ID', data.chat.message.id), + ].join('\n'); } export function formatChatDetail(data: Chat): string { const participants = (data.handles || []).map((h) => { const me = h.is_me ? chalk.dim(' (you)') : ''; - return ` ${h.handle}${me}`; + return ` ${fmtPhone(h.handle)}${me}`; }); return [ kvLine('ID', data.id), @@ -118,7 +165,11 @@ interface SendMessageResponse { } export function formatMessageSent(data: SendMessageResponse): string { - return chalk.green('✓') + ` Message sent (${data.message.id}) to chat ${data.chat_id}`; + return [ + chalk.green('✓') + ' Message sent', + kvLine('Message ID', data.message.id), + kvLine('Chat ID', data.chat_id), + ].join('\n'); } interface Message { @@ -136,6 +187,7 @@ interface Message { is_read: boolean; service?: string | null; effect?: { type?: string; name?: string } | null; + delivery_status?: string | null; } export function formatMessagesList(data: { messages: Message[]; next_cursor?: string | null }): string { @@ -143,12 +195,16 @@ export function formatMessagesList(data: { messages: Message[]; next_cursor?: st if (msgs.length === 0) return 'No messages found.'; const rows = msgs.map((m) => { - const sender = m.from_handle?.handle || m.from || (m.is_from_me ? 'you' : '?'); + const sender = m.from_handle?.handle + ? fmtPhone(m.from_handle.handle) + : m.from ? fmtPhone(m.from) : (m.is_from_me ? chalk.dim('you') : '?'); const body = (m.parts || []) .map((p) => (p?.type === 'text' ? p.value : `[${p?.type || 'media'}]`)) .join(' ') || ''; - return `${chalk.cyan(m.id)} ${chalk.dim(fmtDate(m.created_at))} ${chalk.bold(sender)} ${truncate(body, 60)}`; + const status = m.delivery_status || (m.is_read ? 'read' : m.is_delivered ? 'delivered' : 'sent'); + const direction = m.is_from_me ? chalk.dim('→') : chalk.green('←'); + return `${chalk.dim(fmtDate(m.created_at))} ${direction} ${chalk.bold(sender)} ${truncate(body, 50)} ${statusColor(status)}`; }); const lines = [...rows]; if (data.next_cursor) { @@ -158,21 +214,26 @@ export function formatMessagesList(data: { messages: Message[]; next_cursor?: st } export function formatMessageDetail(data: Message): string { - const sender = data.from_handle?.handle || data.from || (data.is_from_me ? 'you' : '–'); + const sender = data.from_handle?.handle + ? fmtPhone(data.from_handle.handle) + : data.from ? fmtPhone(data.from) : (data.is_from_me ? 'you' : '–'); const body = (data.parts || []) .map((p) => (p?.type === 'text' ? p.value : `[${p?.type || 'media'}]`)) .join(' ') || '–'; + const status = data.delivery_status || (data.is_read ? 'read' : data.is_delivered ? 'delivered' : 'sent'); const lines = [ kvLine('ID', data.id), kvLine('Chat', data.chat_id), kvLine('From', sender), + kvLine('Direction', data.is_from_me ? 'outbound' : 'inbound'), kvLine('Service', data.service || '–'), + kvLine('Status', statusColor(status)), kvLine('Body', body), - kvLine('Sent', fmtDate(data.sent_at)), - kvLine('Delivered', data.is_delivered ? fmtDate(data.delivered_at) : 'No'), - kvLine('Read', data.is_read ? fmtDate(data.read_at) : 'No'), + kvLine('Sent', fmtDateAbsolute(data.sent_at)), + kvLine('Delivered', data.is_delivered ? fmtDateAbsolute(data.delivered_at) : chalk.dim('–')), + kvLine('Read', data.is_read ? fmtDateAbsolute(data.read_at) : chalk.dim('–')), ]; if (data.effect) { lines.push(kvLine('Effect', `${data.effect.type}: ${data.effect.name}`)); @@ -205,10 +266,10 @@ export function formatWebhooksList(data: { subscriptions: WebhookSubscription[] const subs = data.subscriptions; if (subs.length === 0) return 'No webhook subscriptions found.'; - const header = `${pad('ID', 40)} ${pad('URL', 40)} ${pad('EVENTS', 8)} ACTIVE`; + const header = `${pad('ID', 38)} ${pad('URL', 40)} ${pad('EVENTS', 8)} ACTIVE`; const rows = subs.map( (s) => - `${pad(s.id, 40)} ${pad(truncate(s.target_url, 38), 40)} ${pad(String(s.subscribed_events.length), 8)} ${s.is_active ? chalk.green('✓') : chalk.red('✗')}` + `${pad(s.id, 38)} ${pad(truncate(s.target_url, 38), 40)} ${pad(String(s.subscribed_events.length), 8)} ${s.is_active ? chalk.green('active') : chalk.red('inactive')}` ); return [chalk.dim(header), ...rows].join('\n'); } @@ -217,14 +278,33 @@ export function formatWebhookDetail(data: WebhookSubscription): string { const lines = [ kvLine('ID', data.id), kvLine('URL', data.target_url), - kvLine('Active', data.is_active ? 'Yes' : 'No'), - kvLine('Created', fmtDate(data.created_at)), - kvLine('Updated', fmtDate(data.updated_at)), + kvLine('Active', data.is_active ? chalk.green('Yes') : chalk.red('No')), + kvLine('Created', fmtDateAbsolute(data.created_at)), + kvLine('Updated', fmtDateAbsolute(data.updated_at)), ` ${chalk.bold('Events:')}`, ...data.subscribed_events.map((e) => ` ${e}`), ]; if (data.signing_secret) { - lines.splice(3, 0, kvLine('Signing Secret', data.signing_secret)); + lines.splice(3, 0, ''); + lines.splice(3, 0, ` ${chalk.yellow('⚠ Signing Secret (save this — it cannot be retrieved later):')}`); + lines.splice(4, 0, ` ${chalk.bold(data.signing_secret)}`); + lines.splice(5, 0, ''); + } + return lines.join('\n'); +} + +export function formatWebhookCreated(data: WebhookSubscription): string { + const lines = [ + chalk.green('✓') + ' Webhook subscription created', + '', + kvLine('ID', data.id), + kvLine('URL', data.target_url), + kvLine('Events', data.subscribed_events.join(', ')), + ]; + if (data.signing_secret) { + lines.push(''); + lines.push(` ${chalk.yellow('⚠ Save your signing secret — it cannot be retrieved later:')}`); + lines.push(` ${chalk.bold(data.signing_secret)}`); } return lines.join('\n'); } @@ -246,8 +326,8 @@ export function formatAttachmentMeta(data: Attachment): string { kvLine('ID', data.id), kvLine('Filename', data.filename), kvLine('Type', data.content_type), - kvLine('Size', `${data.size_bytes} bytes`), - kvLine('Status', data.status), + kvLine('Size', formatBytes(data.size_bytes)), + kvLine('Status', statusColor(data.status)), kvLine('Download URL', data.download_url || '–'), kvLine('Created', fmtDate(data.created_at)), ].join('\n'); @@ -276,3 +356,11 @@ export function formatUploadUrl(data: UploadResult): string { headerEntries, ].join('\n'); } + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts index 27a58be..c6057c8 100644 --- a/test/commands/doctor.test.ts +++ b/test/commands/doctor.test.ts @@ -73,11 +73,11 @@ describe('doctor', () => { await cmd.run(); const output = logs.join('\n'); - expect(output).toContain('\u2713 Config file exists'); - expect(output).toContain('\u2713 API token is configured'); - expect(output).toContain('\u2713 Default phone number is set'); - expect(output).toContain('\u2713 API connection successful'); - expect(output).toContain('4 checks passed, 0 issues found'); + 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 API connected'); + expect(output).toContain('passed'); }); it('reports missing config when no config file exists', async () => { @@ -87,8 +87,8 @@ describe('doctor', () => { await cmd.run(); const output = logs.join('\n'); - expect(output).toContain('\u2717 API token is not configured'); - expect(output).toContain('\u2717 Default phone number is not set'); + expect(output).toContain('\u2717 API token not configured'); + expect(output).toContain('\u2717 Phone number not set'); expect(output).not.toContain('0 issues found'); }); @@ -117,8 +117,8 @@ describe('doctor', () => { await cmd.run(); const output = logs.join('\n'); - expect(output).toContain('\u2713 API token is configured'); - expect(output).toContain('\u2717 API connection failed'); + expect(output).toContain('\u2713 API token configured'); + expect(output).toContain('\u2717 API auth failed'); }); it('masks token values in output', async () => { @@ -155,6 +155,6 @@ describe('doctor', () => { const output = logs.join('\n'); expect(output).not.toContain('my-secret-token-value'); - expect(output).toContain('my-s****alue'); + expect(output).toContain('my-secre••••••••'); }); }); diff --git a/test/commands/messages/send.test.ts b/test/commands/messages/send.test.ts index f765e9e..c6e861a 100644 --- a/test/commands/messages/send.test.ts +++ b/test/commands/messages/send.test.ts @@ -54,7 +54,7 @@ describe('messages send', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new MessagesSend( - ['chat-123', '--from', '+12025551234', '--message', 'Hello!'], + ['chat-123', '--message', 'Hello!'], config ); await cmd.run(); @@ -84,7 +84,7 @@ describe('messages send', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new MessagesSend( - ['chat-123', '--from', '+12025551234', '--message', 'Wow!', '--effect', 'fireworks'], + ['chat-123', '--message', 'Wow!', '--effect', 'fireworks'], config ); await cmd.run(); @@ -110,7 +110,7 @@ describe('messages send', () => { const config = await Config.load({ root: process.cwd() }); const cmd = new MessagesSend( - ['chat-123', '--from', '+12025551234', '--message', 'Reply', '--reply-to', 'original-msg-id'], + ['chat-123', '--message', 'Reply', '--reply-to', 'original-msg-id'], config ); await cmd.run(); @@ -122,7 +122,7 @@ describe('messages send', () => { it('requires chat ID argument', async () => { const config = await Config.load({ root: process.cwd() }); - const cmd = new MessagesSend(['--from', '+12025551234', '--message', 'Hello!'], config); + const cmd = new MessagesSend(['--message', 'Hello!'], config); await expect(cmd.run()).rejects.toThrow('Missing 1 required arg'); });