Skip to content
4 changes: 2 additions & 2 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import analyze from './commands/analyze';
import renameCmd from './commands/rename';
import { readAndParsePackageJson } from './package';
import { extractFirst, usageText } from './utils';
import { cliExitWithError } from './utils/cli-error';

const withPgTeardown = (fn: Function, skipTeardown: boolean = false) => async (...args: any[]) => {
try {
Expand Down Expand Up @@ -113,9 +114,8 @@ export const commands = async (argv: Partial<ParsedArgs>, prompter: Inquirerer,
const commandFn = commandMap[command];

if (!commandFn) {
console.error(`Unknown command: ${command}`);
console.log(usageText);
process.exit(1);
await cliExitWithError(`Unknown command: ${command}`);
}

await commandFn(newArgv, prompter, options);
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer';
import { getPgEnvOptions } from 'pg-env';

import { getTargetDatabase } from '../utils';
import { cliExitWithError } from '../utils/cli-error';

const log = new Logger('remove');

Expand All @@ -15,9 +16,7 @@ export default async (
) => {

if (!argv.to) {
log.error('Error: No change specified');
log.info('Usage: lql remove --to <change>');
process.exit(1);
await cliExitWithError('No change specified. Usage: lql remove --to <change>');
}

const database = await getTargetDatabase(argv, prompter, {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { Inquirerer } from 'inquirerer';
import { ParsedArgs } from 'minimist';
import path from 'path';
import { LaunchQLPackage } from '@launchql/core';
import { cliExitWithError } from '../utils/cli-error';

export default async (argv: Partial<ParsedArgs>, _prompter: Inquirerer) => {
const cwd = (argv.cwd as string) || process.cwd();
const to = (argv.to as string) || (argv._ && argv._[0] as string);
if (!to) {
console.error('Missing new name. Use --to <name> or provide as positional argument.');
process.exit(1);
await cliExitWithError('Missing new name. Use --to <name> or provide as positional argument.');
}
const dryRun = !!argv['dry-run'] || !!argv.dryRun;
const syncPkg = !!argv['sync-pkg-name'] || !!argv.syncPkgName;
Expand Down
22 changes: 17 additions & 5 deletions packages/cli/src/commands/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { Logger } from '@launchql/logger';
import { getEnvOptions } from '@launchql/env';
import { CLIOptions, Inquirerer, Question } from 'inquirerer';
import { getPgEnvOptions } from 'pg-env';

import { getTargetDatabase } from '../utils';
import { selectPackage } from '../utils/module-utils';
import { selectDeployedChange, selectDeployedPackage } from '../utils/deployed-changes';
import { cliExitWithError } from '../utils/cli-error';

const log = new Logger('revert');


const revertUsageText = `
LaunchQL Revert Command:

Expand All @@ -21,12 +22,14 @@ Options:
--recursive Revert recursively through dependencies
--package <name> Revert specific package
--to <target> Revert to specific change or tag
--to Interactive selection of deployed changes
--tx Use transactions (default: true)
--cwd <directory> Working directory (default: current directory)

Examples:
lql revert Revert latest changes
lql revert --to @v1.0.0 Revert to specific tag
lql revert --to Interactive selection from deployed changes
`;

export default async (
Expand Down Expand Up @@ -71,8 +74,11 @@ export default async (
log.debug(`Using current directory: ${cwd}`);

let packageName: string | undefined;
if (recursive) {
packageName = await selectPackage(argv, prompter, cwd, 'revert', log);
if (recursive && argv.to !== true) {
packageName = await selectDeployedPackage(database, argv, prompter, log, 'revert');
if (!packageName) {
await cliExitWithError('No package found to revert');
}
}

const pkg = new LaunchQLPackage(cwd);
Expand All @@ -85,7 +91,13 @@ export default async (
});

let target: string | undefined;
if (packageName && argv.to) {

if (argv.to === true) {
target = await selectDeployedChange(database, argv, prompter, log, 'revert');
if (!target) {
await cliExitWithError('No target selected, operation cancelled');
}
} else if (packageName && argv.to) {
target = `${packageName}:${argv.to}`;
} else if (packageName) {
target = packageName;
Expand Down
20 changes: 16 additions & 4 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer';
import { getPgEnvOptions } from 'pg-env';

import { getTargetDatabase } from '../utils';
import { selectPackage } from '../utils/module-utils';
import { selectDeployedChange, selectDeployedPackage } from '../utils/deployed-changes';
import { cliExitWithError } from '../utils/cli-error';

const log = new Logger('verify');

Expand All @@ -21,11 +22,13 @@ Options:
--recursive Verify recursively through dependencies
--package <name> Verify specific package
--to <target> Verify up to specific change or tag
--to Interactive selection of deployed changes
--cwd <directory> Working directory (default: current directory)

Examples:
lql verify Verify current database state
lql verify --package mypackage Verify specific package
lql verify --to Interactive selection from deployed changes
`;

export default async (
Expand All @@ -49,8 +52,11 @@ export default async (
log.debug(`Using current directory: ${cwd}`);

let packageName: string | undefined;
if (recursive) {
packageName = await selectPackage(argv, prompter, cwd, 'verify', log);
if (recursive && argv.to !== true) {
packageName = await selectDeployedPackage(database, argv, prompter, log, 'verify');
if (!packageName) {
await cliExitWithError('No package found to verify');
}
}

const project = new LaunchQLPackage(cwd);
Expand All @@ -60,7 +66,13 @@ export default async (
});

let target: string | undefined;
if (packageName && argv.to) {

if (argv.to === true) {
target = await selectDeployedChange(database, argv, prompter, log, 'verify');
if (!target) {
await cliExitWithError('No target selected, operation cancelled');
}
} else if (packageName && argv.to) {
target = `${packageName}:${argv.to}`;
} else if (packageName) {
target = packageName;
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const app = new CLI(commands, options);
app.run().then(()=> {
// all done!
}).catch(error => {
console.error(error);
// Should not reach here with the new CLI error handling pattern
// But keep as fallback for unexpected errors
console.error('Unexpected error:', error);
process.exit(1);
});
54 changes: 54 additions & 0 deletions packages/cli/src/utils/cli-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { LaunchQLError } from '@launchql/types';
import { Logger } from '@launchql/logger';
import { teardownPgPools } from 'pg-cache';

const log = new Logger('cli');

/**
* CLI error utility that logs error information and exits with code 1.
* Provides consistent error handling and user experience across all CLI commands.
*
* IMPORTANT: This function properly cleans up PostgreSQL connections before exiting.
*/
export const cliExitWithError = async (
error: LaunchQLError | Error | string,
context?: Record<string, any>
): Promise<never> => {
if (error instanceof LaunchQLError) {
// For LaunchQLError instances, use structured logging
log.error(`Error: ${error.message}`);

// Log additional context if available
if (error.context && Object.keys(error.context).length > 0) {
log.debug('Error context:', error.context);
}

// Log any additional context provided
if (context) {
log.debug('Additional context:', context);
}
} else if (error instanceof Error) {
// For generic Error instances
log.error(`Error: ${error.message}`);
if (context) {
log.debug('Context:', context);
}
} else if (typeof error === 'string') {
// For simple string messages
log.error(`Error: ${error}`);
if (context) {
log.debug('Context:', context);
}
}

// Perform cleanup before exiting
try {
await teardownPgPools();
log.debug('Database connections cleaned up');
} catch (cleanupError) {
log.warn('Failed to cleanup database connections:', cleanupError);
// Don't let cleanup errors prevent the exit
}

process.exit(1);
};
97 changes: 97 additions & 0 deletions packages/cli/src/utils/deployed-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { LaunchQLMigrate } from '@launchql/core';
import { Logger } from '@launchql/logger';
import { Inquirerer } from 'inquirerer';
import { getPgEnvOptions } from 'pg-env';

export async function selectDeployedChange(
database: string,
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
log: Logger,
action: 'revert' | 'verify' = 'revert'
): Promise<string | undefined> {
const pgEnv = getPgEnvOptions({ database });
const client = new LaunchQLMigrate(pgEnv);

let selectedPackage: string;

if (argv.package) {
selectedPackage = argv.package;
} else {
const packageStatuses = await client.status();

if (packageStatuses.length === 0) {
log.warn('No deployed packages found in database');
return undefined;
}

const packageAnswer = await prompter.prompt(argv, [{
type: 'autocomplete',
name: 'package',
message: `Select package to ${action} from:`,
options: packageStatuses.map(status => ({
name: status.package,
value: status.package,
description: `${status.totalDeployed} changes, last: ${status.lastChange}`
}))
}]);
selectedPackage = (packageAnswer as any).package;
}

const deployedChanges = await client.getDeployedChanges(database, selectedPackage);

if (deployedChanges.length === 0) {
log.warn(`No deployed changes found for package ${selectedPackage}`);
return undefined;
}

const changeAnswer = await prompter.prompt(argv, [{
type: 'autocomplete',
name: 'change',
message: `Select change to ${action} to in ${selectedPackage}:`,
options: deployedChanges.map(change => ({
name: change.change_name,
value: change.change_name,
description: `Deployed: ${new Date(change.deployed_at).toLocaleString()}`
}))
}]);
const selectedChange = (changeAnswer as any).change;

return `${selectedPackage}:${selectedChange}`;

}

export async function selectDeployedPackage(
database: string,
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
log: Logger,
action: 'revert' | 'verify' = 'revert'
): Promise<string | undefined> {
if (argv.package) {
return argv.package;
}

const pgEnv = getPgEnvOptions({ database });
const client = new LaunchQLMigrate(pgEnv);

const packageStatuses = await client.status();

if (packageStatuses.length === 0) {
log.warn('No deployed packages found in database');
return undefined;
}

const packageAnswer = await prompter.prompt(argv, [{
type: 'autocomplete',
name: 'package',
message: `Select package to ${action}:`,
options: packageStatuses.map(status => ({
name: status.package,
value: status.package,
description: `${status.totalDeployed} changes, last: ${status.lastChange}`
}))
}]);

return (packageAnswer as any).package;
}
Loading