create-eth
Version:
Create a Scaffold-ETH-2 app
1,139 lines (1,109 loc) ⢠50.6 kB
JavaScript
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