fix(cli): normalize Windows drive-letter paths in wheels new#2835
Conversation
…`wheels new`
`wheels new <name>` fails on a fresh Scoop install with:
[ERROR] Error: lucee.runtime.exp.NativeException:
there is no Resource provider available with the name [c],
available resource providers are [ftp, zip, tar, tgz, http, https, ram, s3]
before any module output appears. Reported by Risto on Windows 11 via
Scoop after #2766 + the manifest fix landed (which unblocked `wheels new`
routing to the wheels module in the first place — see scoop-wheels
direct-push 30ea6e5 + 6cf06fc, 2026-05-28).
## Root cause
`Module.init()` receives `cwd` from LuCLI as the JVM's `user.dir`,
which on Windows is `C:\Users\<name>` with backslashes. The early
`wheels new` code path then constructs paths like:
targetDir = variables.cwd & "/" & appName
= "C:\Users\cy" & "/" & "blog"
= "C:\Users\cy/blog" # mixed slashes!
Lucee 7's `ResourceUtil` runs a URI scheme-detection regex
(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) BEFORE its Windows drive-letter
special case in this code path. The regex matches `c:` from
`C:\Users\cy/blog`, extracts "c" as the scheme, looks for a
matching resource provider, finds none (only `ftp`, `zip`,
`tar`, `tgz`, `http`, `https`, `ram`, `s3` are registered),
and throws. The same call with pure backslashes (`C:\Users\cy\blog`)
works because Lucee's Windows path detection kicks in earlier;
the same call with pure forward slashes (`C:/Users/cy/blog`)
works for the same reason.
The other suspect site is `resolveProjectRoot()` /
`resolveFrameworkSource()`, where `java.io.File.getCanonicalPath()`
returns backslashes (Windows native) and we concat with
`/vendor/wheels`. Same mixed-slash pattern, same crash.
Mac/Linux are unaffected because paths never start with a
letter-colon prefix. The bug has been latent in the Scoop install
since day one but was masked by the `-Dlucli.binary.name=wheels`
omission (scoop-wheels 30ea6e5) — until that was fixed, no
Windows `wheels new` invocation reached the wheels module at all.
## Fix
Two-layer defense:
1. **Normalize all backslashes to forward slashes** before any
path-existence check. `$normalizePath()` runs on `variables.cwd`
in `init()` and on every `File.getCanonicalPath()` result in
`resolveProjectRoot()` and `resolveFrameworkSource()`. Forward-
slash-only paths bypass the URI scheme ambiguity because
`C:/...` matches Lucee's Windows-path detection before its
URI-scheme regex.
2. **`$safeDirExists()` / `$safeFileExists()` wrappers** with a
`java.io.File`-based fallback. If anything still slips through
normalization (a path the user provided via `WHEELS_FRAMEWORK_PATH`,
a mapping, etc.), `directoryExists()` throws the same
NativeException — we catch it and fall back to
`new java.io.File(path).isDirectory()`, which is OS-native and
has no concept of URI schemes.
Layer 1 prevents the throw in the first place; layer 2 prevents the
crash if layer 1 is incomplete. Both layers are no-ops on Mac/Linux
(no backslashes to normalize; `directoryExists` doesn't throw).
## Scope
Limited to the early `wheels new` path: `init()`,
`resolveProjectRoot()`, `scaffoldNewApp()`'s first `directoryExists`,
and `resolveFrameworkSource()`'s three `directoryExists` calls. Once
the framework source is located and we enter the recursive
`copyTemplateDir()` loop, paths are constructed from the (already
normalized) `targetDir` so they inherit forward slashes — no
additional changes needed there.
## Verification
- Smoke-tested on macOS by overwriting `~/.wheels/modules/wheels/Module.cfc`
with the patched version: `wheels new test-patched` scaffolded
successfully with identical output to unpatched.
- Windows verification pending — patched Module.cfc to be hand-delivered
to Risto for drop-in test against his Scoop install before this PR
merges.
Refs scoop-wheels manifest update 30ea6e5 (binary.name fix that
unmasked this bug).
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Solid, well-motivated fix for a real Windows Scoop regression. The two-layer defence ($normalizePath + $safeDirExists) is the right approach, and the root-cause write-up in the PR body is excellent. One dead-code finding, a missing CHANGELOG entry, and a gap in unit tests. Commenting rather than requesting changes — none of these block the fix from being correct.
Correctness
$safeFileExists is defined but never called.
cli/lucli/Module.cfc lines 84-92 add:
private boolean function $safeFileExists(required string p) {
try {
return fileExists(arguments.p);
} catch (any e) {
return createObject("java", "java.io.File").init(arguments.p).isFile();
}
}A search of every fileExists() call in the file confirms none was updated to $safeFileExists(). The PR description says scope is limited to the pre-scaffold directory-existence path — that is consistent, but then the helper should either be removed or have a comment explaining it is reserved for a follow-up. Dead helpers add maintenance surface for no current benefit.
Suggestion: Remove $safeFileExists now and re-introduce it in the PR that wires it up, or add an explicit note so reviewers know it is not accidentally orphaned.
Tests
No spec covers $normalizePath directly. The function is a pure string transformation and is trivially testable on macOS/Linux. Because $normalizePath is private, a thin public accessor or integration-level test through the module is needed. This is the highest-value regression guard given this is the exact failure mode the PR is fixing.
The $safeDirExists fallback arm (the catch that calls java.io.File.isDirectory()) is also untested. A stub that forces directoryExists() to throw would cover that path.
Docs
No [Unreleased] CHANGELOG entry for this fix. The section currently lists only the version-switcher docs change from #2827. A ### Fixed item should be added.
Commits
Single commit: fix(cli): normalize Windows drive-letter paths to forward slashes in wheels new — header is ~80 chars, within the 100-char limit. Type fix, scope cli, both valid. No issues.
|
Wheels Bot -- Reviewer B (round 1) A review 4424204651 body is 'Test body via field' with no line comments -- a placeholder, not a real review. Sycophancy: None detected (no claims made). False positives: None detected (no claims made). Missed issues:
Verdict alignment: COMMENTED is incidentally fine for a draft but not reached by analysis. Convergence: Not aligned -- A must submit a real review covering (1) DRAFT/Windows context, (2) unused safeFileExists, (3) CLI test status. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A response (round 1)
B's analysis rests on an incorrect premise — the review evaluated (ID 4424204651 with body "Test body via field") is not my substantive initial review. My actual review (PRR_kwDOABMUH88AAAABB7QBaw, submitted at 2026-06-04T02:37:13Z) already covered $safeFileExists dead code and test coverage gaps. I concede one genuine miss — the silent exception swallow in both safe helpers — and defend the rest of my findings against B's characterization.
Conceded points
- Silent exception swallow: B is correct that I missed this. Both
$safeDirExistsand$safeFileExistsusecatch(any e)with no logging. On Windows — exactly the platform this fix targets — swallowing exceptions silently makes debugging harder. If the fallbackjava.io.Filecall itself fails (e.g., security manager, permissions), the caller seesfalseand gets a misleading "directory does not exist" result rather than an actionable error. The catch blocks should at minimum log towriteLog()orsystemOutput()before returning the fallback value.
Defended points
-
B's premise that my review was a placeholder: B evaluated review ID 4424204651 with body "Test body via field." The review the GitHub API returns for this SHA from wheels-bot is PRR_kwDOABMUH88AAAABB7QBaw with a full, substantive body covering correctness, tests, docs, and commit conventions. B appears to have evaluated a different review ID — possibly an artifact of an earlier test submission not visible in the current review list. My actual review is not a placeholder.
-
$safeFileExistsunused: B listed this as a missed issue, but my review's Correctness section flagged it explicitly, including the suggestion to either remove it now or document it as reserved for a follow-up PR. -
Test coverage: B says "Test coverage unmentioned." My actual review's Tests section calls out exactly this — that
$normalizePathneeds a unit spec (it is a pure string transformation, trivially testable) and that the$safeDirExistsfallback catch arm has no spec exercising the throw path. The CLAUDE.mdtools/test-cli-local.shmandate is worth noting as a checklist item, but the coverage gap was already in my review. -
DRAFT status and verdict: B says a real verdict should wait due to DRAFT status.
gh pr view 2835 --json isDraftreturnsfalse— the author has moved the PR out of draft and it is open for review. What I did not flag, and should have, is the unchecked "[ ] Windows drop-in test on risto's Scoop install" item in the test plan. That remains open and is worth a comment, but since the fix logic and root cause analysis are sound, it does not change my verdict.
Updated verdict
Still COMMENT — the core fix is correct, the root cause analysis is thorough, and the open items (dead $safeFileExists, silent exception swallow in both helpers, missing unit spec for $normalizePath, unchecked Windows manual verification checkbox) are robustness and polish gaps, not correctness blockers.
Address wheels-bot review on #2835: - $safeFileExists was added beside $safeDirExists but never wired to a call site; the Windows path fix only checks directories. Remove the dead helper; re-add with its java.io.File fallback if a file-existence check ever needs it. - Add an [Unreleased] "### Fixed" CHANGELOG entry for the drive-letter URI misparse fix. $safeDirExists and its 8 call sites are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Follow-up review for the updated head (f487bf54b). The previous review covered SHA 7e04f014a; the PR has since gained a refactor commit and a rebase on develop. The two issues the author addressed — removing the dead $safeFileExists helper and adding the CHANGELOG entry — are done cleanly. Two gaps from the prior cycle remain: the silent catch(any e) in $safeDirExists and the absence of a unit test for $normalizePath. Neither is a correctness blocker, so verdict is still comment.
Correctness
The core fix is sound. The two-layer defense — normalize backslashes to forward slashes in $normalizePath(), fall back to java.io.File.isDirectory() in $safeDirExists() — addresses the root cause cleanly. Each loop iteration in resolveProjectRoot and resolveFrameworkSource re-normalizes the result of File.init(dir).getCanonicalPath() before concatenation, so the traversal stays clean on Windows even after java.io.File.getParent() returns a backslash-separated string internally.
UNC paths are silently mishandled. $normalizePath converts \\server\share to //server/share, which is not a valid path on any platform. This is an edge case that cannot appear from a Scoop user.dir (user.dir is always a local path), so it is not a blocker. The function docblock should note the limitation to prevent future misuse:
/**
* Normalize a LOCAL filesystem path to forward slashes for safe handoff
* to Lucee file APIs on Windows. UNC paths (\\server\share) are not
* supported and must not be passed here.
*/Tests
$normalizePath still has no spec. This is a pure string transformation with no framework or file-system dependency — exactly the function that broke on Windows — and it is trivially testable. Since the function is private, the cleanest path is an integration-level check via the module's resolved cwd:
// cli/lucli/tests/specs/commands/NewCommandSpec.cfc — new describe block
describe("path normalization", () => {
it("converts backslash cwd to forward slashes on all platforms", () => {
// Simulates what Windows LuCLI hands Module.init()
var m = new cli.lucli.Module(cwd="C:\Users\cy");
expect(m.variables.cwd).toBe("C:/Users/cy");
});
});The $safeDirExists fallback catch arm is still untested. The path where Lucee throws and java.io.File.isDirectory() takes over is exercised only by the manual Windows drop-in noted in the PR checklist.
Conventions
Silent exception swallow in $safeDirExists. Flagged in round 1 of the prior review cycle but not addressed. From cli/lucli/Module.cfc:
private boolean function $safeDirExists(required string p) {
try {
return directoryExists(arguments.p);
} catch (any e) {
return createObject("java", "java.io.File").init(arguments.p).isDirectory();
// e is discarded with no log
}
}If directoryExists() throws for a reason unrelated to the URI-scheme issue (permissions, a null path, etc.), the caller gets false with no diagnostic. On Windows — the exact platform this fix targets — that silent false could cause wheels new to pass directory checks while masking a real I/O failure. A systemOutput() before the fallback return surfaces this in CLI output without breaking UX:
} catch (any e) {
systemOutput("[wheels] directoryExists fallback for: #arguments.p# (#e.message#)", true);
return createObject("java", "java.io.File").init(arguments.p).isDirectory();
}Commits
Both commits are well-formed:
| Commit | Header | Chars | DCO |
|---|---|---|---|
7e04f014a |
fix(cli): normalize Windows drive-letter paths to forward slashes in wheels new |
~73 | Author signed |
ef03ef777 |
refactor(cli): drop unused $safeFileExists, document #2835 in CHANGELOG |
~61 | Author signed |
Minor nit: the refactor commit mixes two concerns (dead-code removal + CHANGELOG). Not a lint violation under squash-merge — just noted for history readability.
PR Checklist
The [ ] Windows drop-in test on risto's Scoop install checkbox in the PR description remains unchecked, yet the PR is no longer marked draft. If the Windows test passed, please tick the box or post a confirmation comment so the release pipeline and future reviewers can confirm test status without reading the full discussion.
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Round 3 / new head b8bfea85a — a merge-from-develop rebase only. The two PR-own commits (7e04f014a, ef03ef777) are byte-for-byte identical to those reviewed in round 2. The develop merge brought in the main() dispatcher fix (#2842) and its MainCommandSpec.cfc, neither of which belong to this diff. The core normalization fix remains correct. Three items from round 2 are still open; verdict holds at comment (no correctness blockers).
Correctness
The fix is sound and unchanged — $normalizePath() eliminates the mixed-slash form before any Lucee ResourceUtil call; $safeDirExists() adds a Java-level fallback for any path that still reaches directoryExists(). No new correctness issues.
Conventions
Silent exception swallow in $safeDirExists — still unresolved (raised in rounds 1 and 2, cli/lucli/Module.cfc lines 75–81):
private boolean function $safeDirExists(required string p) {
try {
return directoryExists(arguments.p);
} catch (any e) {
return createObject("java", "java.io.File").init(arguments.p).isDirectory();
// e discarded silently — no log
}
}When directoryExists() throws for a reason unrelated to the drive-letter scheme (permissions, null path), the caller gets false with zero diagnostics. On Windows — the exact platform this fix targets — a silent false can cause wheels new to bypass directory-exists guards while masking a real I/O failure. A single systemOutput() before the fallback return makes the diagnosis visible without breaking UX:
} catch (any e) {
systemOutput("[wheels] directoryExists() fallback for: " & arguments.p & " (" & e.message & ")", true);
return createObject("java", "java.io.File").init(arguments.p).isDirectory();
}Tests
$normalizePath still has no spec — pure string transformation, no framework or file-system dependency, trivially testable. cli/lucli/tests/specs/commands/NewCommandSpec.cfc has no coverage for backslash-to-forward-slash conversion. Suggested addition inside the existing NewCommandSpec:
describe("path normalization", () => {
it("converts backslash cwd to forward slashes", () => {
// Simulate what Windows LuCLI hands Module.init()
var m = new cli.lucli.Module(cwd="C:\Users\cy");
expect(m.variables.cwd).toBe("C:/Users/cy");
});
});$safeDirExists fallback path still untested. The catch arm that calls java.io.File.isDirectory() has no spec exercising the throw.
PR Checklist
The [ ] Windows drop-in test on risto's Scoop install checkbox in the PR description remains unchecked and the PR is not in draft. If the Windows test passed, please tick the box or post a brief confirmation so the next reviewer does not have to re-read the full discussion thread.
Commits
| Commit | Header | Chars | Type |
|---|---|---|---|
7e04f014a |
fix(cli): normalize Windows drive-letter paths to forward slashes in wheels new |
~73 | valid |
ef03ef777 |
refactor(cli): drop unused $safeFileExists, document #2835 in CHANGELOG |
~61 | valid |
Both within the 100-char limit. Squash target is the PR title (fix(cli): normalize Windows drive-letter paths in wheels new), also valid.
Wheels Bot — Reviewer B (round 1)A's round 3 review on SycophancyNone detected. A held COMMENTED and enumerated concrete open items instead of saying LGTM. False positivesNone detected. I verified the two claims against the actual diff:
Missed issuesNone detected. A covered the relevant ground for a CLI-only change: correctness of the two-layer normalization approach, diagnostics gap in the fallback path, test coverage against CLAUDE.md's Verdict alignmentCOMMENTED is consistent with the findings. The open items are advisory (no correctness blocker) but real, and approving before they are addressed would be premature. ConvergenceAligned. A and I agree on two actionable changes:
Both are concrete and addressable by the bot. The Windows verification checkbox ( |
Wheels Bot — Address Review held (PR already merged)Reviewer A and Reviewer B converged on The two consensus advisory items remain valid and should be picked up in a follow-up PR against
Skipped (not in consensus): the unchecked Windows verification checkbox is a human gate, not a bot-actionable finding. |
…-argument Resolve CHANGELOG.md [Unreleased]/Fixed conflict by keeping both the --no-* flag fix entry (#2855) and develop's #2835/#2840/#2857 entries. cli/lucli/Module.cfc auto-merged cleanly — the PR's argsFromCollection one-liner sits in a different region than develop's CLI changes. Signed-off-by: Peter Amiri <peter@alurium.com>
Summary
wheels new <name>fails on a fresh Scoop install with:…before any module output appears. Draft because Windows verification is pending — patched
Module.cfcis being hand-delivered to risto for drop-in test against his Scoop install. Will mark ready once that confirms.Root cause
Module.init()receivescwdfrom LuCLI as the JVM'suser.dir, which on Windows isC:\Users\<name>with backslashes. The earlywheels newcode path then constructs paths like:Lucee 7's
ResourceUtilruns a URI scheme-detection regex (^[a-zA-Z][a-zA-Z0-9+.-]*:) before its Windows drive-letter special case in some code paths. The regex matchesc:fromC:\Users\cy/blog, extractscas the scheme, looks for a matching resource provider, finds none (onlyftp,zip,tar,tgz,http,https,ram,s3), and throws.The same call with pure backslashes (
C:\Users\cy\blog) works because Lucee's Windows path detection kicks in earlier; the same call with pure forward slashes (C:/Users/cy/blog) works for the same reason. Only the mixed-slash form crashes.The other suspect site is
resolveProjectRoot()/resolveFrameworkSource(), wherejava.io.File.getCanonicalPath()returns backslashes (Windows native) and we concat with/vendor/wheels— same crash.Mac/Linux are unaffected (no
<letter>:prefix). This has been latent in the Scoop install since day one but was masked by a separate routing bug (the missing-Dlucli.binary.name=wheelsflag inscoop-wheels/bucket/wheels.json, fixed in wheels-dev/scoop-wheels@30ea6e5). With that gone, the first Windows user to install fresh and runwheels newhits this immediately.Fix — two-layer defense
$normalizePath()replaces backslashes with forward slashes. Called onvariables.cwdininit()and on everyFile.getCanonicalPath()result inresolveProjectRoot()andresolveFrameworkSource(). Forward-slash-only paths bypass the URI scheme ambiguity becauseC:/...matches Lucee's Windows-path detection before the URI regex.$safeDirExists()/$safeFileExists()wrap their built-in counterparts with ajava.io.File-based fallback. If anything still slips through normalization (a user-providedWHEELS_FRAMEWORK_PATH, a mapping, etc.),directoryExists()throws the sameNativeException— we catch it and fall back tonew java.io.File(path).isDirectory(), which is OS-native and has no concept of URI schemes.Layer 1 prevents the throw; layer 2 catches anything layer 1 misses. Both no-op on Mac/Linux.
Scope
Limited to the pre-scaffold path:
init(),resolveProjectRoot(),scaffoldNewApp()'s firstdirectoryExists, andresolveFrameworkSource()'s threedirectoryExistscalls. OncetargetDiris forward-slash-normalized, the recursivecopyTemplateDir()loop inherits clean paths — no additional changes needed.Test plan
~/.wheels/modules/wheels/Module.cfcwith the patched version.wheels new test-patchedscaffolded successfully with identical output to unpatched.Module.cfcto be copied toC:\Users\cy\.wheels\modules\wheels\Module.cfc, thenwheels new blogre-run.wheels newstill works on Mac stable channel after the next 4.0.3 (or follow-on snapshot) reaches Scoop. Requires AUTO_MERGE_PAT scopes also resolved (separate Bug A — see scoop-wheels CI history).