Skip to content

Commit 57e2499

Browse files
Merge pull request #393 from RtlZeroMemory/fix/windows-create-rezi-debug
fix(create-rezi): harden windows scaffold installs
2 parents ea2aed0 + ad95ee0 commit 57e2499

8 files changed

Lines changed: 191 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ The format is based on Keep a Changelog and the project follows Semantic Version
66

77
## [Unreleased]
88

9+
### Bug Fixes
10+
11+
- **create-rezi/cli**: Fixed Windows nested installs by switching `create-rezi` to the standard `cross-spawn` process launcher and by resolving npm installs through the active npm entrypoint instead of relying on Git Bash shell resolution.
12+
- **create-rezi/minimal**: Replaced the invalid bare `+` keybinding in the minimal template with Windows-safe `=` / `shift+=` bindings while keeping `+` as an accepted command alias.
13+
914
## [0.1.0-alpha.68] - 2026-04-15
1015

1116
### CI / Tooling

package-lock.json

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

packages/create-rezi/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
"bun": ">=1.3.0"
2323
},
2424
"devDependencies": {
25-
"@rezi-ui/testkit": "0.1.0-alpha.61"
25+
"@rezi-ui/testkit": "0.1.0-alpha.61",
26+
"@types/cross-spawn": "^6.0.6"
27+
},
28+
"dependencies": {
29+
"cross-spawn": "^7.0.6"
2630
}
2731
}

packages/create-rezi/src/__tests__/index.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { resolve } from "node:path";
22
import { assert, test } from "@rezi-ui/testkit";
3-
import { createInstallEnv, resolveInstallCwd } from "../index.js";
3+
import { createInstallEnv, resolveInstallCwd, resolveInstallInvocation } from "../index.js";
4+
5+
const WINDOWS_ROAMING_NPM_EXEC_PATH =
6+
"C:\\Users\\example\\AppData\\Roaming\\npm\\node_modules\\npm\\bin\\npm-cli.js";
47

