Skip to content

Commit ad08607

Browse files
refactor: external command logic
1 parent 9d4961f commit ad08607

11 files changed

Lines changed: 221 additions & 89 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# Node dependencies
1717
node_modules
1818
test/**/node_modules
19+
!test/external-command/node_modules
1920

2021
# Lock files
2122
yarn.lock

packages/webpack-cli/src/webpack-cli.ts

Lines changed: 69 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ interface CommandOptions<
100100
usage?: string;
101101
dependencies?: string[];
102102
pkg?: string;
103-
external?: boolean;
104103
preload?: () => Promise<C>;
105104
options?:
106105
| CommandOption[]
@@ -543,13 +542,13 @@ class WebpackCLI {
543542

544543
async makeCommand<A = void, O extends CommanderArgs = CommanderArgs, C extends Context = Context>(
545544
options: CommandOptions<A, O, C>,
546-
): Promise<Command | undefined> {
545+
): Promise<Command> {
547546
const alreadyLoaded = this.program.commands.find(
548547
(command) => command.name() === options.rawName,
549548
);
550549

551550
if (alreadyLoaded) {
552-
return;
551+
return alreadyLoaded as Command;
553552
}
554553

555554
const command = this.program.command(options.name, {
@@ -1163,9 +1162,7 @@ class WebpackCLI {
11631162
} else {
11641163
const [name] = options;
11651164

1166-
await this.#loadCommandByName(name);
1167-
1168-
const command = this.#findCommandByName(name);
1165+
const command = await this.#loadCommandByName(name);
11691166

11701167
if (!command) {
11711168
const builtInCommandUsed = Object.values(this.#commands).find(
@@ -1202,9 +1199,9 @@ class WebpackCLI {
12021199
outputIncorrectUsageOfHelp();
12031200
}
12041201

1205-
await this.#loadCommandByName(commandName);
1206-
1207-
const command = isGlobalOption(optionName) ? program : this.#findCommandByName(commandName);
1202+
const command = isGlobalOption(optionName)
1203+
? program
1204+
: await this.#loadCommandByName(commandName);
12081205

12091206
if (!command) {
12101207
this.logger.error(`Can't find and load command '${commandName}'`);
@@ -1916,81 +1913,58 @@ class WebpackCLI {
19161913
);
19171914
}
19181915

1919-
async #loadCommandByName(commandName: string, allowToInstall = false) {
1916+
async #loadCommandByName(commandName: string): Promise<Command | undefined> {
19201917
if (this.#isCommand(commandName, this.#commands.build)) {
1921-
await this.makeCommand(this.#commands.build);
1918+
return await this.makeCommand(this.#commands.build);
19221919
} else if (this.#isCommand(commandName, this.#commands.serve)) {
1923-
await this.makeCommand(this.#commands.serve);
1920+
return await this.makeCommand(this.#commands.serve);
19241921
} else if (this.#isCommand(commandName, this.#commands.watch)) {
1925-
await this.makeCommand(this.#commands.watch);
1922+
return await this.makeCommand(this.#commands.watch);
19261923
} else if (this.#isCommand(commandName, this.#commands.help)) {
19271924
// Stub for the `help` command
1928-
await this.makeCommand(this.#commands.help);
1925+
return await this.makeCommand(this.#commands.help);
19291926
} else if (this.#isCommand(commandName, this.#commands.version)) {
1930-
await this.makeCommand(this.#commands.version);
1927+
return await this.makeCommand(this.#commands.version);
19311928
} else if (this.#isCommand(commandName, this.#commands.info)) {
1932-
await this.makeCommand(this.#commands.info);
1929+
return await this.makeCommand(this.#commands.info);
19331930
} else if (this.#isCommand(commandName, this.#commands.configtest)) {
1934-
await this.makeCommand(this.#commands.configtest);
1935-
} else {
1936-
const builtInExternalCommandInfo = Object.values(this.#commands)
1937-
.filter((item) => item.external)
1938-
.find(
1939-
(externalBuiltInCommandInfo) =>
1940-
externalBuiltInCommandInfo.rawName === commandName ||
1941-
(Array.isArray(externalBuiltInCommandInfo.alias)
1942-
? externalBuiltInCommandInfo.alias.includes(commandName)
1943-
: externalBuiltInCommandInfo.alias === commandName),
1944-
);
1945-
1946-
let pkg: string;
1947-
1948-
if (builtInExternalCommandInfo && builtInExternalCommandInfo.pkg) {
1949-
({ pkg } = builtInExternalCommandInfo);
1950-
} else {
1951-
pkg = commandName;
1952-
}
1953-
1954-
if (pkg !== "webpack-cli" && !(await this.isPackageInstalled(pkg))) {
1955-
if (!allowToInstall) {
1956-
return;
1957-
}
1958-
1959-
pkg = await this.installPackage(pkg, {
1960-
preMessage: () => {
1961-
this.logger.error(
1962-
`For using this command you need to install: '${this.colors.green(pkg)}' package.`,
1963-
);
1964-
},
1965-
});
1966-
}
1967-
1968-
type Instantiable<
1969-
InstanceType = unknown,
1970-
ConstructorParameters extends unknown[] = unknown[],
1971-
> = new (...args: ConstructorParameters) => InstanceType;
1972-
1973-
let LoadedCommand: Instantiable<() => void>;
1931+
return await this.makeCommand(this.#commands.configtest);
1932+
}
19741933

1975-
try {
1976-
LoadedCommand = (await import(pkg)).default;
1977-
} catch {
1978-
// Ignore, command is not installed
1979-
return;
1980-
}
1934+
const pkg: string = commandName;
19811935

1982-
let command;
1936+
type Instantiable<
1937+
InstanceType = unknown,
1938+
ConstructorParameters extends unknown[] = unknown[],
1939+
> = new (...args: ConstructorParameters) => InstanceType & { apply(cli: WebpackCLI): Command };
19831940

1984-
try {
1985-
command = new LoadedCommand();
1941+
let LoadedCommand: Instantiable<() => void>;
19861942

1987-
await command.apply(this);
1988-
} catch (error) {
1943+
try {
1944+
LoadedCommand = (await import(pkg)).default;
1945+
} catch (error) {
1946+
if ((error as NodeJS.ErrnoException).code !== "ERR_MODULE_NOT_FOUND") {
19891947
this.logger.error(`Unable to load '${pkg}' command`);
19901948
this.logger.error(error);
19911949
process.exit(2);
19921950
}
1951+
1952+
return;
1953+
}
1954+
1955+
let command;
1956+
let externalCommand: Command;
1957+
1958+
try {
1959+
command = new LoadedCommand();
1960+
externalCommand = await command.apply(this);
1961+
} catch (error) {
1962+
this.logger.error(`Unable to load '${pkg}' command`);
1963+
this.logger.error(error);
1964+
process.exit(2);
19931965
}
1966+
1967+
return externalCommand;
19941968
}
19951969

19961970
async run(args: readonly string[], parseOptions: ParseOptions) {
@@ -2123,25 +2097,25 @@ class WebpackCLI {
21232097
process.exit(0);
21242098
}
21252099

2126-
let commandNameToRun = operand;
2127-
let commandOperands = operands.slice(1);
2128-
21292100
let isKnownCommand = false;
21302101

21312102
for (const command of Object.values(this.#commands)) {
21322103
if (
2133-
command.rawName === commandNameToRun ||
2104+
command.rawName === operand ||
21342105
(Array.isArray(command.alias)
2135-
? command.alias.includes(commandNameToRun)
2136-
: command.alias === commandNameToRun)
2106+
? command.alias.includes(operand)
2107+
: command.alias === operand)
21372108
) {
21382109
isKnownCommand = true;
21392110
break;
21402111
}
21412112
}
21422113

2114+
let command: Command | undefined;
2115+
let commandOperands = operands.slice(1);
2116+
21432117
if (isKnownCommand) {
2144-
await this.#loadCommandByName(commandNameToRun, true);
2118+
command = await this.#loadCommandByName(operand);
21452119
} else {
21462120
let isEntrySyntax: boolean;
21472121

@@ -2153,33 +2127,39 @@ class WebpackCLI {
21532127
}
21542128

21552129
if (isEntrySyntax) {
2156-
commandNameToRun = defaultCommandNameToRun;
21572130
commandOperands = operands;
21582131

2159-
await this.#loadCommandByName(commandNameToRun);
2132+
command = await this.#loadCommandByName(defaultCommandNameToRun);
21602133
} else {
2161-
this.logger.error(`Unknown command or entry '${operand}'`);
2134+
// Try to load external command
2135+
try {
2136+
command = await this.#loadCommandByName(operand);
2137+
} catch {
2138+
// Nothing
2139+
}
21622140

2163-
const found = Object.values(this.#commands).find(
2164-
(commandOptions) => distance(operand, commandOptions.rawName) < 3,
2165-
);
2141+
if (!command) {
2142+
this.logger.error(`Unknown command or entry '${operand}'`);
21662143

2167-
if (found) {
2168-
this.logger.error(
2169-
`Did you mean '${found.rawName}' (alias '${Array.isArray(found.alias) ? found.alias.join(", ") : found.alias}')?`,
2144+
const found = Object.values(this.#commands).find(
2145+
(commandOptions) => distance(operand, commandOptions.rawName) < 3,
21702146
);
2171-
}
21722147

2173-
this.logger.error("Run 'webpack --help' to see available commands and options");
2174-
process.exit(2);
2148+
if (found) {
2149+
this.logger.error(
2150+
`Did you mean '${found.rawName}' (alias '${Array.isArray(found.alias) ? found.alias.join(", ") : found.alias}')?`,
2151+
);
2152+
}
2153+
2154+
this.logger.error("Run 'webpack --help' to see available commands and options");
2155+
process.exit(2);
2156+
}
21752157
}
21762158
}
21772159

2178-
const command = this.#findCommandByName(commandNameToRun);
2179-
21802160
if (!command) {
21812161
throw new Error(
2182-
`Internal error: Registered command "${commandNameToRun}" is missing an action handler.`,
2162+
`Internal error: Registered command "${operand}" is missing an action handler.`,
21832163
);
21842164
}
21852165

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const { run } = require("../utils/test-utils");
2+
3+
describe("external command", () => {
4+
it("should work", async () => {
5+
const { exitCode, stdout, stderr } = await run(__dirname, ["custom-command"], {
6+
nodeOptions: ["--import=./register-loader.mjs"],
7+
});
8+
9+
expect(exitCode).toBe(0);
10+
expect(stderr).toBeFalsy();
11+
expect(stdout).toContain("custom unknown");
12+
});
13+
14+
it("should work with options", async () => {
15+
const { exitCode, stdout, stderr } = await run(__dirname, ["custom-command", "--output=json"], {
16+
nodeOptions: ["--import=./register-loader.mjs"],
17+
});
18+
19+
expect(exitCode).toBe(0);
20+
expect(stderr).toBeFalsy();
21+
expect(stdout).toContain("custom json");
22+
});
23+
24+
it("should work with help", async () => {
25+
const { exitCode, stdout, stderr } = await run(__dirname, ["help", "custom-command"], {
26+
nodeOptions: ["--import=./register-loader.mjs"],
27+
});
28+
29+
expect(exitCode).toBe(0);
30+
expect(stderr).toBeFalsy();
31+
expect(stdout).toContain("Usage: webpack custom-command|cc [options]");
32+
expect(stdout).toContain("-o, --output <value> To get the output in a specified format");
33+
});
34+
35+
it("should work with help for option", async () => {
36+
const { exitCode, stdout, stderr } = await run(
37+
__dirname,
38+
["help", "custom-command", "--output"],
39+
{
40+
nodeOptions: ["--import=./register-loader.mjs"],
41+
},
42+
);
43+
44+
expect(exitCode).toBe(0);
45+
expect(stderr).toBeFalsy();
46+
expect(stdout).toContain("Usage: webpack custom-command --output <value>");
47+
expect(stdout).toContain("Description: To get the output in a specified format");
48+
});
49+
50+
it("should handle errors in external commands", async () => {
51+
const { exitCode, stdout, stderr } = await run(__dirname, ["errored-custom-command"], {
52+
nodeOptions: ["--import=./register-loader.mjs"],
53+
});
54+
55+
expect(exitCode).toBe(2);
56+
expect(stderr).toContain("Unable to load 'errored-custom-command' command");
57+
expect(stderr).toContain("Error: error in apply");
58+
expect(stdout).toBeFalsy();
59+
});
60+
61+
it("should handle errors in external commands when loading", async () => {
62+
const { exitCode, stdout, stderr } = await run(__dirname, ["errored-loading-custom-command"], {
63+
nodeOptions: ["--import=./register-loader.mjs"],
64+
});
65+
66+
expect(exitCode).toBe(2);
67+
expect(stderr).toContain("Unable to load 'errored-loading-custom-command' command");
68+
expect(stderr).toContain("Error: error in loading");
69+
expect(stdout).toBeFalsy();
70+
});
71+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { join } from "node:path";
2+
import { pathToFileURL } from "node:url";
3+
4+
export async function resolve(specifier, context, nextResolve) {
5+
try {
6+
return await nextResolve(specifier, context);
7+
} catch (err) {
8+
if (err.code === "ERR_MODULE_NOT_FOUND" && !specifier.startsWith(".")) {
9+
try {
10+
const baseDir = pathToFileURL(join(process.cwd(), "node_modules/")).href;
11+
const resolved = join(baseDir, specifier, "index.js");
12+
13+
return {
14+
url: resolved,
15+
shortCircuit: true,
16+
};
17+
} catch {
18+
throw err;
19+
}
20+
}
21+
throw err;
22+
}
23+
}

test/external-command/node_modules/custom-command/index.js

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/external-command/node_modules/custom-command/package.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/external-command/node_modules/errored-custom-command/index.js

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)