Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 129 additions & 25 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -434,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 <appname>`", "cyan");
out(" to scaffold one.", "cyan");
return "";
}

out("Starting Wheels server...", "cyan");

// Stage required JDBC drivers into the Lucee Express lib/ext/ before
Expand Down Expand Up @@ -493,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);
Expand Down Expand Up @@ -3797,26 +3830,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.
*
* - `<express-root>/lib/ext/` — Tomcat parent classpath. Satisfies
* `Class.forName("org.sqlite.JDBC")` for any caller that uses raw
* JDBC.
*
* 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.
* - `<server-root>/<server-name>/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
* 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 {
Expand All @@ -3833,24 +3866,85 @@ 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.
}
}

/**
* 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
* 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
Expand Down Expand Up @@ -4648,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];
Expand Down Expand Up @@ -4704,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);
Expand Down
54 changes: 54 additions & 0 deletions cli/lucli/services/BundleStager.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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;
}

}
60 changes: 60 additions & 0 deletions cli/lucli/services/CfclassesPurger.cfc
Original file line number Diff line number Diff line change
@@ -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 `<server>/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/<server>/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;
}

}
9 changes: 7 additions & 2 deletions cli/lucli/services/Scaffold.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading