Skip to content

Wheels 4.0.3

Latest

Choose a tag to compare

@github-actions github-actions released this 10 Jun 04:26
f0bdd14

Wheels 4.0.3 — third patch on the 4.0 line. Completes the CLI argument-parsing overhaul (ArgSpec consumes 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 Oracle DROP TABLE/DROP VIEW work 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_PASSWORD in .env). ~45 PRs since the 4.0.2 GA (2026-05-27).

Added

  • wheels <cmd> --help now renders command-specific help from the command function's metadata hint, resolving the g / d aliases (so wheels g --help reaches the generate help, and wheels d --help reaches destroy). Unknown commands and the bare wheels help / wheels --help path 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 --help and always calls showHelp with no args (#2886)
  • wheels-bot can now review fork PRs (external / first-time contributors), which it previously could not. GitHub withholds both the vars context and secrets from pull_request runs triggered by a forked repository, so Reviewer A's vars.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 new bot-review-a-fork.yml workflow runs the initial Reviewer A review via pull_request_target (which executes in the base-repo context, where vars + secrets are available) for fork PRs that a maintainer has tagged with the bot-review label. Hardened against the pull_request_target "pwn-request" class: it checks out the base branch only and reviews the fork's changes through gh pr diff, never checking out or executing fork-controlled code, so the local ./.github/actions/wheels-bot-skip-check composite action always resolves to trusted base code (the fork's commit objects are fetched read-only via refs/pull/<n>/head so the review's git commands still resolve). bot-review-b.yml is hardened the same way — it previously checked out github.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 with persist-credentials: false. The bot-review label (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 as arg1, arg2, ...; --key=value as key=value; --no-key normalized to key=false), but Module.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 every false value, so --no-sqlite/--no-routes/--no-test-db/--no-open-browser never survived the round trip) and is structurally lossy — it cannot distinguish a genuine --no-X negation from an explicit --X=false. ArgSpec consumes 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 lossy false round trip. Designed for incremental adoption: getArgs() and argsFromCollection() remain in place as a deprecated shim until every call site is converted, and each command that adopts ArgSpec drops its hand-rolled token loop in the same change. Cross-engine clean (no closures, no struct-member collisions, no application-scope function storage, no attributeCollection = arguments); boolean coercion handles both the string "false" LuCLI normally emits and a literal false value, so Lucee/Adobe/BoxLang all agree on the parsed semantics. Required-positional violations throw Wheels.CLI.MissingArgument with 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 out client — the most confusing case — because Lucee 7 throws "client scope is not enabled" when clientManagement is 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 no server.lucee/server.boxlang), instantiates a RustCFMLAdapter (extends Base, 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 the cfcache built-in, the framework's cfcache-backed template/static cache degrades gracefully to a no-op when the adapter reports supportsCfcache() = false, so requests still render (cacheless-but-working). The new supportsCfcache() capability defaults to true on 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-as browser-test fixture (mounted by set(loadBrowserTestFixtures = true)) now honors an application.wheels.browserLoginAsHandler override. Set it in config/settings.cfmset(browserLoginAsHandler = "AuthFixture##loginAs") — and the framework dispatches /_browser/login-as to that controller##action instead of the default BrowserTestLogin##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 new wheels.middleware.BrowserTestFixtureGuard middleware attached to the /_browser scope so the gate still applies under override. The setting falls back to BrowserTestLogin##create when 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.InvalidArguments for 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 failed wheels migrate latest|up|down|info|doctor|rename-system-tables (re-throws the underlying MigrationError instead of swallowing it), and wheels routes when the server returns an unparseable or unsuccessful response (Wheels.RoutesFailed). Help/no-args paths (wheels generate, wheels db with no subcommand) and non-error states (wheels routes with zero configured routes, wheels db reset without --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 console and test CLI subcommands now consume LuCLI's structured argCollection directly via cli.lucli.services.ArgSpec (parseConsoleArgs / parseTestArgs calling .parse(structuredArgs(arguments))), continuing the #2861 migration (whose ArgSpec foundation shipped in #2862) past the eight leaf commands. console reads --password=<value>; test reads --filter (and its documented --directory alias), --reporter, --db (tracked as explicit so the runner distinguishes an implicit default from a chosen one), the --verbose / --ci / --core flags, --no-test-db (test-db=false), a bare positional filter, and the -v shorthand (which LuCLI delivers as a positional). Both also fix the latent arg1-gate the round trip masked: named-only invocations like wheels console --password=x and wheels 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=value forms — 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---core mode default, $normalizeTestFilter short-name normalization, and the full runTests(...) argument set; console's reload-password auto-detection when none is supplied. Covered by 13 new server-free specs in cli/lucli/tests/specs/commands/CommandArgParsingSpec.cfc (via ModuleArgvProbe). This leaves the dispatchers (generate, create, db, browser), the parser-delegating deploy / packages / migrate, and the LuCLI-passthrough start on the getArgs() shim, which is removed once those are converted (#2861)
  • Eight leaf CLI subcommands — new, seed, notes, analyze, doctor, stats, upgrade, and destroy — now consume LuCLI's structured argCollection directly via cli.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, whose ArgSpec foundation shipped in #2862). Beyond removing the per-command parsing duplication, this fixes a latent bug the round trip masked: the legacy getArgs() only rebuilt argv when a positional arg1 was present, so named-only invocations were silently dropped one layer in — wheels seed --environment=production, wheels doctor --verbose, wheels stats --verbose, and wheels notes --annotations=... all ran with defaults regardless of what the user passed. Consuming the structured map directly means the named keys (and --no-X negations) survive. One deliberate behavioral delta: wheels new with options but no app name (e.g. wheels new --no-sqlite) now errors with the #2214 Wheels.InvalidArguments "app name required" exception instead of falling through to the usage guide — previously the arg1-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 --force may appear before or after the positionals), upgrade's check-gate and --dry-run / --to "did you mean" nudge, and doctor / stats's -v shorthand (which LuCLI delivers as a positional, not a flag). A new private structuredArgs() / argvToCollection() helper pair sources the collection — preferring LuCLI's live handoff and reconstructing it from the instance-level __arguments fallback for internal delegation (e.g. createnew) and unit tests. The migrated parse logic is covered by server-free specs in cli/lucli/tests/specs/commands/CommandArgParsingSpec.cfc (via ModuleArgvProbe). getArgs() / argsFromCollection() remain as the deprecated shim for the not-yet-migrated commands — the dispatchers (generate, create, db, browser), the parser-delegating deploy / packages, migrate, and the space-separated-flag test / console — and the shim is removed once those are converted (#2861)
  • The final eight CLI subcommands — the dispatchers generate, create, db, browser; the parser-delegating deploy, packages, migrate; and the LuCLI-passthrough start — now source LuCLI's structured argCollection through structuredArgs(arguments) and reconstruct argv once via the new cli.lucli.services.ArgSpec.toArgv() passthrough, instead of the per-command getArgs() / argsFromCollection() round trip. With every call site converted, the getArgs() / argsFromCollection() shim is removed from Module.cfc, completing the #2861 migration (whose ArgSpec foundation shipped in #2862 and whose leaf/console/test batches landed in #2872 and #2874). These commands forward to downstream consumers that take a flat argv array (the deep generate sub-handlers, the unit-tested DeployArgsParser and packages parsers, runForgetOrPretend, and LuCLI's own server 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-X for key=false (the #2856 fix, now centralized in one tested place) so negations like --no-routes / --no-migration survive. The migration is behavior-preserving — structuredArgs() and the deleted getArgs() read the __arguments fallback identically, and toArgv() copies argsFromCollection()'s emit logic exactly — and incidentally retires the same arg1-gate latent bug the leaf-command migration did: wheels start --force with no positional previously fell through to an empty arg list. The #2855 --no-sqlite negation is now pinned end-to-end by a new assertion in tools/test-onboarding.sh (real CLI → LuCLI → ArgSpec → scaffolder: no db/*.sqlite files, lucee.json configuration.datasources == {}), closing the test-robustness gap the #2856 unit test left open. argsFromCollection's unit coverage moved to ArgSpec.toArgv specs; the getArgs arg1-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-snapshot guides tree is removed and its one unique page, "Reading the Changelog", is salvaged into v4-0-0/upgrading/. Both sites deploy from develop, so in-progress patch docs already live in the v4-0-0 tree; a separate *-snapshot tree 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 Windows Resource 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 original there 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 info now renders the framework-version line again — it read the long-gone vendor/wheels/events/onapplicationstart/settings.cfm path and silently printed nothing, so it now reads the authoritative vendor/wheels/wheels.json manifest by absolute path (no wheels mapping needed) and applies the same structural placeholder guard as wheels.BuildInfo (an unstamped dev checkout reports 0.0.0-dev rather than leaking the raw @build.version@ token). Two internal $-prefixed test helpers ($normalizeTestFilter, $resolveAppTestDataSource) were leaking into the MCP tools/list as callable tools — they are now listed in mcpHiddenTools() (kept public so TestCommandSpec can still unit-test them directly; LuCLI matches hidden names case-insensitively). wheels --help now lists the create app command (it was a working command + MCP tool but absent from the banner) and its notes line no longer advertises a HACK default the parser doesn't use (the default stays TODO,FIXME,OPTIMIZE; --annotations customizes it). wheels reload now honors an explicit --password=<value> override (parity with wheels console; auto-detect from .env/config remains the default). The interactive console /help now lists the /datasource and /q aliases it already accepts. The wheels mcp instructions and the deprecated /wheels/mcp endpoint's deprecation notice now point to the live MCP integration guide instead of a doc path (mcp-configuration-guide.md) that never existed. Docs: the wheels test flag table documents the real --directory alias for --filter, and the agent CLAUDE.md packages block lists the real wheels packages registry info verb.
  • wheels generate scaffold and wheels generate api-resource now honor --hasOne. The flag worked for wheels generate model and is documented for scaffold/api-resource (code-generation.mdx), but Scaffold.cfc's generateScaffold() / generateApiResource() neither declared a hasOne parameter nor forwarded one to CodeGen.generateModel() (which already accepts it and renders the {{hasOneRelationships}} placeholder), so it was silently dropped. Both signatures now accept hasOne and pass it through, and the Module.cfc scaffold/api-resource handlers forward arrayToList(parsed.hasOne) (mirroring how belongsTo/hasMany are already threaded). wheels generate scaffold Employee name:string --hasOne=Profile now emits hasOne('Profile'); in the model's config(); same for api-resource. Covered by new ScaffoldSpec cases 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 g works as a true generate alias again; wheels console accepts --password=<value>; wheels generate api-resource registers its resource route; wheels validate strips CFML comments before source-scanning so commented-out code can't satisfy (or trip) a check; wheels migrate --help documents the real subcommand surface; wheels test exit-code handling and wheels deploy flag handling were corrected alongside release-channel and guide docs; the generators emit enum() definitions again, warn on view-generation failures instead of continuing silently, and the duplicate-route message names the offending route; and wheels start warns when its pinned port is already taken instead of failing opaquely.
  • wheels reload and wheels generate admin now 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 reached cli.lucli.Module::$requireRunningServer() without the requireProjectConfig flag, so in a project with no lucee.json / .env port they still fell back to the hardcoded common-port probe ([8080, 60000, 3000, 8500]) and could silently attach to a sibling app: reload would reset the wrong app's state, and generate admin would 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 pass requireProjectConfig = true; with no project-bound port they throw Wheels.ServerNotRunning with a "set 'port' in lucee.json (or PORT in .env), then start with: wheels start" diagnostic instead of proceeding. generate admin is gated (rather than left on the read-side fallback alongside info / 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 in cli/lucli/tests/specs/services/ServerDetectionSpec.cfc that drive reload() and generateAdmin() 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 exhausting lucee.json and .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 start in app_a, then wheels migrate latest in app_b ran app_b's migrations against app_a's PostgreSQL). detectServerPort() now accepts a requireProjectConfig flag 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 throws Wheels.ServerNotRunning with 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 in cli/lucli/tests/specs/services/ServerDetectionSpec.cfc that simulate a sibling app on an ephemeral port and assert the write-side guard refuses to attach (#2878)
  • wheels migrate latest no longer crashes on PostgreSQL (and CockroachDB) when a migration emits an inline foreign-key constraint — e.g. anything wheels generate scaffold ... --belongsTo=author produces. wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator was missing the public addForeignKeyOptions(sql, options) method that every other adapter implements (MySQLMigrator, SQLiteMigrator, MicrosoftSQLServerMigrator, OracleMigrator); Abstract.createTable() builds the inline FK clause via foreignKeys[i].toForeignKeySQL()ForeignKeyDefinition.cfcadapter.addForeignKeyOptions(...), so every PostgreSQL FK column threw Component [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, and CockroachDBMigrator (which extends PostgreSQLMigrator) inherits the fix automatically. The reporter's "works on Windows" observation lined up with the wheels new SQLite default — only PostgreSQL/CockroachDB targets ever hit the missing method (#2876)
  • CLI services in Module.cfc now instantiate via the module-relative path (new services.X()) instead of the absolute FQN (new cli.lucli.services.X()), so wheels new and the other subcommands resolve their service classes when running from the installed distribution. The module tarball is built with tar -C cli/lucli ., which flattens the module root so services live at <module-root>/services/ with no cli/lucli/ tree and no cli.lucli mapping — the absolute form only resolved against the source-tree layout. That split is why the fast-test job (which runs from source, where both forms resolve) stayed green while the snapshot smoke test — which installs the built tarball and runs wheels new — failed on every develop push since #2861 with could not find component or class with name [cli.lucli.services.ArgSpec]. All 8 absolute references (7× ArgSpec, 1× the latent TestRunner call) are converted to the relative form the 17 sibling services already use; the ArgSpec docblock example is updated to match so it cannot re-seed the pattern (#2873)
  • Oracle DROP TABLE / DROP VIEW in the migrator now work on Oracle 19c/21c. wheels.databaseAdapters.Oracle.OracleMigrator::dropTable() emitted DROP TABLE IF EXISTS <name> CASCADE CONSTRAINTS and dropView() inherited DROP VIEW IF EXISTS from Abstract, but Oracle only added the IF EXISTS DDL modifier in 23c — on 19c/21c both are a hard parse error (ORA-00933). Because the remove-table migration 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.protectedControllerMethods is now populated at application start from the public method surface of wheels.Global plus the wheels.controller.* and wheels.view.* mixin components, so framework helpers like env(), model(), findAll(), redirectTo(), and linkTo() can no longer be invoked as controller actions from a URL. The list was previously initialized to an empty string (the orphaned local.allowedGlobalMethods = "get,set,mapper" line in onapplicationstart.cfc pointed to the intent but never wired it up), so $callAction()'s allow-list check was a no-op. Any unauthenticated GET /<anyController>/env request reached the global env() 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 from getMetaData().functions on 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 throws Wheels.ActionNotAllowed and 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 at processing.cfc:132 fires before the StructKeyExists(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 via gh pr view --json headRefOid, which races with pushes that land mid-session: between the workflow's checkout and the model's gh pr view call 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.yml passes the already-checked-out steps.pr.outputs.sha into /review-pr and /respond-to-critique, and bot-review-b.yml keys its checkout, skip-check marker-pattern, and /review-the-review invocation off github.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 is gh + read-only git (no echo/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 via gh 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 emitted wheels-bot:address-review:<pr>:<sha>, wheels-bot:advisor:<pr>:<sha>, and converged-approve/converged-changes markers could lag the commit actually being addressed, defeating the per-SHA idempotency gate. bot-advisor.yml now threads its already-resolved steps.pr.outputs.sha into /advise-on-deadlock; bot-address-review.yml gains an equivalent resolve step that captures headRefOid alongside the head ref it already needed for the branch checkout and threads steps.pr.outputs.sha into /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 view remains 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-db and every other --no-* flag the CLI documents now reach their command-level parsers again. LuCLI normalizes --no-key on the command line to key=false in the arg collection it hands modules, and Module.cfc::argsFromCollection() was silently dropping false entries — so the literal-token matchers in new(), g admin, and test() 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> for false values, so all four --no-* flags surface to the command handlers unchanged. --nosqlite (no hyphen) was never affected because LuCLI does not strip a leading no that lacks the hyphen. Spotted in #2855 after the prior --no-sqlite plumbing fix in #2624.
  • 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)
  • 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)
  • 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)
  • 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)
  • The Debian/Ubuntu apt install instructions now pipe the distribution key through sudo gpg --dearmor before writing /usr/share/keyrings/wheels.gpg instead of tee-ing it verbatim. The key published at apt.wheels.dev/wheels.gpg is ASCII-armored, and modern apt rejects an armored key in a signed-by= keyring with an "unsupported filetype" warning followed by NO_PUBKEY — so apt update failed signature verification and the install never worked. Corrected across the install guide, the CLI installation reference, the release-channels guide, the apt.wheels.dev landing page, and the tools/distribution-drafts/ repo templates (#2838)
  • The apt.wheels.dev publishing template (tools/distribution-drafts/apt-repo/) no longer wipes the stable package index when a bleeding-edge snapshot publishes. regenerate-apt-metadata.sh rebuilt 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 local pool/stable/, produced an empty Packages, and the unscoped upload overwrote the good stable index on R2 — leaving apt install wheels with "Unable to locate package wheels" even though the .deb was present in the pool. The regen now honors a CHANNELS env (the workflow passes only the dispatched channel) and the upload is scoped to that channel's dists/ 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 -1 bundle-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 *CommandSpec bundles that instantiate new cli.lucli.Module() no longer fail to load with can't find component [modules.BaseModule]: a lightweight BaseModule test double under cli/lucli/tests/_modules/ plus a /modules mapping (added alongside the existing /modules/wheels, which longest-prefix resolution keeps authoritative for the wheels module) lets Module.cfc instantiate under TestBox — resurrecting the Db/Info command specs as real behavioral coverage. The stale AdminSpec route 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) are xdescribe/xit-skipped with documented reasons, pending a command-by-command CLI test audit. Finally, tools/ci/run-tests.sh now 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 -1 sentinel) so summaries read "1 error(s)" with the bundle path and globalException populated — covers both it() called outside a describe() body and a beforeAll() that throws during spec load (#2829)