diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index a9b617ce..96518fa8 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -4,6 +4,7 @@ import { FlagsType } from '../types' import Codegen from './codegen' import Compile from './compile' +import Functions from './functions' export type BuildFlags = FlagsType @@ -15,13 +16,14 @@ export default class Build extends Command { ] static override flags = { + ...Functions.flags, ...Codegen.flags, ...Compile.flags, } public async run(): Promise { const { flags } = await this.parse(Build) - await Build.build(this, flags) + await Functions.runFunctions(this, flags, Build.build, 'build') } public static async build(cmd: Command, flags: BuildFlags): Promise { diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts index 7e3594cc..7255efc9 100644 --- a/packages/cli/src/commands/codegen.ts +++ b/packages/cli/src/commands/codegen.ts @@ -7,6 +7,8 @@ import { AbisInterfaceGenerator, InputsInterfaceGenerator, ManifestHandler } fro import log from '../log' import { FlagsType, Manifest } from '../types' +import Functions, { DefaultFunctionConfig } from './functions' + export type CodegenFlags = FlagsType export default class Codegen extends Command { @@ -17,11 +19,16 @@ export default class Codegen extends Command { ] static override flags = { - manifest: Flags.string({ char: 'm', description: 'Specify a custom manifest file path', default: 'manifest.yaml' }), + ...Functions.flags, + manifest: Flags.string({ + char: 'm', + description: 'Specify a custom manifest file path', + default: DefaultFunctionConfig.manifest, + }), 'types-directory': Flags.string({ char: 't', description: 'Output directory for generated types', - default: './src/types', + default: DefaultFunctionConfig['types-directory'], }), clean: Flags.boolean({ char: 'c', @@ -32,7 +39,7 @@ export default class Codegen extends Command { public async run(): Promise { const { flags } = await this.parse(Codegen) - await Codegen.codegen(this, flags) + await Functions.runFunctions(this, flags, Codegen.codegen, 'code generation') } public static async codegen(cmd: Command, flags: CodegenFlags): Promise { @@ -59,8 +66,8 @@ export default class Codegen extends Command { } if (!fs.existsSync(typesDir)) fs.mkdirSync(typesDir, { recursive: true }) - this.generateAbisCode(manifest, typesDir, manifestDir) - this.generateInputsCode(manifest, typesDir) + Codegen.generateAbisCode(manifest, typesDir, manifestDir) + Codegen.generateInputsCode(manifest, typesDir) log.stopAction() } diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ef6130ca..ee2f08dc 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -7,6 +7,8 @@ import { execBinCommand } from '../lib/packageManager' import log from '../log' import { FlagsType } from '../types' +import Functions, { DefaultFunctionConfig } from './functions' + export type CompileFlags = FlagsType export default class Compile extends Command { @@ -17,14 +19,19 @@ export default class Compile extends Command { ] static override flags = { - function: Flags.string({ char: 'f', description: 'Function to compile', default: 'src/function.ts' }), - manifest: Flags.string({ char: 'm', description: 'Manifest to validate', default: 'manifest.yaml' }), - 'build-directory': Flags.string({ char: 'b', description: 'Output directory for compilation', default: './build' }), + ...Functions.flags, + function: Flags.string({ char: 'f', description: 'Function to compile', default: DefaultFunctionConfig.function }), + manifest: Flags.string({ char: 'm', description: 'Manifest to validate', default: DefaultFunctionConfig.manifest }), + 'build-directory': Flags.string({ + char: 'b', + description: 'Output directory for compilation', + default: DefaultFunctionConfig['build-directory'], + }), } public async run(): Promise { const { flags } = await this.parse(Compile) - await Compile.compile(this, flags) + await Functions.runFunctions(this, flags, Compile.compile, 'compilation') } public static async compile( diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index d25ca9b3..d62f006d 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -11,6 +11,7 @@ import { FlagsType } from '../types' import Authenticate from './authenticate' import Build from './build' +import Functions from './functions' const MIMIC_REGISTRY_DEFAULT = 'https://api-protocol.mimic.fi' @@ -27,6 +28,7 @@ export default class Deploy extends Command { ] static override flags = { + ...Functions.flags, ...Authenticate.flags, ...Build.flags, 'build-directory': Flags.string({ @@ -40,7 +42,7 @@ export default class Deploy extends Command { public async run(): Promise { const { flags } = await this.parse(Deploy) - await Deploy.deploy(this, flags) + await Functions.runFunctions(this, flags, Deploy.deploy, 'deployment') } public static async deploy(cmd: Command, flags: DeployFlags): Promise { @@ -69,7 +71,7 @@ export default class Deploy extends Command { } log.startAction('Uploading to Mimic Registry') - const CID = await this.uploadToRegistry(cmd, neededFiles, credentials, registryUrl) + const CID = await Deploy.uploadToRegistry(cmd, neededFiles, credentials, registryUrl) console.log(`IPFS CID: ${log.highlightText(CID)}`) log.stopAction() diff --git a/packages/cli/src/commands/functions.ts b/packages/cli/src/commands/functions.ts new file mode 100644 index 00000000..22c6666d --- /dev/null +++ b/packages/cli/src/commands/functions.ts @@ -0,0 +1,124 @@ +import { Command, Flags } from '@oclif/core' +import * as fs from 'fs' +import * as yaml from 'js-yaml' +import { z } from 'zod' + +import log from '../log' +import { FlagsType } from '../types' + +export type FunctionsFlags = FlagsType + +export const FunctionConfigSchema = z.object({ + name: z.string().min(1, 'Function name is required'), + manifest: z.string().min(1, 'Manifest path is required'), + function: z.string().min(1, 'Function path is required'), + 'build-directory': z.string().min(1, 'Build directory is required'), + 'types-directory': z.string().min(1, 'Types directory is required'), +}) + +export const MimicConfigSchema = z.object({ + tasks: z.array(FunctionConfigSchema).min(1, 'At least one task is required'), +}) + +export const DefaultFunctionConfig = { + name: '', + manifest: 'manifest.yaml', + function: 'src/function.ts', + 'build-directory': './build', + 'types-directory': './src/types', +} as const + +export type FunctionConfig = { + name: string + manifest: string + function: string + 'build-directory': string + 'types-directory': string +} + +export default class Functions extends Command { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + run(): Promise { + throw new Error('Method not implemented.') + } + + static override description = 'Filters tasks based on a mimic.yaml configuration file' + + static MIMIC_CONFIG_FILE = 'mimic.yaml' + + static flags = { + 'config-file': Flags.string({ + description: `Path to the ${Functions.MIMIC_CONFIG_FILE} file, this overrides other parameters like build-directory and function`, + default: Functions.MIMIC_CONFIG_FILE, + }), + 'no-config': Flags.boolean({ + description: `Do not read ${Functions.MIMIC_CONFIG_FILE}; use defaults and explicit flags instead`, + default: false, + }), + include: Flags.string({ + description: `When ${Functions.MIMIC_CONFIG_FILE} exists, only run tasks with these names (space-separated)`, + multiple: true, + exclusive: ['exclude'], + char: 'i', + }), + exclude: Flags.string({ + description: `When ${Functions.MIMIC_CONFIG_FILE} exists, exclude tasks with these names (space-separated)`, + multiple: true, + exclusive: ['include'], + char: 'e', + }), + } + + public static async runFunctions>( + cmd: Command, + flags: T, + cmdLogic: (cmd: Command, flags: T) => Promise, + cmdActions: string + ): Promise { + const functions = Functions.filterFunctions(cmd, flags) + for (const func of functions) { + log.startAction(`Starting ${cmdActions} for function ${func.name}`) + await cmdLogic(cmd, { ...flags, ...func } as T) + } + } + + public static filterFunctions(cmd: Command, flags: FunctionsFlags & Partial): FunctionConfig[] { + if (flags['no-config']) { + return [{ ...DefaultFunctionConfig, ...flags }] + } + + if (!fs.existsSync(flags['config-file'])) { + if (flags['config-file'] !== Functions.MIMIC_CONFIG_FILE) { + cmd.error(`Could not find ${flags['config-file']}`, { code: 'ConfigNotFound' }) + } + + // If doesn't exists return the default with the flags the user added + return [{ ...DefaultFunctionConfig, ...flags }] + } + + const fileContents = fs.readFileSync(flags['config-file'], 'utf8') + const rawConfig = yaml.load(fileContents) + + try { + const config = MimicConfigSchema.parse(rawConfig) + + let tasks = config.tasks || [] + + if (flags.include && flags.include.length > 0) { + tasks = tasks.filter((task) => flags.include!.includes(task.name)) + } + + if (flags.exclude && flags.exclude.length > 0) { + tasks = tasks.filter((task) => !flags.exclude!.includes(task.name)) + } + + return tasks + } catch (error) { + if (error instanceof z.ZodError) { + const errors = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('\n') + cmd.error(`Invalid ${Functions.MIMIC_CONFIG_FILE} configuration:\n${errors}`, { code: 'InvalidConfig' }) + } + throw error + } + } +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index c6b0f6ef..ac25fe29 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -79,8 +79,8 @@ export default class Init extends Command { cmd.error(`Failed to clone template repository. Details: ${error}`) } - this.installDependencies(absDir) - this.runCodegen(absDir) + Init.installDependencies(absDir) + Init.runCodegen(absDir) log.stopAction() console.log('New project initialized!') } diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 3ddd9df2..47e372a8 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -74,7 +74,7 @@ export default class Login extends Command { } } - this.saveAndConfirm(cmd, profileName || CredentialsManager.getDefaultProfileName(), apiKey, flags['force-login']) + Login.saveAndConfirm(cmd, profileName || CredentialsManager.getDefaultProfileName(), apiKey, flags['force-login']) } private static async saveAndConfirm( diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index e742f8e8..44a7bcaa 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -5,6 +5,7 @@ import { execBinCommand } from '../lib/packageManager' import { FlagsType } from '../types' import Build from './build' +import Functions from './functions' export type TestFlags = FlagsType @@ -14,6 +15,7 @@ export default class Test extends Command { static override examples = ['<%= config.bin %> <%= command.id %> --directory ./tests'] static override flags = { + ...Functions.flags, ...Build.flags, directory: Flags.string({ char: 'd', description: 'Path to the testing directory', default: './tests' }), 'skip-build': Flags.boolean({ description: 'Skip build before testing', default: false }), @@ -29,7 +31,7 @@ export default class Test extends Command { const baseDir = path.resolve('./') const testPath = path.join(baseDir, directory) - if (!skipBuild) await Build.build(cmd, flags) + if (!skipBuild) await Functions.runFunctions(cmd, flags, Build.build, 'building') const result = execBinCommand('tsx', ['./node_modules/mocha/bin/mocha.js', `${testPath}/**/*.spec.ts`], baseDir) cmd.exit(result.status ?? 1) diff --git a/packages/cli/tests/commands/functions.spec.ts b/packages/cli/tests/commands/functions.spec.ts new file mode 100644 index 00000000..ab0f31d5 --- /dev/null +++ b/packages/cli/tests/commands/functions.spec.ts @@ -0,0 +1,399 @@ +import { Command } from '@oclif/core' +import { expect } from 'chai' +import * as fs from 'fs' +import * as sinon from 'sinon' + +import Functions, { FunctionConfigSchema, MimicConfigSchema } from '../../src/commands/functions' + +describe('Functions', () => { + const basePath = `${__dirname}/../fixtures` + const configFilePath = `${basePath}/mimic.yaml` + const validConfig = { + tasks: [ + { + name: 'task1', + manifest: 'manifest.yaml', + function: 'src/function.ts', + 'build-directory': './build', + 'types-directory': './src/types', + }, + { + name: 'task2', + manifest: 'src/task2/manifest.yaml', + function: 'src/task2/function.ts', + 'build-directory': './build/task2', + 'types-directory': './src/task2/types', + }, + ], + } + + describe('FunctionConfigSchema', () => { + context('when all required fields are present', () => { + it('validates successfully', () => { + const config = validConfig.tasks[0] + expect(() => FunctionConfigSchema.parse(config)).to.not.throw() + }) + }) + + context('when required fields are missing', () => { + context('when name is missing', () => { + it('throws error', () => { + const config = { ...validConfig.tasks[0], name: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + + context('when manifest is missing', () => { + it('throws error', () => { + const config = { ...validConfig.tasks[0], manifest: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when function is missing', () => { + it('throws error', () => { + const config = { ...validConfig.tasks[0], function: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when build-directory is missing', () => { + it('throws error', () => { + const config = { ...validConfig.tasks[0], 'build-directory': '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when types-directory is missing', () => { + it('throws error', () => { + const config = { ...validConfig.tasks[0], 'types-directory': '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + }) + }) + + describe('MimicConfigSchema', () => { + context('when config has valid tasks array', () => { + it('validates successfully with single task', () => { + const config = { tasks: [validConfig.tasks[0]] } + expect(() => MimicConfigSchema.parse(config)).to.not.throw() + }) + + it('validates successfully with multiple tasks', () => { + expect(() => MimicConfigSchema.parse(validConfig)).to.not.throw() + }) + }) + + context('when tasks array is empty', () => { + it('throws validation error', () => { + const config = { tasks: [] } + expect(() => MimicConfigSchema.parse(config)).to.throw() + }) + }) + + context('when tasks array is missing', () => { + it('throws validation error', () => { + const config = {} + expect(() => MimicConfigSchema.parse(config)).to.throw() + }) + }) + + context('when a task in the array is invalid', () => { + it('throws validation error for invalid task', () => { + const config = { + tasks: [ + validConfig.tasks[0], + { ...validConfig.tasks[1], name: '' }, // Invalid: empty name + ], + } + expect(() => MimicConfigSchema.parse(config)).to.throw() + }) + }) + }) + + describe('filterFunctions', () => { + let cmdStub: sinon.SinonStubbedInstance + + beforeEach(() => { + cmdStub = sinon.createStubInstance(Command) + }) + + context('when config file does not exist', () => { + const flags = { + 'config-file': `${basePath}/nonexistent-mimic.yaml`, + include: [], + exclude: [], + } + + context('when no flags are provided', () => { + it('returns default', () => { + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('') + expect(result[0].manifest).to.equal('manifest.yaml') + expect(result[0].function).to.equal('src/function.ts') + expect(result[0]['build-directory']).to.equal('./build') + expect(result[0]['types-directory']).to.equal('./src/types') + }) + }) + + context('when flags are provided', () => { + it('returns default config with overridden manifest', () => { + const result = Functions.filterFunctions(cmdStub, { + 'config-file': `${basePath}/nonexistent-mimic.yaml`, + manifest: 'custom-manifest.yaml', + include: [], + exclude: [], + }) + + expect(result[0].manifest).to.equal('custom-manifest.yaml') + }) + + it('returns default config with overridden types-directory', () => { + const result = Functions.filterFunctions(cmdStub, { + 'config-file': `${basePath}/nonexistent-mimic.yaml`, + 'types-directory': './custom/types', + include: [], + exclude: [], + }) + + expect(result[0]['types-directory']).to.equal('./custom/types') + }) + + it('returns default config with overridden build-directory', () => { + const result = Functions.filterFunctions(cmdStub, { + 'config-file': `${basePath}/nonexistent-mimic.yaml`, + 'build-directory': './custom/build', + include: [], + exclude: [], + }) + + expect(result[0]['build-directory']).to.equal('./custom/build') + }) + + it('returns default config with overridden function', () => { + const result = Functions.filterFunctions(cmdStub, { + 'config-file': `${basePath}/nonexistent-mimic.yaml`, + function: 'src/custom/function.ts', + include: [], + exclude: [], + }) + + expect(result[0].function).to.equal('src/custom/function.ts') + }) + }) + + context('when a non-default config path is provided', () => { + it('throws an error', () => { + const customFlags = { + 'config-file': `${basePath}/custom-mimic.yaml`, + include: [], + exclude: [], + } + + cmdStub.error.throws(new Error('ConfigNotFound')) + expect(() => Functions.filterFunctions(cmdStub, customFlags)).to.throw('ConfigNotFound') + expect(cmdStub.error.calledOnce).to.be.true + }) + }) + }) + + context('when config file exists', () => { + beforeEach(() => { + fs.mkdirSync(basePath, { recursive: true }) + fs.writeFileSync( + configFilePath, + ` +tasks: + - name: task1 + manifest: manifest.yaml + function: src/function.ts + build-directory: ./build + types-directory: ./src/types + - name: task2 + manifest: src/task2/manifest.yaml + function: src/task2/function.ts + build-directory: ./build/task2 + types-directory: ./src/task2/types + ` + ) + }) + + afterEach(() => { + if (fs.existsSync(configFilePath)) fs.unlinkSync(configFilePath) + }) + + context('when config is valid', () => { + context('when --no-config is provided', () => { + it('returns default config without reading the file', () => { + const flags = { + 'config-file': configFilePath, + 'no-config': true, + include: [], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('') + expect(result[0].manifest).to.equal('manifest.yaml') + expect(result[0].function).to.equal('src/function.ts') + expect(result[0]['build-directory']).to.equal('./build') + expect(result[0]['types-directory']).to.equal('./src/types') + }) + }) + + it('returns all tasks', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(2) + expect(result[0].name).to.equal('task1') + expect(result[1].name).to.equal('task2') + }) + + context('when include filter is provided', () => { + it('returns only included tasks', () => { + const flags = { + 'config-file': configFilePath, + include: ['task1'], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('task1') + }) + + it('returns multiple included tasks', () => { + const flags = { + 'config-file': configFilePath, + include: ['task1', 'task2'], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(2) + }) + + it('returns empty array when included task does not exist', () => { + const flags = { + 'config-file': configFilePath, + include: ['nonexistent'], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(0) + }) + }) + + context('when exclude filter is provided', () => { + it('excludes specified tasks', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: ['task1'], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('task2') + }) + + it('excludes multiple tasks', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: ['task1', 'task2'], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(0) + }) + + it('returns all tasks when excluding non-existent task', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: ['nonexistent'], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(2) + }) + }) + + context('when config is invalid', () => { + beforeEach(() => { + fs.writeFileSync( + configFilePath, + `tasks: + - name: task1 + manifest: manifest.yaml` + ) + }) + + it('throws error with validation message', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: [], + } + + expect(() => Functions.filterFunctions(cmdStub, flags)).to.throw() + expect(cmdStub.error.calledOnce).to.be.true + }) + + it('displays helpful error message for missing fields', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: [], + } + + try { + Functions.filterFunctions(cmdStub, flags) + } catch { + expect(cmdStub.error.calledOnce).to.be.true + const errorCall = cmdStub.error.getCall(0) + expect(errorCall.args[0]).to.include('Invalid mimic.yaml configuration') + } + }) + }) + + context('when YAML is malformed', () => { + beforeEach(() => { + fs.writeFileSync( + configFilePath, + `tasks: + - name: task1 + invalid yaml: [` + ) + }) + + it('throws error when parsing YAML', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: [], + } + + expect(() => Functions.filterFunctions(cmdStub, flags)).to.throw() + }) + }) + }) + }) + }) +})