diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md index 8de36ff461..b1bfd2acaf 100644 --- a/.ai/wheels/testing/browser-testing.md +++ b/.ai/wheels/testing/browser-testing.md @@ -59,10 +59,11 @@ bash tools/test-local.sh # skips browser specs if JARs missin ## Key gotchas +- **`this.browser` is not wired in plain `describe()` blocks.** Calling any DSL method on `this.browser` outside a `browserDescribe()` block throws `Wheels.BrowserTest.NotWired` (message names `browserDescribe()` as the fix; `detail` names the method that was called). The sentinel `UnwiredBrowserGuard` is installed at construction and after each `$endBrowserContext()` teardown. - **`##` in selectors** — CFML requires `##` to emit literal `#`. `"##email"` → `"#email"` at runtime. - **`client` is a Lucee reserved scope.** `var client = ...` in a closure throws "client scope is not enabled". Use `var c = ...` or `var bc = ...`. (Generalized rule: see CLAUDE.md anti-pattern #11.) - **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl). - **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI. -- **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. +- **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()` — `super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored. - **Fixture routes** — `/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. - **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines. diff --git a/.ai/wheels/troubleshooting/common-errors.md b/.ai/wheels/troubleshooting/common-errors.md index 7f2a24c1e2..6cac4ced64 100755 --- a/.ai/wheels/troubleshooting/common-errors.md +++ b/.ai/wheels/troubleshooting/common-errors.md @@ -9,6 +9,26 @@ Frequent issues encountered when developing Wheels applications and their soluti - Association syntax has specific Wheels conventions - Migration parameter binding can be unreliable +## Startup / Initialization Errors + +### "Application Error — Wheels failed to initialize" +**Error (on-page):** +``` +Application Error +Wheels failed to initialize. Check the server log for details. +
could not find component or class with name [wheels.Injector]+``` + +**Cause:** `onApplicationStart` threw before `application.wo` was assigned — typically because the `/wheels` CFML mapping points to a directory that doesn't contain `Injector.cfc`. The `onError` handler now surfaces the original exception text instead of cascading into "The key [WO] does not exist" (fixed in [#2774](https://github.com/wheels-dev/wheels/pull/2774)). + +**Resolution:** +1. Read the `
` block — it contains the original error (missing class, path mismatch, etc.).
+2. Check the server log for the full stack trace from `onApplicationStart`.
+3. Verify the `/wheels` mapping resolves: on a fresh install, `vendor/wheels/Injector.cfc` must exist. If it doesn't, re-run `wheels new` or copy the framework files manually.
+4. Run `wheels reload` (or stop/start the server) to pick up the corrected mapping.
+
+**Note:** Before the #2774 fix, this failure cascaded into a second `[WO] does not exist` exception that hid the real cause. If you see the old cascade on a version that predates this fix (i.e. 4.0.1 or earlier), the underlying cause is always a failed `onApplicationStart` — see above.
+
## Common Association Errors
### "Missing argument name" in hasMany()
diff --git a/.ai/wheels/troubleshooting/shared-dev-databases.md b/.ai/wheels/troubleshooting/shared-dev-databases.md
new file mode 100644
index 0000000000..67de4eff22
--- /dev/null
+++ b/.ai/wheels/troubleshooting/shared-dev-databases.md
@@ -0,0 +1,103 @@
+# Shared Development Databases
+
+Short reference for the orphan-migration case. User-facing version lives at
+`web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx`.
+
+## What's an orphan?
+
+A row in `wheels_migrator_versions` whose `version` timestamp has no matching
+file in `app/migrator/migrations/`. Common cause: shared dev DB, peer ran
+their migration first against the shared DB before their file was merged.
+
+## Detection
+
+`Migrator.cfc::$getOrphanVersions()` — returns an array of orphan version
+strings, sorted ascending. Excludes the sentinel `"0"` returned when the
+tracking table is empty.
+
+## Display
+
+`wheels migrate info` marks orphan rows with `[?]` and the literal
+`********** NO FILE **********` (Rails-style). Includes a footer
+explaining the cause. Rendering logic lives in
+`Migrator.cfc::$buildInfoOutput()` so it's unit-testable without the HTTP
+dispatcher.
+
+## Behavior in `migrateTo()`
+
+If `currentVersion > target` ONLY because of orphans (no local file with
+version > target marked migrated), the down branch is skipped. Either:
+
+- Pending local migrations exist → fall through to up branch with a
+ warning naming the orphans
+- Nothing pending → emit "Nothing to do" naming current vs target and
+ return immediately
+
+If SOME DB versions > target are orphans and SOME have local files, the
+down branch runs as usual but emits a warning naming the orphans (they
+get skipped by the existing loop because it iterates files only).
+
+## Schema enrichment (Plan 3)
+
+`wheels_migrator_versions` carries two extra columns added in 4.0.x:
+`name VARCHAR(255) NULL` and `applied_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP`
+(`TEXT` on SQLite — DEFAULT not supported on existing-table ADD COLUMN).
+Added automatically on first migrator call via `$ensureTrackingColumns()`,
+gated by `application[appKey].$trackingColumnsEnsured` cache so the ALTER
+runs once per app process. Failure is non-fatal (legacy schema still works).
+
+Populated by `$setVersionAsMigrated(version, migrationName)` when:
+- The enriched columns flag is set, AND
+- The caller passes a non-empty `migrationName`
+
+Read by `$getOrphanVersionsWithMeta()` → array of `{version, name, appliedAt}`
+structs. Falls back to bare-version structs when columns aren't ensured
+or the SELECT fails (e.g. concurrent connection hasn't committed the ALTER).
+
+Display via `$buildInfoOutput()` and `cli.cfm`'s `doctor` case render:
+- `[x] ` for applied local-file rows (timestamps are only
+ surfaced on orphan rows because `getAvailableMigrations()` doesn't
+ re-query the tracking table for applied_at on local files)
+- `[?] ********** NO FILE **********` for legacy NULL orphans
+- `[?] (applied )` for enriched orphans
+
+Existing rows (pre-enrichment) get NULL for both columns. Going-forward-only;
+no backfill at bootstrap time.
+
+## Reconciliation commands
+
+Three CLI subcommands for manual reconciliation against the tracking
+table (Flyway `validate` / `repair` / `SkipExecutingMigrations`
+analogues):
+
+- `wheels migrate doctor` — comprehensive health report. Reads
+ `Migrator.doctor()`. Pure read; no mutation. Reports orphans,
+ pending, and applied counts with a human-readable summary.
+- `wheels migrate forget --yes` — removes a single row
+ from `wheels_migrator_versions`. Requires `--yes`. Refuses if the
+ version has a matching local file (use `migrate down` instead) or
+ if it's not in the tracking table.
+- `wheels migrate pretend --yes` — inserts a row without
+ running `up()`. Requires `--yes`. Refuses if already applied or
+ if no local file matches.
+
+Implementation:
+- `Migrator.cfc::doctor()`, `forgetVersion()`, `pretendVersion()`
+- `cli.cfm` cases: `doctor`, `forgetVersion`, `pretendVersion`
+- `Module.cfc::runForgetOrPretend()` handles `--yes` gating
+- Tests: `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
+
+## Related
+
+- Issue #2780 (the original report)
+- PR #2798 (orphan detection + info display + docs, merged 2026-05-22)
+- `vendor/wheels/Migrator.cfc::$getOrphanVersions()`
+- `vendor/wheels/Migrator.cfc::$buildInfoOutput()`
+- `vendor/wheels/Migrator.cfc::doctor()`
+- `vendor/wheels/Migrator.cfc::forgetVersion()`
+- `vendor/wheels/Migrator.cfc::pretendVersion()`
+- `vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc`
+- `vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc`
+- `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
+- Follow-up work (separate PR):
+ - Schema enrichment of `wheels_migrator_versions` (add `name` and `applied_at` columns)
diff --git a/.github/RELEASE_PLAYBOOK.md b/.github/RELEASE_PLAYBOOK.md
index 42d7cac455..2584ab9a0d 100644
--- a/.github/RELEASE_PLAYBOOK.md
+++ b/.github/RELEASE_PLAYBOOK.md
@@ -29,25 +29,45 @@ on them is green. ~5 minutes/day max.
1. **Cherry-pick or merge stabilization commits to `release/X.Y.Z`** if using a
release-branch workflow. Skip this if cutting directly from develop.
-2. **Update `CHANGELOG.md`** with the release date (replace `=> TBD` for the
- target version). `release.yml` will refuse to publish if it sees TBD on the
- target version.
-3. **Verify `box.json` version** matches what you want to release. After the
- last GA, the `bump-develop-version.yml` workflow set it to next-patch; if
- you're shipping a bigger bump, update it manually.
+2. **Update `CHANGELOG.md`**: rename `# [Unreleased]` to
+ `# [X.Y.Z](...) => YYYY-MM-DD` with the real release date (replace any
+ `=> TBD`). Then confirm the separator line directly below the renamed
+ section is exactly `---` (three dashes), NOT `----`. `release.yml` refuses
+ to publish if it sees TBD on the target version, and also fails if a `----`
+ separator would make the release-notes awk bleed into the previous version's
+ notes (recurring footgun — see #2606, #2768; now guarded in `release.yml`).
+3. **Verify `wheels.json` version** matches what you want to release.
+ `wheels.json` is the canonical version source since the 4.0 rebrand (the
+ workflows read it; `box.json` is legacy and may be absent or empty). After
+ the last GA, the `bump-develop-version.yml` workflow set it to next-patch;
+ if you're shipping a bigger bump, update it manually.
### Release day
-```bash
-# 1. On main, fast-forward from the release branch.
-git checkout main
-git merge --ff-only release/X.Y.Z # (or develop, if no release branch)
+`main` has a **"PRs only" ruleset** — `git push origin main` is rejected with
+`GH013`. You also never tag manually: `release.yml` auto-creates the tag via
+`softprops/action-gh-release` once the merge lands on main.
-# 2. Tag and push. release.yml fires automatically.
-git tag -a vX.Y.Z -m "Release X.Y.Z"
-git push origin main --tags
+```bash
+# 1. Open a PR into main from a release branch. (Cutting from develop directly
+# is fine — just branch off develop so the PR has a head ref to merge.)
+git checkout develop && git pull
+git checkout -b release/X.Y.Z-to-main
+git push -u origin release/X.Y.Z-to-main
+gh pr create --base main --head release/X.Y.Z-to-main \
+ --title "Release X.Y.Z" --body "Cut X.Y.Z. See CHANGELOG."
+
+# 2. Merge with "Create a merge commit" (NOT squash — preserves develop
+# history on main).
+gh pr merge --merge --delete-branch
```
+The push-to-main from that merge triggers `release.yml`, which builds the
+artifacts and calls `softprops/action-gh-release` with `tag_name: vX.Y.Z`,
+creating the tag at main HEAD automatically. Do **not** run `git tag` /
+`git push --tags` — the legacy `git push origin main --tags` flow is rejected
+by the ruleset anyway.
+
The tag push triggers:
- `release.yml` builds artifacts, publishes to `wheels-dev/wheels/releases`
- `release.yml`'s "Dispatch downstream package managers" step then fires
@@ -149,7 +169,8 @@ If any pin to `_amd64.deb` and the apt-wheels-dev / yum-wheels-dev
+ # ships `wheels__amd64.deb` and the apt-wheels / yum-wheels
# receiver workflow 404s on its `wheels-be__amd64.deb` fetch.
# RC builds keep CHANNEL=stable — RCs aren't fanned out to apt/yum
# (see release.yml § "Dispatch Linux repo buckets").
@@ -588,8 +605,8 @@ jobs:
#############################################
# Dispatch Linux repo buckets (apt.wheels.dev / yum.wheels.dev)
#
- # Fires repository_dispatch on wheels-dev/apt-wheels-dev and
- # wheels-dev/yum-wheels-dev so their metadata-regen workflows pick up
+ # Fires repository_dispatch on wheels-dev/apt-wheels and
+ # wheels-dev/yum-wheels so their metadata-regen workflows pick up
# the new .deb / .rpm asset and republish apt.wheels.dev /
# yum.wheels.dev within minutes. See issue #2605.
#
@@ -616,7 +633,7 @@ jobs:
fi
# Same channel derivation as the homebrew/scoop dispatch.
- # apt-wheels-dev / yum-wheels-dev route on `channel` in the payload
+ # apt-wheels / yum-wheels route on `channel` in the payload
# to slot the .deb/.rpm into the right pool (stable vs bleeding-edge).
# RC releases aren't currently published to apt/yum, so we skip them.
if echo "${WHEELS_RELEASE_VERSION}" | grep -qiE '\-snapshot[\.\+]'; then
@@ -632,7 +649,7 @@ jobs:
# Static event type, fixed repo list — no untrusted input flows into
# the curl arguments. WHEELS_RELEASE_VERSION is generated earlier in
# the same workflow from wheels.json + run number.
- for repo in apt-wheels-dev yum-wheels-dev; do
+ for repo in apt-wheels yum-wheels; do
echo "Dispatching wheels-released to wheels-dev/${repo}..."
http_status=$(curl -sS -o /tmp/linux-dispatch-resp.txt -w "%{http_code}" \
-X POST \
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
index 35ec475128..5d5d913222 100644
--- a/.github/workflows/snapshot.yml
+++ b/.github/workflows/snapshot.yml
@@ -215,7 +215,7 @@ jobs:
FORGEBOX_PASS: ${{ secrets.FORGEBOX_PASS }}
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
# Optional: when present, the build job's "Dispatch Linux repo buckets"
- # step fires repository_dispatch on apt-wheels-dev / yum-wheels-dev so
+ # step fires repository_dispatch on apt-wheels / yum-wheels so
# the bleeding-edge channel of apt.wheels.dev / yum.wheels.dev tracks
# every develop push. When absent, the step skips silently. See #2605.
LINUX_REPO_DISPATCH_TOKEN: ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}
diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml
index c65ddc4997..ef1a8ea07d 100644
--- a/.github/workflows/web-deploy.yml
+++ b/.github/workflows/web-deploy.yml
@@ -52,6 +52,14 @@ jobs:
- name: Build ${{ matrix.site }}
working-directory: web
+ env:
+ # `sites/packages` calls the GitHub REST API at build time to
+ # enumerate the package registry. Without auth that's capped at
+ # 60 req/hour per runner IP, and rapid CI activity on a single
+ # branch trips the limit (failure mode: 403 from the contents API,
+ # build aborts mid-prerender). Passing GITHUB_TOKEN raises the cap
+ # to 5000/hour. Other site builds ignore the var.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm --filter @wheels-dev/site-${{ matrix.site }} build
- name: Deploy to Cloudflare Pages
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 219c217a87..f0607a5b39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,46 @@ All historical references to "CFWheels" in this changelog have been preserved fo
----
+# [4.0.2](https://github.com/wheels-dev/wheels/releases/tag/v4.0.2) => 2026-05-27
+
+> **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 `_type` / `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 `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 --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 --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 `[?] (applied )` 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-`include`d 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 `queryExecute` bound the row id as `cf_sql_integer`, which overflows on Adobe when CockroachDB's default `unique_rowid()` PK returns a ~60-bit value (`cf_sql_bigint` now). Verified on the full matrix CI across all engines × databases (#2817)
+- `model().create()` / `.update()` / `.deleteAll()` inside a migration's `up()` or `down()` no longer silently roll back. The Migrator wraps every `up()` / `down()` invocation in its own outer `cftransaction`, and Wheels Model's default `transaction="commit"` opened a nested `cftransaction` whose JDBC nested-transaction semantics differ per adapter — most acutely on MSSQL, where the inner commit didn't release the row and the eventual outer commit dropped it, leaving the user with orphan FK rows referencing IDs that never persisted. `Migrator.cfc` now sets `request.$wheelsTransactionWrapper = true` around each `up()` / `down()` / `migrateIndividual()` invocation and `Model::invokeWithTransaction()` reads that signal, treating the call as `alreadyopen` and skipping the nested `cftransaction` entirely. The flag is request-scope and cleared in both the success and error paths of every migrator code path, so it never leaks past the migration's outer transaction (#2810)
+- `migrateIndividual()` no longer issues a spurious `transaction action="commit"` on the error path after its `catch` block has already issued `transaction action="rollback"`. Unlike `migrateTo()`'s loops there was no enclosing `for` to `break` out of, so control always fell through to the commit; the fix `return`s from inside the `catch` after the rollback, mirroring `migrateTo()`'s early-exit. On Lucee the second action against a closed transaction is a silent no-op, but on Adobe CF 2023/2025 (and potentially BoxLang) the JDBC driver could throw a "transaction not active" error that masked the real migration failure. Follow-up to #2810 (#2813)
+- `wheels upgrade check` underscore-references opt-in advisory no longer contradicts the mixed-convention warning when `set(useUnderscoreReferenceColumns=true)` lives in an environment override file (e.g. `config/production/settings.cfm`) rather than the root `config/settings.cfm`. The pre-check guard in `cli/lucli/Module.cfc::runUpgradeCheck()` now walks all of `config/` recursively (matching advisory #2's `scanDir: "config"` scope) instead of reading only `config/settings.cfm`, so the opt-in advisory is suppressed whenever the flag is set anywhere in the config tree. Comment-stripping still runs per file so a commented-out `set(useUnderscoreReferenceColumns=true);` cannot satisfy the guard (Anti-Pattern #14) (#2809)
+- `wheels migrate latest` no longer takes a misleading "down" branch and silently no-ops when `wheels_migrator_versions` records a version whose migration file isn't in the current checkout (shared dev DB / peer migration not yet pulled). `Migrator.migrateTo()` now diffs the tracking table against `app/migrator/migrations/` via the new `$getOrphanVersions()` helper and branches on orphan-at-top before the existing direction check — applying pending local migrations with a clear warning when all DB versions above target are orphans, emitting "Nothing to do" naming target vs current when none are pending, and warning + letting the existing down loop continue in mixed cases (orphan rows skip naturally because the loop iterates files). `wheels migrate info` also now shows orphan rows with `[?] ********** NO FILE **********` (Rails-style) and a footer explaining the cause (#2798)
+- `tools/test-local.sh` silently aborted with `EXIT=1` (no `/tmp/wheels-test-server.log` written, no diagnostic printed) on every install since the `lucli` → `wheels` rebrand window closed — i.e. anyone whose `~/.lucli/express/` directory never existed. Line 81 ran `LUCEE_LIB=$(find ~/.wheels/express ~/.lucli/express -path "*/lib/ext" -type d 2>/dev/null | head -1)` under `set -euo pipefail`; `find` exits non-zero whenever any path argument doesn't exist (stderr suppressed via `2>/dev/null`, but the exit status survives), `pipefail` propagated it through `head -1`, and the assignment tripped `set -e`. The cleanup trap then fired with no server to clean up, leaving the user staring at "Starting Wheels CLI server on port 8080…" with no further output. Dropped the now-dead `~/.lucli/express` fallback (the rename landed in 3.0 and recent CLI releases extract Lucee Express to `~/.wheels/express/` only) and added `|| true` for defense in depth so a missing directory (e.g. a truly fresh install before `wheels start` has ever run) leaves `LUCEE_LIB` empty and the downstream `[ -n "$LUCEE_LIB" ]` guard skips the JDBC pre-install cleanly (#2796)
+- Routes registered inside `.namespace("foo")` (or equivalent `.scope()` / `.package()`) with a redundant namespace prefix in the controller path — e.g. `to="foo/dashboard##index"` instead of `to="dashboard##index"` — previously silently produced a `foo.foo/dashboard` lookup that downstream flattened to a `Foodashboard`-style class name with an opaque `Wheels.ViewNotFound` error. The Mapper now rejects this at route-registration time with `Wheels.MapperArgumentInvalid`, naming the namespace and the offending value and pointing at the correct shorter form, so users can find the bad route definition instead of chasing the symptom (#2794)
+- `WheelsTest` auto-bind missed user-defined global helpers added via `include` in `app/global/functions.cfm`. The pseudo-constructor used `getMetaData(application.wo).functions`, which only enumerates methods declared directly on the CFC and skips symbols merged in via `cfinclude`. Specs that called custom helpers (e.g. `can()`, `hasRole()`) had to manually rebind each one in `beforeAll()`. The auto-bind now iterates `application.wo` as a struct and binds every UDF via `isCustomFunction()`, preserving the existing public-only filter for declared methods (#2793)
+- Model layer SELECT clause builder now routes column identifiers through the adapter's `$quoteIdentifier`, so reserved-word column names (e.g. `key`, `order`, `group`) survive on every supported dialect instead of breaking `findAll` / `findOne` / dynamic finders with cryptic SQL syntax errors. The WHERE / ORDER BY paths already quoted columns; `$createSQLFieldList` and the empty-pagination column-list extraction in `read.cfc` now match (#2787)
+- `wheels packages install ` is now a transparent alias for `wheels packages add ` on every caller path that actually reaches `Module.cfc` (stdio MCP server, scripted in-process clients, spec suite). Previously the dispatch layer's `case "install":` branch printed a yellow warning to stdout and returned `""` without installing anything — so even though `PackagesMainCli.install()` itself had been a true alias for `add()` since #2729, any caller routing through the CLI module's verb dispatch silently no-op'd. The shell-facing `wheels packages install ` form is still intercepted by LuCLI's built-in extension installer upstream of module dispatch and remains broken on that path (and is documented as such in the module-owned help text), but MCP tool calls and programmatic callers now behave identically to `add`. Both branches now share a single fall-through body so the validation, error shape, and install behavior cannot drift apart again (#2786)
+- `wheels.wheelstest.BrowserTest` now throws a clear `Wheels.BrowserTest.NotWired` error — naming `browserDescribe()` as the fix — when a spec calls a DSL method on `this.browser` from a plain `describe()` block. Previously the uninitialized `this.browser` was an empty string, producing the misleading `function [visitUrl] does not exist in the String` on every newcomer's first BrowserTest spec. A sentinel `UnwiredBrowserGuard` is now installed at `this.browser` before `browserDescribe()` wires the real `BrowserClient` and after `$endBrowserContext()` tears it down (#2782)
+- `BrowserTest`'s default base URL is no longer hardcoded to `http://localhost:8080`. `$resolveBaseUrl()` now consults a layered lookup at instance time: `this.baseUrl` (per-spec override, set in the component pseudo-constructor) → `get("browserTestBaseUrl")` (Wheels setting) → `-Dwheels.browserTest.baseUrl=...` (JVM system property) → `WHEELS_BROWSER_TEST_BASE_URL` env var → `cgi.server_name`/`cgi.server_port` auto-detect → `http://localhost:8080` default. Specs running against a non-8080 server (Titan on 60050, `wheels new` scaffolds on 60080) can set `this.baseUrl` in the pseudo-constructor or rely on the CGI auto-detect instead of comparing `getBaseUrl()` against a sentinel string. The bare-env-var approach still works for CI but is no longer the only escape hatch (the JVM caches env vars at process start, so post-launch `export` had no effect). Regression spec at `vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc` (#2783)
+- Linux `.deb` / `.rpm` packages double-nested the framework at `/opt/wheels/module/vendor/wheels/wheels/` instead of `/opt/wheels/module/vendor/wheels/`. `wheels-core-VER.zip` carries a top-level `wheels/` directory that `unzip` preserves; the nfpm `type: tree` rule then copied the entire `build/framework/` tree (wrapper and all) into the destination, leaving `Injector.cfc` one level too deep. Every fresh `wheels new` install on Ubuntu/Fedora then crashed on first request with `could not find component or class with name [wheels.Injector]`, cascading into the cryptic `The key [WO] does not exist.` error in `onError`. The brew formula handles this correctly via `(share/"wheels/framework/wheels").install Dir["*"]`; the Linux nfpm configs now pin `src` at `./build/framework/wheels/` to match. Regression spec at `vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc` (#2776)
+- `onError` in the generated app template and demo `public/Application.cfc` now guards `application.wo` with `StructKeyExists(application, "wo")` after the recovery try/catch. When `new wheels.Injector(...)` fails during `onApplicationStart` (e.g. a stale `/wheels` mapping under Lucee Express 7), the original error is preserved via a minimal HTML fallback instead of cascading into the cryptic "The key [WO] does not exist" exception that hit "Your First 15 Minutes" tutorial users on fresh installs (#2774)
+- Bare `?reload=true` now auto-detects changes to `app/global/*.cfm` in development and re-evaluates `app/global/functions.cfm` (plus any files it ``s) when any tracked file's mtime has changed, so new helpers added to `app/global/functions.cfm` (or any file it includes) are picked up without the password-gated `applicationStop()` path. Wheels snapshots the include directory on application start and re-evaluates the include via a new `application.wo.$reincludeGlobals()` hook when `$globalIncludesChanged()` detects an added/removed/touched file. Opt out with `set(reloadOnGlobalChange=false)` in `config/settings.cfm` (#2795)
+- The documented Linux bleeding-edge install commands in the guides 404'd after #2759 renamed the snapshot artifacts from `wheels_*` to `wheels-be_*` (deb) / `wheels-be-*` (rpm) to differentiate the channel by package name. All six affected pages — three unique (`start-here/installing`, `start-here/release-channels`, `command-line-tools/installation`) mirrored across the v4-0-0 and v4-0-1-snapshot doc versions — now point at the correct `wheels-be_*` assets, and the "switching channels" snippets reflect the real `Conflicts: wheels` package metadata instead of assuming same-package-name `--allow-downgrades` / `dnf downgrade` transitions (#2777)
+- Stale `schema_migrations` table-name references in the v4 migration and seeding guides (`v4-0-0/basics/seeding`, `v4-0-1-snapshot/basics/migrations`, `v4-0-1-snapshot/basics/seeding`) now read `wheels_migrator_versions`, matching the on-disk table name since the `c_o_r_e_*` → `wheels_*` rename — no `schema_migrations` table exists anywhere in `vendor/wheels/`, `cli/`, or `app/`. Carryover from #2799 (#2801)
+
+---
+
# [4.0.1](https://github.com/wheels-dev/wheels/releases/tag/v4.0.1) => 2026-05-20
> **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).
diff --git a/CLAUDE.md b/CLAUDE.md
index 93f1266760..3b41faa55d 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -221,6 +221,25 @@ Any validator, analyzer, scanner, or upgrade-check that does substring-matching
- `cli/lucli/Module.cfc::stripCfmlComments()`
- `cli/lucli/services/Doctor.cfc::$stripCfmlBlockComments()`
+### 15. Migrator helpers accept singular AND plural column names — prefer the plural
+**Source:** [#2781](https://github.com/wheels-dev/wheels/issues/2781) (`t.references()`) + [#2803](https://github.com/wheels-dev/wheels/issues/2803) (`t.primaryKey()`) — these two helpers were the last outliers in `TableDefinition.cfc`. Every sibling helper accepted `columnNames` / `columnName` via `$combineArguments`, but `references` required `referenceNames` and `primaryKey` required `name`. AI agents and humans both kept reaching for the consistent form and hitting "argument required" errors. Now resolved: both accept `columnNames` as an alias, and that's the preferred form going forward.
+
+```cfm
+// RIGHT — modern, matches every other column helper
+t.string(columnNames="name");
+t.integer(columnNames="age");
+t.references(columnNames="user");
+t.primaryKey(columnNames="userId", autoIncrement=true);
+
+// LEGACY — still works, but the new code path uses columnNames
+t.references(referenceNames="user");
+t.primaryKey(name="userId", autoIncrement=true);
+```
+
+For new migrator helpers or anywhere you accept a column-name argument: declare `string columnNames` (NOT `required`), and call `$combineArguments(args=arguments, combine="columnNames,columnName", required=true)` at the top of the body. The pattern is documented in [vendor/wheels/migrator/CLAUDE.md](vendor/wheels/migrator/CLAUDE.md). Boolean nullable flag is `allowNull` everywhere — never `null`.
+
+`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `_id` / `_type` columns matching Wheels model `belongsTo` defaults.
+
## Wheels Conventions
- **config()**: All model associations/validations/callbacks and controller filters/verifies go in `config()`.
@@ -540,6 +559,20 @@ Specs extend `wheels.wheelstest.BrowserTest`. Install Playwright once: `wheels b
## Migrations & Seeding
+### Shared Dev DB Reconciliation
+
+`wheels_migrator_versions` can drift from on-disk files when several developers share a single dev database (peer applied a migration whose file isn't yet in your branch). Detected and surfaced automatically; reconciliation is explicit:
+
+- `wheels migrate latest` — when a peer's tracked version sits above your latest local file, it now applies pending local migrations with a warning instead of silently no-op'ing on a "down" branch.
+- `wheels migrate info` — orphan rows render as `[?] (applied )` when the enriched `wheels_migrator_versions.name` / `.applied_at` columns are populated, or `[?] ********** NO FILE **********` (Rails-style) for legacy rows.
+- `wheels migrate doctor` — single-command health report. Lists orphans + pending; pure read.
+- `wheels migrate forget --yes` — delete a stale tracking row (refuses if a matching local file exists, refuses if version not in table).
+- `wheels migrate pretend --yes` — record a version as applied without running `up()` (refuses if already applied or no matching file).
+
+Tracking-table schema: `wheels_migrator_versions(version, core_level, name, applied_at)`. The `name` and `applied_at` columns are additive (NULL for legacy rows) and added automatically via `$ensureTrackingColumns()` on first migrator call after upgrade. Both columns are populated by `$setVersionAsMigrated(version, migrationName)` going forward; existing rows stay NULL and display version-only.
+
+Both `forget` and `pretend` are dry-run by default; `--yes` is required to mutate. Helpers live on `Migrator.cfc`: `$getOrphanVersions()`, `$getOrphanVersionsWithMeta()`, `doctor()`, `forgetVersion()`, `pretendVersion()`, `$buildInfoOutput()`, `$ensureTrackingColumns()`. Deep reference: [.ai/wheels/troubleshooting/shared-dev-databases.md](.ai/wheels/troubleshooting/shared-dev-databases.md). User-facing guide: `web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx`. Shipped across #2798, #2799, and the schema enrichment PR.
+
### Auto-Migration
Generate migrations from model/DB schema diffs. Rename detection via explicit hints (authoritative) + heuristic suggestions (normalized-token + Levenshtein).
@@ -688,7 +721,8 @@ Prefer MCP tools when the Wheels MCP server is available. Fall back to CLI other
| Task | MCP | CLI |
|------|-----|-----|
| Generate | `wheels_generate(type, name, attributes)` | `wheels g model/controller/scaffold Name attrs` |
-| Migrate | `wheels_migrate(action="latest\|up\|down\|info")` | `wheels migrate latest\|up\|down\|info` |
+| Migrate | `wheels_migrate(action="latest\|up\|down\|info\|doctor")` | `wheels migrate latest\|up\|down\|info\|doctor` |
+| Migrator reconciliation | — | `wheels migrate forget\|pretend --yes` (shared dev DB orphan cleanup; see #2780) |
| Test | `wheels_test()` | `wheels test run` |
| Reload | `wheels_reload()` | `?reload=true&password=...` |
| Server | `wheels_server(action="status")` | `wheels start\|stop\|status` |
@@ -711,6 +745,7 @@ Search `.ai/` for deeper documentation:
- [.ai/wheels/channels/channels.md](.ai/wheels/channels/channels.md)
- [.ai/wheels/snippets/model-snippets.md](.ai/wheels/snippets/model-snippets.md), [controller-snippets.md](.ai/wheels/snippets/controller-snippets.md)
- [.ai/wheels/troubleshooting/common-errors.md](.ai/wheels/troubleshooting/common-errors.md), [form-helper-errors.md](.ai/wheels/troubleshooting/form-helper-errors.md)
+- [.ai/wheels/troubleshooting/shared-dev-databases.md](.ai/wheels/troubleshooting/shared-dev-databases.md) — Orphan-version handling + `migrate doctor` / `forget` / `pretend` reconciliation commands (#2780)
- [.ai/cfml/](.ai/cfml/) — CFML language reference (syntax, components, control flow)
**External:** user-facing guides at `web/sites/guides/src/content/docs/v4-0-0/` (deployment, command-line-tools/mcp-integration, etc.) — these ship to guides.wheels.dev. Use when you need the version Wheels users read.
diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc
index b4849313b0..9c00b6255a 100644
--- a/cli/lucli/Module.cfc
+++ b/cli/lucli/Module.cfc
@@ -347,6 +347,17 @@ component extends="modules.BaseModule" {
out("Migration failed: #e.message#", "red");
return "";
}
+ case "doctor":
+ try {
+ return runMigration("doctor");
+ } catch (MigrationError e) {
+ out("Doctor failed: #e.message#", "red");
+ return "";
+ }
+ case "forget":
+ return runForgetOrPretend("forgetVersion", args);
+ case "pretend":
+ return runForgetOrPretend("pretendVersion", args);
case "rename-system-tables":
// F15 Phase 2: opt-in one-shot rename of legacy c_o_r_e_*
// system tables to wheels_*. Idempotent (no-op when nothing
@@ -363,7 +374,7 @@ component extends="modules.BaseModule" {
}
default:
out("Unknown migration action: #action#", "red");
- out("Usage: wheels migrate [latest|up|down|info|rename-system-tables]");
+ out("Usage: wheels migrate [latest|up|down|info|doctor|forget|pretend|rename-system-tables]");
return "";
}
}
@@ -2089,6 +2100,20 @@ component extends="modules.BaseModule" {
opts.name = positional[2];
var mainCli = new modules.wheels.services.packages.PackagesMainCli();
return mainCli.show(opts);
+ case "install":
+ // LuCLI's built-in extension installer intercepts the
+ // literal verb `install` on the user-facing CLI surface
+ // — same trap that bit `wheels browser install` (renamed
+ // to `wheels browser setup` in #2345). But every other
+ // caller path reaches this dispatch directly: the
+ // stdio MCP server (`wheels mcp wheels`), scripted
+ // in-process clients, and the bundle's own spec suite.
+ // `PackagesMainCli.install()` has been a transparent
+ // alias for `add()` since #2729, so the dispatch layer
+ // must match — otherwise `install ` silently
+ // no-ops on the only paths LuCLI does NOT intercept.
+ // Fall through to the `add` branch (same validation,
+ // same error shape, same install behavior).
case "add":
if (arrayLen(positional) < 2) {
throw(message="add requires a name: wheels packages add [@]");
@@ -2096,20 +2121,6 @@ component extends="modules.BaseModule" {
opts.target = positional[2];
var mainCli = new modules.wheels.services.packages.PackagesMainCli();
return mainCli.add(opts);
- case "install":
- // Dead code on the CLI surface: LuCLI's built-in extension
- // installer intercepts the literal verb `install` across
- // all modules before dispatch reaches Module.cfc — the
- // same trap that bit `wheels browser install` (renamed
- // to `wheels browser setup` in #2345). Kept as a
- // documentation marker for future maintainers; if LuCLI
- // ever stops intercepting, this case keeps a friendly
- // redirect for users still typing the historic verb.
- out("'wheels packages install' is intercepted by LuCLI's", "yellow");
- out("built-in extension installer and won't reach this module.", "yellow");
- out("Use:", "yellow");
- out(" wheels packages add " & (arrayLen(positional) >= 2 ? positional[2] : ""), "bold");
- return "";
case "update":
opts.target = arrayLen(positional) >= 2 ? positional[2] : "";
var mainCli = new modules.wheels.services.packages.PackagesMainCli();
@@ -3336,6 +3347,7 @@ component extends="modules.BaseModule" {
case "up": command = "migrateUp"; break;
case "down": command = "migrateDown"; break;
case "info": command = "info"; break;
+ case "doctor": command = "doctor"; break;
}
var migrateUrl = "http://localhost:#serverPort#/wheels/cli?command=#command#&format=json";
@@ -3355,12 +3367,88 @@ component extends="modules.BaseModule" {
// the previous code silently treated it as success. See issue #2315.
var result = parseCliResponse(httpResult, "Migration #action#");
+ // For `doctor`, switch the output color to yellow when the report
+ // signals unhealthy state (orphans or pending migrations). Green
+ // on an unhealthy result reads as "everything's fine" when it
+ // isn't. Other actions stay green on success.
+ var color = "green";
+ if (arguments.action == "doctor" && structKeyExists(result, "healthy") && !result.healthy) {
+ color = "yellow";
+ }
+
if (structKeyExists(result, "message") && len(result.message)) {
- out(result.message, "green");
+ out(result.message, color);
} else {
- out("Migration #action# completed.", "green");
+ out("Migration #action# completed.", color);
+ }
+
+ return "";
+ }
+
+ private string function runForgetOrPretend(required string command, required array args) {
+ // `forget` and `pretend` require an explicit arg plus
+ // `--yes` to confirm. Default behavior is to print what would
+ // happen and refuse without the flag. See issue #2780.
+ var version = "";
+ var yes = false;
+ for (var i = 2; i <= arrayLen(arguments.args); i++) {
+ var a = arguments.args[i];
+ if (a == "--yes" || a == "-y") {
+ yes = true;
+ } else if (!a.startsWith("--")) {
+ version = a;
+ }
+ }
+
+ var verb = arguments.command == "forgetVersion" ? "forget" : "pretend";
+
+ if (!Len(version)) {
+ out("Missing required argument: ", "red");
+ out("Usage:");
+ out(" wheels migrate #verb# --yes");
+ return "";
+ }
+
+ if (!yes) {
+ out("This will modify wheels_migrator_versions.", "yellow");
+ out("Re-run with --yes to confirm:", "yellow");
+ out(" wheels migrate #verb# #version# --yes");
+ return "";
+ }
+
+ var serverPort = $requireRunningServer([
+ "Migration reconciliation requires a running server.",
+ "Start one with: wheels start"
+ ]);
+
+ out("Running #verb# for version #version#...", "cyan");
+
+ // URL-encode version: $sanitiseVersion() on the server side strips
+ // non-digits before SQL use (no injection path), but raw URL-special
+ // characters (&, =, %) in the CLI argument could still inject
+ // spurious query parameters before reaching that point.
+ var reconcileUrl = "http://localhost:#serverPort#/wheels/cli?command=#arguments.command#&version=#URLEncodedFormat(version)#&format=json";
+
+ var httpResult = "";
+ try {
+ httpResult = makeHttpRequest(reconcileUrl);
+ } catch (any httpErr) {
+ throw(
+ type = "MigrationError",
+ message = "#verb# failed (connection error): #httpErr.message#",
+ detail = httpErr.detail ?: ""
+ );
}
+ var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response"};
+ var success = parsed.success ?: false;
+ var msg = parsed.message ?: "";
+
+ if (success) {
+ out(msg, "green");
+ } else {
+ out(msg, "red");
+ }
return "";
}
@@ -3651,14 +3739,18 @@ component extends="modules.BaseModule" {
// Compare major versions
var currentMajor = val(listFirst(currentVersion, "."));
var targetMajor = val(listFirst(target, "."));
+ var sameMajor = (currentMajor == targetMajor);
- if (currentMajor == targetMajor) {
+ if (sameMajor) {
out("Same major version — no known breaking changes.", "green");
- out("Upgrade with: brew upgrade wheels");
- return "";
+ out("Scanning for opt-in recommendations...", "green");
+ out("");
}
- // Breaking changes database
+ // Check database. Each entry may set `severity` to either "breaking"
+ // (the default — flagged in red, gated by major-version-bump scenarios)
+ // or "advisory" (cyan, runs regardless of version-jump — for opt-in
+ // convention changes the user can adopt at their convenience).
var checks = [];
// 2.x -> 3.x
@@ -3723,6 +3815,7 @@ component extends="modules.BaseModule" {
// reminder to re-verify, not a false positive.
arrayAppend(checks, {
description: "RateLimiter middleware — defaults changed in 4.0 (advisory: review config)",
+ severity: "advisory",
pattern: "new\s+wheels\.middleware\.RateLimiter",
checkType: "grep",
scanDir: "config",
@@ -3802,22 +3895,95 @@ component extends="modules.BaseModule" {
});
}
- // Run checks
+ // ─── Advisory entries — run regardless of version-jump ──────────
+ // These are opt-in convention recommendations, not breaking changes.
+ // They appear in the "Recommended Improvements" section of the
+ // scanner output and never fail CI (exit code 0).
+
+ // Suggest opt-in to _id reference columns when t.references()
+ // is used and the underscore flag is not yet set. The flag flips
+ // the suffix produced by t.references() from `id` to `_id`,
+ // matching Wheels model `belongsTo` defaults. See #2781 + #2802.
+ //
+ // Pre-check the flag across all of config/ before appending — the
+ // check-struct schema doesn't support multi-condition AND logic, so
+ // emitting the advisory unconditionally would fire on every app that
+ // has already opted in (where advisory #2 is the relevant one) and
+ // contradict reality. Walk config/ recursively to match advisory #2's
+ // `scanDir: "config"` scope — users may set the flag in an
+ // environment override file (e.g. config/production/settings.cfm) and
+ // reading only config/settings.cfm would miss it (#2808). Comment-
+ // strip each file first so a commented-out
+ // `// set(useUnderscoreReferenceColumns=true);` doesn't satisfy the
+ // guard (Anti-Pattern #14 — same shape as line 970).
+ var underscoreFlagAlreadySet = false;
+ var configDir = variables.projectRoot & "/config";
+ if (directoryExists(configDir)) {
+ var configFiles = [];
+ for (var ext in ["cfm", "cfc"]) {
+ var found = directoryList(configDir, true, "path", "*." & ext);
+ for (var f in found) arrayAppend(configFiles, f);
+ }
+ for (var configFile in configFiles) {
+ // `reFindNoCase()` returns the 1-based match position (0 = no
+ // match). DO NOT wrap with `len()` — len() coerces the int to
+ // a string and measures digit count, so len(0)=1 and len(25)=2
+ // are both truthy. Use `> 0` for an unambiguous boolean.
+ if (reFindNoCase(
+ "useUnderscoreReferenceColumns\s*=\s*true",
+ stripCfmlComments(fileRead(configFile))
+ ) > 0) {
+ underscoreFlagAlreadySet = true;
+ break;
+ }
+ }
+ }
+ if (!underscoreFlagAlreadySet) {
+ arrayAppend(checks, {
+ description: "t.references() produces legacy `id` columns (opt in to `_id` for `belongsTo` defaults)",
+ severity: "advisory",
+ pattern: "t\.references\s*\(",
+ checkType: "grep",
+ scanDir: "app/migrator/migrations",
+ extensions: "cfc",
+ fix: "Opt into _id naming via `set(useUnderscoreReferenceColumns=true)` in config/settings.cfm. Existing applied migrations are unaffected — only NEW migrations get the new suffix. Apps generated by `wheels new` already opt in by default. See ##2781."
+ });
+ }
+
+ // Mixed-convention warning: fires when the flag is set, alerting
+ // users that legacy migrations (pre-flag) may have left `id`
+ // columns in the DB while new migrations will produce `_id`.
+ // Informational — the user reads it once and decides if a data
+ // migration is needed.
+ arrayAppend(checks, {
+ description: "useUnderscoreReferenceColumns=true is set — confirm legacy migrations don't conflict",
+ severity: "advisory",
+ pattern: "useUnderscoreReferenceColumns\s*=\s*true",
+ checkType: "grep",
+ scanDir: "config",
+ extensions: "cfm,cfc",
+ fix: "If migrations under app/migrator/migrations/ were applied before this flag was set, the database still has `id` columns. New migrations will create `_id`. For full consistency, write a data migration to rename old reference columns."
+ });
+
+ // Run checks. Matched checks land in `issues` (severity=breaking) or
+ // `advisories` (severity=advisory); unmatched land in `passed`.
var issues = [];
+ var advisories = [];
var passed = [];
for (var check in checks) {
+ var severity = structKeyExists(check, "severity") ? check.severity : "breaking";
+ var matched = false;
+ var matchEntry = {};
+
if (check.checkType == "directory") {
var dirPath = variables.projectRoot & "/" & check.path;
if (directoryExists(dirPath)) {
var contents = directoryList(dirPath, false, "name");
if (arrayLen(contents)) {
- arrayAppend(issues, {description: check.description, fix: check.fix, matches: [check.path & "/"]});
- } else {
- arrayAppend(passed, check.description);
+ matched = true;
+ matchEntry = {description: check.description, fix: check.fix, matches: [check.path & "/"]};
}
- } else {
- arrayAppend(passed, check.description);
}
} else if (check.checkType == "grep") {
// Build the file set to scan. Checks may use `scanDir` +
@@ -3862,7 +4028,13 @@ component extends="modules.BaseModule" {
var matches = [];
for (var filePath in filesToScan) {
- var content = fileRead(filePath);
+ // Strip CFML comments before grepping (Anti-Pattern #14):
+ // a commented-out `// t.references(...)` or
+ // `/* set(...) */` must not satisfy the pattern. Multi-line
+ // block comments collapse and may shift reported line
+ // numbers — same tradeoff `stripCfmlComments` callers at
+ // lines 970 and 5532 already accept.
+ var content = stripCfmlComments(fileRead(filePath));
var lines = listToArray(content, chr(10), true);
for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) {
if (reFindNoCase(check.pattern, lines[lineNum])) {
@@ -3879,25 +4051,35 @@ component extends="modules.BaseModule" {
// treat as pass to avoid noisy false positives.
var isAbsent = structKeyExists(check, "absent") && check.absent;
if (isAbsent) {
- if (!arrayLen(filesToScan) || arrayLen(matches)) {
- arrayAppend(passed, check.description);
- } else {
+ if (arrayLen(filesToScan) && !arrayLen(matches)) {
+ matched = true;
var hint = structKeyExists(check, "scanDir") && len(check.scanDir)
? check.scanDir & "/ (no occurrences found)"
: "(no occurrences found)";
- arrayAppend(issues, {description: check.description, fix: check.fix, matches: [hint]});
+ matchEntry = {description: check.description, fix: check.fix, matches: [hint]};
}
} else {
if (arrayLen(matches)) {
- arrayAppend(issues, {description: check.description, fix: check.fix, matches: matches});
- } else {
- arrayAppend(passed, check.description);
+ matched = true;
+ matchEntry = {description: check.description, fix: check.fix, matches: matches};
}
}
}
+
+ // Bucket the result by severity. Advisories surface as opt-in
+ // recommendations alongside (but distinct from) breaking changes.
+ if (matched) {
+ if (severity == "advisory") {
+ arrayAppend(advisories, matchEntry);
+ } else {
+ arrayAppend(issues, matchEntry);
+ }
+ } else {
+ arrayAppend(passed, check.description);
+ }
}
- // Output
+ // Output — three sections in priority order: Breaking → Recommended → All Clear
if (arrayLen(issues)) {
out("Breaking Changes (#arrayLen(issues)# found):", "yellow");
for (var issue in issues) {
@@ -3910,6 +4092,22 @@ component extends="modules.BaseModule" {
}
}
+ if (arrayLen(advisories)) {
+ out("Recommended Improvements (#arrayLen(advisories)# found):", "cyan");
+ for (var advisory in advisories) {
+ out(" ~ #advisory.description#", "cyan");
+ for (var match in advisory.matches) {
+ out(" #match#");
+ }
+ // Advisory fix lines are intentionally uncolored so the
+ // section header and description carry the cyan accent and
+ // opt-in items read lighter than breaking-change fixes
+ // (which use cyan on the fix line for stronger emphasis).
+ out(" -> #advisory.fix#");
+ out("");
+ }
+ }
+
if (arrayLen(passed)) {
out("All Clear (#arrayLen(passed)# checks):", "green");
for (var p in passed) {
diff --git a/cli/lucli/templates/app/config/settings.cfm b/cli/lucli/templates/app/config/settings.cfm
index 367e17c3d3..1fe35e52f0 100644
--- a/cli/lucli/templates/app/config/settings.cfm
+++ b/cli/lucli/templates/app/config/settings.cfm
@@ -24,5 +24,14 @@
// Reload your application with ?reload=true&password={{reloadPassword}}
set(reloadPassword="{{reloadPassword}}");
+ /*
+ Migrator: produce `_id` columns from t.references() and friends
+ (matching Wheels model `belongsTo` defaults). Default in the framework
+ is `false` for backwards compatibility with existing apps; new apps
+ generated by `wheels new` opt in here. Flip to false if you need the
+ legacy `id` (no underscore) suffix.
+ */
+ set(useUnderscoreReferenceColumns=true);
+
// CLI-Appends-Here
diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc
index 6c55edc97c..8fa013b179 100644
--- a/cli/lucli/templates/app/public/Application.cfc
+++ b/cli/lucli/templates/app/public/Application.cfc
@@ -274,6 +274,34 @@ component output="false" {
// Must never break error handling
}
+ // If the Wheels global never came up (e.g. the /wheels mapping is
+ // stale or Injector.cfc can't be resolved), the original error is
+ // already lost — fall back to a minimal HTML response rather than
+ // cascading into "The key [WO] does not exist." (issue ##2773).
+ if (!StructKeyExists(application, "wo")) {
+ setting requestTimeout=30;
+ // Surface a real 5xx so monitoring tools and CDNs don't cache this
+ // failure as a successful response. Use a plain struct for
+ // attributeCollection — Adobe CF 2023/2025 reject the `arguments`
+ // scope on built-in tags (CLAUDE.md cross-engine invariant ##10).
+ try {
+ local.statusArgs = {statusCode: 500, statusText: "Internal Server Error"};
+ cfheader(attributeCollection=local.statusArgs);
+ } catch (any headerErr) {
+ // Header may already have been written; the body still renders.
+ }
+ WriteOutput("Application Error
");
+ WriteOutput("Wheels failed to initialize. Check the server log for details.
");
+ try {
+ if (isStruct(arguments.Exception) && StructKeyExists(arguments.Exception, "message")) {
+ WriteOutput("" & encodeForHTML(arguments.Exception.message) & "
");
+ }
+ } catch (any fallbackErr) {
+ // Last-ditch render must never throw.
+ }
+ return;
+ }
+
local.requestTimeout = application.wo.$getRequestTimeout() + 30;
if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "onErrorRequestTimeout")) {
local.requestTimeout = application.wheels.onErrorRequestTimeout;
diff --git a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc
index ba23958570..e03ccccf34 100644
--- a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc
+++ b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc
@@ -76,5 +76,69 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(out).toInclude("registry");
});
});
+
+ describe("wheels packages install — alias for add", () => {
+
+ // Issue #2785: prior implementation made `case "install":` in
+ // Module.cfc a friendly-redirect dead branch that printed a
+ // warning to stdout and returned "" without installing anything.
+ // That meant any caller that reached Module.cfc via a path that
+ // is not the user-facing CLI (MCP tools, scripted clients, specs)
+ // silently got nothing back when typing `install` — even though
+ // `PackagesMainCli.install()` itself has always been a true alias
+ // for `add()`. The alias must be wired through the dispatch
+ // layer too, so that the only place `install` ever no-ops is the
+ // LuCLI extension-installer intercept (which we cannot patch),
+ // and every in-process caller gets the same behavior as `add`.
+ it("throws the same BadInput error as `add` when name is missing", () => {
+ mod.__arguments = ["install"];
+ var threw = {flag: false, message: ""};
+ try {
+ mod.packages();
+ } catch (any e) {
+ threw.flag = true;
+ threw.message = e.message;
+ }
+ expect(threw.flag).toBeTrue();
+ // The error must point users at the canonical `add` verb so
+ // programmatic callers (MCP, scripts) see the right shape.
+ expect(threw.message).toInclude("add");
+ });
+
+ it("dispatches `install ` to the same code path as `add `", () => {
+ // Both verbs must reach PackagesMainCli — meaning neither
+ // short-circuits with a warning before instantiation. A
+ // bogus package name still throws (registry lookup fails),
+ // but it must throw the SAME way for both verbs. The prior
+ // behavior was that `install` silently returned "" while
+ // `add` threw — a divergence that broke any caller that
+ // expected the alias to be transparent.
+ var captureThrow = (verb) => {
+ var localMod = new cli.lucli.Module(cwd = variables.tempRoot);
+ localMod.__arguments = [verb, "wheels-this-package-does-not-exist-#CreateUUID()#"];
+ var threw = {flag: false, type: ""};
+ try {
+ localMod.packages();
+ } catch (any e) {
+ threw.flag = true;
+ threw.type = e.type;
+ }
+ return threw;
+ };
+ var addResult = captureThrow("add");
+ var installResult = captureThrow("install");
+ expect(addResult.flag).toBeTrue();
+ expect(installResult.flag).toBeTrue();
+ // Both must throw, and both must throw with a non-empty type
+ // — proving they reached the same registry-lookup code path
+ // rather than `install` being intercepted by a different branch.
+ expect(installResult.type).notToBe("");
+ // And both must throw the SAME exception type — a future
+ // regression that made `install` throw at argument validation
+ // (before the registry call) would still satisfy the non-empty
+ // check above, so pin the equivalence explicitly.
+ expect(installResult.type).toBe(addResult.type);
+ });
+ });
}
}
diff --git a/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc b/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc
new file mode 100644
index 0000000000..6c1be71993
--- /dev/null
+++ b/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc
@@ -0,0 +1,103 @@
+/**
+ * Source-level coverage for the underscore-references advisory entries
+ * shipped in PR4 of the #2781 follow-up series. These advisories surface in
+ * the "Recommended Improvements" section of `wheels upgrade check` output
+ * (severity=advisory — runs regardless of major-version-jump, doesn't gate
+ * CI exit codes).
+ *
+ * Like UpgradeCommandSpec, source-level inspection — Module.cfc can't be
+ * instantiated under TestBox.
+ */
+component extends="wheels.wheelstest.system.BaseSpec" {
+
+ function beforeAll() {
+ variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc"));
+ }
+
+ function run() {
+
+ describe("wheels upgrade — t.references() opt-in advisory", () => {
+
+ it("declares an advisory check that greps app/migrator/migrations for t.references(", () => {
+ expect(variables.moduleSource).toInclude("t\.references\s*\(");
+ expect(variables.moduleSource).toInclude("severity: ""advisory""");
+ expect(variables.moduleSource).toInclude("app/migrator/migrations");
+ });
+
+ it("mentions useUnderscoreReferenceColumns in the fix message", () => {
+ expect(variables.moduleSource).toInclude("useUnderscoreReferenceColumns=true");
+ });
+
+ it("notes that applied migrations are unaffected (avoids alarming users)", () => {
+ expect(variables.moduleSource).toInclude("Existing applied migrations are unaffected");
+ });
+
+ });
+
+ describe("wheels upgrade — mixed-convention warning", () => {
+
+ it("declares an advisory check that greps config/ for useUnderscoreReferenceColumns=true", () => {
+ expect(variables.moduleSource).toInclude("useUnderscoreReferenceColumns\s*=\s*true");
+ });
+
+ it("warns about legacy migrations possibly leaving id columns", () => {
+ expect(variables.moduleSource).toInclude("legacy migrations");
+ expect(variables.moduleSource).toInclude("data migration");
+ });
+
+ });
+
+ describe("wheels upgrade — false-positive guards", () => {
+
+ it("suppresses the opt-in advisory when the flag is already set anywhere in config/", () => {
+ // Source-level assertion on the guard variable: the t.references()
+ // advisory must only fire when the flag isn't already set, else
+ // new apps (which ship with the flag on by default) would see
+ // advisory #1 contradicting advisory #2.
+ expect(variables.moduleSource).toInclude("underscoreFlagAlreadySet");
+ expect(variables.moduleSource).toInclude("if (!underscoreFlagAlreadySet)");
+ });
+
+ it("strips CFML comments before grepping (Anti-Pattern ##14)", () => {
+ // The grep loop must run input through stripCfmlComments so a
+ // commented-out `// t.references(...)` or
+ // `// set(useUnderscoreReferenceColumns=true);` doesn't trip
+ // the advisory. The fix lives in the shared grep loop, so this
+ // is also a framework-wide benefit for every check struct.
+ expect(variables.moduleSource).toInclude("stripCfmlComments(fileRead(filePath))");
+ });
+
+ it("scans config/ recursively for the underscore flag pre-check (matches advisory ##2 scope)", () => {
+ // Fix for ##2808: the pre-check originally read only
+ // `config/settings.cfm`, but advisory ##2 below scans all of
+ // `config/` recursively. If a user sets the flag in an
+ // environment override file (e.g.
+ // `config/production/settings.cfm`), the pre-check missed it
+ // and both advisories fired contradicting each other.
+ // Scope-symmetry: walk the same tree the warning scans.
+ expect(variables.moduleSource).toInclude("directoryExists(configDir)");
+ expect(variables.moduleSource).toInclude("directoryList(configDir, true");
+ });
+
+ it("strips CFML comments on every config/ file scanned by the guard", () => {
+ // The flag-already-set guard must still strip comments so a
+ // commented-out `// set(useUnderscoreReferenceColumns=true);`
+ // in any env-override file doesn't satisfy the guard and
+ // suppress a real advisory (Anti-Pattern ##14).
+ expect(variables.moduleSource).toInclude("stripCfmlComments(fileRead(configFile))");
+ });
+
+ it("does not wrap reFindNoCase with len() — that pattern is always truthy", () => {
+ // `reFindNoCase()` returns an integer position (0 = no match).
+ // `len(0)` returns 1 (digit count of "0"), `len(25)` returns 2,
+ // so any reFindNoCase result wrapped in len() is truthy. The
+ // guard would be dead in every app whose config/settings.cfm
+ // exists. Use direct `> 0` comparison instead.
+ expect(variables.moduleSource).notToInclude("len(reFindNoCase");
+ });
+
+ });
+
+ }
+
+}
diff --git a/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc b/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc
new file mode 100644
index 0000000000..790bb527d6
--- /dev/null
+++ b/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc
@@ -0,0 +1,64 @@
+/**
+ * Source-level regression for `wheels upgrade check` scanner structure.
+ *
+ * PR3 of the #2781 follow-up series introduces a `severity` field on each
+ * check ("breaking" by default; "advisory" for opt-in recommendations) and
+ * adds a "Recommended Improvements" output section. Same-major upgrades no
+ * longer short-circuit before the checks loop — advisories must run
+ * regardless of major-version-bump.
+ *
+ * Like other CLI command specs (ConsoleCommandSpec, ReloadCommandSpec), we
+ * inspect Module.cfc source rather than instantiating it, because Module
+ * extends `modules.BaseModule` which is only resolvable at LuCLI runtime,
+ * not in TestBox.
+ */
+component extends="wheels.wheelstest.system.BaseSpec" {
+
+ function beforeAll() {
+ variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc"));
+ }
+
+ function run() {
+
+ describe("wheels upgrade — scanner structure (PR3)", () => {
+
+ it("declares the severity field convention in a comment", () => {
+ // The check-database comment must document the severity field
+ // so future contributors know the alias exists. Failure here
+ // means the convention wasn't recorded in code.
+ expect(variables.moduleSource).toInclude("severity");
+ expect(variables.moduleSource).toInclude("advisory");
+ });
+
+ it("does not short-circuit same-major upgrades before running checks", () => {
+ // Pre-PR3 behavior: `if (currentMajor == targetMajor) { ...; return ""; }`
+ // halted execution before checks could run. PR3 removes that
+ // early return so advisories fire on same-major upgrades too.
+ // Source check: ensure the `sameMajor` boolean is set and used,
+ // indicating the new flow that doesn't early-return.
+ expect(variables.moduleSource).toInclude("sameMajor");
+ });
+
+ it("buckets matched checks into issues OR advisories by severity", () => {
+ // The iteration loop must split matched checks into two buckets.
+ expect(variables.moduleSource).toInclude("advisories");
+ expect(variables.moduleSource).toInclude("matchEntry");
+ });
+
+ it("renders a 'Recommended Improvements' output section", () => {
+ expect(variables.moduleSource).toInclude("Recommended Improvements");
+ });
+
+ it("preserves the 'Breaking Changes' output section", () => {
+ expect(variables.moduleSource).toInclude("Breaking Changes");
+ });
+
+ it("preserves the 'All Clear' output section", () => {
+ expect(variables.moduleSource).toInclude("All Clear");
+ });
+
+ });
+
+ }
+
+}
diff --git a/public/Application.cfc b/public/Application.cfc
index 6534f6aa49..32946246e5 100644
--- a/public/Application.cfc
+++ b/public/Application.cfc
@@ -288,6 +288,34 @@ component output="false" {
// Must never break error handling
}
+ // If the Wheels global never came up (e.g. the /wheels mapping is
+ // stale or Injector.cfc can't be resolved), the original error is
+ // already lost — fall back to a minimal HTML response rather than
+ // cascading into "The key [WO] does not exist." (issue ##2773).
+ if (!StructKeyExists(application, "wo")) {
+ setting requestTimeout=30;
+ // Surface a real 5xx so monitoring tools and CDNs don't cache this
+ // failure as a successful response. Use a plain struct for
+ // attributeCollection — Adobe CF 2023/2025 reject the `arguments`
+ // scope on built-in tags (CLAUDE.md cross-engine invariant ##10).
+ try {
+ local.statusArgs = {statusCode: 500, statusText: "Internal Server Error"};
+ cfheader(attributeCollection=local.statusArgs);
+ } catch (any headerErr) {
+ // Header may already have been written; the body still renders.
+ }
+ WriteOutput("Application Error
");
+ WriteOutput("Wheels failed to initialize. Check the server log for details.
");
+ try {
+ if (isStruct(arguments.Exception) && StructKeyExists(arguments.Exception, "message")) {
+ WriteOutput("" & encodeForHTML(arguments.Exception.message) & "
");
+ }
+ } catch (any fallbackErr) {
+ // Last-ditch render must never throw.
+ }
+ return;
+ }
+
// In case the error was caused by a timeout we have to add extra time for error handling.
// We have to check if onErrorRequestTimeout exists since errors can be triggered before the application.wheels struct has been created.
local.requestTimeout = application.wo.$getRequestTimeout() + 30;
diff --git a/tools/distribution-drafts/apt-repo/README.md b/tools/distribution-drafts/apt-repo/README.md
index 1be8706d2e..de4e3d07d2 100644
--- a/tools/distribution-drafts/apt-repo/README.md
+++ b/tools/distribution-drafts/apt-repo/README.md
@@ -1,9 +1,15 @@
-# `apt-wheels-dev` bucket repo template
+# `apt-wheels` bucket repo template
-This directory is the **template** for the standalone `wheels-dev/apt-wheels-dev`
-repository that backs `https://apt.wheels.dev`. The bucket repo holds the static
-apt metadata tree plus the pooled `.deb` artifacts, and is auto-deployed to
-Cloudflare Pages on every push.
+This directory is the **template** for the standalone `wheels-dev/apt-wheels`
+repository that backs `https://apt.wheels.dev`. The bucket repo holds the
+**workflow + scripts + landing page + signing key**. The apt metadata tree
+(`dists/`) and the `.deb` pool (`pool/`) live in **Cloudflare R2** (bucket
+`wheels-apt`) and are served via R2's custom-domain feature.
+
+> **Note on Pages vs R2:** The original Phase 2 design called for Cloudflare
+> Pages serving the bucket repo directly. Pages has a hard **25 MiB per-file**
+> limit; the `.deb` is ~80 MB. The architecture pivoted to R2 (no per-object
+> size limit) during initial rollout.
Copy these files into the new repo when it's created — they are designed to
work out of the box once the Phase 2 operational prerequisites (GPG key,
@@ -101,13 +107,15 @@ Before this bucket repo will function:
into 1Password under `op://Wheels/wheels-linux-repo-signing/` (Wheels
project vault on `my.1password.com`).
Public key (ASCII-armored) overwrites `wheels.gpg` at the bucket-repo root.
-2. **Cloudflare Pages** — create a Pages project pointing at this repo, bind
- the apex domain `apt.wheels.dev`. The build command is empty (the repo
- *is* the static site); the output dir is `./`.
+2. **R2 bucket** — create a Cloudflare R2 bucket named `wheels-apt` and
+ attach the `apt.wheels.dev` custom domain. R2 has no per-object size
+ limit (unlike Pages' 25 MiB), which is why binaries are stored here.
3. **CI secrets** (set on the bucket repo at
- `https://github.com/wheels-dev/apt-wheels-dev/settings/secrets/actions`):
+ `https://github.com/wheels-dev/apt-wheels/settings/secrets/actions`):
- `WHEELS_REPO_GPG_PRIVATE_KEY` — ASCII-armored private key
- `WHEELS_REPO_GPG_PASSPHRASE` — passphrase
+ - `CLOUDFLARE_API_TOKEN` — token with `Workers R2 Storage:Edit` on the
+ account that owns the `wheels-apt` bucket
4. **Upstream dispatch** — the release workflow in `wheels-dev/wheels`
fires a `repository_dispatch` (`wheels-released`) at this repo when a
new `.deb` is published to the GitHub Release. The token used by the
diff --git a/tools/distribution-drafts/apt-repo/templates/index.html b/tools/distribution-drafts/apt-repo/templates/index.html
index f01cb9d5dd..afb48c2203 100644
--- a/tools/distribution-drafts/apt-repo/templates/index.html
+++ b/tools/distribution-drafts/apt-repo/templates/index.html
@@ -79,7 +79,7 @@ Upgrading
sudo apt update && sudo apt upgrade wheels # or wheels-be
Signing key
- The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.
+ The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.
Other platforms
@@ -89,7 +89,7 @@ Other platforms