UNPKG

create-eth

Version:
1,139 lines (1,109 loc) • 50.6 kB
import { execa, execaCommand } from 'execa'; import fs, { promises, lstatSync, readlinkSync, readdirSync, existsSync } from 'fs'; import path, { basename, resolve } from 'path'; import mergeJsonStr from 'merge-packages'; import { fileURLToPath, pathToFileURL } from 'url'; import ncp from 'ncp'; import { promisify } from 'util'; import * as https from 'https'; import chalk from 'chalk'; import { Listr } from 'listr2'; import arg from 'arg'; import validateProjectName from 'validate-npm-package-name'; import { confirm, input, select } from '@inquirer/prompts'; import semver from 'semver'; const findFilesRecursiveSync = (baseDir, criteriaFn = () => true) => { const subPaths = fs.readdirSync(baseDir); const files = subPaths.map(relativePath => { const fullPath = path.resolve(baseDir, relativePath); return fs.lstatSync(fullPath).isDirectory() ? [...findFilesRecursiveSync(fullPath, criteriaFn)] : criteriaFn(fullPath) ? [fullPath] : []; }); return files.flat(); }; function mergePackageJson(targetPackageJsonPath, secondPackageJsonPath, isDev) { const existsTarget = fs.existsSync(targetPackageJsonPath); const existsSecond = fs.existsSync(secondPackageJsonPath); if (!existsTarget && !existsSecond) { return; } const targetPackageJson = existsTarget ? fs.readFileSync(targetPackageJsonPath, "utf8") : "{}"; const secondPackageJson = existsSecond ? fs.readFileSync(secondPackageJsonPath, "utf8") : "{}"; const mergedPkgStr = mergeJsonStr.default(targetPackageJson, secondPackageJson); const formattedPkgStr = JSON.stringify(JSON.parse(mergedPkgStr), null, 2); fs.writeFileSync(targetPackageJsonPath, formattedPkgStr, "utf8"); if (isDev) { const devStr = `TODO: write relevant information for the contributor`; fs.writeFileSync(`${targetPackageJsonPath}.dev`, devStr, "utf8"); } } const { mkdir, link, symlink } = promises; const passesFilter = (source, options) => { const isDSStore = /\.DS_Store$/.test(source); if (isDSStore) { return false; // Exclude .DS_Store files } return options?.filter === undefined ? true // no filter : typeof options.filter === "function" ? options.filter(source) // filter is function : options.filter.test(source); // filter is regex }; /** * The goal is that this function has the same API as ncp, so they can be used * interchangeably. * * - clobber not implemented */ const linkRecursive = async (source, destination, options) => { if (!passesFilter(source, options)) { return; } const stat = lstatSync(source); if (stat.isSymbolicLink()) { const linkTarget = readlinkSync(source); return symlink(linkTarget, destination); } if (stat.isDirectory()) { const subPaths = readdirSync(source); await Promise.all(subPaths.map(async (subPath) => { const sourceSubpath = path.join(source, subPath); const isSubPathAFolder = lstatSync(sourceSubpath).isDirectory(); const destSubPath = path.join(destination, subPath); if (!passesFilter(destSubPath, options)) { return; } const existsDestSubPath = existsSync(destSubPath); if (isSubPathAFolder && !existsDestSubPath) { await mkdir(destSubPath); } await linkRecursive(sourceSubpath, destSubPath, options); })); return; } return link(source, destination); }; const createEthExtensions = [ { extensionFlagValue: "subgraph", description: "Adds support for building, testing, and deploying subgraphs locally, with seamless front-end integration and easy deployment to Subgraph Studio.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "subgraph", }, { extensionFlagValue: "x402", description: "Adds support for monetizing APIs and pages with x402 protocol micropayments.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "x402", }, { extensionFlagValue: "eip-712", description: "Provides EIP-712 typed message signing, sending, and verification in a user-friendly way.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "eip-712", }, { extensionFlagValue: "ponder", description: "Provides a pre-configured setup for ponder.sh, helping you get started quickly with event indexing.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "ponder", }, { extensionFlagValue: "erc-20", description: "Adds support for ERC-20 token contracts, including balance checks and token transfers.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "erc-20", }, { extensionFlagValue: "eip-5792", description: "Provides EIP-5792 wallet capabilities, allowing multiple calls and status checks via new JSON-RPC methods.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "eip-5792", }, { extensionFlagValue: "randao", description: "Provides on-chain randomness using RANDAO for unpredictable random sources.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "randao", }, { extensionFlagValue: "erc-721", description: "Adds support for ERC-721 NFT contracts, including supply, balance, listing, and transfer features.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "erc-721", }, { extensionFlagValue: "porto", description: "This extension brings porto.sh SDK to Scaffold-ETH 2.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "porto", }, { extensionFlagValue: "envio", description: "This extension integrates Envio Indexer, it makes indexing your deployed smart contracts as simple as possible", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "envio", }, { extensionFlagValue: "drizzle-neon", description: "This extension sets up a local database with Drizzle ORM. Optimized to work using Neon as the database provider.", repository: "https://github.com/scaffold-eth/create-eth-extensions", branch: "drizzle-neon", }, ]; const challenges = [ { extensionFlagValue: "challenge-tokenization", description: "Speedrun Ethereum Challenge: Tokenization.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-tokenization", }, { extensionFlagValue: "challenge-crowdfunding", description: "Speedrun Ethereum Challenge: Crowdfunding App.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-crowdfunding", }, { extensionFlagValue: "challenge-token-vendor", description: "Speedrun Ethereum Challenge: Token Vendor.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-token-vendor", }, { extensionFlagValue: "challenge-dice-game", description: "Speedrun Ethereum Challenge: Dice Game.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-dice-game", }, { extensionFlagValue: "challenge-dex", description: "Speedrun Ethereum Challenge: Build a DEX.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-dex", }, { extensionFlagValue: "challenge-state-channels", description: "Speedrun Ethereum Challenge: A State Channel Application.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-state-channels", }, { extensionFlagValue: "challenge-multisig", description: "Speedrun Ethereum Challenge: Multisig Wallet.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-multisig", }, { extensionFlagValue: "challenge-svg-nft", description: "Speedrun Ethereum Challenge: SVG NFT.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-svg-nft", }, { extensionFlagValue: "challenge-oracles", description: "Speedrun Ethereum Challenge: Oracles.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-oracles", }, { extensionFlagValue: "challenge-over-collateralized-lending", description: "Speedrun Ethereum Challenge: Over-collateralized Lending.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-over-collateralized-lending", }, { extensionFlagValue: "challenge-prediction-markets", description: "Speedrun Ethereum Challenge: Prediction Markets.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-prediction-markets", }, { extensionFlagValue: "challenge-stablecoins", description: "Speedrun Ethereum Challenge: Stablecoins.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-stablecoins", }, { extensionFlagValue: "challenge-zk-voting", description: "Speedrun Ethereum Challenge: ZK Voting.", repository: "https://github.com/scaffold-eth/se-2-challenges", branch: "challenge-zk-voting", }, ]; const organizations = [ { extensionFlagValue: "metamask/erc-7715-extension", name: "MetaMask Advanced Permissions", description: "This extension helps you get started with MetaMask Advanced Permissions (ERC-7715).", repository: "https://github.com/MetaMask/erc-7715-extension", }, { extensionFlagValue: "metamask/gator-extension", name: "Smart Accounts Kit", description: "This extension helps you get started with MetaMask Smart Accounts, and ERC-7710 delegations.", repository: "https://github.com/MetaMask/gator-extension", }, { extensionFlagValue: "signinwithethereum/scaffold-siwe-ext", name: "SIWE Extension", description: "This extension helps you get started with SIWE (Sign-In with Ethereum). Authenticate using your Ethereum wallet. No passwords needed.", repository: "https://github.com/signinwithethereum/scaffold-siwe-ext", }, { extensionFlagValue: "ethereumidentitykit/scaffold-efp-ext", name: "EFP Extension", description: "This extension adds Ethereum Follow Protocol (EFP) social graph functionality to your Scaffold-ETH 2 project. EFP is the onchain social graph protocol for Ethereum accounts - like Twitter follows, but fully decentralized and portable across any dApp.", repository: "https://github.com/ethereumidentitykit/scaffold-efp-ext", }, ]; const extensions$1 = [...createEthExtensions, ...challenges, ...organizations]; const BASE_DIR = "base"; const SOLIDITY_FRAMEWORKS_DIR = "solidity-frameworks"; const EXAMPLE_CONTRACTS_DIR = "example-contracts"; const SOLIDITY_FRAMEWORKS = { HARDHAT: "hardhat", FOUNDRY: "foundry", }; const GLOBAL_ARGS_DEFAULTS = { solidityFramework: "", }; const TRUSTED_GITHUB_ORGANIZATIONS = ["scaffold-eth", "buidlguidl"]; const extensions = extensions$1; const CURATED_EXTENSIONS = extensions.reduce((acc, ext) => { if (!ext.repository) { throw new Error(`Extension must have 'repository': ${JSON.stringify(ext)}`); } if (!ext.extensionFlagValue) { throw new Error(`Extension must have 'extensionFlagValue': ${JSON.stringify(ext)}`); } acc[ext.extensionFlagValue] = { repository: ext.repository, branch: ext.branch, createEthVersion: ext.createEthVersion, }; return acc; }, {}); function deconstructGithubUrl(url) { const urlParts = url.split("/"); const ownerName = urlParts[3]; const repoName = urlParts[4]; const branch = urlParts[5] === "tree" ? urlParts[6] : undefined; return { ownerName, repoName, branch }; } const validateExternalExtension = async (extensionName, dev) => { if (dev) { // Check externalExtensions/${extensionName} exists try { const currentFileUrl = import.meta.url; const externalExtensionsDirectory = path.resolve(decodeURI(fileURLToPath(currentFileUrl)), "../../externalExtensions"); await fs.promises.access(`${externalExtensionsDirectory}/${extensionName}`); } catch { throw new Error(`Extension not found in "externalExtensions/${extensionName}"`); } return extensionName; } const { githubUrl, githubBranchUrl, branch, owner } = getDataFromExternalExtensionArgument(extensionName); const isTrusted = TRUSTED_GITHUB_ORGANIZATIONS.includes(owner.toLowerCase()) || !!CURATED_EXTENSIONS[extensionName]; const recommendedCreateEthVersion = CURATED_EXTENSIONS[extensionName]?.createEthVersion; // Check if repository exists await new Promise((resolve, reject) => { https .get(githubBranchUrl, res => { if (res.statusCode !== 200) { reject(new Error(`Extension not found: ${githubUrl}`)); } else { resolve(null); } }) .on("error", err => { reject(err); }); }); return { repository: githubUrl, branch, isTrusted, recommendedCreateEthVersion }; }; // Gets the data from the argument passed to the `--extension` option. const getDataFromExternalExtensionArgument = (externalExtension) => { if (CURATED_EXTENSIONS[externalExtension]) { externalExtension = getArgumentFromExternalExtensionOption(CURATED_EXTENSIONS[externalExtension]); } const isGithubUrl = externalExtension.startsWith("https://github.com/"); // Check format: owner/project:branch (branch is optional) const regex = /^[^/]+\/[^/]+(:[^/]+)?$/; if (!regex.test(externalExtension) && !isGithubUrl) { throw new Error(`Invalid extension format. Use "owner/project", "owner/project:branch" or github url.`); } let owner; let project; let branch; if (isGithubUrl) { const { ownerName, repoName, branch: urlBranch } = deconstructGithubUrl(externalExtension); owner = ownerName; project = repoName; branch = urlBranch; } else { // Extract owner, project and branch if format passed is owner/project:branch owner = externalExtension.split("/")[0]; project = externalExtension.split(":")[0].split("/")[1]; branch = externalExtension.split(":")[1]; } const githubUrl = `https://github.com/${owner}/${project}`; let githubBranchUrl; if (branch) { githubBranchUrl = `https://github.com/${owner}/${project}/tree/${branch}`; } return { githubBranchUrl: githubBranchUrl ?? githubUrl, githubUrl, branch, owner, project, }; }; // Parse the externalExtensionOption object into a argument string. // e.g. { repository: "owner/project", branch: "branch" } => "owner/project:branch" const getArgumentFromExternalExtensionOption = (externalExtensionOption) => { const { repository, branch } = externalExtensionOption || {}; const owner = repository?.split("/")[3]; const project = repository?.split("/")[4]; return `${owner}/${project}${branch ? `:${branch}` : ""}`; }; // Gets the solidity framework directories from the external extension repository const getSolidityFrameworkDirsFromExternalExtension = async (externalExtension) => { const solidityFrameworks = Object.values(SOLIDITY_FRAMEWORKS); const filterSolidityFrameworkDirs = (dirs) => { return dirs.filter(dir => solidityFrameworks.includes(dir)).reverse(); }; if (typeof externalExtension === "string") { const currentFileUrl = import.meta.url; const externalExtensionsDirectory = path.resolve(decodeURI(fileURLToPath(currentFileUrl)), "../../externalExtensions"); const externalExtensionSolidityFrameworkDirs = await fs.promises.readdir(`${externalExtensionsDirectory}/${externalExtension}/extension/packages`); return filterSolidityFrameworkDirs(externalExtensionSolidityFrameworkDirs); } const { branch, repository } = externalExtension; const { ownerName, repoName } = deconstructGithubUrl(repository); const frameworkChecks = solidityFrameworks.map(async (framework) => { const branchOrHead = branch ? `tree/${branch}` : "blob/HEAD"; const githubUrl = `https://github.com/${ownerName}/${repoName}/${branchOrHead}/extension/packages/${framework}`; try { const res = await fetch(githubUrl); if (res.status === 200) return framework; if (res.status === 404) return null; throw new Error(`${framework.charAt(0).toUpperCase() + framework.slice(1)} framework check failed with status ${res.status}. You can verify it at ${githubUrl}.`); } catch (err) { console.warn(err.message); return framework; } }); const results = await Promise.all(frameworkChecks); const frameworkDirs = results.filter((framework) => framework !== null); return frameworkDirs; }; const EXTERNAL_EXTENSION_TMP_DIR = "tmp-external-extension"; const copy = promisify(ncp); let copyOrLink = copy; const isTemplateRegex = /([^/\\]*?)\.template\./; const isPackageJsonRegex = /package\.json/; const isYarnLockRegex = /yarn\.lock/; const isConfigRegex = /([^/\\]*?)\\config\.json/; const isArgsRegex = /([^/\\]*?)\.args\./; const isSolidityFrameworkFolderRegex = /solidity-frameworks$/; const isPackagesFolderRegex = /packages$/; const isDeployedContractsRegex = /packages\/nextjs\/contracts\/deployedContracts\.ts/; const getSolidityFrameworkPath = (solidityFramework, templatesDirectory) => path.resolve(templatesDirectory, SOLIDITY_FRAMEWORKS_DIR, solidityFramework); const copyBaseFiles = async (basePath, targetDir, { dev: isDev }) => { await copyOrLink(basePath, targetDir, { clobber: false, filter: fileName => { const isTemplate = isTemplateRegex.test(fileName); const isYarnLock = isYarnLockRegex.test(fileName); const isDeployedContracts = isDeployedContractsRegex.test(fileName); const isPackageJson = isPackageJsonRegex.test(fileName); const skipDevOnly = isDev && (isYarnLock || isDeployedContracts || isPackageJson); return !isTemplate && !skipDevOnly; }, }); if (isDev) { // We don't want symlink below files in dev mode const baseYarnLockPaths = findFilesRecursiveSync(basePath, path => isYarnLockRegex.test(path)); baseYarnLockPaths.forEach(yarnLockPath => { const partialPath = yarnLockPath.split(basePath)[1]; void copy(path.join(basePath, partialPath), path.join(targetDir, partialPath)); }); const basePackageJsonPaths = findFilesRecursiveSync(basePath, path => isPackageJsonRegex.test(path)); basePackageJsonPaths.forEach(packageJsonPath => { const partialPath = packageJsonPath.split(basePath)[1]; mergePackageJson(path.join(targetDir, partialPath), path.join(basePath, partialPath), isDev); }); const baseDeployedContractsPaths = findFilesRecursiveSync(basePath, path => isDeployedContractsRegex.test(path)); baseDeployedContractsPaths.forEach(deployedContractsPath => { const partialPath = deployedContractsPath.split(basePath)[1]; void copy(path.join(basePath, partialPath), path.join(targetDir, partialPath)); }); } }; const isUnselectedSolidityFrameworkFile = ({ path, solidityFramework, }) => { const unselectedSolidityFrameworks = [SOLIDITY_FRAMEWORKS.FOUNDRY, SOLIDITY_FRAMEWORKS.HARDHAT].filter(sf => sf !== solidityFramework); return unselectedSolidityFrameworks.map(sf => new RegExp(`${sf}`)).some(sfregex => sfregex.test(path)); }; const copyExtensionFiles = async ({ dev: isDev, solidityFramework }, extensionPath, targetDir) => { // copy (or link if dev) root files await copyOrLink(extensionPath, path.join(targetDir), { clobber: false, filter: path => { const isConfig = isConfigRegex.test(path); const isArgs = isArgsRegex.test(path); const isSolidityFrameworkFolder = isSolidityFrameworkFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isPackagesFolder = isPackagesFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isTemplate = isTemplateRegex.test(path); // PR NOTE: this wasn't needed before because ncp had the clobber: false const isPackageJson = isPackageJsonRegex.test(path); const shouldSkip = isConfig || isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder; return !shouldSkip; }, }); // merge root package.json mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "package.json"), isDev); const extensionPackagesPath = path.join(extensionPath, "packages"); const hasPackages = fs.existsSync(extensionPackagesPath); if (hasPackages) { // copy extension packages files await copyOrLink(extensionPackagesPath, path.join(targetDir, "packages"), { clobber: false, filter: path => { const isArgs = isArgsRegex.test(path); const isTemplate = isTemplateRegex.test(path); const isPackageJson = isPackageJsonRegex.test(path); const isUnselectedSolidityFramework = isUnselectedSolidityFrameworkFile({ path, solidityFramework }); const shouldSkip = isArgs || isTemplate || isPackageJson || isUnselectedSolidityFramework; return !shouldSkip; }, }); // copy each package's package.json const extensionPackages = fs.readdirSync(extensionPackagesPath); extensionPackages.forEach(packageName => { const isUnselectedSolidityFramework = isUnselectedSolidityFrameworkFile({ path: path.join(targetDir, "packages", packageName, "package.json"), solidityFramework, }); if (isUnselectedSolidityFramework) { return; } mergePackageJson(path.join(targetDir, "packages", packageName, "package.json"), path.join(extensionPath, "packages", packageName, "package.json"), isDev); if (packageName === solidityFramework) { mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "packages", packageName, "root.package.json"), isDev); } }); } }; const processTemplatedFiles = async ({ solidityFramework, externalExtension, dev: isDev }, basePath, solidityFrameworkPath, exampleContractsPath, targetDir) => { const baseTemplatedFileDescriptors = findFilesRecursiveSync(basePath, path => isTemplateRegex.test(path)).map(baseTemplatePath => ({ path: baseTemplatePath, fileUrl: pathToFileURL(baseTemplatePath).href, relativePath: baseTemplatePath.split(basePath)[1], source: "base", })); const solidityFrameworkTemplatedFileDescriptors = solidityFrameworkPath ? findFilesRecursiveSync(solidityFrameworkPath, filePath => isTemplateRegex.test(filePath)) .map(solidityFrameworkTemplatePath => ({ path: solidityFrameworkTemplatePath, fileUrl: pathToFileURL(solidityFrameworkTemplatePath).href, relativePath: solidityFrameworkTemplatePath.split(solidityFrameworkPath)[1], source: `extension ${solidityFramework}`, })) .flat() : []; const starterContractsTemplateFileDescriptors = exampleContractsPath ? findFilesRecursiveSync(exampleContractsPath, filePath => isTemplateRegex.test(filePath)) .map(exampleContractTemplatePath => ({ path: exampleContractTemplatePath, fileUrl: pathToFileURL(exampleContractTemplatePath).href, relativePath: exampleContractTemplatePath.split(exampleContractsPath)[1], source: `example-contracts ${solidityFramework}`, })) .flat() : []; const externalExtensionFolder = isDev ? typeof externalExtension === "string" ? path.join(basePath, "../../externalExtensions", externalExtension, "extension") : undefined : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension"); const externalExtensionTemplatedFileDescriptors = externalExtension && externalExtensionFolder ? findFilesRecursiveSync(externalExtensionFolder, filePath => isTemplateRegex.test(filePath)).map(extensionTemplatePath => ({ path: extensionTemplatePath, fileUrl: pathToFileURL(extensionTemplatePath).href, relativePath: extensionTemplatePath.split(externalExtensionFolder)[1], source: `external extension ${isDev ? externalExtension : getArgumentFromExternalExtensionOption(externalExtension)}`, })) : []; await Promise.all([ ...baseTemplatedFileDescriptors, ...solidityFrameworkTemplatedFileDescriptors, ...externalExtensionTemplatedFileDescriptors, ...starterContractsTemplateFileDescriptors, ].map(async (templateFileDescriptor) => { const templateTargetName = templateFileDescriptor.path.match(isTemplateRegex)?.[1]; const argsPath = templateFileDescriptor.relativePath.replace(isTemplateRegex, `${templateTargetName}.args.`); const argsFileUrls = []; if (solidityFrameworkPath) { const argsFilePath = path.join(solidityFrameworkPath, argsPath); const fileExists = fs.existsSync(argsFilePath); if (fileExists) { argsFileUrls.push(pathToFileURL(argsFilePath).href); } } if (exampleContractsPath) { const argsFilePath = path.join(exampleContractsPath, argsPath); const fileExists = fs.existsSync(argsFilePath); if (fileExists) { argsFileUrls.push(pathToFileURL(argsFilePath).href); } } if (externalExtension) { const argsFilePath = isDev ? path.join(basePath, "../../externalExtensions", externalExtension, "extension", argsPath) : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension", argsPath); const fileExists = fs.existsSync(argsFilePath); if (fileExists) { argsFileUrls?.push(pathToFileURL(argsFilePath).href); } } const args = await Promise.all(argsFileUrls.map(async (argsFileUrl) => (await import(argsFileUrl)))); const fileTemplate = (await import(templateFileDescriptor.fileUrl)).default; if (!fileTemplate) { throw new Error(`Template ${templateTargetName} from ${templateFileDescriptor.source} doesn't have a default export`); } if (typeof fileTemplate !== "function") { throw new Error(`Template ${templateTargetName} from ${templateFileDescriptor.source} is not exporting a function by default`); } const allKeys = [...new Set(args.flatMap(Object.keys))]; const freshArgs = Object.fromEntries(allKeys.map(key => [ key, // INFO: key for the freshArgs object [], // INFO: initial value for the freshArgs object ])); const combinedArgs = args.reduce((accumulated, arg) => { Object.entries(arg).map(([key, value]) => { accumulated[key]?.push(value); }); return accumulated; }, freshArgs); const globalArgs = { solidityFramework: [solidityFramework || GLOBAL_ARGS_DEFAULTS.solidityFramework], }; const output = fileTemplate({ ...combinedArgs, ...globalArgs }); const targetPath = path.join(targetDir, templateFileDescriptor.relativePath.split(templateTargetName)[0], templateTargetName); fs.writeFileSync(targetPath, output); if (isDev) { const hasCombinedArgs = Object.keys(combinedArgs).length > 0; const hasArgsPaths = argsFileUrls.length > 0; const devOutput = `--- TEMPLATE FILE templates/${templateFileDescriptor.source}${templateFileDescriptor.relativePath} --- ARGS FILES ${hasArgsPaths ? argsFileUrls.map(url => `\t- ${url.split("templates")[1] || url.split("externalExtensions")[1]}`).join("\n") : "(no args files writing to the template)"} --- RESULTING ARGS ${hasCombinedArgs ? Object.entries(combinedArgs) .map(([argName, argValue]) => `\t- ${argName}:\t[${argValue.join(",")}]`) // TODO improvement: figure out how to add the values added by each args file .join("\n") : "(no args sent for the template)"} `; fs.writeFileSync(`${targetPath}.dev`, devOutput); } })); }; const setUpExternalExtensionFiles = async (options, tmpDir) => { // 1. Create tmp directory to clone external extension await fs.promises.mkdir(tmpDir); const { repository, branch } = options.externalExtension; // 2. Clone external extension if (branch) { await execa("git", ["clone", "--branch", branch, repository, tmpDir], { cwd: tmpDir, }); } else { await execa("git", ["clone", repository, tmpDir], { cwd: tmpDir }); } }; async function copyTemplateFiles(options, templateDir, targetDir) { copyOrLink = options.dev ? linkRecursive : copy; const basePath = path.join(templateDir, BASE_DIR); const tmpDir = path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR); // 1. Copy base template to target directory await copyBaseFiles(basePath, targetDir, options); // 2. Copy solidity framework folder const solidityFrameworkPath = options.solidityFramework && getSolidityFrameworkPath(options.solidityFramework, templateDir); if (solidityFrameworkPath) { await copyExtensionFiles(options, solidityFrameworkPath, targetDir); } const exampleContractsPath = options.solidityFramework && path.resolve(templateDir, EXAMPLE_CONTRACTS_DIR, options.solidityFramework); // 3. Set up external extension if needed if (options.externalExtension) { let externalExtensionPath = path.join(tmpDir, "extension"); if (options.dev) { externalExtensionPath = path.join(templateDir, "../externalExtensions", options.externalExtension, "extension"); } else { await setUpExternalExtensionFiles(options, tmpDir); } if (options.solidityFramework) { const externalExtensionSolidityPath = path.join(externalExtensionPath, "packages", options.solidityFramework, "contracts"); // if external extension does not have solidity framework, we copy the example contracts if (!fs.existsSync(externalExtensionSolidityPath) && exampleContractsPath) { await copyExtensionFiles(options, exampleContractsPath, targetDir); } } await copyExtensionFiles(options, externalExtensionPath, targetDir); } const shouldCopyExampleContracts = !options.externalExtension && options.solidityFramework && exampleContractsPath; if (shouldCopyExampleContracts) { await copyExtensionFiles(options, exampleContractsPath, targetDir); } // 4. Process templated files and generate output await processTemplatedFiles(options, basePath, solidityFrameworkPath, shouldCopyExampleContracts ? exampleContractsPath : null, targetDir); // 5. Delete tmp directory if (options.externalExtension && !options.dev) { await fs.promises.rm(tmpDir, { recursive: true }); } // 6. Initialize git repo to avoid husky error await execa("git", ["init"], { cwd: targetDir }); await execa("git", ["checkout", "-b", "main"], { cwd: targetDir }); } async function createProjectDirectory(projectName) { const resolvedPath = path.resolve(projectName); if (fs.existsSync(resolvedPath)) { const files = fs.readdirSync(resolvedPath); if (files.length > 0) { throw new Error(`Directory ${resolvedPath} is not empty`); } return true; } try { const result = await execa("mkdir", [projectName]); if (result.failed) { throw new Error("There was a problem running the mkdir command"); } } catch (error) { throw new Error("Failed to create directory", { cause: error }); } return true; } async function installPackages(targetDir, task) { const execute = execaCommand("yarn install", { cwd: targetDir }); let outputBuffer = ""; const chunkSize = 1024; execute?.stdout?.on("data", (data) => { outputBuffer += data.toString(); if (outputBuffer.length > chunkSize) { outputBuffer = outputBuffer.slice(-1 * chunkSize); } const visibleOutput = outputBuffer .match(new RegExp(`.{1,${chunkSize}}`, "g")) ?.slice(-1) .map(chunk => chunk.trimEnd() + "\n") .join("") ?? outputBuffer; task.output = visibleOutput; if (visibleOutput.includes("Link step")) { task.output = chalk.yellow(`starting link step, this might take a little time...`); } }); execute?.stderr?.on("data", (data) => { outputBuffer += data.toString(); if (outputBuffer.length > chunkSize) { outputBuffer = outputBuffer.slice(-1 * chunkSize); } const visibleOutput = outputBuffer .match(new RegExp(`.{1,${chunkSize}}`, "g")) ?.slice(-1) .map(chunk => chunk.trimEnd() + "\n") .join("") ?? outputBuffer; task.output = visibleOutput; }); await execute; } var version = "2.0.23"; var packageJson = { version: version}; const foundryLibraries = ["foundry-rs/forge-std", "OpenZeppelin/openzeppelin-contracts", "gnsps/solidity-bytes-utils"]; const createEthVersion = packageJson.version; async function createFirstGitCommit(targetDir, options) { try { await execa("git", ["add", "-A"], { cwd: targetDir }); await execa("git", ["commit", "-m", `Initial commit with šŸ—ļø create-eth @ ${createEthVersion}`, "--no-verify"], { cwd: targetDir, }); if (options.solidityFramework === SOLIDITY_FRAMEWORKS.FOUNDRY) { const foundryWorkSpacePath = path.resolve(targetDir, "packages", SOLIDITY_FRAMEWORKS.FOUNDRY); // forge install foundry libraries await execa("forge", ["install", ...foundryLibraries], { cwd: foundryWorkSpacePath }); await execa("git", ["add", "-A"], { cwd: targetDir }); await execa("git", ["commit", "--amend", "--no-edit"], { cwd: targetDir }); } } catch (e) { // cast error as ExecaError to get stderr throw new Error("Failed to initialize git repository", { cause: e?.stderr ?? e, }); } } // TODO: Instead of using execa, use prettier package from cli to format targetDir async function prettierFormat(targetDir) { try { const result = await execa("yarn", ["format"], { cwd: targetDir }); if (result.failed) { throw new Error("There was a problem running the format command"); } } catch (error) { throw new Error("Failed to create directory", { cause: error }); } return true; } function renderOutroMessage(options) { let message = ` \n ${chalk.bold.green("Congratulations!")} Your project has been scaffolded! šŸŽ‰ ${chalk.bold("Next steps:")} ${options.project === "." ? "" : `${chalk.dim("cd")} ${options.project}`} `; if (!options.install) { message += ` \t${chalk.bold("Install dependencies & format files")} \t${chalk.dim("yarn")} install && ${chalk.dim("yarn")} format `; } if (options.solidityFramework === SOLIDITY_FRAMEWORKS.HARDHAT || options.solidityFramework === SOLIDITY_FRAMEWORKS.FOUNDRY) { message += ` \t${chalk.bold("Start the local development node")} \t${chalk.dim("yarn")} chain `; message += ` \t${chalk.bold("In a new terminal window, deploy your contracts")} \t${chalk.dim("yarn")} deploy `; } message += ` \t${chalk.bold("In a new terminal window, start the frontend")} \t${chalk.dim("yarn")} start `; message += ` ${chalk.bold.green("Thanks for using Scaffold-ETH 2 šŸ™, Happy Building!")} `; console.log(message); } async function createProject(options) { console.log(`\n`); const currentFileUrl = import.meta.url; const templateDirectory = path.resolve(decodeURI(fileURLToPath(currentFileUrl)), "../../templates"); const targetDirectory = path.resolve(process.cwd(), options.project); const tasks = new Listr([ { title: `šŸ“ Create project directory ${targetDirectory}`, task: () => createProjectDirectory(options.project), }, { title: `šŸš€ Creating a new Scaffold-ETH 2 app in ${chalk.green.bold(options.project)}${options.externalExtension ? ` with the ${chalk.green.bold(options.dev ? options.externalExtension : getArgumentFromExternalExtensionOption(options.externalExtension))} extension` : ""}`, task: () => copyTemplateFiles(options, templateDirectory, targetDirectory), }, { title: "šŸ“¦ Installing dependencies with yarn, this could take a while", task: (_, task) => installPackages(targetDirectory, task), skip: () => { if (!options.install) { return "Manually skipped, since `--skip-install` flag was passed"; } return false; }, rendererOptions: { outputBar: 8, persistentOutput: false, }, }, { title: "šŸŖ„ Formatting files", task: () => prettierFormat(targetDirectory), skip: () => { if (!options.install) { return "Can't use source prettier, since `yarn install` was skipped"; } return false; }, }, { title: `šŸ“” Initializing Git repository${options.solidityFramework === SOLIDITY_FRAMEWORKS.FOUNDRY ? " and submodules" : ""}`, task: () => createFirstGitCommit(targetDirectory, options), }, ], { rendererOptions: { collapseSkips: false, suffixSkips: true } }); await tasks.run(); renderOutroMessage(options); } function validateNpmName(name) { const nameValidation = validateProjectName(basename(resolve(name))); if (nameValidation.validForNewPackages) { return { valid: true }; } return { valid: false, problems: [...(nameValidation.errors || []), ...(nameValidation.warnings || [])], }; } // TODO update smartContractFramework code with general extensions async function parseArgumentsIntoOptions(rawArgs) { const args = arg({ "--skip-install": Boolean, "--skip": "--skip-install", "--dev": Boolean, "--solidity-framework": solidityFrameworkHandler, "-s": "--solidity-framework", "--extension": String, "-e": "--extension", "--help": Boolean, "-h": "--help", }, { argv: rawArgs.slice(2), }); const skipInstall = args["--skip-install"] ?? null; const dev = args["--dev"] ?? false; // info: use false avoid asking user const help = args["--help"] ?? false; let project = args._[0] ?? null; // use the original extension arg const extensionName = args["--extension"]; // ToDo. Allow multiple const extension = extensionName ? await validateExternalExtension(extensionName, dev) : null; // if dev mode, extension would be a string if (extension && typeof extension === "object" && !extension.isTrusted) { console.log(chalk.yellow(` You are using a third-party extension. Make sure you trust the source of ${chalk.yellow.bold(extension.repository)}\n`)); } // Check if extension createEthVersion matches current version if (extension && typeof extension === "object" && extension.recommendedCreateEthVersion) { const currentVersion = packageJson.version; if (extension.recommendedCreateEthVersion !== currentVersion) { console.log(chalk.yellow(`\nāš ļø This extension requires create-eth ${chalk.bold(`v${extension.recommendedCreateEthVersion}`)}, but you're running ${chalk.bold(`v${currentVersion}`)}.\n`)); const switchVersion = await confirm({ message: `Would you like to run with the correct version (${extension.recommendedCreateEthVersion})?`, default: true, }); if (switchVersion) { console.log(chalk.gray(`\nSwitching to create-eth@${extension.recommendedCreateEthVersion}...\n`)); await execa("npx", [`create-eth@${extension.recommendedCreateEthVersion}`, ...rawArgs.slice(2)], { stdio: "inherit", }); process.exit(0); } const proceed = await confirm({ message: "Do you want to proceed with the current version anyway?", default: false, }); if (!proceed) { console.log(chalk.gray("\nSetup cancelled. No project was created")); process.exit(0); } } } if (project) { const validation = validateNpmName(project); if (!validation.valid) { console.error(`Could not create a project called ${chalk.yellow(`"${project}"`)} because of naming restrictions:`); validation.problems.forEach(p => console.error(`${chalk.red(">>")} Project ${p}`)); project = null; } } let solidityFrameworkChoices = [ SOLIDITY_FRAMEWORKS.HARDHAT, SOLIDITY_FRAMEWORKS.FOUNDRY, { value: null, name: "none" }, ]; if (extension) { const externalExtensionSolidityFrameworkDirs = await getSolidityFrameworkDirsFromExternalExtension(extension); if (externalExtensionSolidityFrameworkDirs.length !== 0) { solidityFrameworkChoices = externalExtensionSolidityFrameworkDirs; } } // if length is 1, we don't give user a choice and set it ourselves. const solidityFramework = solidityFrameworkChoices.length === 1 ? solidityFrameworkChoices[0] : (args["--solidity-framework"] ?? null); return { rawOptions: { project, install: !skipInstall, dev, externalExtension: extension, help, solidityFramework: solidityFramework, }, solidityFrameworkChoices, }; } const SOLIDITY_FRAMEWORK_OPTIONS = [...Object.values(SOLIDITY_FRAMEWORKS), "none"]; function solidityFrameworkHandler(value) { const lowercasedValue = value.toLowerCase(); if (SOLIDITY_FRAMEWORK_OPTIONS.includes(lowercasedValue)) { return lowercasedValue; } // choose from cli prompts return null; } const defaultOptions = { project: "my-dapp-example", dev: false}; async function promptForMissingOptions(options, solidityFrameworkChoices) { const project = options.project ?? (await input({ message: "Your project name:", default: defaultOptions.project, validate: (name) => { const validation = validateNpmName(name); if (validation.valid) { return true; } return "Project " + validation.problems[0]; }, })); const solidityFramework = options.solidityFramework ?? (await select({ message: "What solidity framework do you want to use?", choices: solidityFrameworkChoices.map(choice => typeof choice === "string" ? { value: choice, name: choice } : choice), default: SOLIDITY_FRAMEWORKS.HARDHAT, })); const mergedOptions = { project, install: options.install, dev: options.dev ?? defaultOptions.dev, solidityFramework: solidityFramework === "none" ? null : solidityFramework, externalExtension: options.externalExtension, }; return mergedOptions; } const TITLE_TEXT = ` ${chalk.bold.blue("+-+-+-+-+-+-+-+-+-+-+-+-+-+-+")} ${chalk.bold.blue("| Create Scaffold-ETH 2 app |")} ${chalk.bold.blue("+-+-+-+-+-+-+-+-+-+-+-+-+-+-+")} `; function renderIntroMessage() { console.log(TITLE_TEXT); } const REQUIRED_FOUNDRY_VERSION = "1.4.0"; // Custom error for Foundry validation class FoundryValidationError extends Error { constructor(message) { super(message); this.name = "FoundryValidationError"; } } const validateFoundry = async () => { let versionMatch = null; // Check if forge is installed try { const { stdout: forgeVersion } = await execa("forge", ["--version"]); // Extract version from output like "forge Version: 1.4.3-stable" versionMatch = forgeVersion.match(/forge Version: (\d+\.\d+\.\d+)/); if (!versionMatch) { throw new Error(); } } catch { const message = ` ${chalk.bold.yellow("Could not parse foundry version.")} ${chalk.bold.yellow("Please ensure foundry is properly installed")} ${chalk.bold.yellow("Checkout: https://getfoundry.sh")} `; throw new FoundryValidationError(message); } // Parse and validate version try { const version = versionMatch[1]; if (semver.lt(version, REQUIRED_FOUNDRY_VERSION)) { const message = ` ${chalk.bold.yellow("Foundry version is older than required.")} ${chalk.bold.yellow(`Current version: ${version}, required: >= ${REQUIRED_FOUNDRY_VERSION}`)} ${chalk.bold.yellow("Please update foundry by running: foundryup")} ${chalk.bold.yellow("Checkout: https://getfoundry.sh")} `; throw new FoundryValidationError(message); } } catch (error) { // Re-throw custom validation errors if (error instanceof FoundryValidationError) { throw error; } throw new Error("Unknown error occurred while validating Foundry version"); } }; const checkSystemRequirements = async () => { const errors = []; try { const { stdout: nodeVersion } = await execa("node", ["--version"]); const cleanNodeVersion = nodeVersion.replace("v", ""); if (semver.lt(cleanNodeVersion, "20.18.3")) { errors.push(`Node.js version must be >= 20.18.3. Current version: ${nodeVersion}`); } } catch { errors.push("Node.js is not installed. Please install Node.js >= 20.18.3"); } try { const { stdout: yarnVersion } = await execa("yarn", ["--version"]); if (semver.lt(yarnVersion, "1.0.0")) { errors.push(`Yarn version should be >= 1.0.0. Recommended version is >= 2.0.0. Current version: ${yarnVersion}`); } } catch { errors.push("Yarn is not installed. Please install Yarn >= 1.0.0. Recommended version is >= 2.0.0"); } try { await execa("git", ["--version"]); try { await execa("git", ["config", "user.name"]); } catch { errors.push("Git user.name is not configured. Please set it using: git config --global user.name 'Your Name'"); } try { await execa("git", ["config", "user.email"]); } catch { errors.push("Git user.email is not configured. Please set it using: git config --global user.email 'your.email@example.com'"); } } catch { errors.push("Git is not installed. Please install Git"); } return { errors }; }; const showHelpMessage = () => { console.log(` ${chalk.bold.blue("Usage:")} ${chalk.bold.green("npx create-eth<@version>")} ${chalk.gray("[--skip | --skip-install] [-s <solidity-framework> | --solidity-framework <solidity-framework>] [-e <extension> | --extension <extension>] [-h | --help]")} `); console.log(` ${chalk.bold.blue("Options:")} ${chalk.gray("--skip, --skip-install")} Skip packages installation ${chalk.gray("-s, --solidity-framework")} Choose solidity framework ${chalk.gray("-e, --extension")} Add curated or third-party extension ${chalk.gray("-h, --help")} Help `); }; async function cli(args) { try { renderIntroMessage(); const { errors } = await checkSystemRequirements(); if (errors.length > 0) { console.log(chalk.red("\nāŒ Create-eth requirements not met:")); errors.forEach(error => console.log(chalk.red(` - ${error}`))); process.exit(1); } const { rawOptions, solidityFrameworkChoices } = await parseArgumentsIntoOptions(args); if