Skip to content

Releases: wheels-dev/wheels

Wheels 4.0.5

19 Jun 12:12
51eb6e4

Choose a tag to compare

Added

  • Linux .deb / .rpm packages are now architecture-independent (all / noarch) and install on arm64 (aarch64) as well as amd64 — the package launches the CLI through a portable java -jar launcher instead of an amd64-only native binary, so apt install wheels / dnf install wheels now work on arm64 hosts (#3223)

Fixed

  • The Wheels CLI .rpm now starts on RHEL-family distributions (Rocky Linux, Fedora, AlmaLinux): the /usr/bin/wheels wrapper's Java-21 probe only checked Debian/Ubuntu paths, so wheels failed with "cannot find a Java 21 runtime" even with java-21-openjdk-headless installed. It now also resolves the RHEL/Fedora JRE layout (and falls back to java on PATH) (#3223)

Wheels 4.0.4

19 Jun 01:48
7cd32a7

Choose a tag to compare

Added

  • wheels upgrade check --strict escalates advisory findings (the "Recommended Improvements" section) to the same hard-fail path as breaking findings, throwing Wheels.UpgradeCheckFailed and exiting non-zero so CI pipelines can gate on opt-in convention changes. Without --strict, advisories continue to report-and-pass. Under --format=json the emitted document's success field is gated on both breaking findings and the strict-advisory case, and the strict flag is echoed back so jq .success and $? always agree. The flag is documented in wheels upgrade help output (#2963).
  • services/ArgSpec.toInputSchema() derives a JSON-Schema-compatible {type:"object", properties, required, additionalProperties:false} envelope from a command's declared positionals / flags / options. positional() / flag() / option() now accept an optional description argument that flows into each emitted property (FastMCP / Symfony JsonDescriptor pattern). Foundation for per-tool MCP input schemas; wiring into tools/list is a follow-up (#2963).
  • wheels jobs work (long-lived worker loop with --queue, --interval, --max-jobs, --quiet) and wheels jobs status (--queue, --format=json) CLI commands — thin wrappers over the existing jobsProcessNext/jobsStatus framework bridge that was left without a CLI surface in the LuCLI migration; the retry/purge/monitor verbs remain tracked follow-ups (#3090)
  • Changelog entries are now authored as per-PR fragment files under changelog.d/ (<slug>.<type>.md) instead of direct CHANGELOG.md edits, eliminating the [Unreleased]-anchor merge conflict every concurrently-open PR used to have with every other. tools/changelog-promote.sh <version> assembles fragments (plus any legacy [Unreleased] content) into the new version section at release cut and clears the folder. PR template, bot prompts (propose-fix, review-pr, address-review), and the TDD gate updated to the fragment convention (#2958)
  • Development-mode warning (debug bar + wheels log) when a controller overrides config() without calling super.config(), which silently drops the base controller's protectsFromForgery() CSRF wiring and other inherited setup such as filters and verifies (#2960)
  • wheels deploy now delivers env.secret values to app and accessory containers via a remote env file (Kamal model): the file is created with 600 permissions before any content lands and re-locked to 600 right after the upload (the SFTP layer is also told not to carry local file attributes onto the remote), values travel over SFTP only (never argv, dry-run output, or exception summaries), and docker run references it with --env-file. A declared secret with no resolvable .kamal/secrets value fails fast with Wheels.Deploy.EnvSecretMissing (names only) before any remote call; the Wheels.Deploy.EnvSecretUnsupported fail-fast from #3008 is retired (#2957)
  • New apps scaffolded with wheels new now ship a /up liveness/warm-up endpoint (app/controllers/Up.cfc + route). wheels deploy's proxy healthcheck already probes /up before traffic cutover, so the dispatch → controller → render path is compiled on a freshly deployed node before the first real visitor — moving the one-time cold-start compile (the bulk of first-request latency) off user traffic. The production-config guide documents the warm-up recipe and recommends setting the engine's template-inspection mode to never in production (#3210)
  • mcpToolSpecs() on the CLI module returns per-tool MCP input schemas (test, seed, analyze, destroy, notes, upgrade, doctor, stats), each built from the SAME ArgSpec the command's parser uses — extracted into shared per-command builders with property descriptions — so the CLI parse surface and the MCP tools/list advertisement cannot drift. Read by LuCLI per the mcpHiddenTools()-style optional convention (runtime support ships separately); commands still on hand-rolled parsing gain entries as #2861 migrates them. wheels test now also throws Wheels.TestRunFailed (non-zero exit) when the run crashes before producing results — previously a mid-run crash printed red but exited 0, which the post-run Wheels.TestsFailed gate (failing tests only) never caught (#2963).
  • set(subpath="/wheelsproject1") (or the WHEELS_SUBPATH environment variable) now overrides the cgi.script_name-derived webPath / rootPath / rootcomponentPath / wheelsComponentPath so apps deployed under a URL subpath — CommandBox single-site → IIS subfolder migrations, reverse proxies that fold /public/ out of the URL, generally any deployment where cgi.script_name does not match the public mount point — no longer need to hand-patch application.$wheels.webPath after each framework upgrade. Detection priority: explicit set(subpath=...) in config/settings.cfm wins; otherwise server.system.environment.WHEELS_SUBPATH is consulted; otherwise the legacy cgi.script_name derivation runs unchanged so existing root-install behavior is preserved. Path derivation is extracted into a pure, unit-testable helper $resolveFrameworkPaths() on wheels.Global. Subpath input is normalized (leading slash added, trailing slashes stripped, "/" treated as a root install) and the helper uses only cross-engine-safe primitives (Replace, ListChangeDelims, Right/Left with the Lucee 7 Left(str, 0) guard). (#2968)
  • wheels upgrade apply performs the framework swap: it replaces the app's vendor/wheels/ with the framework bundled in the installed CLI, announcing the exact backup destination (vendor/wheels.bak-<timestamp>/) and the one-line recovery command before touching anything (--nobackup opts out; a mid-copy failure throws Wheels.FrameworkUpgrader.CopyFailed naming the backup to restore from). Bare wheels upgrade prints usage and never modifies files — destructive commands require the explicit verb, so MCP clients calling wheels_upgrade with {} can never trigger the swap. Safety rails fire before any mutation: refuses outside a Wheels app, when source or target doesn't sniff as a real framework directory (wheels.json/box.json must carry a non-empty version identifying Wheels — a generic app box.json is rejected), when source and target resolve to the same directory (e.g. inside the wheels repo checkout), on unknown flags/subcommands, and when --to= doesn't match the bundled framework version — downloading arbitrary --to= targets is the planned follow-up. wheels upgrade check keeps the read-only scan unchanged (including --strict, --format=json, and the non-zero-exit contract); its closing hint now points at wheels upgrade apply instead of brew upgrade wheels, which only ever upgraded the CLI binary (#3035)

Changed

  • Docs: upgrade guide, release-channels, and 3x-to-4x migration guide updated to document wheels upgrade apply as the framework-swap verb alongside wheels upgrade check (#3045)
  • The development debug bar's static CSS and JavaScript are now maintained as standalone files (vendor/wheels/public/assets/css/debugbar.css, vendor/wheels/public/assets/js/debugbar.js) and included into the bar, eliminating the CFML ##-escaped inline blocks (a documented "unescaped # crashes the suite" hazard) and trimming the per-response debug payload via inter-tag whitespace collapse. The bar remains development-only and unchanged in production (#3210)
  • wheels deploy config validation now rejects the 13 Kamal top-level keys the runtime never reads (boot, healthcheck, hooks, volumes, labels, logging, retain_containers, minimum_version, asset_path, require_destination, allow_empty_roles, run_directory, readiness_delay) instead of accepting-and-ignoring them; the unknown top-level key error now lists the allowed keys (#3088)
  • Dev-UI pages (/wheels/info, /wheels/routes, /wheels/migrator, etc.) no longer inline ~1MB of JS/CSS (jQuery, Semantic UI, marked, highlight.js, base64 icon font) into every response. Bundled assets are now served from a /wheels/assets/* route with Cache-Control: public, max-age=31536000, immutable and a framework-version cache-buster, shrinking typical dev-UI page payloads from ~1.1MB to under 100KB after first load (#2959).

Performance

  • URLFor() controller/action route lookup is now memoized in application scope with negative caching, instead of a per-request memo that only cached matches. The previous memo was rebuilt on every request and was never written on a miss, so wildcard-[controller] apps — where $addRoute strips the controller key, guaranteeing no match — re-scanned the entire route table for every linkTo / urlFor / redirectTo call, on every request. The new application.wheels.urlForCache survives across requests and caches both hits and misses (empty-string sentinel) for O(1) lookup. Invalidation is plumbed through both $lockedLoadRoutes (route reload) and $addRoute (any mutation, including test-suite manipulation), so a previously negative-cached (controller, action) pair that a newly-added route now matches can never serve a stale miss (#2955)
  • model() and controller() (global helpers in vendor/wheels/Global.cfc) now take a lock-free warm fast path on cache hits — a direct StructKeyExists lookup against application.wheels.models / application.wheels.controllers returns the cached class before $doubleCheckedLock (and its $invoke reflective cfinvoke dispatch) is consulted. Issue #2897 noted these as the framework's hottest warm-path calls, taking two reflective dispatches per association / per validation / per row just to evaluate a one-line cache predicate. The slow path is unchanged; cold-path bootstrap, ?reload=true c...
Read more

Wheels 4.0.3

10 Jun 04:26
f0bdd14

Choose a tag to compare

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 d...
Read more

Wheels 4.0.2

27 May 14:03
b1755b3

Choose a tag to compare

Wheels 4.0.2 — second patch on the 4.0 line. Adds shared-development-database migrator reconciliation (wheels migrate doctor / forget / pretend, orphan-version auto-detection, and name / applied_at enrichment of the wheels_migrator_versions tracking table) plus columnNames aliases across t.references(), t.primaryKey(), and the Migration.cfc command helpers; ships native GPG-signed Linux package repositories at apt.wheels.dev and yum.wheels.dev (Cloudflare R2); resolves BrowserTest base URLs through a layered instance-time lookup; and greens the compatibility matrix across BoxLang and Adobe ColdFusion 2023/2025. ~30 PRs since the 4.0.1 GA (2026-05-20).

Added

  • Native Linux package repositories are now live at apt.wheels.dev and yum.wheels.dev, GPG-signed and served from Cloudflare R2. Debian/Ubuntu installs fetch the key with curl -fsSL https://apt.wheels.dev/wheels.gpg | sudo tee /usr/share/keyrings/wheels.gpg, add a deb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable main source, then sudo apt install wheels; Fedora/RHEL installs add the repo via dnf config-manager --add-repo https://yum.wheels.dev/wheels.repo then dnf install wheels, and upgrades collapse to a single apt upgrade wheels / dnf upgrade wheels with no version pinning. The buckets are backed by R2 rather than Cloudflare Pages because the .deb (80 MB) and .rpm (81 MB) artifacts exceed Pages' 25 MiB per-file limit, while R2 has no object-size limit and still supports custom-domain serving. The install and release-channel guides now lead with the native sources, keeping the one-off GitHub-Release download behind an aside for air-gapped use (#2814)
  • t.primaryKey() in the migrator now accepts columnName and columnNames as aliases for the legacy name parameter, matching the argument-naming convention of every other column helper in TableDefinition.cfc. The legacy name= form keeps working (it's still what init() passes when adding the conventional id primary key). Plural columnNames wins when both aliases are supplied, mirroring addReference() / dropReference() precedence semantics. Unlike sibling helpers, columnNames here does NOT accept a comma-separated list — primaryKey() always creates one PK column, so columnNames="a,b" produces a single column literally named a,b; call t.primaryKey() multiple times for composite PKs (#2812)
  • t.references() in the migrator now accepts columnNames as an alias for the legacy referenceNames argument, matching every sibling column helper (t.string, t.integer, …) that uses $combineArguments to take both the plural and singular forms. The new useUnderscoreReferenceColumns setting (boolean, framework default false, wheels new template default true) controls whether t.references(columnNames="user") produces user_id (matching Wheels model belongsTo defaults) or the legacy userid (no underscore) suffix; polymorphic references follow the same flag for the <name>_type / <name>type column. Migration.cfc::addReference() and removeColumn(referenceName=) respect the flag too. Existing apps keep working unchanged since the framework default is false; only new apps generated by wheels new opt into the underscore form (#2802)
  • The command-version migrator helpers in Migration.cfc now accept the same plural/singular column-name aliases as the TableDefinition helpers fixed in #2802, via $combineArguments: addColumn / changeColumn / removeColumn take columnNames as an alias for columnName, addReference / dropReference take columnName / columnNames as aliases for referenceName, and addForeignKey takes columnName as an alias for column. Legacy parameter names keep working. Two hard-coded & "id" concatenations (in removeColumn and addReference) now route through useUnderscoreReferenceColumns, so an app that opted into the underscore convention and created user_id via t.references() can also drop or constrain that column through the command-version helpers. changeColumn's original required-argument enforcement is preserved for non-reference column types via a conditional required on $combineArguments (#2804)
  • Two new advisory checks in wheels upgrade check: a t.references() opt-in suggestion (fires when an app uses t.references( in migrations without set(useUnderscoreReferenceColumns=true)) and a mixed-convention warning (fires when the flag is set so users audit legacy migrations for <name>id columns that pre-date the opt-in). Both surface in the "Recommended Improvements" section introduced by the upgrade-tier scaffolding — advisory severity, never gates CI. The opt-in advisory is suppressed when the flag is already set in config/settings.cfm to avoid contradicting the mixed-convention warning. The shared grep loop now strips CFML comments before pattern matching (Anti-Pattern #14), so commented-out code can't trip any check — a framework-wide improvement that benefits every existing breaking-change check too (#2807)
  • Three new wheels migrate subcommands for manual reconciliation against the tracking table — Flyway validate / repair / SkipExecutingMigrations analogues. wheels migrate doctor prints a single-command health report covering applied/pending/orphan versions plus a human-readable summary; pure read, never mutates. wheels migrate forget <version> --yes removes a single orphan row from wheels_migrator_versions (refuses if a matching local file exists — use migrate down for legitimate rollbacks — and refuses if the version isn't in the table). wheels migrate pretend <version> --yes records a version as applied without running its up() (refuses if already applied or if no local file matches, so future down() calls still work). Both forget and pretend require explicit --yes to mutate; without it they print what would happen and exit. Implementation lives in Migrator.cfc::doctor(), forgetVersion(), pretendVersion(). Covers the shared-dev-DB pattern surfaced in #2780 beyond what the orphan auto-detection in #2798 could resolve automatically (#2799)

Changed

  • wheels upgrade check output now distinguishes opt-in recommendations from breaking changes. Each check struct in cli/lucli/Module.cfc::runUpgradeCheck() accepts a new optional severity field (default "breaking", the existing behavior); matches with severity: "advisory" bucket into a separate Recommended Improvements (N found): section that renders alongside Breaking Changes (N found): and All Clear (N checks):. The same-major short-circuit was also removed so advisories can fire on point-release upgrades — the "no known breaking changes" message still prints, but execution continues into the checks loop. Scaffolding-only; advisory-annotated entries land in a follow-up PR (#2805)
  • wheels_migrator_versions gains two additive nullable columns: name VARCHAR(255) (the migration's human-readable name, e.g. create_users) and applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP (when the migration was applied). Added automatically on the first migrator call after upgrade via the new Migrator.$ensureTrackingColumns() helper — idempotent, gated by application[appKey].$trackingColumnsEnsured so the ALTER runs once per app process, and non-fatal (legacy schema continues to work if the ALTER fails). Newly applied migrations populate both columns; existing rows pre-dating the enrichment stay NULL. wheels migrate info and wheels migrate doctor now show [?] <version> <name> (applied <timestamp>) for orphan rows when the columns are populated, instead of just the literal ********** NO FILE ********** — letting you see what a peer applied and when even though the file isn't in your branch yet. SQLite skips the column DEFAULT (not supported on existing-table ADD COLUMN) and gets explicit timestamps from CFML on insert. Per-engine SQL covers MySQL, PostgreSQL, SQLite, MSSQL, Oracle, H2, and CockroachDB (#2800)

Fixed

  • The Wheels Compatibility Matrix is green again for BoxLang and Adobe CF 2023/2025 ahead of the v4.0.2 cut. BoxLang (17 fail / 72 error on every database) traced to a single root cause: Global.cfc's pseudo-constructor seeded local.varKey = "" for its include-injected-UDF promotion loop, and BoxLang materializes that as variables.local, which then shadows the function-local local scope of every mixed-in $-helper — so Migrator/Model calls like local.appKey = $appKey() resolved against {varKey} and threw KeyNotFoundException: The key [APPKEY] was not found in the struct. Valid keys are ([VARKEY]). Lucee/Adobe keep local reserved to the function scope so they never saw it; the loop now lives in a real function ($promoteIncludedGlobalsToThis()). Adobe CF 2023/2025 were crashing the whole suite (HTTP 404 + ~1MB HTML prefix corrupting the result JSON) because InvokeMethodSpec invoked Public.index(), rendering the congratulations welcome page into the test-runner response buffer, which Adobe commits mid-run — now captured with cfsavecontent. Five further Adobe-specific bugs: RequestId middleware wrote request.wheels.requestId through a request parameter that shadows the scope on Adobe (Anti-Pattern #11, now a request-less helper); TestClient emitted a cfhttp POST with no cfhttpparam for empty bodies (Adobe requires at least one); ParallelRunner.$collectFailures relied on array-by-reference mutation that Adobe passes by value (returns the array now); $reincludeGlobals re-included an already-bound file, tripping "Routines cannot be declared more than once" (now evaluated in a throwaway GlobalIncludeLoader); RewriteConfigInstallerSpec compared file content to a literal string where Adobe 2025's fileWrite/fileRead round-trip appends a newline (compares to a normalized baseline now); and OuterTransactionSignalSpec's cleanup `quer...
Read more

Wheels 4.0.1

20 May 15:50
63777a0

Choose a tag to compare

Wheels 4.0.1 — first patch on the 4.0 line. Hardens Adobe ColdFusion 2023/2025 compatibility (Adobe-specific cfheader attributeCollection rejection, env() reserved-word parameter, Vite asset-walk array-by-value), fixes the Windows Scoop install regressions (wheels.cmd cmd.exe pre-parser, .zip.sha512 sidecar layout), and adds viewStyle framework presets to paginationNav() plus plural mappings aliases to package.json. ~100 PRs since the 4.0.0 GA (2026-05-12).

Added

  • paginationNav() and pageNumberLinks() now accept a viewStyle argument with named CSS-framework presets ("plain", "bootstrap5", "bootstrap4", "tailwind"). Bootstrap presets emit the canonical <nav><ul class="pagination"><li class="page-item active" aria-current="page"><span class="page-link">N</span></li> structure — with the active class on the <li> wrapper and a <span> (not anchor) for the current page — so Bootstrap-styled apps no longer need a Replace() regex hack to move the active class off the anchor. viewStyle defaults to "plain", preserving today's output byte-for-byte (#2718)
  • Docs: added "Reading the Changelog" guide page under the Upgrading section explaining where CHANGELOG.md lives (repo root, not inside vendor/wheels/), how to look up PR references cited in upgrade guides, and how to access the changelog offline when working with a vendored copy of the framework (#2719)
  • Document CORS allow-list defaults drift when migrating from 3.x set(accessControlAllow*) global settings to wheels.middleware.Cors; add header comparison table, explicit-constructor-args fix, and common-issues entry to the 3.x→4.x upgrade guide and a migration callout to the CORS reference page (#2708)
  • PackageLoader now derives a per-package CFML mapping from package.json and reflects it into application.mappings, so CFCs inside a hyphenated package (e.g. vendor/wheels-sentry/) can reference siblings via a static identifier (new wheelsSentry.SentryClient()) instead of CreateObject("component", "vendor.wheels-sentry.SentryClient"). The alias defaults to lower-camel-case of the manifest name (wheels-sentrywheelsSentry, wheels_legacy_adapterwheelsLegacyAdapter) and is overridable via a mapping field in package.json. Two packages computing the same alias are caught at load time — the first claimant keeps the mapping and the second is recorded in getFailedPackages() so the conflict is visible. Exposed via PackageLoader.getPackageMappings() (#2712)
  • wheels deploy init now scaffolds a starter Dockerfile (Lucee 7 + Java 21 multi-stage, /up HEALTHCHECK aligned with the generated kamal-proxy healthcheck) and a .dockerignore alongside config/deploy.yml and .kamal/secrets. --force also gates the Dockerfile — an existing user-authored Dockerfile aborts the init without --force, while an existing .dockerignore is silently preserved (since it's commonly user-curated even before adopting wheels deploy). The npm builder stage works for any Wheels app — projects without a JS pipeline pass through unchanged; projects with a package.json install + build automatically. Secrets (reload password, DB password, registry password) are injected at deploy time via .kamal/secrets, never baked into the image (#2673)
  • package.json now also accepts a mappings struct (plural) so a package can register additional dotted CFML mapping aliases beyond the singular mapping identifier. Keys are dotted names (e.g. plugins.sentry); values are paths relative to the package directory ("." for the root, "sub" for a subdirectory). Lets a package keep legacy callsites like new plugins.sentry.SentryClient() resolving when it's installed at vendor/wheels-sentry/ instead of plugins/sentry/. Each dotted segment must match [A-Za-z_][A-Za-z0-9_]*; absolute paths and .. traversal are rejected. Collisions with any existing alias (singular or plural, same or different package) fail the package and unwind its singular registration so the mapping registries stay internally consistent (#2739)

Changed

  • lockingSpec now consults the new $supportsAdvisoryLocks() model adapter capability and skips the withAdvisoryLock describe block via beforeEach { skip(...) } instead of erroring on adapters that don't support standalone advisory locks (H2, SQL Server, Oracle, CockroachDB). PostgreSQL, MySQL, and SQLite (no-op) report true; SQL Server reports false until its lock path grows an implicit-transaction wrapper. Compat-matrix can now distinguish "lock implementation broken" from "lock not applicable to this DB"
  • Reconcile upgrade docs: blog skeleton now lists all eleven canonical breaking changes (matching the canonical upgrade guide), fixes the wheels.Testwheels.WheelsTest test-base-class rename description (previously mislabeled as a "testbox namespace" move), and adds the previously-missing application.wireboxapplication.wheelsdi and Vite manifest strictness entries; stats table "Breaking defaults hardened | 7" corrected to "Breaking changes | 11" with four detail-row delta labels updated from Changed/Renamed/New to Breaking (#2632)
  • Compat-matrix CF-engine readiness probe now tracks the last observed HTTP status, surfaces partial progress every 10 attempts, and on timeout distinguishes "engine never bound" (HTTP 000) from "engine bound but returning 5xx" (e.g. issue #2646's $blockInProduction symptom) — printing the response body and a stack-frame-stripped log slice when the latter occurs. Previously a 5-minute timeout dumped tail -50 of raw container logs, dominated by ~30 lines of undertow/runwar stack frames, hiding the actual root cause

Fixed

  • The Scoop wheels.cmd wrapper (both wheels and wheels-be channels) was failing on Windows 11 build 10.0.26200.8457 with The filename, directory name, or volume label syntax is incorrect. before LuCLI could run, due to two compounding bugs: (1) call "%~dp0lucli-<ver>.bat" %* made cmd.exe pre-parse the entire bat-jar concatenation (~915 KB of bat preamble + :JAR_BOUNDARY + raw JAR ZIP bytes) looking for labels, and the pre-parser tripped on byte sequences in the ZIP tail; (2) JAVA_HOME=%~dp0share\jdk pointed at a location Scoop's extraction didn't reliably produce — on at least one machine the inlined OpenJDK 21 zip landed at %~dp0jdk-21.0.2 (the ZIP's root layout, as if extract_to=share/jdk were ignored) instead. The fix landed directly in the canonical bucket via wheels-dev/scoop-wheels#6: the wrapper now dispatches LuCLI via "%JAVA_HOME%\bin\java.exe" -client -jar "%~dp0lucli-<ver>.bat" %* (java reads the JAR via stream and skips the bat preamble, bypassing cmd's pre-parser entirely), and a one-line fallback if not exist "%JAVA_HOME%\bin\java.exe" set "JAVA_HOME=%~dp0jdk-21.0.2" handles the broken-extraction case. As part of the same cleanup, the stale Scoop manifest drafts at tools/distribution-drafts/scoop/ (build-manifests.py, validate.py, wheels.json, wheels-be.json, and the local README), plus the regression spec at vendor/wheels/tests/specs/cli/ScoopWrapperSpec.cfc that pinned against those drafts, are removed — the live bucket has had its own self-hosted autoupdate workflow and an inline-JDK layout since scoop-wheels@3f22250 and the in-repo drafts had silently diverged on every meaningful axis (inline JDK vs. depends:, real hashes vs. placeholder zeros, autoupdate strategy, wrapper template). tools/distribution-drafts/README.md is updated to note that the scoop bucket is now canonical and no longer mirrored here. Closes #2765 (#2767)
  • Release artifacts (wheels-core, wheels-cli, wheels-base-template, wheels-starter-app) now ship *.zip.sha512 / *.zip.md5 checksum sidecars (was *.sha512 / *.md5) so the scoop-wheels autoupdate config — which expects the .zip.sha512 shape via $url.sha512 substitution — no longer 404s on every non-module artifact. wheels-module already used the correct shape; this brings the other four artifacts and both release workflows (release.yml, release-candidate.yml, plus the snapshot.yml reusable-workflow chain) into line. Closes the Windows install regression reported in #2758 + scoop-wheels#2 (#2761)
  • Docs: Windows install steps in start-here/installing.mdx and command-line-tools/installation.mdx now call out scoop bucket add java as a prerequisite. Scoop's depends: declaration does not auto-add the dependency bucket on the user's behalf, so users hit Couldn't find manifest for 'openjdk21' from 'java' bucket before they could proceed (#2761)
  • $viteResolveAssets() on Adobe CF 2023/2025 returned empty preloads and styles arrays when the manifest included transitive imports with CSS chunks. Root cause: Adobe CF copies arrays by value when they are passed directly from a struct literal — $viteWalkImports(preloads = local.rv.preloads, styles = local.rv.styles, ...) handed the walker independent copies on Adobe CF, so every ArrayAppend(arguments.preloads, ...) inside the recursion wrote to garbage and local.rv came back empty. Lucee and BoxLang share the array references, so the bug was Adobe-only. Fix: pass the parent rv struct and mutate arguments.rv.preloads / arguments.rv.styles — struct references are shared on every engine (Cross-Engine Invariant #6). Affects every helper that walks transitive imports: viteScriptTag, viteStyleTag, vitePreloadTag, and $viteHtmlHead. Existing viteSpec assertions on transitive-import walk, diamond-dependency dedup, and cyclic-import termination serve as the regression catch (#2756)
  • env("KEY") and env("KEY", "fallback") now return the correct value on Adobe CF 2023/2025. The second parameter was named default, a CFML reserved word (switch/case/default), and Adobe CF refuses to bind a parameter with that name at all — neither the signature default nor a caller-supplied positional value populates arguments.default, so...
Read more

Wheels 4.0.0

12 May 05:59
3914c90

Choose a tag to compare

Wheels 4.0 — the release that started as 3.1 and grew into a major version. Closes multiple framework-maturity gaps against Rails, Laravel, and Django. See docs/releases/wheels-4.0-audit.md for the full audit trail (260+ merged PRs since 3.0.0). Contributors: @bpamiri, @zainforbjs, @chapmandu, @mlibbe, @MukundaKatta.

Added

Documentation

  • Correct landing page license text from "MIT licensed" to "Apache 2.0 licensed"
  • Add Debug Panel guide covering each tab, configuration settings, and when the bar appears
  • Clarify BoxLang server management in cfml-engines guide; update vm-deployment tip to distinguish CommandBox server management from the wheels dev CLI

ORM & data layer

  • Chainable query builder with where(), orWhere(), whereNull(), whereBetween(), whereIn(), whereNotIn(), orderBy(), limit(), and more for injection-safe fluent queries (#1922)
  • Enum support with enum() for named property values, auto-generated is*() checkers, auto-scopes, and inclusion validation (#1921)
  • Query scopes with scope() for reusable, composable query fragments in models (#1920)
  • Batch processing with findEach() and findInBatches() for memory-efficient record iteration (#1919)
  • Bulk insert/upsert operations (insertAll() / upsertAll()) with per-adapter native UPSERT syntax across MySQL, PostgreSQL, SQL Server, SQLite, H2, CockroachDB, and Oracle (#2101)
  • Polymorphic associations via belongsTo(polymorphic=true) and hasMany(as=...) with type-discriminator JOINs (#2104)
  • Advisory locks (withAdvisoryLock(name, callback)) and pessimistic locking (.forUpdate() on QueryBuilder) for SELECT ... FOR UPDATE (#2103)
  • CockroachDB database adapter — seventh supported database, with unique_rowid() PK convention and RETURNING clause identity select (#1876, #1986, #1993, #1999)
  • throwOnColumnNotFound config setting for strict column validation in WHERE clauses (#1938)
  • SQL identifier quoting for reserved-word conflicts in table/column names (#1874)

Migrations

  • Auto-migration generation from model/DB schema diff (AutoMigrator.diff(modelName), writeMigration()) (#2102)
  • Auto-migration rename detection via explicit hints plus heuristic suggestions (normalized-token + Levenshtein) with new wheels dbmigrate diff CLI command and MCP integration (#2112)

Routing

  • Router modernization: group() helper, typed constraints (whereNumber, whereAlpha, whereUuid, whereSlug, whereIn), API versioning via .version(1), performance indexes (#1891, #1894)
  • Route model binding with binding=true on resource routes or set(routeModelBinding=true) globally to auto-resolve model instances from route key parameters (#1929)

Middleware pipeline (new core framework)

  • Middleware pipeline: closure-based chain running at dispatch level before controller instantiation, route-scoped via .scope(middleware=[...]) or global via set(middleware=[...]) (#1924)
  • Rate limiting middleware with wheels.middleware.RateLimiter supporting fixed window, sliding window, and token bucket strategies with in-memory and database storage (#1931)
  • SecurityHeaders middleware emits Content-Security-Policy, HSTS, and Permissions-Policy headers (#2036)
  • hsts argument on SecurityHeaders middleware to suppress the Strict-Transport-Security header entirely, for apps behind TLS-terminating proxies that emit HSTS themselves (#2174)
  • Multi-tenant support with per-request datasource switching (#1951)

Views

  • Composable pagination view helpers: paginationInfo(), previousPageLink(), nextPageLink(), firstPageLink(), lastPageLink(), pageNumberLinks(), and paginationNav() for building custom pagination UIs (#1930)
  • XSS helpers formalized: h(), hAttr(), stripTags(), stripLinks() (#2097)
  • Redesigned v4.0 congratulations page for scaffolded apps (#2098)
  • vitePreloadTag() view helper emits <link rel="modulepreload"> for a Vite entrypoint and its transitive chunk imports, suitable for Turbo Drive hover-preload patterns
  • viteScriptTag() and viteStyleTag() now resolve transitive chunk imports from the Vite manifest: modulepreload links for JS chunks are emitted into <head>, and CSS from transitive chunks is included in the stylesheet tags (brings parity with Rails/Laravel Vite integrations)
  • viteStrictManifest setting (default true) — missing manifest entries now throw Wheels.ViteAssetNotFound in production. Set to false to restore 3.x silent behavior.

Background jobs & real-time

  • Job worker daemon with CLI commands (wheels jobs work/status/retry/purge/monitor) for persistent background job processing with optimistic locking, timeout recovery, and live monitoring (#1934)
  • Configurable exponential backoff for jobs via this.baseDelay and this.maxDelay with formula Min(baseDelay * 2^attempt, maxDelay) (#1934)
  • Pub/sub channels for SSE: subscribeToChannel(), publish(), poll(), with DatabaseAdapter and in-memory implementations (#1940)

Dependency injection

  • Expanded DI container with asRequestScoped() for per-request service instances, service() global helper, declarative inject() in controller config, bind() interface binding, auto-wiring of init() arguments, and config/services.cfm for service registration (#1933)

Testing infrastructure

  • HTTP test client (TestClient) for integration testing with fluent assertions: visit(), assertOk(), assertSee(), assertJson(), assertJsonPath(), cookie tracking, session support (#2099)
  • Parallel test execution runner (ParallelRunner) partitioning bundles across cfthread workers (#2100)
  • Browser testing via Playwright Java with BrowserTest base class, fluent DSL (navigation, interaction, keyboard, waiting, scoping, cookies, auth, dialogs, viewport, script, screenshots, assertions), and wheels browser:install command (#2113, #2115, #2116, #2121)

Package system

  • Package system (PackageLoader) with packages/vendor/ activation model, package.json manifests with provides.mixins targets, per-package error isolation (#1995)
  • Module system with dependency graph (requires/replaces/suggests topological sort) and lazy loading (#2017)
  • LuCLI module distribution via wheels-cli-lucli repo (#2018)
  • /wheels/packages developer page now shows a "Browse registry" section listing all packages available from wheels-dev/wheels-packages — package name, description, latest version, and a copy-to-clipboard wheels packages install <name> snippet per row. Rows matching an already-installed package show a ✓ Installed badge. Dev/testing only; $blockInProduction() gate keeps it off production servers. Registry data comes from the CLI's Registry.listAll() with 24h app-scope cache (#2271, partial — wheels.dev/packages static-site work deferred)

Engine adapters & cross-engine

  • Engine adapter modules encapsulating Lucee, Adobe CF, and BoxLang engine-specific behavior (#2016)
  • Interface-driven design contracts for framework extension points (#2014)

Migration & legacy

  • Legacy compatibility adapter for 3.x → 4.0 migration soft-landing (#2015)

CLI & LuCLI

  • wheels new now prints a non-blocking hint at the end of app scaffolding when a newer Wheels release is available on the user's channel (stable, bleeding-edge). Channel-aware (skips dev/rc), 24h-cached at $LUCLI_HOME/.update-check.json, 5s HTTP timeout, silent on any failure — never delays or breaks wheels new. (#2556)
  • wheels doctor now detects a stale installed CLI module at ~/.wheels/modules/wheels/ that shadows a source checkout and warns with a remediation command (symlink). Previously, contributors running wheels from a checkout could silently execute a pre-install Module.cfc, making merged fixes appear not to take effect. (#2223)
  • LuCLI Phase 2: zero-Docker local testing via tools/test-local.sh (#2063)
  • LuCLI Phase 2: service layer, generators, MCP annotations (#1941)
  • LuCLI Phase 3–4: scaffold, seed, in-process services (#2065)
  • LuCLI-native Lucee 7 + SQLite CI pipeline (#2032)
  • LuCLI tier 1 commands module + WheelsTest test suite (#2092, #2093)
  • Playwright CLI commands for browser testing (#2013, #2021)

Distribution (new in 4.0)

  • macOS — Homebrew tap at wheels-dev/homebrew-wheels with separate formulae for stable (wheels) and bleeding-edge (wheels-be) channels. Daily auto-update workflow polls the upstream release feeds and opens PRs.
  • Windows — Scoop bucket at wheels-dev/scoop-wheels with wheels / wheels-be manifests. Hourly auto-update via the community Excavator bot. Legacy Chocolatey wheels package on community.chocolatey.org (CommandBox-based v1.x) is no longer maintained — see Windows install docs for the migration. (#2545, #2552)
  • Linux.deb and .rpm packages built by nfpm on every release and uploaded to the GitHub Release alongside the existing zip artifacts. The package installs /usr/bin/wheels, depends on OpenJDK 21, and on first run syncs the framework module into ~/.wheels/. Native apt/yum repositories at apt.wheels.dev / yum.wheels.dev are planned for 4.0.x. (#2545)
  • WinGet — manifest drafts for Wheels.Wheels and Wheels.WheelsBE staged for post-GA submission to the microsoft/winget-pkgs community repo. (#2557)

Configuration & developer experience

  • env() helper for cross-scope environment variable access (#1985)
  • Pre-request logging (#1895)
  • Debug panel redesign (W-001, W-002) (#2000, #2001)
  • Gap migration detection in migrateTo() — detects and runs previously-skipped migrations, not just the endpoint (#1928)
  • Calculated property SQL validation at model config time ...
Read more

Wheels 4.0.0-SNAPSHOT+1783 (snapshot)

10 May 14:04
d63e9be

Choose a tag to compare

Pre-release

Snapshot build from develop. Not for production use.

Wheels 4.0.0-SNAPSHOT+1782 (snapshot)

10 May 13:57
49f51e1

Choose a tag to compare

Pre-release

Snapshot build from develop. Not for production use.

Wheels 4.0.0-SNAPSHOT+1781 (snapshot)

10 May 12:55
6ea3d8b

Choose a tag to compare

Pre-release

Snapshot build from develop. Not for production use.

Wheels 4.0.0-SNAPSHOT+1780 (snapshot)

10 May 07:27
422fcdb

Choose a tag to compare

Pre-release

Snapshot build from develop. Not for production use.