Skip to content

Commit ab6d80c

Browse files
bpamiriclaude
andauthored
fix(cli): normalize Windows drive-letter paths in wheels new (#2835)
* fix(cli): normalize Windows drive-letter paths to forward slashes in `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> * refactor(cli): drop unused $safeFileExists, document #2835 in CHANGELOG 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> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6318bee commit ab6d80c

2 files changed

Lines changed: 68 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
3232

3333
### Fixed
3434

35+
- `wheels new <name>` no longer crashes on a fresh Windows (Scoop) install with `lucee.runtime.exp.NativeException: there is no Resource provider available with the name [c]` before any module output appears. LuCLI hands `Module.init()` a `cwd` of the JVM's `user.dir` (e.g. `C:\Users\cy`, backslashes), and the early scaffold path concatenated `cwd & "/" & appName` into a mixed-slash string like `C:\Users\cy/blog`. Lucee 7's `ResourceUtil` runs a URI scheme-detection regex (`^[a-zA-Z][a-zA-Z0-9+.-]*:`) ahead of its Windows drive-letter special case on this code path, matches `c:`, extracts `c` as a resource-provider scheme, finds none (only `ftp` / `zip` / `tar` / `tgz` / `http` / `https` / `ram` / `s3`), and throws — pure-backslash and pure-forward-slash paths both work, only the mixed form fails. A new `$normalizePath()` replaces backslashes with forward slashes on `variables.cwd` in `init()` and on every `java.io.File.getCanonicalPath()` result in `resolveProjectRoot()` / `resolveFrameworkSource()`, so `C:/Users/cy/blog` matches Lucee's Windows-path detection before the URI regex ever runs; a `$safeDirExists()` wrapper adds a `java.io.File.isDirectory()` fallback for any path that still reaches a `directoryExists()` check with a drive-letter prefix (a user-supplied `WHEELS_FRAMEWORK_PATH`, a CFML mapping). Both no-op on macOS/Linux, where paths carry no `<letter>:` prefix. Latent since the Scoop install first shipped, but masked until the `-Dlucli.binary.name=wheels` routing fix (`wheels-dev/scoop-wheels@30ea6e5`) let `wheels new` actually reach this code (#2835)
3536
- Running the `wheels` CLI with no arguments no longer errors out with `Component [modules.wheels.Module] has no function with name [main]`. LuCLI dispatches a bare `wheels` invocation to a `main()` subcommand on the module; previously `cli/lucli/Module.cfc` only defined `showHelp()`, so picocli's routing surfaced the missing-method exception. `Module.cfc` now defines `main()` as a thin delegate to `showHelp()` (and the function is added to `mcpHiddenTools()` so it doesn't appear as an MCP tool), restoring the expected behavior of printing the help banner when no subcommand is supplied (#2840)
3637
- `wheels new` no longer commits a reload-password secret to source control. The scaffold hard-coded the generated random password as a literal in `config/settings.cfm` (a tracked file) and repeated it in a comment, and wrote it to `.env` as `RELOAD_PASSWORD` while the deployment guides and the `wheels deploy` secrets contract used `WHEELS_RELOAD_PASSWORD` — so pasting the documented `env()` snippet into a fresh app silently resolved to `""` and tripped the "reloadPassword is empty" boot warning. Generated `config/settings.cfm` (and both `app/snippets/*.txt`) now read `set(reloadPassword=env("WHEELS_RELOAD_PASSWORD", ""))`, so the random value the generator creates lives only in the git-ignored `.env`; the scaffold `.env` and the `examples/starter-app` reference now emit `WHEELS_RELOAD_PASSWORD` (the starter-app previously committed a guessable `reloadPassword="changeme"`). The CLI's `detectReloadPassword()` accepts both the prefixed and legacy unprefixed key, so apps generated before the rename keep working, and the configuration + deployment guides are reconciled on the bare `env()` accessor and the `WHEELS_RELOAD_PASSWORD` name (replacing an insecure docker example that used `Server.System.getEnv("RELOAD_PASSWORD") ?: "changeme"`). The scaffolded `lucee.json` also stops embedding the literal — its Lucee Server Admin password reads `#env:WHEELS_LUCEE_ADMIN_PASSWORD#` (a distinct generated secret written to `.env`, separate from the reload password), which LuCLI resolves from `.env` at server start via its native `#env:VAR#` interpolation, so no committed file carries it. **Heads-up for existing apps:** the CFML `env()` lookup is exact-match (only the CLI carries the back-compat alias), so if you adopt the new `config/settings.cfm` form or a guide snippet, rename your `.env` key from `RELOAD_PASSWORD` to `WHEELS_RELOAD_PASSWORD` (#2857)
3738
- Auto-derived model property names now preserve the database's reported column casing again, instead of being force-lowercased on every engine. When a model declares no `property()` mappings, Wheels infers its properties from the database column metadata; a change in the 3.0 line (`Model.cfc`, intended to normalize Oracle's fixed-case identifiers) began calling `lCase()` on every derived property name unconditionally, so an `isHidden` column surfaced as the property `ishidden` on SQL Server, MySQL, SQLite, etc. — silently breaking case-sensitive consumers of serialized model output (`returnAs="structs"`, `renderWith()`, `serializeJSON()`) for anyone upgrading from CFWheels 2.x (the same code preserved case in 2.5 on the same engine + database). Casing is now preserved by default and only lowercased on adapters whose database folds unquoted identifiers to a non-meaningful UPPERCASE default, gated by a new `$lowerCaseColumnNames()` capability on the database adapter (`Base` default `false`; `OracleModel` and `H2Model` override to `true`). So SQL Server / MySQL / SQLite preserve the declared case, PostgreSQL / CockroachDB use the database's own lowercase-folded name, and Oracle / H2 keep the lowercased behavior they have today. Models that explicitly declare `property(name="isHidden", column="isHidden")` were always unaffected and remain so. **Reverse-migration heads-up:** apps that adopted Wheels 3.x/4.x and adapted to the force-lowercased property names — e.g. JSON consumers, view templates, or client-side code that expects `{"ishidden": 1}` — will see that output revert to the originally declared casing (`{"isHidden": 1}`) after applying this patch on SQL Server / MySQL / SQLite. Review any serialized model output consumers before upgrading (#2852)

cli/lucli/Module.cfc

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,62 @@ component extends="modules.BaseModule" {
2424
) {
2525
super.init(argumentCollection = arguments);
2626

27+
// Normalize cwd to forward slashes. Lucee 7 on Windows fails to
28+
// distinguish a drive-letter path (e.g. `C:\Users\cy/blog`, where
29+
// the backslash came from `user.dir` and the forward slash from
30+
// `cwd & "/" & appName`) from a URI like `http:/...`. The mixed-
31+
// slash form trips ResourceUtil's scheme-detection regex, which
32+
// extracts "c" as the scheme and throws "no Resource provider
33+
// available with the name [c]" before any module code runs. All-
34+
// forward-slash paths are accepted by both Lucee and the JDK on
35+
// every platform, so we normalize once here and again on every
36+
// canonical-path concatenation downstream.
37+
variables.cwd = $normalizePath(variables.cwd);
38+
2739
// Resolve project root (where lucee.json / vendor/wheels lives)
28-
variables.projectRoot = resolveProjectRoot(arguments.cwd);
40+
variables.projectRoot = resolveProjectRoot(variables.cwd);
2941

3042
// Module root for template resolution
31-
variables.moduleRoot = getDirectoryFromPath(getCurrentTemplatePath());
43+
variables.moduleRoot = $normalizePath(getDirectoryFromPath(getCurrentTemplatePath()));
3244

3345
// Lazy-init service instances
3446
variables.services = {};
3547

3648
return this;
3749
}
3850

51+
/**
52+
* Normalize a filesystem path for safe handoff to Lucee file APIs on
53+
* Windows. Replaces all backslashes with forward slashes — Lucee
54+
* accepts both on Windows, but mixed-slash strings can trip
55+
* ResourceUtil's URI scheme detection (see init() comment).
56+
*
57+
* No-op on a path that already uses forward slashes (Mac/Linux,
58+
* already-normalized Windows paths).
59+
*/
60+
private string function $normalizePath(required string p) {
61+
return replace(arguments.p, "\", "/", "all");
62+
}
63+
64+
/**
65+
* Java-backed directoryExists() — bypasses Lucee's path resolver so
66+
* paths starting with a Windows drive letter (`C:\…`) never reach
67+
* ResourceUtil's scheme detection. The defensive `try/catch` honors
68+
* Lucee's built-in first (in case mappings or symlinks matter) and
69+
* only falls back to `java.io.File.isDirectory()` when Lucee throws.
70+
*
71+
* Use this in any path-existence check that runs early in `wheels
72+
* new` (before the framework source is located) or in any code that
73+
* constructs paths from `variables.cwd` / `File.getCanonicalPath()`.
74+
*/
75+
private boolean function $safeDirExists(required string p) {
76+
try {
77+
return directoryExists(arguments.p);
78+
} catch (any e) {
79+
return createObject("java", "java.io.File").init(arguments.p).isDirectory();
80+
}
81+
}
82+
3983
/**
4084
* Extract positional arguments from LuCLI's argCollection or __arguments.
4185
*
@@ -4505,9 +4549,12 @@ component extends="modules.BaseModule" {
45054549
// ── New App Scaffolding ──────────────────────────
45064550

45074551
private string function scaffoldNewApp(required string appName, struct options = {}) {
4552+
// variables.cwd is already forward-slash-normalized by init(); the
4553+
// concat below stays clean on Windows. $safeDirExists is a final
4554+
// safety net against any cwd that bypasses normalization.
45084555
var targetDir = variables.cwd & "/" & appName;
45094556

4510-
if (directoryExists(targetDir)) {
4557+
if ($safeDirExists(targetDir)) {
45114558
out("Directory already exists: #appName#", "red");
45124559
// Throw so LuCLI exits non-zero instead of silently succeeding and
45134560
// fooling automation (GH #2214). Done before any files are written.
@@ -5225,8 +5272,8 @@ component extends="modules.BaseModule" {
52255272
}
52265273
if (len(trim(override))) {
52275274
arrayAppend(variables.frameworkSearchPaths, override & " (from $WHEELS_FRAMEWORK_PATH)");
5228-
if (directoryExists(override)) {
5229-
return override;
5275+
if ($safeDirExists(override)) {
5276+
return $normalizePath(override);
52305277
}
52315278
throw(
52325279
type="Wheels.FrameworkPathInvalid",
@@ -5239,21 +5286,24 @@ component extends="modules.BaseModule" {
52395286
if (len(variables.projectRoot)) {
52405287
var projectCandidate = variables.projectRoot & "/vendor/wheels";
52415288
arrayAppend(variables.frameworkSearchPaths, projectCandidate);
5242-
if (directoryExists(projectCandidate)) {
5289+
if ($safeDirExists(projectCandidate)) {
52435290
return projectCandidate;
52445291
}
52455292
}
52465293

52475294
// 3. Module root — if the LuCLI module itself lives inside a wheels
52485295
// repo checkout (cli/lucli/), walk up to find vendor/wheels/.
5296+
// Normalize each canonical path (Windows: backslashes) to
5297+
// forward slashes so concatenation with "/vendor/wheels" stays
5298+
// URI-safe — see init().
52495299
if (len(variables.moduleRoot)) {
52505300
var File = createObject("java", "java.io.File");
52515301
var dir = variables.moduleRoot;
52525302
for (var i = 0; i < 6; i++) {
5253-
var candidate = File.init(dir).getCanonicalPath();
5303+
var candidate = $normalizePath(File.init(dir).getCanonicalPath());
52545304
var frameworkCandidate = candidate & "/vendor/wheels";
52555305
arrayAppend(variables.frameworkSearchPaths, frameworkCandidate);
5256-
if (directoryExists(frameworkCandidate)) {
5306+
if ($safeDirExists(frameworkCandidate)) {
52575307
return frameworkCandidate;
52585308
}
52595309
var parent = File.init(candidate).getParent();
@@ -5444,10 +5494,13 @@ component extends="modules.BaseModule" {
54445494
var dir = len(trim(cwd)) ? cwd : ".";
54455495
var File = createObject("java", "java.io.File");
54465496

5447-
// Walk up at most 5 levels
5497+
// Walk up at most 5 levels. Normalize each canonical path to
5498+
// forward slashes before concatenation — see init() for why mixed
5499+
// slashes break Lucee 7 on Windows. $safeDirExists guards against
5500+
// any path that still slips through with a drive-letter scheme.
54485501
for (var i = 0; i < 5; i++) {
5449-
var candidate = File.init(dir).getCanonicalPath();
5450-
if (directoryExists(candidate & "/vendor/wheels")) {
5502+
var candidate = $normalizePath(File.init(dir).getCanonicalPath());
5503+
if ($safeDirExists(candidate & "/vendor/wheels")) {
54515504
return candidate;
54525505
}
54535506
// Go up one level
@@ -5457,7 +5510,9 @@ component extends="modules.BaseModule" {
54575510
}
54585511

54595512
// Fallback: use cwd as-is
5460-
return len(trim(cwd)) ? File.init(cwd).getCanonicalPath() : File.init(".").getCanonicalPath();
5513+
return $normalizePath(
5514+
len(trim(cwd)) ? File.init(cwd).getCanonicalPath() : File.init(".").getCanonicalPath()
5515+
);
54615516
}
54625517

54635518
/**

0 commit comments

Comments
 (0)