diff --git a/packages/cli/src/commands/authenticate.ts b/packages/cli/src/commands/authenticate.ts index 83f88f2d..c8621450 100644 --- a/packages/cli/src/commands/authenticate.ts +++ b/packages/cli/src/commands/authenticate.ts @@ -8,18 +8,11 @@ import { FlagsType } from '../types' export type AuthenticateFlags = FlagsType export default class Authenticate extends Command { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - run(): Promise { + run(): Promise { throw new Error('Method not implemented.') } - static override description = 'Authenticate with Mimic by storing your API key locally' - - static override examples = [ - '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --profile staging', - '<%= config.bin %> <%= command.id %> --profile production --api-key YOUR_API_KEY', - ] + static override hidden = true static flags = { profile: Flags.string({ 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..85f205ac --- /dev/null +++ b/packages/cli/src/commands/functions.ts @@ -0,0 +1,133 @@ +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 type FunctionConfig = z.infer + +export const MimicConfigSchema = z.object({ + functions: z.array(FunctionConfigSchema).min(1, 'At least one function is required'), +}) + +export const DefaultFunctionConfig = { + name: '', + manifest: 'manifest.yaml', + function: 'src/function.ts', + 'build-directory': './build', + 'types-directory': './src/types', +} as const + +const MIMIC_CONFIG_FILE = 'mimic.yaml' + +export default class Functions extends Command { + run(): Promise { + throw new Error('Method not implemented.') + } + + static override hidden = true + + static flags = { + 'config-file': Flags.string({ + description: `Path to the ${MIMIC_CONFIG_FILE} file, this overrides other parameters like build-directory and function`, + default: MIMIC_CONFIG_FILE, + }), + 'no-config': Flags.boolean({ + description: `Do not read ${MIMIC_CONFIG_FILE}; use defaults and explicit flags instead`, + default: false, + }), + include: Flags.string({ + description: `When ${MIMIC_CONFIG_FILE} exists, only run tasks with these names (space-separated)`, + multiple: true, + exclusive: ['exclude'], + char: 'i', + }), + exclude: Flags.string({ + description: `When ${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(`\nStarting ${cmdActions} for function ${func.name ? func.name : func.function}`) + 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'] !== MIMIC_CONFIG_FILE) { + cmd.error(`Could not find ${flags['config-file']}`, { code: 'ConfigNotFound' }) + } + + // If doesn't exist 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) + + if (!rawConfig || (typeof rawConfig === 'object' && Object.keys(rawConfig).length === 0)) { + cmd.error(`Invalid ${MIMIC_CONFIG_FILE} configuration: file is empty.`) + } + + try { + let { functions } = MimicConfigSchema.parse(rawConfig) + + if (flags.include && flags.include.length > 0) { + Functions.checkMissingFunctions(cmd, functions, flags.include) + functions = functions.filter((fn) => flags.include!.includes(fn.name)) + } + + if (flags.exclude && flags.exclude.length > 0) { + Functions.checkMissingFunctions(cmd, functions, flags.exclude) + functions = functions.filter((fn) => !flags.exclude!.includes(fn.name)) + } + + return functions + } catch (error) { + if (error instanceof z.ZodError) { + const errors = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('\n') + cmd.error(`Invalid ${MIMIC_CONFIG_FILE} configuration:\n${errors}`, { code: 'InvalidConfig' }) + } + throw error + } + } + + private static checkMissingFunctions( + cmd: Command, + functions: FunctionConfig[], + filteredFunctionNames: string[] + ): void { + const functionNames = new Set(functions.map((fn) => fn.name)) + const missingFunctions = filteredFunctionNames.filter((name) => !functionNames.has(name)) + if (missingFunctions.length > 0) { + cmd.warn(`Functions not found in ${MIMIC_CONFIG_FILE}: ${missingFunctions.join(', ')}`) + } + } +} 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..45222871 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,17 @@ export default class Test extends Command { static override examples = ['<%= config.bin %> <%= command.id %> --directory ./tests'] static override flags = { + ...{ + ...Functions.flags, + include: { + ...Functions.flags.include, + description: Functions.flags.include.description + '. Only for building, it does not affect testing', + }, + exclude: { + ...Functions.flags.exclude, + description: Functions.flags.exclude.description + '. Only for building, it does not affect testing', + }, + }, ...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 +41,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..627913c7 --- /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 = { + functions: [ + { + name: 'function1', + manifest: 'manifest.yaml', + function: 'src/function.ts', + 'build-directory': './build', + 'types-directory': './src/types', + }, + { + name: 'function2', + manifest: 'src/function2/manifest.yaml', + function: 'src/function2/function.ts', + 'build-directory': './build/function2', + 'types-directory': './src/function2/types', + }, + ], + } + + describe('FunctionConfigSchema', () => { + context('when all required fields are present', () => { + it('validates successfully', () => { + const config = validConfig.functions[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.functions[0], name: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + + context('when manifest is missing', () => { + it('throws error', () => { + const config = { ...validConfig.functions[0], manifest: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when function is missing', () => { + it('throws error', () => { + const config = { ...validConfig.functions[0], function: '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when build-directory is missing', () => { + it('throws error', () => { + const config = { ...validConfig.functions[0], 'build-directory': '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + context('when types-directory is missing', () => { + it('throws error', () => { + const config = { ...validConfig.functions[0], 'types-directory': '' } + expect(() => FunctionConfigSchema.parse(config)).to.throw() + }) + }) + }) + }) + + describe('MimicConfigSchema', () => { + context('when config has valid functions array', () => { + it('validates successfully with single function', () => { + const config = { functions: [validConfig.functions[0]] } + expect(() => MimicConfigSchema.parse(config)).to.not.throw() + }) + + it('validates successfully with multiple functions', () => { + expect(() => MimicConfigSchema.parse(validConfig)).to.not.throw() + }) + }) + + context('when functions array is empty', () => { + it('throws validation error', () => { + const config = { functions: [] } + expect(() => MimicConfigSchema.parse(config)).to.throw() + }) + }) + + context('when functions array is missing', () => { + it('throws validation error', () => { + const config = {} + expect(() => MimicConfigSchema.parse(config)).to.throw() + }) + }) + + context('when a function in the array is invalid', () => { + it('throws validation error for invalid function', () => { + const config = { + functions: [ + validConfig.functions[0], + { ...validConfig.functions[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, + ` +functions: + - name: function1 + manifest: manifest.yaml + function: src/function.ts + build-directory: ./build + types-directory: ./src/types + - name: function2 + manifest: src/function2/manifest.yaml + function: src/function2/function.ts + build-directory: ./build/function2 + types-directory: ./src/function2/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 functions', () => { + 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('function1') + expect(result[1].name).to.equal('function2') + }) + + context('when include filter is provided', () => { + it('returns only included functions', () => { + const flags = { + 'config-file': configFilePath, + include: ['function1'], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('function1') + }) + + it('returns multiple included functions', () => { + const flags = { + 'config-file': configFilePath, + include: ['function1', 'function2'], + exclude: [], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(2) + }) + + it('returns empty array when included function 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 functions', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: ['function1'], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(1) + expect(result[0].name).to.equal('function2') + }) + + it('excludes multiple functions', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: ['function1', 'function2'], + } + + const result = Functions.filterFunctions(cmdStub, flags) + + expect(result).to.have.lengthOf(0) + }) + + it('returns all functions when excluding non-existent function', () => { + 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, + `functions: + - name: function1 + 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, + `functions: + - name: function1 + invalid yaml: [` + ) + }) + + it('throws error when parsing YAML', () => { + const flags = { + 'config-file': configFilePath, + include: [], + exclude: [], + } + + expect(() => Functions.filterFunctions(cmdStub, flags)).to.throw() + }) + }) + }) + }) + }) +})