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/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'); });