Skip to content

Commit 6db7409

Browse files
authored
Remove small runtime CLI dependencies (#15239)
1 parent 242e822 commit 6db7409

12 files changed

Lines changed: 261 additions & 116 deletions

File tree

integration/cli-test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
11
import { spawnSync } from "node:child_process";
2-
import { existsSync, rmSync } from "node:fs";
2+
import {
3+
copyFileSync,
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
rmSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { tmpdir } from "node:os";
311
import * as path from "node:path";
12+
import { fileURLToPath } from "node:url";
413

514
import { expect, test } from "@playwright/test";
615
import dedent from "dedent";
716
import semver from "semver";
817

918
import { createProject } from "./helpers/vite";
1019

20+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
21+
const rootDirectory = path.resolve(__dirname, "..");
1122
const nodeBin = process.argv[0];
1223
const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js";
24+
const reactRouterPackageBin = path.join(
25+
rootDirectory,
26+
"packages/react-router-dev/bin.cjs",
27+
);
1328

1429
const run = (command: string[], options: Parameters<typeof spawnSync>[2]) =>
1530
spawnSync(nodeBin, [reactRouterBin, ...command], options);
1631

32+
const getBinNodeEnv = (command: string[]) => {
33+
let cwd = mkdtempSync(path.join(tmpdir(), "react-router-bin-"));
34+
let env = { ...process.env };
35+
delete env.NODE_ENV;
36+
37+
try {
38+
mkdirSync(path.join(cwd, "dist/cli"), { recursive: true });
39+
copyFileSync(reactRouterPackageBin, path.join(cwd, "bin.cjs"));
40+
writeFileSync(
41+
path.join(cwd, "dist/cli/index.js"),
42+
"console.log(process.env.NODE_ENV);",
43+
);
44+
45+
let { stdout, stderr, status } = spawnSync(
46+
nodeBin,
47+
["bin.cjs", ...command],
48+
{ cwd, env },
49+
);
50+
expect(stderr.toString()).toBe("");
51+
expect(status).toBe(0);
52+
return stdout.toString().trim();
53+
} finally {
54+
rmSync(cwd, { recursive: true, force: true });
55+
}
56+
};
57+
1758
const helpText = dedent`
1859
react-router
1960
@@ -109,6 +150,17 @@ test.describe("cli", () => {
109150
expect(status).toBe(0);
110151
});
111152

153+
test("bin sets NODE_ENV based on the positional command", async () => {
154+
expect(getBinNodeEnv(["dev", "--host", "127.0.0.1"])).toBe("development");
155+
expect(getBinNodeEnv(["--host", "127.0.0.1", "dev"])).toBe("development");
156+
expect(getBinNodeEnv(["build", "--mode", "development"])).toBe(
157+
"production",
158+
);
159+
expect(getBinNodeEnv(["--mode", "development", "build"])).toBe(
160+
"production",
161+
);
162+
});
163+
112164
test("routes", async () => {
113165
const cwd = await createProject();
114166
let { stdout, stderr, status } = run(["routes"], { cwd });
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Use Node's built-in `parseArgs` utility for CLI argument parsing and remove the `arg` dependency.
1+
Use Node's built-in utilities for CLI argument parsing, ANSI-stripping, and child process execution to remove the `arg`, `strip-ansi`, and `execa` dependencies.

packages/create-react-router/__tests__/create-react-router-test.ts

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ChildProcessWithoutNullStreams } from "node:child_process";
22
import { execFileSync, spawn } from "node:child_process";
3+
import { EventEmitter } from "node:events";
34
import {
45
existsSync,
56
mkdirSync,
@@ -13,23 +14,25 @@ import { createRequire } from "node:module";
1314
import { tmpdir } from "node:os";
1415
import path from "node:path";
1516
import { fileURLToPath, pathToFileURL } from "node:url";
17+
import { stripVTControlCharacters as stripAnsi } from "node:util";
1618
import semver from "semver";
17-
import stripAnsi from "strip-ansi";
1819

1920
import { jestTimeout } from "./setupAfterEnv";
2021
import { server } from "./msw";
2122

2223
const __filename = fileURLToPath(import.meta.url);
2324
const __dirname = path.dirname(__filename);
2425
const nodeRequire = createRequire(import.meta.url);
25-
const execaModuleId = nodeRequire.resolve("execa");
26-
const mockedExeca = jest.fn();
26+
const actualChildProcess = nodeRequire(
27+
"node:child_process",
28+
) as typeof import("node:child_process");
29+
const mockedSpawn = jest.fn(actualChildProcess.spawn);
2730
const REPO_ROOT = path.resolve(__dirname, "../../..");
2831
const BUILT_CLI = path.resolve(__dirname, "../dist/cli.js");
2932

30-
(jest as any).unstable_mockModule(execaModuleId, () => ({
31-
default: mockedExeca,
32-
execa: mockedExeca,
33+
(jest as any).unstable_mockModule("node:child_process", () => ({
34+
...actualChildProcess,
35+
spawn: mockedSpawn,
3336
}));
3437

3538
let createReactRouter: typeof import("../index").createReactRouter;
@@ -67,6 +70,7 @@ describe("create-react-router CLI", () => {
6770

6871
beforeEach(() => {
6972
jest.clearAllMocks();
73+
mockedSpawn.mockImplementation(actualChildProcess.spawn);
7074
});
7175

7276
afterEach(async () => {
@@ -76,6 +80,14 @@ describe("create-react-router CLI", () => {
7680
tempDirs = new Set<string>();
7781
});
7882

83+
function mockSpawnSuccess() {
84+
mockedSpawn.mockImplementation(() => {
85+
let child = new EventEmitter();
86+
process.nextTick(() => child.emit("exit", 0, null));
87+
return child as ReturnType<typeof spawn>;
88+
});
89+
}
90+
7991
function getProjectDir(name: string) {
8092
let tmpDir = path.join(TEMP_DIR, name);
8193
tempDirs.add(tmpDir);
@@ -580,8 +592,7 @@ describe("create-react-router CLI", () => {
580592

581593
let projectDir = getProjectDir("npm-install-default");
582594

583-
let execa = mockedExeca;
584-
execa.mockImplementation(async () => {});
595+
mockSpawnSuccess();
585596

586597
// Suppress terminal output
587598
let stdoutMock = jest
@@ -599,7 +610,7 @@ describe("create-react-router CLI", () => {
599610

600611
stdoutMock.mockReset();
601612

602-
expect(execa).toHaveBeenCalledWith(
613+
expect(mockedSpawn).toHaveBeenCalledWith(
603614
"npm",
604615
expect.arrayContaining(["install"]),
605616
expect.anything(),
@@ -615,8 +626,7 @@ describe("create-react-router CLI", () => {
615626

616627
let projectDir = getProjectDir("npm-install-on-unknown-package-manager");
617628

618-
let execa = mockedExeca;
619-
execa.mockImplementation(async () => {});
629+
mockSpawnSuccess();
620630

621631
// Suppress terminal output
622632
let stdoutMock = jest
@@ -634,7 +644,7 @@ describe("create-react-router CLI", () => {
634644

635645
stdoutMock.mockReset();
636646

637-
expect(execa).toHaveBeenCalledWith(
647+
expect(mockedSpawn).toHaveBeenCalledWith(
638648
"npm",
639649
expect.arrayContaining(["install"]),
640650
expect.anything(),
@@ -650,8 +660,7 @@ describe("create-react-router CLI", () => {
650660

651661
let projectDir = getProjectDir("npm-install-from-user-agent");
652662

653-
let execa = mockedExeca;
654-
execa.mockImplementation(async () => {});
663+
mockSpawnSuccess();
655664

656665
// Suppress terminal output
657666
let stdoutMock = jest
@@ -669,7 +678,7 @@ describe("create-react-router CLI", () => {
669678

670679
stdoutMock.mockReset();
671680

672-
expect(execa).toHaveBeenCalledWith(
681+
expect(mockedSpawn).toHaveBeenCalledWith(
673682
"npm",
674683
expect.arrayContaining(["install"]),
675684
expect.anything(),
@@ -684,8 +693,7 @@ describe("create-react-router CLI", () => {
684693

685694
let projectDir = getProjectDir("yarn-create-from-user-agent");
686695

687-
let execa = mockedExeca;
688-
execa.mockImplementation(async () => {});
696+
mockSpawnSuccess();
689697

690698
// Suppress terminal output
691699
let stdoutMock = jest
@@ -703,7 +711,7 @@ describe("create-react-router CLI", () => {
703711

704712
stdoutMock.mockReset();
705713

706-
expect(execa).toHaveBeenCalledWith(
714+
expect(mockedSpawn).toHaveBeenCalledWith(
707715
"yarn",
708716
expect.arrayContaining(["install"]),
709717
expect.anything(),
@@ -718,8 +726,7 @@ describe("create-react-router CLI", () => {
718726

719727
let projectDir = getProjectDir("pnpm-create-from-user-agent");
720728

721-
let execa = mockedExeca;
722-
execa.mockImplementation(async () => {});
729+
mockSpawnSuccess();
723730

724731
// Suppress terminal output
725732
let stdoutMock = jest
@@ -737,7 +744,7 @@ describe("create-react-router CLI", () => {
737744

738745
stdoutMock.mockReset();
739746

740-
expect(execa).toHaveBeenCalledWith(
747+
expect(mockedSpawn).toHaveBeenCalledWith(
741748
"pnpm",
742749
expect.arrayContaining(["install"]),
743750
expect.anything(),
@@ -752,8 +759,7 @@ describe("create-react-router CLI", () => {
752759

753760
let projectDir = getProjectDir("bun-create-from-user-agent");
754761

755-
let execa = mockedExeca;
756-
execa.mockImplementation(async () => {});
762+
mockSpawnSuccess();
757763

758764
// Suppress terminal output
759765
let stdoutMock = jest
@@ -771,7 +777,7 @@ describe("create-react-router CLI", () => {
771777

772778
stdoutMock.mockReset();
773779

774-
expect(execa).toHaveBeenCalledWith(
780+
expect(mockedSpawn).toHaveBeenCalledWith(
775781
"bun",
776782
expect.arrayContaining(["install"]),
777783
expect.anything(),
@@ -786,8 +792,7 @@ describe("create-react-router CLI", () => {
786792

787793
let projectDir = getProjectDir("deno-create-from-user-agent");
788794

789-
let execa = mockedExeca;
790-
execa.mockImplementation(async () => {});
795+
mockSpawnSuccess();
791796

792797
// Suppress terminal output
793798
let stdoutMock = jest
@@ -805,7 +810,7 @@ describe("create-react-router CLI", () => {
805810

806811
stdoutMock.mockReset();
807812

808-
expect(execa).toHaveBeenCalledWith(
813+
expect(mockedSpawn).toHaveBeenCalledWith(
809814
"deno",
810815
expect.arrayContaining(["install"]),
811816
expect.anything(),
@@ -820,8 +825,7 @@ describe("create-react-router CLI", () => {
820825

821826
let projectDir = getProjectDir("pnpm-create-override");
822827

823-
let execa = mockedExeca;
824-
execa.mockImplementation(async () => {});
828+
mockSpawnSuccess();
825829

826830
// Suppress terminal output
827831
let stdoutMock = jest
@@ -841,7 +845,7 @@ describe("create-react-router CLI", () => {
841845

842846
stdoutMock.mockReset();
843847

844-
expect(execa).toHaveBeenCalledWith(
848+
expect(mockedSpawn).toHaveBeenCalledWith(
845849
"pnpm",
846850
expect.arrayContaining(["install"]),
847851
expect.anything(),

packages/create-react-router/index.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import process from "node:process";
2+
import { spawn, type StdioOptions } from "node:child_process";
23
import { existsSync } from "node:fs";
34
import { cp, readFile, realpath, writeFile } from "node:fs/promises";
45
import os from "node:os";
56
import path from "node:path";
67
import { fileURLToPath } from "node:url";
7-
import { parseArgs } from "node:util";
8-
import stripAnsi from "strip-ansi";
9-
import { execa } from "execa";
8+
import { parseArgs, stripVTControlCharacters } from "node:util";
109
import * as semver from "semver";
1110
import sortPackageJSON from "sort-package-json";
1211

@@ -534,9 +533,9 @@ async function gitInitStep(ctx: Context) {
534533
let options = { cwd: ctx.cwd, stdio: "ignore" } as const;
535534
let commitMsg = "Initial commit from create-react-router";
536535
try {
537-
await execa("git", ["init"], options);
538-
await execa("git", ["add", "."], options);
539-
await execa("git", ["commit", "-m", commitMsg], options);
536+
await runCommand("git", ["init"], options);
537+
await runCommand("git", ["add", "."], options);
538+
await runCommand("git", ["commit", "-m", commitMsg], options);
540539
} catch (err) {
541540
error("Oh no!", "Failed to initialize git.");
542541
throw err;
@@ -560,7 +559,7 @@ async function doneStep(ctx: Context) {
560559
`\n${prefix}Enter your project directory using`,
561560
color.cyan(`cd .${path.sep}${projectDir}`),
562561
];
563-
let len = enter[0].length + stripAnsi(enter[1]).length;
562+
let len = enter[0].length + stripVTControlCharacters(enter[1]).length;
564563
log(enter.join(len > max ? "\n" + prefix : " "));
565564
}
566565
log(
@@ -592,7 +591,7 @@ async function installDependencies({
592591
showInstallOutput: boolean;
593592
}) {
594593
try {
595-
await execa(pkgManager, ["install"], {
594+
await runCommand(pkgManager, ["install"], {
596595
cwd,
597596
stdio: showInstallOutput ? "inherit" : "ignore",
598597
});
@@ -602,6 +601,30 @@ async function installDependencies({
602601
}
603602
}
604603

604+
function runCommand(
605+
command: string,
606+
args: string[],
607+
options: { cwd: string; stdio: StdioOptions },
608+
) {
609+
return new Promise<void>((resolve, reject) => {
610+
let child = spawn(command, args, options);
611+
child.on("error", reject);
612+
child.on("exit", (code, signal) => {
613+
if (code === 0) {
614+
resolve();
615+
} else {
616+
reject(
617+
new Error(
618+
signal
619+
? `${command} exited with signal ${signal}`
620+
: `${command} exited with code ${code}`,
621+
),
622+
);
623+
}
624+
});
625+
});
626+
}
627+
605628
async function updatePackageJSON(ctx: Context) {
606629
let packageJSONPath = path.join(ctx.cwd, "package.json");
607630
if (!existsSync(packageJSONPath)) {

packages/create-react-router/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,12 @@
3939
}
4040
},
4141
"dependencies": {
42-
"execa": "9.6.1",
4342
"gunzip-maybe": "^1.4.2",
4443
"log-update": "^8.0.0",
4544
"picocolors": "^1.1.1",
4645
"semver": "^7.8.1",
4746
"sisteransi": "^1.0.5",
4847
"sort-package-json": "^3.6.1",
49-
"strip-ansi": "^7.2.0",
5048
"tar-fs": "^3.1.2"
5149
},
5250
"devDependencies": {

0 commit comments

Comments
 (0)