Skip to content

Commit

Permalink
Merge pull request #5959 from NomicFoundation/init-cleanup
Browse files Browse the repository at this point in the history
address the follow-up tasks related to --init
  • Loading branch information
galargh authored Jan 3, 2025
2 parents 704e140 + 2d94531 commit 22389d6
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 65 deletions.
105 changes: 81 additions & 24 deletions v-next/hardhat/src/internal/cli/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import {
} from "@ignored/hardhat-vnext-utils/fs";
import { resolveFromRoot } from "@ignored/hardhat-vnext-utils/path";
import chalk from "chalk";
import * as semver from "semver";

import { findClosestHardhatConfig } from "../../config-loading.js";
import { HARDHAT_NAME } from "../../constants.js";
import { getHardhatVersion } from "../../utils/package.js";
import { ensureTelemetryConsent } from "../telemetry/telemetry-permissions.js";

import {
getDevDependenciesInstallationCommand,
Expand All @@ -29,6 +31,7 @@ import {
promptForForce,
promptForInstall,
promptForTemplate,
promptForUpdate,
promptForWorkspace,
} from "./prompt.js";
import { spawn } from "./subprocess.js";
Expand Down Expand Up @@ -62,9 +65,9 @@ export interface InitHardhatOptions {
* 3. Optionally, ask the user for the workspace to initialize the project in.
* 4. Validate that the package.json file is an esm package if it exists; otherwise, create it.
* 5. Optionally, ask the user for the template to use for the project initialization.
* 6. Validate that the package.json file is an esm package.
* 7. Optionally, ask the user if files should be overwritten.
* 8. Copy the template files to the workspace.
* 6. Optionally, ask the user if files should be overwritten.
* 7. Copy the template files to the workspace.
* 8. Ensure telemetry consent.
* 9. Print the commands to install the project dependencies.
* 10. Optionally, ask the user if the project dependencies should be installed.
* 11. Optionally, run the commands to install the project dependencies.
Expand Down Expand Up @@ -92,6 +95,10 @@ export async function initHardhat(options?: InitHardhatOptions): Promise<void> {
// Overwrite existing files only if the user opts-in to it
await copyProjectFiles(workspace, template, options?.force);

// Ensure telemetry consent first so that we are allowed to also track
// the unfinished init flows
await ensureTelemetryConsent();

// Print the commands to install the project dependencies
// Run them only if the user opts-in to it
await installProjectDependencies(workspace, template, options?.install);
Expand Down Expand Up @@ -355,11 +362,13 @@ export async function copyProjectFiles(
* @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");

Expand All @@ -380,42 +389,42 @@ export async function installProjectDependencies(
templateDependencies[name] = version;
}
}
const workspaceDependencies = workspacePkg.devDependencies ?? {};
const dependenciesToInstall = Object.entries(templateDependencies)

// 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]) => {
// Strip the workspace: prefix from the version
return `${name}@${version.replace(/^workspace:/, "")}`;
});
.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
let command = getDevDependenciesInstallationCommand(
const command = getDevDependenciesInstallationCommand(
packageManager,
dependenciesToInstall,
);
const commandString = command.join(" ");

// We quote all the dependency identifiers to that it can be run on a shell
// without semver symbols interfering with the command
command = [
command[0],
command[1],
command[2],
...command.slice(3).map((arg) => `"${arg}"`),
];

const formattedCommand = command.join(" ");

// Ask the user for permission to install the project dependencies and install them if needed
// Ask the user for permission to install the project dependencies
if (install === undefined) {
install = await promptForInstall(formattedCommand);
install = await promptForInstall(commandString);
}

// If the user grants permission to install the dependencies, run the installation command
if (install) {
console.log();
console.log(formattedCommand);
console.log(commandString);

await spawn(command[0], command.slice(1), {
cwd: workspace,
Expand All @@ -429,6 +438,54 @@ export async function installProjectDependencies(
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(([name, version]) => {
const workspaceVersion = workspaceDependencies[name];
return (
workspaceVersion !== undefined &&
!semver.satisfies(version, workspaceVersion) &&
!semver.intersects(version, workspaceVersion)
);
})
.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(command[0], command.slice(1), {
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() {
Expand Down
7 changes: 5 additions & 2 deletions v-next/hardhat/src/internal/cli/init/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export async function getPackageManager(

/**
* getDevDependenciesInstallationCommand returns the command to install the given dependencies
* as dev dependencies using the given package manager.
* as dev dependencies using the given package manager. The returned command should
* be safe to run on the command line.
*
* @param packageManager The package manager to use.
* @param dependencies The dependencies to install.
Expand All @@ -46,7 +47,9 @@ export function getDevDependenciesInstallationCommand(
pnpm: ["pnpm", "add", "--save-dev"],
};
const command = packageManagerToCommand[packageManager];
command.push(...dependencies);
// We quote all the dependency identifiers so that they can be run on a shell
// without semver symbols interfering with the command
command.push(...dependencies.map((d) => `"${d}"`));
return command;
}

Expand Down
22 changes: 21 additions & 1 deletion v-next/hardhat/src/internal/cli/init/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Template } from "./template.js";

import { HardhatError } from "@ignored/hardhat-vnext-errors";
import chalk from "chalk";

export async function promptForWorkspace(): Promise<string> {
ensureTTY();
Expand Down Expand Up @@ -71,14 +72,33 @@ export async function promptForInstall(
{
name: "install",
type: "confirm",
message: `You need to install the project dependencies using the following command:\n${safelyFormattedCommand}\n\nDo you want to run it now?`,
message: `You need to install the following dependencies using the following command:\n${chalk.italic(safelyFormattedCommand)}\n\nDo you want to run it now?`,
initial: true,
},
]);

return installResponse.install;
}

export async function promptForUpdate(
safelyFormattedCommand: string,
): Promise<boolean> {
ensureTTY();

const { default: enquirer } = await import("enquirer");

const updateResponse = await enquirer.prompt<{ update: boolean }>([
{
name: "update",
type: "confirm",
message: `You need to update the following dependencies using the following command:\n${chalk.italic(safelyFormattedCommand)}\n\nDo you want to run it now?`,
initial: true,
},
]);

return updateResponse.update;
}

/**
* ensureTTY checks if the process is running in a TTY (i.e. a terminal).
* If it is not, it throws and error.
Expand Down
133 changes: 98 additions & 35 deletions v-next/hardhat/test/internal/cli/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,34 +218,90 @@ describe("copyProjectFiles", () => {
});
});

describe("installProjectDependencies", () => {
describe("installProjectDependencies", async () => {
useTmpDir("installProjectDependencies");

disableConsole();

describe("when install is true", () => {
// This test is skipped because installing dependencies over the network is slow
it.skip("should install the project dependencies", async () => {
const template = await getTemplate("empty-typescript");
await writeUtf8File("package.json", JSON.stringify({ type: "module" }));
await installProjectDependencies(process.cwd(), template, true);
assert.ok(await exists("node_modules"), "node_modules should exist");
});
const templates = await getTemplates();

for (const template of templates) {
// NOTE: This test is slow because it installs dependencies over the network.
// It tests installation for all the templates, but only with the npm as the
// package manager. We also support pnpm and yarn.
it(
`should install all the ${template.name} template dependencies in an empty project if the user opts-in to the installation`,
{
skip: process.env.HARDHAT_DISABLE_SLOW_TESTS === "true",
},
async () => {
await writeUtf8File("package.json", JSON.stringify({ type: "module" }));
await installProjectDependencies(process.cwd(), template, true, false);
assert.ok(await exists("node_modules"), "node_modules should exist");
const dependencies = Object.keys(
template.packageJson.devDependencies ?? {},
);
for (const dependency of dependencies) {
const nodeModulesPath = path.join(
"node_modules",
...dependency.split("/"),
);
assert.ok(
await exists(nodeModulesPath),
`${nodeModulesPath} should exist`,
);
}
},
);
}

it("should not install any template dependencies if the user opts-out of the installation", async () => {
const template = await getTemplate("mocha-ethers");
await writeUtf8File("package.json", JSON.stringify({ type: "module" }));
await installProjectDependencies(process.cwd(), template, false, false);
assert.ok(!(await exists("node_modules")), "node_modules should not exist");
});
describe("when install is false", () => {
it("should not install the project dependencies", async () => {

it(
"should install any existing template dependencies that are out of date if the user opts-in to the update",
{
skip: process.env.HARDHAT_DISABLE_SLOW_TESTS === "true",
},
async () => {
const template = await getTemplate("mocha-ethers");
await writeUtf8File("package.json", JSON.stringify({ type: "module" }));
await installProjectDependencies(process.cwd(), template, false);
assert.ok(
!(await exists("node_modules")),
"node_modules should not exist",
await writeUtf8File(
"package.json",
JSON.stringify({
type: "module",
devDependencies: { "@ignored/hardhat-vnext": "0.0.0" },
}),
);
});
});
await installProjectDependencies(process.cwd(), template, false, true);
assert.ok(await exists("node_modules"), "node_modules should exist");
const dependencies = Object.keys(
template.packageJson.devDependencies ?? {},
);
for (const dependency of dependencies) {
const nodeModulesPath = path.join(
"node_modules",
...dependency.split("/"),
);
if (dependency === "@ignored/hardhat-vnext") {
assert.ok(
await exists(nodeModulesPath),
`${nodeModulesPath} should exist`,
);
} else {
assert.ok(
!(await exists(nodeModulesPath)),
`${nodeModulesPath} should not exist`,
);
}
}
},
);
});

// NOTE: This uses network to access the npm registry
describe("initHardhat", async () => {
useTmpDir("initHardhat");

Expand All @@ -254,21 +310,28 @@ describe("initHardhat", async () => {
const templates = await getTemplates();

for (const template of templates) {
it(`should initialize the project using the ${template.name} template in an empty folder`, async () => {
await initHardhat({
template: template.name,
workspace: process.cwd(),
force: false,
install: false,
});
assert.ok(await exists("package.json"), "package.json should exist");
const workspaceFiles = template.files.map(
relativeTemplateToWorkspacePath,
);
for (const file of workspaceFiles) {
const pathToFile = path.join(process.cwd(), file);
assert.ok(await exists(pathToFile), `File ${file} should exist`);
}
});
// NOTE: This test uses network to access the npm registry
it(
`should initialize the project using the ${template.name} template in an empty folder`,
{
skip: process.env.HARDHAT_DISABLE_SLOW_TESTS === "true",
},
async () => {
await initHardhat({
template: template.name,
workspace: process.cwd(),
force: false,
install: false,
});
assert.ok(await exists("package.json"), "package.json should exist");
const workspaceFiles = template.files.map(
relativeTemplateToWorkspacePath,
);
for (const file of workspaceFiles) {
const pathToFile = path.join(process.cwd(), file);
assert.ok(await exists(pathToFile), `File ${file} should exist`);
}
},
);
}
});
Loading

0 comments on commit 22389d6

Please sign in to comment.