-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
78a2648
commit c825801
Showing
28 changed files
with
2,189 additions
and
1,630 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,135 +1,209 @@ | ||
import os from 'os'; | ||
import path from 'path'; | ||
|
||
import { editor, input, select } from '@inquirer/prompts'; | ||
import chalk from 'chalk'; | ||
import { Command } from 'commander'; | ||
import fs from 'fs-extra'; | ||
import * as A from 'fp-ts/lib/Array'; | ||
import * as E from 'fp-ts/lib/Either'; | ||
import { pipe } from 'fp-ts/lib/function'; | ||
import * as O from 'fp-ts/lib/Option'; | ||
import * as T from 'fp-ts/lib/Task'; | ||
import * as TE from 'fp-ts/lib/TaskEither'; | ||
|
||
import { ApiResult } from '../../shared/types'; | ||
import { cliConfig } from '../config/cli-config'; | ||
import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; | ||
import { handleApiResult } from '../utils/database'; | ||
import { handleError } from '../utils/errors'; | ||
|
||
export class BaseCommand extends Command { | ||
constructor(name: string, description: string) { | ||
super(name); | ||
this.description(description); | ||
} | ||
|
||
action(fn: (...args: any[]) => Promise<void> | void): this { | ||
return super.action(async (...args: any[]) => { | ||
try { | ||
await fn(...args); | ||
} catch (error) { | ||
this.handleError(error, `executing ${this.name()}`); | ||
} | ||
}); | ||
} | ||
|
||
protected async showMenu<T>( | ||
message: string, | ||
choices: Array<{ name: string; value: T }>, | ||
options: { includeGoBack?: boolean; goBackValue?: T; goBackLabel?: string; clearConsole?: boolean } = {} | ||
): Promise<T> { | ||
const { | ||
includeGoBack = true, | ||
goBackValue = 'back' as T, | ||
goBackLabel = 'Go back', | ||
clearConsole = true | ||
} = options; | ||
|
||
if (clearConsole) { | ||
// console.clear(); | ||
} | ||
// Core types | ||
export type CommandContext = { | ||
readonly args: ReadonlyArray<string>; | ||
readonly options: Readonly<Record<string, unknown>>; | ||
}; | ||
|
||
export type CommandError = { | ||
readonly code: string; | ||
readonly message: string; | ||
readonly context?: unknown; | ||
}; | ||
|
||
export interface CommandResult { | ||
readonly completed: boolean; | ||
readonly action?: string; | ||
} | ||
|
||
const menuChoices = [...choices]; | ||
export interface BaseCommand<A> { | ||
readonly name: string; | ||
readonly description: string; | ||
readonly execute: (ctx: CommandContext) => Promise<TE.TaskEither<CommandError, A>>; | ||
} | ||
|
||
if (includeGoBack) { | ||
menuChoices.push({ | ||
name: chalk.red(chalk.bold(goBackLabel)), | ||
value: goBackValue | ||
}); | ||
// export type CommandResult<A> = E.Either<CommandError, A>; | ||
|
||
export type CommandTask<A> = TE.TaskEither<CommandError, A>; | ||
|
||
// Command creation helper | ||
export const createCommand = <A extends CommandResult>( | ||
name: string, | ||
description: string, | ||
execute: (ctx: CommandContext) => TE.TaskEither<CommandError, A> | ||
): Command => { | ||
const command = new Command(name); | ||
command.description(description); | ||
|
||
command.action(async (...args) => { | ||
const ctx: CommandContext = { | ||
args: args.slice(0, -1), | ||
options: args[args.length - 1] || {} | ||
}; | ||
await pipe( | ||
execute(ctx), | ||
TE.fold( | ||
(error: CommandError) => | ||
T.of( | ||
(() => { | ||
throw new Error(`Failed to execute ${name} command: ${error.message}`); | ||
})() | ||
), | ||
(value: A) => T.of(value) | ||
) | ||
)(); | ||
}); | ||
return command; | ||
}; | ||
|
||
export const createCommandLoop = <A extends CommandResult>( | ||
loopFn: () => TE.TaskEither<CommandError, A> | ||
): TE.TaskEither<CommandError, A> => { | ||
const runLoop = (previousResult?: A): TE.TaskEither<CommandError, A> => { | ||
if (previousResult?.completed) { | ||
return TE.right(previousResult); | ||
} | ||
return select<T>({ | ||
message, | ||
choices: menuChoices, | ||
pageSize: cliConfig.MENU_PAGE_SIZE | ||
}); | ||
} | ||
|
||
protected async handleApiResult<T>(result: ApiResult<T>, message: string): Promise<T | null> { | ||
return handleApiResult(result, message); | ||
return pipe(loopFn(), TE.chain(runLoop)); | ||
}; | ||
return runLoop(); | ||
}; | ||
|
||
// Helper for handling command results | ||
export const handleCommandResult = <A>( | ||
result: A, | ||
options?: { | ||
onSuccess?: (value: A) => void; | ||
silent?: boolean; | ||
} | ||
|
||
protected async getInput(message: string, validate?: (input: string) => boolean | string): Promise<string> { | ||
return input({ | ||
message, | ||
validate: validate || ((value: string): boolean | string => value.trim() !== '' || 'Value cannot be empty') | ||
}); | ||
} | ||
|
||
protected async getMultilineInput(message: string, initialValue: string = ''): Promise<string> { | ||
console.log(chalk.cyan(message)); | ||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-input-')); | ||
const tempFilePath = path.join(tempDir, 'input.txt'); | ||
|
||
try { | ||
const cleanedInitialValue = | ||
initialValue.startsWith(FRAGMENT_PREFIX) || initialValue.startsWith(ENV_PREFIX) ? '' : initialValue; | ||
await fs.writeFile(tempFilePath, cleanedInitialValue); | ||
const input = await editor({ | ||
message: 'Edit your input', | ||
default: cleanedInitialValue, | ||
waitForUseInput: false, | ||
postfix: '.txt' | ||
}); | ||
return input; | ||
} finally { | ||
await fs.remove(tempDir); | ||
): void => { | ||
if (!options?.silent) { | ||
if (options?.onSuccess) { | ||
options.onSuccess(result); | ||
} | ||
} | ||
|
||
protected async pressKeyToContinue(): Promise<void> { | ||
await input({ message: 'Press Enter to continue...' }); | ||
} | ||
|
||
protected async confirmAction(message: string): Promise<boolean> { | ||
const action = await this.showMenu<'yes' | 'no'>( | ||
message, | ||
[ | ||
{ name: 'Yes', value: 'yes' }, | ||
{ name: 'No', value: 'no' } | ||
], | ||
{ includeGoBack: false } | ||
); | ||
return action === 'yes'; | ||
} | ||
|
||
protected handleError(error: unknown, context: string): void { | ||
handleError(error, context); | ||
} | ||
|
||
async runCommand(program: Command, commandName: string): Promise<void> { | ||
const command = program.commands.find((cmd) => cmd.name() === commandName); | ||
|
||
if (command) { | ||
try { | ||
await command.parseAsync([], { from: 'user' }); | ||
} catch (error) { | ||
console.error(chalk.red(`Error executing command '${commandName}':`, error)); | ||
}; | ||
|
||
// Type guard for command errors | ||
export const isCommandError = (error: unknown): error is CommandError => | ||
typeof error === 'object' && error !== null && 'code' in error && 'message' in error; | ||
|
||
// Helper for creating command errors | ||
export const createCommandError = (code: string, message: string, context?: unknown): CommandError => ({ | ||
code, | ||
message, | ||
context | ||
}); | ||
|
||
// Task composition helpers with fixed types | ||
export const withErrorHandling = <A>(task: T.Task<A>, errorMapper: (error: unknown) => CommandError): CommandTask<A> => | ||
pipe( | ||
task, | ||
TE.fromTask, | ||
TE.mapLeft((error) => errorMapper(error)) | ||
); | ||
|
||
// Validation chain helper with fixed types | ||
export const validate = <A>( | ||
value: A, | ||
validators: Array<(value: A) => E.Either<CommandError, A>> | ||
): E.Either<CommandError, A> => | ||
validators.reduce( | ||
(result, validator) => pipe(result, E.chain(validator)), | ||
E.right(value) as E.Either<CommandError, A> | ||
); | ||
|
||
// Helper for creating TaskEither from Promise | ||
export const taskEitherFromPromise = <A>( | ||
promise: () => Promise<A>, | ||
errorMapper: (error: unknown) => CommandError | ||
): TE.TaskEither<CommandError, A> => | ||
TE.tryCatch(async () => { | ||
const result = await promise(); | ||
return result as A; // Allow undefined/void returns | ||
}, errorMapper); | ||
|
||
// Helper for converting ApiResult to TaskEither | ||
export const fromApiResult = <A>(promise: Promise<ApiResult<A>>): TE.TaskEither<CommandError, A> => | ||
TE.tryCatch( | ||
async () => { | ||
const result = await promise; | ||
|
||
if (!result.success) { | ||
throw new Error(result.error || 'Operation failed'); | ||
} | ||
} else { | ||
console.error(chalk.red(`Command '${commandName}' not found.`)); | ||
} | ||
} | ||
|
||
async runSubCommand(command: BaseCommand): Promise<void> { | ||
try { | ||
await command.parseAsync([], { from: 'user' }); | ||
} catch (error) { | ||
this.handleError(error, `running ${command.name()} command`); | ||
} | ||
} | ||
} | ||
return result.data as A; // Allow undefined data | ||
}, | ||
(error) => createCommandError('API_ERROR', String(error)) | ||
); | ||
|
||
// Helper for converting ApiResult-returning function to TaskEither | ||
export const fromApiFunction = <A>(fn: () => Promise<ApiResult<A>>): TE.TaskEither<CommandError, A> => | ||
TE.tryCatch( | ||
async () => { | ||
const result = await fn(); | ||
|
||
if (!result.success) { | ||
throw new Error(result.error || 'Operation failed'); | ||
} | ||
return result.data as A; // Allow undefined data | ||
}, | ||
(error) => createCommandError('API_ERROR', String(error)) | ||
); | ||
|
||
// Helper for handling nullable values | ||
export const fromNullable = <A>( | ||
value: A | null | undefined, | ||
errorMessage: string | ||
): TE.TaskEither<CommandError, NonNullable<A>> => | ||
pipe( | ||
O.fromNullable(value), | ||
TE.fromOption(() => createCommandError('NULL_ERROR', errorMessage)) | ||
); | ||
|
||
// Helper for handling optional values with custom error | ||
export const requireValue = <A>( | ||
value: A | undefined, | ||
errorCode: string, | ||
errorMessage: string | ||
): TE.TaskEither<CommandError, NonNullable<A>> => | ||
pipe( | ||
O.fromNullable(value), | ||
TE.fromOption(() => createCommandError(errorCode, errorMessage)) | ||
); | ||
|
||
// Helper for handling arrays of TaskEither | ||
export const sequenceArray = <A>(tasks: Array<TE.TaskEither<CommandError, A>>): TE.TaskEither<CommandError, Array<A>> => | ||
pipe(tasks, A.sequence(TE.ApplicativeSeq)); | ||
|
||
// Helper for mapping arrays with TaskEither | ||
export const traverseArray = <A, B>( | ||
arr: Array<A>, | ||
f: (a: A) => TE.TaskEither<CommandError, B> | ||
): TE.TaskEither<CommandError, Array<B>> => pipe(arr, A.traverse(TE.ApplicativeSeq)(f)); | ||
|
||
// Helper for conditional execution | ||
export const whenTE = <A>( | ||
condition: boolean, | ||
task: TE.TaskEither<CommandError, A> | ||
): TE.TaskEither<CommandError, O.Option<A>> => | ||
condition | ||
? pipe( | ||
task, | ||
TE.map((a) => O.some(a)) | ||
) | ||
: TE.right(O.none); | ||
|
||
// Helper for handling errors with recovery | ||
export const withErrorRecovery = <A>( | ||
task: TE.TaskEither<CommandError, A>, | ||
recovery: (error: CommandError) => TE.TaskEither<CommandError, A> | ||
): TE.TaskEither<CommandError, A> => pipe(task, TE.orElse(recovery)); |
Oops, something went wrong.