UNPKG

hardhat

Version:

Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.

640 lines (560 loc) 22.5 kB
import type { Template } from "./template.js"; import type { PackageJson } from "@nomicfoundation/hardhat-utils/package"; import path from "node:path"; import { assertHardhatInvariant, HardhatError, } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { copy, ensureDir, exists, getAllFilesMatching, isDirectory, mkdir, readJsonFile, writeJsonFile, } from "@nomicfoundation/hardhat-utils/fs"; import { resolveFromRoot } from "@nomicfoundation/hardhat-utils/path"; import chalk from "chalk"; import debug from "debug"; import * as semver from "semver"; import { findClosestHardhatConfig } from "../../config-loading.js"; import { HARDHAT_NAME } from "../../constants.js"; import { getHardhatVersion, getLatestHardhatVersion, } from "../../utils/package.js"; import { sendProjectTypeAnalytics } from "../telemetry/analytics/analytics.js"; import { sendErrorTelemetry } from "../telemetry/sentry/reporter.js"; import { getDevDependenciesInstallationCommand, getPackageManager, installsPeerDependenciesByDefault, } from "./package-manager.js"; import { promptForMigrateToEsm, promptForForce, promptForInstall, promptForTemplate, promptForUpdate, promptForWorkspace, promptForHardhatVersion, } from "./prompt.js"; import { spawn } from "./subprocess.js"; import { getTemplates } from "./template.js"; export interface InitHardhatOptions { hardhatVersion?: "hardhat-2" | "hardhat-3"; workspace?: string; migrateToEsm?: boolean; template?: string; force?: boolean; install?: boolean; } const log = debug("hardhat:cli:init"); /** * initHardhat implements the project initialization wizard flow. * * It can be called with the following options: * - workspace: The path to the workspace to initialize the project in. * If not provided, the user will be prompted to select the workspace. * - template: The name of the template to use for the project initialization. * If not provided, the user will be prompted to select the template. * - force: Whether to overwrite existing files in the workspace. * If not provided and there are files that would be overwritten, * the user will be prompted to confirm. * - install: Whether to install the project dependencies. * If not provided and there are dependencies that should be installed, * the user will be prompted to confirm. * * The flow is as follows: * 1. Print the ascii logo. * 2. Print the welcome message. * 3. Optionally, ask the user for the workspace to initialize the project in. * 4. Validate that the package.json exists; otherwise, create it. * 5. Validate that the package.json is an esm package; otherwise, ask the user if they want to set it. * 6. Optionally, ask the user for the template to use for the project initialization. * 7. Optionally, ask the user if files should be overwritten. * 8. Copy the template files to the workspace. * 9. Ensure telemetry consent. * 10. Print the commands to install the project dependencies. * 11. Optionally, ask the user if the project dependencies should be installed. * 12. Optionally, run the commands to install the project dependencies. * 13. Print a message to star the project on GitHub. */ export async function initHardhat(options?: InitHardhatOptions): Promise<void> { try { printAsciiLogo(); await printWelcomeMessage(); const hardhatVersion = options?.hardhatVersion ?? (await promptForHardhatVersion()); // Ask the user for the workspace to initialize the project in // if it was not provided, and validate that it is not already initialized const workspace = await getWorkspace(options?.workspace); // Ask the user for the template to use for the project initialization // if it was not provided, and validate that it exists const [template, projectTypeAnalyticsPromise] = await getTemplate( hardhatVersion, options?.template, ); // Create the package.json file if it does not exist // and validate that it is an esm package await validatePackageJson( workspace, template.packageJson, options?.migrateToEsm, ); // Copy the template files to the workspace // Overwrite existing files only if the user opts-in to it await copyProjectFiles(workspace, template, options?.force); // Print the commands to install the project dependencies // Run them only if the user opts-in to it // Concurrently, await the analytics hit await Promise.all([ installProjectDependencies(workspace, template, options?.install), projectTypeAnalyticsPromise, ]); showStarOnGitHubMessage(); } catch (e) { if (e === "") { // If the user cancels any prompt, we quit silently return; } throw e; } } // generated based on the "DOS Rebel" font function printAsciiLogo() { const logoLines = ` █████ █████ ███ ███ ███ ██████ ░░███ ░░███ ░███ ░███ ░███ ███░░███ ░███ ░███ ██████ ████████ ███████ ░███████ ██████ ███████ ░░░ ░███ ░██████████ ░░░░░███░░███░░███ ███░░███ ░███░░███ ░░░░░███░░░███░ ████░ ░███░░░░███ ███████ ░███ ░░░ ░███ ░███ ░███ ░███ ███████ ░███ ░░░░███ ░███ ░███ ███░░███ ░███ ░███ ░███ ░███ ░███ ███░░███ ░███ ███ ███ ░███ █████ █████░░███████ █████ ░░███████ ████ █████░░███████ ░░█████ ░░██████ ░░░░░ ░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░ ░░░░ ░░░░░ ░░░░░░░ ░░░░░ ░░░░░░ `; // Print an ansi escape sequence to disable auto-wrapping of text in case the // logo doesn't fit process.stdout.write("\x1b[?7l"); console.log(chalk.blue(logoLines)); // Re-enable auto-wapping process.stdout.write("\x1b[?7h"); } // NOTE: This function is exported for testing purposes export async function printWelcomeMessage(): Promise<void> { const hardhatVersion = await getHardhatVersion(); console.log( chalk.cyan(`👷 Welcome to ${HARDHAT_NAME} v${hardhatVersion} 👷\n`), ); // Warn the user if they are using an outdated version of Hardhat try { const latestHardhatVersion = await getLatestHardhatVersion(); if (hardhatVersion !== latestHardhatVersion) { console.warn( chalk.yellow.bold( `⚠️ You are using an outdated version of Hardhat. The latest version is v${latestHardhatVersion}. Please consider upgrading to the latest version before continuing with the project initialization. ⚠️\n`, ), ); } } catch (error) { ensureError(error); try { await sendErrorTelemetry(error); } catch (e) { log("Couldn't report error to sentry: %O", e); } console.warn( chalk.yellow.bold( `⚠️ We couldn't check if you are using the latest version of Hardhat. Please consider upgrading to the latest version if you are not using it yet. ⚠️\n`, ), ); } } /** * getWorkspace asks the user for the workspace to initialize the project in * if the input workspace is undefined. * * It also validates that the workspace is not already initialized. * * NOTE: This function is exported for testing purposes * * @param workspace The path to the workspace to initialize the project in. * @returns The path to the workspace. */ export async function getWorkspace(workspace?: string): Promise<string> { // Ask the user for the workspace to initialize the project in if it was not provided if (workspace === undefined) { workspace = await promptForWorkspace(); } workspace = resolveFromRoot(process.cwd(), workspace); if ((await exists(workspace)) && !(await isDirectory(workspace))) { throw new HardhatError( HardhatError.ERRORS.CORE.GENERAL.WORKSPACE_MUST_BE_A_DIRECTORY, { workspace, }, ); } // If the path points to a non-existent folder, create it; otherwise, do nothing await mkdir(workspace); // Validate that the workspace is not already initialized try { const configFilePath = await findClosestHardhatConfig(workspace); throw new HardhatError( HardhatError.ERRORS.CORE.GENERAL.HARDHAT_PROJECT_ALREADY_CREATED, { hardhatProjectRootPath: configFilePath, }, ); } catch (err) { if ( HardhatError.isHardhatError(err) && err.number === HardhatError.ERRORS.CORE.GENERAL.NO_CONFIG_FILE_FOUND.number ) { // If a configuration file is not found, it is possible to initialize a new project, // hence continuing code execution return workspace; } throw err; } } /** * getTemplate asks the user for the template to use for the project initialization * if the input template is undefined. * * It also validates that the template exists. * * NOTE: This function is exported for testing purposes * * @param template The name of the template to use for the project initialization. * @returns A tuple with two elements: the template and a promise with the analytics hit. */ export async function getTemplate( hardhatVersion: "hardhat-2" | "hardhat-3", template?: string, ): Promise<[Template, Promise<boolean>]> { const templates = await getTemplates(hardhatVersion); // Ask the user for the template to use for the project initialization if it was not provided if (template === undefined) { template = await promptForTemplate(templates); } const projectTypeAnalyticsPromise = sendProjectTypeAnalytics( hardhatVersion, template, ); // Validate that the template exists for (const t of templates) { if (t.name === template) { return [t, projectTypeAnalyticsPromise]; } } // we wait for the GA hit before throwing await projectTypeAnalyticsPromise; throw new HardhatError(HardhatError.ERRORS.CORE.GENERAL.TEMPLATE_NOT_FOUND, { template, }); } /** * validatePackageJson creates the package.json file if it does not exist * in the workspace. * * It also validates that the package.json file is an esm package. * * NOTE: This function is exported for testing purposes * * @param workspace The path to the workspace to initialize the project in. */ export async function validatePackageJson( workspace: string, templatePkg: PackageJson, migrateToEsm?: boolean, ): Promise<void> { const absolutePathToPackageJson = path.join(workspace, "package.json"); const shouldUseEsm = templatePkg.type === "module"; // Create the package.json file if it does not exist if (!(await exists(absolutePathToPackageJson))) { const packageJson: PackageJson = { name: path.basename(workspace), version: "1.0.0", }; if (shouldUseEsm) { packageJson.type = "module"; } await writeJsonFile(absolutePathToPackageJson, packageJson); } const packageManager = getPackageManager(); // We know this works with npm, pnpm, but not with yarn. If, so we use // pnpm or npm exclusively. // If you read this comment and wonder if this is outdated, you can // answer it by checking if the most popular versions of yarn and other // package managers support `<package manager> pkg set type=module`. const packageManagerToUse = packageManager === "pnpm" ? "pnpm" : "npm"; // We need to set the hardhat version in the package.json file // to ensure that the template is compatible with the current Hardhat version. // This is needed because we have hardhat-2 and hardhat-3 templates, // and the user may install hardhat 3 first and then initialize a project // with a hardhat-2 template. const templateHardhatVersion = templatePkg.devDependencies?.hardhat ?? ""; if (templateHardhatVersion.startsWith("^2")) { await spawn( [packageManagerToUse, "pkg", "delete", "dependencies.hardhat"].join(" "), [], { cwd: workspace, shell: true, stdio: "inherit", }, ); } if (!shouldUseEsm) { return; } const pkg: PackageJson = await readJsonFile(absolutePathToPackageJson); // Validate that the package.json file is an esm package if (pkg.type === "module") { return; } if (migrateToEsm === undefined) { migrateToEsm = await promptForMigrateToEsm(absolutePathToPackageJson); } if (!migrateToEsm) { throw new HardhatError(HardhatError.ERRORS.CORE.GENERAL.ONLY_ESM_SUPPORTED); } await spawn( [packageManagerToUse, "pkg", "set", "type=module"].join(" "), [], { cwd: workspace, shell: true, stdio: "inherit", }, ); } /** * The following two functions are used to convert between relative workspace * and template paths. To begin with, they are used to handle the special case * of .gitignore. * * The reason for this is that npm ignores .gitignore files * during npm pack (see https://github.com/npm/npm/issues/3763). That's why when * we encounter a gitignore file in the template, we assume that it should be * called .gitignore in the workspace (and vice versa). * * They are exported for testing purposes only. */ export function relativeWorkspaceToTemplatePath(file: string): string { if (path.basename(file) === ".gitignore") { return path.join(path.dirname(file), "gitignore"); } return file; } export function relativeTemplateToWorkspacePath(file: string): string { if (path.basename(file) === "gitignore") { return path.join(path.dirname(file), ".gitignore"); } return file; } /** * copyProjectFiles copies the template files to the workspace. * * If there are clashing files in the workspace, they will be overwritten only * if the force option is true or if the user opts-in to it. * * NOTE: This function is exported for testing purposes * * @param workspace The path to the workspace to initialize the project in. * @param template The template to use for the project initialization. * @param force Whether to overwrite existing files in the workspace. */ export async function copyProjectFiles( workspace: string, template: Template, force?: boolean, ): Promise<void> { // Find all the files in the workspace that would have been overwritten by the template files const matchingRelativeWorkspacePaths = await getAllFilesMatching( workspace, (file) => { const relativeWorkspacePath = path.relative(workspace, file); const relativeTemplatePath = relativeWorkspaceToTemplatePath( relativeWorkspacePath, ); return template.files.includes(relativeTemplatePath); }, ).then((files) => files.map((f) => path.relative(workspace, f))); // Ask the user for permission to overwrite existing files if needed if (matchingRelativeWorkspacePaths.length !== 0) { if (force === undefined) { force = await promptForForce(matchingRelativeWorkspacePaths); } } // Copy the template files to the workspace for (const relativeTemplatePath of template.files) { const relativeWorkspacePath = relativeTemplateToWorkspacePath(relativeTemplatePath); if ( force === false && matchingRelativeWorkspacePaths.includes(relativeWorkspacePath) ) { continue; } const absoluteTemplatePath = path.join(template.path, relativeTemplatePath); const absoluteWorkspacePath = path.join(workspace, relativeWorkspacePath); await ensureDir(path.dirname(absoluteWorkspacePath)); await copy(absoluteTemplatePath, absoluteWorkspacePath); } console.log(`✨ ${chalk.cyan(`Template files copied`)} ✨`); } /** * installProjectDependencies prints the commands to install the project dependencies * and runs them if the install option is true or if the user opts-in to it. * * NOTE: This function is exported for testing purposes * * @param workspace The path to the workspace to initialize the project in. * @param template The template to use for the project initialization. * @param install Whether to install the project dependencies. * @param update Whether to update the project dependencies. */ export async function installProjectDependencies( workspace: string, template: Template, install?: boolean, update?: boolean, ): Promise<void> { const pathToWorkspacePackageJson = path.join(workspace, "package.json"); const workspacePkg: PackageJson = await readJsonFile( pathToWorkspacePackageJson, ); const packageManager = getPackageManager(); // Find the template dev dependencies that are not already installed const templateDependencies = template.packageJson.devDependencies ?? {}; // If the package manager doesn't install peer dependencies by default, // we need to add them to the template dependencies if (!(await installsPeerDependenciesByDefault(workspace, packageManager))) { const templatePeerDependencies = template.packageJson.peerDependencies ?? {}; for (const [name, version] of Object.entries(templatePeerDependencies)) { templateDependencies[name] = version; } } // Checking both workspace dependencies and dev dependencies in case the user // installed a dev dependency as a dependency const workspaceDependencies = { ...(workspacePkg.dependencies ?? {}), ...(workspacePkg.devDependencies ?? {}), }; // We need to strip the optional workspace prefix from template dependency versions const templateDependencyEntries = Object.entries(templateDependencies).map( ([name, version]) => [name, version.replace(/^workspace:/, "")], ); // Finding the dependencies that are not already installed const dependenciesToInstall = templateDependencyEntries .filter(([name]) => workspaceDependencies[name] === undefined) .map(([name, version]) => `${name}@${version}`); // Try to install the missing dependencies if there are any if (Object.keys(dependenciesToInstall).length !== 0) { // Retrieve the package manager specific installation command const command = getDevDependenciesInstallationCommand( packageManager, dependenciesToInstall, ); const commandString = command.join(" "); // Ask the user for permission to install the project dependencies if (install === undefined) { install = await promptForInstall(commandString); } // If the user grants permission to install the dependencies, run the installation command if (install) { console.log(); console.log(commandString); await spawn(commandString, [], { cwd: workspace, // We need to run with `shell: true` for this to work on powershell, but // we already enclosed every dependency identifier in quotes, so this // is safe. shell: true, stdio: "inherit", }); console.log(`✨ ${chalk.cyan(`Dependencies installed`)} ✨`); } } // NOTE: Even though the dependency updates are very similar to pure // installations, they are kept separate to allow the user to skip one while // proceeding with the other, and to allow us to design handling of these // two processes independently. // Finding the installed dependencies that have an incompatible version const dependenciesToUpdate = templateDependencyEntries .filter(([dependencyName, templateVersion]) => { const workspaceVersion = workspaceDependencies[dependencyName]; return shouldUpdateDependency(workspaceVersion, templateVersion); }) .map(([name, version]) => `${name}@${version}`); // Try to update the missing dependencies if there are any. if (dependenciesToUpdate.length !== 0) { // Retrieve the package manager specific installation command const command = getDevDependenciesInstallationCommand( packageManager, dependenciesToUpdate, ); const commandString = command.join(" "); // Ask the user for permission to update the project dependencies if (update === undefined) { update = await promptForUpdate(commandString); } if (update) { console.log(); console.log(commandString); await spawn(commandString, [], { cwd: workspace, // We need to run with `shell: true` for this to work on powershell, but // we already enclosed every dependency identifier in quotes, so this // is safe. shell: true, stdio: "inherit", }); console.log(`✨ ${chalk.cyan(`Dependencies updated`)} ✨`); } } } function showStarOnGitHubMessage() { console.log( chalk.cyan("Give Hardhat a star on Github if you're enjoying it! ⭐️✨"), ); console.log(); console.log(chalk.cyan(" https://github.com/NomicFoundation/hardhat")); } // NOTE: This function is exported for testing purposes only. export function shouldUpdateDependency( workspaceVersion: string | undefined, templateVersion: string, ): boolean { // We should not update the dependency if it is not yet installed in the workspace. if (workspaceVersion === undefined) { return false; } // NOTE: a specific version also a valid range that includes itself only const workspaceRange = semver.validRange(workspaceVersion, { includePrerelease: true, }); const templateRange = semver.validRange(templateVersion, { includePrerelease: true, }); assertHardhatInvariant( templateRange !== null, "All dependencies of the template should have valid versions", ); // We should update the dependency if the workspace version could not be parsed. if (workspaceRange === null) { return true; } // We should update the dependency if the workspace range (or, in particular, a specific version) is not // a strict subset of the template range/does not equal the template version. return !semver.subset(workspaceRange, templateRange, { includePrerelease: true, }); }