58
test("resolveInstallCwd resolves targetDir against the current base directory", () => {
69
assert.equal(
@@ -53,3 +56,58 @@ test("createInstallEnv strips parent npm lifecycle metadata but preserves useful
5356
assert.equal(childEnv.npm_package_name, undefined);
5457
assert.equal(childEnv.npm_package_json, undefined);
5558
});
59+
60+
test("resolveInstallInvocation prefers npm_execpath and falls back to node-adjacent npm.cmd on Windows", () => {
61+
assert.deepEqual(
62+
resolveInstallInvocation("npm", {
63+
env: {
64+
npm_execpath: WINDOWS_ROAMING_NPM_EXEC_PATH,
65+
},
66+
platform: "win32",
67+
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
68+
}),
69+
{
70+
command: "C:\\Program Files\\nodejs\\node.exe",
71+
args: [WINDOWS_ROAMING_NPM_EXEC_PATH, "install"],
72+
},
73+
);
74+
75+
assert.deepEqual(
76+
resolveInstallInvocation("npm", {
77+
env: {
78+
npm_execpath:
79+
"C:\\Users\\example\\AppData\\Roaming\\npm\\node_modules\\pnpm\\bin\\pnpm.cjs",
80+
},
81+
platform: "win32",
82+
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
83+
}),
84+
{
85+
command: "C:\\Program Files\\nodejs\\npm.cmd",
86+
args: ["install"],
87+
},
88+
);
89+
90+
assert.deepEqual(
91+
resolveInstallInvocation("npm", {
92+
env: {},
93+
platform: "win32",
94+
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
95+
}),
96+
{
97+
command: "C:\\Program Files\\nodejs\\npm.cmd",
98+
args: ["install"],
99+
},
100+
);
101+
102+
assert.deepEqual(
103+
resolveInstallInvocation("pnpm", {
104+
env: {},
105+
platform: "win32",
106+
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
107+
}),
108+
{
109+
command: "pnpm",
110+
args: ["install"],
111+
},
112+
);
113+
});

packages/create-rezi/src/index.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/usr/bin/env node
2-
import { spawnSync } from "node:child_process";
3-
import { relative, resolve } from "node:path";
2+
import { dirname, join, relative, resolve, win32 } from "node:path";
43
import { cwd, exit, stdin, stdout } from "node:process";
54
import { createInterface } from "node:readline/promises";
5+
import * as crossSpawn from "cross-spawn";
66
import { isMainModuleEntry } from "./mainEntry.js";
77
import {
88
TEMPLATE_DEFINITIONS,
@@ -149,6 +149,15 @@ function shouldStripInstallEnvKey(key: string): boolean {
149149
);
150150
}
151151

152+
function isNpmExecPath(execPath: string): boolean {
153+
const normalized = execPath.replaceAll("\\", "/").toLowerCase();
154+
return (
155+
normalized.endsWith("/npm-cli.js") ||
156+
normalized.endsWith("/npm-cli.mjs") ||
157+
/(^|\/)npm(\.cmd|\.exe)?$/.test(normalized)
158+
);
159+
}
160+
152161
export function createInstallEnv(
153162
parentEnv: Readonly<Record<string, string | undefined>> = process.env,
154163
): NodeJS.ProcessEnv {
@@ -165,6 +174,32 @@ export function resolveInstallCwd(targetDir: string, baseDir: string = cwd()): s
165174
return resolve(baseDir, targetDir);
166175
}
167176

177+
export function resolveInstallInvocation(
178+
packageManager: PackageManager,
179+
{
180+
env = process.env,
181+
platform = process.platform,
182+
nodeExecPath = process.execPath,
183+
}: {
184+
env?: Readonly<Record<string, string | undefined>>;
185+
platform?: NodeJS.Platform;
186+
nodeExecPath?: string;
187+
} = {},
188+
): { command: string; args: string[] } {
189+
if (packageManager === "npm") {
190+
// biome-ignore lint/complexity/useLiteralKeys: process.env-compatible maps use index signatures in TS.
191+
const npmExecPath = env["npm_execpath"];
192+
if (npmExecPath && isNpmExecPath(npmExecPath)) {
193+
return { command: nodeExecPath, args: [npmExecPath, "install"] };
194+
}
195+
if (platform === "win32") {
196+
return { command: win32.join(win32.dirname(nodeExecPath), "npm.cmd"), args: ["install"] };
197+
}
198+
}
199+
200+
return { command: packageManager, args: ["install"] };
201+
}
202+
168203
async function promptText(
169204
rl: ReturnType<typeof createInterface>,
170205
prompt: string,
@@ -200,7 +235,8 @@ async function promptTemplate(rl: ReturnType<typeof createInterface>): Promise<s
200235

201236
function runInstall(pm: PackageManager, targetDir: string): void {
202237
const installCwd = resolveInstallCwd(targetDir);
203-
const res = spawnSync(pm, ["install"], {
238+
const installInvocation = resolveInstallInvocation(pm);
239+
const res = crossSpawn.sync(installInvocation.command, installInvocation.args, {
204240
cwd: installCwd,
205241
stdio: "inherit",
206242
env: createInstallEnv(),

packages/create-rezi/templates/minimal/src/__tests__/keybindings.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolveMinimalCommand } from "../helpers/keybindings.js";
44

55
test("minimal keybinding map resolves expected commands", () => {
66
assert.equal(resolveMinimalCommand("q"), "quit");
7+
assert.equal(resolveMinimalCommand("="), "increment");
78
assert.equal(resolveMinimalCommand("+"), "increment");
89
assert.equal(resolveMinimalCommand("-"), "decrement");
910
assert.equal(resolveMinimalCommand("t"), "cycle-theme");

packages/create-rezi/templates/minimal/src/helpers/keybindings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const COMMAND_BY_KEY: Readonly<Record<string, MinimalCommand>> = Object.freeze({
1111
"ctrl+c": "quit",
1212
h: "toggle-help",
1313
"shift+/": "toggle-help",
14+
"=": "increment",
1415
"+": "increment",
1516
"shift+=": "increment",
1617
"-": "decrement",

packages/create-rezi/templates/minimal/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ app.keys({
125125
"ctrl+c": () => applyCommand(resolveMinimalCommand("ctrl+c")),
126126
h: () => applyCommand(resolveMinimalCommand("h")),
127127
"shift+/": () => applyCommand(resolveMinimalCommand("shift+/")),
128-
"+": () => applyCommand(resolveMinimalCommand("+")),
128+
"=": () => applyCommand(resolveMinimalCommand("=")),
129129
"shift+=": () => applyCommand(resolveMinimalCommand("shift+=")),
130130
"-": () => applyCommand(resolveMinimalCommand("-")),
131131
t: () => applyCommand(resolveMinimalCommand("t")),

0 commit comments

Comments
 (0)