UNPKG

zkapp-cli

Version:

CLI to create zkApps (zero-knowledge apps) for Mina Protocol

443 lines (406 loc) 13.7 kB
import { parse as acornParse } from 'acorn'; import { simple as simpleAcornWalk } from 'acorn-walk'; import chalk from 'chalk'; import fs from 'node:fs'; import { builtinModules } from 'node:module'; import path from 'node:path'; import url from 'node:url'; import ora from 'ora'; import shell from 'shelljs'; // Module external API export { capitalize, findIfClassExtendsSmartContract, isDirEmpty, kebabCase, readDeployAliasesConfig, replaceInFile, setProjectName, setupProject, step, titleCase, }; // Module internal API (exported for testing purposes) export { buildClassHierarchy, checkClassInheritance, resolveImports, resolveModulePath, }; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const acornOptions = { ecmaVersion: 2020, sourceType: 'module', allowReturnOutsideFunction: true, allowImportExportEverywhere: true, allowAwaitOutsideFunction: true, allowSuperOutsideMethod: true, allowHashBang: true, checkPrivateFields: false, }; /** * Helper for any steps for a consistent UX. * @template T * @param {string} step Name of step to show user. * @param {() => Promise<T>} fn An async function to execute. * @param {boolean} [exitOnError=true] Whether to exit on error with the exit code other than 0. * @returns {Promise<T>} */ async function step(str, fn, exitOnError = true) { // discardStdin prevents Ora from accepting input that would be passed to a // subsequent command, like a y/n confirmation step, which would be dangerous. const spin = ora({ text: `${str}...`, discardStdin: true }).start(); try { const result = await fn(); spin.succeed(chalk.green(str)); return result; } catch (err) { spin.fail(str); console.error(' ' + chalk.red(err)); // maintain expected indentation console.log(err); if (exitOnError) { process.exit(1); } } } /** * Sets up the new project from the template. * @param {string} destination Destination dir path. * @param {string} lang ts (default) or js * @returns {Promise<boolean>} True if successful; false if not. */ async function setupProject(destination, lang = 'ts') { const currentDir = shell.pwd().toString(); const projectName = lang === 'ts' ? 'project-ts' : 'project'; const templatePath = path.resolve( __dirname, '..', '..', 'templates', projectName ); const step = 'Set up project'; const spin = ora({ text: `${step}...`, discardStdin: true }).start(); try { const destDir = path.resolve(destination); shell.mkdir('-p', destDir); shell.cd(destDir); // `node:fs.cpSync` instead of the `shell.cp` because `ShellJS` does not implement `cp -a` // https://github.com/shelljs/shelljs/issues/79#issuecomment-30821277 fs.cpSync(`${templatePath}/`, `${destDir}/`, { recursive: true }); shell.mv( path.resolve(destDir, '_.gitignore'), path.resolve(destDir, '.gitignore') ); shell.mv( path.resolve(destDir, '_.npmignore'), path.resolve(destDir, '.npmignore') ); spin.succeed(chalk.green(step)); return true; } catch (err) { spin.fail(step); console.error(err); return false; } finally { shell.cd(currentDir); } } /** * Step to replace placeholder names in the project with the properly-formatted version of it * @param {string} projDir Full path to the project directory * @returns {void} */ function setProjectName(projDir) { const name = projDir.split(path.sep).pop(); replaceInFile( path.join(projDir, 'README.md'), 'PROJECT_NAME', titleCase(name) ); replaceInFile( path.join(projDir, 'package.json'), 'package-name', kebabCase(name) ); } /** * Reads the deploy aliases configuration from the project root. * @param {string} projectRoot The project root directory. * @returns {Object} The deploy aliases configuration. * @throws {Error} And exits if the configuration file is not found or cannot be read. */ function readDeployAliasesConfig(projectRoot) { try { return JSON.parse(fs.readFileSync(`${projectRoot}/config.json`, 'utf8')); } catch (err) { let str; if (err.code === 'ENOENT') { str = `config.json not found. Make sure you're in a zkApp project directory.`; } else { str = 'Unable to read config.json.'; console.error(err); } console.log(chalk.red(str)); process.exit(1); } } /** * Checks if a directory is empty. * @param {string} path The path to the directory to check. * @returns {boolean} True if the directory is empty, false otherwise. */ function isDirEmpty(path) { return fs.readdirSync(path).length === 0; } /** * Helper to replace text in a file. * @param {string} file Path to file * @param {string} a Old text. * @param {string} b New text. */ function replaceInFile(file, a, b) { let content = fs.readFileSync(file, 'utf8'); content = content.replace(a, b); fs.writeFileSync(file, content); } /** * Converts a string to title case. * @param {string} str The string to convert. * @returns {string} The title case string. */ function titleCase(str) { return str .split('-') .map((w) => w.charAt(0).toUpperCase() + w.substring(1).toLowerCase()) .join(' '); } /** * Converts a string to kebab case. * @param {string} str The string to convert. * @returns {string} The kebab case string. */ function kebabCase(str) { return str.toLowerCase().replace(' ', '-'); } /** * Converts a string to capitalized case. * @param {string} string The string to convert. * @returns {string} The capitalized string. */ function capitalize(string) { return string.charAt(0).toUpperCase() + string.slice(1); } /** * Finds all classes that extend the 'SmartContract' class from 'o1js'. * @param {string} entryFilePath - The path of the entry file. * @returns {Array<Object>} - An array of objects containing the class name and file path of the smart contract classes found. */ function findIfClassExtendsSmartContract(entryFilePath) { const classesMap = buildClassHierarchy(entryFilePath); const importMappings = resolveImports(entryFilePath); const smartContractClasses = []; // Check each class in the class map for inheritance from the o1js `SmartContract` for (let className of Object.keys(classesMap)) { const result = checkClassInheritance( className, 'SmartContract', classesMap, new Set(), importMappings ); if (result) { smartContractClasses.push({ className, filePath: classesMap[className].filePath, }); } } return smartContractClasses; } /** * Builds a class hierarchy map based on the provided file path. * @param {string} filePath - The path to the file containing the class declarations. * @returns {Object} - The class hierarchy map, where keys are class names and values are objects containing class information. */ function buildClassHierarchy(filePath) { const source = fs.readFileSync(filePath, 'utf-8'); const ast = acornParse(source, acornOptions); const classesMap = {}; const importSet = new Set(); // Traverse the AST to find class declarations and imports simpleAcornWalk(ast, { ClassDeclaration(node) { const currentClass = node.id.name; const parentClass = node.superClass ? node.superClass.name : null; classesMap[currentClass] = { extends: parentClass, filePath, inheritsFromO1jsSmartContract: false, }; }, ImportDeclaration(node) { if (node.source.value === 'o1js') { node.specifiers.forEach((specifier) => { importSet.add(specifier.local.name); }); } }, }); // Mark classes that extend `SmartContract` from `o1js` in the same file Object.values(classesMap).forEach((classInfo) => { if (importSet.has(classInfo.extends)) { classInfo.inheritsFromO1jsSmartContract = true; } }); return classesMap; } /** * Resolves the imports in the given file path and returns the import mappings. * @param {string} filePath - The path of the file to resolve imports for. * @returns {Object} - The import mappings where the keys are the local names and the values are objects with the resolved paths and module names. */ function resolveImports(filePath) { const source = fs.readFileSync(filePath, 'utf-8'); const ast = acornParse(source, acornOptions); const importMappings = {}; // Traverse the AST to find import declarations simpleAcornWalk(ast, { ImportDeclaration(node) { const sourcePath = node.source.value; node.specifiers.forEach((specifier) => { const resolvedPath = resolveModulePath( sourcePath, path.dirname(filePath) ); importMappings[specifier.local.name] = { resolvedPath, moduleName: sourcePath, }; }); }, }); return importMappings; } /** * Resolves the path of a module based on the provided module name and base path. * @param {string} moduleName - The name of the module to resolve. * @param {string} basePath - The base path to resolve the module path relative to. * @returns {string|null} - The resolved module path, or null if the module is not found or is a built-in module. */ function resolveModulePath(moduleName, basePath) { // Check if the module is a Node.js built-in module if (builtinModules.includes(moduleName)) { return null; } // Resolve relative or absolute paths based on the current file's directory if (path.isAbsolute(moduleName) || moduleName.startsWith('.')) { let modulePath = path.resolve(basePath, moduleName); if (!fs.existsSync(modulePath) && !modulePath.endsWith('.js')) { modulePath += '.js'; } return modulePath; } else { // Module is a node_modules dependency const packagePath = path.join('node_modules', moduleName); const packageJsonPath = path.join(packagePath, 'package.json'); // Try to resolve the main file using the package.json if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); // Skip the primary entry point for the 'o1js' module // This is necessary if the 'o1js' module is of < v1.0.1 // https://github.com/o1-labs/o1js/commit/0a56798210e9e6678a2b18ca0cecd683b05ba6e5 if (moduleName === 'o1js') { delete packageJson['main']; } let mainFile = packageJson.main || packageJson?.exports?.node?.import || 'index.js'; return path.join(packagePath, mainFile); } else { console.error( `Module '${moduleName}' not found in the './node_modules' directory.` ); return null; } } } /** * Checks if a class inherits from a target class by traversing the class hierarchy. * @param {string} className - The name of the class to check. * @param {string} targetClass - The name of the target class to check inheritance against. * @param {Object} classesMap - A map of class names to class information. * @param {Set} visitedClasses - A set of visited class names to avoid infinite loops. * @param {Object} importMappings - A map of class names to resolved file paths and module names. * @returns {boolean} - Returns true if the class inherits from the target class from 'o1js', false otherwise. */ function checkClassInheritance( className, targetClass, classesMap, visitedClasses, importMappings ) { // Avoid infinite loops by tracking visited classes if (visitedClasses.has(className)) return false; visitedClasses.add(className); // If the class is not found in the current classesMap, build its hierarchy from imports if (!classesMap[className]) { let importMapping = importMappings[className]; if (!importMapping) return false; Object.assign(classesMap, buildClassHierarchy(importMapping.resolvedPath)); importMappings = Object.assign( importMappings, resolveImports(importMapping.resolvedPath) ); } const classInfo = classesMap[className]; if (!classInfo) return false; // Propagate inheritsFromO1jsSmartContract from parent class if (classInfo.extends) { const parentClassResult = checkClassInheritance( classInfo.extends, targetClass, classesMap, visitedClasses, importMappings ); // Propagate the inheritsFromO1jsSmartContract flag if (parentClassResult) { classInfo.inheritsFromO1jsSmartContract = true; } } // Check if the class directly extends the target class if ( classInfo.extends === targetClass && importMappings[classInfo.extends]?.moduleName === 'o1js' ) { classInfo.inheritsFromO1jsSmartContract = true; return true; } // Additional check for imported base class if (importMappings[classInfo.extends]) { const baseClassPath = importMappings[classInfo.extends].resolvedPath; const baseClassMap = buildClassHierarchy(baseClassPath); Object.assign(classesMap, baseClassMap); const baseClassInfo = baseClassMap[classInfo.extends]; if (baseClassInfo && baseClassInfo.extends === targetClass) { classInfo.inheritsFromO1jsSmartContract = true; return true; } else if (baseClassInfo) { const parentClassResult = checkClassInheritance( baseClassInfo.extends, targetClass, classesMap, visitedClasses, importMappings ); /* istanbul ignore next */ if (parentClassResult) { classInfo.inheritsFromO1jsSmartContract = true; return true; } } } return classInfo.inheritsFromO1jsSmartContract; }