From b7adaddd91bcad4b81fb1694080b7f5c256a7736 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:54:31 -0700 Subject: [PATCH 1/6] fix(cli): cfml-escape commit subject baked into BuildInfo.cfc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare-core.sh sed-substitutes the latest commit subject into BuildInfo.cfc inside a double-quoted CFML string. # is CFML's variable-interpolation delimiter, so a commit subject ending in a parenthesized PR ref like "(#2352)" produces a syntactically broken .cfc that crashes Lucee on the first request — bricking every fresh brew/chocolatey install. Onboarding finding F1. Escape both # → ## and " → "" before the substitution, then escape sed replacement specials (\, &) so the value transports through the existing sed pipeline cleanly. Add an inline post-substitution check that fails the build if any unescaped # or " remains in the produced commitSubject literal — a regression-stop without standing up a new test harness. Verified end-to-end: with COMMIT_SUBJECT="feat(config): introduce BuildInfo.cfc as the runtime version source (#2352)", the produced BuildInfo.cfc reads `commitSubject: "...source (##2352)",` (valid CFML); reverting the escape pipeline triggers the self-check with exit 1. --- tools/build/scripts/prepare-core.sh | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tools/build/scripts/prepare-core.sh b/tools/build/scripts/prepare-core.sh index 66104758a4..7a0515686f 100755 --- a/tools/build/scripts/prepare-core.sh +++ b/tools/build/scripts/prepare-core.sh @@ -83,7 +83,23 @@ fi # delimiter for fields that may contain / (URLs) or : (timestamps, refs). COMMIT_SHA=$(git rev-parse HEAD 2>/dev/null || echo "") COMMIT_SHORT=$(git rev-parse --short=7 HEAD 2>/dev/null || echo "") -COMMIT_SUBJECT=$(git log -1 --pretty=%s 2>/dev/null | tr -d '|' || echo "") +COMMIT_SUBJECT=$(git log -1 --pretty=%s 2>/dev/null || echo "") +# Strip newlines defensively (commit subjects shouldn't have any). +COMMIT_SUBJECT=$(printf '%s' "${COMMIT_SUBJECT}" | tr -d '\r\n') +# Escape for CFML double-quoted string literal (BuildInfo.cfc embeds this +# value inside one). # is CFML's variable-interpolation delimiter and " +# closes the string — leaving either unescaped breaks compilation. The +# common case is a PR-suffixed subject like "feat(x): foo (#1234)". +# Note: \# in the pattern is required because bash treats a leading +# unescaped # as an anchor-to-start operator inside ${var//...}. +COMMIT_SUBJECT="${COMMIT_SUBJECT//\#/##}" +COMMIT_SUBJECT="${COMMIT_SUBJECT//\"/\"\"}" +# Escape for sed replacement (\, &) and strip the sed delimiter (|), which +# has no clean representation here. Apply after CFML escaping so the layers +# don't interfere. +COMMIT_SUBJECT="${COMMIT_SUBJECT//\\/\\\\}" +COMMIT_SUBJECT="${COMMIT_SUBJECT//&/\\&}" +COMMIT_SUBJECT="${COMMIT_SUBJECT//|/}" BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Run context comes from the GitHub Actions environment. Locally these are # empty and the placeholders stay unsubstituted, which BuildInfo blanks out. @@ -108,5 +124,24 @@ find "${BUILD_DIR}/wheels" -type f \( -name "*.json" -o -name "*.md" -o -name "* "$file" && rm "${file}.bak" done +# Sanity check: BuildInfo.cfc commitSubject must not contain unescaped # +# (CFML's variable-interpolation delimiter) or unescaped " (string close). +# Fail loud here so a build pipeline regression surfaces before the artifact +# ever reaches users — an unescaped # bricks every fresh app on first request. +BUILDINFO_CFC="${BUILD_DIR}/wheels/BuildInfo.cfc" +if [ -f "${BUILDINFO_CFC}" ]; then + cs_line=$(grep -E '^[[:space:]]*commitSubject:' "${BUILDINFO_CFC}" | head -1 || true) + # Strip the prefix and trailing "," to get the bare string literal contents. + cs_literal=$(printf '%s' "${cs_line}" | sed -E 's/^[[:space:]]*commitSubject:[[:space:]]*"//; s/",[[:space:]]*$//') + # Collapse properly-escaped pairs; any remaining # or " is a defect. + cs_collapsed=$(printf '%s' "${cs_literal}" | sed -e 's/##//g' -e 's/""//g') + if printf '%s' "${cs_collapsed}" | grep -qE '[#"]'; then + echo "ERROR: BuildInfo.cfc commitSubject contains an unescaped # or \" — would break CFML compilation." >&2 + echo " Literal: ${cs_literal}" >&2 + echo " Remaining: ${cs_collapsed}" >&2 + exit 1 + fi +fi + echo "Wheels Core prepared for ForgeBox publishing!" echo "Directory: ${BUILD_DIR}/wheels/" \ No newline at end of file From b3212931134d62c5ee79b99ff347e76fb4868270 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:54:41 -0700 Subject: [PATCH 2/6] fix(cli): scaffold writes plural resource name to routes.cfm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateScaffold() takes a singular model name (e.g. "Post") and correctly derives a pluralized form for the controller, views, and migration — but updateRoutes() was being passed the raw singular, producing .resources("post") in routes.cfm. Onboarding finding F4. Wheels' resource convention is plural (PostsController maps to .resources("posts")), so the singular variant either creates a phantom second route that conflicts with any hand-added plural route, or silently fails to bind to the controller scaffolded a few lines earlier. The scaffold runs through cleanly otherwise — this is the one piece that drifted from the convention. Pass pluralName, which is already computed for the model/controller/ view generation right above. Add a regression spec that asserts routes.cfm contains .resources("articles") (plural) and not .resources("article") (singular) after scaffolding "Article". --- cli/lucli/services/Scaffold.cfc | 9 +++++++-- cli/lucli/tests/specs/services/ScaffoldSpec.cfc | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cli/lucli/services/Scaffold.cfc b/cli/lucli/services/Scaffold.cfc index 186e0dee5f..6dba422524 100644 --- a/cli/lucli/services/Scaffold.cfc +++ b/cli/lucli/services/Scaffold.cfc @@ -131,8 +131,13 @@ component { } } - // 6. Update routes - updateRoutes(arguments.name); + // 6. Update routes — pass the PLURAL form. `.resources("...")` follows + // Wheels' convention of plural resource names (e.g. `posts` maps to + // PostsController). Passing the singular `arguments.name` here was an + // onboarding cliff (finding F4): scaffolding `Post` produced + // `.resources("post")`, which conflicted with hand-added plural routes + // and broke the controller convention. + updateRoutes(pluralName); } catch (any e) { results.success = false; diff --git a/cli/lucli/tests/specs/services/ScaffoldSpec.cfc b/cli/lucli/tests/specs/services/ScaffoldSpec.cfc index e7a0f5c510..41fd2a1518 100644 --- a/cli/lucli/tests/specs/services/ScaffoldSpec.cfc +++ b/cli/lucli/tests/specs/services/ScaffoldSpec.cfc @@ -80,6 +80,23 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(arrayLen(files)).toBeGTE(1); }); + it("adds the PLURAL resource route to routes.cfm (regression for F4)", () => { + // Scaffold takes a singular name (`Article`) but routes.cfm + // must follow the plural convention. Onboarding finding F4 + // reported the scaffold writing `.resources("post")` (singular) + // after `wheels generate scaffold Post`, which conflicts with + // any hand-added plural route and breaks the PostsController + // mapping. + var result = scaffold.generateScaffold( + name = "Article", + properties = [{name: "title", type: "string"}] + ); + expect(result.success).toBeTrue(); + var routesContent = fileRead(tempRoot & "/config/routes.cfm"); + expect(routesContent).toInclude('.resources("articles")'); + expect(routesContent).notToInclude('.resources("article")'); + }); + it("handles empty name gracefully", () => { // Scaffold may reject or accept empty names depending on implementation var result = scaffold.generateScaffold( From d18f072699ab35257526eed0f89ec9f8b882c2b5 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:55:32 -0700 Subject: [PATCH 3/6] fix(cli): also stage sqlite driver into per-server lucee-server/bundles/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (#2349) staged the SQLite JDBC driver into every Lucee Express install's lib/ext/ — Tomcat's parent classpath. That satisfies Class.forName("org.sqlite.JDBC") but Lucee 7's datasource resolver consults its OSGi bundle loader for driver instantiation, not the Tomcat classloader. Without the bundle in /lucee-server/bundles/, the very first cfquery against a SQLite datasource fails — and the Wheels migrator goes through cfquery, so `wheels migrate latest` blows up on every fresh app. Onboarding finding F2. Other engines (MySQL, Postgres) avoid the issue because their bundles ship inside lucee-7.x.x.jar and Lucee auto-deploys them; the SQLite extension is third-party and doesn't get the same treatment, so seeding bundles/ is on us. Add BundleStager.stageIntoServerBundles() alongside the existing stageIntoLibExt() — same idempotent / best-effort contract, walks LUCLI_HOME/servers//lucee-server/bundles/. Wire it into $ensureWheelsBundles() so every wheels start populates both targets. The OSGi-symbolic-name filename (org.xerial.sqlite-jdbc-3.49.1.0.jar) is required for Lucee's bundle loader to recognize it. 5 new specs cover detection, idempotency, missing serversRoot, missing bundleSrc, and partial-server-context tolerance. CLI suite 462 → 467 pass on this worktree. --- cli/lucli/Module.cfc | 61 ++++++++------ cli/lucli/services/BundleStager.cfc | 54 +++++++++++++ .../tests/specs/services/BundleStagerSpec.cfc | 80 +++++++++++++++++++ 3 files changed, 172 insertions(+), 23 deletions(-) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index b7ff0d652f..23527e0e76 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -3797,26 +3797,26 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * Stage required JDBC drivers into every Lucee Express install's - * `lib/ext/` (Tomcat classpath) so Lucee can resolve datasource driver - * classes at boot time without hitting class-load failures. Lucee 7's - * stock Express distribution ships drivers for MySQL/MSSQL/PostgreSQL/ - * HSQLDB but not SQLite — yet `wheels new` writes SQLite as the zero- - * config default datasource. Without this stage, every fresh app fails - * on first request with `ClassException: org.sqlite.JDBC`. + * Stage the SQLite JDBC driver into the two locations Lucee 7 reads from, + * so a fresh `wheels new` SQLite-by-default app can boot, migrate, and + * query without manual JAR drops. Idempotent and best-effort. * - * Idempotent: if the JAR already exists in lib/ext/, skip. Best-effort: - * any I/O error is swallowed because we'd rather defer to LuCLI's normal - * server start error reporting than block on bundle staging. + * - `/lib/ext/` — Tomcat parent classpath. Satisfies + * `Class.forName("org.sqlite.JDBC")` for any caller that uses raw + * JDBC. * - * The bundled JAR at cli/lucli/resources/extensions/sqlite/ is the + * - `//lucee-server/bundles/` — Lucee's OSGi + * bundle store. Required for Lucee's datasource resolver (the path + * that `cfquery` and the Wheels migrator go through). Without this, + * `lib/ext/` alone is not enough — Lucee's bundle loader does not + * fall back to the Tomcat parent classloader for datasource driver + * resolution. See onboarding finding F2. + * + * The bundled JAR at `cli/lucli/resources/extensions/sqlite/` is the * upstream xerial sqlite-jdbc with a relaxed `Require-Capability` header * (Felix on Java 21 fails on upstream's strict `osgi.ee;version=1.8` - * exact-match). See tools/lucee-extensions/sqlite/build.sh for the - * patch logic. The JAR works equally well in lib/ext/ (Tomcat classpath) - * and lucee-server/bundles/ (OSGi); we target lib/ext/ here to mirror - * the brew formula's drop strategy and avoid the per-server `--force` - * wipe that bundles/ is subject to. + * exact-match). See `tools/lucee-extensions/sqlite/build.sh` for the + * patch logic. */ private void function $ensureWheelsBundles() { try { @@ -3833,18 +3833,33 @@ component extends="modules.BaseModule" { var lucliHome = $resolveLucliHome(); if (!len(lucliHome)) return; - // Stage into every Lucee Express install's lib/ext/ — that's the - // Tomcat classpath, where Lucee 7 finds JDBC drivers when the - // datasource config doesn't carry an OSGi `bundleName` hint - // (current `wheels new`-generated app.cfm omits the hint per - // GH #2304). Mirrors the brew/chocolatey wrapper's drop strategy - // so dev-checkout, manual-install, and any other path that skips - // the package wrapper still gets a working zero-config SQLite. + // Two staging targets, both required: + // + // 1. lib/ext/ on every Lucee Express install — Tomcat classpath, + // where Class.forName("org.sqlite.JDBC") resolves. Mirrors the + // brew/chocolatey wrapper's drop strategy. + // + // 2. bundles/ on every per-server Lucee context — Lucee 7's + // datasource resolver consults its OSGi bundle loader (NOT + // the parent Tomcat classloader) when instantiating drivers + // for `cfquery`. lib/ext/ alone is not enough: the very first + // query against a SQLite datasource fails because Lucee's + // bundle resolver can't find the driver, even though + // `Class.forName` would. See onboarding finding F2. + // + // Both paths are idempotent. Pre-stage runs before LuCLI extracts + // Express on the very first VM run (so dirs may not exist yet); + // post-stage runs after, when both dirs are guaranteed to exist. stager.stageIntoLibExt( bundleSrc = bundleSrc, expressRoot = lucliHome & "/express", jarFileName = "sqlite-jdbc-3.49.1.0.jar" ); + stager.stageIntoServerBundles( + bundleSrc = bundleSrc, + serversRoot = lucliHome & "/servers", + jarFileName = "org.xerial.sqlite-jdbc-3.49.1.0.jar" + ); } catch (any e) { // Stay out of the way — let LuCLI's server start surface the real // error if the bundle was actually needed and we couldn't stage. diff --git a/cli/lucli/services/BundleStager.cfc b/cli/lucli/services/BundleStager.cfc index 4a2a32c723..d7501f9d36 100644 --- a/cli/lucli/services/BundleStager.cfc +++ b/cli/lucli/services/BundleStager.cfc @@ -89,4 +89,58 @@ component { return result; } + /** + * Copy the given JAR into every per-server Lucee bundles directory + * beneath `serversRoot` (i.e. LUCLI_HOME/servers//lucee-server/bundles). + * Idempotent — skips destinations that already have the JAR. Best-effort — + * silently swallows per-server copy failures. + * + * Why this is necessary on top of `stageIntoLibExt`: Lucee 7's datasource + * subsystem resolves JDBC drivers through its OSGi bundle loader, not the + * Tomcat parent classloader. A JAR on the Tomcat `lib/ext/` classpath is + * visible to `Class.forName()` but not to Lucee's datasource resolver, + * which means SQLite-by-default apps fail at the first `cfquery` even when + * `lib/ext/` has the driver. Onboarding finding F2 documents this — every + * fresh `wheels migrate latest` against a SQLite app fails until the JAR + * lands in bundles/. MySQL and PostgreSQL avoid the issue because their + * bundles ship inside `lucee-7.x.x.jar` and Lucee auto-deploys them; the + * SQLite extension is third-party and doesn't get the same treatment. + * + * @bundleSrc Absolute path to the source JAR (typically the OSGi-named + * variant, e.g. `org.xerial.sqlite-jdbc-3.49.1.0.jar`, since + * Lucee's bundle loader uses the symbolic name). + * @serversRoot Absolute path to LUCLI_HOME/servers/ — staging looks at + * every immediate child directory and treats it as a server + * context. + * @jarFileName Filename to use at the destination. Should match the OSGi + * symbolic name + version (e.g. `org.xerial.sqlite-jdbc-3.49.1.0.jar`). + */ + public struct function stageIntoServerBundles( + required string bundleSrc, + required string serversRoot, + required string jarFileName + ) { + var result = { staged: [], skipped: [], failed: [] }; + if (!fileExists(arguments.bundleSrc)) return result; + if (!directoryExists(arguments.serversRoot)) return result; + + var servers = directoryList(arguments.serversRoot, false, "name"); + for (var s in servers) { + var bundlesDir = arguments.serversRoot & "/" & s & "/lucee-server/bundles"; + if (!directoryExists(bundlesDir)) continue; + var dest = bundlesDir & "/" & arguments.jarFileName; + if (fileExists(dest)) { + arrayAppend(result.skipped, dest); + continue; + } + try { + fileCopy(arguments.bundleSrc, dest); + arrayAppend(result.staged, dest); + } catch (any e) { + arrayAppend(result.failed, dest); + } + } + return result; + } + } diff --git a/cli/lucli/tests/specs/services/BundleStagerSpec.cfc b/cli/lucli/tests/specs/services/BundleStagerSpec.cfc index d1aae91747..21ed13e5ec 100644 --- a/cli/lucli/tests/specs/services/BundleStagerSpec.cfc +++ b/cli/lucli/tests/specs/services/BundleStagerSpec.cfc @@ -34,6 +34,15 @@ component extends="wheels.wheelstest.system.BaseSpec" { return root; } + private string function makeTempServersRoot(required array serverNames) { + var root = getTempDirectory() & "wheels-servers-#createUUID()#"; + directoryCreate(root, true); + for (var s in arguments.serverNames) { + directoryCreate(root & "/" & s & "/lucee-server/bundles", true); + } + return root; + } + function run() { describe("BundleStager.projectUsesSqliteDatasource", () => { @@ -165,5 +174,76 @@ component extends="wheels.wheelstest.system.BaseSpec" { } }); }); + + describe("BundleStager.stageIntoServerBundles", () => { + + it("copies the bundle JAR into every server's lucee-server/bundles/", () => { + var jar = makeTempBundleJar(); + var servers = makeTempServersRoot(["blog", "another-app"]); + try { + var r = variables.stager.stageIntoServerBundles(jar, servers, "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + expect(arrayLen(r.staged)).toBe(2); + expect(arrayLen(r.skipped)).toBe(0); + expect(arrayLen(r.failed)).toBe(0); + expect(fileExists(servers & "/blog/lucee-server/bundles/org.xerial.sqlite-jdbc-3.49.1.0.jar")).toBeTrue(); + expect(fileExists(servers & "/another-app/lucee-server/bundles/org.xerial.sqlite-jdbc-3.49.1.0.jar")).toBeTrue(); + } finally { + fileDelete(jar); + directoryDelete(servers, true); + } + }); + + it("is idempotent — already-staged JARs report as 'skipped' (no overwrite)", () => { + var jar = makeTempBundleJar(); + var servers = makeTempServersRoot(["blog"]); + try { + variables.stager.stageIntoServerBundles(jar, servers, "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + var r2 = variables.stager.stageIntoServerBundles(jar, servers, "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + expect(arrayLen(r2.staged)).toBe(0); + expect(arrayLen(r2.skipped)).toBe(1); + expect(arrayLen(r2.failed)).toBe(0); + } finally { + fileDelete(jar); + directoryDelete(servers, true); + } + }); + + it("returns empty result when serversRoot is missing (no servers started yet)", () => { + var jar = makeTempBundleJar(); + try { + var r = variables.stager.stageIntoServerBundles(jar, "/nonexistent/path", "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + expect(arrayLen(r.staged)).toBe(0); + expect(arrayLen(r.skipped)).toBe(0); + } finally { + fileDelete(jar); + } + }); + + it("returns empty result when bundleSrc is missing", () => { + var servers = makeTempServersRoot(["blog"]); + try { + var r = variables.stager.stageIntoServerBundles("/nonexistent.jar", servers, "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + expect(arrayLen(r.staged)).toBe(0); + } finally { + directoryDelete(servers, true); + } + }); + + it("skips server entries that don't have a lucee-server/bundles/ directory yet", () => { + var jar = makeTempBundleJar(); + var servers = getTempDirectory() & "wheels-servers-partial-#createUUID()#"; + directoryCreate(servers, true); + directoryCreate(servers & "/blog/lucee-server/bundles", true); + directoryCreate(servers & "/half-built", true); // no lucee-server/bundles + try { + var r = variables.stager.stageIntoServerBundles(jar, servers, "org.xerial.sqlite-jdbc-3.49.1.0.jar"); + expect(arrayLen(r.staged)).toBe(1); + expect(fileExists(servers & "/blog/lucee-server/bundles/org.xerial.sqlite-jdbc-3.49.1.0.jar")).toBeTrue(); + } finally { + fileDelete(jar); + directoryDelete(servers, true); + } + }); + }); } } From a9a948ebcf2be65aa752911888c2891b918c5a57 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:56:09 -0700 Subject: [PATCH 4/6] fix(cli): wheels reload purges per-server cfclasses cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LuCLI's dev-server Lucee config defaults to `inspectTemplate=once`, which means Lucee compiles each CFC once, caches the .class on disk under `/lucee-server/context/cfclasses/`, and never rechecks the source timestamp. Wheels' `?reload=true` resets framework state via applicationStop() but does not invalidate Lucee's template cache, so source edits to models, controllers, and config silently keep returning the old compiled class. Even `wheels stop && wheels start` doesn't help — the .class on disk persists. The only path that picks up changes is physically wiping cfclasses. Onboarding finding F5; the single-biggest time sink across the tutorial run. `wheels reload` now physically wipes the cfclasses dir for the project's registered server before triggering the HTTP reload. Order matters: purge first, then reload — the application reset on the next request gets the fresh compile. Extract the deletion into CfclassesPurger.cfc (matching the BundleStager extraction pattern) so the logic is unit-testable without touching a live server. 5 new specs cover happy path, dir preservation, missing path, empty path, and idempotency. Best-effort: per-file failures (Lucee-locked .class mid-compile, Windows file lock) are swallowed rather than aborting the reload — the user is better served by a partial-purge than a full crash. --- cli/lucli/Module.cfc | 39 +++++++++ cli/lucli/services/CfclassesPurger.cfc | 60 ++++++++++++++ .../specs/services/CfclassesPurgerSpec.cfc | 79 +++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 cli/lucli/services/CfclassesPurger.cfc create mode 100644 cli/lucli/tests/specs/services/CfclassesPurgerSpec.cfc diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 23527e0e76..aa42db1e2f 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -410,6 +410,16 @@ component extends="modules.BaseModule" { var password = detectReloadPassword(); + // F5 fix: physically wipe the Lucee compiled-class cache before + // triggering the framework reload. Lucee Express's default + // `inspectTemplate=once` means Lucee compiles each CFC once, caches + // the .class on disk, and never re-checks the source timestamp. + // `?reload=true` resets Wheels application state via applicationStop() + // but does not invalidate Lucee's template cache, so edits to models, + // controllers, and config silently miss until cfclasses is wiped. + // See onboarding finding F5. + $purgeServerCfclasses(); + try { var reloadUrl = "http://localhost:#serverPort#/?reload=true&password=#password#"; var httpResult = makeHttpRequest(reloadUrl); @@ -3866,6 +3876,35 @@ component extends="modules.BaseModule" { } } + /** + * Wipe the per-server Lucee compiled-class cache so the next request + * recompiles every CFC from source. Called from `wheels reload` because + * Lucee's default `inspectTemplate=once` setting prevents source-edit + * detection — without a physical cache wipe, `?reload=true` only resets + * Wheels' application state and edits to models, controllers, and config + * keep returning the previously-compiled .class on subsequent requests. + * See onboarding finding F5. + * + * Best-effort: silently swallows per-file failures (a Lucee-locked .class + * mid-compile or a Windows file lock) rather than blocking the reload. + * The cfclasses directory itself is preserved — Lucee repopulates it on + * the next compile. + */ + private void function $purgeServerCfclasses() { + try { + var lucliHome = $resolveLucliHome(); + if (!len(lucliHome)) return; + + var serverName = $findServerForProject(variables.projectRoot); + if (!len(serverName)) return; + + var cfclassesDir = lucliHome & "/servers/" & serverName & "/lucee-server/context/cfclasses"; + new services.CfclassesPurger().purge(cfclassesDir); + } catch (any e) { + // Don't block reload on cache-purge errors. + } + } + /** * Look up the registered LuCLI server entry whose `.project-path` * matches the given project root. Returns the server name, or empty diff --git a/cli/lucli/services/CfclassesPurger.cfc b/cli/lucli/services/CfclassesPurger.cfc new file mode 100644 index 0000000000..a858cce3f3 --- /dev/null +++ b/cli/lucli/services/CfclassesPurger.cfc @@ -0,0 +1,60 @@ +/** + * Compiled-class cache purge helper for `wheels reload`. + * + * Lucee's default `inspectTemplate=once` setting compiles each CFC once, + * caches the .class on disk under `/lucee-server/context/cfclasses/`, + * and never rechecks the source timestamp. Wheels' `?reload=true` resets + * framework state via `applicationStop()` but does not invalidate Lucee's + * template cache — so source edits to models, controllers, and config + * silently miss until cfclasses is wiped. See onboarding finding F5. + * + * Isolated from Module.cfc so the deletion logic can be exercised against + * temp directories without touching a live Lucee server. + */ +component { + + public function init() { + return this; + } + + /** + * Delete the contents of the given cfclasses directory, preserving the + * directory itself so Lucee can repopulate it on the next compile. + * + * Best-effort: per-entry failures (file locked by an active compile, OS + * permission issue) are recorded but don't abort the purge — better to + * clear what we can than to bail entirely. + * + * Returns a struct documenting what happened, useful for tests and + * diagnostic logging: + * { purged: [list of paths deleted], + * failed: [list of paths that couldn't be deleted] } + * + * @cfclassesDir Absolute path to a server's cfclasses directory (e.g. + * LUCLI_HOME/servers//lucee-server/context/cfclasses). + * If the path doesn't exist, returns empty result without + * error — a pristine server has no cache to purge. + */ + public struct function purge(required string cfclassesDir) { + var result = { purged: [], failed: [] }; + if (!len(arguments.cfclassesDir)) return result; + if (!directoryExists(arguments.cfclassesDir)) return result; + + var entries = directoryList(arguments.cfclassesDir, false, "name"); + for (var entry in entries) { + var path = arguments.cfclassesDir & "/" & entry; + try { + if (directoryExists(path)) { + directoryDelete(path, true); + } else { + fileDelete(path); + } + arrayAppend(result.purged, path); + } catch (any e) { + arrayAppend(result.failed, path); + } + } + return result; + } + +} diff --git a/cli/lucli/tests/specs/services/CfclassesPurgerSpec.cfc b/cli/lucli/tests/specs/services/CfclassesPurgerSpec.cfc new file mode 100644 index 0000000000..bccbd8cd3b --- /dev/null +++ b/cli/lucli/tests/specs/services/CfclassesPurgerSpec.cfc @@ -0,0 +1,79 @@ +/** + * Regression coverage for onboarding finding F5 — `wheels reload` was not + * invalidating Lucee's compiled-class cache, so source edits to models and + * controllers silently missed until cfclasses was physically wiped. + * CfclassesPurger is what `wheels reload` delegates to before triggering + * the framework's `?reload=true` handler. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.purger = new cli.lucli.services.CfclassesPurger(); + } + + private string function makeTempCfclassesDir() { + var root = getTempDirectory() & "wheels-cfclasses-#createUUID()#"; + directoryCreate(root, true); + // Mirror the typical Lucee layout — class files at root, RPC subdir + // for ephemeral compiled CFCs, classloader-resources.json metadata. + fileWrite(root & "/Application_cfc$cf.class", "fake compiled class"); + fileWrite(root & "/Post_cfc$cf.class", "fake compiled class"); + directoryCreate(root & "/RPC/abc123", true); + fileWrite(root & "/RPC/abc123/PostsController_cfc$cf.class", "fake compiled class"); + fileWrite(root & "/RPC/abc123/classloader-resources.json", "{}"); + return root; + } + + function run() { + describe("CfclassesPurger.purge", () => { + + it("deletes every file and subdirectory inside the cfclasses dir", () => { + var dir = makeTempCfclassesDir(); + try { + var r = variables.purger.purge(dir); + expect(arrayLen(r.purged)).toBe(3); // 2 .class files + 1 RPC dir + expect(arrayLen(r.failed)).toBe(0); + expect(directoryExists(dir)).toBeTrue(); // dir itself preserved + expect(arrayLen(directoryList(dir, false, "name"))).toBe(0); + } finally { + if (directoryExists(dir)) directoryDelete(dir, true); + } + }); + + it("preserves the cfclasses directory itself so Lucee can repopulate it", () => { + var dir = makeTempCfclassesDir(); + try { + variables.purger.purge(dir); + expect(directoryExists(dir)).toBeTrue(); + } finally { + if (directoryExists(dir)) directoryDelete(dir, true); + } + }); + + it("returns empty result when cfclassesDir is missing (server never started)", () => { + var r = variables.purger.purge("/nonexistent/path/to/cfclasses"); + expect(arrayLen(r.purged)).toBe(0); + expect(arrayLen(r.failed)).toBe(0); + }); + + it("returns empty result when cfclassesDir is an empty string", () => { + var r = variables.purger.purge(""); + expect(arrayLen(r.purged)).toBe(0); + expect(arrayLen(r.failed)).toBe(0); + }); + + it("is idempotent — purging an already-empty dir is a no-op", () => { + var dir = getTempDirectory() & "wheels-cfclasses-empty-#createUUID()#"; + directoryCreate(dir, true); + try { + var r = variables.purger.purge(dir); + expect(arrayLen(r.purged)).toBe(0); + expect(arrayLen(r.failed)).toBe(0); + expect(directoryExists(dir)).toBeTrue(); + } finally { + if (directoryExists(dir)) directoryDelete(dir, true); + } + }); + }); + } +} From e9652ddee71a7de344f576d1cbd22061a252ea28 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:56:50 -0700 Subject: [PATCH 5/6] fix(cli): refuse wheels start/stop in non-wheels-project directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LuCLI's `server start` and `server stop` derive the server name from the cwd basename when no registered server matches. Running `wheels start` or `wheels stop` from a parent directory or unrelated dir (easy to do by accident, especially in shell sessions where cwd resets between commands) silently registers a phantom server context named after that dir. The user ends up with ghost entries like `ws` or `Downloads` in `wheels server list`, with `` webroots and no path to clean up. Onboarding finding F6. Add a `$isWheelsProjectDir(path)` helper that checks for `config/settings.cfm` — the file `wheels new` always writes and that no other tool creates (box.json and Application.cfc are not specific enough). - `wheels start` refuses up-front when not in a Wheels project, pointing the user at `wheels new`. - `wheels stop` already had a guard for the case where the cwd has no registered server but other servers are running (reports them); extend it to also refuse fallthrough when no servers are running at all, so we never reach LuCLI's name-from-cwd code path. --- cli/lucli/Module.cfc | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index aa42db1e2f..b4307fa77a 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -444,6 +444,20 @@ component extends="modules.BaseModule" { public string function start() { var args = getArgs(arguments); + // Refuse to start from a non-Wheels-project directory. LuCLI's + // `server start` derives the server name from the cwd basename and + // silently registers a new context — running `wheels start` in the + // wrong directory leaves orphan registrations like `ws` or + // `Downloads`. Onboarding finding F6. + if (!$isWheelsProjectDir(variables.projectRoot)) { + out("This directory does not look like a Wheels project.", "yellow"); + out(" Expected: config/settings.cfm under the current directory.", "yellow"); + out(""); + out("Tip: cd into your project directory, or run `wheels new `", "cyan"); + out(" to scaffold one.", "cyan"); + return ""; + } + out("Starting Wheels server...", "cyan"); // Stage required JDBC drivers into the Lucee Express lib/ext/ before @@ -503,6 +517,15 @@ component extends="modules.BaseModule" { out("To list all servers: wheels server list", "cyan"); return ""; } + // No registered server AND no others running. Don't fall through + // to LuCLI's `server stop` — it would create a phantom server + // registration named after the cwd basename. Onboarding finding F6. + out(""); + out("No Wheels server is registered for this directory, and none are running elsewhere.", "yellow"); + if (!$isWheelsProjectDir(variables.projectRoot)) { + out("Tip: run this from inside your Wheels project directory.", "cyan"); + } + return ""; } executeCommand("server", ["stop"], variables.projectRoot); @@ -3876,6 +3899,23 @@ component extends="modules.BaseModule" { } } + /** + * True when the given directory has the structural fingerprint of a + * Wheels project — currently presence of `config/settings.cfm`, the file + * `wheels new` always writes and that no other tool creates. Used by + * `start()` and `stop()` to refuse silent fallthrough to LuCLI's `server` + * subcommands, which would otherwise register a phantom server context + * named after the cwd basename. See onboarding finding F6. + * + * `box.json` alone is not enough — too many non-Wheels CommandBox + * projects ship one — and `Application.cfc` is not enough either since + * any CFML codebase has one. + */ + private boolean function $isWheelsProjectDir(required string path) { + if (!len(arguments.path)) return false; + return fileExists(arguments.path & "/config/settings.cfm"); + } + /** * Wipe the per-server Lucee compiled-class cache so the next request * recompiles every CFC from source. Called from `wheels reload` because From 1ae8e25f39d5f842cef4d47976efe1a315c94c08 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 28 Apr 2026 21:57:18 -0700 Subject: [PATCH 6/6] fix(cli): wheels browser test defaults to app dir and app test runner Two bugs in `wheels browser test` made it useless from the app for running browser specs. Onboarding finding F11. - Default directory was `wheels.tests.specs.wheelstest`, the framework's internal browser-DSL test directory. Apps live at `tests/specs/browser/`, so the runner reported 0 tests against a fresh app spec. - Test URL hit `/wheels/core/tests`, the framework's core test runner. That endpoint only knows about specs under `vendor/wheels/tests/specs/`. App-side specs are mounted by `/wheels/app/tests`, the app test runner. Default directory is now `tests.specs.browser` and the runner hits `/wheels/app/tests`. `--directory=...` still overrides for advanced use. Two related sub-issues from the same finding (running browser specs corrupts the dev server's cfclasses cache for other specs; WHEELS_ENV=test wheels migrate latest looks for an unconfigured `wheelstestdb_sqlite` datasource) are out of scope for this fix and tracked separately. --- cli/lucli/Module.cfc | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index b4307fa77a..ed7ed882df 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -4742,7 +4742,13 @@ component extends="modules.BaseModule" { private string function browserTest(array args = []) { var format = "text"; var verboseOutput = false; - var directory = "wheels.tests.specs.wheelstest"; + // Default to the APP's browser specs (tests/specs/browser/) — not the + // framework's internal browser specs. Onboarding finding F11 reported + // `wheels browser test` running 0 tests because it pointed at + // `wheels.tests.specs.wheelstest`, the framework's own browser-DSL + // test directory, which contains no app code. Override with + // `--directory=...` for advanced use. + var directory = "tests.specs.browser"; for (var i = 2; i <= arrayLen(args); i++) { var arg = args[i]; @@ -4798,7 +4804,11 @@ component extends="modules.BaseModule" { out(""); var serverPort = $getServerPort(); - var testUrl = "http://localhost:#serverPort#/wheels/core/tests?db=sqlite&format=json&directory=#directory#"; + // Hit the APP test runner (`/wheels/app/tests`), not the framework's + // core test runner (`/wheels/core/tests`). The latter only knows + // about specs under `vendor/wheels/tests/specs/`. Apps live under + // `tests/specs/`, mounted by the app runner. F11. + var testUrl = "http://localhost:#serverPort#/wheels/app/tests?db=sqlite&format=json&directory=#directory#"; try { var httpResult = makeHttpRequest(testUrl);