Repository navigation
fix: exit cleanly on Ctrl+C when npm runs the framework on Windows #413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
petebacondarwin
merged 2 commits into
cloudflare:main
from
Cherry:fix/windows-npm-ctrl-c-prompt
Nov 5, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| "cf": patch | ||
| --- | ||
|
|
||
| Exit cleanly after Ctrl+C when a framework command runs through npm on Windows | ||
|
|
||
| `cf dev`, `cf build`, and `cf init` now start `npm` and `npx` commands through | ||
| npm's CLI script instead of the `npm.cmd` and `npx.cmd` launchers. Those | ||
| launchers keep a batch file running, so pressing Ctrl+C left cf waiting at | ||
| cmd's "Terminate batch job (Y/N)?" prompt after the dev server had stopped. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { delimiter, dirname, join } from "node:path"; | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { resolveNpmLauncher } from "../../lib/npm-launcher.js"; | ||
|
|
||
| function npmInstall(root: string, files: string[] = []): string { | ||
| const bin = join(root, "node_modules", "npm", "bin"); | ||
| mkdirSync(bin, { recursive: true }); | ||
| for (const name of ["npm", "npx"]) { | ||
| writeFileSync(join(bin, `${name}-cli.js`), ""); | ||
| } | ||
| for (const file of files) { | ||
| writeFileSync(join(root, file), ""); | ||
| } | ||
| return bin; | ||
| } | ||
|
|
||
| function fakeExecutable(directory: string, file: string): string { | ||
| mkdirSync(directory, { recursive: true }); | ||
| writeFileSync(join(directory, file), ""); | ||
| return join(directory, file); | ||
| } | ||
|
|
||
| // The empty extension lets the real node binary resolve on POSIX test hosts. | ||
| function windowsEnv(...path: string[]): NodeJS.ProcessEnv { | ||
| return { | ||
| Path: path.join(delimiter), | ||
| PATHEXT: [".exe", ".cmd", ""].join(delimiter), | ||
| }; | ||
| } | ||
|
|
||
| describe("resolveNpmLauncher", () => { | ||
| it("runs npx through the CLI script with the node.exe beside npx.cmd", () => { | ||
| const root = mkdtempSync(join(tmpdir(), "cf-npm-launcher-")); | ||
| const nodejs = join(root, "nodejs"); | ||
| const bin = npmInstall(nodejs, ["npx.cmd", "node.exe"]); | ||
| const otherNode = fakeExecutable(join(root, "other"), "node.exe"); | ||
|
|
||
| expect( | ||
| resolveNpmLauncher( | ||
| ["npx", "astro", "dev"], | ||
| root, | ||
| windowsEnv(join(root, "empty"), dirname(otherNode), nodejs), | ||
| "win32" | ||
| ) | ||
| ).toEqual([ | ||
| join(nodejs, "node.exe"), | ||
| join(bin, "npx-cli.js"), | ||
| "astro", | ||
| "dev", | ||
| ]); | ||
| }); | ||
|
|
||
| it("falls back to node from PATH when npm.cmd has no adjacent node.exe", () => { | ||
| const root = mkdtempSync(join(tmpdir(), "cf-npm-launcher-")); | ||
| const prefix = join(root, "prefix"); | ||
| const bin = npmInstall(prefix, ["npm.cmd"]); | ||
| const node = fakeExecutable(join(root, "nodejs"), "node.exe"); | ||
|
|
||
| expect( | ||
| resolveNpmLauncher( | ||
| ["npm", "run", "dev"], | ||
| root, | ||
| windowsEnv(prefix, dirname(node)), | ||
| "win32" | ||
| ) | ||
| ).toEqual([node, join(bin, "npm-cli.js"), "run", "dev"]); | ||
| }); | ||
|
|
||
| it("leaves npm alone when an earlier PATH entry has a native npm.exe shim", () => { | ||
| const root = mkdtempSync(join(tmpdir(), "cf-npm-launcher-")); | ||
| const shim = fakeExecutable(join(root, "shims"), "npm.exe"); | ||
| const nodejs = join(root, "nodejs"); | ||
| npmInstall(nodejs, ["npm.cmd", "node.exe"]); | ||
|
|
||
| expect( | ||
| resolveNpmLauncher( | ||
| ["npm", "run", "dev"], | ||
| root, | ||
| windowsEnv(dirname(shim), nodejs), | ||
| "win32" | ||
| ) | ||
| ).toEqual(["npm", "run", "dev"]); | ||
| }); | ||
|
|
||
| it("prefers an npm upgraded into the global prefix, like npm.cmd", () => { | ||
| const root = mkdtempSync(join(tmpdir(), "cf-npm-launcher-")); | ||
| const nodejs = join(root, "nodejs"); | ||
| const prefix = join(root, "prefix"); | ||
| const bundledBin = npmInstall(nodejs, ["npm.cmd"]); | ||
| const prefixBin = npmInstall(prefix); | ||
| writeFileSync( | ||
| join(bundledBin, "npm-prefix.js"), | ||
| `console.log(${JSON.stringify(prefix)});` | ||
| ); | ||
|
|
||
| expect( | ||
| resolveNpmLauncher( | ||
| ["npm.cmd", "run", "build"], | ||
| root, | ||
| windowsEnv(nodejs, dirname(process.execPath)), | ||
| "win32" | ||
| ) | ||
| ).toEqual([ | ||
| process.execPath, | ||
| join(prefixBin, "npm-cli.js"), | ||
| "run", | ||
| "build", | ||
| ]); | ||
| }); | ||
|
|
||
| it("leaves other commands and other platforms unchanged", () => { | ||
| const root = mkdtempSync(join(tmpdir(), "cf-npm-launcher-")); | ||
| const nodejs = join(root, "nodejs"); | ||
| npmInstall(nodejs, ["npx.cmd", "pnpm.cmd", "node.exe"]); | ||
|
|
||
| expect( | ||
| resolveNpmLauncher(["pnpm", "dev"], root, windowsEnv(nodejs), "win32") | ||
| ).toEqual(["pnpm", "dev"]); | ||
| expect( | ||
| resolveNpmLauncher(["npx", "astro"], root, windowsEnv(nodejs), "linux") | ||
| ).toEqual(["npx", "astro"]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { existsSync, statSync } from "node:fs"; | ||
| import { basename, delimiter, dirname, join, resolve } from "node:path"; | ||
|
|
||
| /** | ||
| * Run `npm` / `npx` on Windows through the CLI script their `.cmd` launcher | ||
| * would start. Node.js's bundled npm.cmd and npx.cmd keep a batch file active | ||
| * while npm runs, so a console Ctrl+C ends at cmd's "Terminate batch job | ||
| * (Y/N)?" prompt instead of exiting. Other commands are returned unchanged. | ||
| */ | ||
| export function resolveNpmLauncher( | ||
| command: [string, ...string[]], | ||
| cwd: string, | ||
| env: NodeJS.ProcessEnv = process.env, | ||
| platform: NodeJS.Platform = process.platform | ||
| ): [string, ...string[]] { | ||
| const [file, ...args] = command; | ||
| if (platform !== "win32" || !/^(?:npm|npx)(?:\.cmd)?$/i.test(file)) { | ||
| return command; | ||
| } | ||
|
|
||
| // Rewrite only when the child would start npm's batch launcher, so an | ||
| // earlier npm.exe shim (such as a version manager's) still wins. | ||
| const launcher = findExecutable(file, cwd, env); | ||
| if (launcher === undefined) { | ||
| return command; | ||
| } | ||
| const name = /^(npm|npx)\.cmd$/i.exec(basename(launcher))?.[1]?.toLowerCase(); | ||
| if (name === undefined) { | ||
| return command; | ||
| } | ||
| const directory = dirname(launcher); | ||
| const bin = join(directory, "node_modules", "npm", "bin"); | ||
| const script = join(bin, `${name}-cli.js`); | ||
| if (!existsSync(script)) { | ||
| return command; | ||
| } | ||
|
|
||
| const adjacentNode = join(directory, "node.exe"); | ||
| let node: string | undefined; | ||
| if (existsSync(adjacentNode)) { | ||
| node = adjacentNode; | ||
| } else { | ||
| // npm.cmd falls back to `node` as cmd would resolve it, not cf's runtime. | ||
| node = findExecutable("node", cwd, env); | ||
| } | ||
| if (node === undefined) { | ||
| return command; | ||
| } | ||
| return [node, globalPrefixScript(node, bin, name) ?? script, ...args]; | ||
| } | ||
|
|
||
| // Mirrors tinyexec's Windows lookup so the rewrite targets the same launcher. | ||
| function findExecutable( | ||
| command: string, | ||
| cwd: string, | ||
| env: NodeJS.ProcessEnv | ||
| ): string | undefined { | ||
| const pathKey = Object.keys(env).find((key) => /^path$/i.test(key)); | ||
| const path = pathKey === undefined ? "" : (env[pathKey] ?? ""); | ||
| let extensions = env.PATHEXT | ||
| ? env.PATHEXT.split(delimiter) | ||
| : [".EXE", ".CMD", ".BAT", ".COM"]; | ||
| if (command.includes(".")) { | ||
| extensions = ["", ...extensions]; | ||
| } | ||
| for (const directory of [cwd, ...path.split(delimiter)]) { | ||
| for (const extension of extensions) { | ||
| const candidate = resolve(cwd, directory, command + extension); | ||
| if (isFile(candidate)) { | ||
| return candidate; | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function isFile(path: string): boolean { | ||
| try { | ||
| return statSync(path).isFile(); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** Like the launchers, prefer an npm installed into the global prefix. */ | ||
| function globalPrefixScript( | ||
| node: string, | ||
| bin: string, | ||
| name: string | ||
| ): string | undefined { | ||
| const prefixScript = join(bin, "npm-prefix.js"); | ||
| if (!existsSync(prefixScript)) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| const prefix = execFileSync(node, [prefixScript], { | ||
|
Cherry marked this conversation as resolved.
Resolved
|
||
| encoding: "utf8", | ||
| stdio: ["ignore", "pipe", "ignore"], | ||
| // A hung probe would otherwise block the command from ever starting. | ||
| timeout: 5000, | ||
| windowsHide: true, | ||
| }).trim(); | ||
| const script = join(prefix, "node_modules", "npm", "bin", `${name}-cli.js`); | ||
| return prefix !== "" && existsSync(script) ? script : undefined; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.