Skip to content

Commit 7e36ab1

Browse files
authored
Improve CLI error logs (#774)
This PR introduces a `CliError` class that displays better error messages (with sub-item support).
1 parent 157f586 commit 7e36ab1

File tree

15 files changed

+147
-91
lines changed

15 files changed

+147
-91
lines changed

packages/cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pnpm codama init --gill
2828

2929
## `codama run`
3030

31-
Once you have your codama config file, you can run your Codama scripts using the `codama run` command as follows:
31+
Once you have your codama configuration file, you can run your Codama scripts using the `codama run` command as follows:
3232

3333
```sh
3434
pnpm codama run # Only runs your before visitors.
@@ -38,7 +38,7 @@ pnpm codama run --all # Runs your before visitors followed by all your scripts
3838

3939
## The configuration file
4040

41-
The codama config file defines an object containing the following fields:
41+
The codama configuration file defines an object containing the following fields:
4242

4343
- `idl` (string): The path to the IDL file. This can be a Codama IDL or an Anchor IDL which will be automatically converted to a Codama IDL.
4444
- `before` (array): An array of visitors that will run before every script.

packages/cli/src/cli/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pico from 'picocolors';
2+
13
import { createProgram } from '../program';
24
import { logDebug, logError } from '../utils';
35

@@ -7,10 +9,11 @@ export async function run(argv: readonly string[]) {
79
try {
810
await program.parseAsync(argv);
911
} catch (err) {
12+
const error = err as { message: string; stack?: string; items?: string[] };
1013
if (program.opts().debug) {
11-
logDebug(`${(err as { stack: string }).stack}`);
14+
logDebug(`${error.stack}`);
1215
}
13-
logError((err as { message: string }).message);
16+
logError(pico.bold(error.message), error.items ?? []);
1417
process.exitCode = 1;
1518
}
1619
}

packages/cli/src/commands/init.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import prompts, { PromptType } from 'prompts';
55
import { Config, ScriptConfig, ScriptName } from '../config';
66
import {
77
canRead,
8+
CliError,
89
importModuleItem,
910
installMissingDependencies,
1011
isRootNode,
@@ -36,14 +37,26 @@ async function doInit(explicitOutput: string | undefined, options: InitOptions)
3637
const configFileType = getConfigFileType(output, options);
3738

3839
if (await canRead(output)) {
39-
throw new Error(`Configuration file already exists at "${output}".`);
40+
throw new CliError(`Configuration file already exists.`, [`${pico.bold('Path')}: ${output}`]);
4041
}
4142

43+
// Start prompts.
4244
logBanner();
4345
const result = await getPromptResult(options, configFileType);
46+
47+
// Check dependencies.
48+
const isAnchor = await isAnchorIdl(result.idlPath);
49+
await installMissingDependencies(`Your configuration requires additional dependencies.`, [
50+
...(isAnchor ? ['@codama/nodes-from-anchor'] : []),
51+
...(result.scripts.includes('js') ? ['@codama/renderers-js'] : []),
52+
...(result.scripts.includes('rust') ? ['@codama/renderers-rust'] : []),
53+
]);
54+
55+
// Write configuration file.
4456
const content = getContentFromPromptResult(result, configFileType);
4557
await writeFile(output, content);
46-
logSuccess(`Configuration file created at "${output}".`);
58+
console.log();
59+
logSuccess(pico.bold('Configuration file created.'), [`${pico.bold('Path')}: ${output}`]);
4760
}
4861

4962
function getOutputPath(explicitOutput: string | undefined, options: Pick<InitOptions, 'gill' | 'js'>): string {
@@ -74,7 +87,7 @@ async function getPromptResult(
7487
(script: string, type: PromptType = 'text') =>
7588
(_: unknown, values: { scripts: string[] }) =>
7689
values.scripts.includes(script) ? type : null;
77-
const result: PromptResult = await prompts(
90+
return await prompts(
7891
[
7992
{
8093
initial: defaults.idlPath,
@@ -121,15 +134,6 @@ async function getPromptResult(
121134
],
122135
PROMPT_OPTIONS,
123136
);
124-
125-
const isAnchor = await isAnchorIdl(result.idlPath);
126-
await installMissingDependencies(`Your configuration requires additional dependencies.`, [
127-
...(isAnchor ? ['@codama/nodes-from-anchor'] : []),
128-
...(result.scripts.includes('js') ? ['@codama/renderers-js'] : []),
129-
...(result.scripts.includes('rust') ? ['@codama/renderers-rust'] : []),
130-
]);
131-
132-
return result;
133137
}
134138

135139
function getDefaultPromptResult(): PromptResult {

packages/cli/src/commands/run.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { RootNode } from '@codama/nodes';
22
import { visit, type Visitor } from '@codama/visitors-core';
33
import { Command } from 'commander';
4+
import pico from 'picocolors';
45

56
import { ScriptName } from '../config';
67
import { getParsedConfigFromCommand, ParsedConfig } from '../parsedConfig';
7-
import { getRootNodeVisitors, logInfo, logSuccess, logWarning } from '../utils';
8+
import { CliError, getRootNodeVisitors, logInfo, logSuccess, logWarning } from '../utils';
89

910
export function setRunCommand(program: Command): void {
1011
program
1112
.command('run')
1213
.argument('[scripts...]', 'The scripts to execute')
13-
.option('-a, --all', 'Run all scripts in the config file')
14+
.option('-a, --all', 'Run all scripts in the configuration file')
1415
.action(doRun);
1516
}
1617

@@ -39,17 +40,20 @@ async function getPlans(
3940
): Promise<RunPlan[]> {
4041
const plans: RunPlan[] = [];
4142
if (scripts.length === 0 && parsedConfig.before.length === 0) {
42-
throw new Error('There are no scripts or before visitors to run.');
43+
throw new CliError('There are no scripts or before visitors to run.');
4344
}
4445

4546
const missingScripts = scripts.filter(script => !parsedConfig.scripts[script]);
4647
if (missingScripts.length > 0) {
4748
const scriptPluralized = missingScripts.length === 1 ? 'Script' : 'Scripts';
48-
const missingScriptsIdentifier = `${scriptPluralized} "${missingScripts.join(', ')}"`;
4949
const message = parsedConfig.configPath
50-
? `${missingScriptsIdentifier} not found in config file "${parsedConfig.configPath}"`
51-
: `${missingScriptsIdentifier} not found because no config file was found`;
52-
throw new Error(message);
50+
? `${scriptPluralized} not found in configuration file.`
51+
: `${scriptPluralized} not found because no configuration file was found.`;
52+
const items = [
53+
`${pico.bold(scriptPluralized)}: ${missingScripts.join(', ')}`,
54+
...(parsedConfig.configPath ? [`${pico.bold('Path')}: ${parsedConfig.configPath}`] : []),
55+
];
56+
throw new CliError(message, items);
5357
}
5458

5559
if (parsedConfig.before.length > 0) {

packages/cli/src/config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import path from 'node:path';
22

3+
import pico from 'picocolors';
4+
35
import { ProgramOptions } from './programOptions';
4-
import { canRead, importModuleItem, logWarning } from './utils';
6+
import { canRead, CliError, importModuleItem, logWarning } from './utils';
57

68
export type Config = Readonly<{
79
idl?: string;
@@ -24,13 +26,13 @@ export async function getConfig(options: Pick<ProgramOptions, 'config'>): Promis
2426
const configPath = options.config != null ? path.resolve(options.config) : await getDefaultConfigPath();
2527

2628
if (!configPath) {
27-
logWarning('No config file found. Using empty configs. Make sure you provide the `--idl` option.');
29+
logWarning('No configuration file found. Using empty configs. Make sure you provide the `--idl` option.');
2830
return [{}, configPath];
2931
}
3032

31-
const configFile = await importModuleItem({ identifier: 'config file', from: configPath });
33+
const configFile = await importModuleItem({ identifier: 'configuration file', from: configPath });
3234
if (!configFile || typeof configFile !== 'object') {
33-
throw new Error(`Invalid config file at "${configPath}"`);
35+
throw new CliError(`Invalid configuration file.`, [`${pico.bold('Path')}: ${configPath}`]);
3436
}
3537

3638
return [configFile, configPath];

packages/cli/src/parsedConfig.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command } from 'commander';
44
import { Config, getConfig, ScriptName, ScriptsConfig, VisitorConfig, VisitorPath } from './config';
55
import { ProgramOptions } from './programOptions';
66
import {
7+
CliError,
78
getRootNodeFromIdl,
89
importModuleItem,
910
isLocalModulePath,
@@ -63,7 +64,7 @@ function parseIdlPath(
6364
if (config.idl) {
6465
return resolveConfigPath(config.idl, configPath);
6566
}
66-
throw new Error('No IDL identified. Please provide the `--idl` option or set it in the config file.');
67+
throw new CliError('No IDL identified. Please provide the `--idl` option or set it in the configuration file.');
6768
}
6869

6970
function parseScripts(scripts: ScriptsConfig, configPath: string | null): ParsedScriptsConfig {

packages/cli/src/utils/errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export class CliError extends Error {
2+
constructor(
3+
message: string,
4+
public items: string[] = [],
5+
options?: ErrorOptions,
6+
) {
7+
super(message, options);
8+
this.name = 'CliError';
9+
}
10+
}

packages/cli/src/utils/import.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { createRequire } from 'node:module';
22

3+
import pico from 'picocolors';
4+
5+
import { CliError } from './errors';
36
import { canRead, isLocalModulePath, resolveRelativePath } from './fs';
47

58
type ImportModuleItemOptions = {
@@ -12,8 +15,8 @@ export async function importModuleItem<T = unknown>(options: ImportModuleItemOpt
1215
const module = await importModule(options);
1316
const moduleItem = pickModuleItem(module, options.item) as T | undefined;
1417
if (moduleItem === undefined) {
15-
const moduleInfo = getModuleInfo(options);
16-
throw new Error(`Failed to ${moduleInfo}.`);
18+
const items = getErrorItems(options);
19+
throw new CliError(`Failed to load ${options.identifier ?? 'module'}.`, items);
1720
}
1821
return moduleItem;
1922
}
@@ -43,9 +46,10 @@ async function importModule<T extends object>(options: ImportModuleItemOptions):
4346
}
4447

4548
async function importLocalModule<T extends object>(options: ImportModuleItemOptions): Promise<T> {
46-
const { identifier, from } = options;
49+
const { from, identifier } = options;
4750
if (!(await canRead(from))) {
48-
throw new Error(`Cannot access ${identifier ?? 'module'} at "${from}"`);
51+
const items = getErrorItems(options);
52+
throw new CliError(`Cannot access ${identifier ?? 'module'}.`, items);
4953
}
5054

5155
const dotIndex = from.lastIndexOf('.');
@@ -71,20 +75,23 @@ async function handleImportPromise<T extends object>(
7175
): Promise<T> {
7276
try {
7377
return (await importPromise) as T;
74-
} catch (error) {
75-
const moduleInfo = getModuleInfo(options);
76-
let causeMessage =
77-
!!error && typeof error === 'object' && 'message' in error && typeof error.message === 'string'
78-
? (error as { message: string }).message
79-
: undefined;
80-
causeMessage = causeMessage ? `\n(caused by: ${causeMessage})` : '';
81-
throw new Error(`Failed to ${moduleInfo}.${causeMessage}`, { cause: error });
78+
} catch (cause) {
79+
const items = getErrorItems(options, cause);
80+
throw new CliError(`Failed to load ${options.identifier ?? 'module'}.`, items, { cause });
8281
}
8382
}
8483

85-
function getModuleInfo(options: ImportModuleItemOptions): string {
86-
const { identifier, from, item } = options;
87-
const importStatement = item ? `import { ${item} } from '${from}'` : `import default from '${from}'`;
88-
if (!identifier) return importStatement;
89-
return `import ${identifier} [${importStatement}]`;
84+
function getErrorItems(options: ImportModuleItemOptions, cause?: unknown): string[] {
85+
const { from, item } = options;
86+
const items = [`${pico.bold('Module')}: ${from}`];
87+
if (item) {
88+
items.push(`${pico.bold('Item')}: ${item}`);
89+
}
90+
91+
const hasCause = !!cause && typeof cause === 'object' && 'message' in cause && typeof cause.message === 'string';
92+
if (hasCause) {
93+
items.push(`${pico.bold('Caused by')}: ${(cause as { message: string }).message}`);
94+
}
95+
96+
return items;
9097
}

packages/cli/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './childCommands';
2+
export * from './errors';
23
export * from './fs';
34
export * from './import';
45
export * from './logs';

packages/cli/src/utils/logs.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,46 @@
11
import pico from 'picocolors';
22

3-
export function logSuccess(message: string, items?: string[]): void {
4-
console.log(pico.green('✔'), message);
3+
type LogLevel = 'debug' | 'error' | 'info' | 'success' | 'warning';
4+
5+
type LogOptions = {
6+
level: LogLevel;
7+
message: string;
8+
items?: string[];
9+
};
10+
11+
function getLogLevelInfo(logLevel: LogLevel) {
12+
const identity = (text: string) => text;
13+
const infos: Record<LogLevel, [string, (text: string) => string, (text: string) => string]> = {
14+
success: ['✔', pico.green, pico.green],
15+
info: ['→', pico.blueBright, identity],
16+
warning: ['▲', pico.yellow, pico.yellow],
17+
error: ['✖', pico.red, pico.red],
18+
debug: ['✱', pico.magenta, pico.magenta],
19+
};
20+
21+
return {
22+
icon: infos[logLevel][0],
23+
color: infos[logLevel][1],
24+
messageColor: infos[logLevel][2],
25+
};
26+
}
27+
28+
const logWrapper = (level: LogLevel) => (message: string, items?: string[]) => log({ level, message, items });
29+
export const logSuccess = logWrapper('success');
30+
export const logError = logWrapper('error');
31+
export const logInfo = logWrapper('info');
32+
export const logWarning = logWrapper('warning');
33+
export const logDebug = logWrapper('debug');
34+
35+
function log({ level, message, items }: LogOptions): void {
36+
const { icon, color, messageColor } = getLogLevelInfo(level);
37+
console.log(color(icon), messageColor(message));
538
if (items) {
6-
logItems(items, pico.green);
39+
logItems(items, color);
740
}
841
}
942

10-
export function logInfo(message: string, items?: string[]): void {
11-
console.log(pico.blueBright('→'), message);
12-
if (items) {
13-
logItems(items, pico.blueBright);
14-
}
15-
}
16-
17-
export function logWarning(message: string, items?: string[]): void {
18-
console.log(pico.yellow('▲'), message);
19-
if (items) {
20-
logItems(items, pico.yellow);
21-
}
22-
}
23-
24-
export function logError(message: string, items?: string[]): void {
25-
console.log(pico.red('✖'), message);
26-
if (items) {
27-
logItems(items, pico.red);
28-
}
29-
}
30-
31-
export function logDebug(message: string, items?: string[]): void {
32-
console.log(pico.magenta('✱'), message);
33-
if (items) {
34-
logItems(items, pico.magenta);
35-
}
36-
}
37-
38-
export function logItems(items: string[], color?: (text: string) => string): void {
43+
function logItems(items: string[], color?: (text: string) => string): void {
3944
const colorFn = color ?? (text => text);
4045
items.forEach((item, index) => {
4146
const prefix = index === items.length - 1 ? '└─' : '├─';

0 commit comments

Comments
 (0)