Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added linqapp-cli-1.3.0.tgz
Binary file not shown.
34 changes: 27 additions & 7 deletions src/commands/chats/create.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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 = {
Expand All @@ -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(', ')})`,
}),
Expand All @@ -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;
Expand All @@ -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)}`);
}
}
Expand Down
11 changes: 10 additions & 1 deletion src/commands/chats/participants/add.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand All @@ -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)}`);
}
Expand Down
11 changes: 10 additions & 1 deletion src/commands/chats/participants/remove.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand All @@ -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)}`);
}
Expand Down
120 changes: 71 additions & 49 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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`);
}
}
26 changes: 17 additions & 9 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading