Skip to content

Commit a8a26c6

Browse files
committed
🧪
1 parent 78a2648 commit a8a26c6

File tree

5 files changed

+866
-441
lines changed

5 files changed

+866
-441
lines changed

src/cli/commands/base-command.ts

Lines changed: 195 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,203 @@
1-
import os from 'os';
2-
import path from 'path';
3-
4-
import { editor, input, select } from '@inquirer/prompts';
5-
import chalk from 'chalk';
1+
// command.ts
2+
import { pipe } from 'fp-ts/lib/function';
3+
import * as E from 'fp-ts/lib/Either';
4+
import * as TE from 'fp-ts/lib/TaskEither';
5+
import * as T from 'fp-ts/lib/Task';
66
import { Command } from 'commander';
7-
import fs from 'fs-extra';
8-
97
import { ApiResult } from '../../shared/types';
10-
import { cliConfig } from '../config/cli-config';
11-
import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants';
12-
import { handleApiResult } from '../utils/database';
13-
import { handleError } from '../utils/errors';
14-
15-
export class BaseCommand extends Command {
16-
constructor(name: string, description: string) {
17-
super(name);
18-
this.description(description);
19-
}
20-
21-
action(fn: (...args: any[]) => Promise<void> | void): this {
22-
return super.action(async (...args: any[]) => {
23-
try {
24-
await fn(...args);
25-
} catch (error) {
26-
this.handleError(error, `executing ${this.name()}`);
27-
}
28-
});
29-
}
30-
31-
protected async showMenu<T>(
32-
message: string,
33-
choices: Array<{ name: string; value: T }>,
34-
options: { includeGoBack?: boolean; goBackValue?: T; goBackLabel?: string; clearConsole?: boolean } = {}
35-
): Promise<T> {
36-
const {
37-
includeGoBack = true,
38-
goBackValue = 'back' as T,
39-
goBackLabel = 'Go back',
40-
clearConsole = true
41-
} = options;
42-
43-
if (clearConsole) {
44-
// console.clear();
45-
}
46-
47-
const menuChoices = [...choices];
48-
49-
if (includeGoBack) {
50-
menuChoices.push({
51-
name: chalk.red(chalk.bold(goBackLabel)),
52-
value: goBackValue
53-
});
54-
}
55-
return select<T>({
56-
message,
57-
choices: menuChoices,
58-
pageSize: cliConfig.MENU_PAGE_SIZE
59-
});
60-
}
61-
62-
protected async handleApiResult<T>(result: ApiResult<T>, message: string): Promise<T | null> {
63-
return handleApiResult(result, message);
64-
}
65-
66-
protected async getInput(message: string, validate?: (input: string) => boolean | string): Promise<string> {
67-
return input({
68-
message,
69-
validate: validate || ((value: string): boolean | string => value.trim() !== '' || 'Value cannot be empty')
70-
});
71-
}
72-
73-
protected async getMultilineInput(message: string, initialValue: string = ''): Promise<string> {
74-
console.log(chalk.cyan(message));
75-
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-input-'));
76-
const tempFilePath = path.join(tempDir, 'input.txt');
77-
78-
try {
79-
const cleanedInitialValue =
80-
initialValue.startsWith(FRAGMENT_PREFIX) || initialValue.startsWith(ENV_PREFIX) ? '' : initialValue;
81-
await fs.writeFile(tempFilePath, cleanedInitialValue);
82-
const input = await editor({
83-
message: 'Edit your input',
84-
default: cleanedInitialValue,
85-
waitForUseInput: false,
86-
postfix: '.txt'
87-
});
88-
return input;
89-
} finally {
90-
await fs.remove(tempDir);
91-
}
92-
}
93-
94-
protected async pressKeyToContinue(): Promise<void> {
95-
await input({ message: 'Press Enter to continue...' });
96-
}
97-
98-
protected async confirmAction(message: string): Promise<boolean> {
99-
const action = await this.showMenu<'yes' | 'no'>(
100-
message,
101-
[
102-
{ name: 'Yes', value: 'yes' },
103-
{ name: 'No', value: 'no' }
104-
],
105-
{ includeGoBack: false }
106-
);
107-
return action === 'yes';
108-
}
8+
import * as O from 'fp-ts/lib/Option';
9+
import * as A from 'fp-ts/lib/Array';
10+
11+
// Core types
12+
export type CommandContext = {
13+
readonly args: ReadonlyArray<string>;
14+
readonly options: Readonly<Record<string, unknown>>;
15+
};
16+
17+
export type CommandError = {
18+
readonly code: string;
19+
readonly message: string;
20+
readonly context?: unknown;
21+
};
22+
23+
export interface BaseCommand<A> {
24+
readonly name: string;
25+
readonly description: string;
26+
readonly execute: (ctx: CommandContext) => Promise<TE.TaskEither<CommandError, A>>;
27+
}
10928

110-
protected handleError(error: unknown, context: string): void {
111-
handleError(error, context);
29+
export type CommandResult<A> = E.Either<CommandError, A>;
30+
export type CommandTask<A> = TE.TaskEither<CommandError, A>;
31+
32+
// Command creation helper
33+
export const createCommand = <A>(
34+
name: string,
35+
description: string,
36+
execute: (ctx: CommandContext) => TE.TaskEither<CommandError, A>
37+
): Command => {
38+
const command = new Command(name);
39+
command.description(description);
40+
41+
command.action(async (...args) => {
42+
const ctx: CommandContext = {
43+
args: args.slice(0, -1),
44+
options: args[args.length - 1] || {}
45+
};
46+
47+
await pipe(
48+
execute(ctx),
49+
TE.fold(
50+
(error: CommandError) =>
51+
T.of(
52+
(() => {
53+
throw new Error(`Failed to execute ${name} command: ${error.message}`);
54+
})()
55+
),
56+
(value: A) =>
57+
T.of(
58+
(() => {
59+
console.log('Command Result:', value);
60+
return value;
61+
})()
62+
)
63+
)
64+
)();
65+
});
66+
67+
return command;
68+
};
69+
70+
// Helper for handling command results
71+
export const handleCommandResult = <A>(
72+
result: A,
73+
options?: {
74+
onSuccess?: (value: A) => void;
75+
silent?: boolean;
11276
}
113-
114-
async runCommand(program: Command, commandName: string): Promise<void> {
115-
const command = program.commands.find((cmd) => cmd.name() === commandName);
116-
117-
if (command) {
118-
try {
119-
await command.parseAsync([], { from: 'user' });
120-
} catch (error) {
121-
console.error(chalk.red(`Error executing command '${commandName}':`, error));
122-
}
77+
): void => {
78+
if (result !== undefined && !options?.silent) {
79+
if (options?.onSuccess) {
80+
options.onSuccess(result);
12381
} else {
124-
console.error(chalk.red(`Command '${commandName}' not found.`));
82+
console.log(result);
12583
}
12684
}
127-
128-
async runSubCommand(command: BaseCommand): Promise<void> {
129-
try {
130-
await command.parseAsync([], { from: 'user' });
131-
} catch (error) {
132-
this.handleError(error, `running ${command.name()} command`);
85+
};
86+
87+
// Type guard for command errors
88+
export const isCommandError = (error: unknown): error is CommandError =>
89+
typeof error === 'object' && error !== null && 'code' in error && 'message' in error;
90+
91+
// Helper for creating command errors
92+
export const createCommandError = (code: string, message: string, context?: unknown): CommandError => ({
93+
code,
94+
message,
95+
context
96+
});
97+
98+
// Task composition helpers with fixed types
99+
export const withErrorHandling = <A>(task: T.Task<A>, errorMapper: (error: unknown) => CommandError): CommandTask<A> =>
100+
pipe(
101+
task,
102+
TE.fromTask,
103+
TE.mapLeft((error) => errorMapper(error))
104+
);
105+
106+
// Validation chain helper with fixed types
107+
export const validate = <A>(
108+
value: A,
109+
validators: Array<(value: A) => E.Either<CommandError, A>>
110+
): E.Either<CommandError, A> =>
111+
validators.reduce(
112+
(result, validator) => pipe(result, E.chain(validator)),
113+
E.right(value) as E.Either<CommandError, A>
114+
);
115+
116+
// Helper for creating TaskEither from Promise
117+
export const taskEitherFromPromise = <A>(
118+
promise: () => Promise<A>,
119+
errorMapper: (error: unknown) => CommandError
120+
): TE.TaskEither<CommandError, A> =>
121+
TE.tryCatch(async () => {
122+
const result = await promise();
123+
if (result === undefined) {
124+
throw new Error('Unexpected undefined result');
133125
}
134-
}
135-
}
126+
return result;
127+
}, errorMapper);
128+
129+
// Helper for converting ApiResult to TaskEither
130+
export const fromApiResult = <A>(promise: Promise<ApiResult<A>>): TE.TaskEither<CommandError, A> =>
131+
TE.tryCatch(
132+
async () => {
133+
const result = await promise;
134+
if (!result.success) {
135+
throw new Error(result.error || 'Operation failed');
136+
}
137+
// Handle the case where data might be undefined but success is true
138+
return result.data !== undefined ? result.data : (null as any);
139+
},
140+
(error) => createCommandError('API_ERROR', String(error))
141+
);
142+
143+
// Helper for converting ApiResult-returning function to TaskEither
144+
export const fromApiFunction = <A>(fn: () => Promise<ApiResult<A>>): TE.TaskEither<CommandError, A> =>
145+
TE.tryCatch(
146+
async () => {
147+
const result = await fn();
148+
if (!result.success || result.data === undefined) {
149+
throw new Error(result.error || 'Operation failed');
150+
}
151+
return result.data;
152+
},
153+
(error) => createCommandError('API_ERROR', String(error))
154+
);
155+
156+
// Helper for handling nullable values
157+
export const fromNullable = <A>(
158+
value: A | null | undefined,
159+
errorMessage: string
160+
): TE.TaskEither<CommandError, NonNullable<A>> =>
161+
pipe(
162+
O.fromNullable(value),
163+
TE.fromOption(() => createCommandError('NULL_ERROR', errorMessage))
164+
);
165+
166+
// Helper for handling optional values with custom error
167+
export const requireValue = <A>(
168+
value: A | undefined,
169+
errorCode: string,
170+
errorMessage: string
171+
): TE.TaskEither<CommandError, NonNullable<A>> =>
172+
pipe(
173+
O.fromNullable(value),
174+
TE.fromOption(() => createCommandError(errorCode, errorMessage))
175+
);
176+
177+
// Helper for handling arrays of TaskEither
178+
export const sequenceArray = <A>(tasks: Array<TE.TaskEither<CommandError, A>>): TE.TaskEither<CommandError, Array<A>> =>
179+
pipe(tasks, A.sequence(TE.ApplicativeSeq));
180+
181+
// Helper for mapping arrays with TaskEither
182+
export const traverseArray = <A, B>(
183+
arr: Array<A>,
184+
f: (a: A) => TE.TaskEither<CommandError, B>
185+
): TE.TaskEither<CommandError, Array<B>> => pipe(arr, A.traverse(TE.ApplicativeSeq)(f));
186+
187+
// Helper for conditional execution
188+
export const whenTE = <A>(
189+
condition: boolean,
190+
task: TE.TaskEither<CommandError, A>
191+
): TE.TaskEither<CommandError, O.Option<A>> =>
192+
condition
193+
? pipe(
194+
task,
195+
TE.map((a) => O.some(a))
196+
)
197+
: TE.right(O.none);
198+
199+
// Helper for handling errors with recovery
200+
export const withErrorRecovery = <A>(
201+
task: TE.TaskEither<CommandError, A>,
202+
recovery: (error: CommandError) => TE.TaskEither<CommandError, A>
203+
): TE.TaskEither<CommandError, A> => pipe(task, TE.orElse(recovery));

0 commit comments

Comments
 (0)