diff --git a/packages/cli/package.json b/packages/cli/package.json index 61e71038..35c1facc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "@types/lodash": "^4.17.15", "@types/mocha": "^10.0.1", "@types/node": "^22.10.5", + "@types/sinon": "^21.0.0", "axios-mock-adapter": "^2.1.0", "chai": "^4.3.7", "eslint-config-mimic": "^0.0.3", diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index fc60d857..d0a719a2 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,7 +1,9 @@ import { Command, Flags } from '@oclif/core' -import Codegen from './codegen' -import Compile from './compile' +import { build } from '../core' +import { createConfirmClean, runTasks } from '../helpers' +import MimicConfigHandler, { taskFilterFlags } from '../lib/MimicConfigHandler' +import { coreLogger } from '../log' export default class Build extends Command { static override description = 'Runs code generation and then compiles the task' @@ -20,18 +22,37 @@ export default class Build extends Command { description: 'remove existing generated types before generating new files', default: false, }), + ...taskFilterFlags, } public async run(): Promise { const { flags } = await this.parse(Build) - const { manifest, task, output, types, clean } = flags - - const codegenArgs: string[] = ['--manifest', manifest, '--output', types] - if (clean) codegenArgs.push('--clean') - - await Codegen.run(codegenArgs) - - const compileArgs: string[] = ['--task', task, '--manifest', manifest, '--output', output] - await Compile.run(compileArgs) + const { manifest, task, output, types, clean, include, exclude } = flags + + const tasks = MimicConfigHandler.getFilteredTasks(this, { + defaultTask: { + manifest, + task: task, + output, + types, + }, + include, + exclude, + }) + await runTasks(this, tasks, async (config) => { + await build( + { + manifestPath: config.manifest, + taskPath: config.task, + outputDir: config.output, + typesDir: config.types, + clean, + confirmClean: createConfirmClean(this, config.types, coreLogger), + }, + coreLogger + ) + + coreLogger.info(`Build complete! Artifacts in ${config.output}/`) + }) } } diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts index 01d1deca..664dc87b 100644 --- a/packages/cli/src/commands/codegen.ts +++ b/packages/cli/src/commands/codegen.ts @@ -1,11 +1,9 @@ -import { confirm } from '@inquirer/prompts' import { Command, Flags } from '@oclif/core' -import * as fs from 'fs' -import { join } from 'path' -import { AbisInterfaceGenerator, InputsInterfaceGenerator, ManifestHandler } from '../lib' -import log from '../log' -import { Manifest } from '../types' +import { codegen } from '../core' +import { createConfirmClean, runTasks } from '../helpers' +import MimicConfigHandler, { taskFilterFlags } from '../lib/MimicConfigHandler' +import { coreLogger } from '../log' export default class Codegen extends Command { static override description = 'Generates typed interfaces for declared inputs and ABIs from your manifest.yaml file' @@ -14,56 +12,39 @@ export default class Codegen extends Command { static override flags = { manifest: Flags.string({ char: 'm', description: 'Specify a custom manifest file path', default: 'manifest.yaml' }), - output: Flags.string({ char: 'o', description: 'Ouput directory for generated types', default: './src/types' }), + output: Flags.string({ char: 'o', description: 'Output directory for generated types', default: './src/types' }), clean: Flags.boolean({ char: 'c', description: 'Remove existing generated types before generating new files', default: false, }), + ...taskFilterFlags, } public async run(): Promise { const { flags } = await this.parse(Codegen) - const { manifest: manifestDir, output: outputDir, clean } = flags - const manifest = ManifestHandler.load(this, manifestDir) - - if (clean) { - const shouldDelete = await confirm({ - message: `Are you sure you want to ${log.warnText('delete')} all the contents in ${log.highlightText(outputDir)}. This action is ${log.warnText('irreversible')}`, - default: false, - }) - if (!shouldDelete) { - console.log('You can remove the --clean flag from your command') - console.log('Stopping initialization...') - this.exit(0) - } - log.startAction(`Deleting contents of ${outputDir}`) - if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true }) - } - - log.startAction('Generating code') - if (Object.keys(manifest.inputs).length == 0 && Object.keys(manifest.abis).length == 0) { - log.stopAction() - return - } - - if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) - - generateAbisCode(manifest, outputDir, manifestDir) - generateInputsCode(manifest, outputDir) - log.stopAction() + const { manifest, output, clean, include, exclude } = flags + + const tasks = MimicConfigHandler.getFilteredTasks(this, { + defaultTask: { + manifest, + types: output, + task: '', + output: '', + }, + include, + exclude, + }) + await runTasks(this, tasks, async (config) => { + await codegen( + { + manifestPath: config.manifest, + outputDir: config.types, + clean, + confirmClean: createConfirmClean(this, config.types, coreLogger), + }, + coreLogger + ) + }) } } - -function generateAbisCode(manifest: Manifest, outputDir: string, manifestDir: string) { - for (const [contractName, path] of Object.entries(manifest.abis)) { - const abi = JSON.parse(fs.readFileSync(join(manifestDir, '../', path), 'utf-8')) - const abiInterface = AbisInterfaceGenerator.generate(abi, contractName) - if (abiInterface.length > 0) fs.writeFileSync(`${outputDir}/${contractName}.ts`, abiInterface) - } -} - -function generateInputsCode(manifest: Manifest, outputDir: string) { - const inputsInterface = InputsInterfaceGenerator.generate(manifest.inputs) - if (inputsInterface.length > 0) fs.writeFileSync(`${outputDir}/index.ts`, inputsInterface) -} diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index c632cfdd..c0c53ea4 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -1,10 +1,9 @@ import { Command, Flags } from '@oclif/core' -import * as fs from 'fs' -import * as path from 'path' -import ManifestHandler from '../lib/ManifestHandler' -import { execBinCommand } from '../lib/packageManager' -import log from '../log' +import { compile } from '../core' +import { runTasks } from '../helpers' +import MimicConfigHandler, { taskFilterFlags } from '../lib/MimicConfigHandler' +import { coreLogger } from '../log' export default class Compile extends Command { static override description = 'Compiles task' @@ -15,45 +14,34 @@ export default class Compile extends Command { task: Flags.string({ char: 't', description: 'task to compile', default: 'src/task.ts' }), manifest: Flags.string({ char: 'm', description: 'manifest to validate', default: 'manifest.yaml' }), output: Flags.string({ char: 'o', description: 'output directory', default: './build' }), + ...taskFilterFlags, } public async run(): Promise { const { flags } = await this.parse(Compile) - const { task: taskFile, output: outputDir, manifest: manifestDir } = flags - - const absTaskFile = path.resolve(taskFile) - const absOutputDir = path.resolve(outputDir) - - if (!fs.existsSync(absOutputDir)) fs.mkdirSync(absOutputDir, { recursive: true }) - - log.startAction('Verifying Manifest') - const manifest = ManifestHandler.load(this, manifestDir) - log.startAction('Compiling') - - const ascArgs = [ - absTaskFile, - '--target', - 'release', - '--outFile', - path.join(absOutputDir, 'task.wasm'), - '--optimize', - '--exportRuntime', - '--transform', - 'json-as/transform', - ] - - const result = execBinCommand('asc', ascArgs, process.cwd()) - if (result.status !== 0) { - this.error('AssemblyScript compilation failed', { - code: 'BuildError', - suggestions: ['Check the AssemblyScript file'], - }) - } - - log.startAction('Saving files') - - fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) - log.stopAction() - console.log(`Build complete! Artifacts in ${outputDir}/`) + const { task: taskPath, output, manifest, include, exclude } = flags + + const tasks = MimicConfigHandler.getFilteredTasks(this, { + defaultTask: { + manifest, + task: taskPath, + output, + types: '', + }, + include, + exclude, + }) + await runTasks(this, tasks, async (config) => { + await compile( + { + manifestPath: config.manifest, + taskPath: config.task, + outputDir: config.output, + }, + coreLogger + ) + + coreLogger.info(`Build complete! Artifacts in ${config.output}/`) + }) } } diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index fca3d202..8cf496fc 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -1,18 +1,14 @@ import { Flags } from '@oclif/core' -import axios, { AxiosError } from 'axios' -import FormData from 'form-data' -import * as fs from 'fs' -import { join, resolve } from 'path' +import { resolve } from 'path' -import { GENERIC_SUGGESTION } from '../errors' -import { ProfileCredentials } from '../lib/CredentialsManager' -import { execBinCommand } from '../lib/packageManager' -import log from '../log' +import { DEFAULT_TASK } from '../constants' +import { build, deploy, MIMIC_REGISTRY_DEFAULT } from '../core' +import { runTasks } from '../helpers' +import MimicConfigHandler, { taskFilterFlags } from '../lib/MimicConfigHandler' +import log, { coreLogger } from '../log' import Authenticate from './authenticate' -const MIMIC_REGISTRY_DEFAULT = 'https://api-protocol.mimic.fi' - export default class Deploy extends Authenticate { static override description = 'Uploads your compiled task artifacts to IPFS and registers it into the Mimic Registry' @@ -24,95 +20,54 @@ export default class Deploy extends Authenticate { static override flags = { ...Authenticate.flags, - input: Flags.string({ char: 'i', description: 'Directory containing the compiled artifacts', default: './build' }), + input: Flags.string({ char: 'i', description: 'Directory containing the compiled artifacts' }), output: Flags.string({ char: 'o', description: 'Output directory for deployment CID', default: './build' }), url: Flags.string({ char: 'u', description: `Mimic Registry base URL`, default: MIMIC_REGISTRY_DEFAULT }), 'skip-compile': Flags.boolean({ description: 'Skip codegen and compile steps before uploading', default: false }), + ...taskFilterFlags, } public async run(): Promise { const { flags } = await this.parse(Deploy) - const { input: inputDir, output: outputDir, 'skip-compile': skipCompile, url: registryUrl } = flags - const fullInputDir = resolve(inputDir) - const fullOutputDir = resolve(outputDir) - - let credentials = this.authenticate(flags) - - if (!skipCompile) { - const codegen = execBinCommand('mimic', ['codegen'], process.cwd()) - if (codegen.status !== 0) - this.error('Code generation failed', { code: 'CodegenError', suggestions: ['Fix manifest and ABI files'] }) - - const compile = execBinCommand('mimic', ['compile', '--output', fullInputDir], process.cwd()) - if (compile.status !== 0) - this.error('Compilation failed', { code: 'BuildError', suggestions: ['Check the task source code'] }) - } - - log.startAction('Validating') - - if (!fs.existsSync(fullInputDir)) - this.error(`Directory ${log.highlightText(fullInputDir)} does not exist`, { - code: 'Directory Not Found', - suggestions: ['Use the --input flag to specify the correct path'], - }) - - const neededFiles = ['manifest.json', 'task.wasm'].map((file) => join(fullInputDir, file)) - for (const file of neededFiles) { - if (!fs.existsSync(file)) - this.error(`Could not find ${file}`, { - code: 'File Not Found', - suggestions: [`Use ${log.highlightText('mimic compile')} to generate the needed files`], - }) - } - - log.startAction('Uploading to Mimic Registry') - const CID = await this.uploadToRegistry(neededFiles, credentials, registryUrl) - console.log(`IPFS CID: ${log.highlightText(CID)}`) - log.stopAction() - - if (!fs.existsSync(fullOutputDir)) fs.mkdirSync(fullOutputDir, { recursive: true }) - fs.writeFileSync(join(fullOutputDir, 'CID.json'), JSON.stringify({ CID }, null, 2)) - console.log(`CID saved at ${log.highlightText(fullOutputDir)}`) - console.log(`Task deployed!`) - } - - private async uploadToRegistry( - files: string[], - credentials: ProfileCredentials, - registryUrl: string - ): Promise { - try { - const form = filesToForm(files) - const { data } = await axios.post(`${registryUrl}/tasks`, form, { - headers: { - 'x-api-key': credentials.apiKey, - 'Content-Type': `multipart/form-data; boundary=${form.getBoundary()}`, + const { profile, 'api-key': apiKey, input, output, 'skip-compile': skipCompile, url, include, exclude } = flags + + const tasks = MimicConfigHandler.getFilteredTasks(this, { + defaultTask: { ...DEFAULT_TASK, output }, + include, + exclude, + }) + await runTasks(this, tasks, async (config) => { + const inputPath = resolve(input ?? config.output) + const outputPath = resolve(config.output) + + const credentials = this.authenticate({ profile, 'api-key': apiKey }) + + if (!skipCompile) { + await build( + { + manifestPath: config.manifest, + taskPath: config.task, + outputDir: inputPath, + typesDir: config.types, + clean: false, + }, + coreLogger + ) + } + + const result = await deploy( + { + inputDir: inputPath, + outputDir: outputPath, + apiKey: credentials.apiKey, + registryUrl: url, }, - }) - return data.CID - } catch (err) { - this.handleError(err, 'Failed to upload to registry') - } - } + coreLogger + ) - private handleError(err: unknown, message: string): never { - if (!(err instanceof AxiosError)) this.error(err as Error) - const statusCode = err.response?.status - if (statusCode === 400) { - const errMessage = err.response?.data?.content?.message || message - this.error(errMessage, { code: 'Bad Request', suggestions: ['Review the uploaded files'] }) - } - if (statusCode === 401) this.error(message, { code: 'Unauthorized', suggestions: ['Review your key'] }) - if (statusCode === 403) this.error(message, { code: 'Invalid api key', suggestions: ['Review your key'] }) - this.error(`${message} - ${err.message}`, { code: `${statusCode} Error`, suggestions: GENERIC_SUGGESTION }) + coreLogger.info(`IPFS CID: ${log.highlightText(result.cid)}`) + coreLogger.info(`CID saved at ${log.highlightText(outputPath)}`) + coreLogger.info(`Task deployed!`) + }) } } - -const filesToForm = (files: string[]): FormData => { - return files.reduce((form, file) => { - const fileStream = fs.createReadStream(file) - const filename = file.split('/').pop() - form.append('file', fileStream, { filename }) - return form - }, new FormData()) -} diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index edd47f4c..843d093b 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -1,7 +1,11 @@ import { Command, Flags } from '@oclif/core' import * as path from 'path' -import { execBinCommand } from '../lib/packageManager' +import { DEFAULT_TASK } from '../constants' +import { buildForTest, getTestPath, runTests } from '../core' +import { runTasks } from '../helpers' +import MimicConfigHandler, { taskFilterFlags } from '../lib/MimicConfigHandler' +import { coreLogger } from '../log' export default class Test extends Command { static override description = 'Runs task tests' @@ -11,22 +15,47 @@ export default class Test extends Command { static override flags = { directory: Flags.string({ char: 'd', description: 'task directory', default: './' }), 'skip-compile': Flags.boolean({ description: 'skip codegen and compile steps' }), + ...taskFilterFlags, } public async run(): Promise { const { flags } = await this.parse(Test) - const { directory, 'skip-compile': skipCompile } = flags + const { directory, 'skip-compile': skipCompile, include, exclude } = flags const baseDir = path.resolve(directory) - const testPath = path.join(baseDir, 'tests') + + const testPaths = new Set() + + const tasks = MimicConfigHandler.getFilteredTasks(this, { + defaultTask: DEFAULT_TASK, + include, + exclude, + baseDir, + }) if (!skipCompile) { - const cg = execBinCommand('mimic', ['codegen'], baseDir) - if (cg.status !== 0) this.exit(cg.status ?? 1) - const cp = execBinCommand('mimic', ['compile'], baseDir) - if (cp.status !== 0) this.exit(cp.status ?? 1) + await runTasks(this, tasks, async (config) => { + const originalCwd = process.cwd() + try { + process.chdir(baseDir) + await buildForTest( + { + manifestPath: config.manifest, + taskPath: config.task, + outputDir: config.output, + typesDir: config.types, + cwd: baseDir, + }, + coreLogger + ) + } finally { + process.chdir(originalCwd) + } + testPaths.add(getTestPath(baseDir)) + }) + } else { + testPaths.add(getTestPath(baseDir)) } - const result = execBinCommand('tsx', ['./node_modules/mocha/bin/mocha.js', `${testPath}/**/*.spec.ts`], baseDir) - this.exit(result.status ?? 1) + if (testPaths.size > 0) runTests({ testPaths: Array.from(testPaths), baseDir }, coreLogger) } } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 00000000..a7166c78 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,10 @@ +import { RequiredTaskConfig } from './types' + +export const DEFAULT_TASK_NAME = 'default' + +export const DEFAULT_TASK: Omit = { + manifest: 'manifest.yaml', + task: 'src/task.ts', + types: './src/types', + output: './build', +} diff --git a/packages/cli/src/core/build.ts b/packages/cli/src/core/build.ts new file mode 100644 index 00000000..1d6dc038 --- /dev/null +++ b/packages/cli/src/core/build.ts @@ -0,0 +1,33 @@ +import { defaultLogger } from '../log' + +import { codegen } from './codegen' +import { compile } from './compile' +import { BuildOptions, CommandResult, Logger } from './types' + +export async function build(options: BuildOptions, logger: Logger = defaultLogger): Promise { + const { manifestPath, taskPath, outputDir, typesDir, clean, confirmClean, cwd } = options + + const codegenResult = await codegen( + { + manifestPath, + outputDir: typesDir, + clean, + confirmClean, + }, + logger + ) + + if (clean && !codegenResult.success) return { success: false } + + const compileResult = await compile( + { + manifestPath, + taskPath, + outputDir, + cwd, + }, + logger + ) + + return { success: codegenResult.success && compileResult.success } +} diff --git a/packages/cli/src/core/codegen.ts b/packages/cli/src/core/codegen.ts new file mode 100644 index 00000000..4a6996db --- /dev/null +++ b/packages/cli/src/core/codegen.ts @@ -0,0 +1,157 @@ +import * as fs from 'fs' +import { load } from 'js-yaml' +import * as path from 'path' +import { ZodError } from 'zod' + +import { DuplicateEntryError, EmptyManifestError, MoreThanOneEntryError } from '../errors' +import { AbisInterfaceGenerator, InputsInterfaceGenerator } from '../lib' +import { defaultLogger } from '../log' +import { Manifest } from '../types' +import { ManifestValidator } from '../validators' + +import { CodegenError, FileNotFoundError, ManifestValidationError } from './errors' +import { CodegenOptions, CommandResult, Logger } from './types' + +export function loadManifest(manifestPath: string): Manifest { + if (!fs.existsSync(manifestPath)) { + throw new FileNotFoundError(manifestPath, ['Use the -m or --manifest flag to specify the correct path']) + } + + let loadedManifest + try { + loadedManifest = load(fs.readFileSync(manifestPath, 'utf-8')) + } catch { + throw new FileNotFoundError(manifestPath, [ + 'Could not read or parse the manifest file', + 'Ensure the file is valid YAML', + ]) + } + + try { + return validateManifest(loadedManifest) + } catch (err) { + throw convertManifestError(err) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function validateManifest(manifest: any): Manifest { + if (!manifest) throw new EmptyManifestError() + + const mergedManifest = { + ...manifest, + inputs: mergeIfUnique(manifest.inputs), + abis: mergeIfUnique(manifest.abis), + metadata: { libVersion: getLibVersion() }, + } + return ManifestValidator.parse(mergedManifest) +} + +function mergeIfUnique(list: Record[]): Record { + const merged: Record = {} + for (const obj of list || []) { + const entries = Object.entries(obj) + if (entries.length !== 1) throw new MoreThanOneEntryError(entries) + const [key, val] = entries[0] + if (key in merged) throw new DuplicateEntryError(key) + merged[key] = val + } + return merged +} + +function getLibVersion(): string { + let currentDir = process.cwd() + while (currentDir !== path.dirname(currentDir)) { + const libPackagePath = path.join(currentDir, 'node_modules', '@mimicprotocol', 'lib-ts', 'package.json') + if (fs.existsSync(libPackagePath)) { + try { + return JSON.parse(fs.readFileSync(libPackagePath, 'utf-8')).version + } catch (error) { + throw new Error(`Failed to read @mimicprotocol/lib-ts version: ${error}`) + } + } + currentDir = path.dirname(currentDir) + } + throw new Error('Could not find @mimicprotocol/lib-ts package') +} + +function convertManifestError(err: unknown): ManifestValidationError { + if (err instanceof MoreThanOneEntryError) { + return new ManifestValidationError(err.message, [ + `${err.location[1][0]}: ${err.location[1][1]} might be missing a prepended '-' on manifest`, + ]) + } + if (err instanceof DuplicateEntryError) { + return new ManifestValidationError(err.message, [`Review manifest for duplicate key: ${err.duplicateKey}`]) + } + if (err instanceof EmptyManifestError) { + return new ManifestValidationError(err.message, ['Verify if you are using the correct manifest file']) + } + if (err instanceof ZodError) { + return new ManifestValidationError( + 'Missing/Incorrect Fields', + err.errors.map((e) => `Fix Field "${e.path.join('.')}" -- ${e.message}`) + ) + } + return new ManifestValidationError(`Unknown Error: ${err}`) +} + +function generateAbisCode(manifest: Manifest, outputDir: string, manifestDir: string): void { + for (const [contractName, abiRelativePath] of Object.entries(manifest.abis)) { + const abiPath = path.join(manifestDir, '../', abiRelativePath) + if (!fs.existsSync(abiPath)) { + throw new CodegenError(`ABI file not found: ${abiPath}`, [ + `Ensure the ABI file exists at: ${abiRelativePath}`, + 'Check the paths in your manifest.yaml', + ]) + } + + const abi = JSON.parse(fs.readFileSync(abiPath, 'utf-8')) + const abiInterface = AbisInterfaceGenerator.generate(abi, contractName) + if (abiInterface.length > 0) { + const outputPath = `${outputDir}/${contractName}.ts` + fs.writeFileSync(outputPath, abiInterface) + } + } +} + +function generateInputsCode(manifest: Manifest, outputDir: string): void { + const inputsInterface = InputsInterfaceGenerator.generate(manifest.inputs) + + if (inputsInterface.length > 0) { + const outputPath = `${outputDir}/index.ts` + fs.writeFileSync(outputPath, inputsInterface) + } +} + +export async function codegen(options: CodegenOptions, logger: Logger = defaultLogger): Promise { + const { manifestPath, outputDir, clean, confirmClean } = options + + const manifest = loadManifest(manifestPath) + + if (clean) { + if (confirmClean) { + const shouldDelete = await confirmClean() + if (!shouldDelete) return { success: false } + } + + logger.startAction(`Deleting contents of ${outputDir}`) + if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true }) + } + + logger.startAction('Generating code') + + if (Object.keys(manifest.inputs).length === 0 && Object.keys(manifest.abis).length === 0) { + logger.stopAction() + return { success: true } + } + + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) + + generateAbisCode(manifest, outputDir, manifestPath) + generateInputsCode(manifest, outputDir) + + logger.stopAction() + + return { success: true } +} diff --git a/packages/cli/src/core/compile.ts b/packages/cli/src/core/compile.ts new file mode 100644 index 00000000..81a48233 --- /dev/null +++ b/packages/cli/src/core/compile.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs' +import * as path from 'path' + +import { execBinCommand } from '../lib/packageManager' +import { defaultLogger } from '../log' + +import { loadManifest } from './codegen' +import { CompilationError, FileNotFoundError } from './errors' +import { CommandResult, CompileOptions, Logger } from './types' + +export async function compile(options: CompileOptions, logger: Logger = defaultLogger): Promise { + const { manifestPath, taskPath, outputDir, cwd = process.cwd() } = options + + const resolvedTaskPath = path.resolve(taskPath) + const resolvedOutputDir = path.resolve(outputDir) + + if (!fs.existsSync(resolvedTaskPath)) { + throw new FileNotFoundError(resolvedTaskPath, [ + 'Use the -t or --task flag to specify the correct path', + `Expected task file at: ${taskPath}`, + ]) + } + + if (!fs.existsSync(resolvedOutputDir)) fs.mkdirSync(resolvedOutputDir, { recursive: true }) + + logger.startAction('Verifying Manifest') + const manifest = loadManifest(manifestPath) + + logger.startAction('Compiling') + + const wasmPath = path.join(resolvedOutputDir, 'task.wasm') + const ascArgs = [ + resolvedTaskPath, + '--target', + 'release', + '--outFile', + wasmPath, + '--optimize', + '--exportRuntime', + '--transform', + 'json-as/transform', + ] + + const result = execBinCommand('asc', ascArgs, cwd) + if (result.status !== 0) { + throw new CompilationError('AssemblyScript compilation failed', [ + 'Check the AssemblyScript file for syntax errors', + 'Ensure all dependencies are installed', + ]) + } + + logger.startAction('Saving files') + const manifestJsonPath = path.join(resolvedOutputDir, 'manifest.json') + fs.writeFileSync(manifestJsonPath, JSON.stringify(manifest, null, 2)) + + logger.stopAction() + + return { success: true } +} diff --git a/packages/cli/src/core/deploy.ts b/packages/cli/src/core/deploy.ts new file mode 100644 index 00000000..ef5cd80f --- /dev/null +++ b/packages/cli/src/core/deploy.ts @@ -0,0 +1,104 @@ +import axios, { AxiosError } from 'axios' +import FormData from 'form-data' +import * as fs from 'fs' +import { join } from 'path' + +import { defaultLogger } from '../log' + +import { DeployError, DirectoryNotFoundError, FileNotFoundError } from './errors' +import { DeployOptions, DeployResult, Logger } from './types' + +export const MIMIC_REGISTRY_DEFAULT = 'https://api-protocol.mimic.fi' +const REQUIRED_FILES = ['manifest.json', 'task.wasm'] + +function validateInputDirectory(inputDir: string): string[] { + if (!fs.existsSync(inputDir)) { + throw new DirectoryNotFoundError(inputDir, ['Use the --input flag to specify the correct path']) + } + + const neededFiles = REQUIRED_FILES.map((file) => join(inputDir, file)) + + for (const file of neededFiles) { + if (!fs.existsSync(file)) throw new FileNotFoundError(file, ['Use `mimic compile` to generate the needed files']) + } + + return neededFiles +} + +function filesToForm(files: string[]): FormData { + return files.reduce((form, file) => { + const fileStream = fs.createReadStream(file) + const filename = file.split('/').pop() + form.append('file', fileStream, { filename }) + return form + }, new FormData()) +} + +async function uploadToRegistry(files: string[], apiKey: string, registryUrl: string): Promise { + try { + const form = filesToForm(files) + const { data } = await axios.post(`${registryUrl}/tasks`, form, { + headers: { + 'x-api-key': apiKey, + 'Content-Type': `multipart/form-data; boundary=${form.getBoundary()}`, + }, + }) + return data.CID + } catch (err) { + handleUploadError(err) + } +} + +function handleUploadError(err: unknown): never { + if (!(err instanceof AxiosError)) { + const message = err instanceof Error ? err.message : String(err) + throw new DeployError(message) + } + + const statusCode = err.response?.status + + switch (statusCode) { + case 400: { + const errMessage = err.response?.data?.content?.message || 'Bad request' + throw new DeployError(errMessage, { + statusCode, + suggestions: ['Review the uploaded files'], + }) + } + case 401: + throw new DeployError('Unauthorized', { + statusCode, + suggestions: ['Review your API key'], + }) + case 403: + throw new DeployError('Invalid API key', { + statusCode, + suggestions: ['Review your API key'], + }) + default: { + const message = err.message ? `Upload failed: ${err.message}` : 'Upload failed' + throw new DeployError(message, { + statusCode, + }) + } + } +} + +export async function deploy(options: DeployOptions, logger: Logger = defaultLogger): Promise { + const { inputDir, outputDir, apiKey, registryUrl = MIMIC_REGISTRY_DEFAULT } = options + + logger.startAction('Validating') + const files = validateInputDirectory(inputDir) + + logger.startAction('Uploading to Mimic Registry') + const cid = await uploadToRegistry(files, apiKey, registryUrl) + + logger.stopAction() + + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) + + const cidJsonPath = join(outputDir, 'CID.json') + fs.writeFileSync(cidJsonPath, JSON.stringify({ CID: cid }, null, 2)) + + return { cid } +} diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts new file mode 100644 index 00000000..8542469b --- /dev/null +++ b/packages/cli/src/core/errors.ts @@ -0,0 +1,79 @@ +const GENERIC_SUGGESTION = [ + 'Contact the Mimic team for further assistance at our website https://www.mimic.fi or discord https://discord.mimic.fi', +] + +export class CoreError extends Error { + public code: string + public suggestions: string[] + + constructor(message: string, options: { code: string; suggestions?: string[] }) { + super(message) + this.name = this.constructor.name + this.code = options.code + this.suggestions = options.suggestions ?? GENERIC_SUGGESTION + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export class FileNotFoundError extends CoreError { + public filePath: string + + constructor(filePath: string, suggestions?: string[]) { + super(`File not found: ${filePath}`, { + code: 'FileNotFound', + suggestions: suggestions ?? [`Check that the file exists at: ${filePath}`], + }) + this.filePath = filePath + } +} + +export class DirectoryNotFoundError extends CoreError { + public dirPath: string + + constructor(dirPath: string, suggestions?: string[]) { + super(`Directory not found: ${dirPath}`, { + code: 'DirectoryNotFound', + suggestions: suggestions ?? [`Check that the directory exists at: ${dirPath}`], + }) + this.dirPath = dirPath + } +} + +export class ManifestValidationError extends CoreError { + constructor(message: string, suggestions?: string[]) { + super(message, { + code: 'ManifestValidationError', + suggestions: suggestions ?? ['Check the manifest.yaml file for errors'], + }) + } +} + +export class CodegenError extends CoreError { + constructor(message: string, suggestions?: string[]) { + super(message, { + code: 'CodegenError', + suggestions: suggestions ?? ['Check the manifest.yaml file and ABI files'], + }) + } +} + +export class CompilationError extends CoreError { + constructor(message: string, suggestions?: string[]) { + super(message, { + code: 'CompilationError', + suggestions: suggestions ?? ['Check the AssemblyScript file for syntax errors'], + }) + } +} + +export class DeployError extends CoreError { + public statusCode?: number + + constructor(message: string, options?: { statusCode?: number; suggestions?: string[] }) { + super(message, { + code: options?.statusCode ? `Deploy${options.statusCode}Error` : 'DeployError', + suggestions: options?.suggestions ?? ['Check your API key and network connection'], + }) + this.statusCode = options?.statusCode + } +} diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts new file mode 100644 index 00000000..0535e6ed --- /dev/null +++ b/packages/cli/src/core/index.ts @@ -0,0 +1,7 @@ +export * from './build' +export * from './codegen' +export * from './compile' +export * from './deploy' +export * from './errors' +export * from './test' +export * from './types' diff --git a/packages/cli/src/core/test.ts b/packages/cli/src/core/test.ts new file mode 100644 index 00000000..674b2857 --- /dev/null +++ b/packages/cli/src/core/test.ts @@ -0,0 +1,39 @@ +import * as path from 'path' + +import { execBinCommand } from '../lib/packageManager' +import { defaultLogger } from '../log' + +import { build } from './build' +import { BuildOptions, Logger, RunTestsOptions } from './types' + +export function getTestPath(baseDir: string): string { + return path.join(baseDir, 'tests', '**', '*.spec.ts') +} + +export async function buildForTest( + options: Omit, + logger: Logger = defaultLogger +): Promise { + await build( + { + ...options, + clean: false, + }, + logger + ) +} + +export function runTests(options: RunTestsOptions, logger: Logger = defaultLogger): void { + const { testPaths, baseDir } = options + + logger.startAction('Running tests') + + const result = execBinCommand('tsx', ['./node_modules/mocha/bin/mocha.js', ...testPaths], baseDir) + + const exitCode = result.status ?? 1 + const success = exitCode === 0 + + logger.stopAction() + + if (!success) process.exit(exitCode) +} diff --git a/packages/cli/src/core/types.ts b/packages/cli/src/core/types.ts new file mode 100644 index 00000000..9531eef5 --- /dev/null +++ b/packages/cli/src/core/types.ts @@ -0,0 +1,85 @@ +export type CommandResult = { + /** Whether the operation succeeded */ + success: boolean +} + +// ============================================================================ +// Codegen Types +// ============================================================================ + +export type CodegenOptions = { + /** Path to the manifest.yaml file */ + manifestPath: string + /** Output directory for generated types */ + outputDir: string + /** Whether to delete existing files before generating */ + clean: boolean + /** Callback for confirming clean operation (returns true to proceed) */ + confirmClean?: () => Promise +} + +// ============================================================================ +// Compile Types +// ============================================================================ + +export type CompileOptions = { + /** Path to the manifest.yaml file */ + manifestPath: string + /** Path to the task TypeScript file */ + taskPath: string + /** Output directory for compiled artifacts */ + outputDir: string + /** Working directory for compilation */ + cwd?: string +} + +// ============================================================================ +// Build Types +// ============================================================================ + +export type BuildOptions = Omit & + CompileOptions & { + /** Output directory for generated types (from codegen) */ + typesDir: string + } + +// ============================================================================ +// Deploy Types +// ============================================================================ + +export type DeployOptions = { + /** Directory containing compiled artifacts (task.wasm, manifest.json) */ + inputDir: string + /** Output directory for CID.json */ + outputDir: string + /** API key for authentication */ + apiKey: string + /** Registry URL */ + registryUrl: string +} + +export type DeployResult = { + /** IPFS CID of the deployed task */ + cid: string +} + +// ============================================================================ +// Test Types +// ============================================================================ + +export type RunTestsOptions = { + /** Glob patterns for test files */ + testPaths: string[] + /** Base directory for running tests */ + baseDir: string +} + +// ============================================================================ +// Logging Interface +// ============================================================================ + +export type Logger = { + startAction(message: string): void + stopAction(): void + info(message: string): void +} diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 80a0ada9..773a6dbf 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -31,3 +31,16 @@ export class EmptyManifestError extends Error { export const GENERIC_SUGGESTION = [ 'Contact the Mimic team for further assistance at our website https://www.mimic.fi or discord https://discord.mimic.fi', ] + +export class CommandError extends Error { + public code: string + public suggestions: string[] + + constructor(message: string, options: { code: string; suggestions: string[] }) { + super(message) + this.name = this.constructor.name + this.code = options.code + this.suggestions = options.suggestions + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/packages/cli/src/helpers.ts b/packages/cli/src/helpers.ts index 564e5adb..a0c5cc2d 100644 --- a/packages/cli/src/helpers.ts +++ b/packages/cli/src/helpers.ts @@ -1,8 +1,15 @@ +import { confirm } from '@inquirer/prompts' +import { Command } from '@oclif/core' import { Interface } from 'ethers' import camelCase from 'lodash/camelCase' import startCase from 'lodash/startCase' -import { AbiFunctionItem } from './types' +import { Logger } from './core/types' +import { DEFAULT_TASK_NAME } from './constants' +import { CoreError } from './core' +import { CommandError } from './errors' +import log from './log' +import { AbiFunctionItem, RequiredTaskConfig } from './types' export function getFunctionSelector(fn: AbiFunctionItem): string { const iface = new Interface([fn]) @@ -12,3 +19,84 @@ export function getFunctionSelector(fn: AbiFunctionItem): string { export function pascalCase(str: string): string { return startCase(camelCase(str)).replace(/\s/g, '') } + +function convertToCommandError(error: unknown): Error { + if (error instanceof CoreError) { + return new CommandError(error.message, { + code: error.code, + suggestions: error.suggestions, + }) + } + return error as Error +} + +function logTaskError(taskName: string, err: Error): void { + console.error(log.warnText(`Task "${taskName}" failed: ${err.message}`)) + + if (err instanceof CommandError) { + if (err.code) { + console.error(` Code: ${err.code}`) + } + if (err.suggestions?.length) { + console.error(` Suggestions:`) + err.suggestions.forEach((suggestion) => console.error(` - ${suggestion}`)) + } + } +} + +export async function runTasks( + command: Command, + configs: RequiredTaskConfig[], + runTask: (config: RequiredTaskConfig) => Promise +): Promise { + const errors: Array<{ task: string; error: Error; code?: string; suggestions?: string[] }> = [] + + const shouldLogHeader = configs.length > 1 || configs[0].name !== DEFAULT_TASK_NAME + const isSingleTask = configs.length === 1 + + for (const config of configs) { + if (shouldLogHeader) { + console.log(`\n${log.highlightText(`[${config.name}]`)}`) + } + try { + await runTask(config) + } catch (error) { + const err = convertToCommandError(error) + + if (isSingleTask) throw err + + logTaskError(config.name, err) + + if (err instanceof CommandError) { + errors.push({ task: config.name, error: err, code: err.code, suggestions: err.suggestions }) + } else { + errors.push({ task: config.name, error: err }) + } + } + } + + if (errors.length > 0) { + console.log(`\n${log.warnText('Summary:')} ${errors.length}/${configs.length} task(s) failed`) + errors.forEach(({ task, code }) => { + console.log(code ? ` - ${task} (${code})` : ` - ${task}`) + }) + command.exit(1) + } +} + +export function createConfirmClean(command: Command, directory: string, logger: Logger): () => Promise { + return async function confirmClean(): Promise { + const shouldDelete = await confirm({ + message: `Are you sure you want to ${log.warnText('delete')} all the contents in ${log.highlightText( + directory + )}. This action is ${log.warnText('irreversible')}`, + default: false, + }) + if (!shouldDelete) { + logger.info('You can remove the --clean flag from your command') + logger.info('Stopping initialization...') + command.exit(0) + } + return shouldDelete + } +} diff --git a/packages/cli/src/lib/ManifestHandler.ts b/packages/cli/src/lib/ManifestHandler.ts index 8ebf4d93..bb43f8c8 100644 --- a/packages/cli/src/lib/ManifestHandler.ts +++ b/packages/cli/src/lib/ManifestHandler.ts @@ -4,7 +4,7 @@ import { load } from 'js-yaml' import * as path from 'path' import { ZodError } from 'zod' -import { DuplicateEntryError, EmptyManifestError, MoreThanOneEntryError } from '../errors' +import { DuplicateEntryError, EmptyManifestError, GENERIC_SUGGESTION, MoreThanOneEntryError } from '../errors' import { Manifest } from '../types' import { ManifestValidator } from '../validators' @@ -72,9 +72,7 @@ function handleValidationError(command: Command, err: unknown): never { suggestions = err.errors.map((e) => `Fix Field "${e.path.join('.')}" -- ${e.message}`) } else { ;[message, code] = [`Unkown Error: ${err}`, 'UnknownError'] - suggestions = [ - 'Contact the Mimic team for further assistance at our website https://www.mimic.fi/ or discord https://discord.com/invite/cpcyV9EsEg', - ] + suggestions = GENERIC_SUGGESTION } command.error(message, { code, suggestions }) diff --git a/packages/cli/src/lib/MimicConfigHandler.ts b/packages/cli/src/lib/MimicConfigHandler.ts new file mode 100644 index 00000000..0e6df665 --- /dev/null +++ b/packages/cli/src/lib/MimicConfigHandler.ts @@ -0,0 +1,166 @@ +import { Command, Flags } from '@oclif/core' +import * as fs from 'fs' +import { load } from 'js-yaml' +import * as path from 'path' +import { ZodError } from 'zod' + +import { DEFAULT_TASK_NAME } from '../constants' +import { GENERIC_SUGGESTION } from '../errors' +import log from '../log' +import { MimicConfig, RequiredTaskConfig } from '../types' +import { MimicConfigValidator } from '../validators' + +const MIMIC_CONFIG_FILE = 'mimic.yaml' + +export const taskFilterFlags = { + include: Flags.string({ + description: `When ${MIMIC_CONFIG_FILE} exists, only run tasks with these names (space-separated)`, + multiple: true, + exclusive: ['exclude'], + }), + exclude: Flags.string({ + description: `When ${MIMIC_CONFIG_FILE} exists, exclude tasks with these names (space-separated)`, + multiple: true, + exclusive: ['include'], + }), +} + +export default { + exists(baseDir: string = process.cwd()): boolean { + return fs.existsSync(path.join(baseDir, MIMIC_CONFIG_FILE)) + }, + + load(command: Command, baseDir: string = process.cwd()): MimicConfig { + const mimicConfigPath = path.join(baseDir, MIMIC_CONFIG_FILE) + + if (!fs.existsSync(mimicConfigPath)) { + command.error(`Could not find ${mimicConfigPath}`, { + code: 'FileNotFound', + suggestions: [`Ensure ${MIMIC_CONFIG_FILE} exists in the project root`], + }) + } + + let loadedMimicConfig + try { + loadedMimicConfig = load(fs.readFileSync(mimicConfigPath, 'utf-8')) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + command.error(`Failed to parse ${mimicConfigPath} as YAML`, { + code: 'ParseError', + suggestions: [`Ensure ${MIMIC_CONFIG_FILE} is valid YAML syntax`], + }) + } + + try { + return MimicConfigValidator.parse(loadedMimicConfig) + } catch (err) { + handleValidationError(command, err) + } + }, + + normalizeTaskConfigs(mimicConfig: MimicConfig): RequiredTaskConfig[] { + return mimicConfig.tasks.map((task) => ({ + ...task, + output: task.output ?? `build/${task.name}`, + types: task.types ?? path.join(path.dirname(task.task), 'types'), + })) + }, + + loadOrDefault( + command: Command, + defaultTask: Omit, + baseDir: string = process.cwd() + ): RequiredTaskConfig[] { + if (this.exists(baseDir)) { + const mimicConfig = this.load(command, baseDir) + return this.normalizeTaskConfigs(mimicConfig) + } + + return [ + { + ...defaultTask, + name: DEFAULT_TASK_NAME, + }, + ] + }, + + getFilteredTasks( + command: Command, + options: { + defaultTask: Omit + include?: string[] + exclude?: string[] + baseDir?: string + } + ): RequiredTaskConfig[] { + const allTasks = this.loadOrDefault(command, options.defaultTask, options.baseDir) + return filterTasks(command, allTasks, options.include, options.exclude) + }, +} + +function warnInvalidTaskNames(names: string[]): void { + if (names.length > 0) { + console.warn(`${log.warnText('Warning:')} The following task names were not found: ${names.join(', ')}`) + } +} + +function filterTasks( + command: Command, + tasks: RequiredTaskConfig[], + include?: string[], + exclude?: string[] +): RequiredTaskConfig[] { + if (include && exclude) { + command.error('Cannot use both --include and --exclude flags simultaneously', { + code: 'ConflictingFlags', + suggestions: ['Use either --include or --exclude, but not both'], + }) + } + + if (!include && !exclude) { + return tasks + } + + const taskNames = new Set(tasks.map((task) => task.name)) + + if (include) { + const invalidNames = include.filter((name) => !taskNames.has(name)) + warnInvalidTaskNames(invalidNames) + + const validNames = new Set(include.filter((name) => taskNames.has(name))) + if (validNames.size === 0) { + console.warn(`${log.warnText('Warning:')} No valid tasks to include. All tasks will be skipped.`) + return [] + } + + return tasks.filter((task) => validNames.has(task.name)) + } + + if (exclude) { + const invalidNames = exclude.filter((name) => !taskNames.has(name)) + warnInvalidTaskNames(invalidNames) + + const excludeSet = new Set(exclude) + const filteredTasks = tasks.filter((task) => !excludeSet.has(task.name)) + if (filteredTasks.length === 0) { + console.warn(`${log.warnText('Warning:')} All tasks are excluded.`) + } + return filteredTasks + } + + return tasks +} + +function handleValidationError(command: Command, err: unknown): never { + if (err instanceof ZodError) { + const message = `Invalid ${MIMIC_CONFIG_FILE} configuration` + const code = 'ValidationError' + const suggestions = err.errors.map((e) => `Fix Field "${e.path.join('.')}" -- ${e.message}`) + command.error(message, { code, suggestions }) + } + + const message = `Unknown Error: ${err}` + const code = 'UnknownError' + const suggestions = GENERIC_SUGGESTION + command.error(message, { code, suggestions }) +} diff --git a/packages/cli/src/lib/index.ts b/packages/cli/src/lib/index.ts index f0d10209..6a752b65 100644 --- a/packages/cli/src/lib/index.ts +++ b/packages/cli/src/lib/index.ts @@ -1,5 +1,6 @@ import AbisInterfaceGenerator from './AbisInterfaceGenerator/index' import InputsInterfaceGenerator from './InputsInterfaceGenerator' import ManifestHandler from './ManifestHandler' +import MimicConfigHandler from './MimicConfigHandler' -export { AbisInterfaceGenerator, InputsInterfaceGenerator, ManifestHandler } +export { AbisInterfaceGenerator, InputsInterfaceGenerator, ManifestHandler, MimicConfigHandler } diff --git a/packages/cli/src/log.ts b/packages/cli/src/log.ts index 58f26351..5665be10 100644 --- a/packages/cli/src/log.ts +++ b/packages/cli/src/log.ts @@ -1,6 +1,8 @@ import { ux } from '@oclif/core' import { StandardAnsi } from '@oclif/core/lib/interfaces/theme' +import { Logger } from './core/types' + const log = { startAction: (text: string, color?: StandardAnsi) => { log.stopAction() @@ -14,4 +16,16 @@ const log = { highlightText: (text: string) => ux.colorize('yellow', text), } +export const coreLogger: Logger = { + startAction: log.startAction, + stopAction: log.stopAction, + info: console.log, +} + +export const defaultLogger: Logger = { + startAction: () => {}, + stopAction: () => {}, + info: () => {}, +} + export default log diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index e710ac0a..7f5d9269 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,10 +1,14 @@ import { z } from 'zod' -import { ManifestValidator } from './validators' +import { ManifestValidator, MimicConfigValidator, TaskConfigValidator } from './validators' export type Manifest = z.infer export type ManifestInputs = z.infer +export type TaskConfig = z.infer +export type MimicConfig = z.infer +export type RequiredTaskConfig = Required + export type AbiParameter = { name?: string escapedName?: string diff --git a/packages/cli/src/validators.ts b/packages/cli/src/validators.ts index bcf24259..919514c1 100644 --- a/packages/cli/src/validators.ts +++ b/packages/cli/src/validators.ts @@ -25,3 +25,15 @@ export const ManifestValidator = z.object({ libVersion: String.regex(SEM_VER_REGEX, 'Must be a valid semver'), }), }) + +export const TaskConfigValidator = z.object({ + name: String, + manifest: String, + task: String, + output: String.optional(), + types: String.optional(), +}) + +export const MimicConfigValidator = z.object({ + tasks: z.array(TaskConfigValidator).min(1, 'At least one task must be defined'), +}) diff --git a/packages/cli/tests/MimicConfigHandler.spec.ts b/packages/cli/tests/MimicConfigHandler.spec.ts new file mode 100644 index 00000000..3f61cb0f --- /dev/null +++ b/packages/cli/tests/MimicConfigHandler.spec.ts @@ -0,0 +1,336 @@ +import { Command } from '@oclif/core' +import { expect } from 'chai' +import * as fs from 'fs' +import * as path from 'path' +import * as sinon from 'sinon' + +import MimicConfigHandler from '../src/lib/MimicConfigHandler' +import { RequiredTaskConfig } from '../src/types' +import { MimicConfigValidator } from '../src/validators' + +describe('MimicConfigHandler', () => { + const mimicConfig = { + tasks: [ + { name: 'swap-task', manifest: './tasks/swap/manifest.yaml', task: './tasks/swap/src/task.ts' }, + { name: 'transfer-task', manifest: './tasks/transfer/manifest.yaml', task: './tasks/transfer/src/task.ts' }, + ], + } + + describe('exists', () => { + context('when mimic.yaml exists in the directory', () => { + it('returns true', () => { + const tempDir = path.join(__dirname, 'temp-workspace-test') + fs.mkdirSync(tempDir, { recursive: true }) + fs.writeFileSync(path.join(tempDir, 'mimic.yaml'), 'tasks: []') + + try { + expect(MimicConfigHandler.exists(tempDir)).to.be.true + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + }) + + context('when mimic.yaml does not exist in the directory', () => { + it('returns false', () => { + const tempDir = path.join(__dirname, 'temp-empty-dir') + fs.mkdirSync(tempDir, { recursive: true }) + + try { + expect(MimicConfigHandler.exists(tempDir)).to.be.false + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + }) + }) + + describe('validate', () => { + context('when the mimic config is valid', () => { + context('when everything is present', () => { + it('returns the parsed mimic config', () => { + const parsedMimicConfig = MimicConfigValidator.parse(mimicConfig) + + expect(parsedMimicConfig).to.not.be.undefined + expect(parsedMimicConfig.tasks).to.have.length(2) + }) + }) + + context('when dealing with tasks', () => { + context('when tasks have optional fields', () => { + it('returns the parsed mimic config with all fields', () => { + const mimicConfigWithOptionals = { + ...mimicConfig, + tasks: [{ ...mimicConfig.tasks[0], output: './build/swap', types: './types/swap' }], + } + const parsedMimicConfig = MimicConfigValidator.parse(mimicConfigWithOptionals) + + expect(parsedMimicConfig).to.not.be.undefined + expect(parsedMimicConfig.tasks[0].output).to.equal('./build/swap') + expect(parsedMimicConfig.tasks[0].types).to.equal('./types/swap') + }) + }) + + context('when tasks do not have optional fields', () => { + it('returns the parsed mimic config with undefined optional fields', () => { + const parsedMimicConfig = MimicConfigValidator.parse(mimicConfig) + + expect(parsedMimicConfig).to.not.be.undefined + expect(parsedMimicConfig.tasks[0].output).to.be.undefined + expect(parsedMimicConfig.tasks[0].types).to.be.undefined + }) + }) + }) + }) + + context('when the mimic config is not valid', () => { + const itReturnsAnError = (w: unknown, ...errors: string[]) => { + it('returns an error', () => { + for (const error of errors) expect(() => MimicConfigValidator.parse(w)).to.throw(error) + }) + } + + context('when the tasks array is empty', () => { + itReturnsAnError({ ...mimicConfig, tasks: [] }, 'At least one task must be defined') + }) + + context('when task name is missing', () => { + itReturnsAnError( + { ...mimicConfig, tasks: [{ manifest: './manifest.yaml', task: './src/task.ts' }] }, + 'Required' + ) + }) + + context('when task manifest is missing', () => { + itReturnsAnError({ ...mimicConfig, tasks: [{ name: 'task', task: './src/task.ts' }] }, 'Required') + }) + + context('when task task is missing', () => { + itReturnsAnError({ ...mimicConfig, tasks: [{ name: 'task', manifest: './manifest.yaml' }] }, 'Required') + }) + }) + }) + + describe('normalizeTaskConfigs', () => { + context('when dealing with optional fields', () => { + context('when optional fields are provided', () => { + it('returns tasks with the provided values', () => { + const mimicConfigWithOptionals = { + ...mimicConfig, + tasks: [{ ...mimicConfig.tasks[0], output: './custom-build', types: './custom-types' }], + } + + const tasks = MimicConfigHandler.normalizeTaskConfigs(mimicConfigWithOptionals) + + expect(tasks).to.have.length(1) + expect(tasks[0].output).to.equal('./custom-build') + expect(tasks[0].types).to.equal('./custom-types') + }) + }) + + context('when optional fields are not provided', () => { + it('returns tasks with computed default values', () => { + const tasks = MimicConfigHandler.normalizeTaskConfigs(mimicConfig) + + expect(tasks).to.have.length(2) + expect(tasks[0].output).to.equal('build/swap-task') + expect(tasks[0].types).to.equal('tasks/swap/src/types') + expect(tasks[1].output).to.equal('build/transfer-task') + expect(tasks[1].types).to.equal('tasks/transfer/src/types') + }) + }) + + context('when some tasks have optional fields and some do not', () => { + it('applies defaults only to tasks missing optional fields', () => { + const mixedMimicConfig = { + ...mimicConfig, + tasks: [mimicConfig.tasks[0], { ...mimicConfig.tasks[1], output: './custom-output' }], + } + + const tasks = MimicConfigHandler.normalizeTaskConfigs(mixedMimicConfig) + + expect(tasks).to.have.length(2) + expect(tasks[0].output).to.equal('build/swap-task') + expect(tasks[0].types).to.equal('tasks/swap/src/types') + expect(tasks[1].output).to.equal('./custom-output') + expect(tasks[1].types).to.equal('tasks/transfer/src/types') + }) + }) + }) + }) + + describe('getFilteredTasks', () => { + let mockCommand: Command + let warnSpy: sinon.SinonSpy + let errorStub: sinon.SinonStub + let loadOrDefaultStub: sinon.SinonStub + + const createTask = (name: string): RequiredTaskConfig => ({ + name, + manifest: `./tasks/${name}/manifest.yaml`, + task: `./tasks/${name}/src/task.ts`, + output: `build/${name}`, + types: `./tasks/${name}/src/types`, + }) + + const tasks: RequiredTaskConfig[] = [createTask('swap-task'), createTask('transfer-task'), createTask('call-task')] + + const defaultTask = { + manifest: 'manifest.yaml', + task: 'src/task.ts', + output: './build', + types: './src/types', + } + + beforeEach('setup mocks', () => { + errorStub = sinon.stub().throws(new Error('Command error')) + mockCommand = { + error: errorStub, + } as unknown as Command + + warnSpy = sinon.spy(console, 'warn') + loadOrDefaultStub = sinon.stub(MimicConfigHandler, 'loadOrDefault').returns(tasks) + }) + + afterEach('restore mocks', () => { + warnSpy.restore() + loadOrDefaultStub.restore() + }) + + context('when no filter flags are provided', () => { + it('returns all tasks', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + }) + + expect(result).to.have.length(3) + expect(result).to.deep.equal(tasks) + expect(warnSpy.called).to.be.false + expect(loadOrDefaultStub.calledOnce).to.be.true + }) + }) + + context('when --include flag is provided', () => { + context('when all task names are valid', () => { + context('when including a single task', () => { + it('returns only the included task', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + include: ['swap-task'], + }) + + expect(result).to.have.length(1) + expect(result[0].name).to.equal('swap-task') + expect(warnSpy.called).to.be.false + }) + }) + + context('when including multiple tasks', () => { + it('returns all included tasks', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + include: ['swap-task', 'transfer-task'], + }) + + expect(result).to.have.length(2) + expect(result.map((t) => t.name)).to.include.members(['swap-task', 'transfer-task']) + expect(warnSpy.called).to.be.false + }) + }) + }) + + context('when some task names are invalid', () => { + it('logs a warning and returns valid tasks', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + include: ['swap-task', 'invalid-task'], + }) + + expect(result).to.have.length(1) + expect(result[0].name).to.equal('swap-task') + expect(warnSpy.calledOnce).to.be.true + expect(warnSpy.firstCall.args[0]).to.include('invalid-task') + }) + }) + + context('when all task names are invalid', () => { + it('logs a warning and returns empty array', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + include: ['invalid-task-1', 'invalid-task-2'], + }) + + expect(result).to.have.length(0) + expect(warnSpy.calledTwice).to.be.true + expect(warnSpy.firstCall.args[0]).to.include('invalid-task-1') + expect(warnSpy.secondCall.args[0]).to.include('No valid tasks to include') + }) + }) + }) + + context('when --exclude flag is provided', () => { + context('when all task names are valid', () => { + context('when excluding a single task', () => { + it('returns tasks except the excluded one', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + exclude: ['swap-task'], + }) + + expect(result).to.have.length(2) + expect(result.map((t) => t.name)).to.include.members(['transfer-task', 'call-task']) + expect(result.map((t) => t.name)).to.not.include('swap-task') + expect(warnSpy.called).to.be.false + }) + }) + + context('when excluding multiple tasks', () => { + it('returns tasks except the excluded ones', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + exclude: ['swap-task', 'transfer-task'], + }) + + expect(result).to.have.length(1) + expect(result[0].name).to.equal('call-task') + expect(warnSpy.called).to.be.false + }) + }) + }) + + context('when some task names are invalid', () => { + it('logs a warning and excludes valid tasks', () => { + const result = MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + exclude: ['swap-task', 'invalid-task'], + }) + + expect(result).to.have.length(2) + expect(result.map((t) => t.name)).to.include.members(['transfer-task', 'call-task']) + expect(result.map((t) => t.name)).to.not.include('swap-task') + expect(warnSpy.calledOnce).to.be.true + expect(warnSpy.firstCall.args[0]).to.include('invalid-task') + }) + }) + }) + + context('when both flags are provided', () => { + it('throws a ConflictingFlags error', () => { + expect(() => { + MimicConfigHandler.getFilteredTasks(mockCommand, { + defaultTask, + include: ['swap-task'], + exclude: ['transfer-task'], + }) + }).to.throw('Command error') + + expect(errorStub.calledOnce).to.be.true + expect(errorStub.firstCall.args[0]).to.equal('Cannot use both --include and --exclude flags simultaneously') + expect(errorStub.firstCall.args[1]).to.deep.equal({ + code: 'ConflictingFlags', + suggestions: ['Use either --include or --exclude, but not both'], + }) + }) + }) + }) +}) diff --git a/packages/cli/tests/commands/build.spec.ts b/packages/cli/tests/commands/build.spec.ts index 26fd8737..026a4d3e 100644 --- a/packages/cli/tests/commands/build.spec.ts +++ b/packages/cli/tests/commands/build.spec.ts @@ -11,10 +11,12 @@ describe('build', () => { const manifestPath = `${basePath}/manifests/manifest.yaml` const outputDir = `${basePath}/output` const typesDir = `${basePath}/src/types` + const mimicConfigPath = path.join(process.cwd(), 'mimic.yaml') afterEach('cleanup generated files', () => { if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true }) if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true }) + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) }) const buildCommand = (args: string[] = []) => { @@ -46,95 +48,136 @@ describe('build', () => { }) } - context('when the manifest exists', () => { - context('when the manifest is valid', () => { - context('when the task compiles successfully', () => { - context('when the manifest has simple inputs', () => { - const expectedInputs = { - firstStaticNumber: 'uint32', - secondStaticNumber: 'uint32', - isTrue: 'bool', - } - itBuildsAndGeneratesTypes(manifestPath, expectedInputs) - }) + context('when the mimic config exists', () => { + beforeEach('create mimic.yaml', () => { + fs.writeFileSync( + mimicConfigPath, + `tasks:\n - name: test-task\n manifest: ${manifestPath}\n task: ${taskPath}\n output: ${outputDir}\n types: ${typesDir}\n` + ) + }) - context('when the manifest has inputs with descriptions', () => { - const manifestWithDescriptions = `${basePath}/manifests/manifest-with-descriptions.yaml` - const expectedInputs = { - firstStaticNumber: 'uint32', - describedNumber: { - type: 'uint32', - description: 'A number parameter with detailed description', - }, - tokenAddress: { - type: 'address', - description: 'The address of the ERC20 token contract', - }, - simpleFlag: 'bool', - } - itBuildsAndGeneratesTypes(manifestWithDescriptions, expectedInputs) - }) - }) + it('generates types and build artifacts for task from config', async () => { + const expectedInputs = { + firstStaticNumber: 'uint32', + secondStaticNumber: 'uint32', + isTrue: 'bool', + } - context('when the task fails to compile', () => { - const invalidTaskPath = `${basePath}/tasks/invalid-task.ts` - const command = buildCommand(withCommonFlags(manifestPath, invalidTaskPath, outputDir, typesDir)) + const command = buildCommand() + const { stdout, error } = await runCommand(command) - itThrowsACliError(command, 'AssemblyScript compilation failed', 'BuildError', 1) - }) + expect(error).to.be.undefined + expect(stdout).to.include('[test-task]') + expect(stdout).to.include('Build complete!') - context('when the types output directory already exists', () => { - beforeEach('pre-create types directory with a file', () => { - if (!fs.existsSync(typesDir)) fs.mkdirSync(typesDir, { recursive: true }) - fs.writeFileSync(path.join(typesDir, 'randomFile.txt'), 'a') + // build artifacts + expect(fs.existsSync(path.join(outputDir, 'task.wasm'))).to.be.true + expect(fs.existsSync(path.join(outputDir, 'manifest.json'))).to.be.true + + // generated types + expect(fs.existsSync(path.join(typesDir, 'index.ts'))).to.be.true + expect(fs.existsSync(path.join(typesDir, 'ERC20.ts'))).to.be.true + + const manifestJson = JSON.parse(fs.readFileSync(path.join(outputDir, 'manifest.json'), 'utf-8')) + expect(manifestJson.inputs).to.be.deep.equal(expectedInputs) + }) + }) + + context('when the mimic config does not exist', () => { + beforeEach('ensure mimic.yaml does not exist', () => { + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) + }) + + context('when the manifest exists', () => { + context('when the manifest is valid', () => { + context('when the task compiles successfully', () => { + context('when the manifest has simple inputs', () => { + const expectedInputs = { + firstStaticNumber: 'uint32', + secondStaticNumber: 'uint32', + isTrue: 'bool', + } + itBuildsAndGeneratesTypes(manifestPath, expectedInputs) + }) + + context('when the manifest has inputs with descriptions', () => { + const manifestWithDescriptions = `${basePath}/manifests/manifest-with-descriptions.yaml` + const expectedInputs = { + firstStaticNumber: 'uint32', + describedNumber: { + type: 'uint32', + description: 'A number parameter with detailed description', + }, + tokenAddress: { + type: 'address', + description: 'The address of the ERC20 token contract', + }, + simpleFlag: 'bool', + } + itBuildsAndGeneratesTypes(manifestWithDescriptions, expectedInputs) + }) }) - it('generates types without requiring clean', async () => { - const command = buildCommand(withCommonFlags(manifestPath, taskPath, outputDir, typesDir)) - const { error } = await runCommand(command) + context('when the task fails to compile', () => { + const invalidTaskPath = `${basePath}/tasks/invalid-task.ts` + const command = buildCommand(withCommonFlags(manifestPath, invalidTaskPath, outputDir, typesDir)) - expect(error).to.be.undefined - expect(fs.existsSync(path.join(typesDir, 'index.ts'))).to.be.true - expect(fs.existsSync(path.join(typesDir, 'ERC20.ts'))).to.be.true + itThrowsACliError(command, 'AssemblyScript compilation failed', 'CompilationError', 2) }) - }) - }) - context('when the manifest is not valid', () => { - context('when the manifest has invalid fields', () => { - const invalidManifest = `${basePath}/manifests/invalid-manifest.yaml` - const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + context('when the types output directory already exists', () => { + beforeEach('pre-create types directory with a file', () => { + if (!fs.existsSync(typesDir)) fs.mkdirSync(typesDir, { recursive: true }) + fs.writeFileSync(path.join(typesDir, 'randomFile.txt'), 'a') + }) + + it('generates types without requiring clean', async () => { + const command = buildCommand(withCommonFlags(manifestPath, taskPath, outputDir, typesDir)) + const { error } = await runCommand(command) - itThrowsACliError(command, 'More than one entry', 'MoreThanOneEntryError', 1) + expect(error).to.be.undefined + expect(fs.existsSync(path.join(typesDir, 'index.ts'))).to.be.true + expect(fs.existsSync(path.join(typesDir, 'ERC20.ts'))).to.be.true + }) + }) }) - context('when the manifest has repeated fields', () => { - const invalidManifest = `${basePath}/manifests/invalid-manifest-repeated.yaml` - const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + context('when the manifest is not valid', () => { + context('when the manifest has invalid fields', () => { + const invalidManifest = `${basePath}/manifests/invalid-manifest.yaml` + const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) - itThrowsACliError(command, 'Duplicate Entry', 'DuplicateEntryError', 1) - }) + itThrowsACliError(command, 'More than one entry', 'ManifestValidationError', 1) + }) - context('when the manifest is incomplete', () => { - const invalidManifest = `${basePath}/manifests/incomplete-manifest.yaml` - const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + context('when the manifest has repeated fields', () => { + const invalidManifest = `${basePath}/manifests/invalid-manifest-repeated.yaml` + const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) - itThrowsACliError(command, 'Missing/Incorrect Fields', 'FieldsError', 3) - }) + itThrowsACliError(command, 'Duplicate Entry', 'ManifestValidationError', 1) + }) - context('when the manifest is empty', () => { - const invalidManifest = `${basePath}/manifests/empty-manifest.yaml` - const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + context('when the manifest is incomplete', () => { + const invalidManifest = `${basePath}/manifests/incomplete-manifest.yaml` + const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + + itThrowsACliError(command, 'Missing/Incorrect Fields', 'ManifestValidationError', 3) + }) - itThrowsACliError(command, 'Empty Manifest', 'EmptyManifestError', 1) + context('when the manifest is empty', () => { + const invalidManifest = `${basePath}/manifests/empty-manifest.yaml` + const command = buildCommand(withCommonFlags(invalidManifest, taskPath, outputDir, typesDir)) + + itThrowsACliError(command, 'Empty Manifest', 'ManifestValidationError', 1) + }) }) }) - }) - context('when the manifest does not exist', () => { - const inexistentManifest = `${manifestPath}-none` - const command = buildCommand(withCommonFlags(inexistentManifest, taskPath, outputDir, typesDir)) + context('when the manifest does not exist', () => { + const inexistentManifest = `${manifestPath}-none` + const command = buildCommand(withCommonFlags(inexistentManifest, taskPath, outputDir, typesDir)) - itThrowsACliError(command, `Could not find ${inexistentManifest}`, 'FileNotFound', 1) + itThrowsACliError(command, `File not found: ${inexistentManifest}`, 'FileNotFound', 1) + }) }) }) diff --git a/packages/cli/tests/commands/codegen.spec.ts b/packages/cli/tests/commands/codegen.spec.ts index 8d6fefe3..144520e9 100644 --- a/packages/cli/tests/commands/codegen.spec.ts +++ b/packages/cli/tests/commands/codegen.spec.ts @@ -2,6 +2,7 @@ import { runCommand } from '@oclif/test' import { expect } from 'chai' import { spawnSync } from 'child_process' import * as fs from 'fs' +import * as path from 'path' import { itThrowsACliError } from '../helpers' @@ -9,86 +10,113 @@ describe('codegen', () => { const basePath = `${__dirname}/../fixtures` const manifestPath = `${basePath}/manifests/manifest.yaml` const outputDir = `${basePath}/src/types` + const mimicConfigPath = path.join(process.cwd(), 'mimic.yaml') afterEach('delete generated files', () => { if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true }) + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) }) - context('when the manifest exists', () => { - context('when clean flag is not passed', () => { - const command = ['codegen', `--manifest ${manifestPath}`, `--output ${outputDir}`] + context('when the mimic config exists', () => { + beforeEach('create mimic.yaml', () => { + fs.writeFileSync( + mimicConfigPath, + `tasks:\n - name: test-task\n manifest: ${manifestPath}\n task: ${basePath}/tasks/task.ts\n types: ${outputDir}\n` + ) + }) + + it('generates correctly for task from config', async () => { + const command = ['codegen'] + const { stdout, error } = await runCommand(command) + + expect(error).to.be.undefined + expect(stdout).to.include('[test-task]') + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true + }) + }) - context('when there are inputs and abis', () => { - it('generates correctly', async () => { - const { error } = await runCommand(command) - expect(error).to.be.undefined - expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true - expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true + context('when the mimic config does not exist', () => { + beforeEach('ensure mimic.yaml does not exist', () => { + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) + }) + + context('when the manifest exists', () => { + context('when clean flag is not passed', () => { + const command = ['codegen', `--manifest ${manifestPath}`, `--output ${outputDir}`] + + context('when there are inputs and abis', () => { + it('generates correctly', async () => { + const { error } = await runCommand(command) + expect(error).to.be.undefined + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true + }) }) - }) - context('when there are no inputs or abis', () => { - const command = ['codegen', `--manifest ${basePath}/manifests/simple-manifest.yaml`, `--output ${outputDir}`] + context('when there are no inputs or abis', () => { + const command = ['codegen', `--manifest ${basePath}/manifests/simple-manifest.yaml`, `--output ${outputDir}`] - it('generates nothing', async () => { - const { error } = await runCommand(command) - expect(error).to.be.undefined - expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.false - expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.false - expect(fs.existsSync(`${outputDir}`)).to.be.false + it('generates nothing', async () => { + const { error } = await runCommand(command) + expect(error).to.be.undefined + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.false + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.false + expect(fs.existsSync(`${outputDir}`)).to.be.false + }) }) }) }) - }) - context('when the manifest does not exist', () => { - const command = ['codegen', `--manifest ${manifestPath}fake`, `--output ${outputDir}`] - - itThrowsACliError(command, `Could not find ${manifestPath}fake`, 'FileNotFound', 1) - }) + context('when the manifest does not exist', () => { + const command = ['codegen', `--manifest ${manifestPath}fake`, `--output ${outputDir}`] - context('when clean flag is passed', () => { - let userResponse - const command = ['codegen', `--manifest ${manifestPath}`, `--output ${outputDir}`, '--clean'] + itThrowsACliError(command, `File not found: ${manifestPath}fake`, 'FileNotFound', 1) + }) - context('when the user accepts the confirmation', () => { - beforeEach('stub user input', () => { - userResponse = 'Y' - }) + context('when clean flag is passed', () => { + let userResponse + const command = ['codegen', `--manifest ${manifestPath}`, `--output ${outputDir}`, '--clean'] - context('when the directory exists', () => { - beforeEach('create directory', () => { - fs.mkdirSync(outputDir, { recursive: true }) + context('when the user accepts the confirmation', () => { + beforeEach('stub user input', () => { + userResponse = 'Y' }) - it("deletes the folder and it's contents", async () => { - const { status } = runCommandWithUserInput(command, userResponse) - expect(status).to.be.equal(0) - expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true - expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + context('when the directory exists', () => { + beforeEach('create directory', () => { + fs.mkdirSync(outputDir, { recursive: true }) + }) + + it("deletes the folder and it's contents", async () => { + const { status } = runCommandWithUserInput(command, userResponse) + expect(status).to.be.equal(0) + expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + }) }) - }) - context('when the directory does not exist', () => { - it("deletes the folder and it's contents", async () => { - const { status } = runCommandWithUserInput(command, userResponse) - expect(status).to.be.equal(0) - expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true - expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + context('when the directory does not exist', () => { + it("deletes the folder and it's contents", async () => { + const { status } = runCommandWithUserInput(command, userResponse) + expect(status).to.be.equal(0) + expect(fs.existsSync(`${outputDir}/index.ts`)).to.be.true + expect(fs.existsSync(`${outputDir}/ERC20.ts`)).to.be.true + }) }) }) - }) - context('when the user rejects the confirmation', () => { - beforeEach('stub user input', () => { - userResponse = 'N' - }) + context('when the user rejects the confirmation', () => { + beforeEach('stub user input', () => { + userResponse = 'N' + }) - it('stops execution', async () => { - const { stdout, status } = runCommandWithUserInput(command, userResponse) - expect(status).to.be.equal(0) - expect(stdout).to.include('You can remove the --clean flag from your command') - expect(stdout).to.include('Stopping initialization...') + it('stops execution', async () => { + const { stdout, status } = runCommandWithUserInput(command, userResponse) + expect(status).to.be.equal(0) + expect(stdout).to.include('You can remove the --clean flag from your command') + expect(stdout).to.include('Stopping initialization...') + }) }) }) }) diff --git a/packages/cli/tests/commands/compile.spec.ts b/packages/cli/tests/commands/compile.spec.ts index 61bbdb47..fa3838f3 100644 --- a/packages/cli/tests/commands/compile.spec.ts +++ b/packages/cli/tests/commands/compile.spec.ts @@ -10,9 +10,11 @@ describe('compile', () => { const taskPath = `${basePath}/tasks/task.ts` const manifestPath = `${basePath}/manifests/manifest.yaml` const outputDir = `${basePath}/output` + const mimicConfigPath = path.join(process.cwd(), 'mimic.yaml') afterEach('delete generated files', () => { if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true }) + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) }) const buildCommand = (manifestPath: string, taskPath: string, outputDir: string) => { @@ -36,93 +38,129 @@ describe('compile', () => { }) } - context('when the manifest exists', () => { - context('when the manifest is valid', () => { - context('when the task compiles successfully', () => { - context('when the manifest has simple inputs', () => { - const expectedInputs = { - firstStaticNumber: 'uint32', - secondStaticNumber: 'uint32', - isTrue: 'bool', - } - itCreatesFilesCorrectly(manifestPath, expectedInputs) - }) + context('when the mimic config exists', () => { + beforeEach('create mimic.yaml', () => { + fs.writeFileSync( + mimicConfigPath, + `tasks:\n - name: test-task\n manifest: ${manifestPath}\n task: ${taskPath}\n output: ${outputDir}\n` + ) + }) - context('when the manifest has inputs with descriptions', () => { - const manifestPath = `${basePath}/manifests/manifest-with-descriptions.yaml` - const expectedInputs = { - firstStaticNumber: 'uint32', - describedNumber: { - type: 'uint32', - description: 'A number parameter with detailed description', - }, - tokenAddress: { - type: 'address', - description: 'The address of the ERC20 token contract', - }, - simpleFlag: 'bool', - } - itCreatesFilesCorrectly(manifestPath, expectedInputs) - }) - }) + it('creates the files correctly for task from config', async () => { + const expectedInputs = { + firstStaticNumber: 'uint32', + secondStaticNumber: 'uint32', + isTrue: 'bool', + } - context('when the task fails to compile', () => { - const taskPath = `${basePath}/tasks/invalid-task.ts` - const command = buildCommand(manifestPath, taskPath, outputDir) + const command = ['compile'] + const { stdout, error } = await runCommand(command) - itThrowsACliError(command, 'AssemblyScript compilation failed', 'BuildError', 1) - }) + expect(error).to.be.undefined + expect(stdout).to.include('[test-task]') + expect(stdout).to.include('Build complete!') + + expect(fs.existsSync(`${outputDir}/task.wasm`)).to.be.true + expect(fs.existsSync(`${outputDir}/manifest.json`)).to.be.true + + const manifest = JSON.parse(fs.readFileSync(`${outputDir}/manifest.json`, 'utf-8')) + expect(manifest.inputs).to.be.deep.equal(expectedInputs) }) + }) - context('when the manifest is not valid', () => { - context('when the manfiest has invalid fields', () => { - const manifestPath = `${basePath}/manifests/invalid-manifest.yaml` - const command = buildCommand(manifestPath, taskPath, outputDir) + context('when the mimic config does not exist', () => { + beforeEach('ensure mimic.yaml does not exist', () => { + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) + }) - itThrowsACliError(command, 'More than one entry', 'MoreThanOneEntryError', 1) - }) + context('when the manifest exists', () => { + context('when the manifest is valid', () => { + context('when the task compiles successfully', () => { + context('when the manifest has simple inputs', () => { + const expectedInputs = { + firstStaticNumber: 'uint32', + secondStaticNumber: 'uint32', + isTrue: 'bool', + } + itCreatesFilesCorrectly(manifestPath, expectedInputs) + }) + + context('when the manifest has inputs with descriptions', () => { + const manifestPath = `${basePath}/manifests/manifest-with-descriptions.yaml` + const expectedInputs = { + firstStaticNumber: 'uint32', + describedNumber: { + type: 'uint32', + description: 'A number parameter with detailed description', + }, + tokenAddress: { + type: 'address', + description: 'The address of the ERC20 token contract', + }, + simpleFlag: 'bool', + } + itCreatesFilesCorrectly(manifestPath, expectedInputs) + }) + }) - context('when the manfiest has repeated fields', () => { - const manifestPath = `${basePath}/manifests/invalid-manifest-repeated.yaml` - const command = buildCommand(manifestPath, taskPath, outputDir) + context('when the task fails to compile', () => { + const taskPath = `${basePath}/tasks/invalid-task.ts` + const command = buildCommand(manifestPath, taskPath, outputDir) - itThrowsACliError(command, 'Duplicate Entry', 'DuplicateEntryError', 1) + itThrowsACliError(command, 'AssemblyScript compilation failed', 'CompilationError', 2) + }) }) - context('when the manifest is incomplete', () => { - const manifestPath = `${basePath}/manifests/incomplete-manifest.yaml` - const command = buildCommand(manifestPath, taskPath, outputDir) + context('when the manifest is not valid', () => { + context('when the manfiest has invalid fields', () => { + const manifestPath = `${basePath}/manifests/invalid-manifest.yaml` + const command = buildCommand(manifestPath, taskPath, outputDir) - itThrowsACliError(command, 'Missing/Incorrect Fields', 'FieldsError', 3) - }) + itThrowsACliError(command, 'More than one entry', 'ManifestValidationError', 1) + }) + + context('when the manfiest has repeated fields', () => { + const manifestPath = `${basePath}/manifests/invalid-manifest-repeated.yaml` + const command = buildCommand(manifestPath, taskPath, outputDir) - context('when the manifest is empty', () => { - const manifestPath = `${basePath}/manifests/empty-manifest.yaml` - const command = buildCommand(manifestPath, taskPath, outputDir) + itThrowsACliError(command, 'Duplicate Entry', 'ManifestValidationError', 1) + }) + + context('when the manifest is incomplete', () => { + const manifestPath = `${basePath}/manifests/incomplete-manifest.yaml` + const command = buildCommand(manifestPath, taskPath, outputDir) + + itThrowsACliError(command, 'Missing/Incorrect Fields', 'ManifestValidationError', 3) + }) - itThrowsACliError(command, 'Empty Manifest', 'EmptyManifestError', 1) + context('when the manifest is empty', () => { + const manifestPath = `${basePath}/manifests/empty-manifest.yaml` + const command = buildCommand(manifestPath, taskPath, outputDir) + + itThrowsACliError(command, 'Empty Manifest', 'ManifestValidationError', 1) + }) }) }) - }) - - context('when the manifest does not exist', () => { - const inexistentManifestPath = `${manifestPath}-none` - const command = buildCommand(inexistentManifestPath, taskPath, outputDir) - itThrowsACliError(command, `Could not find ${inexistentManifestPath}`, 'FileNotFound', 1) - }) + context('when the manifest does not exist', () => { + const inexistentManifestPath = `${manifestPath}-none` + const command = buildCommand(inexistentManifestPath, taskPath, outputDir) - context('when the output directory already exists', () => { - beforeEach('create outputDirectory with files', () => { - if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) - fs.writeFileSync(path.join(outputDir, 'randomFile.txt'), JSON.stringify({ a: 2 }, null, 2)) + itThrowsACliError(command, `File not found: ${inexistentManifestPath}`, 'FileNotFound', 1) }) - const expectedInputs = { - firstStaticNumber: 'uint32', - secondStaticNumber: 'uint32', - isTrue: 'bool', - } - itCreatesFilesCorrectly(manifestPath, expectedInputs) + context('when the output directory already exists', () => { + beforeEach('create outputDirectory with files', () => { + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) + fs.writeFileSync(path.join(outputDir, 'randomFile.txt'), JSON.stringify({ a: 2 }, null, 2)) + }) + + const expectedInputs = { + firstStaticNumber: 'uint32', + secondStaticNumber: 'uint32', + isTrue: 'bool', + } + itCreatesFilesCorrectly(manifestPath, expectedInputs) + }) }) }) diff --git a/packages/cli/tests/commands/deploy.spec.ts b/packages/cli/tests/commands/deploy.spec.ts index 3bda2491..01eccd35 100644 --- a/packages/cli/tests/commands/deploy.spec.ts +++ b/packages/cli/tests/commands/deploy.spec.ts @@ -11,189 +11,256 @@ import { backupCredentials, itThrowsACliError, restoreCredentials } from '../hel describe('deploy', () => { const inputDir = join(__dirname, 'deploy-directory') let outputDir = inputDir + const mimicConfigPath = join(process.cwd(), 'mimic.yaml') + const basePath = `${__dirname}/../fixtures` + const manifestPath = `${basePath}/manifests/manifest.yaml` + const taskPath = `${basePath}/tasks/task.ts` - context('when the default profile exists', () => { + afterEach('cleanup mimic config', () => { + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) + }) + + context('when the mimic config exists', () => { let credentialsManager: CredentialsManager let backupDir: string | null = null + let axiosMock: MockAdapter + const defaultKey = '123' + const CID = '456' + + beforeEach('create mimic.yaml', () => { + fs.writeFileSync( + mimicConfigPath, + `tasks:\n - name: test-task\n manifest: ${manifestPath}\n task: ${taskPath}\n output: ${inputDir}\n` + ) + }) beforeEach('backup existing credentials', () => { credentialsManager = CredentialsManager.getDefault() backupDir = backupCredentials(credentialsManager) }) - afterEach('restore credentials and stubs', () => { + beforeEach('create default profile', () => { + credentialsManager.saveProfile('default', defaultKey) + }) + + beforeEach('create input directory with files', () => { + fs.mkdirSync(inputDir, { recursive: true }) + fs.writeFileSync(`${inputDir}/manifest.json`, '') + fs.writeFileSync(`${inputDir}/task.wasm`, '') + }) + + beforeEach('create axios mock', () => { + axiosMock = new MockAdapter(axios) + axiosMock.onPost(/.*\/tasks/gm).reply(200, { CID }) + }) + + afterEach('cleanup', () => { + axiosMock.restore() restoreCredentials(credentialsManager, backupDir) backupDir = null + if (fs.existsSync(inputDir)) fs.rmSync(inputDir, { recursive: true }) }) - const defaultKey = '123' - beforeEach('create default profile', () => { - credentialsManager.saveProfile('default', defaultKey) + it('deploys successfully using task from config', async () => { + const deployCommand = ['deploy', '--skip-compile'] + await runCommand(deployCommand) + + const requests = axiosMock.history.post + expect(requests).to.have.lengthOf(1) + expect(requests[0].headers?.['x-api-key']).to.equal(defaultKey) + }) + + it('saves the CID on a file using task output from config', async () => { + const deployCommand = ['deploy', '--skip-compile'] + await runCommand(deployCommand) + const json = JSON.parse(fs.readFileSync(`${inputDir}/CID.json`, 'utf-8')) + expect(json.CID).to.be.equal(CID) + }) + }) + + context('when the mimic config does not exist', () => { + beforeEach('ensure mimic.yaml does not exist', () => { + if (fs.existsSync(mimicConfigPath)) fs.unlinkSync(mimicConfigPath) }) - const command = ['deploy', `-i ${inputDir}`, `-o ${outputDir}`, '--skip-compile'] + context('when the default profile exists', () => { + let credentialsManager: CredentialsManager + let backupDir: string | null = null - context('when input directory exists', () => { - beforeEach('create input directory', () => { - fs.mkdirSync(inputDir, { recursive: true }) + beforeEach('backup existing credentials', () => { + credentialsManager = CredentialsManager.getDefault() + backupDir = backupCredentials(credentialsManager) }) - afterEach('delete generated files', () => { - if (fs.existsSync(inputDir)) fs.rmSync(inputDir, { recursive: true }) + afterEach('restore credentials and stubs', () => { + restoreCredentials(credentialsManager, backupDir) + backupDir = null }) - const createFile = (name: string) => { - fs.writeFileSync(`${inputDir}/${name}`, '') - } + const defaultKey = '123' + beforeEach('create default profile', () => { + credentialsManager.saveProfile('default', defaultKey) + }) - context('when the directory contains necessary files', () => { - let axiosMock: MockAdapter + const command = ['deploy', `-i ${inputDir}`, `-o ${outputDir}`, '--skip-compile'] - beforeEach('create files', () => { - ;['manifest.json', 'task.wasm'].map(createFile) + context('when input directory exists', () => { + beforeEach('create input directory', () => { + fs.mkdirSync(inputDir, { recursive: true }) }) - beforeEach('create axios mock', () => { - axiosMock = new MockAdapter(axios) + afterEach('delete generated files', () => { + if (fs.existsSync(inputDir)) fs.rmSync(inputDir, { recursive: true }) }) - afterEach('restore axios mock', () => { - axiosMock.restore() - }) + const createFile = (name: string) => { + fs.writeFileSync(`${inputDir}/${name}`, '') + } - context('when uploading to registry is successful', () => { - const CID = '123' + context('when the directory contains necessary files', () => { + let axiosMock: MockAdapter - beforeEach('mock registry response', () => { - axiosMock.onPost(/.*\/tasks/gm).reply(200, { CID }) + beforeEach('create files', () => { + ;['manifest.json', 'task.wasm'].map(createFile) }) - context('when output directory exists', () => { - context('when the api key is provided', () => { - const apiKey = '456' - const apiKeyCommand = [...command, '--api-key', apiKey] + beforeEach('create axios mock', () => { + axiosMock = new MockAdapter(axios) + }) - it('deploys successfully with the api key', async () => { - await runCommand(apiKeyCommand) + afterEach('restore axios mock', () => { + axiosMock.restore() + }) - const requests = axiosMock.history.post - expect(requests).to.have.lengthOf(1) - expect(requests[0].headers?.['x-api-key']).to.equal(apiKey) - }) + context('when uploading to registry is successful', () => { + const CID = '123' + + beforeEach('mock registry response', () => { + axiosMock.onPost(/.*\/tasks/gm).reply(200, { CID }) }) - context('when a profile is provided', () => { - const apiKey = '789' - const profile = 'custom-profile' - const profileCommand = [...command, '--profile', profile] + context('when output directory exists', () => { + context('when the api key is provided', () => { + const apiKey = '456' + const apiKeyCommand = [...command, '--api-key', apiKey] + + it('deploys successfully with the api key', async () => { + await runCommand(apiKeyCommand) - beforeEach('create profile', () => { - credentialsManager.saveProfile(profile, apiKey) + const requests = axiosMock.history.post + expect(requests).to.have.lengthOf(1) + expect(requests[0].headers?.['x-api-key']).to.equal(apiKey) + }) }) - it('deploys successfully with the custom profile', async () => { - await runCommand(profileCommand) + context('when a profile is provided', () => { + const apiKey = '789' + const profile = 'custom-profile' + const profileCommand = [...command, '--profile', profile] - const requests = axiosMock.history.post - expect(requests).to.have.lengthOf(1) - expect(requests[0].headers?.['x-api-key']).to.equal(apiKey) + beforeEach('create profile', () => { + credentialsManager.saveProfile(profile, apiKey) + }) + + it('deploys successfully with the custom profile', async () => { + await runCommand(profileCommand) + + const requests = axiosMock.history.post + expect(requests).to.have.lengthOf(1) + expect(requests[0].headers?.['x-api-key']).to.equal(apiKey) + }) }) - }) - it('saves the CID on a file', async () => { - await runCommand(command) - const json = JSON.parse(fs.readFileSync(`${outputDir}/CID.json`, 'utf-8')) - expect(json.CID).to.be.equal(CID) + it('saves the CID on a file', async () => { + await runCommand(command) + const json = JSON.parse(fs.readFileSync(`${outputDir}/CID.json`, 'utf-8')) + expect(json.CID).to.be.equal(CID) + }) }) - }) - context('when output directory does not exist', () => { - const noOutDir = `${outputDir}/does-not-exist` - const noOutDirCommand = ['deploy', `-i ${inputDir}`, `-o ${noOutDir}`, '--skip-compile'] + context('when output directory does not exist', () => { + const noOutDir = `${outputDir}/does-not-exist` + const noOutDirCommand = ['deploy', `-i ${inputDir}`, `-o ${noOutDir}`, '--skip-compile'] - it('saves the CID on a file', async () => { - await runCommand(noOutDirCommand) - const json = JSON.parse(fs.readFileSync(`${noOutDir}/CID.json`, 'utf-8')) - expect(json.CID).to.be.equal(CID) + it('saves the CID on a file', async () => { + await runCommand(noOutDirCommand) + const json = JSON.parse(fs.readFileSync(`${noOutDir}/CID.json`, 'utf-8')) + expect(json.CID).to.be.equal(CID) + }) }) }) - }) - context('when uploading to registry is not successful', () => { - context('when there is a bad request failure', () => { - context('when the error message is present', () => { - const message = 'Task with same name and version already exists' + context('when uploading to registry is not successful', () => { + context('when there is a bad request failure', () => { + context('when the error message is present', () => { + const message = 'Task with same name and version already exists' - beforeEach('mock response', () => { - axiosMock.onPost(/.*\/tasks/gm).reply(400, { content: { message } }) + beforeEach('mock response', () => { + axiosMock.onPost(/.*\/tasks/gm).reply(400, { content: { message } }) + }) + + itThrowsACliError(command, message, 'Deploy400Error', 1) }) - itThrowsACliError(command, message, 'Bad Request', 1) + context('when the error message is not present', () => { + beforeEach('mock response', () => { + axiosMock.onPost(/.*\/tasks/gm).reply(400, { content: { errors: ['some error'] } }) + }) + + itThrowsACliError(command, 'Bad request', 'Deploy400Error', 1) + }) }) - context('when the error message is not present', () => { + context('when there is an authorization failure', () => { beforeEach('mock response', () => { - axiosMock.onPost(/.*\/tasks/gm).reply(400, { content: { errors: ['some error'] } }) + axiosMock.onPost(/.*/).reply(401) }) - itThrowsACliError(command, 'Failed to upload to registry', 'Bad Request', 1) + itThrowsACliError(command, 'Unauthorized', 'Deploy401Error', 1) }) - }) - context('when there is an authorization failure', () => { - beforeEach('mock response', () => { - axiosMock.onPost(/.*/).reply(401) + context('when there is an authentication failure', () => { + beforeEach('mock response', () => { + axiosMock.onPost(/.*/).reply(403) + }) + + itThrowsACliError(command, 'Invalid API key', 'Deploy403Error', 1) }) - itThrowsACliError(command, 'Failed to upload to registry', 'Unauthorized', 1) - }) + context('when there is a generic error', () => { + beforeEach('mock response', () => { + axiosMock.onPost(/.*/).reply(501) + }) - context('when there is an authentication failure', () => { - beforeEach('mock response', () => { - axiosMock.onPost(/.*/).reply(403) + itThrowsACliError(command, 'Upload failed: Request failed with status code 501', 'Deploy501Error', 1) }) + }) + }) - itThrowsACliError(command, 'Failed to upload to registry', 'Invalid api key', 1) + context('when the directory does not contain the necessary files', () => { + context('when the directory contains no files', () => { + itThrowsACliError(command, `File not found: ${inputDir}/manifest.json`, 'FileNotFound', 1) }) - context('when there is a generic error', () => { - beforeEach('mock response', () => { - axiosMock.onPost(/.*/).reply(501) + context('when the directory contains only one file', () => { + beforeEach('create file', () => { + createFile('manifest.json') }) - itThrowsACliError( - command, - 'Failed to upload to registry - Request failed with status code 501', - '501 Error', - 1 - ) + itThrowsACliError(command, `File not found: ${inputDir}/task.wasm`, 'FileNotFound', 1) }) }) }) - context('when the directory does not contain the necessary files', () => { - context('when the directory contains no files', () => { - itThrowsACliError(command, `Could not find ${inputDir}/manifest.json`, 'File Not Found', 1) - }) - - context('when the directory contains only one file', () => { - beforeEach('create file', () => { - createFile('manifest.json') - }) - - itThrowsACliError(command, `Could not find ${inputDir}/task.wasm`, 'File Not Found', 1) - }) + context('when input directory does not exist', () => { + itThrowsACliError(command, `Directory not found: ${inputDir}`, 'DirectoryNotFound', 1) }) }) - context('when input directory does not exist', () => { - itThrowsACliError(command, `Directory ${inputDir} does not exist`, 'Directory Not Found', 1) - }) - }) - - context("when the default profile doesn't exist", () => { - const command = ['deploy'] + context("when the default profile doesn't exist", () => { + const command = ['deploy'] - itThrowsACliError(command, 'Authentication required', 'AuthenticationRequired', 3) + itThrowsACliError(command, 'Authentication required', 'AuthenticationRequired', 3) + }) }) }) diff --git a/yarn.lock b/yarn.lock index a3c31e0c..77ad396a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1072,6 +1072,18 @@ "@types/node" "*" "@types/send" "*" +"@types/sinon@^21.0.0": + version "21.0.0" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-21.0.0.tgz#3a598a29b3aec0512a21e57ae0fd4c09aa013ca9" + integrity sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz#49f731d9453f52d64dd79f5a5626c1cf1b81bea4" + integrity sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w== + "@types/uuid@^8.3.4": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"