diff --git a/README.md b/README.md index b4887aa..e65a30f 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 --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 +linq tokens regenerate --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 +``` + ### Profile #### `linq profile get|set` diff --git a/src/commands/init.ts b/src/commands/init.ts index 411f510..7def281 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -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'; @@ -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:', @@ -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); diff --git a/src/commands/login.ts b/src/commands/login.ts index c6501f9..84a3c3e 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,49 +1,84 @@ import { Flags } from '@oclif/core'; -import { input } from '@inquirer/prompts'; +import { password, select, input } from '@inquirer/prompts'; import chalk from 'chalk'; import { BaseCommand } from '../lib/base-command.js'; +import { + saveProfile, + setCurrentProfile, + getCurrentProfile, + listProfiles, + SANDBOX_PROFILE, +} from '../lib/config.js'; +import { createApiClient, BACKEND_URL } from '../lib/api-client.js'; import { LOGO } from '../lib/banner.js'; -import { runAuthFlow, checkExistingSession } from '../lib/auth-flow.js'; const LOGIN_BANNER = LOGO + '\n Welcome back to Linq CLI\n'; export default class Login extends BaseCommand { - static override description = 'Authenticate with Linq'; + static override description = 'Authenticate with Linq using an API token'; static override examples = [ '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --email dev@example.com', + '<%= config.bin %> <%= command.id %> --token YOUR_API_TOKEN', + '<%= config.bin %> <%= command.id %> --profile work', ]; static override flags = { - email: Flags.string({ - char: 'e', - description: 'Email address for OTP login', + profile: Flags.string({ + char: 'p', + description: 'Profile to save credentials to', + }), + token: Flags.string({ + char: 't', + description: 'API token from the Linq dashboard', }), }; async run(): Promise { const { flags } = await this.parse(Login); - 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; + let profileName = flags.profile; + + if (profileName === SANDBOX_PROFILE) { + this.error(`The "${SANDBOX_PROFILE}" profile is reserved for \`linq signup\`. Use --profile to log in to a different profile.`); } - console.log(LOGIN_BANNER); + 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; + } + } + } - let email = flags.email; - if (!email) { + let token = flags.token; + if (!token) { + console.log(LOGIN_BANNER); try { - email = await input({ - message: 'Email address:', - validate: (v) => { - if (!v.trim()) return 'Email is required'; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())) return 'Enter a valid email'; - return true; - }, + token = await password({ + message: 'Enter your API token:', + mask: '*', + validate: (v) => (v && v.trim() ? true : 'Token cannot be empty'), }); } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { @@ -52,22 +87,97 @@ export default class Login extends BaseCommand { throw error; } } - email = email.trim().toLowerCase(); + token = token.trim(); + if (!token) this.error('Token cannot be empty'); - await runAuthFlow({ - email, - log: (msg) => this.log(msg), - exit: (code) => this.exit(code), - parseError: (res) => this.parseError(res), - }); - } + // Validate the token by hitting synapse + this.log('\nValidating token...'); + const client = createApiClient(token); + let data; + try { + data = await client.phoneNumbers.list(); + } catch { + this.error('Invalid token or API error. Please check your token and try again.'); + } + this.log(`${chalk.green('✓')} Token is valid!\n`); - private async parseError(res: Response): Promise { + 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 body = (await res.json()) as { message?: string; error?: string }; - return body.message || body.error || `Request failed (${res.status})`; + const res = await fetch(`${BACKEND_URL}/cli/account-info`, { + headers: { Authorization: `Bearer ${token}` }, + }); + 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 { - return `Request failed (${res.status})`; + // pass + } + + let fromPhone: string | undefined; + 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].phoneNumber; + } else if (phones.length > 1) { + if ((tier ?? 0) >= 1) { + try { + fromPhone = await select({ + message: 'Select a default phone number:', + choices: phones.map(p => ({ name: p.phoneNumber, value: p.phoneNumber })), + }); + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + this.exit(1); + } + throw error; + } + } else { + fromPhone = phones[0].phoneNumber; + } } + + if (accountPhones.length > 0) { + tenantType = (fromPhone && accountPhones.find(p => p.phoneNumber === fromPhone)?.tenantType) + ?? accountPhones[0].tenantType; + } + + await saveProfile(profileName, { + token, + ...(fromPhone && { fromPhone }), + ...(partnerId && { partnerId }), + ...(orgId && { orgId }), + ...(tier !== undefined && { tier }), + ...(tenantType && { tenantType }), + ...(name && { name }), + }); + await setCurrentProfile(profileName); + + let accountLabel = ''; + if (tier === 0 && tenantType === 'SINGLE') accountLabel = 'Sandbox Line'; + else if (tier === 0 && tenantType === 'MULTI') accountLabel = 'Shared Line'; + else if ((tier ?? 0) >= 1) accountLabel = 'Paid'; + + this.log(chalk.green('✓ Welcome back!\n')); + if (accountLabel) this.log(` ${chalk.dim('Account:')} ${accountLabel}`); + if (fromPhone) this.log(` ${chalk.dim('Phone:')} ${chalk.bold(fromPhone)}`); + if (name) this.log(` ${chalk.dim('Name:')} ${name}`); + this.log(` ${chalk.dim('Profile:')} ${profileName}`); + this.log(''); } } diff --git a/src/commands/tokens/create.ts b/src/commands/tokens/create.ts new file mode 100644 index 0000000..4109218 --- /dev/null +++ b/src/commands/tokens/create.ts @@ -0,0 +1,133 @@ +import { Flags } from '@oclif/core'; +import { input, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { loadConfig, requireToken } from '../../lib/config.js'; +import { BACKEND_URL } from '../../lib/api-client.js'; +import { + TokenSummaryWithSecret, + parseExpiresIn, + formatExpiresAt, +} from '../../lib/tokens-helpers.js'; + +const EXPIRATION_CHOICES = [ + { name: '7 days', value: '7d' }, + { name: '30 days', value: '30d' }, + { name: '60 days', value: '60d' }, + { name: '90 days', value: '90d' }, + { name: 'Custom date', value: 'custom' }, + { name: 'Never', value: 'none' }, +]; + +export default class TokensCreate extends BaseCommand { + static override description = 'Create a new API token'; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --name "My CLI Token"', + '<%= config.bin %> <%= command.id %> --name "Worker" --expires-in 30d', + '<%= config.bin %> <%= command.id %> --name "Test" --expires-in 2026-12-01', + ]; + + static override flags = { + name: Flags.string({ char: 'n', description: 'Token name' }), + 'expires-in': Flags.string({ + description: 'Expiration: 7d, 30d, 60d, 90d, none, or YYYY-MM-DD (default: none)', + }), + profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), + token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(TokensCreate); + const config = await loadConfig(flags.profile); + const callerToken = requireToken(flags.token, config); + + const anyFlagProvided = flags.name !== undefined || flags['expires-in'] !== undefined; + const interactive = !!process.stdin.isTTY && !anyFlagProvided; + + let name: string | undefined = flags.name?.trim(); + let expiresIn: string | undefined = flags['expires-in']; + + if (interactive) { + try { + name = (await input({ + message: 'Name:', + validate: (v) => (v.trim() ? true : 'Name cannot be empty'), + })).trim(); + + const choice = await select({ + message: 'Expiration:', + choices: EXPIRATION_CHOICES, + }); + + if (choice === 'custom') { + expiresIn = (await input({ + message: 'Expiration date (YYYY-MM-DD):', + validate: (v) => + /^\d{4}-\d{2}-\d{2}$/.test(v.trim()) + ? true + : 'Enter a date as YYYY-MM-DD', + })).trim(); + } else { + expiresIn = choice; + } + } catch (e) { + if (e instanceof Error && e.name === 'ExitPromptError') this.exit(1); + throw e; + } + } else { + if (!name) { + this.error('Missing --name. Required when running non-interactively.'); + } + // expiresIn defaults to undefined → no expiration + } + + let expiresAt: string | undefined; + try { + expiresAt = parseExpiresIn(expiresIn); + } catch (e) { + this.error(e instanceof Error ? e.message : String(e)); + } + + const body: { name: string; expiresAt?: string } = { name }; + if (expiresAt) body.expiresAt = expiresAt; + + let created: TokenSummaryWithSecret; + try { + const res = await fetch(`${BACKEND_URL}/v3/api-tokens`, { + method: 'POST', + headers: { + Authorization: `Bearer ${callerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to create token (${res.status})`); + } + created = (await res.json()) as TokenSummaryWithSecret; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + if (flags.json) { + this.log(JSON.stringify(created, null, 2)); + return; + } + + this.log(chalk.green('\n ✓ Token created!\n')); + this.log(` ${chalk.dim('Name:')} ${created.name || '(unnamed)'}`); + this.log(` ${chalk.dim('ID:')} ${created.id}`); + this.log(` ${chalk.dim('Expires:')} ${formatExpiresAt(created.expiresAt)}`); + this.log(''); + this.log(` ${chalk.dim('Token:')}`); + this.log(` ${chalk.bold(created.token)}`); + this.log(''); + this.log(chalk.yellow(' ⚠ Save this token securely — it will not be shown again.')); + this.log(''); + } +} diff --git a/src/commands/tokens/delete.ts b/src/commands/tokens/delete.ts new file mode 100644 index 0000000..5f4408e --- /dev/null +++ b/src/commands/tokens/delete.ts @@ -0,0 +1,119 @@ +import { Args, Flags } from '@oclif/core'; +import { input } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { loadConfig, requireToken } from '../../lib/config.js'; +import { BACKEND_URL } from '../../lib/api-client.js'; +import { + ListTokensResponse, + findActiveTokenId, + isAutomatedEnv, +} from '../../lib/tokens-helpers.js'; + +export default class TokensDelete extends BaseCommand { + static override description = 'Delete an API token (interactive only)'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> ', + ]; + + static override args = { + id: Args.string({ description: 'Token ID to delete', required: true }), + }; + + static override flags = { + profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), + token: Flags.string({ char: 't', description: 'API token', hidden: true }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(TokensDelete); + + // Hard refuse for non-interactive / automated environments. + if (isAutomatedEnv()) { + this.error( + 'linq tokens delete is an interactive-only command.\n Detected running in a script, CI, or by an AI assistant.', + ); + } + + const config = await loadConfig(flags.profile); + const callerToken = requireToken(flags.token, config); + + // Fetch the full token list so we can: (1) show the target's name/prefix, + // (2) detect if the target is the currently-active token. + let list: ListTokensResponse; + try { + const res = await fetch(`${BACKEND_URL}/v3/api-tokens`, { + headers: { Authorization: `Bearer ${callerToken}` }, + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to list tokens (${res.status})`); + } + list = (await res.json()) as ListTokensResponse; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + const target = list.tokens.find((t) => t.id === args.id); + if (!target) { + this.error(`No token found with id "${args.id}".`); + } + + const activeId = findActiveTokenId(callerToken, list.tokens); + if (activeId && target.id === activeId) { + this.error( + 'Cannot delete the token you are currently logged in with.\n' + + ` Active token: ${target.tokenPrefix}... (${target.name || 'unnamed'})\n` + + ' Log in with a different token first (linq login --token ...), then try again.' + ); + } + + this.log(''); + this.log(' Token to delete:'); + this.log(` ${chalk.dim('Name:')} ${target.name || '(unnamed)'}`); + this.log(` ${chalk.dim('Prefix:')} ${target.tokenPrefix}...`); + this.log(` ${chalk.dim('ID:')} ${target.id}`); + this.log(''); + this.log(chalk.yellow(' This is permanent. The token will be revoked and cannot be recovered.')); + this.log(''); + + let answer: string; + try { + answer = await input({ + message: 'Type "yes" to confirm:', + }); + } catch (e) { + if (e instanceof Error && e.name === 'ExitPromptError') { + this.log(chalk.dim(' Aborted.\n')); + this.exit(1); + } + throw e; + } + + if (answer.trim().toLowerCase() !== 'yes') { + this.log(chalk.dim('\n Aborted.\n')); + return; + } + + try { + const res = await fetch( + `${BACKEND_URL}/v3/api-tokens/${encodeURIComponent(target.id)}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${callerToken}` }, + } + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to delete token (${res.status})`); + } + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + this.log(chalk.green(`\n ✓ Deleted "${target.name || target.tokenPrefix + '...'}"\n`)); + } +} diff --git a/src/commands/tokens/list.ts b/src/commands/tokens/list.ts new file mode 100644 index 0000000..973c396 --- /dev/null +++ b/src/commands/tokens/list.ts @@ -0,0 +1,96 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { loadConfig, requireToken } from '../../lib/config.js'; +import { BACKEND_URL } from '../../lib/api-client.js'; +import { + ListTokensResponse, + formatExpiresAt, + formatLastUsed, + findActiveTokenId, +} from '../../lib/tokens-helpers.js'; + +export default class TokensList extends BaseCommand { + static override description = 'List API tokens for your account'; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static override flags = { + profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), + token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(TokensList); + const config = await loadConfig(flags.profile); + const token = requireToken(flags.token, config); + + let data: ListTokensResponse; + try { + const res = await fetch(`${BACKEND_URL}/v3/api-tokens`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to list tokens (${res.status})`); + } + data = (await res.json()) as ListTokensResponse; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + if (flags.json) { + this.log(JSON.stringify(data, null, 2)); + return; + } + + if (data.tokens.length === 0) { + this.log('\n No tokens yet.\n'); + this.log(chalk.dim(' Create one: linq tokens create --name "My Token"\n')); + return; + } + + const activeId = findActiveTokenId(token, data.tokens); + + // Build rows + const rows = data.tokens.map((t) => ({ + id: t.id, + name: t.name || '(unnamed)', + expires: formatExpiresAt(t.expiresAt), + lastUsed: formatLastUsed(t.lastUsedAt), + isActive: t.id === activeId, + })); + + // Column widths + const nameW = Math.max(4, ...rows.map((r) => r.name.length)); + const expiresW = Math.max(7, ...rows.map((r) => r.expires.length)); + const lastUsedW = Math.max(9, ...rows.map((r) => r.lastUsed.length)); + + const pad = (s: string, w: number) => s + ' '.repeat(Math.max(0, w - s.length)); + + this.log(`\n ${chalk.bold('Your API tokens')} (${data.tokens.length})\n`); + this.log( + ' ' + + chalk.dim(pad('ID', 36)) + ' ' + + chalk.dim(pad('NAME', nameW)) + ' ' + + chalk.dim(pad('EXPIRES', expiresW)) + ' ' + + chalk.dim(pad('LAST USED', lastUsedW)) + ); + for (const r of rows) { + this.log( + ' ' + + chalk.cyan(pad(r.id, 36)) + ' ' + + pad(r.name, nameW) + ' ' + + pad(r.expires, expiresW) + ' ' + + chalk.dim(pad(r.lastUsed, lastUsedW)) + + (r.isActive ? chalk.green(' ← active') : '') + ); + } + this.log(''); + } +} diff --git a/src/commands/tokens/regenerate.ts b/src/commands/tokens/regenerate.ts new file mode 100644 index 0000000..ececa6e --- /dev/null +++ b/src/commands/tokens/regenerate.ts @@ -0,0 +1,132 @@ +import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { BaseCommand } from '../../lib/base-command.js'; +import { + loadConfig, + requireToken, + saveProfile, + getCurrentProfile, +} from '../../lib/config.js'; +import { BACKEND_URL } from '../../lib/api-client.js'; +import { + ListTokensResponse, + TokenSummaryWithSecret, + parseExpiresIn, + formatExpiresAt, + findActiveTokenId, +} from '../../lib/tokens-helpers.js'; + +export default class TokensRegenerate extends BaseCommand { + static override description = + 'Regenerate an API token (mints a new secret, immediately expires the old one)'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> --expires-in 30d', + ]; + + static override args = { + id: Args.string({ description: 'Token ID to regenerate', required: true }), + }; + + static override flags = { + 'expires-in': Flags.string({ + description: 'Expiration: 7d, 30d, 60d, 90d, none, or YYYY-MM-DD', + default: 'none', + }), + profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), + token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(TokensRegenerate); + const config = await loadConfig(flags.profile); + const callerToken = requireToken(flags.token, config); + + let expiresAt: string | undefined; + try { + expiresAt = parseExpiresIn(flags['expires-in']); + } catch (e) { + this.error(e instanceof Error ? e.message : String(e)); + } + + // Detect whether the target is the active token (so we can auto-update + // the local profile after regenerate — the old secret is now invalid). + let isActive = false; + try { + const listRes = await fetch(`${BACKEND_URL}/v3/api-tokens`, { + headers: { Authorization: `Bearer ${callerToken}` }, + }); + if (listRes.ok) { + const list = (await listRes.json()) as ListTokensResponse; + const activeId = findActiveTokenId(callerToken, list.tokens); + isActive = !!activeId && activeId === args.id; + } + } catch { + // Non-fatal — we just won't auto-update the profile if we can't check. + } + + const body: { expiresAt?: string } = {}; + if (expiresAt) body.expiresAt = expiresAt; + + let created: TokenSummaryWithSecret; + try { + const res = await fetch( + `${BACKEND_URL}/v3/api-tokens/${encodeURIComponent(args.id)}/regenerate`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${callerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + } + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to regenerate token (${res.status})`); + } + created = (await res.json()) as TokenSummaryWithSecret; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + let profileUpdated = false; + if (isActive && created.token) { + const profileName = flags.profile || (await getCurrentProfile()) || 'default'; + try { + await saveProfile(profileName, { token: created.token }); + profileUpdated = true; + } catch { + // Non-fatal — user can still manually update with linq login --token + } + } + + if (flags.json) { + this.log(JSON.stringify({ ...created, profileUpdated }, null, 2)); + return; + } + + this.log(chalk.green('\n ✓ Token regenerated. The old token has been expired.\n')); + this.log(` ${chalk.dim('Name:')} ${created.name || '(unnamed)'}`); + this.log(` ${chalk.dim('ID:')} ${created.id}`); + this.log(` ${chalk.dim('Expires:')} ${formatExpiresAt(created.expiresAt)}`); + this.log(''); + this.log(` ${chalk.dim('Token:')}`); + this.log(` ${chalk.bold(created.token)}`); + this.log(''); + this.log(chalk.yellow(' ⚠ Save this token securely — it will not be shown again.')); + if (profileUpdated) { + this.log(''); + this.log(chalk.green(" ✓ Local profile updated. You're already logged in with the new token.")); + } else if (isActive) { + this.log(''); + this.log(chalk.yellow( + ' ⚠ This was your active token. Run `linq login --token ` to update your local config.' + )); + } + this.log(''); + } +} diff --git a/src/commands/tokens/rename.ts b/src/commands/tokens/rename.ts new file mode 100644 index 0000000..7677861 --- /dev/null +++ b/src/commands/tokens/rename.ts @@ -0,0 +1,61 @@ +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 { BACKEND_URL } from '../../lib/api-client.js'; +import { TokenSummary } from '../../lib/tokens-helpers.js'; + +export default class TokensRename extends BaseCommand { + static override description = 'Rename an API token'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> --name "New Name"', + ]; + + static override args = { + id: Args.string({ description: 'Token ID to rename', required: true }), + }; + + static override flags = { + name: Flags.string({ char: 'n', description: 'New token name', required: true }), + profile: Flags.string({ char: 'p', description: 'Config profile to use', hidden: true }), + token: Flags.string({ char: 't', description: 'API token', hidden: true }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(TokensRename); + const config = await loadConfig(flags.profile); + const callerToken = requireToken(flags.token, config); + + const newName = flags.name.trim(); + if (!newName) this.error('Name cannot be empty'); + + let updated: TokenSummary; + try { + const res = await fetch(`${BACKEND_URL}/v3/api-tokens/${encodeURIComponent(args.id)}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${callerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: newName }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.error(err.message || `Failed to rename token (${res.status})`); + } + updated = (await res.json()) as TokenSummary; + } catch (e) { + if (e instanceof Error && 'oclif' in e) throw e; + this.error('Could not connect to Linq. Please try again later.'); + } + + if (flags.json) { + this.log(JSON.stringify(updated, null, 2)); + return; + } + + this.log(chalk.green(`\n ✓ Renamed to "${updated.name}"\n`)); + } +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index ca8f053..7434ff9 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -37,12 +37,14 @@ export default class Whoami extends BaseCommand { return; } + const maskedToken = token.length > 12 ? `${token.slice(0, 12)}...` : token; + if (flags.json) { this.log(JSON.stringify({ email: config.email, name: config.name, phone: config.fromPhone, - apiKey: token, + apiKey: maskedToken, tier: config.tier, tenantType: config.tenantType, }, null, 2)); @@ -62,13 +64,13 @@ export default class Whoami extends BaseCommand { 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(` ${chalk.dim('API Key:')} ${maskedToken}`); } 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(` ${chalk.dim('API Key:')} ${maskedToken}`); } this.log(''); diff --git a/src/lib/auth-flow.ts b/src/lib/auth-flow.ts index 538d8e7..fac2149 100644 --- a/src/lib/auth-flow.ts +++ b/src/lib/auth-flow.ts @@ -80,6 +80,7 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { signupToken?: string; token?: string; orgId?: string; + partnerId?: string | null; email: string; name?: string; accountInfo?: { @@ -136,7 +137,19 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { } } - // Step 3: If new user, ask for name and finalize via /cli/signup + // If the email already has an account, bounce them to `linq login`. + // Signup is for brand-new users only. + if (!verifyResult.needsSignup) { + log(''); + log(chalk.yellow(` You already have a Linq account for ${chalk.bold(email)}.`)); + log(''); + log(` Use ${chalk.cyan('linq login --token ')} to sign in.`); + log(` If you've lost your token, generate a new one at ${chalk.cyan('https://dashboard.linqapp.com/api-tooling/')}`); + log(''); + exit(0); + } + + // Step 3: New user — ask for name and finalize via /cli/signup let isNewUser = false; if (verifyResult.needsSignup) { isNewUser = true; @@ -176,6 +189,7 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { const data = (await signupRes.json()) as { token: string; orgId: string; + partnerId: string | null; email: string; name: string; accountInfo: VerifyResult['accountInfo']; @@ -209,6 +223,7 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { await saveProfile('default', { token: verifyResult.token, fromPhone: phoneNumber, + ...(verifyResult.partnerId && { partnerId: verifyResult.partnerId }), orgId: verifyResult.orgId, email: verifyResult.email, name: verifyResult.name, @@ -225,7 +240,9 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { if (accountLabel) log(` ${chalk.dim('Account:')} ${accountLabel}`); log(` ${chalk.dim('Phone:')} ${chalk.bold(phoneNumber || 'pending')}`); log(` ${chalk.dim('Email:')} ${verifyResult.email}`); - log(` ${chalk.dim('API Key:')} ${verifyResult.token}`); + log(` ${chalk.dim('API Key:')} ${chalk.bold(verifyResult.token)}`); + log(''); + log(chalk.yellow(' ⚠ Save this token securely — it will not be shown again.')); log(''); if (phoneNumber && accountLabel === 'Shared Line') { log(' Your number is shared and allows a max of 20 contacts.'); diff --git a/src/lib/partner.ts b/src/lib/partner.ts deleted file mode 100644 index 526d0ed..0000000 --- a/src/lib/partner.ts +++ /dev/null @@ -1,19 +0,0 @@ -const WEBHOOK_BASE_URL = - process.env.WEBHOOK_BASE_URL || 'https://webhook.linqapp.com'; - -/** - * Fetch the partner ID for an API token. - * Returns null silently on any failure (network, auth, etc). - */ -export async function fetchPartnerId(token: string): Promise { - try { - const res = await fetch(`${WEBHOOK_BASE_URL}/partner-id`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) return null; - const data = (await res.json()) as { partnerId?: string }; - return data.partnerId ?? null; - } catch { - return null; - } -} diff --git a/src/lib/tokens-helpers.ts b/src/lib/tokens-helpers.ts new file mode 100644 index 0000000..6b4c180 --- /dev/null +++ b/src/lib/tokens-helpers.ts @@ -0,0 +1,82 @@ +const PRESET_DAYS: Record = { + '7d': 7, + '30d': 30, + '60d': 60, + '90d': 90, +}; + +export function parseExpiresIn(input: string | undefined): string | undefined { + if (!input || input === 'none') return undefined; + const days = PRESET_DAYS[input]; + if (days !== undefined) { + const d = new Date(); + d.setDate(d.getDate() + days); + return d.toISOString(); + } + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + const d = new Date(`${input}T23:59:59.999Z`); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + } + throw new Error( + `Invalid --expires-in "${input}". Use 7d, 30d, 60d, 90d, none, or YYYY-MM-DD.` + ); +} + +export function formatExpiresAt(iso: string | null | undefined): string { + if (!iso) return 'never'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return 'invalid'; + if (d.getTime() <= Date.now()) return 'expired'; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export function formatLastUsed(iso: string | null | undefined): string { + if (!iso) return 'never'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return 'invalid'; + const diffMs = Date.now() - d.getTime(); + const min = Math.floor(diffMs / 60_000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d ago`; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export interface TokenSummary { + id: string; + name: string | null; + tokenPrefix: string; + scopes: string[]; + expiresAt: string | null; + lastUsedAt: string | null; + createdAt?: string; +} + +export interface TokenSummaryWithSecret extends TokenSummary { + token: string; +} + +export interface ListTokensResponse { + tokens: TokenSummary[]; +} + +export function findActiveTokenId( + storedToken: string, + tokens: TokenSummary[], +): string | undefined { + const match = tokens.find((t) => storedToken.startsWith(t.tokenPrefix)); + return match?.id; +} + +export function isAutomatedEnv(): boolean { + return ( + !process.stdin.isTTY + || process.env.CI === 'true' + || process.env.CLAUDECODE === '1' + || !!process.env.CURSOR_TRACE_ID + || !!process.env.AIDER_API_KEY + ); +} diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 89d3d34..36a5498 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -67,19 +67,31 @@ describe('init', () => { expect(savedConfig.profiles.default.fromPhone).toBe('+12025551234'); }); - it('prompts for phone selection with multiple numbers', async () => { + it('prompts for phone selection with multiple numbers (paid tier)', async () => { mockSelect.mockResolvedValueOnce('default'); // profile selection mockPassword.mockResolvedValueOnce('test-token-123'); mockSelect.mockResolvedValueOnce('+18005551234'); // phone selection - mockFetch.mockResolvedValue( - createMockResponse(200, { + mockFetch + .mockResolvedValueOnce(createMockResponse(200, { phone_numbers: [ { phone_number: '+12025551234' }, { phone_number: '+18005551234' }, ], - }) - ); + })) + .mockResolvedValueOnce(createMockResponse(200, { + partnerId: 'p1', + orgId: '1', + name: 'Acme', + accountInfo: { + tier: 1, + phones: [ + { phoneNumber: '+12025551234', tenantType: 'SINGLE' }, + { phoneNumber: '+18005551234', tenantType: 'SINGLE' }, + ], + }, + })) + .mockResolvedValue(createMockResponse(200, { partnerId: 'p1' })); const config = await Config.load({ root: process.cwd() }); const cmd = new Init([], config); diff --git a/test/commands/login.test.ts b/test/commands/login.test.ts index bc37cae..dd60949 100644 --- a/test/commands/login.test.ts +++ b/test/commands/login.test.ts @@ -4,9 +4,13 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +const mockPassword = vi.fn(); +const mockSelect = vi.fn(); const mockInput = vi.fn(); vi.mock('@inquirer/prompts', () => ({ + password: (...args: unknown[]) => mockPassword(...args), + select: (...args: unknown[]) => mockSelect(...args), input: (...args: unknown[]) => mockInput(...args), })); @@ -22,7 +26,7 @@ function jsonResponse(status: number, body: unknown): Response { const { default: Login } = await import('../../src/commands/login.js'); -describe('login (email OTP flow)', () => { +describe('login (token paste)', () => { let tempDir: string; let originalHome: string | undefined; @@ -31,6 +35,8 @@ describe('login (email OTP flow)', () => { originalHome = process.env.HOME; process.env.HOME = tempDir; mockFetch.mockReset(); + mockPassword.mockReset(); + mockSelect.mockReset(); mockInput.mockReset(); }); @@ -43,98 +49,107 @@ describe('login (email OTP flow)', () => { return path.join(tempDir, '.linq', 'config.json'); } - it('happy path: existing user → send-otp → verify-code returns token → profile saved', async () => { - mockInput.mockResolvedValueOnce('123456'); // OTP code - + it('--token: validates, fetches account-info, saves full profile', async () => { + // Call order in login.ts: + // 1. client.phoneNumbers.list() — synapse + // 2. fetch /cli/account-info — zero-service (returns partnerId too) mockFetch - .mockResolvedValueOnce(jsonResponse(200, { sessionId: 'sess-1' })) - .mockResolvedValueOnce( - jsonResponse(200, { - needsSignup: false, - token: 'returning-user-token', - orgId: '999', - email: 'me@example.com', - name: 'Me', - accountInfo: { - tier: 0, - phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], - }, - }) - ); + .mockResolvedValueOnce(jsonResponse(200, { + phone_numbers: [{ id: 'pn-1', phone_number: '+18005551234' }], + })) + .mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + tier: 0, + phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], + }, + })); const config = await Config.load({ root: process.cwd() }); - const cmd = new Login(['--email', 'me@example.com'], config); + const cmd = new Login(['--token', 'linq_test_token', '--profile', 'default'], config); await cmd.run(); - // Should NOT have called /cli/signup — user already exists - const calls = mockFetch.mock.calls.map((c) => c[0]); - expect(calls.some((u: string) => u.endsWith('/cli/signup'))).toBe(false); - const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); - expect(saved.profiles.default.token).toBe('returning-user-token'); - expect(saved.profiles.default.email).toBe('me@example.com'); + expect(saved.profiles.default.token).toBe('linq_test_token'); + expect(saved.profiles.default.fromPhone).toBe('+18005551234'); + expect(saved.profiles.default.orgId).toBe('999'); + expect(saved.profiles.default.tier).toBe(0); + expect(saved.profiles.default.tenantType).toBe('MULTI'); + expect(saved.profiles.default.name).toBe('Acme'); + expect(saved.profile).toBe('default'); }); - it('reprompts on invalid OTP, then succeeds on second attempt', async () => { - mockInput - .mockResolvedValueOnce('000000') // wrong code - .mockResolvedValueOnce('123456'); // right code + it('--token: invalid token errors out', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthorized' })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new Login(['--token', 'bad-token', '--profile', 'default'], config); + + await expect(cmd.run()).rejects.toThrow(/Invalid token|API error/); + }); + it('shared-line user with multiple phones auto-picks first (no prompt)', async () => { mockFetch - // send-otp - .mockResolvedValueOnce(jsonResponse(200, { sessionId: 'sess-1' })) - // first verify-code: bad OTP - .mockResolvedValueOnce( - jsonResponse(400, { message: 'Invalid verification code.' }) - ) - // second verify-code: success - .mockResolvedValueOnce( - jsonResponse(200, { - needsSignup: false, - token: 'after-retry-token', - orgId: '1', - email: 'me@example.com', - name: 'Me', - accountInfo: { - tier: 0, - phones: [{ phoneNumber: '+18005551234', tenantType: 'MULTI' }], - }, - }) - ); + .mockResolvedValueOnce(jsonResponse(200, { + phone_numbers: [ + { id: 'pn-1', phone_number: '+18005551111' }, + { id: 'pn-2', phone_number: '+18005552222' }, + ], + })) + .mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + tier: 0, + phones: [ + { phoneNumber: '+18005551111', tenantType: 'MULTI' }, + { phoneNumber: '+18005552222', tenantType: 'MULTI' }, + ], + }, + })); const config = await Config.load({ root: process.cwd() }); - const cmd = new Login(['--email', 'me@example.com'], config); + const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); await cmd.run(); - // Both OTPs were consumed - expect(mockInput).toHaveBeenCalledTimes(2); - + expect(mockSelect).not.toHaveBeenCalled(); const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); - expect(saved.profiles.default.token).toBe('after-retry-token'); + expect(saved.profiles.default.fromPhone).toBe('+18005551111'); }); - it('refuses to run if already logged in', async () => { - const configDir = path.join(tempDir, '.linq'); - await fs.mkdir(configDir, { recursive: true }); - const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - await fs.writeFile( - path.join(configDir, 'config.json'), - JSON.stringify({ - profile: 'default', - profiles: { - default: { - token: 'existing-token', - email: 'me@example.com', - sessionExpiresAt: future, - }, + it('paid user with multiple phones is prompted', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + phone_numbers: [ + { id: 'pn-1', phone_number: '+18005551111' }, + { id: 'pn-2', phone_number: '+18005552222' }, + ], + })) + .mockResolvedValueOnce(jsonResponse(200, { + partnerId: 'partner-1', + orgId: '999', + name: 'Acme', + accountInfo: { + tier: 1, + phones: [ + { phoneNumber: '+18005551111', tenantType: 'SINGLE' }, + { phoneNumber: '+18005552222', tenantType: 'SINGLE' }, + ], }, - }) - ); + })); + + mockSelect.mockResolvedValueOnce('+18005552222'); const config = await Config.load({ root: process.cwd() }); - const cmd = new Login(['--email', 'other@example.com'], config); + const cmd = new Login(['--token', 'linq_test', '--profile', 'default'], config); await cmd.run(); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockSelect).toHaveBeenCalledTimes(1); + const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(saved.profiles.default.fromPhone).toBe('+18005552222'); + expect(saved.profiles.default.tier).toBe(1); }); }); diff --git a/test/commands/tokens/create.test.ts b/test/commands/tokens/create.test.ts new file mode 100644 index 0000000..b20812f --- /dev/null +++ b/test/commands/tokens/create.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockInput = vi.fn(); +const mockSelect = vi.fn(); + +vi.mock('@inquirer/prompts', () => ({ + input: (...args: unknown[]) => mockInput(...args), + select: (...args: unknown[]) => mockSelect(...args), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const { default: TokensCreate } = await import('../../../src/commands/tokens/create.js'); + +describe('tokens create', () => { + let tempDir: string; + let originalHome: string | undefined; + let logs: string[] = []; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'linq-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + mockFetch.mockReset(); + mockInput.mockReset(); + mockSelect.mockReset(); + logs = []; + + const configDir = path.join(tempDir, '.linq'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + profile: 'default', + profiles: { default: { token: 'caller-token' } }, + }) + ); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('non-interactive: --name only → posts with no expiresAt', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + id: 'new-id', + name: 'Test', + tokenPrefix: 'linq_xxx', + scopes: [], + expiresAt: null, + lastUsedAt: null, + createdAt: '2026-05-20T00:00:00Z', + token: 'linq_xxxFULLSECRET', + }) + ); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensCreate(['--name', 'Test'], config); + const logSpy = vi.spyOn(cmd, 'log').mockImplementation((m?: string) => { logs.push(m ?? ''); }); + await cmd.run(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain('/v3/api-tokens'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ name: 'Test' }); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(logs.join('\n')).toContain('linq_xxxFULLSECRET'); + expect(logs.join('\n')).toContain('Save this token'); + }); + + it('non-interactive: --name + --expires-in 30d → posts with expiresAt set', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + id: 'new-id', name: 'Test', tokenPrefix: 'linq_xxx', + scopes: [], expiresAt: null, lastUsedAt: null, + createdAt: '2026-05-20T00:00:00Z', token: 'linq_xxx', + }) + ); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensCreate(['--name', 'Test', '--expires-in', '30d'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body.name).toBe('Test'); + expect(typeof body.expiresAt).toBe('string'); + // 30d from now → should be a future ISO date roughly 30 days out + const expires = new Date(body.expiresAt).getTime(); + const diffDays = (expires - Date.now()) / (1000 * 60 * 60 * 24); + expect(diffDays).toBeGreaterThan(29); + expect(diffDays).toBeLessThan(31); + }); + + it('non-interactive: missing --name in non-TTY → error', async () => { + // In vitest, stdin.isTTY is undefined/false → non-interactive. + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensCreate([], config); + await expect(cmd.run()).rejects.toThrow(/Missing --name/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('non-interactive: invalid --expires-in → error', async () => { + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensCreate(['--name', 'X', '--expires-in', 'bogus'], config); + await expect(cmd.run()).rejects.toThrow(/Invalid --expires-in/); + }); + + it('server error → surfaces message', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(400, { message: 'Name already exists' })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensCreate(['--name', 'Dup'], config); + await expect(cmd.run()).rejects.toThrow(/Name already exists/); + }); +}); diff --git a/test/commands/tokens/delete.test.ts b/test/commands/tokens/delete.test.ts new file mode 100644 index 0000000..15cfc07 --- /dev/null +++ b/test/commands/tokens/delete.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockInput = vi.fn(); + +vi.mock('@inquirer/prompts', () => ({ + input: (...args: unknown[]) => mockInput(...args), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const { default: TokensDelete } = await import('../../../src/commands/tokens/delete.js'); + +describe('tokens delete', () => { + let tempDir: string; + let originalHome: string | undefined; + let originalIsTTY: boolean | undefined; + let originalCI: string | undefined; + let originalClaude: string | undefined; + let originalCursor: string | undefined; + let originalAider: string | undefined; + const STORED = 'linq_abc12345XXXXXXXXXXXXXXXXXXXXXX'; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'linq-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + mockFetch.mockReset(); + mockInput.mockReset(); + + originalIsTTY = process.stdin.isTTY; + originalCI = process.env.CI; + originalClaude = process.env.CLAUDECODE; + originalCursor = process.env.CURSOR_TRACE_ID; + originalAider = process.env.AIDER_API_KEY; + + const configDir = path.join(tempDir, '.linq'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + profile: 'default', + profiles: { default: { token: STORED } }, + }) + ); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + (process.stdin as unknown as { isTTY: boolean | undefined }).isTTY = originalIsTTY; + process.env.CI = originalCI; + process.env.CLAUDECODE = originalClaude; + process.env.CURSOR_TRACE_ID = originalCursor; + process.env.AIDER_API_KEY = originalAider; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function setInteractive() { + (process.stdin as unknown as { isTTY: boolean }).isTTY = true; + delete process.env.CI; + delete process.env.CLAUDECODE; + delete process.env.CURSOR_TRACE_ID; + delete process.env.AIDER_API_KEY; + } + + function setTTY(value: boolean) { + (process.stdin as unknown as { isTTY: boolean }).isTTY = value; + } + + it('refuses in non-interactive environments', async () => { + setTTY(false); + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensDelete(['some-id'], config); + await expect(cmd.run()).rejects.toThrow(/interactive-only/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('hard-refuses deleting the active token', async () => { + setInteractive(); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensDelete(['active-id'], config); + await expect(cmd.run()).rejects.toThrow(/currently logged in with/); + expect(mockInput).not.toHaveBeenCalled(); + }); + + it('errors if the token id is unknown', async () => { + setInteractive(); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensDelete(['unknown-id'], config); + await expect(cmd.run()).rejects.toThrow(/No token found/); + }); + + it('aborts if user does not type "yes"', async () => { + setInteractive(); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + { id: 'other-id', name: 'Other', tokenPrefix: 'XYZ12345', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + mockInput.mockResolvedValueOnce('no'); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensDelete(['other-id'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + // Should not have called DELETE + const calls = mockFetch.mock.calls.map((c) => (c[1] as RequestInit | undefined)?.method); + expect(calls).not.toContain('DELETE'); + }); + + it('deletes when user confirms with "yes"', async () => { + setInteractive(); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + { id: 'other-id', name: 'Other', tokenPrefix: 'XYZ12345', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })) + .mockResolvedValueOnce(jsonResponse(200, { id: 'other-id', deleted: true })); + + mockInput.mockResolvedValueOnce('yes'); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensDelete(['other-id'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + const deleteCall = mockFetch.mock.calls.find( + (c) => (c[1] as RequestInit | undefined)?.method === 'DELETE' + ); + expect(deleteCall).toBeDefined(); + expect(deleteCall![0]).toContain('/v3/api-tokens/other-id'); + }); +}); diff --git a/test/commands/tokens/list.test.ts b/test/commands/tokens/list.test.ts new file mode 100644 index 0000000..cdf83a1 --- /dev/null +++ b/test/commands/tokens/list.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const { default: TokensList } = await import('../../../src/commands/tokens/list.js'); + +describe('tokens list', () => { + let tempDir: string; + let originalHome: string | undefined; + let logs: string[] = []; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'linq-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + mockFetch.mockReset(); + logs = []; + + const configDir = path.join(tempDir, '.linq'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + profile: 'default', + profiles: { default: { token: 'linq_abc123XXXXXXXXXXXXXXXXXXXXXX' } }, + }) + ); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function newCmd(argv: string[]): TokensList { + return new TokensList(argv, new Proxy({} as Config, { get: () => undefined })); + } + + it('renders empty state when no tokens', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { tokens: [] })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensList([], config); + const logSpy = vi.spyOn(cmd, 'log').mockImplementation((m?: string) => { logs.push(m ?? ''); }); + await cmd.run(); + + expect(logs.join('\n')).toContain('No tokens yet'); + }); + + it('renders tokens with active marker on the matching prefix', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'id-1', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + { id: 'id-2', name: 'Other', tokenPrefix: 'XYZ12345', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensList([], config); + const logSpy = vi.spyOn(cmd, 'log').mockImplementation((m?: string) => { logs.push(m ?? ''); }); + await cmd.run(); + + const out = logs.join('\n'); + expect(out).toContain('id-1'); + expect(out).toContain('Mine'); + expect(out).toContain('active'); + // active marker should appear on the Mine row, not on Other + const mineLine = logs.find((l) => l.includes('id-1')); + const otherLine = logs.find((l) => l.includes('id-2')); + expect(mineLine).toMatch(/active/); + expect(otherLine).not.toMatch(/active/); + }); + + it('--json outputs the raw response', async () => { + const body = { + tokens: [ + { id: 'id-1', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + }; + mockFetch.mockResolvedValueOnce(jsonResponse(200, body)); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensList(['--json'], config); + const logSpy = vi.spyOn(cmd, 'log').mockImplementation((m?: string) => { logs.push(m ?? ''); }); + await cmd.run(); + + expect(JSON.parse(logs.join(''))).toEqual(body); + }); + + it('errors on server failure', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(500, { message: 'boom' })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensList([], config); + await expect(cmd.run()).rejects.toThrow(/boom/); + }); +}); diff --git a/test/commands/tokens/regenerate.test.ts b/test/commands/tokens/regenerate.test.ts new file mode 100644 index 0000000..54cdd38 --- /dev/null +++ b/test/commands/tokens/regenerate.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const { default: TokensRegenerate } = await import('../../../src/commands/tokens/regenerate.js'); + +describe('tokens regenerate', () => { + let tempDir: string; + let originalHome: string | undefined; + const STORED = 'linq_abc12345XXXXXXXXXXXXXXXXXXXXXX'; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'linq-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + mockFetch.mockReset(); + + const configDir = path.join(tempDir, '.linq'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + profile: 'default', + profiles: { default: { token: STORED } }, + }) + ); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function configPath() { + return path.join(tempDir, '.linq', 'config.json'); + } + + it('regenerating a non-active token does NOT update local profile', async () => { + // 1st call: list, used to detect active + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + { id: 'other-id', name: 'Other', tokenPrefix: 'XYZ12345', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + // 2nd call: regenerate + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + id: 'other-id', name: 'Other', tokenPrefix: 'linq_new', + scopes: [], expiresAt: null, lastUsedAt: null, + createdAt: '2026-05-20T00:00:00Z', token: 'linq_newSECRET', + })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensRegenerate(['other-id'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + // Config token unchanged + const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(saved.profiles.default.token).toBe(STORED); + }); + + it('regenerating the active token DOES update local profile', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + tokens: [ + { id: 'active-id', name: 'Mine', tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + ], + })); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + id: 'active-id', name: 'Mine', tokenPrefix: 'linq_new', + scopes: [], expiresAt: null, lastUsedAt: null, + createdAt: '2026-05-20T00:00:00Z', token: 'linq_newSECRET999', + })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensRegenerate(['active-id'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + const saved = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(saved.profiles.default.token).toBe('linq_newSECRET999'); + }); + + it('regenerate server error surfaces message', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { tokens: [] })); + mockFetch.mockResolvedValueOnce(jsonResponse(500, { message: 'PNS down' })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensRegenerate(['some-id'], config); + await expect(cmd.run()).rejects.toThrow(/PNS down/); + }); +}); diff --git a/test/commands/tokens/rename.test.ts b/test/commands/tokens/rename.test.ts new file mode 100644 index 0000000..c6039ad --- /dev/null +++ b/test/commands/tokens/rename.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const { default: TokensRename } = await import('../../../src/commands/tokens/rename.js'); + +describe('tokens rename', () => { + let tempDir: string; + let originalHome: string | undefined; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'linq-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + mockFetch.mockReset(); + + const configDir = path.join(tempDir, '.linq'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'config.json'), + JSON.stringify({ + profile: 'default', + profiles: { default: { token: 'caller-token' } }, + }) + ); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('PUTs to /v3/api-tokens/:id with the new name', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + id: 'abc', name: 'New Name', tokenPrefix: 'linq_xxx', + scopes: [], expiresAt: null, lastUsedAt: null, + }) + ); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensRename(['abc', '--name', 'New Name'], config); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain('/v3/api-tokens/abc'); + expect((init as RequestInit).method).toBe('PUT'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ name: 'New Name' }); + }); + + it('surfaces server error message', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(404, { message: 'Token not found' })); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new TokensRename(['abc', '--name', 'X'], config); + await expect(cmd.run()).rejects.toThrow(/Token not found/); + }); +}); diff --git a/test/lib/tokens-helpers.test.ts b/test/lib/tokens-helpers.test.ts new file mode 100644 index 0000000..564891d --- /dev/null +++ b/test/lib/tokens-helpers.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + parseExpiresIn, + formatExpiresAt, + formatLastUsed, + findActiveTokenId, + isAutomatedEnv, +} from '../../src/lib/tokens-helpers.js'; + +describe('parseExpiresIn', () => { + it('returns undefined for none / empty', () => { + expect(parseExpiresIn(undefined)).toBeUndefined(); + expect(parseExpiresIn('none')).toBeUndefined(); + }); + + it('converts day presets to future ISO dates', () => { + const iso = parseExpiresIn('30d'); + expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T/); + const diffDays = (new Date(iso!).getTime() - Date.now()) / (1000 * 60 * 60 * 24); + expect(diffDays).toBeGreaterThan(29); + expect(diffDays).toBeLessThan(31); + }); + + it('accepts YYYY-MM-DD as a custom date', () => { + const iso = parseExpiresIn('2030-01-15'); + expect(iso).toMatch(/^2030-01-15T/); + }); + + it('throws on garbage', () => { + expect(() => parseExpiresIn('forever')).toThrow(/Invalid/); + expect(() => parseExpiresIn('30days')).toThrow(/Invalid/); + }); +}); + +describe('formatExpiresAt', () => { + it('returns "never" for null/undefined', () => { + expect(formatExpiresAt(null)).toBe('never'); + expect(formatExpiresAt(undefined)).toBe('never'); + }); + + it('returns "expired" for past dates', () => { + expect(formatExpiresAt('2020-01-01T00:00:00Z')).toBe('expired'); + }); + + it('formats future dates as a short calendar string', () => { + const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + expect(formatExpiresAt(future)).toMatch(/\w+ \d+, \d{4}/); + }); +}); + +describe('formatLastUsed', () => { + it('returns "never" for null', () => { + expect(formatLastUsed(null)).toBe('never'); + }); + + it('uses relative time within a day', () => { + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + expect(formatLastUsed(fiveMinAgo)).toBe('5m ago'); + const twoHrAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + expect(formatLastUsed(twoHrAgo)).toBe('2h ago'); + }); +}); + +describe('findActiveTokenId', () => { + const tokens = [ + { id: 'id-1', name: null, tokenPrefix: 'linq_abc', scopes: [], expiresAt: null, lastUsedAt: null }, + { id: 'id-2', name: null, tokenPrefix: 'XYZ12345', scopes: [], expiresAt: null, lastUsedAt: null }, + ]; + + it('matches by prefix', () => { + expect(findActiveTokenId('linq_abc999XXX', tokens)).toBe('id-1'); + expect(findActiveTokenId('XYZ12345ZZZ', tokens)).toBe('id-2'); + }); + + it('returns undefined when nothing matches', () => { + expect(findActiveTokenId('nope_xxx', tokens)).toBeUndefined(); + }); +}); + +describe('isAutomatedEnv', () => { + it('returns true when stdin is not a TTY', () => { + // In vitest, stdin.isTTY is undefined → counts as automated + expect(isAutomatedEnv()).toBe(true); + }); +});