@consensys/create-web3-app
Version:
CLI tool for generating Web3 starter projects, streamlining the setup of monorepo structures with a frontend (Next.js or React) and blockchain tooling (HardHat or Foundry). It leverages the commander library for command-line interactions and guides users
563 lines (497 loc) • 17.6 kB
text/typescript
import { exec } from "child_process";
import { promises as fs, constants as fsConstants } from "fs";
import {
BLOCKCHAIN_TOOLING_CHOICES,
PACAKGE_MANAGER_CHOICES,
TEMPLATES,
isDegitTemplate,
isGitTemplate,
CLI_VERSION,
isGitAvailable,
} from "../constants/index.js";
import path from "path";
import util from "util";
import inquirer from "inquirer";
import degit from "degit";
import ora, { Ora } from "ora";
import chalk from "chalk";
import { identifyRun, track, flush } from "../analytics/index.js";
export const execAsync = util.promisify(exec);
const promptForFramework = async (): Promise<string> => {
const templateChoices = TEMPLATES.map((template) => {
if (template.id === "metamask-nextjs-wagmi") {
return {
name: `${template.name} ${chalk.hex("#FFA500")("(Recommended)")}`,
value: template.name,
};
}
return template.name;
});
const { frameworkName }: { frameworkName: string } = await inquirer.prompt([
{
type: "list",
name: "frameworkName",
message: "Please select the template you want to use:",
choices: templateChoices,
},
]);
console.log(`Selected template: ${frameworkName}`);
const selectedTemplate = TEMPLATES.find(
(template) => template.name === frameworkName
);
if (!selectedTemplate) {
throw new Error(
`Internal error: Could not find template data for selected name "${frameworkName}"`
);
}
return selectedTemplate.id;
};
const promptForBlockchainTooling = async (): Promise<string> => {
const toolingChoice = BLOCKCHAIN_TOOLING_CHOICES.map((choice) => choice.name);
const { tooling }: { tooling: string } = await inquirer.prompt([
{
type: "list",
name: "tooling",
message: "Would you like to include blockchain tooling?",
choices: toolingChoice,
},
]);
console.log(`Selected tooling: ${tooling}`);
if (tooling === "Foundry") {
console.log(
chalk.yellow(
"\nNote: Foundry's 'forge' CLI must be installed and in your PATH to use this option."
)
);
}
return tooling;
};
const promptForPackageManager = async (): Promise<string> => {
const packageManagerChoice = PACAKGE_MANAGER_CHOICES.map(
(choice) => choice.name
);
const { packageManager }: { packageManager: string } = await inquirer.prompt([
{
type: "list",
name: "packageManager",
message: "Please select the package manager you want to use:",
choices: packageManagerChoice,
},
]);
console.log(`Selected package manager: ${packageManager}`);
if (packageManager === "pnpm") {
let pnpmAvailable = true;
try {
await execAsync("pnpm -v");
} catch {
pnpmAvailable = false;
}
if (!pnpmAvailable) {
console.log(
chalk.yellow("pnpm is not installed or not found in your PATH.")
);
const { installPnpmNow } = await inquirer.prompt([
{
type: "confirm",
name: "installPnpmNow",
message: "Would you like to install pnpm globally using npm now?",
default: true,
},
]);
if (installPnpmNow) {
try {
console.log(chalk.blue("Installing pnpm globally via npm..."));
await execAsync("npm install -g pnpm");
console.log(chalk.green("pnpm installed successfully."));
} catch (installError) {
throw new Error(
"Failed to install pnpm automatically. Please install it manually and re-run the command."
);
}
} else {
throw new Error(
"pnpm installation declined. Please install pnpm manually or choose a different package manager."
);
}
}
}
return packageManager;
};
const promptForProjectDetails = async (args: string): Promise<string> => {
if (!args) {
const { projectName } = await inquirer.prompt([
{
type: "input",
name: "projectName",
message: "Please specify a name for your project: ",
validate: (input) => {
if (!input) {
return "Project name cannot be empty";
}
const kebabCaseRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
if (!kebabCaseRegex.test(input)) {
return "Project name must be in kebab-case (e.g., my-awesome-project)";
}
return true;
},
},
]);
console.log("Creating project with name:", projectName);
return projectName;
}
return args;
};
export interface ProjectOptions {
projectName: string;
templateId: string;
blockchain_tooling: "hardhat" | "foundry" | "none";
packageManager: "npm" | "yarn" | "pnpm";
dynamicEnvId?: string;
}
export const promptForOptions = async (
args: string
): Promise<ProjectOptions> => {
const projectName = await promptForProjectDetails(args);
const templateId = await promptForFramework();
const tooling = await promptForBlockchainTooling();
const packageManager = await promptForPackageManager();
let dynamicEnvId: string | undefined = undefined;
if (templateId === "metamask-dynamic") {
const { addDynamicIdNow } = await inquirer.prompt([
{
type: "confirm",
name: "addDynamicIdNow",
message: `The selected template uses Dynamic.xyz. You'll need a Dynamic Environment ID added to a .env file. Would you like to add it now? You can get one from https://app.dynamic.xyz/dashboard/developer/api`,
default: true,
},
]);
if (addDynamicIdNow) {
const { providedDynamicId } = await inquirer.prompt([
{
type: "password",
name: "providedDynamicId",
message: "Please paste your Dynamic Environment ID:",
mask: "*",
validate: (input) =>
input ? true : "Dynamic Environment ID cannot be empty",
},
]);
dynamicEnvId = providedDynamicId;
console.log("Dynamic Environment ID received.");
} else {
console.log(
chalk.yellow(
"Okay, please remember to add NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=<your_id> to the .env file in your site's directory later."
)
);
}
} else if (templateId === "metamask-web3auth") {
console.log(
chalk.yellow(
"\nNote: The selected template requires a Web3Auth client ID. You can obtain one from https://dashboard.web3auth.io/ and later add NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=<your_id> to a .env file in your site's directory."
)
);
}
const options: ProjectOptions = {
projectName: projectName,
templateId: templateId,
blockchain_tooling: BLOCKCHAIN_TOOLING_CHOICES.find(
(choice) => choice.name === tooling
)?.value as ProjectOptions["blockchain_tooling"],
packageManager: PACAKGE_MANAGER_CHOICES.find(
(choice) => choice.name === packageManager
)?.value as ProjectOptions["packageManager"],
dynamicEnvId: dynamicEnvId,
};
if (!TEMPLATES.some((t) => t.id === options.templateId)) {
throw new Error(`Invalid template ID resolved: ${options.templateId}`);
}
return options;
};
export const cloneTemplate = async (
options: Pick<
ProjectOptions,
"templateId" | "projectName" | "dynamicEnvId" | "blockchain_tooling"
>,
destinationPath: string
) => {
const { templateId, projectName, dynamicEnvId, blockchain_tooling } = options;
const template = TEMPLATES.find((t) => t.id === templateId);
if (!template) {
throw new Error(`Template with id "${templateId}" not found.`);
}
const spinner = ora(
`Preparing template "${template.name}" into ${destinationPath}...`
).start();
if (!(await isGitAvailable())) {
spinner.fail("Git is not installed or not found in your PATH.");
track("git_not_installed", {
destination_path: destinationPath,
template_id: templateId,
});
throw new Error(
"Git is required to clone templates. Please install Git (https://git-scm.com/downloads) and try again."
);
}
try {
if (isDegitTemplate(template)) {
spinner.text = `Cloning template "${template.name}" from ${template.degitSource} using degit...`;
const emitter = degit(template.degitSource, {
cache: false,
force: true,
verbose: false,
});
await emitter.clone(destinationPath);
} else if (isGitTemplate(template)) {
spinner.text = `Cloning template "${template.name}" from ${template.repo_url} using git...`;
await execAsync(`git clone ${template.repo_url} ${destinationPath}`);
await fs.rm(path.join(destinationPath, ".git"), {
recursive: true,
force: true,
});
} else {
spinner.fail(`Template preparation failed.`);
throw new Error(`Template has neither repo_url nor degitSource defined.`);
}
const packageJsonPath = path.join(destinationPath, "package.json");
try {
spinner.text = `Updating package name to ${path.basename(
projectName
)}...`;
const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
packageJson.name = path.basename(projectName);
if (blockchain_tooling !== "none") {
packageJson.name = `site`;
}
const newPackageJsonContent = JSON.stringify(packageJson, null, 2);
await fs.writeFile(packageJsonPath, newPackageJsonContent, "utf-8");
} catch (pkgError) {
console.warn(
`Warning: Could not update package.json name in ${destinationPath}. Manual update might be needed. Error: ${
pkgError instanceof Error ? pkgError.message : pkgError
}`
);
}
if (dynamicEnvId) {
spinner.text = `Creating .env file with Dynamic Environment ID...`;
const envContent = `NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=${dynamicEnvId}\n`;
const envPath = path.join(destinationPath, ".env");
await fs.writeFile(envPath, envContent, "utf-8");
spinner.text = `.env file created successfully.`;
}
spinner.succeed(
`Template "${template.name}" prepared successfully in ${destinationPath}.`
);
} catch (error) {
spinner.fail(`Error preparing template "${template.name}".`);
console.error(`Error details:`, error);
throw error;
}
};
export const initializeMonorepo = async (options: ProjectOptions) => {
const { projectName, packageManager } = options;
console.log("Initializing monorepo structure...");
await fs.mkdir(path.join(projectName, "packages"), { recursive: true });
if (packageManager === "pnpm") {
await fs.writeFile(
path.join(projectName, "pnpm-workspace.yaml"),
`packages:\n - 'packages/*'`
);
}
await fs.writeFile(
path.join(projectName, ".gitignore"),
`node_modules\n.DS_Store\npackages/*/node_modules\npackages/*/.DS_Store\npackages/*/dist\npackages/*/.env\npackages/*/.turbo\npackages/*/coverage`
);
const rootPackageJson = {
name: projectName,
private: true,
workspaces: ["packages/*"],
scripts: {},
};
await fs.writeFile(
path.join(projectName, "package.json"),
JSON.stringify(rootPackageJson, null, 2)
);
await fs.mkdir(path.join(projectName, "packages", "site"), {
recursive: true,
});
console.log("Monorepo structure initialized.");
};
export const createHardhatProject = async (options: ProjectOptions) => {
const { projectName, templateId } = options;
console.log("Setting up project with HardHat...");
await initializeMonorepo(options);
console.log("Cloning Hardhat template...");
await execAsync(
`git clone https://github.com/Consensys/hardhat-template.git ${path.join(
projectName,
"packages",
"blockchain"
)}`
);
await fs.rm(path.join(projectName, "packages", "blockchain", ".git"), {
recursive: true,
force: true,
});
await cloneTemplate(
{
templateId,
projectName,
dynamicEnvId: options.dynamicEnvId,
blockchain_tooling: "hardhat",
},
path.join(projectName, "packages", "site")
);
console.log("Hardhat project setup complete.");
};
export const createFoundryProject = async (
options: ProjectOptions,
spinner?: Ora
) => {
const { projectName, templateId } = options;
let forgeAvailable = true;
try {
await execAsync("forge --version");
console.log(
chalk.green(
"Foundry (forge) installation verified. Proceeding with setup..."
)
);
} catch (error) {
forgeAvailable = false;
}
if (!forgeAvailable) {
spinner?.stop();
console.log(
chalk.yellow(
"Looks like Foundry is not installed or not found in your PATH."
)
);
const { switchToHardhat } = await inquirer.prompt([
{
type: "confirm",
name: "switchToHardhat",
message: "Would you like to switch to Hardhat instead?",
default: true,
},
]);
track("foundry_not_installed", {
attempted_blockchain_tooling: "foundry",
switched_to_hardhat: switchToHardhat,
});
if (switchToHardhat) {
console.log(chalk.blue("Switching to Hardhat setup..."));
Object.assign(options, { blockchain_tooling: "hardhat" as const });
if (spinner) {
spinner.text = "Creating Hardhat project structure...";
spinner.start();
}
await createHardhatProject(options);
return;
}
spinner?.start();
throw new Error(
"Forge (Foundry) is not installed or not found in your PATH. Please install it to continue.\nInstallation guide: https://book.getfoundry.sh/getting-started/installation"
);
}
console.log("Setting up project with Foundry...");
await initializeMonorepo(options);
console.log("Initializing Foundry project with 'forge init'...");
const blockchainPath = path.join(projectName, "packages", "blockchain");
await fs.mkdir(blockchainPath, { recursive: true });
await execAsync(`cd ${blockchainPath} && foundryup && forge init . --no-git`);
await cloneTemplate(
{
templateId,
projectName,
dynamicEnvId: options.dynamicEnvId,
blockchain_tooling: "foundry",
},
path.join(projectName, "packages", "site")
);
console.log("Foundry project setup complete.");
};
export const createProject = async (args: string) => {
try {
await fs.access(process.cwd(), fsConstants.W_OK);
} catch {
console.error(
chalk.red(
"The directory you're in is read-only for your user. Please cd to a writable folder (e.g. your home directory) or run the terminal as Administrator."
)
);
track("cwd_not_writable", {
cwd: process.cwd(),
});
return;
}
identifyRun();
const options = await promptForOptions(args);
const installCommand = `${options.packageManager} install`;
const mainSpinner = ora("Setting up your Web3 project...").start();
const t0 = Date.now();
try {
track("cli_started", {
cli_version: CLI_VERSION,
});
if (options.blockchain_tooling === "hardhat") {
mainSpinner.text = "Creating Hardhat project structure...";
await createHardhatProject(options);
} else if (options.blockchain_tooling === "foundry") {
mainSpinner.text = "Creating Foundry project structure...";
await createFoundryProject(options, mainSpinner);
} else {
mainSpinner.text = "Cloning base template...";
await cloneTemplate(
{
templateId: options.templateId,
projectName: options.projectName,
dynamicEnvId: options.dynamicEnvId,
blockchain_tooling: "none",
},
options.projectName
);
}
mainSpinner.text = `Installing dependencies using ${options.packageManager}... (This may take a few minutes)`;
const projectPath = options.projectName;
await execAsync(`cd ${projectPath} && ${installCommand}`);
mainSpinner.succeed("Project setup complete!");
console.log(`\nSuccess! Created ${options.projectName}.`);
console.log("Inside that directory, you can run several commands:");
if (options.blockchain_tooling !== "none") {
console.log(`\n In the root directory (${options.projectName}):`);
console.log(` ${options.packageManager} run dev`);
console.log(" Runs the frontend development server.");
console.log(`\n In packages/blockchain:`);
console.log(` ${options.packageManager} run compile`);
console.log(" Compiles the smart contracts.");
console.log(` ${options.packageManager} run test`);
console.log(" Runs the contract tests.");
console.log(`\n In packages/site:`);
console.log(` ${options.packageManager} run dev`);
console.log(" Runs the frontend development server.");
} else {
console.log(`\n cd ${options.projectName} && ${options.packageManager} run dev`);
console.log(" Starts the development server.");
}
track("project_created", {
template_id: options.templateId,
blockchain_tooling: options.blockchain_tooling,
package_manager: options.packageManager,
dynamic_env: Boolean(options.dynamicEnvId),
exec_time_ms: Date.now() - t0,
});
console.log("\nHappy Hacking!");
} catch (error) {
mainSpinner.fail("An error occurred during project creation.");
track("project_creation_failed", {
template_id: options?.templateId,
blockchain_tooling: options?.blockchain_tooling,
error_message: (error as Error).message,
});
console.error("Error details:", error);
} finally {
flush();
}
};