Wheels 4.0.3 — third patch on the 4.0 line. Completes the CLI argument-parsing overhaul (
ArgSpecconsumes LuCLI's structured arguments in every command —--no-*negations and named-only flags now reach their parsers, and user-error paths exit non-zero) and lands the fixes from a full 24-command CLI audit; write-side commands (migrate,seed,reload,generate admin) now refuse to attach to a sibling project's server instead of running against the wrong database; PostgreSQL/CockroachDB foreign-key migrations and pre-23c OracleDROP TABLE/DROP VIEWwork again; framework helpers can no longer be invoked as controller actions from a URL; auto-derived model properties preserve database column casing; and scaffolded apps keep their reload password out of source control (WHEELS_RELOAD_PASSWORDin.env). ~45 PRs since the 4.0.2 GA (2026-05-27).
Added
wheels <cmd> --helpnow renders command-specific help from the command function's metadatahint, resolving theg/daliases (sowheels g --helpreaches thegeneratehelp, andwheels d --helpreachesdestroy). Unknown commands and the barewheels help/wheels --helppath fall through to the global listing unchanged. Forward-compatible:showHelp()with no subcommand argument behaves exactly as before, so the feature is dormant until the matching LuCLI dispatch fix (bpamiri/LuCLI#5) ships — the runtime currently discards the subcommand on--helpand always callsshowHelpwith no args (#2886)- wheels-bot can now review fork PRs (external / first-time contributors), which it previously could not. GitHub withholds both the
varscontext andsecretsfrompull_requestruns triggered by a forked repository, so Reviewer A'svars.WHEELS_BOT_ENABLED == 'true'job gate read empty and the job skipped — and Reviewer B, which only fires after A submits a review, never ran. A newbot-review-a-fork.ymlworkflow runs the initial Reviewer A review viapull_request_target(which executes in the base-repo context, where vars + secrets are available) for fork PRs that a maintainer has tagged with thebot-reviewlabel. Hardened against thepull_request_target"pwn-request" class: it checks out the base branch only and reviews the fork's changes throughgh pr diff, never checking out or executing fork-controlled code, so the local./.github/actions/wheels-bot-skip-checkcomposite action always resolves to trusted base code (the fork's commit objects are fetched read-only viarefs/pull/<n>/headso the review's git commands still resolve).bot-review-b.ymlis hardened the same way — it previously checked outgithub.event.review.commit_id(a fork commit on fork PRs) and then ran that local composite action, a latent pwn-request that was unexploitable only because Reviewer A never started the loop on forks; it now checks out the base branch withpersist-credentials: false. Thebot-reviewlabel (appliable only by write-access users) is the human-in-the-loop vet of the fork diff, and Reviewer A's tool surface stays read-only (#2871) cli.lucli.services.ArgSpec— a typed argument-spec builder for Wheels CLI subcommands. LuCLI hands every module function a structured argument map (positionals asarg1, arg2, ...;--key=valueaskey=value;--no-keynormalized tokey=false), butModule.cfc::argsFromCollection()has historically flattened that map back to argv so each of ~18 subcommands could re-parse it with a hand-rolled token loop. The flatten step was the root cause of #2855 (it silently dropped everyfalsevalue, so--no-sqlite/--no-routes/--no-test-db/--no-open-browsernever survived the round trip) and is structurally lossy — it cannot distinguish a genuine--no-Xnegation from an explicit--X=false.ArgSpecconsumes the structured handoff directly: a command declares its positionals, flags, and options up front (.positional(name, required, default, type),.flag(name, default),.option(name, default, type)), then calls.parse(arguments)to receive a typed result struct — no flatten, no re-parse, no lossyfalseround trip. Designed for incremental adoption:getArgs()andargsFromCollection()remain in place as a deprecated shim until every call site is converted, and each command that adoptsArgSpecdrops its hand-rolled token loop in the same change. Cross-engine clean (no closures, no struct-member collisions, noapplication-scope function storage, noattributeCollection = arguments); boolean coercion handles both the string"false"LuCLI normally emits and a literalfalsevalue, so Lucee/Adobe/BoxLang all agree on the parsed semantics. Required-positional violations throwWheels.CLI.MissingArgumentwith the positional's declared name in the message. The cross-framework research that informed the API surface (Rails/Thor, Laravel/Artisan, Django/argparse, Phoenix/Mix, Spring/picocli, Symfony Console) is recorded on the issue (#2861)- A "Reserved scope names" section in the Controllers and Actions guide documenting identifiers (
client,url,form,session,cgi,request,application,cookie,server,arguments,variables,local,this) that must not be used as local variable names in Wheels controllers (and CFML components generally). Specifically calls outclient— the most confusing case — because Lucee 7 throws"client scope is not enabled"whenclientManagementis off, making the error look like an application misconfiguration rather than a bad variable name (#2833) - RustCFML is now recognized as a first-class engine in the engine-adapter layer. Wheels detects it via
server.coldfusion.productName == "RustCFML"(it exposes noserver.lucee/server.boxlang), instantiates aRustCFMLAdapter(extendsBase, whose defaults are Lucee-shaped, matching RustCFML's semantics) ordered before the Adobe ColdFusion fallback, and accepts any version in$checkMinimumVersion(RustCFML is pre-1.0 and rapidly evolving, so the usual minimum-version guard doesn't apply). Because RustCFML does not yet implement thecfcachebuilt-in, the framework's cfcache-backed template/static cache degrades gracefully to a no-op when the adapter reportssupportsCfcache() = false, so requests still render (cacheless-but-working). The newsupportsCfcache()capability defaults totrueon Lucee/Adobe/BoxLang, leaving their behavior unchanged. Support is best-effort: RustCFML is a young, JVM-free CFML interpreter and is not yet part of the CI matrix (#2837) - The built-in
/_browser/login-asbrowser-test fixture (mounted byset(loadBrowserTestFixtures = true)) now honors anapplication.wheels.browserLoginAsHandleroverride. Set it inconfig/settings.cfm—set(browserLoginAsHandler = "AuthFixture##loginAs")— and the framework dispatches/_browser/login-asto that controller##action instead of the defaultBrowserTestLogin##create, letting apps with richer session shapes (e.g.session.member = { id, email, firstName, lastName }) drive the fixture without forking the vendor tree or duplicating the route + env-gate boilerplate. Env-gating moves to a newwheels.middleware.BrowserTestFixtureGuardmiddleware attached to the/_browserscope so the gate still applies under override. The setting falls back toBrowserTestLogin##createwhen unset or empty (#2830)
Changed
- CLI user-error paths now exit non-zero instead of silently returning success. Several commands printed a red error message and then
return "", which LuCLI maps to exit code 0 — so a typo'd subcommand or a failed migration looked like success to CI pipelines, deploy scripts, and pre-commit hooks. The following now throw a typed exception (Wheels.InvalidArgumentsfor unknown input; the original error is re-thrown for runtime failures), which LuCLI maps to a non-zero exit while still printing the same friendly diagnostic first:wheels generate <unknown-type>,wheels create <unknown-type>,wheels migrate <unknown-action>,wheels db <unknown-subcommand>, a failedwheels migrate latest|up|down|info|doctor|rename-system-tables(re-throws the underlyingMigrationErrorinstead of swallowing it), andwheels routeswhen the server returns an unparseable or unsuccessful response (Wheels.RoutesFailed). Help/no-args paths (wheels generate,wheels dbwith no subcommand) and non-error states (wheels routeswith zero configured routes,wheels db resetwithout--force) are unchanged and still exit 0. Over MCP these surface as proper tool errors instead of empty results. Scripts that previously relied on these error paths exiting 0 will now see a non-zero exit — that is the intended fix. - The
consoleandtestCLI subcommands now consume LuCLI's structuredargCollectiondirectly viacli.lucli.services.ArgSpec(parseConsoleArgs/parseTestArgscalling.parse(structuredArgs(arguments))), continuing the #2861 migration (whoseArgSpecfoundation shipped in #2862) past the eight leaf commands.consolereads--password=<value>;testreads--filter(and its documented--directoryalias),--reporter,--db(tracked as explicit so the runner distinguishes an implicit default from a chosen one), the--verbose/--ci/--coreflags,--no-test-db(test-db=false), a bare positional filter, and the-vshorthand (which LuCLI delivers as a positional). Both also fix the latentarg1-gate the round trip masked: named-only invocations likewheels console --password=xandwheels test --core(no positional) now take effect instead of silently running with defaults. One deliberate behavioral delta per command: the space-separated option forms (wheels console --password secret,wheels test --filter models) are dropped for the--key=valueforms — LuCLI delivers a space-separated value as a bare flag plus a separate positional, never a named value. Everything else is preserved:test's APP-vs---coremode default,$normalizeTestFiltershort-name normalization, and the fullrunTests(...)argument set;console's reload-password auto-detection when none is supplied. Covered by 13 new server-free specs incli/lucli/tests/specs/commands/CommandArgParsingSpec.cfc(viaModuleArgvProbe). This leaves the dispatchers (generate,create,db,browser), the parser-delegatingdeploy/packages/migrate, and the LuCLI-passthroughstarton thegetArgs()shim, which is removed once those are converted (#2861) - Eight leaf CLI subcommands —
new,seed,notes,analyze,doctor,stats,upgrade, anddestroy— now consume LuCLI's structuredargCollectiondirectly viacli.lucli.services.ArgSpec(.parse(structuredArgs(arguments))) instead of flattening it back to argv and re-parsing with a hand-rolled token loop (the round trip tracked in #2861, whoseArgSpecfoundation shipped in #2862). Beyond removing the per-command parsing duplication, this fixes a latent bug the round trip masked: the legacygetArgs()only rebuilt argv when a positionalarg1was present, so named-only invocations were silently dropped one layer in —wheels seed --environment=production,wheels doctor --verbose,wheels stats --verbose, andwheels notes --annotations=...all ran with defaults regardless of what the user passed. Consuming the structured map directly means the named keys (and--no-Xnegations) survive. One deliberate behavioral delta:wheels newwith options but no app name (e.g.wheels new --no-sqlite) now errors with the #2214Wheels.InvalidArguments"app name required" exception instead of falling through to the usage guide — previously thearg1-gate dropped the named-only args, leaving an empty arg list that took the usage branch. Everything else is preserved: each command keeps its usage branches and the #2214 throw,destroy's<type> <name>/<name> <type>smart reorder (now gap-tolerant, so--forcemay appear before or after the positionals),upgrade'scheck-gate and--dry-run/--to"did you mean" nudge, anddoctor/stats's-vshorthand (which LuCLI delivers as a positional, not a flag). A new privatestructuredArgs()/argvToCollection()helper pair sources the collection — preferring LuCLI's live handoff and reconstructing it from the instance-level__argumentsfallback for internal delegation (e.g.create→new) and unit tests. The migrated parse logic is covered by server-free specs incli/lucli/tests/specs/commands/CommandArgParsingSpec.cfc(viaModuleArgvProbe).getArgs()/argsFromCollection()remain as the deprecated shim for the not-yet-migrated commands — the dispatchers (generate,create,db,browser), the parser-delegatingdeploy/packages,migrate, and the space-separated-flagtest/console— and the shim is removed once those are converted (#2861) - The final eight CLI subcommands — the dispatchers
generate,create,db,browser; the parser-delegatingdeploy,packages,migrate; and the LuCLI-passthroughstart— now source LuCLI's structuredargCollectionthroughstructuredArgs(arguments)and reconstruct argv once via the newcli.lucli.services.ArgSpec.toArgv()passthrough, instead of the per-commandgetArgs()/argsFromCollection()round trip. With every call site converted, thegetArgs()/argsFromCollection()shim is removed fromModule.cfc, completing the #2861 migration (whoseArgSpecfoundation shipped in #2862 and whose leaf/console/testbatches landed in #2872 and #2874). These commands forward to downstream consumers that take a flat argv array (the deepgeneratesub-handlers, the unit-testedDeployArgsParserand packages parsers,runForgetOrPretend, and LuCLI's ownserver start), so the maintainer-chosen passthrough keeps those parsers — and their dedicated test suites — unchanged rather than rewriting them.toArgv()is non-lossy: it re-emits--no-Xforkey=false(the #2856 fix, now centralized in one tested place) so negations like--no-routes/--no-migrationsurvive. The migration is behavior-preserving —structuredArgs()and the deletedgetArgs()read the__argumentsfallback identically, andtoArgv()copiesargsFromCollection()'s emit logic exactly — and incidentally retires the samearg1-gate latent bug the leaf-command migration did:wheels start --forcewith no positional previously fell through to an empty arg list. The #2855--no-sqlitenegation is now pinned end-to-end by a new assertion intools/test-onboarding.sh(real CLI → LuCLI → ArgSpec → scaffolder: nodb/*.sqlitefiles,lucee.jsonconfiguration.datasources == {}), closing the test-robustness gap the #2856 unit test left open.argsFromCollection's unit coverage moved toArgSpec.toArgvspecs; thegetArgsarg1-gate regression spec was retired with the function. Closes #2861 - Reconcile bot pipeline unblock plan doc with shipped implementation: mark checkboxes as historically complete and align the allowlist note with the final
classify-conflicts.sh - Version switcher now labels the 4.0 stable docs "v4.0 (current)" (was "v4.0.0"); the vestigial pre-GA
v4-0-1-snapshotguides tree is removed and its one unique page, "Reading the Changelog", is salvaged intov4-0-0/upgrading/. Both sites deploy fromdevelop, so in-progress patch docs already live in thev4-0-0tree; a separate*-snapshottree is only warranted when a different minor/major (e.g.v4-1-snapshot) is under development. Courtesy redirects cover the high-traffic/v4-0-1-snapshot/*paths (#2827) - CLI path normalisation now lives in a single, unit-tested
Helpers.normalizePath();Module.$normalizePath()(added in #2835 to fix the WindowsResource provider [c]crash) delegates to it instead of carrying a private copy, so the regression coverage exercises the real bootstrap path rather than a decoy. The CLI installation guide also gains a Windows troubleshooting entry for the originalthere is no Resource provider available with the name [c]error (#2841)
Fixed
- CLI-audit tail polish (follow-up to the #2882–#2886 audit sweep):
wheels infonow renders the framework-version line again — it read the long-gonevendor/wheels/events/onapplicationstart/settings.cfmpath and silently printed nothing, so it now reads the authoritativevendor/wheels/wheels.jsonmanifest by absolute path (nowheelsmapping needed) and applies the same structural placeholder guard aswheels.BuildInfo(an unstamped dev checkout reports0.0.0-devrather than leaking the raw@build.version@token). Two internal$-prefixed test helpers ($normalizeTestFilter,$resolveAppTestDataSource) were leaking into the MCPtools/listas callable tools — they are now listed inmcpHiddenTools()(keptpublicsoTestCommandSpeccan still unit-test them directly; LuCLI matches hidden names case-insensitively).wheels --helpnow lists thecreate appcommand (it was a working command + MCP tool but absent from the banner) and itsnotesline no longer advertises aHACKdefault the parser doesn't use (the default staysTODO,FIXME,OPTIMIZE;--annotationscustomizes it).wheels reloadnow honors an explicit--password=<value>override (parity withwheels console; auto-detect from.env/config remains the default). The interactive console/helpnow lists the/datasourceand/qaliases it already accepts. Thewheels mcpinstructions and the deprecated/wheels/mcpendpoint's deprecation notice now point to the live MCP integration guide instead of a doc path (mcp-configuration-guide.md) that never existed. Docs: thewheels testflag table documents the real--directoryalias for--filter, and the agentCLAUDE.mdpackages block lists the realwheels packages registry infoverb. wheels generate scaffoldandwheels generate api-resourcenow honor--hasOne. The flag worked forwheels generate modeland is documented for scaffold/api-resource (code-generation.mdx), butScaffold.cfc'sgenerateScaffold()/generateApiResource()neither declared ahasOneparameter nor forwarded one toCodeGen.generateModel()(which already accepts it and renders the{{hasOneRelationships}}placeholder), so it was silently dropped. Both signatures now accepthasOneand pass it through, and the Module.cfc scaffold/api-resource handlers forwardarrayToList(parsed.hasOne)(mirroring howbelongsTo/hasManyare already threaded).wheels generate scaffold Employee name:string --hasOne=Profilenow emitshasOne('Profile');in the model'sconfig(); same for api-resource. Covered by newScaffoldSpeccases for both paths.- A full audit of the CLI's 24 commands repaired the broken or mis-documented paths it surfaced (#2882, #2883, #2884, #2885):
wheels gworks as a truegeneratealias again;wheels consoleaccepts--password=<value>;wheels generate api-resourceregisters its resource route;wheels validatestrips CFML comments before source-scanning so commented-out code can't satisfy (or trip) a check;wheels migrate --helpdocuments the real subcommand surface;wheels testexit-code handling andwheels deployflag handling were corrected alongside release-channel and guide docs; the generators emitenum()definitions again, warn on view-generation failures instead of continuing silently, and the duplicate-route message names the offending route; andwheels startwarns when its pinned port is already taken instead of failing opaquely. wheels reloadandwheels generate adminnow refuse to attach to a server that isn't bound to the current project, closing the same #2878 gap for two more server-dependent commands that #2879 fixed for the write-side migrators. Both reachedcli.lucli.Module::$requireRunningServer()without therequireProjectConfigflag, so in a project with nolucee.json/.envport they still fell back to the hardcoded common-port probe ([8080, 60000, 3000, 8500]) and could silently attach to a sibling app:reloadwould reset the wrong app's state, andgenerate adminwould introspect the wrong schema and scaffold its controller/views into the current project from a sibling's model — wrong-schema output written into the right project. Both now passrequireProjectConfig = true; with no project-bound port they throwWheels.ServerNotRunningwith a "set 'port' in lucee.json (or PORT in .env), then start with: wheels start" diagnostic instead of proceeding.generate adminis gated (rather than left on the read-side fallback alongsideinfo/routes) precisely because it both reads a schema and writes files into cwd, so a wrong-server attach is a correctness bug, not just a wrong read. Covered by new server-free specs incli/lucli/tests/specs/services/ServerDetectionSpec.cfcthat drivereload()andgenerateAdmin()in a no-config project and assert the guard refuses to attach (#2878)wheels migrate(and its sibling write-side runners —seed,migrate forget/pretend,migrate rename-system-tables) refuse to attach to a server that isn't bound to the current project.cli.lucli.Module::detectServerPort()previously fell back to a hardcoded common-port probe ([8080, 60000, 3000, 8500]) after exhaustinglucee.jsonand.env, so a freshly-scaffolded project with no port config could silently attach to a sibling app's open Lucee instance and run its migrations against the wrong database (the #2876 / #2878 repro:wheels new app_a+wheels startinapp_a, thenwheels migrate latestinapp_branapp_b's migrations againstapp_a's PostgreSQL).detectServerPort()now accepts arequireProjectConfigflag that skips the common-port fallback, and$requireRunningServer()threads it through to every write-side caller. When the flag is set and no project-bound port resolves, the CLI throwsWheels.ServerNotRunningwith a clear "set 'port' in lucee.json (or PORT in .env), then start with: wheels start" diagnostic instead of proceeding. Read-side commands (info,routes,console,dbStatus,dbVersion) keep the legacy fallback — they don't mutate anything, and removing it would regress the no-config development experience. Covered by new server-free specs incli/lucli/tests/specs/services/ServerDetectionSpec.cfcthat simulate a sibling app on an ephemeral port and assert the write-side guard refuses to attach (#2878)wheels migrate latestno longer crashes on PostgreSQL (and CockroachDB) when a migration emits an inline foreign-key constraint — e.g. anythingwheels generate scaffold ... --belongsTo=authorproduces.wheels.databaseAdapters.PostgreSQL.PostgreSQLMigratorwas missing the publicaddForeignKeyOptions(sql, options)method that every other adapter implements (MySQLMigrator,SQLiteMigrator,MicrosoftSQLServerMigrator,OracleMigrator);Abstract.createTable()builds the inline FK clause viaforeignKeys[i].toForeignKeySQL()→ForeignKeyDefinition.cfc→adapter.addForeignKeyOptions(...), so every PostgreSQL FK column threwComponent [wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator] has no function with name [addForeignKeyOptions]and aborted the migration. The new implementation mirrors the MySQL signature (FOREIGN KEY (col) REFERENCES tbl (refCol)), which PostgreSQL accepts verbatim, andCockroachDBMigrator(which extendsPostgreSQLMigrator) inherits the fix automatically. The reporter's "works on Windows" observation lined up with thewheels newSQLite default — only PostgreSQL/CockroachDB targets ever hit the missing method (#2876)- CLI services in
Module.cfcnow instantiate via the module-relative path (new services.X()) instead of the absolute FQN (new cli.lucli.services.X()), sowheels newand the other subcommands resolve their service classes when running from the installed distribution. The module tarball is built withtar -C cli/lucli ., which flattens the module root so services live at<module-root>/services/with nocli/lucli/tree and nocli.luclimapping — the absolute form only resolved against the source-tree layout. That split is why thefast-testjob (which runs from source, where both forms resolve) stayed green while the snapshot smoke test — which installs the built tarball and runswheels new— failed on everydeveloppush since #2861 withcould not find component or class with name [cli.lucli.services.ArgSpec]. All 8 absolute references (7×ArgSpec, 1× the latentTestRunnercall) are converted to the relative form the 17 sibling services already use; theArgSpecdocblock example is updated to match so it cannot re-seed the pattern (#2873) - Oracle
DROP TABLE/DROP VIEWin the migrator now work on Oracle 19c/21c.wheels.databaseAdapters.Oracle.OracleMigrator::dropTable()emittedDROP TABLE IF EXISTS <name> CASCADE CONSTRAINTSanddropView()inheritedDROP VIEW IF EXISTSfromAbstract, but Oracle only added theIF EXISTSDDL modifier in 23c — on 19c/21c both are a hard parse error (ORA-00933). Because theremove-tablemigration template re-throws on error,migrate down, rollbacks,force-create, and migrator test re-runs failed outright on pre-23c Oracle. Both helpers now emit the version-agnostic Oracle PL/SQL idiom —BEGIN EXECUTE IMMEDIATE 'DROP TABLE <name> CASCADE CONSTRAINTS'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END;— which runs the bare DROP and swallows ORA-00942 ("table or view does not exist"), preserving "drop if exists" semantics on every supported Oracle version with no version detection.$execute(vendor/wheels/migrator/Base.cfc) never splits on;and deliberately omits the trailing-semicolon append for Oracle, so the anonymous block reaches the driver intact. Framework-side counterpart to the demo-app test-populate fix in #2864 (#2869) application.wheels.protectedControllerMethodsis now populated at application start from the public method surface ofwheels.Globalplus thewheels.controller.*andwheels.view.*mixin components, so framework helpers likeenv(),model(),findAll(),redirectTo(), andlinkTo()can no longer be invoked as controller actions from a URL. The list was previously initialized to an empty string (the orphanedlocal.allowedGlobalMethods = "get,set,mapper"line inonapplicationstart.cfcpointed to the intent but never wired it up), so$callAction()'s allow-list check was a no-op. Any unauthenticatedGET /<anyController>/envrequest reached the globalenv()helper directly and raised"The parameter [name] to function [env] is required but was not passed in."as a 500; other helper names dispatched into unintended code paths. Derived fromgetMetaData().functionson each source component (excluding$-prefixed internal methods, which are already gated separately), so the list stays in sync with the framework's mixin surface automatically. Reaching one of these names now throwsWheels.ActionNotAllowedand falls through to the missing-action / 404 path, matching every other non-existent action. Migration note: applications that defined controller actions with the same name as a public framework helper (e.g.env,model,redirectTo) will need to rename those actions — they now return 404 rather than dispatching, since the protection gate atprocessing.cfc:132fires before theStructKeyExists(this, action)lookup that would otherwise reach a same-named user action (#2844)- wheels-bot no longer re-fires Reviewer A/B on commits it has already reviewed. The review idempotency markers (
<!-- wheels-bot:review-a:<pr>:<sha> -->/review-b) embedded a SHA the skill prompts re-derived at review time viagh pr view --json headRefOid, which races with pushes that land mid-session: between the workflow's checkout and the model'sgh pr viewcall a new push could move the PR head, so the emitted marker SHA lagged the commit the review actually ran against. The skip-check gate then failed to recognise an already-reviewed head and Reviewer A re-fired on superseded commits while Reviewer B emitted contradictory verdicts on different SHAs (observed across the #2847 review cycle, where Reviewer B self-diagnosed the drift twice). The workflows now capture the head SHA exactly once and thread it into the prompts as an explicit<head-sha>argument:bot-review-a.ymlpasses the already-checked-outsteps.pr.outputs.shainto/review-prand/respond-to-critique, andbot-review-b.ymlkeys its checkout, skip-check marker-pattern, and/review-the-reviewinvocation offgithub.event.review.commit_id(the commit Reviewer A's review was attached to, immune to head drift from concurrent pushes). The prompts emit the marker from that argument instead of re-deriving it — the Reviewer A/B Bash allowlist isgh+ read-onlygit(noecho/printenv), so a step-level env var would be unreadable by the model and the SHA must travel in the prompt text, the same channel the PR number already uses. A structural spec,vendor/wheels/tests/specs/cli/BotReviewMarkerShaThreadingSpec.cfc, guards the wiring across both workflow YAMLs and all three prompts (#2848) - wheels-bot's convergence/deadlock loop now emits its idempotency markers from a workflow-captured head SHA, closing the same stale-SHA race fixed for Reviewer A/B (#2848) in the two commands that were out of scope there because they fire on the convergence/deadlock trigger path rather than the
pull_request/ review-submitted paths./address-review(the consensus implementer) and/advise-on-deadlock(the senior advisor) previously re-derived the marker SHA viagh pr view --json headRefOid, which floats to the PR's current head when a push lands between the workflow's checkout and the model's call — so the emittedwheels-bot:address-review:<pr>:<sha>,wheels-bot:advisor:<pr>:<sha>, andconverged-approve/converged-changesmarkers could lag the commit actually being addressed, defeating the per-SHA idempotency gate.bot-advisor.ymlnow threads its already-resolvedsteps.pr.outputs.shainto/advise-on-deadlock;bot-address-review.ymlgains an equivalent resolve step that capturesheadRefOidalongside the head ref it already needed for the branch checkout and threadssteps.pr.outputs.shainto/address-review(its checkout stays branch-name-keyed because that stage commits and pushes back, so the captured SHA is the head at run start — the marker's<sha-before>). Both prompts take an explicit<head-sha>argument and emit every marker from it; as with #2848 the prohibition is scoped narrowly to "don't re-derive the SHA" —gh pr viewremains the normal way to read comments, reviews, and the diff, because a blanket ban made Reviewer A flood permission denials and post nothing. A structural spec,vendor/wheels/tests/specs/cli/BotConvergenceMarkerShaThreadingSpec.cfc, guards the wiring across both workflow YAMLs and both prompts (#2848) wheels new <app> --no-sqlite,wheels generate admin <Model> --no-routes,wheels test --no-test-dband every other--no-*flag the CLI documents now reach their command-level parsers again. LuCLI normalizes--no-keyon the command line tokey=falsein the arg collection it hands modules, andModule.cfc::argsFromCollection()was silently droppingfalseentries — so the literal-token matchers innew(),g admin, andtest()never saw the user's negation and the defaults stuck (SQLite still scaffolded, routes still generated, test DB still applied). The rebuild now re-emits--no-<key>forfalsevalues, so all four--no-*flags surface to the command handlers unchanged.--nosqlite(no hyphen) was never affected because LuCLI does not strip a leadingnothat lacks the hyphen. Spotted in #2855 after the prior--no-sqliteplumbing fix in #2624.wheels new <name>no longer crashes on a fresh Windows (Scoop) install withlucee.runtime.exp.NativeException: there is no Resource provider available with the name [c]before any module output appears. LuCLI handsModule.init()acwdof the JVM'suser.dir(e.g.C:\Users\cy, backslashes), and the early scaffold path concatenatedcwd & "/" & appNameinto a mixed-slash string likeC:\Users\cy/blog. Lucee 7'sResourceUtilruns a URI scheme-detection regex (^[a-zA-Z][a-zA-Z0-9+.-]*:) ahead of its Windows drive-letter special case on this code path, matchesc:, extractscas a resource-provider scheme, finds none (onlyftp/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 onvariables.cwdininit()and on everyjava.io.File.getCanonicalPath()result inresolveProjectRoot()/resolveFrameworkSource(), soC:/Users/cy/blogmatches Lucee's Windows-path detection before the URI regex ever runs; a$safeDirExists()wrapper adds ajava.io.File.isDirectory()fallback for any path that still reaches adirectoryExists()check with a drive-letter prefix (a user-suppliedWHEELS_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=wheelsrouting fix (wheels-dev/scoop-wheels@30ea6e5) letwheels newactually reach this code (#2835)- Running the
wheelsCLI with no arguments no longer errors out withComponent [modules.wheels.Module] has no function with name [main]. LuCLI dispatches a barewheelsinvocation to amain()subcommand on the module; previouslycli/lucli/Module.cfconly definedshowHelp(), so picocli's routing surfaced the missing-method exception.Module.cfcnow definesmain()as a thin delegate toshowHelp()(and the function is added tomcpHiddenTools()so it doesn't appear as an MCP tool), restoring the expected behavior of printing the help banner when no subcommand is supplied (#2840) wheels newno longer commits a reload-password secret to source control. The scaffold hard-coded the generated random password as a literal inconfig/settings.cfm(a tracked file) and repeated it in a comment, and wrote it to.envasRELOAD_PASSWORDwhile the deployment guides and thewheels deploysecrets contract usedWHEELS_RELOAD_PASSWORD— so pasting the documentedenv()snippet into a fresh app silently resolved to""and tripped the "reloadPassword is empty" boot warning. Generatedconfig/settings.cfm(and bothapp/snippets/*.txt) now readset(reloadPassword=env("WHEELS_RELOAD_PASSWORD", "")), so the random value the generator creates lives only in the git-ignored.env; the scaffold.envand theexamples/starter-appreference now emitWHEELS_RELOAD_PASSWORD(the starter-app previously committed a guessablereloadPassword="changeme"). The CLI'sdetectReloadPassword()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 bareenv()accessor and theWHEELS_RELOAD_PASSWORDname (replacing an insecure docker example that usedServer.System.getEnv("RELOAD_PASSWORD") ?: "changeme"). The scaffoldedlucee.jsonalso 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.envat server start via its native#env:VAR#interpolation, so no committed file carries it. Heads-up for existing apps: the CFMLenv()lookup is exact-match (only the CLI carries the back-compat alias), so if you adopt the newconfig/settings.cfmform or a guide snippet, rename your.envkey fromRELOAD_PASSWORDtoWHEELS_RELOAD_PASSWORD(#2857)- 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 callinglCase()on every derived property name unconditionally, so anisHiddencolumn surfaced as the propertyishiddenon 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 (Basedefaultfalse;OracleModelandH2Modeloverride totrue). 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 declareproperty(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) - The Debian/Ubuntu
aptinstall instructions now pipe the distribution key throughsudo gpg --dearmorbefore writing/usr/share/keyrings/wheels.gpginstead oftee-ing it verbatim. The key published atapt.wheels.dev/wheels.gpgis ASCII-armored, and modernaptrejects an armored key in asigned-by=keyring with an "unsupported filetype" warning followed byNO_PUBKEY— soapt updatefailed signature verification and the install never worked. Corrected across the install guide, the CLI installation reference, the release-channels guide, theapt.wheels.devlanding page, and thetools/distribution-drafts/repo templates (#2838) - The
apt.wheels.devpublishing template (tools/distribution-drafts/apt-repo/) no longer wipes thestablepackage index when ableeding-edgesnapshot publishes.regenerate-apt-metadata.shrebuilt both channels on every run while the workflow synced only the dispatched channel's pool into the runner, so a frequent bleeding-edge publish scanned an empty localpool/stable/, produced an emptyPackages, and the unscoped upload overwrote the good stable index on R2 — leavingapt install wheelswith "Unable to locate package wheels" even though the.debwas present in the pool. The regen now honors aCHANNELSenv (the workflow passes only the dispatched channel) and the upload is scoped to that channel'sdists/subtree, so the two channels can no longer clobber each other (#2838) - The Wheels CLI test suite (
cli/lucli/tests/specs, served at/wheels/cli/tests) is green again after the BDDRunner error-count fix unmasked 13 pre-existing failures the old-1bundle-error sentinel had been arithmetically cancelling (a negative error total netted real failures down to<= 0, so the CI gate read the suite as passing). The eight*CommandSpecbundles that instantiatenew cli.lucli.Module()no longer fail to load withcan't find component [modules.BaseModule]: a lightweightBaseModuletest double undercli/lucli/tests/_modules/plus a/modulesmapping (added alongside the existing/modules/wheels, which longest-prefix resolution keeps authoritative for the wheels module) letsModule.cfcinstantiate under TestBox — resurrecting the Db/Info command specs as real behavioral coverage. The staleAdminSpecroute assertion now expects.namespace("admin")(the service's current named-route-prefixed output) instead of the legacy.scope(path="admin"). Command specs that need the LuCLI runtime, a running Wheels server, CodeGen harness fixtures, or the CLI bash wrapper (Deploy/Destroy/Generate/Packages, plus the server-dependent Migrate/Test cases) and the unbuilt-feature specs (Doctor #2260 mixin-detail, Scaffold route-model-binding) arexdescribe/xit-skipped with documented reasons, pending a command-by-command CLI test audit. Finally,tools/ci/run-tests.shnow clamps a negative error count for its pass/fail decision and fails explicitly when it sees one, so this masking class of bug can never silently turn a red suite green again (#2829) - WheelsTest BDD runner now captures spec-load and bundle-execution errors against the offending bundle instead of bubbling out as an anonymous
BundleRunnerMajorException, and reports the resulting error count as a positive number (was the-1sentinel) so summaries read "1 error(s)" with the bundle path andglobalExceptionpopulated — covers bothit()called outside adescribe()body and abeforeAll()that throws during spec load (#2829)