diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index be6713a..83d2bd8 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { CompareModule, SqlGeneratorModule } from '@foxschema/core'; import { readConfig, CONFIG_DIR, CONFIG_FILE } from '../runtime/config'; import { getDek } from '../runtime/keyring'; +import { friendlyError } from '../format/friendlyError'; function safe(fn: () => T): T | null { try { @@ -36,7 +37,7 @@ export async function runDoctor(): Promise { const mod = await import('@foxschema/core'); core = chalk.green(`loaded (${Object.keys(mod).length} exports)`); } catch (e) { - core = chalk.red(`failed: ${e instanceof Error ? e.message : String(e)}`); + core = chalk.red(`failed: ${friendlyError(e)}`); } console.log(` @foxschema/core ${core} ${coreModulesOk ? chalk.green('(modules ok)') : chalk.red('(modules missing)')}`); } diff --git a/apps/cli/src/commands/migrate.ts b/apps/cli/src/commands/migrate.ts index d1fa995..046264e 100644 --- a/apps/cli/src/commands/migrate.ts +++ b/apps/cli/src/commands/migrate.ts @@ -2,6 +2,7 @@ import { confirm } from '@inquirer/prompts'; import chalk from 'chalk'; import type { TableDiff } from '@foxschema/core'; import { ensureSourceTarget, resolveRef } from '../runtime/connectionRef'; +import { friendlyError } from '../format/friendlyError'; import { compareModule, connectionModule, loadScopedTables, migrationModule, parseScope, sqlGenerator } from '../runtime/engine'; import { getContext } from '../runtime/store'; @@ -136,7 +137,7 @@ export async function runMigrate(opts: MigrateOptions): Promise { await migrationModule.execute(tgt.dialect, tgt.option, tgt.schema, steps, send, { continueOnError: !!opts.continueOnError }); } catch (err) { finalStatus = 'FAILED'; - finalError = err instanceof Error ? err.message : String(err); + finalError = friendlyError(err); send({ type: 'done', success: false, rolledBack: false, error: finalError }); } diff --git a/apps/cli/src/format/__tests__/friendlyError.test.ts b/apps/cli/src/format/__tests__/friendlyError.test.ts new file mode 100644 index 0000000..fde59b0 --- /dev/null +++ b/apps/cli/src/format/__tests__/friendlyError.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { friendlyError } from '../friendlyError'; + +describe('friendlyError', () => { + it('rewrites ECONNREFUSED with host:port into a plain-language cause + suggestion', () => { + const err = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + expect(friendlyError(err)).toBe("Can't reach 127.0.0.1:5432 — is the database running and reachable? Try `fox doctor`."); + }); + + it('falls back to a generic message for ECONNREFUSED without a parseable host:port', () => { + expect(friendlyError(new Error('ECONNREFUSED'))).toContain("Can't reach the database"); + }); + + it('rewrites ENOTFOUND (DNS failure)', () => { + const err = new Error('getaddrinfo ENOTFOUND badhost.example.com'); + expect(friendlyError(err)).toBe('Can\'t resolve host "badhost.example.com" — check the hostname.'); + }); + + it('rewrites ETIMEDOUT', () => { + expect(friendlyError(new Error('connect ETIMEDOUT 10.0.0.5:1433'))).toContain('timed out'); + }); + + it('rewrites Postgres auth failure', () => { + expect(friendlyError(new Error('password authentication failed for user "foo"'))).toBe( + 'Login failed — check the username and password on this connection.' + ); + }); + + it('rewrites MySQL auth failure', () => { + expect(friendlyError(new Error("Access denied for user 'foo'@'localhost'"))).toBe( + 'Login failed — check the username and password on this connection.' + ); + }); + + it('rewrites Oracle ORA-01017 auth failure', () => { + expect(friendlyError(new Error('ORA-01017: invalid username/password; logon denied'))).toBe( + 'Login failed — check the username and password on this connection.' + ); + }); + + it('leaves a message we threw ourselves unchanged (no matching signature)', () => { + const msg = 'Saved connection "prod" not found (see `fox connections list`).'; + expect(friendlyError(new Error(msg))).toBe(msg); + }); + + it('stringifies a non-Error thrown value', () => { + expect(friendlyError('plain string error')).toBe('plain string error'); + }); +}); diff --git a/apps/cli/src/format/friendlyError.ts b/apps/cli/src/format/friendlyError.ts new file mode 100644 index 0000000..58e182e --- /dev/null +++ b/apps/cli/src/format/friendlyError.ts @@ -0,0 +1,40 @@ +interface Pattern { + test: RegExp; + render: (match: RegExpMatchArray) => string; +} + +// Ordered most-specific first. Each pattern targets a raw driver/network error +// signature (Node's own error codes, or a dialect's auth-failure wording) and +// rewrites it to a one-line cause + suggestion. Anything that doesn't match a +// pattern — including every error this CLI throws itself, which is already +// written to be readable — passes through unchanged. +const PATTERNS: Pattern[] = [ + { + test: /ECONNREFUSED\s+([\w.-]+):(\d+)/i, + render: (m) => `Can't reach ${m[1]}:${m[2]} — is the database running and reachable? Try \`fox doctor\`.`, + }, + { test: /ECONNREFUSED/i, render: () => "Can't reach the database — is it running and reachable? Try `fox doctor`." }, + { + test: /(?:getaddrinfo )?ENOTFOUND\s+([\w.-]+)/i, + render: (m) => `Can't resolve host "${m[1]}" — check the hostname.`, + }, + { test: /ETIMEDOUT/i, render: () => 'Connection timed out — check the host, port, and any firewall/VPN.' }, + { + test: /password authentication failed|access denied for user|login failed for user|ORA-01017|ORA-01005|SQLCODE=-1403|SQL30082/i, + render: () => 'Login failed — check the username and password on this connection.', + }, + { + test: /database "?([\w.-]+)"? does not exist|unknown database '?([\w.-]+)'?/i, + render: (m) => `Database "${m[1] ?? m[2]}" doesn't exist on that server.`, + }, +]; + +/** `err instanceof Error ? err.message : String(err)`, plus a rewrite of known raw driver/network errors into a plain-language cause + suggestion. */ +export function friendlyError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + for (const p of PATTERNS) { + const match = message.match(p.test); + if (match) return p.render(match); + } + return message; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index bfac494..858c7d5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; import chalk from 'chalk'; +import { friendlyError } from './format/friendlyError'; import { runSetup } from './commands/setup'; import { runDoctor } from './commands/doctor'; import { addConnection, listConnections, removeConnection } from './commands/connections'; @@ -29,7 +30,22 @@ const program = new Command(); program .name('fox') .description('Fox Schema CLI — database schema diff & migration, in your terminal') - .version(VERSION, '-v, --version'); + .version(VERSION, '-v, --version') + .showSuggestionAfterError() + .addHelpText( + 'after', + ` +Examples: + $ fox Open the interactive UI + $ fox connections add Save a connection (prompts for details) + $ fox compare --source prod --target staging Diff two saved connections + $ fox migrate --source prod --target staging Dry run — preview the migration SQL + $ fox migrate --source prod --target staging --execute --yes + Apply the migration without confirming + $ fox compare --source prod --target staging --json --no-fail > diff.json + CI-friendly JSON, always exit 0 +` + ); program .command('version') @@ -144,6 +160,6 @@ program const bareInvocation = process.argv.length <= 2; (bareInvocation ? launchTui() : program.parseAsync(process.argv)).catch((err) => { - console.error(chalk.red(err instanceof Error ? err.message : String(err))); + console.error(chalk.red(friendlyError(err))); process.exit(1); }); diff --git a/apps/cli/src/tui/data/useCompare.ts b/apps/cli/src/tui/data/useCompare.ts index 011e9e9..29495ba 100644 --- a/apps/cli/src/tui/data/useCompare.ts +++ b/apps/cli/src/tui/data/useCompare.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { DbObjectType, SchemaCompareResult } from '@foxschema/core'; import { compareModule, loadScopedTables } from '../../runtime/engine'; +import { friendlyError } from '../../format/friendlyError'; import type { AsyncState, ConnRef } from '../types'; /** Plain, hook-independent data function — mirrors commands/compare.ts's runCompare sequence exactly. */ @@ -27,7 +28,7 @@ export function useCompare(source: ConnRef, target: ConnRef, scope?: DbObjectTyp if (!cancelled) setState({ status: 'ready', data }); }) .catch((e) => { - if (!cancelled) setState({ status: 'error', error: e instanceof Error ? e.message : String(e) }); + if (!cancelled) setState({ status: 'error', error: friendlyError(e) }); }); return () => { cancelled = true; diff --git a/apps/cli/src/tui/data/useConnections.ts b/apps/cli/src/tui/data/useConnections.ts index 06378b9..a81c356 100644 --- a/apps/cli/src/tui/data/useConnections.ts +++ b/apps/cli/src/tui/data/useConnections.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { ConnectionOptions } from '@foxschema/core'; import type { SavedConnectionSummary } from '@foxschema/web/connection-store'; import { getContext } from '../../runtime/store'; +import { friendlyError } from '../../format/friendlyError'; import type { AsyncState } from '../types'; /** Plain, hook-independent data function — unit-testable with the same vi.spyOn(store, 'getContext') pattern as the line commands. */ @@ -31,7 +32,7 @@ export function useConnections(): AsyncState & { reloa if (!cancelled) setState({ status: 'ready', data }); }) .catch((e) => { - if (!cancelled) setState({ status: 'error', error: e instanceof Error ? e.message : String(e) }); + if (!cancelled) setState({ status: 'error', error: friendlyError(e) }); }); return () => { cancelled = true; diff --git a/apps/cli/src/tui/data/useHistory.ts b/apps/cli/src/tui/data/useHistory.ts index 9149a1f..eeddc81 100644 --- a/apps/cli/src/tui/data/useHistory.ts +++ b/apps/cli/src/tui/data/useHistory.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { MigrationRunDetail, MigrationRunSummary } from '@foxschema/web/migration-history'; import { getContext } from '../../runtime/store'; +import { friendlyError } from '../../format/friendlyError'; import type { AsyncState } from '../types'; /** Plain, hook-independent data functions — unit-testable with the same vi.spyOn(store, 'getContext') pattern as the line commands. */ @@ -24,7 +25,7 @@ export function useHistoryList(): AsyncState { if (!cancelled) setState({ status: 'ready', data }); }) .catch((e) => { - if (!cancelled) setState({ status: 'error', error: e instanceof Error ? e.message : String(e) }); + if (!cancelled) setState({ status: 'error', error: friendlyError(e) }); }); return () => { cancelled = true; @@ -45,7 +46,7 @@ export function useHistoryDetail(runId: string): AsyncState { - if (!cancelled) setState({ status: 'error', error: e instanceof Error ? e.message : String(e) }); + if (!cancelled) setState({ status: 'error', error: friendlyError(e) }); }); return () => { cancelled = true; diff --git a/apps/cli/src/tui/data/useMigrate.ts b/apps/cli/src/tui/data/useMigrate.ts index 95862b5..f2b8be0 100644 --- a/apps/cli/src/tui/data/useMigrate.ts +++ b/apps/cli/src/tui/data/useMigrate.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MigrationEvent, MigrationStep, TableDiff } from '@foxschema/core'; import { connectionModule, migrationModule, sqlGenerator } from '../../runtime/engine'; import { getContext } from '../../runtime/store'; +import { friendlyError } from '../../format/friendlyError'; import type { ConnRef } from '../types'; export interface ObjectResult { @@ -100,7 +101,7 @@ export function useMigrate( await migrationModule.execute(target.dialect, target.option, target.schema, steps, send, { continueOnError }); } catch (err) { finalStatus = 'FAILED'; - finalError = err instanceof Error ? err.message : String(err); + finalError = friendlyError(err); setOutcome({ status: 'failed', error: finalError }); } diff --git a/apps/cli/src/tui/screens/ConnectionFormScreen.tsx b/apps/cli/src/tui/screens/ConnectionFormScreen.tsx index 5b8ab20..1098d33 100644 --- a/apps/cli/src/tui/screens/ConnectionFormScreen.tsx +++ b/apps/cli/src/tui/screens/ConnectionFormScreen.tsx @@ -5,6 +5,7 @@ import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import { buildConnectionString, type ConnectionOptions } from '@foxschema/core'; import { getContext } from '../../runtime/store'; +import { friendlyError } from '../../format/friendlyError'; import { KeyHints } from '../components/KeyHints'; import type { ConnRef, Role } from '../types'; @@ -72,7 +73,7 @@ export function ConnectionFormScreen({ role, onSubmit }: Props): React.JSX.Eleme onSubmit({ dialect, option, schema: values.schema || '', label: values.name || dialect }); } catch (e) { setBusy(false); - setError(e instanceof Error ? e.message : String(e)); + setError(friendlyError(e)); } }; diff --git a/apps/cli/src/tui/screens/ConnectionManageScreen.tsx b/apps/cli/src/tui/screens/ConnectionManageScreen.tsx index 2a58c22..7e9530b 100644 --- a/apps/cli/src/tui/screens/ConnectionManageScreen.tsx +++ b/apps/cli/src/tui/screens/ConnectionManageScreen.tsx @@ -5,6 +5,7 @@ import SelectInput from 'ink-select-input'; import type { SavedConnectionSummary } from '@foxschema/web/connection-store'; import { useConnections } from '../data/useConnections'; import { getContext } from '../../runtime/store'; +import { friendlyError } from '../../format/friendlyError'; import { KeyHints } from '../components/KeyHints'; /** List saved connections; selecting one offers to delete it. Adding a connection stays in the compare/migrate flow (`+ Add a new connection`). */ @@ -25,7 +26,7 @@ export function ConnectionManageScreen(): React.JSX.Element { state.reload(); } catch (e) { setBusy(false); - setError(e instanceof Error ? e.message : String(e)); + setError(friendlyError(e)); return; } setBusy(false); diff --git a/apps/cli/src/tui/screens/ConnectionPickerScreen.tsx b/apps/cli/src/tui/screens/ConnectionPickerScreen.tsx index 2cbb35c..ae1e43e 100644 --- a/apps/cli/src/tui/screens/ConnectionPickerScreen.tsx +++ b/apps/cli/src/tui/screens/ConnectionPickerScreen.tsx @@ -4,6 +4,7 @@ import Spinner from 'ink-spinner'; import SelectInput from 'ink-select-input'; import { useConnections, resolveConnection } from '../data/useConnections'; import type { ConnRef, Role } from '../types'; +import { friendlyError } from '../../format/friendlyError'; import { KeyHints } from '../components/KeyHints'; interface Props { @@ -37,7 +38,7 @@ export function ConnectionPickerScreen({ role, onPicked, onAddNew }: Props): Rea state.status === 'ready' ? state.data.find((c) => c.id === item.value)?.name || resolved.dialect : resolved.dialect; onPicked({ dialect: resolved.dialect, option: resolved.option, schema: resolved.schema ?? '', label }); } catch (e) { - setResolveError(e instanceof Error ? e.message : String(e)); + setResolveError(friendlyError(e)); setResolving(null); } }; diff --git a/apps/cli/src/tui/screens/SetupRequiredScreen.tsx b/apps/cli/src/tui/screens/SetupRequiredScreen.tsx index c806148..089fd8c 100644 --- a/apps/cli/src/tui/screens/SetupRequiredScreen.tsx +++ b/apps/cli/src/tui/screens/SetupRequiredScreen.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; import { performSetup, EMAIL_RE } from '../../runtime/setup'; +import { friendlyError } from '../../format/friendlyError'; interface Props { reason: 'not-set-up' | 'key-unreachable'; @@ -48,7 +49,7 @@ export function SetupRequiredScreen({ reason, onComplete }: Props): React.JSX.El onComplete(); } catch (e) { setBusy(false); - setError(e instanceof Error ? e.message : String(e)); + setError(friendlyError(e)); } };