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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fn: () => T): T | null {
try {
Expand Down Expand Up @@ -36,7 +37,7 @@ export async function runDoctor(): Promise<void> {
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)')}`);
}
3 changes: 2 additions & 1 deletion apps/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -136,7 +137,7 @@ export async function runMigrate(opts: MigrateOptions): Promise<void> {
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 });
}

Expand Down
49 changes: 49 additions & 0 deletions apps/cli/src/format/__tests__/friendlyError.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
40 changes: 40 additions & 0 deletions apps/cli/src/format/friendlyError.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 18 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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);
});
3 changes: 2 additions & 1 deletion apps/cli/src/tui/data/useCompare.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/data/useConnections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -31,7 +32,7 @@ export function useConnections(): AsyncState<SavedConnectionSummary[]> & { 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;
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/tui/data/useHistory.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -24,7 +25,7 @@ export function useHistoryList(): AsyncState<MigrationRunSummary[]> {
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;
Expand All @@ -45,7 +46,7 @@ export function useHistoryDetail(runId: string): AsyncState<MigrationRunDetail |
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;
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/data/useMigrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/screens/ConnectionFormScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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));
}
};

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/screens/ConnectionManageScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/screens/ConnectionPickerScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
};
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/tui/screens/SetupRequiredScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
};

Expand Down
Loading