Skip to content

Commit

Permalink
🧪
Browse files Browse the repository at this point in the history
  • Loading branch information
thibaultyou committed Oct 27, 2024
1 parent 78a2648 commit c825801
Show file tree
Hide file tree
Showing 28 changed files with 2,189 additions and 1,630 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export default [
allowNamedFunctions: true
}
],
'func-style': ['error', 'declaration'],
'func-style': ['error', 'declaration', { allowArrowFunctions: true } ],
'no-multi-spaces': 'error',
'no-multiple-empty-lines': [
'error',
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"cli-spinner": "0.2.10",
"commander": "12.1.0",
"dotenv": "16.4.5",
"fp-ts": "2.16.9",
"fs-extra": "11.2.0",
"js-yaml": "4.1.0",
"nunjucks": "3.2.4",
Expand Down
6 changes: 3 additions & 3 deletions src/app/utils/yaml-operations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as yaml from 'js-yaml';

import { PromptMetadata, Variable } from '../../shared/types';
import { PromptMetadata, PromptVariable } from '../../shared/types';
import logger from '../../shared/utils/logger';
import { appConfig } from '../config/app-config';

Expand Down Expand Up @@ -73,8 +73,8 @@ export function isValidMetadata(obj: unknown): obj is PromptMetadata {
return true;
}

function isValidVariable(obj: unknown): obj is Variable {
const variable = obj as Partial<Variable>;
function isValidVariable(obj: unknown): obj is PromptVariable {
const variable = obj as Partial<PromptVariable>;
return (
typeof variable === 'object' &&
variable !== null &&
Expand Down
322 changes: 198 additions & 124 deletions src/cli/commands/base-command.ts
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(
(() => {

Check failure on line 57 in src/cli/commands/base-command.ts

View workflow job for this annotation

GitHub Actions / update_views

Missing return type on function
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));
Loading

0 comments on commit c825801

Please sign in to comment.