diff --git a/ERRORS.md b/ERRORS.md index 2e54f98..bdbb555 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -62,9 +62,9 @@ Every command inherits these. Specific messages are subject to change, but the * | Scenario | Typical message | |---|---| | No credentials | `Not signed in.` with a hint listing `auth login` variants | -| HTTP 401 | `Not authenticated` | +| HTTP 401 | `Not signed in.` | | HTTP 403 | `Permission denied` | -| OAuth refresh failed | `Token refresh failed` with a hint to re-authenticate | +| OAuth refresh failed | `Couldn't refresh your session ()` with a hint to sign in again | | WebSocket upgrade rejected (for streaming commands) | `WebSocket upgrade rejected ()` | ### Rate limit / plan (exit `4`) @@ -78,7 +78,7 @@ Every command inherits these. Specific messages are subject to change, but the * | Scenario | Typical message | |---|---| -| Unknown command | `Unknown command: polylane ` with a hint pointing at `polylane --help` | +| Unknown command | `Unknown command: polylane `, plus `Closest match: polylane ` when one is close, with a hint pointing at `polylane --help` | | Unknown flag | `Unknown flag: ` | | Flag requires a value | `Flag requires a value` | | Flag expects a number | `Flag expects a number, got ""` | diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 517d072..150ade4 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -104,8 +104,9 @@ async function fetchOIDCConfig(domain: string): Promise { const res = await fetch(url); if (!res.ok) { throw new CLIError( - `Failed to fetch OIDC config from ${url}: ${res.status}`, - ExitCode.NETWORK + `Couldn't fetch the sign-in configuration from ${url} (${res.status})`, + ExitCode.NETWORK, + 'Check your network and the --domain flag, then try again' ); } return (await res.json()) as OIDCConfig; @@ -246,11 +247,11 @@ function renderErrorHtml(error: string): string { return map[c] ?? c; }); return renderShell( - 'Authentication failed', + 'Sign-in did not complete', // Red-500 from Tailwind default — high-contrast destructive accent '#ef4444', - 'Authentication failed', - safe, + 'Sign-in did not complete', + `Close this tab and run polylane auth login again from your terminal. (${safe})`, ALERT_ICON ); } @@ -307,12 +308,20 @@ async function startCallbackServer(expectedState: string, timeoutMs: number): Pr }); server.on('error', (err) => { - reject(new CLIError(`Failed to start callback server: ${err.message}`, ExitCode.GENERAL)); + reject( + new CLIError( + `Couldn't start the local sign-in listener: ${err.message}`, + ExitCode.GENERAL, + `Check that port ${CALLBACK_PORT} is free, then run \`polylane auth login\` again` + ) + ); }); setTimeout(() => { server.close(); - reject(new CLIError('OAuth flow timed out', ExitCode.TIMEOUT)); + reject( + new CLIError('Sign-in timed out', ExitCode.TIMEOUT, 'Run `polylane auth login` to try again') + ); }, timeoutMs); }); } @@ -391,7 +400,11 @@ export async function oauthBrowserFlow( if (!tokenRes.ok) { const body = await tokenRes.text(); - throw new CLIError(`Token exchange failed: ${tokenRes.status} ${body}`, ExitCode.AUTH); + throw new CLIError( + `Sign-in did not complete: the token exchange returned ${tokenRes.status} ${body}`, + ExitCode.AUTH, + 'Run `polylane auth login` to try again' + ); } return (await tokenRes.json()) as OAuthTokenResponse; } @@ -463,12 +476,13 @@ export async function oauthDeviceCodeFlow(config: Config): Promise { diff --git a/src/auth/refresh.ts b/src/auth/refresh.ts index 8b004a6..598ff5e 100644 --- a/src/auth/refresh.ts +++ b/src/auth/refresh.ts @@ -31,9 +31,9 @@ export async function refreshToken( if (!res.ok) { throw new CLIError( - `Token refresh failed: ${res.status}`, + `Couldn't refresh your session (${res.status})`, ExitCode.AUTH, - 'Run `polylane auth login` to re-authenticate' + 'Run `polylane auth login` to sign in again' ); } diff --git a/src/auth/resolver.ts b/src/auth/resolver.ts index 632f57b..5165bae 100644 --- a/src/auth/resolver.ts +++ b/src/auth/resolver.ts @@ -38,9 +38,9 @@ export async function resolveCredential(config: Config): Promise { } throw new CLIError( - 'Not authenticated', + 'Not signed in.', ExitCode.AUTH, - 'Run `polylane auth login` to authenticate' + 'Run `polylane auth login`' ); } diff --git a/src/auth/setup.ts b/src/auth/setup.ts index 64b80ed..8a0dae9 100644 --- a/src/auth/setup.ts +++ b/src/auth/setup.ts @@ -10,15 +10,15 @@ export async function ensureAuth(config: Config): Promise { if (!isInteractive(config.nonInteractive)) { throw new CLIError( - 'Not authenticated', + 'Not signed in.', ExitCode.AUTH, - 'Run `polylane auth login` to authenticate' + 'Run `polylane auth login`' ); } throw new CLIError( - 'Not authenticated', + 'Not signed in.', ExitCode.AUTH, - 'Run `polylane auth login` to authenticate' + 'Run `polylane auth login`' ); } diff --git a/src/commands/artifact/delete.ts b/src/commands/artifact/delete.ts index f5b2c5c..1c92101 100644 --- a/src/commands/artifact/delete.ts +++ b/src/commands/artifact/delete.ts @@ -26,7 +26,7 @@ export const artifactDeleteCommand: Command = { false ); if (!ok) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index a7dcd3a..9465650 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -27,7 +27,7 @@ interface WorkspaceItem { } async function validateApiKey(config: Config, key: string): Promise { - const spinner = new Spinner('Validating API key…'); + const spinner = new Spinner('Checking the API key…'); spinner.start(); try { const res = await request( @@ -41,7 +41,11 @@ async function validateApiKey(config: Config, key: string): Promise { - const spinner = new Spinner('Fetching workspaces…'); + const spinner = new Spinner('Finding your workspaces…'); spinner.start(); try { const list = await requestJson<{ items: WorkspaceItem[]; count: number }>( diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index f711078..408d544 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -24,7 +24,7 @@ export const authLogoutCommand: Command = { true ); if (!ok) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 418a46a..6d73b5e 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -79,15 +79,15 @@ function workspaceStep(landing?: Landing): string[] { case 'created': return [ landing.workspaceSlug - ? ` 1. Your first workspace ("${landing.workspaceSlug}") was created — set it as the default` - : ` 1. Your first workspace was created — set it as the default`, + ? ` 1. Your first workspace ("${landing.workspaceSlug}") was created: set it as the default` + : ` 1. Your first workspace was created: set it as the default`, ...setDefault, ]; case 'joined': return [ landing.workspaceSlug - ? ` 1. You joined the "${landing.workspaceSlug}" workspace — set it as the default` - : ` 1. You joined an existing workspace — set it as the default`, + ? ` 1. You joined the "${landing.workspaceSlug}" workspace: set it as the default` + : ` 1. You joined an existing workspace: set it as the default`, ...setDefault, ]; case 'existing': @@ -159,7 +159,7 @@ async function verifyEmail(config: Config, email: string, code: string): Promise const json = (await res.json()) as VerifyEmailEnvelope; if (res.status === 400) return null; if (!res.ok || !json.success) { - throw new CLIError(json.error?.detail ?? json.error?.message ?? 'Email verification failed', ExitCode.GENERAL); + throw new CLIError(json.error?.detail ?? json.error?.message ?? 'Email verification did not complete', ExitCode.GENERAL); } const expiresAt = parseSessionExpiresAt(res.headers.get('set-cookie')) ?? @@ -233,7 +233,7 @@ async function emailSignup(config: Config, args: Record): Promi }); const json = (await res.json()) as SignupEnvelope; if (!res.ok || !json.success) { - throw new CLIError(json.error?.detail ?? json.error?.message ?? 'Signup failed', ExitCode.GENERAL); + throw new CLIError(json.error?.detail ?? json.error?.message ?? 'Signup did not complete', ExitCode.GENERAL); } const { user, token } = json.result; if (!user) { @@ -300,7 +300,7 @@ async function emailSignup(config: Config, args: Record): Promi } } throw new CLIError( - 'Email verification failed', + 'Email verification did not complete', ExitCode.GENERAL, `Re-run \`polylane auth signup\` for a fresh code, or finish later with: polylane auth signup --email ${email} --code ` ); diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 25aaf0a..6e3b62e 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -115,7 +115,7 @@ async function confirmBrowserConnect( process.stderr.write(`✓ Connected: ${account.alias || account.account}${detail ? ` (${detail})` : ''}\n`); } } else { - process.stderr.write('Not seeing the connection yet — check with `polylane cloud list`.\n'); + process.stderr.write('Not seeing the connection yet. Check with `polylane cloud list`.\n'); } } @@ -131,7 +131,7 @@ function printConnectSuccess(config: Config, result: ConnectResult): void { process.stderr.write(`✓ Connected: ${account.alias || account.account}${detail ? ` (${detail})` : ''}\n`); } for (const failure of result.failures) { - process.stderr.write(`Failed to connect ${failure.account}: ${failure.message}\n`); + process.stderr.write(`Couldn't connect ${failure.account}: ${failure.message}\n`); } } diff --git a/src/commands/cloud/disconnect.ts b/src/commands/cloud/disconnect.ts index 6519370..b866eb9 100644 --- a/src/commands/cloud/disconnect.ts +++ b/src/commands/cloud/disconnect.ts @@ -28,7 +28,7 @@ export const cloudDisconnectCommand: Command = { false ); if (!confirmed) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/help.ts b/src/commands/help.ts index b397cee..cfd66dc 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,6 +1,6 @@ import type { Command } from '../command'; import type { Config } from '../config/schema'; -import { registry, renderHelp } from '../registry'; +import { registry, renderHelp, unknownCommandMessage } from '../registry'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; @@ -16,7 +16,7 @@ export const helpCommand: Command = { const resolved = registry.resolve(positional); if (!resolved) { throw new CLIError( - `Unknown command: polylane ${positional.join(' ')}`, + unknownCommandMessage(positional, registry.suggestCommand(positional)), ExitCode.USAGE, 'Run `polylane --help` to list commands' ); diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index f286cb7..3025a30 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -290,7 +290,7 @@ export async function waitForBrowserCompletion( } const spinner = new Spinner(`Waiting for ${opts.waitingFor}… (Ctrl+C to stop waiting)`); const onSigint = (): void => { - spinner.stop(`Stopped waiting — the browser setup continues on its own. ${opts.interruptHint}`); + spinner.stop(`Stopped waiting. The browser setup continues on its own. ${opts.interruptHint}`); process.exit(0); }; spinner.start(); diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 0e86ac4..64a0c0d 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -52,9 +52,9 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string { value: 'honeycomb', label: 'Honeycomb', hint: 'configuration API key' }, { value: 'axiom', label: 'Axiom', hint: 'API token' }, { value: 'betterstack', label: 'Better Stack', hint: 'global, Uptime and Telemetry tokens' }, - { value: 'devin', label: 'Devin', hint: 'API key — coding agent' }, - { value: 'cursor', label: 'Cursor', hint: 'API key — coding agent' }, - { value: 'factory', label: 'Factory', hint: 'API key — coding agent' }, + { value: 'devin', label: 'Devin', hint: 'API key · coding agent' }, + { value: 'cursor', label: 'Cursor', hint: 'API key · coding agent' }, + { value: 'factory', label: 'Factory', hint: 'API key · coding agent' }, { value: 'mcp', label: 'MCP server', hint: 'any MCP server by URL' }, ]; @@ -134,7 +134,7 @@ async function confirmBrowserConnect( if (found) { process.stderr.write(`✓ ${label} connected: ${found.name}\n`); } else { - process.stderr.write('Not seeing the connection yet — check with `polylane integration list`.\n'); + process.stderr.write('Not seeing the connection yet. Check with `polylane integration list`.\n'); } } @@ -277,7 +277,7 @@ async function connectWithCredentials( let apiKey = ''; let appKey = ''; const ok = await runSteps([ - choiceStep(config, args, 'site', '--site', 'Datadog site — the one in your Datadog URL', DATADOG_SITES, (v) => { + choiceStep(config, args, 'site', '--site', 'Datadog site: the one in your Datadog URL', DATADOG_SITES, (v) => { site = v; }), secretStep( @@ -324,7 +324,7 @@ async function connectWithCredentials( args, 'region', '--region', - 'Honeycomb region — the one in your Honeycomb URL', + 'Honeycomb region: the one in your Honeycomb URL', [ { value: 'us', label: 'US (api.honeycomb.io)' }, { value: 'eu', label: 'EU (api.eu1.honeycomb.io)' }, @@ -362,7 +362,7 @@ async function connectWithCredentials( args, 'region', '--region', - 'Axiom edge deployment region — see your organization settings (https://app.axiom.co/settings/org)', + 'Axiom edge deployment region: see your organization settings (https://app.axiom.co/settings/org)', [ { value: 'us-east-1', label: 'US East 1' }, { value: 'eu-central-1', label: 'EU Central 1' }, @@ -475,7 +475,7 @@ async function connectWithCredentials( } const answer = await promptConfirmOrBack( { nonInteractive: config.nonInteractive }, - `Use ${agent.name} for all autofixes? (instead of the Polylane executor — you can change this later)`, + `Use ${agent.name} for all autofixes? (instead of the Polylane executor; you can change this later)`, true ); if (answer === BACK) return BACK; diff --git a/src/commands/integration/disconnect.ts b/src/commands/integration/disconnect.ts index c833d3e..b7b3288 100644 --- a/src/commands/integration/disconnect.ts +++ b/src/commands/integration/disconnect.ts @@ -28,7 +28,7 @@ export const integrationDisconnectCommand: Command = { false ); if (!confirmed) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/memory/delete.ts b/src/commands/memory/delete.ts index 9a4e9ed..8e2e005 100644 --- a/src/commands/memory/delete.ts +++ b/src/commands/memory/delete.ts @@ -22,7 +22,7 @@ export const memoryDeleteCommand: Command = { false ); if (!ok) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/note/delete.ts b/src/commands/note/delete.ts index 91257d0..2715381 100644 --- a/src/commands/note/delete.ts +++ b/src/commands/note/delete.ts @@ -22,7 +22,7 @@ export const noteDeleteCommand: Command = { false ); if (!ok) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/note/global-clear.ts b/src/commands/note/global-clear.ts index 4f97b1d..46eeaa8 100644 --- a/src/commands/note/global-clear.ts +++ b/src/commands/note/global-clear.ts @@ -20,7 +20,7 @@ export const noteGlobalClearCommand: Command = { false ); if (!ok) { - process.stderr.write('Cancelled\n'); + process.stderr.write('Cancelled. Nothing changed.\n'); return; } } diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 9624e40..2aa16e7 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -409,7 +409,7 @@ export const scanCommand: Command = { outputJson({ workspaceId, targets: 0, reports: [], risks: [], consoleUrl: null }); return; } - say('Nothing to scan — no cloud accounts or integrations are connected.'); + say('Nothing to scan yet: no cloud accounts or integrations are connected.'); say('Connect one with `polylane cloud connect` or `polylane integration connect`.'); return; } @@ -439,12 +439,12 @@ export const scanCommand: Command = { const consoleUrl = resolveConsoleUrl(results) ?? (await scansFallbackUrl(cfg, api, workspaceId)); for (const f of failed) { - say(color(`Scan failed for ${f.target.label}${f.error ? ` (${f.error})` : ''}`, '33', useColor)); + say(color(`The ${f.target.label} scan didn't finish${f.error ? ` (${f.error})` : ''}.`, '33', useColor)); } if (timedOut.length > 0) { say( color( - `${timedOut.length} scan${timedOut.length === 1 ? ' is' : 's are'} still running — view progress in the console.`, + `${timedOut.length} scan${timedOut.length === 1 ? ' is' : 's are'} still running; view progress in the console.`, '2', useColor ) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 25e89f0..95951ae 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -455,7 +455,7 @@ export const setupCommand: Command = { let outcomes: WriteOutcome[]; if (project) { if (!agent.project) { - say(`${agent.id}: skipped — no project-level convention; run without --project`); + say(`${agent.id}: skipped, no project-level convention; run without --project`); continue; } outcomes = agent.project(process.cwd(), config.dryRun); @@ -471,7 +471,7 @@ export const setupCommand: Command = { const detail = outcome.detail ? ` (${outcome.detail})` : ''; say(`${agent.id}: ${outcome.label} ${state}: ${outcome.path}${detail}`); if (outcome.needsManualStep) { - say(`${agent.id}: register it manually — see https://docs.polylane.com/coding-agents/platform-mcp`); + say(`${agent.id}: register it manually: https://docs.polylane.com/coding-agents/platform-mcp`); } } } diff --git a/src/commands/thread/ask.ts b/src/commands/thread/ask.ts index 1359897..6c45b9f 100644 --- a/src/commands/thread/ask.ts +++ b/src/commands/thread/ask.ts @@ -74,7 +74,7 @@ export const threadAskCommand: Command = { process.stderr.write(`Thread: ${url}\n\n`); } - const spinner = !config.quiet && textMode ? new Spinner('Waiting for the reply…') : null; + const spinner = !config.quiet && textMode ? new Spinner('Working on the reply…') : null; if (spinner) spinner.start(); let spinnerStopped = false; const stopSpinner = (): void => { diff --git a/src/commands/thread/continue.ts b/src/commands/thread/continue.ts index fa18cda..b83f0f8 100644 --- a/src/commands/thread/continue.ts +++ b/src/commands/thread/continue.ts @@ -55,7 +55,7 @@ export const threadContinueCommand: Command = { process.stderr.write(`Thread: ${url}\n\n`); } - const spinner = !config.quiet && textMode ? new Spinner('Waiting for the reply…') : null; + const spinner = !config.quiet && textMode ? new Spinner('Working on the reply…') : null; if (spinner) spinner.start(); let spinnerStopped = false; const stopSpinner = (): void => { diff --git a/src/commands/update.ts b/src/commands/update.ts index 91aa626..c0876b6 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -50,7 +50,7 @@ export const updateCommand: Command = { const latest = await fetchLatest(); if (!latest) { - process.stderr.write("Couldn't reach npm to check for updates\n"); + process.stderr.write("Couldn't reach npm to check for updates. Try again later.\n"); return; } @@ -61,7 +61,7 @@ export const updateCommand: Command = { process.stderr.write(`Run: npm install -g @coreplane/polylane@${latest}\n`); process.stderr.write(` or: brew upgrade polylane\n`); } else { - process.stderr.write(`Up to date\n`); + process.stderr.write(`You're up to date.\n`); } }, }; diff --git a/src/errors/api.ts b/src/errors/api.ts index 38cbc67..523e4d9 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -8,14 +8,14 @@ export interface ApiErrorPayload { export function mapApiError(status: number, error: ApiErrorPayload | null): CLIError { const detail = error?.detail; - const message = error?.message ?? 'Unknown error'; + const message = error?.message ?? `The request did not succeed (${status})`; switch (status) { case 400: return new CLIError(detail || 'Bad request', ExitCode.USAGE); case 401: return new CLIError( - detail || 'Not authenticated', + detail || 'Not signed in.', ExitCode.AUTH, 'Run `polylane auth login`' ); diff --git a/src/errors/handler.ts b/src/errors/handler.ts index b0a81ce..e17ff3e 100644 --- a/src/errors/handler.ts +++ b/src/errors/handler.ts @@ -23,7 +23,7 @@ function wrapUnknown(err: unknown): CLIError { } if (hasCode(err, 'ENOTFOUND')) { return new CLIError( - `DNS lookup failed: ${msg}`, + `Couldn't resolve the API host: ${msg}`, ExitCode.NETWORK, 'Check your network connection and the --domain flag' ); diff --git a/src/main.ts b/src/main.ts index ff1abcc..a3148ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { renderGroupHelp, renderCommandHelp, buildStatusLine, + unknownCommandMessage, } from './registry'; import type { Config } from './config/schema'; import type { Credential } from './auth/types'; @@ -103,7 +104,7 @@ async function run(): Promise { const config = loadConfig(flags as GlobalFlags); handleError( new CLIError( - `Unknown command: polylane ${commandPath.join(' ')}`, + unknownCommandMessage(commandPath, registry.suggestCommand(commandPath)), ExitCode.USAGE, 'Run `polylane --help` to list commands' ), diff --git a/src/registry.ts b/src/registry.ts index 582104c..2be66aa 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -107,6 +107,22 @@ export class CommandRegistry { return [...this.all]; } + suggestCommand(path: string[]): string | null { + const input = path[0]; + if (!input) return null; + const candidates = new Set(); + for (const cmd of this.all) { + candidates.add(cmd.name.split(/\s+/)[0]!); + } + let best: { name: string; distance: number } | null = null; + for (const candidate of candidates) { + const distance = editDistance(input.toLowerCase(), candidate.toLowerCase()); + if (distance > 2 || distance * 2 > candidate.length) continue; + if (!best || distance < best.distance) best = { name: candidate, distance }; + } + return best ? best.name : null; + } + getResourceGroups(): Array<{ resource: string; commands: Command[]; meta: ResourceGroup }> { const byResource = new Map(); for (const cmd of this.all) { @@ -129,6 +145,30 @@ export class CommandRegistry { } } +function editDistance(a: string, b: string): number { + const prev = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + let diagonal = prev[0]!; + prev[0] = i; + for (let j = 1; j <= b.length; j++) { + const next = Math.min( + prev[j]! + 1, + prev[j - 1]! + 1, + diagonal + (a[i - 1] === b[j - 1] ? 0 : 1) + ); + diagonal = prev[j]!; + prev[j] = next; + } + } + return prev[b.length]!; +} + +export function unknownCommandMessage(path: string[], suggestion: string | null): string { + const base = `Unknown command: polylane ${path.join(' ')}`; + return suggestion ? `${base}. Closest match: polylane ${suggestion}.` : base; +} + export const registry = new CommandRegistry(); function color(s: string, code: string, useColor: boolean): string { @@ -183,7 +223,7 @@ export function renderRootHelp( lines.push(dim(statusMessage)); lines.push(''); } - lines.push(`${bold('polylane')} — CLI for the Polylane platform`); + lines.push(`${bold('polylane')}: investigate production issues from your terminal`); lines.push(''); lines.push(`${bold('Usage:')} polylane [options]`); lines.push(''); diff --git a/test/errors.test.ts b/test/errors.test.ts index 3464dc5..602116a 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -30,7 +30,7 @@ describe('mapApiError', () => { it('maps 401 -> AUTH with hint', () => { const err = mapApiError(401, { message: 'Unauthorized' }); assert.equal(err.exitCode, ExitCode.AUTH); - assert.equal(err.message, 'Not authenticated'); + assert.equal(err.message, 'Not signed in.'); assert.ok(err.hint?.includes('polylane auth login')); }); diff --git a/test/registry.test.ts b/test/registry.test.ts index 72a6ea5..574c592 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { CommandRegistry } from '../src/registry'; +import { CommandRegistry, unknownCommandMessage } from '../src/registry'; import type { Command } from '../src/command'; const makeCmd = (name: string): Command => ({ @@ -44,6 +44,31 @@ describe('CommandRegistry', () => { assert.equal(resolved.command.name, 'only sub'); }); + it('suggests the closest command for a typo', () => { + const r = new CommandRegistry(); + r.register(makeCmd('scan')); + r.register(makeCmd('setup')); + r.register(makeCmd('workspace list')); + assert.equal(r.suggestCommand(['scna']), 'scan'); + assert.equal(r.suggestCommand(['workspce', 'list']), 'workspace'); + }); + + it('suggests nothing when no command is close', () => { + const r = new CommandRegistry(); + r.register(makeCmd('scan')); + r.register(makeCmd('workspace list')); + assert.equal(r.suggestCommand(['frobnicate']), null); + assert.equal(r.suggestCommand([]), null); + }); + + it('renders the unknown-command message with and without a suggestion', () => { + assert.equal( + unknownCommandMessage(['scna'], 'scan'), + 'Unknown command: polylane scna. Closest match: polylane scan.' + ); + assert.equal(unknownCommandMessage(['frobnicate'], null), 'Unknown command: polylane frobnicate'); + }); + it('groups commands by first path segment', () => { const r = new CommandRegistry(); r.register(makeCmd('workspace list')); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index b27421b..e840408 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -91,7 +91,7 @@ describe('unknown command handling', () => { helpCommand.execute(mockConfig(), {}, { _: ['nope', 'sub'] }), (err: unknown) => { assert.ok(err instanceof CLIError); - assert.equal(err.message, 'Unknown command: polylane nope sub'); + assert.equal(err.message, 'Unknown command: polylane nope sub. Closest match: polylane note.'); assert.equal(err.exitCode, ExitCode.USAGE); assert.ok(err.hint?.includes('polylane --help')); return true;