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
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,12 @@ The flow: enter email → check inbox for the 6-digit code → enter the code

#### `linq login`

Log in to an existing account using email OTP.
Authenticate with an API token. Run bare to paste your token interactively, or use `--token` to pass it directly.

```bash
linq login
linq login --token linq_xxxxxxxxxxxx
linq login --profile work
```

#### `linq logout`
Expand Down Expand Up @@ -117,12 +119,63 @@ linq doctor

#### `linq init`

Interactive setup wizard. Useful if you already have an API token (e.g. from the dashboard) and want to wire it up to the CLI without going through the OTP flow.
Interactive setup wizard. Same behavior as `linq login` — included for backwards compatibility.

```bash
linq init
```

### Tokens

Manage API tokens directly from the CLI. Mirrors the [web dashboard's API Tooling page](https://dashboard.linqapp.com/api-tooling/).

#### `linq tokens list`

List your API tokens. The token currently saved in your profile is marked `← active`.

```bash
linq tokens list
linq tokens list --json
```

#### `linq tokens create`

Create a new API token. Run bare for an interactive wizard (name + expiration preset/custom/never). With flags, runs non-interactively and defaults to no expiry.

```bash
linq tokens create
linq tokens create --name "My CLI Token"
linq tokens create --name "Worker" --expires-in 30d
linq tokens create --name "Test" --expires-in 2026-12-01
```

Expiration accepts `7d`, `30d`, `60d`, `90d`, `none`, or a `YYYY-MM-DD` date. **The full token is shown only once** — save it somewhere safe.

#### `linq tokens rename`

Rename a token.

```bash
linq tokens rename <id> --name "New Name"
```

#### `linq tokens regenerate`

Mint a new secret for an existing token. The old secret is immediately expired. If you regenerate the token currently saved in your profile, the new secret is automatically written to your local config so you stay logged in.

```bash
linq tokens regenerate <id>
linq tokens regenerate <id> --expires-in 30d
```

#### `linq tokens delete`

Permanently delete a token. **Interactive only** — refuses to run when invoked from a script, CI pipeline, or AI assistant. Also hard-refuses deleting the token you're currently logged in with (you'd lock yourself out).

```bash
linq tokens delete <id>
```

### Profile

#### `linq profile get|set`
Expand Down
71 changes: 51 additions & 20 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import {
listProfiles,
SANDBOX_PROFILE,
} from '../lib/config.js';
import { fetchPartnerId } from '../lib/partner.js';
import { createApiClient } from '../lib/api-client.js';
import { createApiClient, BACKEND_URL } from '../lib/api-client.js';
import { LOGO } from '../lib/banner.js';

const INIT_BANNER = LOGO + '\n Welcome to Linq CLI Setup\n';
Expand Down Expand Up @@ -68,11 +67,6 @@ export default class Init extends BaseCommand {

console.log(INIT_BANNER);

this.log(
'Get your API token from "Integration Details" in the Linq dashboard:'
);
this.log('https://zero.linqapp.com/api-tooling/\n');

// Prompt for API token
const token = await password({
message: 'Enter your API token:',
Expand Down Expand Up @@ -100,32 +94,69 @@ export default class Init extends BaseCommand {

this.log('\u2713 Token is valid!\n');

// Select default phone number
let orgId: string | undefined;
let tier: number | undefined;
let tenantType: string | undefined;
let name: string | undefined;
let partnerId: string | undefined;
let accountPhones: { phoneNumber: string; tenantType: string }[] = [];
try {
const res = await fetch(`${BACKEND_URL}/cli/account-info`, {
headers: { 'Authorization': `Bearer ${token.trim()}` },
});
if (res.ok) {
const acc = await res.json() as {
partnerId?: string;
orgId?: string;
name?: string | null;
accountInfo?: { tier: number; phones: { phoneNumber: string; tenantType: string }[] } | null;
};
partnerId = acc.partnerId;
orgId = acc.orgId;
name = acc.name ?? undefined;
tier = acc.accountInfo?.tier;
accountPhones = acc.accountInfo?.phones ?? [];
}
} catch {
// pass
}

let fromPhone: string | undefined;
const phones = data.phone_numbers || [];
const synapsePhones = (data.phone_numbers || []).map(p => ({ phoneNumber: p.phone_number }));
const phones = accountPhones.length > 0 ? accountPhones : synapsePhones;

if (phones.length === 1) {
fromPhone = phones[0].phone_number;
fromPhone = phones[0].phoneNumber;
this.log(`Default phone number set to ${fromPhone} (only number on account)\n`);
} else if (phones.length > 1) {
fromPhone = await select({
message: 'Select a default phone number:',
choices: phones.map((p) => ({
name: p.phone_number,
value: p.phone_number,
})),
});
this.log('');
if ((tier ?? 0) >= 1) {
fromPhone = await select({
message: 'Select a default phone number:',
choices: phones.map((p) => ({
name: p.phoneNumber,
value: p.phoneNumber,
})),
});
this.log('');
} else {
fromPhone = phones[0].phoneNumber;
}
}

// Fetch partner ID
const partnerId = await fetchPartnerId(token.trim());
if (accountPhones.length > 0) {
tenantType = (fromPhone && accountPhones.find(p => p.phoneNumber === fromPhone)?.tenantType)
?? accountPhones[0].tenantType;
}

// Save to profile
await saveProfile(profileName, {
token: token.trim(),
...(fromPhone && { fromPhone }),
...(partnerId && { partnerId }),
...(orgId && { orgId }),
...(tier !== undefined && { tier }),
...(tenantType && { tenantType }),
...(name && { name }),
});
await setCurrentProfile(profileName);

Expand Down
Loading
Loading