diff --git a/linqapp-cli-1.3.0.tgz b/linqapp-cli-1.3.0.tgz new file mode 100644 index 0000000..1304b88 Binary files /dev/null and b/linqapp-cli-1.3.0.tgz differ diff --git a/src/commands/chats/create.ts b/src/commands/chats/create.ts index 19b6e9f..0c76069 100644 --- a/src/commands/chats/create.ts +++ b/src/commands/chats/create.ts @@ -1,9 +1,11 @@ import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../../lib/base-command.js'; import { loadConfig, requireToken, requireFromPhone } from '../../lib/config.js'; import { createApiClient } from '../../lib/api-client.js'; import { formatChatCreated } from '../../lib/format.js'; -import type Linq from '@linqapp/sdk'; +import { addBreadcrumb } from '../../lib/telemetry.js'; +import Linq from '@linqapp/sdk'; type MessagePart = Linq.Chats.ChatCreateParams['message']['parts'][number]; type MessageEffect = Linq.Chats.ChatCreateParams['message']['effect']; @@ -33,7 +35,6 @@ export default class ChatsCreate extends BaseCommand { '<%= config.bin %> <%= 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/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/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/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..0f010ba 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,8 +1,11 @@ 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'; 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 +40,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 +72,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 +87,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 +106,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 +152,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 +215,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 +249,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 +257,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 +277,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 +293,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 +366,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 +401,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..696b380 --- /dev/null +++ b/src/commands/webhooks/logs.ts @@ -0,0 +1,247 @@ +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 (seenIds.size > 5000) { + seenIds.delete(seenIds.values().next().value!); + } + + if (flags.json) { + this.log(JSON.stringify(log)); + } else { + this.printLog(log); + } + } + } 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)}`); + } + } +} 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'); }); 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 () => {