diff --git a/.ai/wheels/cross-engine-compatibility.md b/.ai/wheels/cross-engine-compatibility.md
index 2e10312826..80bafffabc 100644
--- a/.ai/wheels/cross-engine-compatibility.md
+++ b/.ai/wheels/cross-engine-compatibility.md
@@ -100,6 +100,36 @@ var fn = obj["dynamicMethod"];
var result = fn();
```
+### Quoted Named Argument in a Direct Call (Adobe CF 2023)
+
+A named argument whose *name* is a quoted string literal — `obj.method("name" = value)` — is a **compile error on Adobe CF 2023 only** (`MissingNameException: Invalid construct: Either argument or name is missing` / "each parameter must have a name"). Lucee 6/7, BoxLang, **and Adobe CF 2025** all accept it. This is the inverse footprint of the related invariants: the parenthesized-`new` receiver (invariant 16a) fails on both Adobe engines, the zero-arg `application`-scope call (16b/16b-ext) is Adobe 2025 only, and this one is the lone **Adobe 2023-only** shape — so an Adobe 2025 smoke, or Lucee-only local verification, does **not** cover it.
+
+**Context-independent (unlike the closure-sensitive 16b-ext).** Probe-verified on adobe2023/62023, the quoted named-arg call fails identically in every call context tested — a single-expression arrow (`(x) => obj.method("n" = x)`), a block-bodied arrow, a regular `function()` closure, and a plain statement in a non-closure method body all throw the same `MissingNameException`. There is no "safe" context; only changing the call shape (below) helps.
+
+You only reach for a quoted argument name when the name is otherwise illegal as a bare token — a `$`-prefixed or hyphenated argument. Measured boundaries that compile on adobe2023 (do **not** "fix" these):
+- an *unquoted* named argument, `obj.method(name = value)`, compiles on every engine;
+- a quoted key in a *struct literal*, `{"$x" = 1}`, compiles everywhere — it is a struct key, not a function-argument name;
+- passing the same `$`-prefixed value via `argumentCollection` compiles everywhere.
+
+Adobe misattributes the error to the enclosing `describe(...)` / `function` line, and because the core suite compiles via `directory="wheels.tests.specs"`, a single occurrence zeroes the **entire adobe2023 leg** (`tests="0"` on every database) while Lucee/BoxLang/Adobe 2025 stay green.
+
+```cfm
+// WRONG — MissingNameException on adobe2023 (compiles on Lucee/BoxLang/adobe2025)
+var out = c.URLFor(controller = "posts", action = "index", "$argsResolved" = true);
+
+// RIGHT — the $-prefixed name lives on a struct key, not an argument name
+var args = {controller = "posts", action = "index"};
+args["$argsResolved"] = true;
+var out = c.URLFor(argumentCollection = args);
+```
+
+**Reference**: `vendor/wheels/tests/specs/view/linkToUrlForDifferentialSpec.cfc` ([#4192](https://github.com/wheels-dev/wheels/pull/4192)). It was pre-existing and only the adobe2023 matrix legs caught it. Bisect against a running container (~13s vs ~19min), using the Adobe 2023 port `62023`:
+
+```bash
+curl -s "http://localhost:62023/wheels/core/tests?db=sqlite&format=json&cli=true" | \
+ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass','COMPILE FAIL'), d.get('RootCause',{}).get('snippet',''))"
+```
+
### Method Reference Extraction Loses Receiver (BoxLang)
BoxLang implements method dispatch with JavaScript-style semantics: pulling a method off an object into a local variable produces a bare function reference with no bound receiver. Calling that local then runs the function in an empty context, and any in-component call inside (helpers prefixed with `$`, `this.x()`, etc.) fails to resolve. The lookup-and-call **must** stay in a single expression for BoxLang to bind the receiver.
@@ -230,6 +260,58 @@ try {
**Reference example**: `$restoreEmailViewVariables()` in [`vendor/wheels/controller/miscellaneous.cfc`](../../vendor/wheels/controller/miscellaneous.cfc) — the `sendEmail` variables-scope restore runs from `finally` via a `public` `$`-prefixed helper (mixin invariant: helpers must be public). Found while addressing review on [#2922](https://github.com/wheels-dev/wheels/pull/2922).
+### `continue` and `break` in One `try`/`catch` Inside a Loop: `continue` Exits the Loop (Lucee 6/7)
+
+Inside a loop, a `try`/`catch` that contains **both** a `continue` and a `break` makes the `continue` leave the loop, as if it were a `break`. The `break` doesn't have to run: an `if (false) break;` in the `catch` is enough. Where the two keywords sit doesn't matter either: `continue` in the `try` and `break` in the `catch`, or both in the `catch`.
+
+```cfm
+// WRONG: on Lucee 6/7 this returns "first", not "first,second,third"
+local.trace = "";
+for (local.item in ["first", "second", "third"]) {
+ local.trace = ListAppend(local.trace, local.item);
+ try {
+ if (true) {
+ continue;
+ }
+ } catch (any e) {
+ break;
+ }
+}
+
+// RIGHT: no continue; let if/else carry control to the end of the iteration
+for (local.item in local.items) {
+ try {
+ if (skip(local.item)) {
+ recordSkip(local.item);
+ } else {
+ process(local.item);
+ }
+ } catch (any e) {
+ if (stopOnError) break;
+ }
+}
+```
+
+**Measured** with the same probe spec (compat-matrix dispatch, SQLite):
+
+| Engine (build) | `continue` + `break` in one `try`/`catch` inside a loop |
+|---|---|
+| Lucee 6.2.5.48 | **`continue` exits the loop** |
+| Lucee 7.0.1.100 (the CI pin) | **`continue` exits the loop** |
+| Lucee 7.0.0.395 (local) | **`continue` exits the loop** |
+| Adobe CF 2023.0.11, 2025.0.06 | correct |
+| BoxLang 1.11.0+52 | correct |
+
+The RustCFML v0.693.0 lane reported no failures in the same run; its per-spec result wasn't checked individually.
+
+**Measured as correct on every engine above:**
+- the same `try`/`catch` with a `continue` but no `break`;
+- a loop **inside** a `try` that uses both `continue` and `break` for that loop. This covers a `for`-in loop that continues and then breaks, a `while` with `break` around a nested `for` with `continue`, and one loop with both.
+
+The failing shape reproduces with `for`-in and index loops, with or without an outer `try`/`finally`.
+
+**Where it bit:** `TenantMigrator.migrateAll()`. A `continue` in the inner `try` (after recording a failed tenant), next to `if (arguments.stopOnError) break;` in its `catch`, recorded only the first failed tenant with `stopOnError = false`. The fix uses `if`/`else` ([#4169](https://github.com/wheels-dev/wheels/pull/4169)). A source sweep of `vendor/wheels/` when this was found turned up no other occurrence of the failing shape.
+
### `DirectoryCreate()` Second Argument Is Lucee-Only
Lucee accepts `DirectoryCreate(path, createPath, mode)` and recurses parent directories when `createPath=true`. Adobe CF's signature varies by version and at least some Adobe builds reject any second argument with `"The function takes 1 parameter"` (issue #2567).
diff --git a/.ai/wheels/deploy.md b/.ai/wheels/deploy.md
index a2afb6ae3b..0c7d7bc4b2 100644
--- a/.ai/wheels/deploy.md
+++ b/.ai/wheels/deploy.md
@@ -85,5 +85,5 @@ Fixtures at `cli/lucli/tests/_fixtures/deploy/configs/` (`minimal.yml`, `full.ym
## Reference docs
-- User guides: `web/sites/guides/src/content/docs/v4-0-0/deployment/` (first-deploy, production-config, accessories, secrets, hooks, migrating-from-kamal, security-hardening, docker-deployment)
+- User guides: `web/sites/guides/src/content/docs/v4-2-0/deployment/` (first-deploy, production-config, accessories, secrets, hooks, migrating-from-kamal, security-hardening, docker-deployment)
- In-source CLI docs: `cli/lucli/services/deploy/cli/docs/` (per-verb)
diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md
index 3ba17c62f2..e264e5d4ba 100644
--- a/.ai/wheels/testing/browser-testing.md
+++ b/.ai/wheels/testing/browser-testing.md
@@ -17,7 +17,6 @@ component extends="wheels.wheelstest.BrowserTest" {
// from inside the suite body via this helper.
browserDescribe("Login flow", () => {
it("can load a page and read its title", () => {
- if (this.browserTestSkipped) return;
this.browser.visitUrl("data:text/html,
Hix
")
.assertTitleContains("Hi");
});
@@ -63,8 +62,9 @@ bash tools/test-local.sh # skips browser specs if JARs missin
- **`##` 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. 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.
+- **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit: each spec is reported as **Skipped** with `this.browserTestSkipReason` (never as a pass). `it`s in a plain `describe()` call `browserSpecGuard();` first (skip with reason, or Error on a launch failure); a bare `if (this.browserTestSkipped) return;` reads as a pass.
+- **`this.browserLaunchError`** — Playwright installed but the browser could not start (missing system libraries, broken driver): `browserDescribe` reports each spec as an **error** with this message. `WHEELS_BROWSER_SKIP_LAUNCH_FAILURES=true` turns that into a skip. The decision is `$browserSpecGate()`.
+- **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:30007` 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.
- **Isolated application context (#3374)** — `BrowserTest.$startBrowserContext()` sends `X-Wheels-Test-Context` (Playwright `extraHTTPHeaders`) plus a `WHEELS_TEST_CONTEXT` cookie so fixture HTTP binds `_wheelsTest`, not the live app. `Application.cfc` must include `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` (ships in `wheels new`). Without that snippet, browser tests still work via the #3373 live-scope swap.
- **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. The `/_browser/login-as` handler is configurable: `set(browserLoginAsHandler = "AuthFixture##loginAs")` in `config/settings.cfm` substitutes that `Controller##action` at route-registration time (default is `BrowserTestLogin##create`). Env-gating is handled by `wheels.middleware.BrowserTestFixtureGuard` on the whole `/_browser` scope — custom handlers do not need to re-implement the guard. Empty string or absent setting falls back to the default. (#2830)
- **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.
diff --git a/.editorconfig b/.editorconfig
index 03298c4e16..ab625b6859 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -16,3 +16,10 @@ indent_style = space
[{index.cfm,rewrite.cfm,root.cfm,wheels/index.cfm}]
insert_final_newline = false
+
+# Per-request/lifecycle event templates emit zero output: they are $include'd into
+# the response buffer, so a trailing newline after leaks as whitespace
+# before renderText output and breaks XML. Keep them newline-free (demo app/events
+# and the CLI generator templates). See #3882.
+[**/events/{onabort,onapplicationend,onapplicationstart,onrequestend,onrequeststart,onsessionend,onsessionstart}.cfm]
+insert_final_newline = false
diff --git a/.env b/.env
index 6291d7bc7e..7b394c0e2f 100644
--- a/.env
+++ b/.env
@@ -2,7 +2,3 @@ LUCEE_EXTENSIONS=465E1E35-2425-4F4E-8B3FAB638BD7280A;name=H2;version=1.3.172
cfconfig_adminPassword=commandbox
WHEELS_ENV=development
BASE_URL=http://127.0.0.1:8080
-
-# Consumed by wheels.tests.specs.global.EnvFileParsingSpec: a .env value of
-# numeric 1 must survive parsing as a VALUE, not become the boolean true.
-WHEELS_SPEC_NUMERIC_ONE=1.0
diff --git a/.github/RELEASE_PLAYBOOK.md b/.github/RELEASE_PLAYBOOK.md
index 2028900470..ee6b149217 100644
--- a/.github/RELEASE_PLAYBOOK.md
+++ b/.github/RELEASE_PLAYBOOK.md
@@ -236,6 +236,7 @@ If any pin to `/dev/null 2>&1; then
diff --git a/.github/actions/setup-wheels-test-env/action.yml b/.github/actions/setup-wheels-test-env/action.yml
index 946213e4a3..10102f69bd 100644
--- a/.github/actions/setup-wheels-test-env/action.yml
+++ b/.github/actions/setup-wheels-test-env/action.yml
@@ -15,7 +15,7 @@ inputs:
port:
description: 'Port to bind Lucee to'
required: false
- default: '60007'
+ default: '30007'
install-playwright:
description: 'Install Playwright + Chromium (set "false" to skip if no browser tests run)'
required: false
@@ -65,7 +65,7 @@ runs:
SHA=$(echo "$row" | jq -r '.sha256')
echo "Downloading ${FILE}..."
- curl -sL "$URL" -o ~/.wheels/browser/lib/"$FILE"
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "$URL" -o ~/.wheels/browser/lib/"$FILE"
ACTUAL=$(sha256sum ~/.wheels/browser/lib/"$FILE" | cut -d' ' -f1)
if [ "$ACTUAL" != "$SHA" ]; then
@@ -81,7 +81,7 @@ runs:
- name: Download SQLite JDBC driver
shell: bash
run: |
- curl -sL "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
-o /tmp/sqlite-jdbc.jar
- name: Start Lucee server
diff --git a/.github/scripts/blog-announce-on-publish.sh b/.github/scripts/blog-announce-on-publish.sh
index d8be906b6a..7146cc57f1 100644
--- a/.github/scripts/blog-announce-on-publish.sh
+++ b/.github/scripts/blog-announce-on-publish.sh
@@ -10,8 +10,9 @@
# `announcement:` block still lacks a `discussionUrl` (backfill).
# 2. Run web/scripts/blog-announce-discussion.mjs over the candidates. The
# dedupe already lives in that script: before creating a discussion it
-# searches existing Announcements by exact title and/or the post's blog URL,
-# and on a hit writes that URL back instead of creating a duplicate.
+# lists the Announcements category and matches the post's slug marker,
+# exact title or blog URL, and on a hit writes that URL back instead of
+# creating a duplicate.
# 3. If the frontmatter gained a discussionUrl, commit it on
# docs/bot-blog-announce- and open a tiny PR to develop (same
# pattern as the publish workflow — never push to develop directly).
diff --git a/.github/workflows/ajp-integration.yml b/.github/workflows/ajp-integration.yml
new file mode 100644
index 0000000000..1cddfaf21f
--- /dev/null
+++ b/.github/workflows/ajp-integration.yml
@@ -0,0 +1,42 @@
+name: TestClient works behind an AJP front end
+
+# Optional, manual regression check (#4210): runs the internal TestClient specs through Apache
+# httpd -> mod_proxy_ajp -> Lucee 7's AJP connector, the shape of IIS+BonCode / mod_jk
+# deployments, and asserts they pass. See tools/ci/ajp/run.sh for what the servlet reports there.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ ajp:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v5
+
+ - name: Build the Lucee 7 image
+ run: |
+ MAX_ATTEMPTS=3
+ ATTEMPT=1
+ until docker compose build lucee7; do
+ if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
+ echo "::error::docker compose build failed after ${MAX_ATTEMPTS} attempts"
+ exit 1
+ fi
+ ATTEMPT=$((ATTEMPT + 1))
+ sleep 15
+ done
+
+ - name: Run the TestClient specs through httpd and AJP
+ run: AJP_HTTPD_PORT=9590 AJP_RESULT_JSON=ajp-result.json bash tools/ci/ajp/run.sh wheels-ajp
+
+ - name: Upload the result
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: ajp-result
+ path: ajp-result.json
+ if-no-files-found: ignore
diff --git a/.github/workflows/blog-announce-on-publish.yml b/.github/workflows/blog-announce-on-publish.yml
index 32b7d3e88e..bc2b2d5edc 100644
--- a/.github/workflows/blog-announce-on-publish.yml
+++ b/.github/workflows/blog-announce-on-publish.yml
@@ -11,8 +11,9 @@ name: Blog — Announce on Publish
# the Discussion already existed but the publish never landed, so the next
# day's run re-announced and duplicated the thread. Announcing only on
# push-to-develop means a post is durable before any announcement is made,
-# and the dedupe in web/scripts/blog-announce-discussion.mjs (exact title /
-# blog-URL search before create) is a second line of defence.
+# and the dedupe in web/scripts/blog-announce-discussion.mjs (before create it
+# lists the category and matches the post's slug marker, title or blog URL) is
+# a second line of defence.
on:
push:
@@ -32,7 +33,7 @@ concurrency:
jobs:
announce:
name: Announce published posts
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Generate App token
@@ -57,6 +58,10 @@ jobs:
with:
node-version: 22
+ # Fail closed: never announce with a dedupe that doesn't pass its own tests.
+ - name: Test the announcement helper
+ run: node --test web/scripts/blog-announce-discussion.test.mjs
+
- name: Announce and write back discussion URLs
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
diff --git a/.github/workflows/blog-publish-scheduled.yml b/.github/workflows/blog-publish-scheduled.yml
index a3034bd65a..1bf13a43ad 100644
--- a/.github/workflows/blog-publish-scheduled.yml
+++ b/.github/workflows/blog-publish-scheduled.yml
@@ -27,7 +27,7 @@ concurrency:
jobs:
publish:
name: Publish posts scheduled for today
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Generate App token
diff --git a/.github/workflows/bot-address-review.yml b/.github/workflows/bot-address-review.yml
index ca510610da..3f4ccc554d 100644
--- a/.github/workflows/bot-address-review.yml
+++ b/.github/workflows/bot-address-review.yml
@@ -40,7 +40,7 @@ concurrency:
jobs:
address-review:
name: Address review
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 60
# Opt-in only: the maintainer-applied `bot-address-review` label
# (labeling requires triage access or higher — keep the triage role
@@ -129,7 +129,7 @@ jobs:
if: steps.gate.outputs.skip == 'false'
uses: ./.github/actions/setup-wheels-test-env
with:
- port: '60007'
+ port: '30007'
install-playwright: 'false'
- name: Run Address Review
diff --git a/.github/workflows/bot-auto-close.yml b/.github/workflows/bot-auto-close.yml
index 6f4f1b7276..6d29eac3ab 100644
--- a/.github/workflows/bot-auto-close.yml
+++ b/.github/workflows/bot-auto-close.yml
@@ -15,7 +15,7 @@ concurrency:
jobs:
auto-close:
name: Auto-close stale triage
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 10
if: vars.WHEELS_BOT_ENABLED == 'true'
steps:
diff --git a/.github/workflows/bot-freshen.yml b/.github/workflows/bot-freshen.yml
index c9a61b69ff..db67192fc3 100644
--- a/.github/workflows/bot-freshen.yml
+++ b/.github/workflows/bot-freshen.yml
@@ -23,7 +23,7 @@ jobs:
freshen:
name: Freshen open bot PRs
if: vars.WHEELS_BOT_ENABLED == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
env:
REPO: wheels-dev/wheels
diff --git a/.github/workflows/bot-propose-fix.yml b/.github/workflows/bot-propose-fix.yml
index f9f11c9e6e..94d5f2b5cd 100644
--- a/.github/workflows/bot-propose-fix.yml
+++ b/.github/workflows/bot-propose-fix.yml
@@ -20,7 +20,7 @@ concurrency:
jobs:
propose-fix:
name: Propose fix
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 60
# Phase 4 (auto-fire): runs from wheels-bot[bot] comments containing a
# high- OR medium-confidence triage marker (bug path) or research marker
@@ -143,7 +143,7 @@ jobs:
if: steps.gate.outputs.skip == 'false' && steps.campaign.outputs.skip == 'false'
uses: ./.github/actions/setup-wheels-test-env
with:
- port: '60007'
+ port: '30007'
install-playwright: 'false'
- name: Run Propose Fix
diff --git a/.github/workflows/bot-research.yml b/.github/workflows/bot-research.yml
index 1fa6210e1b..1c4f47ae44 100644
--- a/.github/workflows/bot-research.yml
+++ b/.github/workflows/bot-research.yml
@@ -20,7 +20,7 @@ concurrency:
jobs:
research:
name: Cross-framework research
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 30
# Phase 4 (auto-fire): runs from wheels-bot[bot] comments containing
# `wheels-bot:triage-class:framework-design`. workflow_dispatch is
diff --git a/.github/workflows/bot-resolve-conflicts.yml b/.github/workflows/bot-resolve-conflicts.yml
index e5a18e0fbe..0cb7af665c 100644
--- a/.github/workflows/bot-resolve-conflicts.yml
+++ b/.github/workflows/bot-resolve-conflicts.yml
@@ -24,7 +24,7 @@ jobs:
resolve:
name: Resolve conflicts (tiered)
if: vars.WHEELS_BOT_ENABLED == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 60
env:
PR_NUMBER: ${{ inputs.pr-number }}
diff --git a/.github/workflows/bot-review-fork.yml b/.github/workflows/bot-review-fork.yml
index a67b79fb17..2c158b2ef1 100644
--- a/.github/workflows/bot-review-fork.yml
+++ b/.github/workflows/bot-review-fork.yml
@@ -54,7 +54,7 @@ jobs:
# a labeled fork PR (the pull_request one skips on the absent vars gate,
# this one runs), so distinct check names keep the UI unambiguous.
name: Reviewer (fork)
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
# Fork PRs only, and only once a maintainer has applied the `bot-review`
# label. `synchronize` re-reviews on new pushes while the label is present;
diff --git a/.github/workflows/bot-review.yml b/.github/workflows/bot-review.yml
index 85cedacfc1..1b9d918244 100644
--- a/.github/workflows/bot-review.yml
+++ b/.github/workflows/bot-review.yml
@@ -28,7 +28,7 @@ concurrency:
jobs:
review:
name: Reviewer
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
# Reviews bot PRs (even draft) and human ready-for-review PRs.
if: |
diff --git a/.github/workflows/bot-tdd-gate.yml b/.github/workflows/bot-tdd-gate.yml
index f811ed6b28..2e76343b4a 100644
--- a/.github/workflows/bot-tdd-gate.yml
+++ b/.github/workflows/bot-tdd-gate.yml
@@ -12,7 +12,7 @@ permissions:
jobs:
tdd-gate:
name: Bot PR TDD Gate
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Decide if this PR is bot-authored
diff --git a/.github/workflows/bot-triage.yml b/.github/workflows/bot-triage.yml
index 6942d7a910..d306ddf509 100644
--- a/.github/workflows/bot-triage.yml
+++ b/.github/workflows/bot-triage.yml
@@ -20,7 +20,7 @@ concurrency:
jobs:
triage:
name: Triage issue
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 25
if: |
vars.WHEELS_BOT_ENABLED == 'true'
diff --git a/.github/workflows/bot-update-docs.yml b/.github/workflows/bot-update-docs.yml
index 05d8f6f7e7..d565645a77 100644
--- a/.github/workflows/bot-update-docs.yml
+++ b/.github/workflows/bot-update-docs.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
update-docs:
name: Update docs
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 30
# Auto-fire only for the bot's own PRs. The bot-identity check is
# load-bearing: prevents human PRs from triggering this stage and
diff --git a/.github/workflows/bot-write-docs.yml b/.github/workflows/bot-write-docs.yml
index b99fac164f..600228080a 100644
--- a/.github/workflows/bot-write-docs.yml
+++ b/.github/workflows/bot-write-docs.yml
@@ -26,7 +26,7 @@ concurrency:
jobs:
write-docs:
name: Write docs
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 30
# Auto-fires from wheels-bot[bot] comments with docs-confidence:high
# OR docs-confidence:medium. Low stays manual.
diff --git a/.github/workflows/bump-develop-version.yml b/.github/workflows/bump-develop-version.yml
index a634c9fe28..db8c952ade 100644
--- a/.github/workflows/bump-develop-version.yml
+++ b/.github/workflows/bump-develop-version.yml
@@ -46,7 +46,7 @@ permissions:
jobs:
bump:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
# repository_dispatch carries the version in client_payload.version
# (set by release.yml's dispatch step). workflow_dispatch supplies it
@@ -102,7 +102,7 @@ jobs:
jq '.version' wheels.json
- name: Open bump PR
- uses: peter-evans/create-pull-request@v6
+ uses: peter-evans/create-pull-request@v8.1.1
with:
branch: "auto-bump/develop-${{ env.NEW_VERSION }}"
title: "chore: bump develop snapshot target to ${{ env.NEW_VERSION }}"
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 95c05edbe7..54b9c06a53 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -16,7 +16,7 @@ on:
jobs:
complexity-gate:
name: Complexity gate (no new/regressed complexity > 30)
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
diff --git a/.github/workflows/cold-start-smoke.yml b/.github/workflows/cold-start-smoke.yml
index b20ea9bb7c..b40fb25e42 100644
--- a/.github/workflows/cold-start-smoke.yml
+++ b/.github/workflows/cold-start-smoke.yml
@@ -56,7 +56,7 @@ concurrency:
jobs:
cold-start:
name: "Cold start: ${{ matrix.engine }}"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
diff --git a/.github/workflows/commandbox-install-smoke.yml b/.github/workflows/commandbox-install-smoke.yml
index ac8703a268..0981b74066 100644
--- a/.github/workflows/commandbox-install-smoke.yml
+++ b/.github/workflows/commandbox-install-smoke.yml
@@ -65,7 +65,7 @@ permissions:
jobs:
commandbox-install-smoke:
name: "box install -> serve (CommandBox)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 25
# CommandBox is the runtime on the ForgeBox install path. Run the whole job
# inside the official image so `box` is on PATH exactly as a real CommandBox
@@ -121,6 +121,10 @@ jobs:
bash tools/build/scripts/prepare-core.sh "${PKG_VERSION}" "develop" "${{ github.run_number }}" "false"
test -d build-wheels-base || { echo "::error::prepare-base.sh produced no build-wheels-base/"; exit 1; }
test -d build-wheels-core/wheels || { echo "::error::prepare-core.sh produced no build-wheels-core/wheels/"; exit 1; }
+ # Both packages carry the consumer AI docs, never the maintainer tier (#3947).
+ for pkg in build-wheels-base build-wheels-core/wheels; do
+ bash tools/build/scripts/ship-consumer-docs.sh verify "$pkg"
+ done
# Structural guards on the prepared base artifact (cheap, pre-boot). These
# pin the three packaging defects the campaign repaired so a regression is
@@ -234,35 +238,36 @@ jobs:
# --- Production journey: the non-dev reload-gate / no-trace probes. ------
# Reuse tools/ci/smoke-env.sh (the same harness the Smoke workflow drives)
- # against a production boot. environment.cfm hardcodes set(environment=...),
- # so flip it to production before boot (the app cold-boots into it). The
- # reload password is read from env("WHEELS_RELOAD_PASSWORD") in settings.cfm
- # and commandbox-dotenv loads .env at boot, so the password must live in
- # .env (a container -e var is overridden by .env on this path) for probe 6
- # (authorized reload must 302) to be meaningful.
+ # against a production boot. The generated environment.cfm reads
+ # env("WHEELS_ENV") (#3946); like a wheels deploy init image (Dockerfile
+ # ENV, .env left out), this boot gets WHEELS_ENV=production from the
+ # server's process environment only, without touching environment.cfm, so
+ # the probes fail if the generated config stops honouring it. A WHEELS_ENV
+ # line in .env would win over the process value, so it is removed. The reload
+ # password is read from env("WHEELS_RELOAD_PASSWORD") in settings.cfm and
+ # commandbox-dotenv loads .env at boot, so it must live in .env (a container
+ # -e var is overridden by .env on this path) for probe 6 (authorized reload
+ # must 302) to be meaningful.
- name: Switch app to production
run: |
set -e
cd "$APP_DIR"
- sed -i 's/set(environment="[a-z]*")/set(environment="production")/' config/environment.cfm
- grep -qF 'set(environment="production")' config/environment.cfm || {
- echo "::error::environment.cfm substitution failed — pattern drift?"; exit 1; }
- # Reload password into .env so settings.cfm's env() read + the smoke
- # probe agree. (commandbox-dotenv wins over container env on this path.)
- if [ -f .env ]; then
- sed -i 's/^WHEELS_RELOAD_PASSWORD=.*/WHEELS_RELOAD_PASSWORD='"${SMOKE_RELOAD_PASSWORD}"'/' .env
- grep -q '^WHEELS_RELOAD_PASSWORD=' .env || echo "WHEELS_RELOAD_PASSWORD=${SMOKE_RELOAD_PASSWORD}" >> .env
- else
- printf 'WHEELS_RELOAD_PASSWORD=%s\n' "${SMOKE_RELOAD_PASSWORD}" > .env
- fi
- grep -n 'set(environment' config/environment.cfm
+ grep -qF 'env("WHEELS_ENV", "")' config/environment.cfm || {
+ echo "::error::config/environment.cfm no longer reads WHEELS_ENV"; exit 1; }
+ touch .env
+ sed -i '/^WHEELS_ENV=/d; /^WHEELS_RELOAD_PASSWORD=/d' .env
+ echo "WHEELS_RELOAD_PASSWORD=${SMOKE_RELOAD_PASSWORD}" >> .env
+ grep -n 'WHEELS_ENV' config/environment.cfm
+ if grep -q '^WHEELS_ENV=' .env; then
+ echo "::error::.env still sets WHEELS_ENV"; exit 1; fi
grep -n '^WHEELS_RELOAD_PASSWORD' .env
- name: Start CommandBox server (production)
run: |
set -e
cd "$APP_DIR"
- box server start port=8080 host=0.0.0.0 openbrowser=false || true
+ # The process environment is the only source of WHEELS_ENV here (see above).
+ WHEELS_ENV=production box server start port=8080 host=0.0.0.0 openbrowser=false || true
curl -s -o /dev/null --retry 40 --retry-delay 3 --retry-all-errors \
--retry-connrefused --max-time 10 "http://localhost:8080/" \
|| { echo "::error::server did not bind in production"; box server log || true; exit 1; }
diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml
index e7b1f20978..f0bb040bf4 100644
--- a/.github/workflows/compat-matrix.yml
+++ b/.github/workflows/compat-matrix.yml
@@ -34,7 +34,7 @@ on:
type: boolean
default: false
engines:
- description: 'Comma list of engines to run (blank = all: lucee6,lucee7,adobe2023,adobe2025,boxlang; add rustcfml to include that lane in a narrowed run)'
+ description: 'Comma list of engines to run (blank = all: lucee6,lucee7,adobe2023,adobe2025,boxlang; add rustcfml to include that lane in a narrowed run, tz to include the America/New_York lanes)'
type: string
default: ''
databases:
@@ -68,7 +68,7 @@ jobs:
# iteration on one engine or database.
plan:
name: Plan matrix
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
has_legs: ${{ steps.plan.outputs.has_legs }}
@@ -86,6 +86,11 @@ jobs:
ENGINES = ["lucee6", "lucee7", "adobe2023", "adobe2025", "boxlang"]
DATABASES = ["mysql", "postgres", "sqlserver", "h2", "cockroachdb", "oracle", "sqlite"]
LUCEE_ONLY = {"h2"}
+ # Every other leg runs the engine's JVM in UTC, where converting between local time
+ # and UTC changes nothing, so a local/UTC mix-up can't show. These legs run the core
+ # suite on SQLite with the JVM in a zone that has a UTC offset and daylight saving.
+ TZ_ENGINES = ["lucee7", "adobe2025", "boxlang"]
+ TZ_ZONE = "America/New_York"
def pick(raw, allowed, extra=()):
items = [x.strip().lower() for x in (raw or "").split(",") if x.strip()]
@@ -97,21 +102,28 @@ jobs:
sys.exit(1)
return items, False
- engines, all_engines = pick(os.environ.get("ENGINES_INPUT"), ENGINES, extra=("rustcfml",))
+ engines, all_engines = pick(os.environ.get("ENGINES_INPUT"), ENGINES, extra=("rustcfml", "tz"))
databases, all_databases = pick(os.environ.get("DATABASES_INPUT"), DATABASES)
run_rustcfml = (all_engines and all_databases) or "rustcfml" in engines
- engines = [e for e in engines if e != "rustcfml"]
+ run_tz = (all_engines and all_databases) or "tz" in engines
+ engines = [e for e in engines if e not in ("rustcfml", "tz")]
+ # tz/suffix are set on every leg so the job can read them unconditionally.
include = [
- {"cfengine": e, "database": d}
+ {"cfengine": e, "database": d, "tz": "", "suffix": ""}
for e in ENGINES if e in engines
for d in DATABASES if d in databases
if not (d in LUCEE_ONLY and not e.startswith("lucee"))
]
+ if run_tz:
+ include += [
+ {"cfengine": e, "database": "sqlite", "tz": TZ_ZONE, "suffix": "-tz-ny"}
+ for e in TZ_ENGINES
+ ]
if not include and not run_rustcfml:
print("::error::The engines/databases inputs select no legs")
sys.exit(1)
- print(f"{len(include)} leg(s): " + ", ".join(f"{l['cfengine']}+{l['database']}" for l in include))
+ print(f"{len(include)} leg(s): " + ", ".join(f"{l['cfengine']}+{l['database']}{l['suffix']}" for l in include))
print(f"rustcfml lane: {run_rustcfml}")
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
# An empty include list is not a valid matrix; the tests job
@@ -122,14 +134,18 @@ jobs:
PY
tests:
- name: "${{ matrix.cfengine }} + ${{ matrix.database }}"
+ name: "${{ matrix.cfengine }} + ${{ matrix.database }}${{ matrix.tz != '' && format(' (TZ {0})', matrix.tz) || '' }}"
needs: plan
if: needs.plan.outputs.has_legs == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
env:
+ # The engine container's time zone (compose.yml reads it; UTC when empty, as on every leg
+ # but the TZ lanes). Job-level so a crash-recovery `docker compose up` keeps it too.
+ # EngineTimeZoneSpec fails the leg if the JVM or the engine didn't take it.
+ ENGINE_TZ: ${{ matrix.tz }}
PORT_lucee6: 60006
PORT_lucee7: 60007
PORT_adobe2023: 62023
@@ -143,8 +159,9 @@ jobs:
if: startsWith(matrix.cfengine, 'adobe')
run: |
mkdir -p ./.engine/${{ matrix.cfengine }}/WEB-INF/lib
- wget -q https://download.oracle.com/otn-pub/otn_software/jdbc/1927/ojdbc10.jar \
- -O ./.engine/${{ matrix.cfengine }}/WEB-INF/lib/ojdbc10.jar
+ curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \
+ -o ./.engine/${{ matrix.cfengine }}/WEB-INF/lib/ojdbc10.jar \
+ https://download.oracle.com/otn-pub/otn_software/jdbc/1927/ojdbc10.jar
- name: Start CF engine
# Retry the build to absorb transient external download failures
@@ -484,65 +501,8 @@ jobs:
# request which would re-run the entire test suite — runner.cfm
# does not cache results between requests)
if [ -f "$RESULT_FILE" ]; then
- ENGINE="${{ matrix.cfengine }}" DB="${DB}" \
- RESULT_FILE="$RESULT_FILE" JUNIT_FILE="$JUNIT_FILE" \
- python3 -c "
- import json, sys, os
- from xml.etree.ElementTree import Element, SubElement, tostring
-
- engine = os.environ['ENGINE']
- db = os.environ['DB']
- prefix = f'{engine}/{db}'
-
- try:
- d = json.load(open(os.environ['RESULT_FILE']))
- except:
- sys.exit(0)
-
- def safe_str(val, default=''):
- \"\"\"Coerce None/null JSON values to string for XML serialization.\"\"\"
- return str(val) if val is not None else default
-
- def process_suite(parent_el, suite):
- \"\"\"Recursively process suites (TestBox suites can be nested).\"\"\"
- for sp in suite.get('specStats', []):
- tc = SubElement(parent_el, 'testcase',
- name=safe_str(sp.get('name')),
- classname=f\"{prefix} :: {safe_str(suite.get('name'))}\",
- time=str(sp.get('totalDuration', 0) / 1000))
- if sp.get('status') == 'Failed':
- f = SubElement(tc, 'failure', message=safe_str(sp.get('failMessage')))
- f.text = safe_str(sp.get('failDetail'))
- elif sp.get('status') == 'Error':
- e = SubElement(tc, 'error', message=safe_str(sp.get('failMessage')))
- e.text = safe_str(sp.get('failDetail'))
- elif sp.get('status') == 'Skipped':
- SubElement(tc, 'skipped')
- # Recurse into child suites
- for child in suite.get('suiteStats', []):
- process_suite(parent_el, child)
-
- root = Element('testsuites',
- name=prefix,
- tests=str(int(d.get('totalSpecs', 0))),
- failures=str(int(d.get('totalFail', 0))),
- errors=str(int(d.get('totalError', 0))),
- time=str(d.get('totalDuration', 0) / 1000))
-
- for b in d.get('bundleStats', []):
- ts = SubElement(root, 'testsuite',
- name=f\"{prefix} :: {b.get('name', '')}\",
- tests=str(int(b.get('totalSpecs', 0))),
- failures=str(int(b.get('totalFail', 0))),
- errors=str(int(b.get('totalError', 0))),
- time=str(b.get('totalDuration', 0) / 1000))
- for s in b.get('suiteStats', []):
- process_suite(ts, s)
-
- with open(os.environ['JUNIT_FILE'], 'wb') as f:
- f.write(b'')
- f.write(tostring(root))
- " || { echo "JUnit conversion failed for ${DB} (non-fatal)"; rm -f "$JUNIT_FILE"; }
+ python3 tools/ci/testbox_junit.py "$RESULT_FILE" "$JUNIT_FILE" "${{ matrix.cfengine }}/${DB}${{ matrix.suffix }}" \
+ || { echo "JUnit conversion failed for ${DB} (non-fatal)"; rm -f "$JUNIT_FILE"; }
fi
# Zero-test guard (#3302): MIN_SPECS (workflow env) is the floor.
@@ -683,14 +643,14 @@ jobs:
if: always()
uses: actions/upload-artifact@v6
with:
- name: test-results-${{ matrix.cfengine }}-${{ matrix.database }}
+ name: test-results-${{ matrix.cfengine }}-${{ matrix.database }}${{ matrix.suffix }}
path: /tmp/test-results/
- name: Upload JUnit XML artifacts
if: always()
uses: actions/upload-artifact@v6
with:
- name: junit-${{ matrix.cfengine }}-${{ matrix.database }}
+ name: junit-${{ matrix.cfengine }}-${{ matrix.database }}${{ matrix.suffix }}
path: /tmp/junit-results/
#############################################
@@ -712,7 +672,7 @@ jobs:
# Full runs always include it; a narrowed dispatch only when `engines`
# lists rustcfml.
if: needs.plan.outputs.rustcfml == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@v5
@@ -739,7 +699,7 @@ jobs:
name: Publish Test Results
needs: tests
if: always()
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
checks: write
pull-requests: write
@@ -776,7 +736,7 @@ jobs:
name: Test Matrix Summary
needs: tests
if: always()
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
pull-requests: write
outputs:
@@ -804,16 +764,17 @@ jobs:
# SOFT_FAIL_DBS and MIN_SPECS come from the workflow-level env.
- for engine in lucee6 lucee7 adobe2023 adobe2025 boxlang; do
- ROW="| **${engine}** |"
- for db in mysql postgres sqlserver h2 cockroachdb oracle sqlite; do
- FILE="results/test-results-${engine}-${db}/${engine}-${db}-result.txt"
- IS_SOFT_FAIL=false
- if echo ",${SOFT_FAIL_DBS}," | grep -q ",${db},"; then
- IS_SOFT_FAIL=true
- fi
- if [ -f "$FILE" ]; then
- STATS=$(python3 -c "
+ # One grid cell for a leg's result file.
+ cell() {
+ local FILE="$1" db="$2" IS_SOFT_FAIL=false STATS FAIL SPECS
+ if echo ",${SOFT_FAIL_DBS}," | grep -q ",${db},"; then
+ IS_SOFT_FAIL=true
+ fi
+ if [ ! -f "$FILE" ]; then
+ echo " -- |"
+ return
+ fi
+ STATS=$(python3 -c "
import json, sys
try:
d = json.load(open('$FILE'))
@@ -821,27 +782,38 @@ jobs:
except:
print(-1, -1)
" 2>/dev/null || echo "-1 -1")
- FAIL="${STATS% *}"
- SPECS="${STATS#* }"
- if [ "$FAIL" = "-1" ]; then
- ROW="${ROW} :x: no result |"
- elif [ "$SPECS" -lt "$MIN_SPECS" ]; then
- ROW="${ROW} :x: ${SPECS} tests |"
- elif [ "$FAIL" = "0" ]; then
- ROW="${ROW} :white_check_mark: |"
- elif [ "$IS_SOFT_FAIL" = true ]; then
- ROW="${ROW} :warning: ${FAIL} |"
- else
- ROW="${ROW} :x: ${FAIL} |"
- fi
- else
- ROW="${ROW} -- |"
- fi
+ FAIL="${STATS% *}"
+ SPECS="${STATS#* }"
+ if [ "$FAIL" = "-1" ]; then
+ echo " :x: no result |"
+ elif [ "$SPECS" -lt "$MIN_SPECS" ]; then
+ echo " :x: ${SPECS} tests |"
+ elif [ "$FAIL" = "0" ]; then
+ echo " :white_check_mark: |"
+ elif [ "$IS_SOFT_FAIL" = true ]; then
+ echo " :warning: ${FAIL} |"
+ else
+ echo " :x: ${FAIL} |"
+ fi
+ }
+
+ for engine in lucee6 lucee7 adobe2023 adobe2025 boxlang; do
+ ROW="| **${engine}** |"
+ for db in mysql postgres sqlserver h2 cockroachdb oracle sqlite; do
+ ROW="${ROW}$(cell "results/test-results-${engine}-${db}/${engine}-${db}-result.txt" "$db")"
done
MATRIX_MD="${MATRIX_MD}
${ROW}"
done
+ # The America/New_York lanes (SQLite only), one row each when they ran.
+ for engine in lucee7 adobe2025 boxlang; do
+ DIR="results/test-results-${engine}-sqlite-tz-ny"
+ [ -d "$DIR" ] || continue
+ MATRIX_MD="${MATRIX_MD}
+ | **${engine}** (TZ America/New_York) | | | | | | |$(cell "${DIR}/${engine}-sqlite-result.txt" sqlite)"
+ done
+
SOFT_FAIL_NOTE=""
if [ -n "$SOFT_FAIL_DBS" ]; then
SOFT_FAIL_NOTE="*Soft-fail databases (${SOFT_FAIL_DBS}): :warning: N cells are spec failures that do not gate the run.*
@@ -910,7 +882,7 @@ jobs:
name: Notify on failure
needs: [tests, rustcfml, test-matrix-summary]
if: always() && (github.event_name == 'schedule' || inputs.notify == true)
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
actions: read
issues: write
@@ -983,7 +955,7 @@ jobs:
name: Compatibility Matrix Gate
needs: [plan, tests, rustcfml]
if: always()
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Require every leg to pass
diff --git a/.github/workflows/compat-pr.yml b/.github/workflows/compat-pr.yml
index 2dce3f58d1..ceac713b8c 100644
--- a/.github/workflows/compat-pr.yml
+++ b/.github/workflows/compat-pr.yml
@@ -41,7 +41,7 @@ env:
jobs:
compat:
name: "${{ matrix.cfengine }} + ${{ matrix.database }}"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
@@ -87,8 +87,9 @@ jobs:
if: steps.changes.outputs.framework_changed == 'true' && matrix.cfengine == 'adobe2023'
run: |
mkdir -p ./.engine/adobe2023/WEB-INF/lib
- wget -q https://download.oracle.com/otn-pub/otn_software/jdbc/1927/ojdbc10.jar \
- -O ./.engine/adobe2023/WEB-INF/lib/ojdbc10.jar
+ curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \
+ -o ./.engine/adobe2023/WEB-INF/lib/ojdbc10.jar \
+ https://download.oracle.com/otn-pub/otn_software/jdbc/1927/ojdbc10.jar
- name: Start CF engine
if: steps.changes.outputs.framework_changed == 'true'
diff --git a/.github/workflows/consumer-docs-check.yml b/.github/workflows/consumer-docs-check.yml
new file mode 100644
index 0000000000..7959ec2034
--- /dev/null
+++ b/.github/workflows/consumer-docs-check.yml
@@ -0,0 +1,38 @@
+name: Consumer AI docs check
+
+# Runs tools/build/scripts/ship-consumer-docs.sh check: the consumer AI docs in
+# docs/consumer-ai/ exist, the root CLAUDE.md points at them, and the wheels new
+# template's copies (cli/lucli/templates/app/CLAUDE.md, AGENTS.md, .ai/README.md)
+# are byte-identical to them. The release and snapshot builds run the same check,
+# so without this a PR that edits only one copy passes and the next develop
+# snapshot fails.
+
+on:
+ # No path filter on pull_request, as in rustcfml-ci.yml: a path-gated check
+ # can't be made required (it never reports on PRs it skips). The check is a
+ # few seconds of shell, so running it on every PR to develop is cheap.
+ pull_request:
+ branches: [develop]
+ push:
+ branches: [develop]
+ paths:
+ - "docs/consumer-ai/**"
+ - "cli/lucli/templates/**"
+ - "CLAUDE.md"
+ - "tools/build/scripts/ship-consumer-docs.sh"
+ - ".github/workflows/consumer-docs-check.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ consumer-docs:
+ name: "Consumer AI docs in sync"
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v5
+
+ - name: Check the consumer AI docs and the template copies
+ run: bash tools/build/scripts/ship-consumer-docs.sh check
diff --git a/.github/workflows/deploy-ci.yml b/.github/workflows/deploy-ci.yml
index f1a3754705..00b3bf8114 100644
--- a/.github/workflows/deploy-ci.yml
+++ b/.github/workflows/deploy-ci.yml
@@ -45,7 +45,7 @@ permissions:
jobs:
changes:
name: "Detect CLI changes"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
relevant: ${{ steps.diff.outputs.relevant }}
steps:
@@ -78,7 +78,7 @@ jobs:
# vendor/wheels/migrator/, Seeder.cfc, JobWorker.cfc what CliBridge calls
# Wider framework boot breakage is caught by the core suite, which is
# required on its own.
- PATTERN='^(cli/lucli/|tools/test-cli-local\.sh$|tools/ci/testbox_results\.py$|tools/ci/test_testbox_results\.py$|tools/ci/fallback_port_sentinel\.py$|tools/ci/test_fallback_port_sentinel\.py$|tools/ci/fixtures/testbox/|tools/deploy-[^/]*\.sh$|tools/deploy-dry-run-normalize\.py$|\.github/workflows/deploy-ci\.yml$|\.github/actions/setup-lucli/|tools/lucli\.json$|lucee\.json$|public/|config/|app/(events|global|migrator)/|vendor/wheels/(public/|Public\.cfc$|events/|wheelstest/|migrator/|Seeder\.cfc$|JobWorker\.cfc$))'
+ PATTERN='^(cli/lucli/|tools/test-cli-local\.sh$|tools/ci/testbox_results\.py$|tools/ci/test_testbox_results\.py$|tools/ci/fallback_port_sentinel\.py$|tools/ci/test_fallback_port_sentinel\.py$|tools/ci/testbox_junit\.py$|tools/ci/test_testbox_junit\.py$|tools/ci/fixtures/testbox/|tools/deploy-[^/]*\.sh$|tools/deploy-dry-run-normalize\.py$|\.github/workflows/deploy-ci\.yml$|\.github/actions/setup-lucli/|tools/lucli\.json$|lucee\.json$|public/|config/|app/(events|global|migrator)/|vendor/wheels/(public/|Public\.cfc$|events/|wheelstest/|migrator/|Seeder\.cfc$|JobWorker\.cfc$))'
if [ "$EVENT_NAME" = "pull_request" ]; then
# fetch-depth: 0 already holds the base commit; fetch it (with full
# history, never --depth) only if it is somehow missing.
@@ -110,7 +110,7 @@ jobs:
name: "Deploy CLI tests + verb smoke"
needs: changes
if: needs.changes.outputs.relevant == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
WHEELS_CI: "true"
@@ -144,6 +144,12 @@ jobs:
- name: Test the fallback-port sentinel
run: python3 tools/ci/test_fallback_port_sentinel.py
+ - name: Test the matrix JUnit converter
+ # tools/ci/testbox_junit.py feeds the compatibility matrix's PR comment;
+ # each spec must keep a unique classname + name or the publisher merges
+ # them and undercounts (#3901).
+ run: python3 tools/ci/test_testbox_junit.py
+
- name: Run CLI test suite
env:
WHEELS_CLI_TEST_RESULT_FILE: ${{ runner.temp }}/cli-test-results.json
@@ -191,7 +197,7 @@ jobs:
name: "CLI suite"
needs: [changes, deploy-gate]
if: always()
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Report the CLI suite verdict
env:
diff --git a/.github/workflows/distribution-install-smoke.yml b/.github/workflows/distribution-install-smoke.yml
index 7180e53fcd..fce3485071 100644
--- a/.github/workflows/distribution-install-smoke.yml
+++ b/.github/workflows/distribution-install-smoke.yml
@@ -28,7 +28,8 @@ name: Distribution install smoke (brew / scoop / apt / yum)
# on each channel and assert `wheels --version` reports the current GA.
# `wheels --version` is answered by the channel wrapper without starting
# LuCLI, so each leg also reads the version the installed LuCLI runtime
-# carries (lucli/version.properties), requires the tools/lucli.json pin,
+# carries (lucli/version.properties), requires the tools/lucli.json pin
+# (or a version tools/lucli-tested.json lists for that release line),
# and runs `wheels info`, which boots the JVM and the Wheels module.
#
# Runs daily (propagation has settled by then) and on demand. It does NOT run on
@@ -63,22 +64,18 @@ permissions:
jobs:
resolve:
name: Resolve expected version
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
lucli: ${{ steps.pin.outputs.lucli }}
+ lucli_ok: ${{ steps.pin.outputs.lucli_ok }}
steps:
- uses: actions/checkout@v5
with:
- sparse-checkout: tools/lucli.json
+ sparse-checkout: |
+ tools/lucli.json
+ tools/lucli-tested.json
sparse-checkout-cone-mode: false
- - name: Read the LuCLI runtime pin every channel must ship
- id: pin
- run: |
- set -euo pipefail
- LUCLI=$(jq -er '.LUCLI_VERSION | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+(\\.[0-9]+)?$"))' tools/lucli.json)
- echo "lucli=$LUCLI" >> "$GITHUB_OUTPUT"
- echo "Every channel must ship LuCLI $LUCLI (tools/lucli.json)"
- name: Determine the GA version each channel must serve
id: v
env:
@@ -102,11 +99,48 @@ jobs:
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "All channels must serve wheels $VER"
+ # The channels install the GA, so they must ship the LuCLI that GA was
+ # built with: read the pin from the GA's tag, not from this checkout
+ # (develop's pin moves ahead of the release between cuts, #4038).
+ - name: Read the LuCLI runtime pin the GA was built with
+ id: pin
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ VER: ${{ steps.v.outputs.version }}
+ run: |
+ set -euo pipefail
+ # Only a 404 (a release older than tools/lucli.json) may fall back to
+ # this checkout; any other API error (rate limit, 5xx, auth) fails the
+ # job, or it would silently compare against develop's pin again.
+ ERR="$(mktemp)"
+ if CONTENT="$(gh api "repos/${REPO}/contents/tools/lucli.json?ref=v${VER}" --jq .content 2>"${ERR}")"; then
+ PINFILE="$(printf '%s' "${CONTENT}" | base64 -d)"
+ SOURCE="tools/lucli.json at v${VER}"
+ elif grep -q 'HTTP 404' "${ERR}"; then
+ PINFILE="$(cat tools/lucli.json)"
+ SOURCE="tools/lucli.json in this checkout (v${VER} has none)"
+ echo "::notice::v${VER} has no tools/lucli.json; using the checked-out pin"
+ else
+ echo "::error::Could not read tools/lucli.json at v${VER}: $(cat "${ERR}")"
+ exit 1
+ fi
+ LUCLI=$(printf '%s' "${PINFILE}" | jq -er '.LUCLI_VERSION | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+(\\.[0-9]+)?$"))')
+ echo "lucli=$LUCLI" >> "$GITHUB_OUTPUT"
+ # A channel may also ship a newer LuCLI that was tested with this
+ # release line (tools/lucli-tested.json on this checkout, keyed by
+ # major.minor). Anything else, including an untested newer runtime,
+ # still fails.
+ LINE="$(printf '%s' "${VER}" | cut -d. -f1,2)"
+ TESTED="$(jq -r --arg l "${LINE}" '.lines[$l] // [] | map(select(test("^[0-9]+\\.[0-9]+\\.[0-9]+(\\.[0-9]+)?$"))) | join(" ")' tools/lucli-tested.json)"
+ OK="$(printf '%s\n' "${LUCLI}" ${TESTED} | sort -u | tr '\n' ' ' | sed 's/ $//')"
+ echo "lucli_ok=$OK" >> "$GITHUB_OUTPUT"
+ echo "Every channel must ship LuCLI $LUCLI (${SOURCE}), or one tested with the ${LINE} line: ${OK}"
index-integrity:
needs: resolve
name: "Index integrity (apt + yum dists)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 10
# Direct probe of the PUBLISHED dist indexes — no install, no containers.
# Catches the #3218 cross-channel clobber deterministically: a regression
@@ -242,6 +276,7 @@ jobs:
matrix:
# macos-latest is Apple Silicon (arm64); ubuntu-latest exercises the
# Linuxbrew path (amd64). Both are documented install targets.
+ # Deliberately *-latest (unlike the pinned jobs): this tests what users get.
os: [macos-latest, ubuntu-latest]
env:
EXPECTED: ${{ needs.resolve.outputs.version }}
@@ -260,9 +295,16 @@ jobs:
# runner image ubuntu24/20260920 (Homebrew 7.0.4), `brew install` resolves
# dependencies before auto-tapping core, so `depends_on "openjdk@21"`
# fails with "No available formula" (#3691). Tap core first.
+ # Tapping core clones its newest formulae, but HOMEBREW_NO_AUTO_UPDATE
+ # keeps the runner image's brew. When core adopts formula DSL newer than
+ # that brew (e.g. test-only `resource "x", :test do`), loading a dependency
+ # fails ("undefined method 'new' for an instance of Symbol"). Update brew
+ # once so brew and core move together, as they do for users.
- name: Tap homebrew/core (Linuxbrew)
if: runner.os == 'Linux'
- run: brew tap homebrew/core
+ run: |
+ brew tap homebrew/core
+ brew update
- name: brew tap + install (the documented path)
run: |
set -euo pipefail
@@ -293,12 +335,13 @@ jobs:
# `wheels --version` is answered by the wrapper without starting LuCLI,
# so on its own it never proves the runtime works. Read the version the
- # installed runtime carries, require the pin, and run a command that
+ # installed runtime carries, require the pin (or a tested one), and run a command that
# boots the JVM and the Wheels module through LuCLI.
- - name: Assert the installed LuCLI runtime == pin, and that it boots
+ - name: Assert the installed LuCLI runtime is the pin or a tested one, and that it boots
env:
CHANNEL: "Homebrew (${{ matrix.os }})"
PINNED_LUCLI: ${{ needs.resolve.outputs.lucli }}
+ LUCLI_OK: ${{ needs.resolve.outputs.lucli_ok }}
run: |
set -euo pipefail
RUNTIME="$(brew --prefix wheels)/libexec/wheels"
@@ -306,9 +349,9 @@ jobs:
echo "::error::${CHANNEL}: no LuCLI runtime at ${RUNTIME}"; exit 1
fi
GOT="$(unzip -p "$RUNTIME" lucli/version.properties 2>/dev/null | sed -n 's/^lucli\.version=//p' | tr -d '\r' || true)"
- echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}')"
- if [ "$GOT" != "$PINNED_LUCLI" ]; then
- echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI}"; exit 1
+ echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}', tested: ${LUCLI_OK})"
+ if [ -z "$GOT" ] || ! printf '%s\n' ${LUCLI_OK} | grep -qxF -- "$GOT"; then
+ echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI} or a version tested with this line (${LUCLI_OK}; tools/lucli-tested.json)"; exit 1
fi
if ! OUT="$(wheels info 2>&1)"; then
echo "::error::${CHANNEL}: wheels info failed (the LuCLI runtime did not start)"
@@ -356,10 +399,11 @@ jobs:
# `wheels --version` is answered by the wrapper without starting LuCLI.
# Require the pinned runtime and boot it (see the Homebrew job).
- - name: Assert the installed LuCLI runtime == pin, and that it boots
+ - name: Assert the installed LuCLI runtime is the pin or a tested one, and that it boots
shell: pwsh
env:
PINNED_LUCLI: ${{ needs.resolve.outputs.lucli }}
+ LUCLI_OK: ${{ needs.resolve.outputs.lucli_ok }}
run: |
$ErrorActionPreference = 'Stop'
$jar = "$env:USERPROFILE\scoop\apps\wheels\current\lucli.jar"
@@ -378,9 +422,9 @@ jobs:
}
} finally { $zip.Dispose() }
$got = [regex]::Match($text, 'lucli\.version=([^\r\n]+)').Groups[1].Value.Trim()
- Write-Host "Scoop: installed LuCLI runtime => '$got' (pinned '$env:PINNED_LUCLI')"
- if ($got -ne $env:PINNED_LUCLI) {
- Write-Host "::error::Scoop ships LuCLI '$got', expected the pinned $env:PINNED_LUCLI"; exit 1
+ Write-Host "Scoop: installed LuCLI runtime => '$got' (pinned '$env:PINNED_LUCLI', tested: $env:LUCLI_OK)"
+ if (-not $got -or (($env:LUCLI_OK -split ' ') -notcontains $got)) {
+ Write-Host "::error::Scoop ships LuCLI '$got', expected the pinned $env:PINNED_LUCLI or a version tested with this line ($env:LUCLI_OK; tools/lucli-tested.json)"; exit 1
}
$out = (wheels info 2>&1 | Out-String)
Write-Host $out
@@ -401,6 +445,7 @@ jobs:
matrix:
include:
- arch: amd64
+ # Deliberately ubuntu-latest (unlike the pinned jobs): this tests what users get.
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
@@ -411,11 +456,10 @@ jobs:
- name: Add apt.wheels.dev + install
run: |
set -euo pipefail
- # The published wheels.gpg is ASCII-armored, so dearmor it into the
- # keyring. (The apt-wheels README still shows a bare `tee` of the
- # armored key — the user-facing docs should be updated to dearmor to
- # match this; tracked as a follow-up.)
- curl -fsSL https://apt.wheels.dev/wheels.gpg | sudo gpg --dearmor -o /usr/share/keyrings/wheels.gpg
+ # The documented command: the published wheels.gpg is ASCII-armored,
+ # so it is dearmored into the keyring; --batch --yes lets a re-run
+ # overwrite it without a TTY (guides, apt-wheels README + landing page).
+ curl -fsSL https://apt.wheels.dev/wheels.gpg | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/wheels.gpg
echo "deb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable main" \
| sudo tee /etc/apt/sources.list.d/wheels.list >/dev/null
sudo apt-get update
@@ -441,12 +485,13 @@ jobs:
# `wheels --version` is answered by the wrapper without starting LuCLI,
# so on its own it never proves the runtime works. Read the version the
- # installed runtime carries, require the pin, and run a command that
+ # installed runtime carries, require the pin (or a tested one), and run a command that
# boots the JVM and the Wheels module through LuCLI.
- - name: Assert the installed LuCLI runtime == pin, and that it boots
+ - name: Assert the installed LuCLI runtime is the pin or a tested one, and that it boots
env:
CHANNEL: "apt (${{ matrix.arch }})"
PINNED_LUCLI: ${{ needs.resolve.outputs.lucli }}
+ LUCLI_OK: ${{ needs.resolve.outputs.lucli_ok }}
run: |
set -euo pipefail
RUNTIME="/opt/wheels/lucli.jar"
@@ -454,9 +499,9 @@ jobs:
echo "::error::${CHANNEL}: no LuCLI runtime at ${RUNTIME}"; exit 1
fi
GOT="$(unzip -p "$RUNTIME" lucli/version.properties 2>/dev/null | sed -n 's/^lucli\.version=//p' | tr -d '\r' || true)"
- echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}')"
- if [ "$GOT" != "$PINNED_LUCLI" ]; then
- echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI}"; exit 1
+ echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}', tested: ${LUCLI_OK})"
+ if [ -z "$GOT" ] || ! printf '%s\n' ${LUCLI_OK} | grep -qxF -- "$GOT"; then
+ echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI} or a version tested with this line (${LUCLI_OK}; tools/lucli-tested.json)"; exit 1
fi
if ! OUT="$(wheels info 2>&1)"; then
echo "::error::${CHANNEL}: wheels info failed (the LuCLI runtime did not start)"
@@ -467,13 +512,67 @@ jobs:
echo "::error::${CHANNEL}: wheels info did not reach the Wheels module through LuCLI"; exit 1
fi
+ rpm-payload:
+ needs: resolve
+ name: "rpm payload (${{ matrix.label }})"
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ # The GA .rpm must unpack with rpm2cpio on every rpm we support and install
+ # with dnf (#3975). rpm 4.19 (Rocky 10) exits 1 on a package whose archive-
+ # size header doesn't match its cpio stream, which nfpm-only builds (4.1.2
+ # and earlier) have; rpm 4.16 (Rocky 9) doesn't check.
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - label: "rocky 10, rpm 4.19"
+ image: rockylinux/rockylinux:10
+ - label: "rocky 9, rpm 4.16"
+ image: rockylinux/rockylinux:9
+ container:
+ image: ${{ matrix.image }}
+ env:
+ EXPECTED: ${{ needs.resolve.outputs.version }}
+ steps:
+ - name: Download the GA .rpm
+ run: |
+ set -euo pipefail
+ dnf -y -q install cpio >/dev/null
+ curl -fsSLo /tmp/wheels.rpm \
+ "https://github.com/wheels-dev/wheels/releases/download/v${EXPECTED}/wheels-${EXPECTED}.noarch.rpm"
+ rpm --version
+ rpm -K /tmp/wheels.rpm
+ - name: rpm2cpio | cpio -t exits 0
+ run: |
+ set -uo pipefail
+ # 4.1.2 and earlier were built by nfpm alone; their header is known to
+ # fail rpm 4.19's check. Report it, but gate only on later releases.
+ KNOWN_BAD=0
+ if [ "$(printf '%s\n' "${EXPECTED}" "4.1.2" | sort -V | tail -1)" = "4.1.2" ]; then KNOWN_BAD=1; fi
+ if rpm2cpio /tmp/wheels.rpm | cpio -t --quiet > /tmp/entries; then
+ echo "rpm2cpio | cpio -t OK ($(wc -l < /tmp/entries) entries)"
+ elif [ "${KNOWN_BAD}" = 1 ]; then
+ echo "::notice::wheels ${EXPECTED} predates the rpmbuild re-wrap (#3975): rpm2cpio | cpio -t fails on $(rpm --version); not gating"
+ else
+ echo "::error::rpm2cpio | cpio -t failed on $(rpm --version) for wheels ${EXPECTED}"
+ echo "archive-size header: $(rpm -qp --qf '%{LONGARCHIVESIZE}' /tmp/wheels.rpm), stream: $(rpm2cpio /tmp/wheels.rpm | wc -c)"
+ exit 1
+ fi
+ - name: dnf install the downloaded .rpm and run it
+ run: |
+ set -euo pipefail
+ dnf -y install /tmp/wheels.rpm
+ GOT="$(wheels --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
+ echo "wheels --version => '${GOT}' (expected '${EXPECTED}')"
+ [ "${GOT}" = "${EXPECTED}" ] || { echo "::error::installed wheels reports '${GOT}', expected '${EXPECTED}'"; exit 1; }
+
yum:
needs: resolve
name: "yum (rocky 9)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
- # RHEL-family, dnf4 — matches the documented `dnf config-manager --add-repo`
- # path. The release ships a noarch rpm; dnf serves it on this amd64 container.
+ # RHEL-family, dnf4 — runs the documented commands (the .repo file is
+ # downloaded into /etc/yum.repos.d/, which works on dnf4 and dnf5). The release ships a noarch rpm; dnf serves it on this amd64 container.
# Gates (hard-fail): as of 4.0.5 the rpm's wrapper resolves the RHEL/Fedora
# Java-21 layout, so `wheels --version` works on Rocky 9 (was the 4.0.4 bug).
container:
@@ -484,8 +583,7 @@ jobs:
- name: Add yum.wheels.dev + install (the documented path)
run: |
set -euo pipefail
- dnf -y install dnf-plugins-core
- dnf -y config-manager --add-repo https://yum.wheels.dev/wheels.repo
+ curl -fsSL https://yum.wheels.dev/wheels.repo -o /etc/yum.repos.d/wheels.repo
dnf -y install wheels
- name: Assert wheels --version == GA
env:
@@ -508,12 +606,13 @@ jobs:
# `wheels --version` is answered by the wrapper without starting LuCLI,
# so on its own it never proves the runtime works. Read the version the
- # installed runtime carries, require the pin, and run a command that
+ # installed runtime carries, require the pin (or a tested one), and run a command that
# boots the JVM and the Wheels module through LuCLI.
- - name: Assert the installed LuCLI runtime == pin, and that it boots
+ - name: Assert the installed LuCLI runtime is the pin or a tested one, and that it boots
env:
CHANNEL: "yum (rocky 9)"
PINNED_LUCLI: ${{ needs.resolve.outputs.lucli }}
+ LUCLI_OK: ${{ needs.resolve.outputs.lucli_ok }}
run: |
set -euo pipefail
dnf -y install unzip >/dev/null
@@ -522,9 +621,9 @@ jobs:
echo "::error::${CHANNEL}: no LuCLI runtime at ${RUNTIME}"; exit 1
fi
GOT="$(unzip -p "$RUNTIME" lucli/version.properties 2>/dev/null | sed -n 's/^lucli\.version=//p' | tr -d '\r' || true)"
- echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}')"
- if [ "$GOT" != "$PINNED_LUCLI" ]; then
- echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI}"; exit 1
+ echo "${CHANNEL}: installed LuCLI runtime => '${GOT}' (pinned '${PINNED_LUCLI}', tested: ${LUCLI_OK})"
+ if [ -z "$GOT" ] || ! printf '%s\n' ${LUCLI_OK} | grep -qxF -- "$GOT"; then
+ echo "::error::${CHANNEL} ships LuCLI '${GOT:-unknown}', expected the pinned ${PINNED_LUCLI} or a version tested with this line (${LUCLI_OK}; tools/lucli-tested.json)"; exit 1
fi
if ! OUT="$(wheels info 2>&1)"; then
echo "::error::${CHANNEL}: wheels info failed (the LuCLI runtime did not start)"
@@ -534,3 +633,46 @@ jobs:
if ! grep -q "Wheels CLI" <<<"$OUT"; then
echo "::error::${CHANNEL}: wheels info did not reach the Wheels module through LuCLI"; exit 1
fi
+
+ yum-fedora-be:
+ needs: resolve
+ name: "yum (fedora 44, bleeding-edge)"
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ # Fedora 44 (dnf5) ships Java 25, not 21: the rpm's Java 21-or-newer
+ # dependency (#4237) is what makes it installable. Bleeding-edge only for
+ # now; a stable Fedora leg is owed once 4.2.0 ships with that dependency.
+ container:
+ image: fedora:44
+ steps:
+ - name: Add yum.wheels.dev bleeding-edge + install (the documented path)
+ run: |
+ set -euo pipefail
+ curl -fsSL https://yum.wheels.dev/wheels-be.repo -o /etc/yum.repos.d/wheels-be.repo
+ dnf -y install wheels-be
+ java -version 2>&1 | head -1
+ - name: Assert wheels --version is a bleeding-edge snapshot
+ run: |
+ set -uo pipefail
+ RAW="$(wheels --version 2>&1 || true)"
+ FIRST="$(printf '%s\n' "$RAW" | head -1)"
+ echo "yum (fedora 44, bleeding-edge): wheels --version => '${FIRST}'"
+ # Snapshot numbers float, so assert the shape, not one version.
+ if ! printf '%s\n' "$FIRST" | grep -qE '^wheels [0-9]+\.[0-9]+\.[0-9]+-snapshot\.[0-9]+ \(bleeding-edge\)$'; then
+ echo "::error::Fedora bleeding-edge: unexpected wheels --version output"
+ printf '%s\n' "$RAW" | head -20
+ exit 1
+ fi
+ # `wheels --version` is answered by the wrapper; `wheels info` boots the
+ # JVM (Java 25 here) and the Wheels module through LuCLI.
+ - name: Assert the runtime boots on Fedora's Java
+ run: |
+ set -euo pipefail
+ if ! OUT="$(wheels info 2>&1)"; then
+ echo "::error::Fedora bleeding-edge: wheels info failed (the LuCLI runtime did not start)"
+ printf '%s\n' "$OUT" | tail -40; exit 1
+ fi
+ printf '%s\n' "$OUT" | head -20
+ if ! grep -q "Wheels CLI" <<<"$OUT"; then
+ echo "::error::Fedora bleeding-edge: wheels info did not reach the Wheels module through LuCLI"; exit 1
+ fi
diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml
index f277aa2024..6300018ccb 100644
--- a/.github/workflows/docs-validation.yml
+++ b/.github/workflows/docs-validation.yml
@@ -71,7 +71,7 @@ concurrency:
jobs:
validate:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 240
steps:
diff --git a/.github/workflows/docs-verify.yml b/.github/workflows/docs-verify.yml
index f01acd0e5d..f7fa3f92dc 100644
--- a/.github/workflows/docs-verify.yml
+++ b/.github/workflows/docs-verify.yml
@@ -6,10 +6,12 @@ on:
paths:
- 'web/sites/guides/src/content/docs/v4-0-0/**'
- 'web/sites/guides/src/content/docs/v4-1-0/**'
+ - 'web/sites/guides/src/content/docs/v4-2-0/**'
- 'web/sites/guides/scripts/verify-docs/**'
- 'web/sites/guides/package.json'
- 'web/sites/guides/src/sidebars/v4-0-0.json'
- 'web/sites/guides/src/sidebars/v4-1-0.json'
+ - 'web/sites/guides/src/sidebars/v4-2-0.json'
- '.github/workflows/docs-verify.yml'
# The harness exercises the checkout's CLI module (overlaid onto the
# installed brew CLI below), so CLI changes can flip {test:cli} blocks
@@ -18,13 +20,13 @@ on:
jobs:
verify:
- # ubuntu-latest with Linuxbrew. Was macos-latest, but Node 22's test-runner
+ # ubuntu-24.04 with Linuxbrew. Was macos-latest, but Node 22's test-runner
# workers on macos-latest ARM64 have a broken child_process.spawn: ENOENT
# on every exec, even /bin/bash, even with absolute paths that statSync
# confirms exist. Direct `node -e "spawn('wheels')"` works fine; only
# --test worker contexts fail. The wheels formula supports Linux via the
# same install flow, and Linux posix_spawn is unaffected.
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
env:
@@ -60,7 +62,7 @@ jobs:
- name: Install Wheels CLI
env:
# Homebrew 5.x's Linux sandbox needs a rootless bwrap, which
- # ubuntu-latest runners don't provide (and 24.04's unprivileged
+ # Ubuntu 24.04 runners don't provide (and its unprivileged
# userns restrictions make it unreliable even when installed).
# The ephemeral runner is already isolation enough for CI.
HOMEBREW_NO_SANDBOX_LINUX: "1"
@@ -138,10 +140,8 @@ jobs:
# lets `wheels new` succeed, which makes fixture cwds real. See
# #2178 for the full root-cause write-up.
#
- # Still soft-fail: surfaces the remaining tutorial-driver fixture
- # issues (e.g., blog-tutorial's lucee.json emission). Drop
- # continue-on-error once those are fixed.
- continue-on-error: true
+ # Blocking: the last known failure, the mini-tutorial reloading before
+ # any server was started, is fixed (#4426).
env:
WHEELS_FRAMEWORK_PATH: ${{ github.workspace }}/vendor/wheels
run: |
diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml
index 4e12f78eab..0c05c36a6b 100644
--- a/.github/workflows/generate-changelog.yml
+++ b/.github/workflows/generate-changelog.yml
@@ -36,7 +36,7 @@ on:
jobs:
generate:
name: Generate Changelog
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
diff --git a/.github/workflows/iis-windows.yml b/.github/workflows/iis-windows.yml
new file mode 100644
index 0000000000..d429ec9668
--- /dev/null
+++ b/.github/workflows/iis-windows.yml
@@ -0,0 +1,68 @@
+# Windows/IIS check (test only, not a required check).
+# Serves the repository's app through IIS + URL Rewrite + the BonCode connector to
+# Lucee 7 (Tomcat), with the IIS rewrite rules from the v4.2 guide and the
+# web.config files in tools/ci/iis/, and reports which rules hold.
+# Adobe ColdFusion is out of scope (it needs a licensed installer).
+name: IIS (Windows)
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - '.github/workflows/iis-windows.yml'
+ - 'tools/ci/iis/**'
+ - 'public/urlrewrite.xml'
+ - 'web/sites/guides/src/content/docs/v4-2-0/deployment/iis-and-windows.mdx'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ iis:
+ name: IIS + Lucee 7 (BonCode)
+ runs-on: windows-latest
+ timeout-minutes: 45
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+
+ - name: Deploy the test apps
+ run: ./tools/ci/iis/deploy.ps1
+
+ - name: Install IIS, URL Rewrite, Lucee Express and BonCode
+ run: ./tools/ci/iis/install.ps1
+
+ - name: Run the IIS checks
+ run: ./tools/ci/iis/checks.ps1
+
+ - name: Server logs
+ if: failure()
+ run: |
+ foreach ($dir in 'C:\lucee\logs', 'C:\inetpub\logs\LogFiles\W3SVC1') {
+ if (Test-Path $dir) {
+ Get-ChildItem $dir -File | Sort-Object LastWriteTime -Descending | Select-Object -First 4 | ForEach-Object {
+ Write-Host "===== $($_.FullName)"
+ Get-Content $_.FullName -Tail 60
+ }
+ }
+ }
+ Get-ChildItem $env:TEMP -Filter 'Setup Log*.txt' -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "===== $($_.FullName)"; Get-Content $_.FullName -Tail 40 }
+
+ - name: Upload the report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: iis-report
+ path: ${{ runner.temp }}/iis-report.md
+ if-no-files-found: ignore
diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml
index 209fca3b90..2ed6e72b50 100644
--- a/.github/workflows/label.yml
+++ b/.github/workflows/label.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
label:
name: Auto-Label PR
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@v6
with:
diff --git a/.github/workflows/linux-launcher.yml b/.github/workflows/linux-launcher.yml
index 939fcea289..c8b90e9193 100644
--- a/.github/workflows/linux-launcher.yml
+++ b/.github/workflows/linux-launcher.yml
@@ -32,7 +32,7 @@ permissions:
jobs:
java-resolution:
name: "Launcher selects Java 21+"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
diff --git a/.github/workflows/mirror-images.yml b/.github/workflows/mirror-images.yml
new file mode 100644
index 0000000000..dfd5ff2b0d
--- /dev/null
+++ b/.github/workflows/mirror-images.yml
@@ -0,0 +1,124 @@
+name: Mirror Docker Hub images to GHCR
+
+# Copies the Docker Hub images CI pulls (tools/ci/mirror-images.txt) to
+# ghcr.io/wheels-dev/mirror/, unchanged, so CI doesn't hit Docker Hub's
+# anonymous pull limit on shared GitHub runners. Copies run weekly, on
+# dispatch, and on a push to develop that changes the image list, so only
+# reviewed code writes the tags CI pulls. A PR that changes the list gets a dry
+# check instead (each source resolves; nothing is written): mirror a new image
+# by merging it to the list, then repoint to it in a later PR. Each image
+# copies in its own job, so the Docker Hub pulls spread across runners, and an
+# image already up to date costs no pull.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '23 5 * * 1'
+ push:
+ branches: [develop]
+ paths:
+ - 'tools/ci/mirror-images.txt'
+ - 'tools/ci/mirror-images.sh'
+ - '.github/workflows/mirror-images.yml'
+ pull_request:
+ paths:
+ - 'tools/ci/mirror-images.txt'
+ - 'tools/ci/mirror-images.sh'
+ - '.github/workflows/mirror-images.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: mirror-images-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ list:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ outputs:
+ images: ${{ steps.list.outputs.images }}
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Read the image list
+ id: list
+ run: |
+ set -euo pipefail
+ images="$(bash tools/ci/mirror-images.sh list)"
+ echo "$images"
+ echo "images=${images}" >> "$GITHUB_OUTPUT"
+
+ check:
+ # PRs, forks included: resolve each reference on Docker Hub (a HEAD, which
+ # doesn't count toward the pull limit) and compare it with the mirror,
+ # with a read-only token. Nothing is written.
+ if: github.event_name == 'pull_request'
+ needs: list
+ name: "check ${{ matrix.image }}"
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: read
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ${{ fromJSON(needs.list.outputs.images) }}
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Install crane
+ uses: imjasonh/setup-crane@feee3b6bb0d4c68370f256a4502498c9227e5c6b # v0.7
+
+ - name: Log in to GHCR (read-only)
+ env:
+ GHCR_USER: ${{ github.actor }}
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: echo "$GHCR_TOKEN" | crane auth login ghcr.io -u "$GHCR_USER" --password-stdin
+
+ - name: Check the image
+ env:
+ IMAGE: ${{ matrix.image }}
+ run: bash tools/ci/mirror-images.sh check "$IMAGE"
+
+ mirror:
+ # Only develop's reviewed script writes the live tags: a dispatch with
+ # --ref gets no write job.
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/develop'
+ needs: list
+ name: "mirror ${{ matrix.image }}"
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ packages: write
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ${{ fromJSON(needs.list.outputs.images) }}
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Install crane
+ uses: imjasonh/setup-crane@feee3b6bb0d4c68370f256a4502498c9227e5c6b # v0.7
+
+ - name: Log in to GHCR
+ env:
+ GHCR_USER: ${{ github.actor }}
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: echo "$GHCR_TOKEN" | crane auth login ghcr.io -u "$GHCR_USER" --password-stdin
+
+ - name: Copy the image
+ env:
+ IMAGE: ${{ matrix.image }}
+ run: bash tools/ci/mirror-images.sh copy "$IMAGE"
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index e521063ec5..314ff263fd 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -13,7 +13,7 @@ permissions:
jobs:
commitlint:
name: Validate Commit Messages
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
PR_TITLE: ${{ github.event.pull_request.title }}
steps:
@@ -28,10 +28,13 @@ jobs:
fast-test:
name: "Lucee 7 + SQLite (LuCLI)"
if: github.event.action != 'edited'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
WHEELS_CI: "true"
- WHEELS_BROWSER_TEST_BASE_URL: "http://localhost:60007"
+ # A framework-repository run: RequireRepoPathSpec fails if the repository-only
+ # files are missing here, instead of letting those specs skip.
+ WHEELS_EXPECT_REPO: "true"
+ WHEELS_BROWSER_TEST_BASE_URL: "http://localhost:30007"
WHEELS_BROWSER_CI_ENABLE: "true"
steps:
- uses: actions/checkout@v5
@@ -74,7 +77,7 @@ jobs:
SHA=$(echo "$row" | jq -r '.sha256')
echo "Downloading ${FILE}..."
- curl -sL "$URL" -o ~/.wheels/browser/lib/"$FILE"
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "$URL" -o ~/.wheels/browser/lib/"$FILE"
ACTUAL=$(sha256sum ~/.wheels/browser/lib/"$FILE" | cut -d' ' -f1)
if [ "$ACTUAL" != "$SHA" ]; then
@@ -89,7 +92,7 @@ jobs:
- name: Download SQLite JDBC driver
run: |
- curl -sL "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
-o /tmp/sqlite-jdbc.jar
- name: Start Lucee server
@@ -97,12 +100,12 @@ jobs:
cp tools/ci/lucee.ci.json lucee.json
# Start server (downloads Lucee Express on first run)
- nohup lucli server run --port=60007 > /tmp/lucli-server.log 2>&1 &
+ nohup lucli server run --port=30007 > /tmp/lucli-server.log 2>&1 &
echo $! > /tmp/lucli-server.pid
echo "Waiting for Lucee to start..."
for i in $(seq 1 120); do
- if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:60007/" 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:30007/" 2>/dev/null; then
echo "Lucee is up (attempt ${i})"
break
fi
@@ -135,21 +138,21 @@ jobs:
lucli server stop 2>/dev/null || true
# Wait for port to be freed
for i in $(seq 1 15); do
- if ! curl -s -o /dev/null --connect-timeout 1 "http://localhost:60007/" 2>/dev/null; then
+ if ! curl -s -o /dev/null --connect-timeout 1 "http://localhost:30007/" 2>/dev/null; then
break
fi
sleep 1
done
# Also kill any lingering java processes on the port
- fuser -k 60007/tcp 2>/dev/null || true
+ fuser -k 30007/tcp 2>/dev/null || true
sleep 2
- nohup lucli server run --port=60007 --force > /tmp/lucli-server.log 2>&1 &
+ nohup lucli server run --port=30007 --force > /tmp/lucli-server.log 2>&1 &
echo $! > /tmp/lucli-server.pid
echo "Waiting for Lucee to restart..."
for i in $(seq 1 60); do
- if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:60007/" 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:30007/" 2>/dev/null; then
echo "Lucee restarted with SQLite JDBC"
break
fi
@@ -159,7 +162,7 @@ jobs:
- name: Run tests
run: |
mkdir -p /tmp/test-results /tmp/junit-results
- PORT=60007 \
+ PORT=30007 \
RESULT_DIR=/tmp/test-results \
JUNIT_DIR=/tmp/junit-results \
bash tools/ci/run-tests.sh
@@ -195,7 +198,7 @@ jobs:
tutorial-e2e:
name: "Tutorial E2E (new → scaffold → migrate → routes → .json → test)"
if: github.event.action != 'edited'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
diff --git a/.github/workflows/refresh-packages-baseline.yml b/.github/workflows/refresh-packages-baseline.yml
index bc0108c89c..3efc54b0ff 100644
--- a/.github/workflows/refresh-packages-baseline.yml
+++ b/.github/workflows/refresh-packages-baseline.yml
@@ -23,7 +23,7 @@ on:
jobs:
refresh:
name: Refresh packages-index baseline
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
diff --git a/.github/workflows/refresh-visual-baselines.yml b/.github/workflows/refresh-visual-baselines.yml
index 8115777fd5..08c4e71a9e 100644
--- a/.github/workflows/refresh-visual-baselines.yml
+++ b/.github/workflows/refresh-visual-baselines.yml
@@ -64,7 +64,7 @@ on:
jobs:
refresh:
name: Refresh ${{ inputs.sites }} baseline(s) on ${{ github.ref_name }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
# Serialize per-branch so two simultaneous refreshes can't both build,
# commit, and race on `git push` (the second would be rejected as
# non-fast-forward). Matches the pattern web-deploy.yml uses for the
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index 13c69ff058..79ed03d1e6 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -18,7 +18,7 @@ jobs:
#############################################
build:
name: Build & Publish Release Candidate
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
id: Checkout-Repository
@@ -100,7 +100,10 @@ jobs:
- name: Install CommandBox
run: |
- curl -fsSl https://downloads.ortussolutions.com/debs/gpg | sudo gpg --dearmor -o /usr/share/keyrings/ortussolutions.gpg
+ # Download to a file first: a retried transfer can't hand gpg a partial or
+ # duplicated stream.
+ curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors -o "$RUNNER_TEMP/ortussolutions.gpg" https://downloads.ortussolutions.com/debs/gpg
+ sudo gpg --dearmor -o /usr/share/keyrings/ortussolutions.gpg "$RUNNER_TEMP/ortussolutions.gpg"
echo "deb [signed-by=/usr/share/keyrings/ortussolutions.gpg] https://downloads.ortussolutions.com/debs/noarch /" | sudo tee /etc/apt/sources.list.d/ortussolutions.list
sudo apt-get update && sudo apt-get install -y commandbox
@@ -145,8 +148,11 @@ jobs:
local VERSION=$(jq -r '.version' "$MANIFEST")
echo " Version: $VERSION"
+ # Fatal: a mismatched manifest still publishes, under the wrong
+ # version (the starter app shipped as 1.0.0 on every release, #3908).
if [[ "$VERSION" != "${{ env.WHEELS_VERSION }}" ]]; then
- echo " WARNING: Version mismatch! Expected ${{ env.WHEELS_VERSION }}, got $VERSION"
+ echo " ERROR: Version mismatch! Expected ${{ env.WHEELS_VERSION }}, got $VERSION"
+ exit 1
fi
local FILE_COUNT=$(find "$PACKAGE_DIR" -type f | wc -l)
diff --git a/.github/workflows/release-guard-tests.yml b/.github/workflows/release-guard-tests.yml
new file mode 100644
index 0000000000..6c4177eeae
--- /dev/null
+++ b/.github/workflows/release-guard-tests.yml
@@ -0,0 +1,33 @@
+name: Release Guard Tests
+
+# Fixture tests for tools/ci/release_republish_guard.py, the check that stops
+# release.yml from republishing an already-released version (#4221). The guard
+# itself only runs on a push to main, so its decision logic is tested here
+# against canned GitHub API responses instead of a real release.
+
+on:
+ pull_request:
+ paths:
+ - 'tools/ci/release_republish_guard.py'
+ - 'tools/ci/test_release_republish_guard.py'
+ - '.github/workflows/release.yml'
+ - '.github/workflows/release-guard-tests.yml'
+ push:
+ branches: [develop, main]
+ paths:
+ - 'tools/ci/release_republish_guard.py'
+ - 'tools/ci/test_release_republish_guard.py'
+ - '.github/workflows/release.yml'
+ - '.github/workflows/release-guard-tests.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ guard-tests:
+ name: Republish guard fixtures
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+ - name: Test the republish guard
+ run: python3 tools/ci/test_release_republish_guard.py
diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml
index c5e3571e19..bc4204ce38 100644
--- a/.github/workflows/release-readiness.yml
+++ b/.github/workflows/release-readiness.yml
@@ -37,7 +37,7 @@ jobs:
# below only reads. Never blocks the gate (fork PRs get a read-only token).
ensure-label:
name: Ensure deferred label
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
continue-on-error: true
permissions:
issues: write
@@ -53,7 +53,7 @@ jobs:
readiness:
name: Release readiness
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: read
issues: read
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6b4f16ce3c..bb78f1ecdd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,7 +60,13 @@ jobs:
#############################################
build:
name: Build & Publish Release
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
+ # Stable runs queue instead of overlapping, so two quick pushes to main
+ # can't both pass the republish check before either uploads (#4221).
+ # Snapshot runs get a unique group and never wait.
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/main' && 'release-stable' || format('release-{0}', github.run_id) }}
+ cancel-in-progress: false
steps:
- name: Checkout Repository
id: Checkout-Repository
@@ -93,6 +99,18 @@ jobs:
echo "BRANCH=main" >> $GITHUB_ENV
fi
+ # A published version's assets never change (#4221). Without this check a
+ # push to main whose wheels.json still names a released version rebuilt
+ # and replaced that release's assets in place (v4.1.0, 2026-09-17), and
+ # republished it to ForgeBox. It runs before any build or publish step
+ # and fails closed. The decision (and its fixtures) lives in
+ # tools/ci/release_republish_guard.py.
+ - name: Refuse to republish an existing release
+ if: github.ref == 'refs/heads/main'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 tools/ci/release_republish_guard.py --repo "$GITHUB_REPOSITORY" --tag "v${WHEELS_VERSION}" --sha "$GITHUB_SHA"
+
- name: Make build scripts executable
run: |
chmod +x tools/build/scripts/*.sh
@@ -146,6 +164,45 @@ jobs:
echo "✓ Release checklist validation passed"
+ # `wheels lookup` (and its MCP tool) reads an offline docs index shipped
+ # inside the module, cli/lucli/data/docs-index.json. It is built here,
+ # before anything is published, so a failure can't leave a version on
+ # ForgeBox with no GitHub release or module behind it (#4314).
+ #
+ # It has to describe this build's framework version:
+ # - a release (main) reads the committed docs/api/v.json, with
+ # --strict, so a missing file fails the build with the reason;
+ # - a snapshot build reads the API JSON the snapshot workflow's fast-test
+ # job generated from the running framework (artifact api-docs-snapshot),
+ # also with --strict;
+ # - a manual run on develop has no fast-test artifact, so it builds from
+ # the newest API JSON committed and says so in a warning, rather than
+ # failing the recovery path.
+ - name: Setup Node for Docs Index
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Download API docs snapshot
+ id: api-docs-snapshot
+ if: env.BRANCH == 'develop'
+ continue-on-error: true
+ uses: actions/download-artifact@v6
+ with:
+ name: api-docs-snapshot
+ path: docs/api
+
+ - name: Build Docs Index For wheels lookup
+ env:
+ SNAPSHOT_API_JSON: ${{ steps.api-docs-snapshot.outcome }}
+ run: |
+ if [ "$BRANCH" = "main" ] || [ "$SNAPSHOT_API_JSON" = "success" ]; then
+ node tools/build/scripts/build-docs-index.mjs --strict --version "$WHEELS_VERSION"
+ else
+ echo "::warning title=Docs index::No api-docs-snapshot artifact (a manual run on develop?), so the wheels lookup index uses the newest committed API JSON instead of this build's."
+ node tools/build/scripts/build-docs-index.mjs --version "$WHEELS_VERSION"
+ fi
+
#############################################
# Prepare and Publish to ForgeBox
#############################################
@@ -178,7 +235,10 @@ jobs:
- name: Install CommandBox
if: github.ref == 'refs/heads/main'
run: |
- curl -fsSl https://downloads.ortussolutions.com/debs/gpg | sudo gpg --dearmor -o /usr/share/keyrings/ortussolutions.gpg
+ # Download to a file first: a retry after partial output would corrupt
+ # what gpg reads if curl piped straight into it.
+ curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors -o "${RUNNER_TEMP}/ortussolutions.gpg.asc" https://downloads.ortussolutions.com/debs/gpg
+ sudo gpg --dearmor -o /usr/share/keyrings/ortussolutions.gpg "${RUNNER_TEMP}/ortussolutions.gpg.asc"
echo "deb [signed-by=/usr/share/keyrings/ortussolutions.gpg] https://downloads.ortussolutions.com/debs/noarch /" | sudo tee /etc/apt/sources.list.d/ortussolutions.list
sudo apt-get update && sudo apt-get install -y commandbox
@@ -234,8 +294,11 @@ jobs:
echo " Version: $VERSION"
# Check that version matches expected version
+ # Fatal: a mismatched manifest still publishes, under the wrong
+ # version (the starter app shipped as 1.0.0 on every release, #3908).
if [[ "$VERSION" != "${{ env.WHEELS_VERSION }}" ]]; then
- echo " WARNING: Version mismatch! Expected ${{ env.WHEELS_VERSION }}, got $VERSION"
+ echo " ERROR: Version mismatch! Expected ${{ env.WHEELS_VERSION }}, got $VERSION"
+ exit 1
fi
# Count files
@@ -269,17 +332,8 @@ jobs:
echo "=========================================="
./tools/build/scripts/ship-consumer-docs.sh check
- for pkg in build-wheels-core/wheels build-wheels-starterApp; do
- if [ ! -f "$pkg/CLAUDE.md" ] || [ ! -f "$pkg/AGENTS.md" ]; then
- echo "ERROR: $pkg is missing the consumer AI docs (CLAUDE.md / AGENTS.md)"
- exit 1
- fi
- for leaked in docs/superpowers docs/plans .claude .github CLAUDE.local.md; do
- if [ -e "$pkg/$leaked" ]; then
- echo "ERROR: $pkg leaks maintainer-only path: $leaked"
- exit 1
- fi
- done
+ for pkg in build-wheels-base build-wheels-core/wheels build-wheels-starterApp; do
+ ./tools/build/scripts/ship-consumer-docs.sh verify "$pkg"
done
echo " ✓ Two-tier AI docs validated"
@@ -349,7 +403,9 @@ jobs:
# report a stale version. (Historically mirrored the same substitution
# the now-frozen prepare-cli.sh did for the legacy wheels-cli ForgeBox
# artifact; that leg was removed in #3184.) See #2326.
- find cli/lucli -type f \( -name "*.json" -o -name "*.md" -o -name "*.cfm" -o -name "*.cfc" \) -exec sed -i.bak "s/@build\.version@/${MODULE_VERSION}/g" {} \;
+ # data/ (the docs index) is skipped: the guides it holds quote the
+ # placeholder literally.
+ find cli/lucli -type f \( -name "*.json" -o -name "*.md" -o -name "*.cfm" -o -name "*.cfc" \) -not -path "cli/lucli/data/*" -exec sed -i.bak "s/@build\.version@/${MODULE_VERSION}/g" {} \;
find cli/lucli -name "*.bak" -delete
# Generate .module-version at build time with the canonical version.
# This file is read at runtime by the brew/scoop/.deb/.rpm wrappers
@@ -374,7 +430,7 @@ jobs:
# Build .deb / .rpm Linux packages alongside the zip artifacts. nfpm
# needs no Linux-specific build host (it's a single Go binary), so this
- # runs on the same ubuntu-latest runner as the rest of the pipeline.
+ # runs on the same ubuntu-24.04 runner as the rest of the pipeline.
# Generates arch-independent wheels__all.deb and wheels-.noarch.rpm.
# See tools/distribution-drafts/linux-packages/ for the nfpm configs.
- name: Install nfpm
@@ -383,7 +439,7 @@ jobs:
# From the release's checksums.txt; bump together with NFPM_VERSION.
NFPM_SHA256="535962c4771db671352884b5591254481f851822557ad8476605c1e77a12504c"
NFPM_TGZ="${RUNNER_TEMP}/nfpm.tar.gz"
- curl -fsSL -o "${NFPM_TGZ}" \
+ curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors -o "${NFPM_TGZ}" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz"
echo "${NFPM_SHA256} ${NFPM_TGZ}" | sha256sum -c -
sudo tar -xzf "${NFPM_TGZ}" -C /usr/local/bin nfpm
@@ -809,7 +865,7 @@ jobs:
smoke-test-distribution:
name: Smoke Test Installed Distribution
needs: build
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@v4
diff --git a/.github/workflows/rustcfml-ci.yml b/.github/workflows/rustcfml-ci.yml
index 21314c4cd1..69de4b658a 100644
--- a/.github/workflows/rustcfml-ci.yml
+++ b/.github/workflows/rustcfml-ci.yml
@@ -29,7 +29,7 @@ on:
jobs:
rustcfml:
name: "RustCFML (Linux)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@v5
@@ -49,6 +49,11 @@ jobs:
- name: Test the engine download verification (no engine)
run: python3 tools/rustcfml/test_resolve_engine.py
+ # Engine-free: the daily version check skips a release listed in
+ # tools/rustcfml/KNOWN_BAD, and every entry carries a tag, a link and a reason.
+ - name: Test the known-bad release list (no engine)
+ run: python3 tools/rustcfml/test_check_version.py
+
- name: Read pinned engine version
id: engine
run: echo "version=$(tr -d '[:space:]' < tools/rustcfml/ENGINE_VERSION)" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/rustcfml-version-check.yml b/.github/workflows/rustcfml-version-check.yml
index 0ecb1ff95b..bb817cbc90 100644
--- a/.github/workflows/rustcfml-version-check.yml
+++ b/.github/workflows/rustcfml-version-check.yml
@@ -25,7 +25,7 @@ permissions:
jobs:
check:
name: Check for newer RustCFML
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout develop (or the dispatched ref)
uses: actions/checkout@v6
@@ -39,7 +39,11 @@ jobs:
run: bash tools/rustcfml/check-version.sh
- name: Notify once for a rejected candidate
- if: env.RUSTCFML_REJECTED != ''
+ # Also after a failed check step: when the newest release is rejected and the
+ # stable fallback then can't be evaluated, check-version.sh exits 1, but the
+ # newest's rejection (exported to GITHUB_ENV before that) must still be filed.
+ # Not on a cancelled run.
+ if: ${{ !cancelled() && env.RUSTCFML_REJECTED != '' }}
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
@@ -51,7 +55,7 @@ jobs:
# Only from develop: a branch dispatch that happens to go green must not
# open a bump PR built on a feature branch.
if: env.RUSTCFML_LATEST != '' && github.ref == 'refs/heads/develop'
- uses: peter-evans/create-pull-request@v6
+ uses: peter-evans/create-pull-request@v8.1.1
with:
branch: "auto-bump/rustcfml-${{ env.RUSTCFML_LATEST }}"
title: "chore(rustcfml): bump engine pin to ${{ env.RUSTCFML_LATEST }}"
diff --git a/.github/workflows/smoke-env.yml b/.github/workflows/smoke-env.yml
index cd27789d9d..d086fd902b 100644
--- a/.github/workflows/smoke-env.yml
+++ b/.github/workflows/smoke-env.yml
@@ -41,7 +41,7 @@ permissions:
jobs:
smoke:
name: "Smoke: ${{ matrix.wheels_env }} (Lucee 7)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
@@ -75,7 +75,7 @@ jobs:
- name: Run smoke probes
run: |
- BASE_URL="http://localhost:60007" SMOKE_ENV="${{ matrix.wheels_env }}" bash tools/ci/smoke-env.sh
+ BASE_URL="http://localhost:30007" SMOKE_ENV="${{ matrix.wheels_env }}" bash tools/ci/smoke-env.sh
- name: Debug server logs
if: failure()
@@ -100,7 +100,7 @@ jobs:
# tools/docker/adobe2023/lib/) so nothing depends on an absent DB server.
smoke-adobe:
name: "Smoke: ${{ matrix.wheels_env }} (Adobe 2023)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
index 03c391bf18..4a3c411aaf 100644
--- a/.github/workflows/snapshot.yml
+++ b/.github/workflows/snapshot.yml
@@ -20,10 +20,10 @@ jobs:
#############################################
fast-test:
name: "Lucee 7 + SQLite (LuCLI)"
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
WHEELS_CI: "true"
- WHEELS_BROWSER_TEST_BASE_URL: "http://localhost:60007"
+ WHEELS_BROWSER_TEST_BASE_URL: "http://localhost:30007"
WHEELS_BROWSER_CI_ENABLE: "true"
steps:
- uses: actions/checkout@v5
@@ -66,7 +66,7 @@ jobs:
SHA=$(echo "$row" | jq -r '.sha256')
echo "Downloading ${FILE}..."
- curl -sL "$URL" -o ~/.wheels/browser/lib/"$FILE"
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "$URL" -o ~/.wheels/browser/lib/"$FILE"
ACTUAL=$(sha256sum ~/.wheels/browser/lib/"$FILE" | cut -d' ' -f1)
if [ "$ACTUAL" != "$SHA" ]; then
@@ -81,17 +81,17 @@ jobs:
- name: Download SQLite JDBC driver
run: |
- curl -sL "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
+ curl -sL --retry 3 --retry-delay 2 --retry-all-errors "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.50.3.0/sqlite-jdbc-3.50.3.0.jar" \
-o /tmp/sqlite-jdbc.jar
- name: Start Lucee server
run: |
cp tools/ci/lucee.ci.json lucee.json
- nohup lucli server run --port=60007 > /tmp/lucli-server.log 2>&1 &
+ nohup lucli server run --port=30007 > /tmp/lucli-server.log 2>&1 &
echo $! > /tmp/lucli-server.pid
echo "Waiting for Lucee to start..."
for i in $(seq 1 120); do
- if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:60007/" 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:30007/" 2>/dev/null; then
echo "Lucee is up (attempt ${i})"
break
fi
@@ -119,18 +119,18 @@ jobs:
kill $(cat /tmp/lucli-server.pid) 2>/dev/null || true
lucli server stop 2>/dev/null || true
for i in $(seq 1 15); do
- if ! curl -s -o /dev/null --connect-timeout 1 "http://localhost:60007/" 2>/dev/null; then
+ if ! curl -s -o /dev/null --connect-timeout 1 "http://localhost:30007/" 2>/dev/null; then
break
fi
sleep 1
done
- fuser -k 60007/tcp 2>/dev/null || true
+ fuser -k 30007/tcp 2>/dev/null || true
sleep 2
- nohup lucli server run --port=60007 --force > /tmp/lucli-server.log 2>&1 &
+ nohup lucli server run --port=30007 --force > /tmp/lucli-server.log 2>&1 &
echo $! > /tmp/lucli-server.pid
echo "Waiting for Lucee to restart..."
for i in $(seq 1 60); do
- if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:60007/" 2>/dev/null; then
+ if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:30007/" 2>/dev/null; then
echo "Lucee restarted with SQLite JDBC"
break
fi
@@ -140,7 +140,7 @@ jobs:
- name: Run tests
run: |
mkdir -p /tmp/test-results /tmp/junit-results
- PORT=60007 \
+ PORT=30007 \
RESULT_DIR=/tmp/test-results \
JUNIT_DIR=/tmp/junit-results \
bash tools/ci/run-tests.sh
@@ -169,7 +169,7 @@ jobs:
# web/scripts/generate-api-docs.mjs (version.replace(/\./g, '-')).
SNAPSHOT_FILE="docs/api/v${VERSION}-snapshot.json"
curl -sf --max-time 60 \
- "http://localhost:60007/wheels/api?format=json&type=core" \
+ "http://localhost:30007/wheels/api?format=json&type=core" \
-o "$SNAPSHOT_FILE"
if ! jq -e '.functions and .sections' "$SNAPSHOT_FILE" > /dev/null 2>&1; then
echo "::error::API docs JSON invalid or empty"
@@ -229,7 +229,7 @@ jobs:
# version on api.wheels.dev tracks the current framework state.
deploy-api-snapshot:
needs: fast-test
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: read
deployments: write
diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml
index 048bbcf411..f9e338acca 100644
--- a/.github/workflows/version-bump.yml
+++ b/.github/workflows/version-bump.yml
@@ -24,7 +24,7 @@ on:
jobs:
bump-version:
name: Bump Version Numbers
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@v6
diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml
index 07ce7cb13e..0fcd387683 100644
--- a/.github/workflows/web-deploy.yml
+++ b/.github/workflows/web-deploy.yml
@@ -17,7 +17,7 @@ on:
jobs:
deploy:
name: Deploy ${{ matrix.site }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
concurrency:
group: web-deploy-${{ matrix.site }}-${{ github.ref }}
cancel-in-progress: true
@@ -62,6 +62,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm --filter @wheels-dev/site-${{ matrix.site }} build
+ # The guides and API sites build llms.txt, llms-full.txt, llms-small.txt, the per-version
+ # llms-txt/ sets and a .md for every page. Fail before deploying if a file is missing, a link
+ # in llms.txt doesn't resolve, a page lacks its .md, or a non-current version leaks into
+ # llms-full.txt / llms-small.txt (web/scripts/check-llms.mjs).
+ - name: Check llms files
+ if: matrix.site == 'guides' || matrix.site == 'api'
+ working-directory: web
+ env:
+ SITE: ${{ matrix.site }}
+ run: node scripts/check-llms.mjs "sites/${SITE}/dist" "${SITE}"
+
- name: Deploy to Cloudflare Pages
# Dependabot-triggered runs can't read repository secrets (they use
# the separate Dependabot secrets store), so CLOUDFLARE_API_TOKEN is
@@ -93,7 +104,7 @@ jobs:
detect-scope:
name: Detect change scope
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
content_only: ${{ steps.scope.outputs.content_only }}
blog_content_changed: ${{ steps.scope.outputs.blog_content_changed }}
@@ -225,7 +236,7 @@ jobs:
(needs.detect-scope.result != 'success' ||
github.event_name != 'pull_request' ||
needs.detect-scope.outputs.content_only != 'true')
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: write
steps:
@@ -265,6 +276,11 @@ jobs:
- name: Build all sites
working-directory: web
+ env:
+ # Same reason as the build job: sites/packages enumerates the package
+ # registry through the GitHub REST API at build time, and the
+ # unauthenticated 60/hour cap returns 403 under busy CI.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm build
# The blog index page is content-driven — new articles appear on it
diff --git a/.github/workflows/web-scripts-tests.yml b/.github/workflows/web-scripts-tests.yml
new file mode 100644
index 0000000000..c9defa6e25
--- /dev/null
+++ b/.github/workflows/web-scripts-tests.yml
@@ -0,0 +1,53 @@
+name: Web Scripts Tests
+
+# Runs the node:test suites under web/scripts/ (for example the blog
+# announcement helper). The web workspace's vitest config only picks up
+# sites/*/src/**/*.test.ts, so these never ran in CI, and a dedupe query that
+# matched nothing passed its mocked tests for weeks. This job runs them on every
+# PR that touches the scripts or the blog workflows that call them.
+#
+# It also tests tools/build/scripts/build-docs-index.mjs, which builds the
+# offline index `wheels lookup` reads, and builds that index from the PR's
+# guides and API JSON: a guide page the builder can't parse fails here, not at
+# release time.
+
+on:
+ pull_request:
+ paths:
+ - 'web/scripts/**'
+ - '.github/workflows/blog-*.yml'
+ - '.github/scripts/blog-*.sh'
+ - '.github/workflows/web-scripts-tests.yml'
+ - 'tools/build/scripts/build-docs-index*.mjs'
+ - 'web/sites/guides/src/content/**'
+ - 'docs/api/**'
+ push:
+ branches: [develop]
+ paths:
+ - 'web/scripts/**'
+ - '.github/workflows/web-scripts-tests.yml'
+ - 'tools/build/scripts/build-docs-index*.mjs'
+
+permissions:
+ contents: read
+
+jobs:
+ node-tests:
+ name: web/scripts node tests
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ # Node 22 expands the quoted glob itself (a bare directory argument is
+ # treated as one file and fails).
+ - name: Run node tests
+ run: node --test 'web/scripts/**/*.test.mjs'
+ - name: Run the docs index builder's tests
+ run: node --test 'tools/build/scripts/*.test.mjs'
+ # Not --strict: a PR tree may not have its version's API JSON yet (it is
+ # generated at the snapshot build and committed at the release cut).
+ - name: Build the docs index from this tree
+ run: node tools/build/scripts/build-docs-index.mjs --out "$RUNNER_TEMP/docs-index.json"
diff --git a/.gitignore b/.gitignore
index a5a04f95ae..8480c16646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,10 +87,6 @@ web/tests/visual-diffs/
# none were ignored either — so any `git add -A` after running the suite swept
# them into the diff. That happened twice: caught in review on the #3334/#3350/
# #3349/#3325/#3351 batch, and missed on #3362, which merged seven of them.
-#
-# public/testbox/system/stubs/ predates this entry with one file already tracked
-# (F952D54F…). Ignoring a directory does not untrack what is already in the index,
-# so that one stays as-is; only the accidental additions were removed.
public/testbox/system/stubs/
/rewrite.config
/box.json
@@ -103,6 +99,11 @@ public/testbox/system/stubs/
# Build outputs (docs bundle etc.)
artifacts/
+# The starter app ships vendor/wheels/ in its release zip (prepare-starterApp.sh
+# copies it); a local `box install` in the example is not committed.
+/examples/starter-app/vendor/
+/examples/starter-app/db/*.sqlite
+
# Linux package build staging (tools/distribution-drafts/linux-packages)
.linux-pkg-build/
@@ -116,3 +117,6 @@ public/wheels-docs/
.wheels-test-local.lock/
.wheels-test-local.lock.takeover/
lucee.json.test-local.bak
+
+# Built at release/snapshot time by tools/build/scripts/build-docs-index.mjs (wheels lookup)
+cli/lucli/data/docs-index.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d57f073614..3a52b58408 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,365 @@ All historical references to "CFWheels" in this changelog have been preserved fo
---
+# [4.2.0](https://github.com/wheels-dev/wheels/releases/tag/v4.2.0) => 2026-10-12
+
+### Added
+
+- `transaction="savepoint"` runs `invokeWithTransaction()` or any persistence method as a nested unit: inside an open transaction it rolls back only its own writes when the method returns `false` or throws, and the outer transaction carries on. With no transaction open it behaves like `commit`. Records in a rolled-back unit get `afterRollback` right away and never `afterCommit`. Each unit runs a primary-key read before setting its savepoint, because Lucee drops a savepoint set before a transaction's first query. Inside a raw CFML `transaction {}` block a unit also nests as a savepoint on Lucee, BoxLang and Adobe ColdFusion; on RustCFML, which can't see the raw block, it joins it like `commit` (#3958, #4068)
+- A `transactionIsolation` setting sets the default isolation level of the transactions Wheels opens for model writes and `invokeWithTransaction()` (default `read_committed`, unchanged). Set it to `read_uncommitted`, `repeatable_read` or `serializable`, or to `""` to send no isolation attribute and use the engine's or driver's default. An `isolation` argument on the call still wins (#4059)
+- On SQL Server 2016+ (database compatibility level 130 or higher), a query whose IN lists would bind more than the 2097 parameters SQL Server accepts now runs: its largest lists of integers, plain decimals, strings and uniqueidentifiers are bound as one parameter each and expanded on the server with `STRING_SPLIT`, every value still bound. Queries within the limit keep their SQL. Any other list (dates, times, float, real, bit, text or binary values, or numbers such as `+5`, `.5` or `1E5`), and databases below compatibility level 130, keep the `Wheels.TooManyParameters` error, whose details now say which lists SQL Server runs as one parameter; long date and time lists are tracked in #4318 (#4103)
+- Added `$snapshotRoutes()`, `$restoreRoutes()`, and `$clearRoutes()` to `wheels.WheelsTest` so tests that redefine the route table can snapshot and restore every route-derived structure (routes, staticRoutes, namedRoutePositions, the urlFor caches, and the router index) in one call, instead of hand-rolling a partial save/restore that leaks stale route state into later specs. ([#4194](https://github.com/wheels-dev/wheels/issues/4194))
+- `wheels migrate unlock` reports who holds the migration lock: the host, owner id, how long it has held it and when its lease expires. While a live instance holds it, the command exits non-zero and names `wheels migrate unlock --force`, which removes the lock and prints what it removed. The `migrate` MCP tool has the same `unlock` action, and it only removes the lock when `force` is true. In the framework, `migrationLockStatus()` returns the same details and `releaseMigrationLock(force)` removes the lock: without `force`, only an expired lease. The `Wheels.MigrationLockTimeout` error now points at the command (#4209).
+- `$testClient().get()` accepts an optional `timeout` (seconds, default 30) that bounds the underlying HTTP request, so a request that may hang fails the asserting test case rather than timing out the whole test run (#4219)
+- On SQL Server 2016 and later, a statement whose date or timestamp IN lists push it past the parameter limit now runs: each long list binds as one `STRING_SPLIT` parameter, its values compared the way the same list is when it fits (in the column's own type on a `DATETIME`, `SMALLDATETIME` or `TIME` column, otherwise as `datetime2(7)` / `date`), so it matches exactly the same rows. Values must be written as `yyyy-mm-dd` with an optional time, `HH:mm` with optional seconds for a time, or be CFML dates; dates in other forms still throw `Wheels.TooManyParameters` (#4318)
+- Background jobs can call `heartbeat()` from inside `perform()` to signal that they are still running. Once a job has heartbeated, it is reclaimed when its heartbeats stop for its heartbeat grace, even inside its timeout: the job's own `this.heartbeatGrace`, or else `set(jobsHeartbeatGraceSeconds = n)` (default 300; `0` turns it off; values below 30 are raised to 30). Heartbeat at least every grace / 3. The stale-job reaper measures from the latest heartbeat (`COALESCE(heartbeatAt, updatedAt)`), so a long job that keeps heartbeating isn't reaped and run a second time. A job that never heartbeats is reclaimed only after its claim window (its timeout plus the larger of 60 seconds and its timeout), as before. A stopped heartbeat isn't proof the worker died: a `perform()` stuck in a long call that can't heartbeat is reclaimed too, so heartbeat around such calls or raise the grace. `heartbeat()` never extends a job's timeout, which stays the hard cap on one attempt. An exclusive job's heartbeat renews its lease by its grace window, so a job that stops heartbeating loses its lease on the same clock it's reclaimed on. A heartbeat after the job's claim was reaped and re-issued throws `Wheels.Job.Fenced`, telling the old attempt to stop. Each new claim clears the previous attempt's heartbeat. `wheels_jobs` gains a nullable `heartbeatAt` column, added to existing tables automatically (never inside a transaction); without it, `heartbeat()` renews `updatedAt` instead. ([#4477](https://github.com/wheels-dev/wheels/issues/4477), [#4502](https://github.com/wheels-dev/wheels/issues/4502))
+- Jobs gain `this.idempotent` (default `true`). After a reap, a job is retried only when its class is idempotent, which is today's behaviour. A class with `this.idempotent = false` is not re-run: the reaped attempt ends with the new status `interrupted`, counted by `queueStats()` and `JobWorker.getStats()` and purgeable with `purge(status = "interrupted")`. ([#4477](https://github.com/wheels-dev/wheels/issues/4477))
+- `enqueue()`, `enqueueIn()` and `enqueueAt()` accept `uniqueKey`: only one job is written per key, including when two enqueues race each other. A duplicate writes nothing and returns `{enqueued: false, duplicate: true, status: "duplicate"}` with the existing job's `id`; a key stays taken while its row exists, until it is purged. Every enqueue result now includes `enqueued` and `duplicate`. `wheels_jobs` gains a nullable `uniqueKey` column (VARCHAR(255)) with a unique index (filtered to non-NULL keys on SQL Server). It is added to existing tables automatically under the migration lock, taken without waiting, and backfilled from `id`; servers still on the previous version keep enqueuing during a rolling upgrade. `enqueue(uniqueKey=...)` throws `Wheels.Job.UniqueKeyUnavailable` when the column can't be added, and `Wheels.Job.InvalidUniqueKey` for a key over 255 characters. ([#4478](https://github.com/wheels-dev/wheels/issues/4478))
+- Background jobs can now be made exclusive: set `this.exclusive = true` and a second run of that job class can't start on any server while one is running. A job can instead set `this.concurrencyKey = "..."`, or define `concurrencyKeyFor(struct data)` (which takes precedence), to share one lease among the jobs with the same key (for example one run per account). A job that finds its lease busy waits as `pending` without using up an attempt, and counts as skipped. The lease is held in a new `wheels_job_locks` table, created on first use, through `wheels.LeaseLock`, the cross-server lease the migrator already used, now extracted for reuse. Exclusivity is only as strong as the lease: it lasts the job's claim timeout plus a grace period, and a run that outlives it (for example one stuck in a blocked database call) can overlap a second run; that is logged and counted as a lost lease. Servers sharing the table need synchronised clocks. ([#4479](https://github.com/wheels-dev/wheels/issues/4479))
+- Background jobs gain per-host control for apps running on more than one server.
+ - `set(jobsMaxConcurrentPerHost = n)` caps how many jobs one server runs at once. The default `0` means no cap, which is today's behaviour. The cap is enforced in the worker, so `wheels jobs work`, `processNext()` and the new `JobRunner.tick()` all respect it. The count and the claim happen under one lock per host, so two polls can't both take the last slot.
+ - `new wheels.JobRunner().drain(expiresInSeconds)` / `.resume()` stop a server from starting new jobs while the jobs it is already running finish (for deploys). Reaping continues, and an expired drain lifts itself.
+ - `JobRunner.tick(queues, maxConcurrent, timeout, maxJobs)` is one round for a scheduled task: it registers the server in the new `wheels_job_hosts` table (auto-created, never inside a transaction), reaps, and runs up to `maxJobs` jobs unless the server is draining or full. `JobRunner.status()` reports the server's running jobs, cap and drain.
+ - A server is identified by the host name recorded in `wheels_jobs.claimedBy`. Set `set(jobsHostName = "...")` when several app servers share one machine, so each gets its own cap and drain. `processNext()` results gain `capped` and `draining`. ([#4480](https://github.com/wheels-dev/wheels/issues/4480))
+- `wheels jobs drain` stops the running server from starting new background jobs while the jobs it is already running finish, and `wheels jobs resume` lifts it. For deploys, `wheels jobs drain --wait` blocks until the server has no running jobs (up to 600 seconds, or `--wait=`) and exits with an error if jobs are still running, so the server can be stopped safely. `--expires=` sets when the drain lifts itself (default 3600; 0 = until resumed). `wheels jobs status` now also shows this server's running jobs, cap and drain. ([#4480](https://github.com/wheels-dev/wheels/issues/4480))
+- Recurring background jobs.
+ - **Defining schedules.** Define schedules in `config/schedules.cfm`:
+ - `schedule("digest").job("DigestJob").cron("0 7 * * MON").timezone("America/New_York")`
+ - `schedule("canary").job("CanaryJob").every(15, "minutes")`
+ They are synced to a new `wheels_job_schedules` table at application start. Apps can also insert their own rows at runtime (e.g. from an admin page); the sync never changes those, and a code schedule removed from the file is disabled, not deleted.
+ - **Running them.** Call `new wheels.JobScheduler().enqueueDue()` regularly on one or every server. Each due slot is enqueued once, with `uniqueKey = ":"`, however many servers call it.
+ - **Expressions.** Cron takes the standard 5 fields with lists, ranges, steps and month and day names, the `@hourly` to `@yearly` aliases, and an IANA time zone. A local time skipped by a DST change runs at the next valid instant, and a repeated one runs once. Intervals are minutes or hours, aligned to UTC.
+ - **Missed slots.** `catchUp("latest")` (default) runs only the newest missed slot within `catchUpWindow()` (default 3600 seconds); `catchUp("none")` runs only a current one. A slot never runs twice.
+ ([#4481](https://github.com/wheels-dev/wheels/issues/4481))
+- A token-gated jobs tick route, `/wheels/jobs/tick`, lets an engine scheduled task, cron job or systemd timer run background jobs inside the app server without a worker process. Each call runs one `JobRunner.tick()` (reap dead workers' jobs, then run jobs up to the per-host cap) and returns a JSON summary; an overlapping call on the same server is skipped. The route is off (404) unless `set(jobsRunnerToken = "...")` is set; every call must send the token in the `X-Wheels-Jobs-Token` header (compared in constant time), or as `?token=` only with `set(jobsRunnerTokenInQuery = true)` for schedulers that can only set a URL (the token then ends up in access logs). Requests carrying a forwarding header (`X-Forwarded-For`, `Forwarded`, `X-Real-IP`, `CF-Connecting-IP`) are refused: the token, not the client's address, is the boundary. It works with `enablePublicComponent = false`. `wheels jobs tick` runs one tick from the command line. ([#4482](https://github.com/wheels-dev/wheels/issues/4482))
+- Background jobs gained lifecycle hooks. `beforePerform(data)` and `afterPerform(data, result)` run with `perform()`, under its timeout, and a throw from either fails the attempt. `onSuccess(result)` runs once the job is recorded as completed, and `onFailure(error, attempt, isFinal)` after each failed attempt, with `isFinal = true` when the job won't be tried again (including when a crashed worker's last attempt is reaped). Both are best-effort: a throw is logged and never changes the job's outcome. `set(jobsOnFailure = "component.path.method")` adds an app-wide failure hook, called with one struct (`jobId`, `jobClass`, `queue`, `attempt`, `maxRetries`, `isFinal`, `error`) as its `event` argument; job data is never passed to it. `perform()`'s return value is now stored in a new `wheels_jobs.result` column (added automatically): simple values as-is, anything else as JSON, cut short at 4000 bytes with a `...[truncated]` marker. ([#4483](https://github.com/wheels-dev/wheels/issues/4483))
+- `wheels jobs install` writes a migration that creates the background-job tables (`wheels_jobs`, `wheels_job_hosts`, `wheels_job_schedules`, `wheels_job_locks`), so an app can apply the jobs schema once, reviewed and under migration control, instead of having every server create and alter it at startup. The migration adopts tables Wheels already created automatically, adding only what they lack, and running it again changes nothing. Apps that use it set `set(jobsAutoCreateTables = false)`, which stops the framework from creating or altering the job tables at runtime; a missing table then raises `Wheels.Job.SchemaMissing` from enqueue and from a worker's poll, with a pointer to `wheels jobs install`. Auto-create stays the default (`jobsAutoCreateTables = true`). The migration's `down()` drops the job tables, including ones it adopted, and every job in them. The job schema is now defined once (`wheels.JobSchema`), so the migration and auto-create build the same tables. ([#4484](https://github.com/wheels-dev/wheels/issues/4484))
+- Added `afterCommit` and `afterRollback` model callbacks. `afterCommit(methods, on)` runs its methods only after the database transaction actually **commits** — the right place for side effects that must not happen on a rollback (enqueue a job, send mail, bust a cache, call an external API). `afterRollback(methods, on)` runs when the transaction **rolls back**. Both cover `create`, `update`, and `delete`, support an optional `on="create,update,delete"` filter, and (for nested writes or an `invokeWithTransaction` block) fire once, together, when the **outermost** transaction resolves. With no transaction (`transactionMode="none"`) `afterCommit` fires immediately after each write. A callback that throws runs after the commit, so the commit stands; the error is logged to `wheels.log` and propagated. These callbacks require a Wheels-managed transaction (`transaction()` / `invokeWithTransaction`): a write placed inside a raw CFML `transaction {}` block is skipped (with a one-time `wheels.log` warning) on Lucee and BoxLang, and is not detectable on Adobe CF or RustCFML — there the behaviour inside a raw `transaction {}` is left to the engine and is not guaranteed.
+- In the `development` environment with `showErrorInformation` on (its default there), an error response asked for as JSON (`Accept: application/json` or `?format=json`) is now the structured payload the error page's Copy button builds: exception type and message, suggested action, location, source snippet and classified stack, plus `statusCode`. `Accept: text/markdown` (without `application/json`) or `?format=md` returns the same content as Markdown. Both use the error page's status code and drop anything the request wrote before it failed. Testing, maintenance and production responses are unchanged. The payload's location now picks an app frame that is the throw site itself, as the error page does, and its JSON keys keep their case on every engine.
+- `appCacheFetch()`, `appCacheRead()`, `appCacheWrite()`, `appCacheExists()`, `appCacheDelete()` and `appCacheClear()` cache your own computed values in the application cache. `appCacheFetch(key, callback, time)` returns the cached value or computes, stores and returns it. They work in every environment. A cached `false`, `0` or `""` counts as a hit. Keys are case-sensitive strings or a struct or array of values. Entries live in their own `data` category, so `appCacheClear()` never empties the action, page, partial or query caches. `$addToCache()` now returns whether it stored the value.
+- `travelTo(moment, callback)` on `wheels.WheelsTest` freezes the framework clock for the callback, so time-dependent behaviour is testable deterministically without sleeping. Framework code reads the clock through a `$now()` / `$tick()` seam that returns the live clock in production (no behaviour change) and the frozen instant under `travelTo()`: cache expiry and culling, the `RateLimiter` rate-limit windows, and model `createdAt` / `updatedAt` timestamps honour it. The override is request-scoped and restored in a catch-free finally, so it never leaks into another spec (#4387)
+- `wheels deploy` now mounts a top-level `volumes:` list on every app container (Kamal's format: `host:container` or `host:container:ro|rw`, a host path or a named volume), so a SQLite app can keep its database on the host across redeploys. Entries are validated, and accessory `volumes:` / `directories:` arguments are now quoted like the other docker arguments, so paths with spaces or special characters work. Because they are no longer passed through the remote shell unquoted, a `~` or `$VAR` in an accessory volume's host path is no longer expanded on the host: write the absolute path (for example `/home/deploy/data` instead of `~/data`) (#4018)
+- `wheels deploy` can run the database migrations once per deploy, on one host. Add `migrate: true` (or `migrate: {host: , timeout: }`) to `config/deploy.yml`. The migrate host (default: the first host of the first role, which must be proxy-fronted) boots and cuts over first, with `WHEELS_MIGRATE_ON_BOOT=true` and a longer health-check timeout (default 300s). Every other app container gets `WHEELS_MIGRATE_ON_BOOT=false`, so hosts sharing a database no longer all migrate at start. A migration that fails stops the deploy before any host cuts over. Without the block nothing changes. Apps with only job/worker roles are refused for now (#4063).
+- New `set(migrateOnBoot=true)`: migrate to the latest version when the application starts, and fail the start (`Wheels.BootMigrationFailed`, written to the wheels log) when a migration is still pending afterwards. A boolean `WHEELS_MIGRATE_ON_BOOT` environment variable overrides both `migrateOnBoot` and `autoMigrateDatabase`: `true` migrates strictly, `false` never migrates at start. Without it, `autoMigrateDatabase` behaves exactly as before (#4063).
+- `escapeForLike()`, a global string helper that escapes the `LIKE` wildcards (`%`, `_`, `[`) and the `\` escape character in a search term, so a user's input is matched literally in a `LIKE '...' ESCAPE '\'` comparison instead of being read as wildcards (#4355)
+- `$testClient().fromAddress(ip)` sets the client address (`REMOTE_ADDR`) for subsequent requests, so per-client behaviour such as rate limiting and IP rules can be exercised in specs. In the isolated test application the address reaches middleware through the request context; it never changes the `cgi` scope and is ignored outside the test context (#4343)
+- `forgetCachedQueries()`, a public helper to drop the per-request finder cache that `cacheQueriesDuringRequest` fills: `model("Post").forgetCachedQueries()` clears one model's slot (and returns the model for chaining), `forgetCachedQueries("Post")` clears it by name from anywhere, and `forgetCachedQueries(all = true)` clears every model's. A bare call outside a model throws rather than silently wiping everything. Replaces the old advice to delete the reserved `request.wheels["$queryCache"]` key directly (#4364)
+- `wheels framework install` installs the CLI's bundled framework into `vendor/wheels/` for an app that has none (for example one moving off CommandBox), creating `vendor/` if needed and pinning `box.json`'s `wheels-core` when present. It refuses when a framework is already installed (use `wheels upgrade apply` to replace one), and `--to=` must match the bundled version (no downloads)
+- New controller helper `isSafeRedirectUrl(redirectUrl)` returns `true` when a URL stays on this site (a relative URL, or one on the current host), the same rule `redirectTo(url=…)` applies, so a return-to URL can be checked before it's stored or linked. Like every framework helper it can't be routed as an action: an app with a controller action named `isSafeRedirectUrl` must rename it.
+- `enqueue()`, `enqueueIn()` and `enqueueAt()` accept `transactional=false`, and a job class can set `this.transactional = false`: the job is then written after the outermost Wheels-managed transaction ends, whether it commits or rolls back, so failure notices and audit records survive a rollback. With no transaction open it's written immediately; the default (`true`) is unchanged. A raw `transaction {}` block and a request that ends with `abort` aren't covered
+- `wheels jobs enqueue ` adds a job to the queue from the command line: now, after `--in=`, or at `--at=`, with `--data` (a JSON object), `--queue` and `--priority`. It accepts only a component under `app/jobs/` (or a `jobClassPrefixes` path) that extends `wheels.Job`, checked before the class is loaded. Scheduling here is a one-off delayed run; there is no recurring schedule.
+- `wheels jobs work --stop-when-empty` exits once a poll finds no job ready to run, so one-shot batches from cron or CI finish. `--max-jobs=N` alone still waits for N jobs; combine the two to cap a batch.
+- The jobs tick route (`/wheels/jobs/tick`) takes its batch from settings: `jobsRunnerTickMaxJobs` (most jobs per call, `1`-`1000`, default `1`), `jobsRunnerTickTimeout` (seconds per job, `1`-`86400`, default `300`) and `jobsRunnerTickQueues` (comma list, default every queue); a fractional or out-of-range value falls back to the default. The defaults keep today's behaviour of one job per call. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does.
+- `LocalDisk` storage driver: an opt-in `resolveSymlinks` config flag (default `false`) that adds a stricter containment check on top of the always-on lexical guard (#3912). When enabled, `$resolve()` canonicalises paths through the filesystem so a symlink planted under `root` that targets outside it is **rejected** with `Wheels.Storage.InvalidKey` instead of followed. The disk runs a one-time behavioural capability probe at construction and **fails closed** with `Wheels.Storage.InvalidConfiguration` on a runtime that can't resolve symlinks (the JVM-free RustCFML engine, or Windows without symlink privilege), rather than silently enabling an ineffective strict mode. Detection is a capability probe, never an engine-name check. ([#4020](https://github.com/wheels-dev/wheels/issues/4020))
+- `belongsTo(polymorphic=true)`, `hasMany(as=...)` and `hasOne(as=...)` accept a `foreignType` argument naming the polymorphic type column, so an underscore-shaped schema (`commentable_id` / `commentable_type`, as `t.references(polymorphic=true)` writes it with `useUnderscoreReferenceColumns`) works when both `foreignKey` and `foreignType` are passed. The default stays `type`
+- `renderNotFound(message, type, extendedInfo)` — a public controller helper for producing a 404 from app code: it sets the 404 status, shows the typed error in development (when `showErrorInformation` is on) and the `onmissingtemplate.cfm` page in production. Generated scaffolds now call it instead of the internal `$throwErrorOrShow404Page`, whose signature was never a public contract (that internal still works for apps generated by 4.1.2). It is a controller mixin (not a `wheels.Global` helper), so it is scoped to controllers and not added to models. Because it is a public controller helper it is now a reserved action name — an existing app with an action literally named `renderNotFound` would get `Wheels.ActionNotAllowed` instead of dispatching (no such action exists in the shipped examples or guides). An app that already defines its own `renderNotFound` controller helper transparently overrides this one, and generated scaffolds then call that helper with `message=`. ([#3900](https://github.com/wheels-dev/wheels/issues/3900))
+- Specs can assert how many SQL statements a call sends: `assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count)`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)`, in every `wheels.WheelsTest` spec. They count what the model layer sends (a query-cache hit sends nothing), take an optional `dataSource` filter, nest, return the callback's result, and list the statements on failure with each bound value as `?`
+- Models report what their last successful save wrote: `savedChanges()`, `hasSavedChange(property)`, `savedChangeFrom(property)` and `savedChangedProperties()`, the saved counterparts of `allChanges()`, `hasChanged()`, `changedFrom()` and `changedProperties()`, which a save resets. They're available from the save's `after*` callbacks on, and each `afterCommit` sees the save that queued it, so an `afterCommit` can act on what a commit changed (for example, announce a record once each time it ends up published) without a hand-kept flag. An `afterRollback` sees the save that was rolled back, after which the previous state is restored. `reload()` and `clearChangeInformation()` clear it
+- Job schedules (`config/schedules.cfm`, `schedule()`) now run from the normal job processing: every worker poll (`wheels jobs work`), `processQueue()` and `JobRunner.tick()` enqueue due schedule slots, at most once every `jobsScheduleCheckSeconds` (default 15; `0` turns it off), so no separate scheduler process is needed. Any number of servers can do it at once: each slot is still enqueued exactly once. An app without schedules is unaffected: the check only runs when the schedules table exists. `JobRunner.tick()` reports the slots it enqueued as `scheduled`.
+- New `strictArguments` setting reports an argument that a framework declaration doesn't know, instead of ignoring it: a typo or another framework's name such as `foreign_key`, `orderby` or a migration column's `nullable`. It checks associations, validations, callback registration, `property()`, `table()`, the finders (including dynamic finders), controller `caches()` / `protectsFromForgery()` / `provides()` / `usesLayout()` and migration column functions. `"warn"` (the development default) logs one `wheels.log` line per model or controller, function and argument, with a suggested name; `"throw"` raises `Wheels.UnknownArgument`; `"off"` (the default elsewhere) keeps the old behaviour
+- `TestClient` (`$testClient()`) sends the session's authenticity token to forgery-protected actions the way a browser does. It picks up the token from a page it fetched (the `csrf-token` meta tag from `csrfMetaTags()`, or a form's `authenticityToken` field) and sends it on POST, PUT, PATCH and DELETE as the `X-CSRF-Token` header and, on a form body, the `authenticityToken` field. The token is dropped when the session cookie changes, as at login, and a token or field the test passes itself wins. New methods: `csrfToken()`, `fetchCsrfToken(path)`, `withCsrfToken(token)` and `withoutCsrfToken()`.
+- `withAdvisoryLock()` now accepts an opt-in `transaction = true` argument that pins the lock acquire, the callback, and the release to one connection inside a transaction, so the lock genuinely covers the callback's own queries. On PostgreSQL the lock is transaction-scoped and auto-releases when the transaction ends; on MySQL and SQL Server it is a session lock released before the transaction closes. Supported on PostgreSQL, MySQL, and SQL Server; other databases throw rather than silently running session-scoped. The lock is released even when the callback ends the request with `abort` or `redirectTo()`. Recommended for short critical sections — a long-running callback holds its row locks and a pooled connection (and blocks PostgreSQL VACUUM) for the whole call. Defaults to `false` (the existing behaviour). ([#4198](https://github.com/wheels-dev/wheels/issues/4198))
+- `wheels upgrade check` checks the 4.1 → 4.2 items when an app below 4.2.0 targets 4.2.0 or later, and points at the 4.1 → 4.2 guide instead of reporting "no new breaking changes". Items that fail at runtime on 4.2 (an environment selected without `WHEELS_ENV`, an invalid `joinType`, `condition`/`unless` shapes 4.2 rejects, a `renderNotFound` route, `tinyInt1isBit=false`) fail the check; the rest (event templates, `/vendor` in `.gitignore`, `returnAs="structs"`, `X-Forwarded-Proto` without `trustProxyHeaders`, and others) are advisory.
+- `wheels upgrade check` (4.1 → 4.2) adds three advisories: app code that reads `persisted` from a job result (4.2's `enqueue()` throws `Wheels.Job.EnqueueFailed` instead), a `tests/runner.cfm` that doesn't include the built-in `wheels/tests/app-runner.cfm`, and, as lines to review, `where` conditions, query-builder calls and dynamic finders that compare with an empty string, which 4.2 binds as `''` instead of NULL. Each names the file and line, with comments stripped first.
+- `wheels upgrade check` names each framework fix that lives in the app-owned `public/Application.cfc` and is missing from the app's copy (session cookie settings, the `onError` DI guard and running-app message, the test-context include, the jBCrypt load path, the `.env` boolean parser, the reload helpers, header fallback and subfolder redirect), with the guide section for each. A missing `onApplicationEnd()` / `onSessionEnd()` `arguments.applicationScope` routing fails the check, because on Adobe ColdFusion it can leave the whole site erroring after a shutdown.
+- `wheels lookup ` looks up the Wheels API reference and the guides offline, and so does the new `lookup` MCP tool. A function name (`findAll`, `model.findAll`) prints its full entry: signature, parameters, example and link. A phrase (`nested resources`) lists the best-matching guide sections and functions, each with a link and an id that prints the whole entry. The CLI ships a docs index for its own framework version, built at release and snapshot time, so no network, server or project is needed.
+
+### Changed
+
+- On MySQL, `t.boolean()` / `addColumn(columnType="boolean")` now create a `BIT(1)` column instead of `TINYINT(1)`. A DSN with `tinyInt1isBit=false` reported `TINYINT(1)` as a plain integer, so the model validated boolean properties as numbers ("is not a number"). Existing `TINYINT(1)` columns are unchanged: drop `tinyInt1isBit=false` from the DSN, or convert the column with `changeColumn(columnType="boolean")`. `BIT(1)` values read back as `1` on Lucee and Adobe and as `true` on BoxLang; both are truthy and compare equal in CFML (#3897)
+- On Oracle 23ai and later, `t.boolean()` / `addColumn(columnType="boolean")` now create a native `BOOLEAN` column instead of a numeric column, and the model maps a `BOOLEAN` column to `cf_sql_bit`. A numeric column reached the model as a number, so automatic validation rejected `true` ("is not a number"). Oracle releases before 23ai get `NUMBER(1)` (#4097). Existing numeric boolean columns are unchanged: Oracle cannot change the datatype of a non-empty column, so convert one by adding a `BOOLEAN` column, copying the values and dropping the old column. A `BOOLEAN` value reads back as `1` / `0` on Lucee and Adobe CF and as `true` / `false` on BoxLang (#3897)
+- An empty string in a `where` value now binds as a real empty string instead of SQL NULL, so `count(where="col = ''")` matches rows that store `''`, and `col <> ''` / `col != ''` match every other non-NULL row. Before, all three compared against NULL and matched nothing. This applies to `findAll()` / `count()` and the other finders, `updateAll()` / `deleteAll()` `where` conditions, dynamic finders such as `findOneByName("")` and the query builder, so `updateAll()` and `deleteAll()` with such a condition now change those rows. Use `IS NULL` / `IS NOT NULL` (or `whereNull()` / `whereNotNull()`) to match NULL. Lookups keyed on request values, such as `findOne(where="token = '#params.token#'")` or `findOneByToken(params.token)`, now match a row that stores `''` when the value is empty, so check required values first. The unquoted `NULL` keyword still binds as NULL; an empty value whose column binds as a number, date, time or boolean still matches nothing (SQLite binds its text datetimes as strings, so they follow the new behaviour); and Oracle, which stores `''` as NULL, is unchanged (#4055)
+- A migration that uses a column type the database adapter doesn't map (for example `t.column(columnName="payload", columnType="jsonb")`) now fails at migrate time with `Wheels.Migrator.UnsupportedColumnType`, naming the type and the adapter and listing the supported types, instead of emitting a column with no type; that typeless column was a DDL syntax error on most databases, a raw missing-key error on Oracle, and on SQLite a column whose model failed to load (#4095)
+- Instances that share a database now run its migrations one at a time: `migrateTo()`, `migrateToLatest()`, `migrateIndividual()` and `redoMigration()` hold a cross-process lock, a lease row in the new `wheels_migrator_locks` table, on every supported database. An instance that waits re-reads which migrations are pending once it has the lock, so it doesn't run them again. It gives up after `migrationLockTimeout` (default 300 seconds) with `Wheels.MigrationLockTimeout`, naming the host holding the lock. The lease (`migrationLockLease`, default 3600 seconds) is renewed before each migration step, and a holder that died frees the lock when its lease runs out; a single migration step that runs longer than the lease can lose the lock to another instance (`Wheels.MigrationLockLost`). To clear a stuck lock at once, run `wheels migrate unlock --force` or delete its row from `wheels_migrator_locks` (#4134)
+- `singularize()` returns `psychoanalysis` for `psychoanalyses` instead of the misspelled `psychoanalyasis`; other `-ses` words such as `bases`, `theses` and `analyses` are unchanged (#4187)
+- `singularize("databases")` returns `database` instead of `databasis`, so a name such as `Databases` or `userDatabases` now singularizes to `Database` / `userDatabase`. An app that relied on the old `databasis` output, for example a model class or table named after it, needs to rename it. That's unlikely, but check before upgrading. Other `-ases` words (`bases`, `cases`, `phases`, `purchases`, `vases`) are unchanged (#4189)
+- On MySQL and PostgreSQL, `withAdvisoryLock()` on a tenant (non-shared) model takes its lock on the active tenant's datasource instead of the application's default one, like the model's queries, SQL Server and `withAdvisoryLock(transaction = true)`. Two tenants on different databases using the same lock name no longer exclude each other, and no longer wait for each other inside one application (on MySQL, whose lock names are server-wide, tenant databases on the same MySQL server still share them); for one lock across all tenants, call `withAdvisoryLock()` through a shared model. The lock queries also build their connection options like other model queries, leaving out an empty username and password (#4223)
+- On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type. A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, matched no row, because every engine sends the value as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. An `=` or `IN` condition with such a value can now match the row that holds it, and a `<>` or `NOT IN` condition now leaves that row out, as SQL Server does with a literal: `createdAt <> '2026-01-02 10:00:00.001'` used to return a row stored as `10:00:00.000` and now doesn't, because `.001` rounds to `.000` in a `DATETIME`. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before (#4326)
+- Soft-deleting a record no longer destroys or unlinks `dependent=` children that a restore would need. Children whose model has a soft-delete column are soft-deleted with it, as before. Other children are left alone: `dependent="delete"` / `"deleteAll"` no longer hard-deletes them and `dependent="remove"` / `"removeAll"` no longer sets their foreign keys to `NULL`. On a soft delete, `dependent=` now applies to them only on a real delete (#4370)
+- `delete()`, `deleteAll()`, `deleteOne()` and `deleteByKey()` with `softDelete=false` are always real deletes. They now also remove rows that were already soft-deleted, and the `dependent=` children of a deleted record, without `includeSoftDeletes=true`. An explicit `includeSoftDeletes=false` no longer keeps them. With `instantiate=true` those rows now run their delete callbacks too (#4371)
+- `reload()` throws `Wheels.RecordNotFound` when the object's row no longer exists, instead of silently blanking every property. Uncaught in a controller action, that renders the 404 page. An object with no key (unsaved, or saved on a database that returns no generated key, such as Oracle) is left as it is, where it used to have every property blanked (#4372)
+- When a Wheels-managed transaction rolls back because its method threw, `afterRollback` callbacks now run after the transaction block has ended, as they already did for a rollback without an exception. A callback's database writes on this path are kept instead of being discarded with the transaction, and a callback that throws doesn't replace the method's exception
+- App test runs keep the runner's 30-minute request timeout: the `tests/populate.cfm` that `wheels new` creates no longer lowers it to 300 seconds, and the runner restores its own limit after an app's `populate.cfm` runs. A run that is stopped before it finishes reports an error (and says when the request timeout stopped it) instead of 0 passed, 0 failed, 0 errors.
+- `wheels browser install` now runs `wheels browser setup` (an alias, as `wheels packages install` is of `add`). It used to print that LuCLI intercepts the command and it won't reach Wheels, then do nothing; it does reach Wheels, since LuCLI intercepts `install` only as the first argument.
+- Browser specs that do not run no longer count as passed. Without Playwright (or in CI without `WHEELS_BROWSER_CI_ENABLE`), each `browserDescribe()` spec is reported as skipped, with the reason. When Playwright is installed but the browser cannot start, each one is reported as an error with the launch message, unless `WHEELS_BROWSER_SKIP_LAUNCH_FAILURES=true` asks to skip them. `wheels test` now counts skipped specs (`3 passed, 1 skipped`, not green) and lists each skip reason.
+- `insertAll()` and `upsertAll()` now honour their `transaction` argument like `save()`. With the default (`commit`), all of a call's batches run in one transaction, so a large import is all-or-nothing and a failing batch no longer leaves the earlier batches behind. Pass `transaction="none"` for the previous per-batch commits; `rollback` keeps nothing
+- The `CLAUDE.md` that ships with apps (from `wheels new`, the starter app and the core package) is now short enough for AI coding tools that cap or skip the middle of long files: conventions, common mistakes, testing and a topic index, under 8 KB (it was over 32 KB). The reference topics (models, routing, views, caching, middleware, DI, auth, storage, packages, migrations, jobs, SSE) moved unchanged to `.ai/.md`, and `AGENTS.md` tells agents to open a topic file when the task reaches it. Existing apps can copy `CLAUDE.md`, `AGENTS.md` and `.ai/` from a new `wheels new` app. The build check now enforces the size budget and that every topic file is linked from the index. (#3960)
+- Global helpers (`app/views/helpers.cfm`) are now available to a controller once Wheels initializes it, rather than as soon as the object exists. A controller built directly with `new` or `CreateObject()`, or code in a controller's own pseudo-constructor (outside its functions), no longer sees them. Call global helpers from `config()` or an action instead.
+- When `config/settings.cfm` sets `dataSourceName` and not `coreTestDataSourceName`, `coreTestDataSourceName` now defaults to that `dataSourceName` instead of a datasource named after the folder of the app's front controller. An app that ran the framework test suite on a datasource with that folder's name must now set `coreTestDataSourceName` to it explicitly.
+- When a run of the framework's own test suite (`/wheels/core/tests`) would use the app's primary datasource, it now needs `_test` and ignores `allowTestsAgainstPrimaryDatasource`: without the test datasource the run is refused and `wheels.log` notes that the setting was ignored, because the suite's `populate.cfm` drops and recreates tables. An explicit `useTestDB=false` still runs it on the primary datasource. Runs that pick another datasource (`?db=`, or a `coreTestDataSourceName` that isn't the primary) are unchanged, and app test runs keep honouring the setting. (#4254)
+- From Wheels 4.2, CSRF protection accepts a valid `X-CSRF-Token` header on any non-GET request. Earlier versions also required `X-Requested-With: XMLHttpRequest`, which Turbo and `fetch()` don't send. When a request carries both a form field and a header, it passes if either holds a valid token, as in Rails. `startFormTag()` and `buttonTo()` gain `authenticityToken=false` to leave the hidden field out. Use it for every form in cached markup that other visitors receive, and supply the token at request time from `csrfMetaTags()`; the caching guide shows how (#3959)
+- Authenticity tokens are now compared exactly on every engine: the submitted value must be the whole session token (#3959)
+- **Breaking:** `wheels deploy` now requires an accessory `port:` to name a bind address (the rule Kamal's unreleased `main` adopted): `"::"` with an IPv4 or bracketed IPv6 address, optionally ending in `/tcp`, `/udp` or `/sctp` (e.g. `"10.0.0.20:5432:5432"` or `"127.0.0.1:5432:5432"`). A bare `port: 5432` or `"5432:5432"` now fails validation with a message naming the accessory; before, it went to `docker run --publish` as written and published on a random host port or on every interface. The published value is also quoted like the other docker arguments (#4067)
+- Migrations: an empty `default` on a column that is not string-like (`bigInteger`, `binary`, `uniqueidentifier`, `integer`, `decimal`, `float`, `date`…) now means "no default". It renders as `DEFAULT NULL` when the column allows null and as no `DEFAULT` clause on a `NOT NULL` column, in `createTable()`, `addColumn()` and `changeColumn()` alike. Before, `bigInteger` and `uniqueidentifier` produced a bare `DEFAULT` (a syntax error on every database for `NOT NULL` columns, and on PostgreSQL in every case), `binary` produced `DEFAULT ''` (rejected by MySQL), and `DEFAULT NULL NOT NULL` was rejected by MySQL.
+- **Breaking:** `findAll(returnAs="structs")` (and `"struct"`) now returns an array of structs, as documented, instead of a struct keyed by row number (`{1: {...}, 2: {...}}`). It serializes to a JSON array, and `ArrayLen()` works on it. Code that indexes rows (`rows[1]`) keeps working. Code that called `StructCount()`, `StructKeyList()` or `StructKeyArray()` on the result, or looped over its keys (`for (key in rows) { rows[key] }`), must switch to `ArrayLen()` and `for (row in rows)`. `findInBatches(returnAs="structs")` batches change the same way. (#4003)
+- `findOne(returnAs="struct")` and `findByKey(returnAs="struct")` return the record's struct instead of a `{1: {...}}` wrapper, and `findEach(returnAs="struct")` now calls its callback for each record (it used to process nothing). With no match they return an empty struct, as before, and so does the query builder's `findOne(returnAs="struct")` after `offset()`, including an offset past the end, which used to return `false` (#4003)
+- `wheels generate auth --strategy=token` indexes the `apiTokenDigest` column it looks every request's token up by, and its next steps say how to create the first account and how to protect actions. The app's `.ai/auth.md` gains a "Token APIs" section with a seed for the first account and a filter that requires a bearer token.
+- `wheels generate migration` still writes a blank migration, but a name that reads like a column change now gets a next step: `AddEmailToUsers` (or `add_email_to_users`) points to `wheels generate property User email:string`, which writes the `addColumn()` for you, and `RemoveXFromY` shows the `removeColumn()` call. Its usage example is now `BackfillUserSlugs`.
+- Guide links in framework and CLI messages (the plugin deprecation notices, the `wheels.Test` deprecation, the `/wheels/mcp` deprecation, `wheels upgrade check`, `wheels mcp setup` and the install hints) are built from the running version, so a 4.1 app links to the 4.1 guides instead of always the 4.0 ones. Links never point at a version whose guides haven't been published yet, and fall back to the latest guides for a dev build. (#3931)
+- The background-job stale-processing reaper now reaps each job on **the timeout of the worker that claimed it**, not the polling worker's. The claiming worker records its `timeout` in a new `wheels_jobs.claimTimeout` column (added to the table automatically, including to tables created before this change — probed on every `$ensureJobTable`, never a cached flag), and `checkTimeouts` builds each row's grace window from that value. Workers configured with **different `timeout` values can now share a queue** — a short-timeout worker won't reap a job a longer-timeout worker is still legitimately running. A row with no recorded `claimTimeout` (claimed before this change, or on a database where the column can't be added) falls back to the polling worker's timeout, so nothing breaks. Builds on #3888/#3984. ([#3989](https://github.com/wheels-dev/wheels/issues/3989))
+- Job schedules: a cron step on a single value (`N/S`) now runs from N to the end of the field, as in Vixie cron and cronie. `5/1` in the minute field was minute 5 alone and is now `5-59`. In day-of-week, `N/S` now runs through `7` (Sunday), so `1/2` adds Sunday, and `7/2` (Sunday) is accepted instead of refused as a reversed range. `5/15`, `N-M/S` and `*/S` are unchanged.
+- A job enqueued inside a Wheels-managed transaction on another datasource than the job store's (a tenant datasource, or a model with its own `dataSource()`) is now written to the job store when that transaction commits, and dropped if it rolls back. Before, depending on the engine, such a job could outlive a rolled-back transaction (BoxLang), be written to the tenant's database where no worker reads it (RustCFML), or not be written at all (Adobe ColdFusion). `enqueue()` returns `{status: "deferred", deferred: true, persisted: false}` in that case. On the application's own datasource, a job still joins the open transaction.
+- `enqueue()`, `enqueueIn()` and `enqueueAt()` throw `Wheels.Job.EnqueueFailed` when the job cannot be written to the job store, instead of returning `persisted: false`, which callers rarely checked, so failed enqueues went unnoticed. One cause is enqueueing inside a raw `transaction {}` block on another datasource (a tenant's), which some engines refuse; inside a Wheels-managed transaction the write now waits for the commit. If an enqueue is best-effort in your app, wrap it in `try` / `catch (Wheels.Job.EnqueueFailed e)`.
+- Background-job timestamps (`runAt`, `createdAt`, `updatedAt`, `completedAt`, `failedAt`, `heartbeatAt`, and the host registry's times) are now stored in UTC, read from the database's own clock, so every server that shares the job tables uses one clock: a server whose clock runs ahead or behind no longer reaps a live job early or picks up a delayed job late. The database's UTC time is read at most once a minute per application (about one-second precision); if a read fails the last good reading is kept. Timestamps are built by the database from integers, so servers in time zones with daylight saving are handled across the clock changes. H2 and SQLite use the server's own clock (both are single-host databases). **H2 requires the JVM to run on UTC:** H2 1.4 does timestamp arithmetic in the JVM's time zone. The API stays in local time: `enqueueAt()` takes a local date/time, and `JobRunner.status()` and the queue stats report local times. **Upgrading:** on SQL Server, MySQL, PostgreSQL, CockroachDB, Oracle and H2, rows written before this release hold the server's local time, so on servers that don't run in UTC either upgrade once the queue is empty (no pending or processing jobs; otherwise a delayed job runs early or late by the offset, and on a server behind UTC an in-flight job looks stale and is reclaimed), or set `this.convertLocalOffsetMinutes` in the `wheels jobs install` migration to the old servers' offset from UTC to convert existing rows once (in one transaction; running it again changes nothing). SQLite rows and servers already running in UTC need neither.
+- The Wheels CLI's runtime is LuCLI 0.6.2.2. `wheels start` and `wheels server run` now listen on `127.0.0.1`, so a dev server is reachable only from your own machine. To reach it from a phone, a VM or another computer, start it with `--host 0.0.0.0`, or set `"bindAddress": "0.0.0.0"` in `lucee.json`; the startup output then warns that the server is reachable from your network. JMX monitoring (`monitoring.enabled`) listens on the same address, and LuCLI refuses to start unauthenticated JMX on a non-loopback address. Clients that resolve `localhost` only to `::1` need `http://127.0.0.1:/`
+- The Wheels CLI's runtime is LuCLI 0.6.2.3. `wheels --env=` (and `--envfile`) now reach the command instead of being dropped; `wheels start --dry-run` no longer writes a default `lucee.json` and its preview shows `--name`; `wheels --timing cfml '…'` (a root option before a root subcommand) runs the subcommand; REPL history and `settings.json` stay inside the CLI's home instead of leaking into `~/.lucli`; `run .cfm` no longer prints the built-in variables setup; hints name `wheels` rather than `lucli`. Over MCP, `generate type=app` is now refused as intended: LuCLI tells the Wheels module that a call came from an MCP tool (see #3980).
+- The stdio MCP server no longer lists the `create` tool (hidden like `new`, so an MCP call to it is refused), and `generate` no longer advertises `app` as a `type` choice. A client that ignores the advertised schema can still send `generate` with `type=app`; refusing that server-side is tracked in #3980. `create`'s advertised type+name schema also never matched the `new` options it forwards (such as `port`). `wheels create app` and `wheels generate app` on the command line are unchanged (#3910)
+- `wheels generate model` / `scaffold` now treat columns as **required by default**: an unmarked `name:type` column is created `allowNull=false` and added to `validatesPresenceOf`, so the generated model and migration agree. Mark a column nullable with `name:type:optional` (excluded from `validatesPresenceOf`), or give it a default with `name:type=value` (also excluded — the default fills an absence); the two combine as `name:type=value:optional`. A `belongsTo` foreign-key column is required by default. Previously every column was added to `validatesPresenceOf` even though the migration made it nullable, so a freshly generated model rejected any row that left a blank column. `:optional` uses the word rather than `?` because zsh (the macOS default shell) treats a bare `?` as a glob.
+- On MySQL, `t.bigInteger()` now creates a signed `BIGINT`, like every other database, instead of `BIGINT UNSIGNED`, so the column accepts negative values and validates as an integer (it used to bind as a decimal and accept fractions). Existing columns are unchanged, and `changeColumn()` keeps a column's current signedness. `t.bigInteger()` and `t.integer()` take a new `unsigned = true` option (MySQL only; ignored elsewhere) for a foreign key to an existing unsigned id, which MySQL otherwise rejects as incompatible (#4121)
+- `wheels new` apps now commit `vendor/`: the generated `.gitignore` no longer ignores it, so a clone, CI run or image build has the framework (`vendor/wheels/`) and every package added with `wheels packages add`. Before, a fresh clone had no framework and no command to restore it. In an existing app, remove the `/vendor/` line from `.gitignore` and commit `vendor/`.
+- `config/routes.cfm` in a new app (`wheels new`) declares the root route before `.wildcard()`, with the wildcard last, as the routing docs recommend. Existing apps are unaffected; the root route matches either way.
+- `wheels new` (and `wheels create app`) now writes `.mcp.json` and `.opencode.json` in the new app, the files `wheels setup agents` writes, so an AI assistant opening the app finds the Wheels MCP server without a second command. Pass `--no-agents` to skip them. If they can't be written, `wheels new` warns and still creates the app.
+- On Oracle, new `t.integer()`, `t.references()` and `id` columns are created as `NUMBER(10)`, new `t.bigInteger()` columns as `NUMBER(19)`, and new `t.boolean()` columns before Oracle 23ai as `NUMBER(1)`, instead of a bare `NUMBER`. They bind and validate as integers, so a new integer column no longer accepts a fractional value. Existing bare `NUMBER` columns keep their current typing, and `changeColumn()` without `precision` keeps a column's current precision, so changing a default or `allowNull` never re-types it (Oracle cannot narrow a populated column); the migrations guide shows how to convert one (#4097)
+- **Breaking:** an absolute URL's `http`/`https` scheme (`urlFor(onlyPath=false)`, asset URLs, anything through `$prependUrl`) now honours the client-controlled `X-Forwarded-Proto` header **only** when `set(trustProxyHeaders=true)` is enabled — matching `isSecure()`. In 4.1.x the header set the scheme regardless of the opt-in, so an app not behind a trusted proxy could be induced to emit `https://` absolute URLs from a spoofed header. The real socket TLS state (`SERVER_PORT_SECURE`) still always wins, unconditionally. **Apps behind a trusted TLS-terminating reverse proxy must set `set(trustProxyHeaders=true)`** before upgrading, or their absolute URLs will fall back to `http://`. The one-time deprecation warning added in 4.1.2 ([#3835](https://github.com/wheels-dev/wheels/pull/3835)) is removed. ([#3836](https://github.com/wheels-dev/wheels/issues/3836))
+- Production error pages (`showErrorInformation` off, the default outside development) now return the correct HTTP status for Wheels errors — `404` for a client-triggerable `Wheels.*NotFound` / `ActionNotAllowed`, `403` for `NotAuthorized` / an invalid CSRF token, `406` for `FormatNotAcceptable` — instead of `500` for every error. The status classification previously ran only on the development (`showErrorInformation` on) rendering path (follows #3075, which fixed the same mapping for development)
+- Test runs no longer use the app's primary datasource through a project `tests/runner.cfm`: a runner of your own, including one copied from an older Wheels release, now gets the same `_test` requirement as the built-in runner. The run uses `_test`, or is refused unless `useTestDB=false` (`wheels test --no-test-db`) asks for the primary datasource.
+- A date passed to the query builder (`where()`, `whereBetween()`, `whereIn()`) or a dynamic finder (`findOneByPublishedAt(date)`) now compares as a date on SQLite. It used to reach the SQL as its CFML literal (`{ts '...'}`), which never matched a stored date, so the query silently returned no rows. Other databases already parsed that literal; they now receive the plain date text too.
+- The starter app's `public/Application.cfc` is synced with the app template, picking up the template's current request, error-page and session-cookie handling and the jBCrypt load path.
+- The `wheels-starter-app` release zip now includes the framework in `vendor/wheels/` and runs on the Wheels CLI with an embedded SQLite database: unzip, copy `.env.example` to `.env` and set its passwords, then `wheels start`, `wheels migrate latest` and `wheels reload`. Under CommandBox (`box server start`) it still uses H2, with no `box install` step.
+- A test run that switches the datasource in the live application scope, because the app's `public/Application.cfc` lacks the `vendor/wheels/events/testcontext.cfm` include (or `WHEELS_ENV` isn't development or testing), now logs a warning to `wheels.log` once per run: until the run ends, other requests to the app read and write the test database. The warning names the fix, and the `wheels upgrade check` advisory for the missing include now says the same
+- `wheels upgrade apply` now parks the old framework at `.wheels/backups/wheels.bak-/` in the project instead of `vendor/wheels.bak-/`. Inside `vendor/`, the backup was loaded as a package and logged a skipped-package error on every boot, and it was committed along with `vendor/`. The backups directory carries its own `.gitignore`. The output names the new location.
+- `wheels upgrade check` names the file it checked when it reports missing code, for example `public/Application.cfc (no occurrences found)`, instead of a bare `(no occurrences found)`.
+- A failed `verifies()` that declares no `handler` and no redirect now ends the request with **HTTP 400** instead of a silent `200` with an empty `text/html` body — the declared precondition was not met. A JSON-format request receives a `{"error":"Verification failed"}` body; any other format receives an empty body (like a bare status). Verifications that declare a `handler` or redirect arguments are unchanged.
+
+### Removed
+
+- The "Convert dot to format parameter" rule is removed from the shipped `urlrewrite.xml` files (the app, the `wheels new` template and the examples). Its regex had doubled backslashes, so it never matched a normal URL; format suffixes such as `/posts.json` keep working, because the router reads them from the path. Existing apps can delete the rule from their own `public/urlrewrite.xml` (#4132)
+
+### Performance
+
+- The RustCFML engine adapter now pins `stringIndexIsLinear()` to false explicitly, so the WHERE-clause literal masker always takes the linear char-array path on RustCFML. Previously this relied on the Base adapter's `java.lang.String` capability probe failing there; because RustCFML already shims part of the `java.*` surface, a future build that shimmed `java.lang.String` would have flipped the probe and silently reverted RustCFML to the O(n²) index scan. The explicit override removes that risk (follow-up to #3903)
+- Binding a long string value in a `where` string, a finder or the query builder no longer slows down sharply with the value's length: the WHERE literal scanner now jumps between quotes instead of reading one character at a time. On RustCFML a long value is linear again; a long run of doubled quotes or a very large IN list is still super-linear there because of the engine's own string indexing (#3903)
+- The WHERE-clause literal scanner (`$maskWhereLiterals`, run by every dynamic finder, key finder, chainable query-builder call and uniqueness validation) is now linear on every engine. It previously walked the string one character at a time with `Mid()`, which costs O(index) on RustCFML, so a long bound value — or one with many quotes, or a large `IN` list — was O(n²): a 30,000-character value took ~620ms and 100,000 characters ~22s. It now reads the string once into a character array and walks that with O(1) indexing on the engines whose `REMatch` round-trips the input faithfully (Lucee, Adobe, RustCFML), and falls back to a single-pass `Find()`-jump scan on engines where it does not (BoxLang drops whitespace matches). The character-array path builds its short ODBC-date lookahead window inline rather than passing the whole array to a helper, because Adobe CF copies an array argument by value on every call, which would reintroduce O(n²) there. Such values now bind in linear time on every supported engine (#3903)
+- Creating a controller instance, which happens on every request, no longer mixes the framework's controller and view methods in one at a time. Each controller class works out once which methods to add and which `super` aliases to create, and every later instance applies that result with two `StructAppend` calls. Measured per instance in the same process: 4.8 ms → 1.4 ms on RustCFML (the CI-pinned v0.693.0), 2.3 ms → 0.9 ms on Adobe ColdFusion 2023, 1.7 ms → 0.8 ms on Adobe ColdFusion 2025, 0.58 ms → 0.33 ms on Lucee 7 and 4.7 ms → 3.6 ms on BoxLang. Development keeps the old per-method path, so controller methods edited there still take effect without a reload; the new `cacheControllerIntegration` setting controls it (#4149)
+- `pluralize()` and `singularize()` cache their results per application, so a repeated word no longer rebuilds the irregular and uncountable lists and the rule table, or runs the rule regexes. Measured per call: 86 µs → 5 µs on RustCFML, about 300 µs → 10 µs on Adobe ColdFusion 2023 / 2025, 152 µs → 12 µs on BoxLang and 25 µs → 5 µs on Lucee 7. The cache is cleared when `set()` changes `uncountables` or `irregulars`, notices a word added to or removed from those settings directly (change an existing irregular through `set()`), is skipped when the current tenant overrides them, keeps a case variant of a word separate, caches only words of up to 100 characters and holds at most 2,000 entries (#4150)
+- `linkTo()` and `URLFor()` do less work per call on JVM engines (Lucee, Adobe, BoxLang): `URLFor` skips a redundant argument-normalisation pass when `linkTo` already resolved the arguments, route-variable substitution uses literal string operations instead of a regular expression per variable, and `$tag` resolves the HTML-attribute-encoding setting once per tag rather than once per attribute. The generated output is byte-for-byte unchanged. ([#4151](https://github.com/wheels-dev/wheels/issues/4151))
+- Creating any model object or controller no longer calls `GetMetadata(this)` while the object is being constructed. The memo of include-injected functions to promote to `this` always ended up under one entry (`wheels.Global`) on every supported engine, so it now uses that key directly. On RustCFML the call cost about 225 µs per object; model `new()` went from 1.30 ms to 1.13 ms there, and from 0.31 ms to 0.25 ms on Lucee 7 (#4174)
+- An app without plugins no longer pays for plugin mixins on every model and controller instance: `$initializeMixins` now returns early when the component's class has no mixins, instead of copying the whole `variables` scope into `variables.core` for nothing. `new()` and each row of a `returnAs="objects"` finder are about 12% faster on Lucee 7 and 8% faster on RustCFML (#4382)
+- Route matching scans only the routes that can match a request: those allowing its HTTP method whose first two path segments match the request's, plus routes keyed less specifically, in declaration order. Which route matches is unchanged. On RustCFML, whose regex cache holds 256 patterns before v0.711 and 1,024 from v0.711, this also keeps an app with more routes than that from recompiling a regex per route on every dynamic-route request.
+
+### Fixed
+
+- A bare `true` or `false` in a finder `where` string (`where="isActive = true"`) is now bound as `1` / `0` instead of failing with "cfqueryparam cannot be determined", and the query builder and dynamic finders (`where("isActive", true)`, `findAllByIsActive(true)`) now work against native boolean columns such as PostgreSQL's `boolean` (#3896)
+- An IN list longer than 1000 values (`whereIn()`, `whereNotIn()` or a hand-written `IN (...)`) now runs on Oracle 19c and earlier, which accept at most 1000 values per list (`ORA-01795`): Wheels splits it into parenthesised groups, OR-joined for `IN` and AND-joined for `NOT IN`, with every value still bound. On SQL Server, which binds at most 2100 parameters per request (2097 once the driver has taken its own), a query that would bind more than 2097 now fails before it runs with `Wheels.TooManyParameters`, naming the limit and the count, instead of the driver's "too many parameters" error (#3906)
+- A long integer `IN` list (`whereIn("id", keys)` or `where="id IN (1,2,...)"` with a few thousand values) no longer throws `java.lang.StackOverflowError` while the `where` string is parsed (#3907)
+- On Oracle, comparing a date or timestamp column with a value written into the SQL text (`parameterize=false`), such as `where="createdAt > {ts '2000-01-01 00:00:00'}"` or `where="createdAt > '2000-01-01'"`, now returns the matching rows: an ISO value is written as an ANSI `TIMESTAMP '...'` / `DATE '...'` literal, which reads the same under any session date format, instead of a quoted string that fails with `ORA-01843` (#4084)
+- A nested-properties struct key outside the range of a SMALLINT or TINYINT primary key (for example `40000` for a SQL Server SMALLINT key) is now treated as a new row, as it already was for INTEGER keys, instead of being looked up with `findByKey()` and rejected at bind time on Adobe ColdFusion. Each adapter gives the range for its column types: SQL Server's TINYINT is 0 to 255, and MySQL covers signed and UNSIGNED columns (#4087)
+- On SQLite, `t.date()`, `t.datetime()`, `t.time()` and `t.timestamp()` now declare `DATE` / `DATETIME` / `TIME` columns instead of `TEXT`, and the model validates columns with those declared types as dates. Automatic validations used to see a string and accept a value like `"not a date"`. Values are still stored as ISO-8601 text. A declared `DATE` or `TIME` column now binds as text too, instead of being stored by the driver as epoch milliseconds. Existing `TEXT` date columns are unchanged and keep the column-name check; convert one with `changeColumn(columnType="datetime")`. A column declared `DATE` or `TIME` outside the migrator may hold old epoch-millisecond values: convert them first, as the 4.1 to 4.2 upgrade guide shows (#4093)
+- The `channel` `DatabaseAdapter.poll()` no longer returns events out of publish order when two events share a `createdAt` timestamp — common on SQL Server, MySQL, and Oracle, whose datetime columns round to coarse ticks, and possible anywhere on a same-millisecond publish. Events now carry a database-native monotonic sequence (SQLite uses its built-in `rowid`; an unrecognised database falls back to `createdAt` + `id`), `poll()` orders by it, and `lastEventId` resumes strictly after the referenced event — which also stops a same-tick event published before it from being re-delivered, and falls back to the `since` window when the referenced event has already been cleaned up. Existing `wheels_events` tables gain the column automatically (#4099)
+- The Postgres/CockroachDB readiness probe for the `wheels_events` monotonic `seq` column now scopes its `information_schema.columns` lookup to the current schema (`AND table_schema = current_schema()`), so a same-named `wheels_events` table in another schema of the same database can no longer make the probe read the wrong table's column metadata and misreport readiness (follow-up to #4106)
+- `wheels --help` prints only the command's summary line and its `Usage:` / `Examples:` sections. Internal notes no longer appear in the help for `reload`, `map`, `upgrade` and `packages`, `wheels coverage --help` and `wheels engines --help` print a one-line summary, and Usage/Examples lines are indented.
+- A `DECIMAL` / `NUMERIC` value with more than 15 significant digits is now stored and matched in a `where` exactly on PostgreSQL, SQL Server, MySQL and CockroachDB under Lucee and BoxLang, which bound it through a double and so wrote or compared a different number; such a value now binds in each database's exact form, and values with up to 15 significant digits keep their existing bind (#4172)
+- `pluralize()` no longer throws `Index -1 out of bounds` on BoxLang for words ending in `-ex`, `-lf` or `-rf` (`vertex`, `index`, `half`, `shelf`, `wolf`), including model names that derive their table name from them. Output on Lucee and Adobe ColdFusion is unchanged (#4185)
+- Fixed `$testClient()` and `BrowserTest` base-URL auto-detection so a loopback test request reaches the running server when the host port is mapped to a different internal port (e.g. inside a container). When a mapping is detected the base URL now uses the server's local listener — its scheme and port, via the loopback address — and is byte-for-byte unchanged otherwise. Explicit `testClientBaseUrl` / `browserTestBaseUrl` overrides still take precedence. ([#4195](https://github.com/wheels-dev/wheels/issues/4195))
+- `withAdvisoryLock()` now keeps a second caller out on Lucee and BoxLang with MySQL or PostgreSQL. The database lock belongs to the pooled session that took it, and a second caller in the same application could borrow that idle session, take the lock too, and leave it held after both finished. Callers in one application now wait on an application-server lock first, and the wait for the database lock gets only the time left of `timeout`, so the whole call waits about `timeout` at most. The release is verified against the session recorded when the lock was taken: retried for up to 5 seconds when it ran on another session, then reported with `Wheels.AdvisoryLockReleaseFailed` naming the lock. Another server taking the lock once ours is gone is not reported as a failure (#4197)
+- `wheels packages` help and messages no longer say `wheels packages install` doesn't work. `install` is an alias of `add`, and the help now says so; `add` stays the documented verb.
+- On SQL Server, `withAdvisoryLock()` works outside a transaction. It failed with "The statement or function must be executed in the context of a user transaction", because `sp_getapplock` took the lock for a transaction; the lock now belongs to the database session, as on MySQL and PostgreSQL, with the same check that the release freed it (#4220)
+- `wheels docs fetch` gives the bundle's checksum download 30 s instead of 300 s (the bundle keeps 300 s), and the CLI's server-detection commands (`lsof`, `netstat`) are stopped after 15 s instead of waiting indefinitely (#4232)
+- On SQL Server, a condition on a `TIME` column works on every engine. It errored on Lucee ("The data types time and datetime are incompatible") and BoxLang, and on Adobe ColdFusion it dropped the value's fraction of a second, so only whole-second values matched. A long time list past SQL Server's parameter limit now runs too, split like a long date list (#4327)
+- An object whose row is soft-deleted behaves consistently (#4372):
+ - After a soft `delete()`, the object's `deletedAt` holds the timestamp written. It goes back to its earlier value if the delete is vetoed or rolled back, so a retry works.
+ - A second soft `delete()`, or one from a stale copy of a row soft-deleted elsewhere, returns `false` and changes nothing. It no longer rewrites `deletedAt`, and it no longer soft-deletes the dependents of a row it didn't delete.
+ - `reload()` reloads a soft-deleted row instead of blanking every property.
+- `order="table.column"` (and `order="table.column DESC"`) is now quoted the same way as `order="column"`, so a table or column whose name is a reserved word works in the qualified form too. The table and column are matched, case-insensitively, against the model and its included associations and written under their real names; a qualifier that isn't one of those tables, such as an alias, is still passed through as written (#4374).
+- `wheels upgrade check` dates the `renderPage()` / `renderPageToString()` rename correctly: it happened in 2.0 (to `renderView()` and `renderView(returnAs="string")`), not 4.0. It also now flags a route that targets an action named `isSafeRedirectUrl`, which 4.2 made a framework helper: a request for that action gets `Wheels.ActionNotAllowed` (#4376).
+- `wheels migrate diff` on SQLite no longer reports DATETIME, DATE and TIME columns as changed to `string`, which made `--write` produce a migration converting them to strings (#4405)
+- The all-models `wheels migrate diff` covers every model file, not only the models a request had already loaded (#4406)
+- `wheels migrate diff --write` names its migration with the same 14-digit version as `wheels generate migration`, moving on a second while that version is taken (#4419)
+- `wheels db status` and `wheels db version --detailed` show when each migration was applied. The migrator stored the time, but the status report always left it blank, so the CLI printed `-` / `applied unknown` (#4407)
+- `wheels test --core` now reports the runner's own error (for example a missing core-test datasource) instead of "no test bundles ran for this scope", and the `--db`-on-app-suite warning names the real `--test-db` flag instead of a non-existent `--useTestDB` (#4408)
+- `wheels browser `, `wheels browser test` without Playwright installed, `wheels setup agents` outside a project, and `wheels generate snippets ` now exit non-zero instead of printing a refusal and exiting 0, so scripts and CI can detect the failure (#4409)
+- The console's `/routes` command no longer fails with a syntax error; it lists routes as `pattern -> controller#action` (#4410)
+- `wheels start` no longer changes `lucee.json` when it has nothing to start. Starting a server that is already running only reports it; it used to move the shutdown port on every repeat. `--dry-run` passes `--port` to LuCLI's dry run instead of writing it, and skips the registry clean-up, JDBC bundles, rewrite config and start token. `-p N` / `-p=N` work like `--port=N`, so the shutdown port is moved off the chosen port. `--dry-run` with `--engine=rustcfml` is refused, where it used to start the server (#4411)
+- `wheels start --env=` / `-e ` use the `lucee.json` environment profile, as `--environment=` already did. LuCLI takes `--env` / `-e` as its own root option, so `wheels start` now forwards the value (#4412)
+- `wheels new --setup-h2` no longer leaves SQLite datasource entries in `lucee.json`; the embedded-database choice in `config/app.cfm` and `lucee.json` now agree (#4413)
+- `wheels generate controller ` with no actions now writes an empty controller instead of an `index()` stub with no matching view, which 500s at runtime (#4414)
+- `wheels deploy build push` honours `builder.arch` (`--platform`, default `linux/amd64`, so an image built on an arm64 workstation runs on amd64 hosts), `builder.args` (`--build-arg` per entry) and `builder.remote` (builds on the service's buildx builder, which `build create` now creates on that endpoint). The image is labelled `service=`. `builder.arch` takes a list or a comma-separated string, and an entry that isn't a plain platform name (letters, digits, `.`, `_`, `/`, `-`) fails with `Wheels.Deploy.InvalidInput` (#4415)
+- `wheels deploy app logs` without `--container` tails each role's newest running container instead of emitting a `docker logs` with no container. `wheels deploy prune images` removes every unused image of the service (`--all`), not only dangling ones, matched by the label `build push` now sets; images built before this release carry no label (#4416)
+- `wheels deploy` argument handling (#4417):
+ - A space-separated flag value works like the `=` form: `--release v8` no longer reads `v8` as the subcommand, and `init --service myapp2` no longer falls back to the directory name. When several space-form values can't be told apart, or a value flag has no value, the command asks for `--flag=value` instead of guessing.
+ - An unknown `--role` fails with `DeployAppCli.UnknownRole` and names the roles in `deploy.yml`, instead of doing nothing and exiting 0.
+ - `deploy config` prints `ssh.port` and `proxy.app_port` as whole numbers (`22`, not `22.0`).
+ - `rollback` without a version and `exec` / `server exec` without a command throw the typed `DeployMainCli.MissingVersion` / `DeployServerCli.MissingCommand`.
+- CLI help and hints match the commands. `wheels coverage --help` lists `--top` and `--[no-]test-db`. `wheels destroy --help` says `--force` is what makes it delete (without it, destroy only lists). `wheels new --help` gives the lowercased app name as the datasource default. The `wheels packages` synopsis shows `remove --yes`. The seed-data snippet hint says to move the files into `app/db/` before running `wheels seed` (#4418)
+- The per-request finder cache (`cacheQueriesDuringRequest`) is now cleared across every model on any ORM write and on a transaction rollback, so a cached `include=` query no longer returns a joined model's pre-write columns — or a rolled-back transaction's phantom rows — for the rest of the request (#4429)
+- On RustCFML, with `flashStorage="cookie"`, a flash message stored with `flashInsert()` can be read with `flash()` in the same request. RustCFML reads the flash cookie back as the attribute struct it was assigned, rather than its value; the flash reader now uses the value. On every engine, an empty flash cookie now reads as an empty flash instead of one with an empty `action` entry (#4433)
+- The all-models `wheels migrate diff` also covers models in subfolders of the model path, named in dot notation (`admin.User`) as `model()` takes them (#4434)
+- `wheels generate snippets seed-data` writes `app/db/seeds.cfm` and `app/db/seeds/development.cfm`, where `wheels seed` reads them, instead of `app/snippets/`; existing seed files are kept unless `--force` is given (#4434)
+- The dependency injector no longer throws a spurious `Wheels.DI.CircularDependency` when two of a request's `cfthread`s resolve the same transient (or request-scoped) service at the same time; the circular-dependency guard is now keyed per thread while keeping its per-request scope (#4448)
+- A `.cfm` page in a subfolder of `public/` no longer fails with `can't find component [wheels.events.EventMethods]`. `public/Application.cfc` built its mappings, and `config/app.cfm` its SQLite and H2 paths, with `expandPath("../...")`, which resolves against the requested page's directory. The `wheels new` template now builds them from `this.wheels.projectRoot`, derived from `Application.cfc`'s own path. Existing apps make the same edit: see the 4.1 to 4.2 upgrade guide (#4470)
+- `Job.processQueue()` now runs jobs through the job worker, so the in-app path the guide documents is as safe as `wheels jobs work`. Every call first reaps jobs a dead worker left in `processing`, which it never did before. Each job is claimed with its own class's timeout recorded as `claimTimeout`, so a server polling with a shorter timeout no longer reaps it while it runs. Jobs still run with their own timeout, as before. The new optional `processQueue(timeout = n)` caps it. `queue` accepts a comma-delimited list. ([#4475](https://github.com/wheels-dev/wheels/issues/4475))
+- A background job whose claim was reaped as stale can no longer overwrite the attempt that replaced it. Every claim now writes a fresh `wheels_jobs.claimToken` (and the claiming host to `claimedBy`, both added to existing tables automatically), and a worker's completion, retry or failure applies only while the row still carries its own token. Before, a stalled worker that finished after its job was requeued and claimed again matched the new attempt's `processing` row: it marked the job `completed` (or requeued it) while the second attempt was still running, and that attempt's own result then matched nothing. A fenced result is discarded and logged as `Wheels.Job.Fenced`. `JobWorker.processNext()` returns `fenced: true` and `processQueue()` reports a `fenced` count. Covers both `JobWorker` and `processQueue()`. Fencing holds once every worker sharing the table runs this version. ([#4476](https://github.com/wheels-dev/wheels/issues/4476))
+- When the `wheels_jobs.uniqueKey` upgrade can't finish, the warning and the `Wheels.Job.UniqueKeyUnavailable` error now say which step failed (adding the column, the backfill, or building the unique index) and give the manual fix for the database in use. On SQL Server below compatibility level 100, where the filtered unique index can't be built, they name the level and the real options: raise it, or create a plain unique index once every server runs 4.2 or later. Previously both messages advised the filtered index, which fails the same way. ([#4489](https://github.com/wheels-dev/wheels/issues/4489))
+- On RustCFML, converting epoch timestamps to app-local time no longer goes wrong by the host's UTC offset when the engine runs outside UTC. The jobs clock (`enqueueAt()`, `JobRunner.status()`, queue stats) and other epoch-to-date reads now count from the epoch instant instead of local midnight on 1970-01-01. JVM engines were not affected (#4525).
+- On Adobe ColdFusion, a model write (`create()`, `save()`, `update()`, `delete()`) inside a raw `transaction {}` block no longer fails with "Nested cftransaction tag should specify same isolation level as the parent". When Adobe rejects the default `read_committed` level of a nested model transaction, the write now joins the outer block at its isolation level. An `isolation` you pass yourself is never dropped: if it differs from the outer block's, the call throws `Wheels.TransactionIsolationMismatch` explaining the fix (#4045)
+- On Adobe ColdFusion, Wheels now detects every model write inside a raw `transaction {}` block, not only one that is the block's first database work. `afterCommit`/`afterRollback` are skipped for those writes with the usual warning (instead of `afterCommit` firing for a write the block then rolls back). RustCFML still can't see a raw block (#4068)
+- The `AGENTS.md` that `wheels new` ships no longer tells assistants to prefer MCP only "when `.mcp.json` is configured", a file a new app doesn't have. It now says to prefer the Wheels MCP tools when the client exposes them, and how to enable them (`wheels setup agents`, or the config snippet by hand), with the `wheels` CLI until then (#3971)
+- `wheels generate api-resource` controllers:
+ - answer 400 with a clear message when the body has no `{"": {...}}` wrapper or is not valid JSON, instead of a 500;
+ - answer 404 for a key that cannot exist (`/api/products/abc`) instead of a 500;
+ - return the index as a JSON array of records instead of query columns;
+ - quote their top-level keys, so they are lower-case on every engine (`products`, `product`, `error`) rather than upper-case on Lucee.
+ Existing generated controllers are unchanged; regenerate or copy the pattern (#3881)
+- `wheels generate api-resource` no longer adds its route inside another generator's marked block in `config/routes.cfm` (such as `wheels generate auth`'s `api` namespace, which `generate auth --force` rewrites); it opens its own `.namespace("api")` block instead, and the route it adds to an existing block is indented inside it without moving that block's `.end()`.
+- An association's `joinType` now accepts `left` and `left outer` as aliases of `outer` (all emit `LEFT OUTER JOIN`), and any other unknown value throws `Wheels.InvalidJoinType` when the association is registered. `joinType="left"` used to emit `LEFT JOIN`, which the nested-include grouping didn't recognise, so a nested include under it (`include="classifications(tag)"`) was silently dropped and the query failed with an unknown column. The grouping now throws instead of dropping a nested join it can't place (#3936)
+- The `wheels-base-template` download ships the application-developer AI docs (`CLAUDE.md`, `AGENTS.md`, `.ai/README.md`), like the core and starter-app packages, instead of the framework maintainers' `CLAUDE.md`, `.ai/` and `.claude/` commands. Existing apps installed from the 4.x base template can replace `CLAUDE.md`, `AGENTS.md` and `.ai/` with the copies from a new `wheels new` app and delete `.claude/`. (#3947)
+- A SQLite column declared `BIGINT` (or `INT8`) and an Oracle `NUMBER(11..19, 0)` column (such as `NUMBER(19)`) are now typed `cf_sql_bigint`, so their 64-bit values bind correctly on Adobe ColdFusion, and `migrate diff` no longer reports a spurious `integer` to `biginteger` change for a SQLite `BIGINT` column. Wheels' own `t.bigInteger()` still creates `INTEGER` on SQLite, which stays 32-bit (#4086)
+- `wheels browser setup` joins the Playwright jars with the platform's path separator, so the classpath it passes to Java works on Windows (`;`) as well as macOS and Linux (`:`).
+- `wheels browser setup` now launches the browser once before it reports ready, and exits non-zero when the install or the launch fails. On a host missing the OS libraries the browser needs (a bare Linux install), it prints Playwright's `install-deps` command for this install instead of saying "Browser testing ready." A launch that doesn't finish within 120 seconds is stopped, along with everything it started.
+- `insertAll()` and `upsertAll()` no longer fail on SQL Server for batches whose rows × columns exceed the parameter limit. They used to send 1000 rows per statement whatever the column count, so a 5-column write of more than about 400 rows threw `Wheels.TooManyParameters` before writing anything; batches are now sized to fit (at least one row per statement)
+- `save(callbacks=false)` and `delete(callbacks=false)` now skip `afterCommit` and `afterRollback` too, like every other callback. They used to queue them. With callbacks on, a save that changes nothing still queues `afterCommit`
+- `t.char()` now creates a `CHAR` column on SQLite, H2, MySQL and Oracle. Those adapters had no `char` type mapping, so the column was created with no type, and on SQLite every model on the table then failed to load (#4092)
+- The background-job table upgrade no longer re-fires the `wheels_jobs.claimTimeout` `ALTER` on every poll when the database user lacks `ALTER` privilege. `wheels jobs work` builds a fresh `JobWorker` each poll, so a blocked `ALTER` was retried every cycle (log noise plus a failed statement each time). A failed `ALTER` is now recorded in an application-scoped, **time-bounded** memo and the retry backs off for a window (re-probed and re-attempted once it elapses, so a later `GRANT` or manual `ALTER` is still picked up) — never a permanent "gave up" flag. The column-existence probe still runs every call, and a missing/`NULL` `claimTimeout` continues to fall back to the polling worker's timeout. ([#4071](https://github.com/wheels-dev/wheels/issues/4071))
+- `clearChangeInformation(property = "x")` no longer resets change tracking for every other property. It reset the whole change baseline and refilled only `x`, so every untouched property read as changed, was written by the next `save()`, and over-reported in `savedChanges()` / `changedProperties()`. It now refreshes only the named property's baseline, leaving the rest intact (#4381)
+- `wheels upgrade apply` prints its report once; it used to appear twice in a terminal.
+- `wheels new --help` (and `wheels help new`) lists the options and examples; it used to print only a one-line summary. A bare `wheels new` prints the same help.
+- A source checkout of the CLI no longer prints the raw `@build.version@` token. `wheels version`, `wheels info`, `wheels --help` and `wheels console` show `-dev` (for example `4.2.0-dev`), the same derivation the framework uses for a checkout, or `0.0.0-dev` outside the monorepo. Stamped release versions are unchanged (#3891)
+- `wheels generate` (every type except `app`) and `wheels destroy` now refuse outside a Wheels project with `Wheels.NotAWheelsProject` and a non-zero exit, as `wheels start` does. With no `vendor/wheels` found, the project root fell back to the current directory, so `wheels generate model Foo` in any directory wrote `app/models/Foo.cfc` and a migration there, and `wheels destroy` could delete a stray app file whose name matched (#3909)
+- `wheels setup --help` shows the setup command's own help again, instead of the general command list.
+- `wheels test` on an app with no specs yet, such as a fresh `wheels new` app, now prints a "No specs yet" notice with a hint (`wheels generate test model `) and exits 0. Before, it failed with "no test bundles ran for this scope". This applies only to the default scope: an explicit `--filter`, `--directory` or path that runs no bundles still fails, and so does any run with failures, errors, a rejected scope or specs that failed to load (#3921).
+- `wheels test --help` (and `--help` for any command that declares its options) now lists every option, with its accepted values and default, followed by examples for running one folder and one spec file. Before, it printed a single line. The list is rendered from the same declaration the parser and the MCP tool schema use (#3962).
+- The app test runner now clears Lucee's compiled-page pool before every run, not only under `wheels coverage`. A spec edited just before `wheels test` is no longer run in its previous version while Lucee's background template check catches up (#3962).
+- When no server is running, `wheels test` (and the MCP `test` tool) now says it needs the project server and to start it with `wheels start`. It suggests `bash tools/test-local.sh` only inside the Wheels framework repository, the one place that script exists (#3972).
+- `wheels test` accepts a file or folder path as its scope: `tests/specs/models`, `tests/specs/models/BookSpec.cfc`, a trailing slash, a leading `./`, a path relative to `tests/specs`, or `BookSpec.cfc`. Each resolves to the dotted scope the runner takes.
+- `wheels test` refuses a scope that names no folder or spec file, or that the runner would reject (including the MCP `test` tool's `directory`). It prints one message naming the scope and the accepted forms, before anything runs. Previously it ran the full suite and then reported that no test bundles ran (#3963, #3893).
+- The `AGENTS.md` / `CLAUDE.md` that `wheels new` writes now show `wheels test tests.specs.` and `--filter=`.
+- An unknown command (`wheels status`, `wheels bogus`) now prints `Unknown command: . Run 'wheels --help' for the commands.`, with a "did you mean" for near misses (`wheels generat` suggests `wheels generate`), and exits non-zero with `Wheels.UnknownCommand`. It used to print Lucee's raw "Component [modules.wheels.Module] has no function with name [status]" (#3890)
+- On CockroachDB, `t.string()` now creates a `VARCHAR(n)` column instead of `STRING(n)`. The two are the same type in CockroachDB, but a `STRING(n)` column reports no length over JDBC, so automatic validations never enforced its limit; `VARCHAR(n)` reports it. Columns created by earlier versions keep the old behaviour until they are re-declared, for example `changeColumn(table = "users", columnName = "name", columnType = "string", limit = 100)` with the column's existing limit (#4101)
+- The migration templates (`wheels generate migration`, the development migrator, new apps' `app/snippets/dbmigrate/`) no longer show `default=''` / `default=""`, which the migrator has rejected on string, text and char columns since 4.1.0. The example header in `create-table` and `change-table`, and the `changeColumn` / `addColumn` calls in `change-column` and `remove-column`, now omit the default.
+- `wheels console`: bare `exit` and `quit` now end the session like `/exit`. After a failed expression they exit non-zero (`Wheels.ConsoleFailed`), as end of input does, so a piped `bad expr; exit` is still reported as a failure. They used to be evaluated as CFML and fail with "variable [EXIT] doesn't exist", so a piped session that ended in `exit` exited 1 (#3892)
+- Controllers include the application's global helper file (`app/views/helpers.cfm`) when each controller object is set up, and before `config()` runs, instead of in `wheels.Controller`'s pseudo-constructor. Every controller now gets the global helpers on engines that reuse a class's pseudo-constructor include across instances (RustCFML v0.721.0 and later), including a controller first created during application start-up. Global helpers still come before a controller's own helpers, so a controller helper can override one. A missing global helper file is skipped, and whether it exists is cached like the controller helper files when `cacheFileChecking` is on.
+- The framework test suite (`/wheels/core/tests`) runs in an app made with `wheels new`. `coreTestDataSourceName` used to default to a datasource named after the app's folder (`public`) before `config/settings.cfm` set `dataSourceName`, so the run failed with `Datasource [public] doesn't exist`. It now defaults to the `dataSourceName` the settings set, so the run uses `_test`. A `coreTestDataSourceName` that names a datasource that does not exist now gets a 409 explaining how to fix it (set `coreTestDataSourceName`, create the datasource, or pass `?db=`) instead of the engine's error.
+- A `coreTestDataSourceName` that differs from `dataSourceName` only in letter case is treated as the app's primary datasource, so the framework suite still uses `_test` (datasource names are case-insensitive).
+- The 409 responses of the test runners have lowercase keys (`success`, `error`, `message`, `datasource`, `candidate`); on Lucee they were serialized in uppercase.
+- With `csrfStore="cookie"`, a valid form post was occasionally refused with 403 (about 3% of new CSRF cookies on Lucee 7.0.1 behind Tomcat). That engine reads a cookie value whose only encoded character is `%2B` back with the `+` as a space, which made the base64 ciphertext undecryptable. The cookie now carries base64url (`-`/`_`, no padding), which has nothing to encode; cookies issued before, including ones already read with a space, still decrypt
+- On RustCFML, a request that sets the cookie-store CSRF cookie and reads the token back in the same request (for example a page rendering `csrfMetaTags()`) no longer fails with an HTTP 500. RustCFML reads a cookie assigned as `{value, httpOnly, ...}` back as that struct rather than its value; the token reader now uses the value
+- A `csrfStore="cookie"` app on Adobe CF returned HTTP 500 the first time it generated an authenticity token: the CSRF cookie was set with `encodeValue` and `preserveCase` of `""` (their defaults), which Adobe rejects for these boolean attributes. They are now left out unless configured.
+- `wheels info` and the `wheels test` preamble now read a single-quoted datasource in `config/settings.cfm` (`set(dataSourceName='name')` and `env('WHEELS_DATASOURCE', 'name')`), which CFML accepts; only double quotes were read (#3952)
+- `delete(callbacks=false)` now also skips the callbacks of the dependent records it deletes or removes through `hasMany()` / `hasOne()` `dependent=`, including their `afterFind` / `afterInitialization` and transaction callbacks; a `hasOne` with `dependent="deleteAll"` or `"removeAll"` (which loads the record) is covered too. `deleteAll(instantiate=true)`, `deleteOne()` and `updateOne()` with `callbacks=false` no longer run `afterFind` / `afterInitialization` on the records they load, like `updateAll(instantiate=true)`. An invalid `dependent` value on `hasMany()` or `hasOne()` now throws `Wheels.InvalidArgument` when the association is declared, instead of at the first delete
+- `wheels deploy` now applies the `boot:` block (Kamal's rolling boot) instead of only validating it: with `boot:` present, each role's hosts boot in batches of `limit` (a count, or a percentage of the role's hosts) with `wait` seconds between batches, and `--dry-run` shows where each pause falls. Configs without `boot:` deploy as before (#3925)
+- `wheels deploy` follow-ups: the `deploy init` Dockerfile no longer hard-codes `WHEELS_DATASOURCE=app` (the app's own datasource name applies) and links the current guides; the local `db/*.sqlite` files are no longer copied into the image (the image gets an empty `db/`, and the generated `.dockerignore` excludes SQLite files), and `deploy init` tells a SQLite app that production starts with an empty database and how to run migrations on boot; `deploy registry login` without `registry.username` fails with a clear error instead of running `docker login -u ''`; a missing or empty `config/deploy.yml` says so (and points at `wheels deploy init`) instead of a raw file error; unknown `proxy.*` keys and a non-boolean `proxy.ssl` are rejected; `deploy app details` / `app live` / `app maintenance` no longer require `--release`, and the verbs that do ask for `--release`; `deploy config` prints proxy, env (secret names only), accessories, ssh and builder too, and shows a `registry.password` entry only when it names a key in `.kamal/secrets`; and the deploy guides no longer call `setup` an alias of `deploy` (#3914)
+- `wheels deploy` now refuses an app or accessory volume whose host path starts with `~` or `$` (which a shell used to expand, but which Docker would take literally now that volume arguments are quoted) with a message asking for an absolute path, and `wheels deploy init` shows a SQLite app the `volumes:` entry that keeps its database on the host across redeploys. `wheels deploy config` also prints `volumes` (#4018)
+- `wheels docs fetch` now mounts an already-installed docs bundle into the current app's `public/wheels-docs` (a second app no longer needs `--force`), unpacks the bundle with Java instead of a shell `unzip` (so a CLI home path with a space works, and Windows needs no `unzip`), refuses archive entries that point outside the docs directory, and honours `--offline` / `WHEELS_OFFLINE=1` by refusing the download while still mounting an installed bundle (#3894, #3899). Thanks @PandaHUN777 (#3915) for the offline-mode fix.
+- Generated apps no longer prepend blank lines to every response. The per-request and lifecycle event templates (`app/events/onrequeststart.cfm`, `onrequestend.cfm`, `onsessionstart.cfm` and siblings) are `$include`d straight into the response buffer, where a bare `cfscript` block and a trailing newline leaked whitespace before the action's output — enough to make `renderText()` of an XML document ill-formed, since the `` declaration must be the first byte. The templates are now wrapped in `cfsilent` with no trailing newline (the `wheels new` generator templates, the demo app, and the published `starter-app` example). `cfsilent` suppresses only output, not execution, so event code — including `cfheader` — still runs; any output a hook deliberately produces must go through the view or `renderText()` rather than being written directly. **Existing apps:** these files are app-owned (`app/events/*.cfm`), so a framework upgrade does not touch them — if `renderText()` of XML (or any byte-exact response) shows leading blank lines, wrap each `app/events/*.cfm` placeholder in `…` with no trailing newline, or delete the empty placeholders you don't use. ([#3882](https://github.com/wheels-dev/wheels/issues/3882))
+- `wheels generate auth --strategy=token` and `--strategy=jwt`: the generated `api/Sessions` controller answers in JSON when the request names no format (no `.json` extension or `Accept` header), instead of an HTTP 500 for a missing view, and its response keys keep their case (`token`, `tokenType`, `expiresIn`, `error`, `revoked`, `message`).
+- After `wheels generate scaffold` or `generate api-resource`, the Next steps now say to run `wheels reload` when the project's dev server is already running, instead of `wheels start`. A running server only picks up the new routes after a reload, so following the old hint led to a 404 on the new route and a 500 on its form (#3883). Thanks @kshivam4781 (#3935).
+- Guide links in framework and CLI messages now reach the 4.2 guides. A link for a 4.2 project or a `--to=4.2.0` upgrade check resolved to the 4.1 guides, which have no 4.1 to 4.2 upgrade page, because the newest linkable guides version was still 4.1. The spec that guards that version now counts guide trees still in development, so it can't fall behind again
+- Cache keys from `$hashedKey()` now keep argument names and the order of array elements and query rows, while still ignoring struct key order. Cached partials, and other cached output keyed on queries or arrays, are no longer reused for inputs that hold the same values in a different order. The unused `normalizeForHash()` engine-adapter method is removed.
+- `Job.enqueue()`, `enqueueIn()` and `enqueueAt()` no longer mutate the `data` struct the caller passes. The framework's internal keys (`$wheelsJobTimeout` and, when present, the tenant context) were added to the caller's struct because CFML passes structs by reference; the data is now duplicated before those keys are added. ([#3887](https://github.com/wheels-dev/wheels/issues/3887))
+- The background-jobs guide (4.1 and 4.2) and the AI jobs reference now describe `maxRetries` as it works: it counts the retries after the first run, so the default `3` means up to four runs. The log line for a job that runs out of retries now reports how many times it ran.
+- A job left in `processing` by a crashed or killed worker is now recovered during normal polling. `JobWorker.processNext()` (the loop behind `wheels jobs work`) runs the stale-processing check before claiming, so a job stuck in `processing` is requeued for retry (counting the attempt) or marked failed when retries are exhausted, instead of staying stuck forever. The reap is scoped to the queues the poll serves and only acts once a job has been idle past a grace margin (`timeout + max(60s, timeout)`), so a worker never reaps another worker's live job, and the requeue is an atomic conditional `UPDATE` guarded by the attempt count a claim bumps — two workers polling the same queue cannot double-requeue or double-count the same job. Recovery is at-least-once: workers sharing a queue should configure the same `timeout`. ([#3888](https://github.com/wheels-dev/wheels/issues/3888))
+- With `jobsAutoCreateTables = false` on PostgreSQL or CockroachDB, a job call made inside a database transaction while the job tables are missing now reports `Wheels.Job.SchemaMissing`, pointing at `wheels jobs install`. Before, the failed table probe aborted the transaction, and the caller saw "current transaction is aborted" instead.
+- `wheels jobs work` no longer cuts every job off at 300 seconds: each job now runs with its own `this.timeout`, like `processQueue()`. The new `--job-timeout=` option caps it. (#4517)
+- The test runner's JSON report keeps its results when a spec errors with an exception that can't be serialized. Each spec's error, failure origin and bundle-level exception are reduced to plain values (type, message, detail, a bounded stack trace and template/line frames) before serializing; before, a JDBC exception on BoxLang sent the serializer into a `StackOverflowError` and the whole run came back with no results. If serialization still fails, the report keeps the totals and each spec's name and status. (#3905)
+- On MariaDB 10.2.7 and later, column defaults are read correctly. MariaDB reports them as SQL expressions: a nullable column without a default as the text `NULL`, and a string default as a quoted literal (`'draft'`). Wheels took the text `NULL` as a real default, so `validatesPresenceOf` was skipped for an unset nullable column on create, and string defaults kept their quotes. The MySQL adapter now normalizes them on MariaDB; a literal string default `'NULL'` stays the text `NULL`, and MySQL is unchanged. (#3927)
+- Over MCP, `generate` with `type=app` (or its alias `a`) is now refused with `Wheels.InvalidArguments` instead of scaffolding an application into the MCP server's working directory, even for a client that skips the advertised input schema; `wheels generate app ` and `wheels generate --type=app ` in a terminal keep working. This uses the runtime-owned `__lucliMcpCall` marker a LuCLI build with wheels-dev/LuCLI#17 adds to every MCP `tools/call`; Wheels drops that marker before any command parses its arguments, so it is safe with today's LuCLI too (#3980)
+- MCP tool polish:
+ - `wheels notes` refuses an annotation marker that isn't a word (a `.` used to match every character);
+ - `wheels seed` advertises the `models` and `count` options its own "Pass models=…" message refers to, forwards them to the server, and refuses an unknown `environment` instead of silently running only the main seeds;
+ - the `routes` tool now has a description, and `reload`'s is no longer cut off mid-sentence (#3893)
+- A middleware registered by component path (`set(middleware=["app.middleware.TokenGate"])` or a route's `middleware=[...]`) no longer needs an `init()`: Wheels calls `init()` only when the component has one. A component with just `handle(request, next)`, which is all `wheels.middleware.MiddlewareInterface` requires, used to fail every request with "has no function with name [init]". An `init()` the component has, including an inherited one, still runs, and an object it returns is still what's used; when it returns nothing or a non-object, the component itself is used.
+- What middleware attaches to the request now reaches the controller. The `AuthMiddleware` result is available as `request.auth`, as documented, including the failed result under `allowAnonymous=true`, and the whole middleware context as `request.wheels.middlewareContext`. Before, the controller was built from the request params alone, so a controller behind `AuthMiddleware` could not tell who had authenticated.
+- `AuthMiddleware` created without an `authenticator` now uses the one registered in the DI container (what `wheels generate auth` and `enableSession()` register), so the documented `new wheels.middleware.AuthMiddleware(strategies="...")` form no longer throws `Wheels.Auth.NoAuthenticator`.
+- The default `AuthMiddleware` 401 body now has lowercase `error` and `status` keys; on Lucee they were serialized as `ERROR` and `STATUS`.
+- Migration column functions (`t.string()` and the others, `t.column()`, `t.references()`) read `null`, the option's name before Wheels 3.0, as a deprecated alias of `allowNull` again. Since 3.0 it was ignored, so an older migration's `null=false` created a nullable column. `allowNull` wins when both are passed, and the deprecation is logged once per migration run. `wheels upgrade check` lists migrations that use `null=false` or `null=true`; check `null=false` columns for `NULL` rows before tightening them
+- Migrations on Lucee with CockroachDB: a migration step that runs any statement before a schema change (a check query, a data update, the `wheels jobs install` migration's catalog checks) no longer fails with "to use multi-statement transactions involving a schema change under weak isolation levels". Lucee leaves a pooled connection at read committed once a transaction on it has ended, and CockroachDB refuses a schema change after another statement at that level. On CockroachDB the migrator now runs each step's transaction at serializable, CockroachDB's own default. Other databases are unchanged.
+- The migration templates under `vendor/wheels/migrator/templates/` no longer show `default=''`, which the migrator has rejected on string, text and char columns since 4.1.0.
+- Specs that mock (`createStub()`, `createMock()`, `createEmptyMock()`, `prepareMock()` with `$()`) work in an app made with `wheels new`. MockBox writes its generated stubs to `public/testbox/system/stubs`, which such an app doesn't have, so every mocked method errored with "parent directory … does not exist". `WheelsTest` now creates the directory when it is missing.
+- A nested controller (`app/controllers//X.cfc`) that declares `extends="Controller"` now logs a hint to `wheels.log` to use `extends="app.controllers.Controller"` — a bare `extends` name resolves relative to the controller's own package, so the base controller cannot be found. The original component-not-found exception is preserved unchanged (its stack and cause are intact); the hint is added only to the log, which the error page already points to
+- `wheels new` creates what the guides and the framework expect:
+ - `config/development/settings.cfm`, `config/testing/settings.cfm`, `config/production/settings.cfm` and `config/maintenance/settings.cfm` again, as commented stubs (the 3.x template shipped them; the 4.x one didn't). The stubs set nothing, so a new app behaves as before.
+ - `.env.example`, with the same keys as `.env` and the secrets (reload and Lucee admin passwords) left blank.
+ - `app/middleware/`.
+ - `app/snippets/dbmigrate/`, which the development migrator UI needs to create a migration.
+ - The `lucee.json` SQLite datasource now uses the `#project:path#` placeholder LuCLI resolves; the old `{project}` was never substituted.
+ - The legacy plugins README moved to the root `plugins/` folder the framework actually loads plugins from, instead of an `app/plugins/` folder it never reads.
+ - New apps no longer ship frozen copies of the generator templates in `app/snippets/`, which kept an app on old templates after a CLI update.
+ - Template comments link to the 4.1 guides.
+- `wheels new` also skips shutdown ports that another project pins in its `lucee.json` (projects you've started, plus folders next to the new app), even when that project isn't running, and warns when the requested `--port` is one of them.
+- `wheels new --port=N` no longer pins the shutdown port to N+1 when another process is already listening there; it writes the first free port above N into `lucee.json` and says so. `wheels start` still moves a pinned shutdown port that is taken later.
+- The `onError` fallback in the app's `public/Application.cfc` no longer reports "Wheels failed to initialize" for an error raised once the application is already running (for example a request timeout after startup); it renders and logs a running-app message instead. It also extends the request timeout (via `onErrorRequestTimeout`) before logging, so the `wheels.log` entry the minimal error page points at is written even after a request timeout. **Existing apps:** this lives in the app-owned `public/Application.cfc`, so it is not picked up automatically — copy the updated `onError`, `$renderMinimalError` and `$logStartupFailure` functions from the template (`cli/lucli/templates/app/public/Application.cfc`) into your app's `public/Application.cfc`, replacing the existing versions. ([#3965](https://github.com/wheels-dev/wheels/issues/3965))
+- On Oracle, a whole number beyond the signed 64-bit range (more than 9,223,372,036,854,775,807, or less than -9,223,372,036,854,775,808) written to or compared against a `NUMBER` column is now bound as text, which Oracle converts exactly. It was clamped (Lucee), wrapped (BoxLang) or rounded through a double, so it could be stored or matched as a different number (#4162)
+- On Oracle, whole-number `NUMBER` columns with a precision of 11 or more, including `NUMBER(38)` (Oracle's `INTEGER`, common for identity ids), are now bound as 64-bit integers. They were bound as 32-bit, so an id above 2,147,483,647 was clamped or wrapped and a find, update or delete by it could act on a different row (#4089)
+- A lowercase or mixed-case sort direction in an `order` string is now honoured: `order="createdAt desc"` (or `Desc`, or with extra spaces or a tab before it) sorts descending in `findAll()`, paginated finders, `table.column` items and the query builder's `orderBy()`. It used to sort ascending, and a `table.column desc` item threw `Wheels.InvalidOrderClause`
+- `wheels packages` now checks a package's `wheelsVersion` range against the app's own `vendor/wheels` version, not the CLI's (falling back to the CLI version outside a project), and a dev CLI build (`0.0.0-dev`) treats every range as compatible, as documented. The manifest cache lives under the CLI home (so `LUCLI_HOME` isolates it), a registry other than the default gets its own `registries//` cache, and `--offline` serves expired cached data or says plainly that nothing is cached yet (#3911)
+- A `wheels packages update` / `add --force` killed mid-swap no longer leaves the package only as a hidden `vendor/.wheels-pkg-previous-*` backup: the next `wheels packages` command restores it when `vendor//` is missing or incomplete, drops it when the new copy is in place, and says how to restore one it cannot attribute. A cross-volume install now copies into a hidden sibling and renames it in, so `vendor//` is never half-written (#3902)
+- A polymorphic association without `foreignKey` / `foreignType` now finds its columns on the child's table like other associations do: `id` / `type` when they exist, otherwise `_id` / `_type`, which `t.references(polymorphic=true)` writes when `useUnderscoreReferenceColumns` is on (the `wheels new` default). Before, those apps threw `key [id] doesn't exist`. When both shapes exist, the names without the underscore win
+- `Job.processQueue()` processes jobs again on BoxLang with PostgreSQL and CockroachDB. Its candidate query passed the `maxrows` option, which BoxLang's PostgreSQL path rejects ("setLargeMaxRows is not yet implemented"), so every call returned an error and ran nothing. It now bounds the batch in the SQL itself, as `JobWorker` does. ([#4487](https://github.com/wheels-dev/wheels/pull/4487))
+- `processRequest()` now restores the request method, transaction mode and the `sendEmail` / `sendFile` deliver flags on every exit, including when the action throws and the spec catches it. Previously it restored them only on the success path, so a caught throw leaked that state into later specs in the same run (reported by a downstream app) (#4350)
+- `wheels reload` against a RustCFML server no longer fails every other run. On the first request after a reload restart, the framework sets `request.cgi` before `onRequestStart`, and RustCFML resolves `IsDefined("cgi.")` against `request.cgi`. The `X-Wheels-Reload-Password` header the CLI sends therefore looked absent, and the reload check never saw it. `public/Application.cfc` now falls back to the raw request headers (`GetHttpRequestData()`) when the `cgi` copy is missing. The password stays header-only, and the reload gate, its rate limit and its constant-time comparison are unchanged. Existing apps: copy the updated header-mapping block from the `wheels new` template into your own `public/Application.cfc` (#3913)
+- On an app served under a subfolder (`set(subpath=...)` or an IIS application), `?reload=true` now redirects back inside the app: `public/Application.cfc`'s `$buildRedirectUrl()` passes the path through the framework's `$reloadRedirectPath()`, which puts the subfolder back. **Existing apps** must apply the same edit to their own `public/Application.cfc`; the snippet is in the 4.1 → 4.2 upgrade guide (#3948).
+- The redirect after a reload (`redirectAfterReload`, on by default in production and maintenance) now collapses a leading run of slashes or backslashes in the request path to a single `/`, and also ignores spaces and control characters in that leading run, so it always goes to a path on the application's own site; a subfolder install still gets its prefix.
+- Route model binding now derives the model from the last segment of a namespaced controller, so `binding=true` on `admin.users` loads `User` instead of silently skipping a nonexistent `Admin.user` model. The development "Route Binding Hint" log line names that model too, and stays quiet when the controller has no model to bind (#3940)
+- The Linux `.rpm` now unpacks cleanly with `rpm2cpio` on rpm 4.19 (Rocky Linux 10). It is rebuilt with `rpmbuild`, because nfpm wrote an archive-size header that rpm 4.19's `rpm2cpio` rejects (exit 1), which broke `rpm2cpio | cpio` installs in user space. The package contents and dependencies are unchanged (#3975)
+- The Linux packages install where the system Java is newer than 21. The `.rpm` now requires Java 21 or newer (`java-21-headless` or `java-headless >= 1:21`), so `dnf install` works on Fedora 44, which ships only Java 25, and the `.deb` accepts OpenJDK 21 or 25, preferring 21. The `wheels` launcher also finds any Java 21 or newer under `/usr/lib/jvm` when `/usr/bin/java` points at an older JDK, and its error now asks for a Java 21 or newer runtime (#4237, #4243)
+- RustCFML engine commands: `wheels info` names RustCFML (with its version and URL) while it serves the project instead of always printing Lucee; `--port 8931` (space form) now works for `wheels engines rustcfml start` and is no longer silently dropped by `wheels start`; `wheels start --engine=` and `wheels engines ` / `wheels engines rustcfml ` exit non-zero instead of booting Lucee or exiting 0; and the first-run RustCFML download (~57 MB) is announced, with how long it took (#3895)
+- RustCFML engine: `wheels start --engine=rustcfml` (and `wheels engines rustcfml start`) now refuses with `Wheels.EngineConflict` while the project's Lucee server is running, and `wheels start` refuses while its RustCFML server is running. Both engines used to run at once on different ports, and the CLI's HTTP commands targeted whichever one port detection picked. `wheels stop` now stops both: it used to stop RustCFML and leave Lucee running. RustCFML state also honours `-Dlucli.home` ahead of `$LUCLI_HOME`, as the rest of the CLI does, so `wheels engines rustcfml status` no longer misses a running server under a launcher that passes the property (#3913)
+- Corrected the session-cookie (#3830) upgrade note's version range: the `this.sessionCookie` block has been in the app template since 4.0.4, so apps created with 4.0.4-4.1.1 (not only 4.0.6-4.1.1) need the `secure` update, and apps created before 4.0.4 have no block at all. Added a dedicated **Upgrading from 4.0 to 4.1** guide page listing every app-owned `public/Application.cfc` edit with its version and fail-safe status. ([#3967](https://github.com/wheels-dev/wheels/issues/3967), [#3966](https://github.com/wheels-dev/wheels/issues/3966), [#3968](https://github.com/wheels-dev/wheels/issues/3968))
+- On SQLite, `t.bigInteger()` now declares `BIGINT` instead of `INTEGER`, so the column is bound as a 64-bit integer. Values above 2,147,483,647 used to fail on Adobe ColdFusion and were silently changed on Lucee (clamped to 2,147,483,647) and BoxLang (wrapped). Existing columns keep `INTEGER` until re-declared with `changeColumn(columnType="biginteger")`; a `biginteger` primary key stays `INTEGER PRIMARY KEY`, SQLite's rowid (#4089)
+- On Oracle, a calculated property declared with `dataType="bigint"` now maps to `cf_sql_bigint` instead of failing with "The column type bigint is not mapped to a CFML SQL type" (#4089)
+- On SQLite, `INTEGER` columns, including `INTEGER PRIMARY KEY` ids, are now bound as 64-bit integers, matching how SQLite stores them. A value or id above 2,147,483,647 used to fail on Adobe ColdFusion and was silently changed on Lucee (clamped to 2,147,483,647) and BoxLang (wrapped), so a find, update or delete by such an id could act on a different row. Existing `INTEGER` columns are protected without a migration (#4142)
+- On SQLite, `updateAll()` now stores a date as ISO-8601 text, as `save()` does, instead of CFML's `{ts '...'}` string, which a `DATETIME` column then failed to read ("Error parsing time stamp"). `changeColumn()` to a date type converts such values left by earlier versions (`{ts}`, `{d}`, `{t}` literals and bare date-only or time-only text) to the form `save()` writes; the 4.1 to 4.2 upgrade guide has a query to find and repair them by hand (#4147)
+- SSE channel resume now honours the bundled `WheelsSSE` client's `lastEventId` url parameter, not just the `Last-Event-ID` header, so a reconnect replays the events it missed out of the box — you no longer have to forward `params.lastEventId` from the action (the header still wins when both are present). The channel `events` filter now trims whitespace, so `events="a, b"` matches `b` (#4375)
+- `wheels start` stops before starting anything when the port in `lucee.json` is taken, names the Wheels server that holds it (with `cd && wheels stop`), and suggests a free `--port`. It used to warn and then fail with LuCLI's `Use: lucli server stop `.
+- `wheels new` without `--port` no longer gives every app port 8080: it takes the first port from 8080 that is free and not pinned in another project's `lucee.json`.
+- The `wheels-starter-app` package is published to ForgeBox under the release version. Its `box.json` carried a fixed `1.0.0`, which the build never replaced, so every release overwrote ForgeBox's `1.0.0` entry and no 4.x starter app was listed under its own version. The release build's package check now fails on a version mismatch instead of printing a warning. (#3908)
+- The starter app's README Quick Start works again: it creates the schema from the migrator page (`/wheels/migrator`) instead of `wheels migrate latest`, which needs a `wheels start` server, and restarts the server afterwards so the seeded settings load.
+- The starter app's migrations create their schema again: nine `default=''` options on string and text columns, rejected since 4.1.0, are gone, and the migration header examples and the `create-table` snippet no longer teach the pattern.
+- The starter app's tests run again: its `tests/runner.cfm`, an old copy of the framework's core-test runner that set the datasource from the folder name ("Datasource [public] doesn't exist"), is replaced by the app template's runner, which runs the specs on the `starterApp_test` datasource.
+- `wheels stop` now stops the project's running server even when it runs under an older registration name (after a `lucee.json` `name` change), instead of stopping only the current name and leaving the old-name server running, and it deletes the live server's start token rather than a stale registration's (#3994)
+- Apps running under a subfolder (`/app1` as an IIS virtual directory or Tomcat context pointing at `public/`) no longer lose the subfolder in five places (#3948):
+ - `/app1/index.cfm` with an empty `PATH_INFO` now routes to the root instead of an `App1` controller.
+ - `?reload=true` redirects back under `/app1`.
+ - `tests/runner.cfm` falls back to the `/wheels` mapping when `/app1/wheels/...` does not exist.
+ - The debug bar's environment-switch links keep `index.cfm` in Partial URL rewriting.
+ - The debug bar's offline-docs links point under the subfolder.
+- `TenantMigrator.migrateAll()` lists a tenant whose migration step failed under `failed` (with `error`, `version`, `direction` and the full `output`) instead of `success`. The migrator reports a failed step in its output rather than throwing, so the tenant used to be counted as migrated. The migrator's output text is unchanged; `Migrator.$lastStepFailure()` exposes the failed step as data.
+- The `wheels.Test` (RocketUnit) deprecation message now links the `v4-1-0` testing guides instead of the stale `v4-0-0` path. ([#3968](https://github.com/wheels-dev/wheels/issues/3968))
+- Test-runner URLs beyond `/wheels/core/tests` and `/wheels/app/tests` now bind the isolated test application too: the `testbox` and `tests_testbox` runner paths are matched (case-insensitively, anchored at the start of the path), so a run reached through a convention path such as `/wheels/testbox` no longer executes specs against the live application. On an app configured for isolation, a runner reached through a route that does not bind the isolated scope (a custom route) is now refused with a `409` naming the canonical runner path; apps without the isolation include keep their existing live-scope behaviour (#4379)
+- `TestClient` keeps and returns cookies correctly on Adobe CF. Adobe hands repeated `Set-Cookie` headers over as a struct, and the client walked its keys instead of its values, so no cookie was kept and every request started a new session. And cookies went back through `cfhttpparam type="cookie"`, which URL-encodes again, so a value the server had already encoded (such as an encrypted token) arrived double-encoded. The client now reads every `Set-Cookie` shape and sends the jar back as one `Cookie` header, values exactly as the server set them; a value set with `withCookie()` is URL-encoded once.
+- A `wheels.wheelstest.TestClient` created inside a test run and pointed at the test server now sends its requests to the test application, as `$testClient()` already did. Before, a client built with `new wheels.wheelstest.TestClient()` or an app subclass reached the live application and its primary datasource while the spec code used `_test`.
+ - The test server is a loopback host (`localhost`, `127.x.x.x`, `[::1]`) or the host of a configured `testClientBaseUrl`.
+ - A client aimed at any other host is unchanged, and so is one created outside a test run, such as in a scheduled task or a script.
+ - Pass `testContext=false` to address the live application.
+- The test-context path trigger binds the isolated test application only for a request whose path targets a runner endpoint (`/wheels/core/tests` or `/wheels/app/tests`), matched at the start of the path after stripping the query string and rejecting `..` / `//` segments (#4348)
+- A request that ends with `abort` inside a Wheels transaction (`invokeWithTransaction()`, a nested call that joins it, a savepoint unit, or `withAdvisoryLock(transaction = true)`) now rolls the transaction back on every engine, as it already did on Lucee and Adobe ColdFusion. On BoxLang the writes made before the abort were kept
+- `t.uniqueidentifier()` now creates a typed UUID column on every database: `UUID` on PostgreSQL, CockroachDB and H2, `CHAR(36)` on MySQL, SQLite and Oracle (SQL Server keeps `UNIQUEIDENTIFIER`). It previously emitted DDL with no type and SQL Server's `newid()` default, which failed everywhere else; `newid()` now becomes each database's own UUID generator. PostgreSQL `uuid` columns now bind as `cf_sql_other`, so finders on a `uuid` column, including ones created outside Wheels, work instead of failing with `operator does not exist: uuid = character varying`. Blank native UUID primary keys are now generated on PostgreSQL, CockroachDB and H2 too, and `generateUUID()` now always returns a version 4 UUID from a cryptographically strong source: on RustCFML it used to return a zero-filled value (#4094)
+- `wheels upgrade apply` names old framework backups still inside `vendor/` (`vendor/wheels.bak-*` left by earlier versions), which make the framework log an error on every start, and says to move or delete them. When the backup can't be made (a separately mounted `vendor/`, for example), it says nothing was changed and gives the workaround: copy `vendor/wheels/` yourself, then use `--nobackup`.
+- `wheels upgrade apply` now updates a CommandBox app's `box.json` `wheels-core` version (an exact version or a `^`/`~` range, in `dependencies` and `devDependencies`) to the framework it installed, and prints the change. Before, the old pin stayed, and a later `box install` copied the old framework back over the new `vendor/wheels/`, leaving a mix of both versions. Other specs (`4.x`, a channel, a `forgebox:` spec) are left alone with the exact line to change. If `box.json` isn't valid JSON, `apply` refuses before changing anything.
+- `wheels upgrade check` on 3.x-era apps: a 3.0 GA app (no version in `vendor/wheels/box.json`) now reports its real version instead of `unknown` and no longer sees findings twice; checks for 3.x code that 4.x removed (WireBox, `renderPage()`, old test bases) still run after the framework swap, while checks for 4.0 default changes and for things that are only deprecated (`plugins/`, which `wheels new` scaffolds, and `paginationLinks()`) are skipped for an app already on 4.x, so a stock 4.x app reports nothing breaking; `Missing csrfCookieEncryptionSecretKey` only fires for apps that set `csrfStore="cookie"`; and the RocketUnit base class (`wheels.Test`, which still runs on 4.x) is now an advisory instead of a breaking finding, so it no longer fails the CI gate, with fix text that says to convert the tests before switching to `wheels.WheelsTest` (the `wheels.Testbox` alias, also advisory, can switch directly) (#3939)
+- `wheels upgrade check --to=` now says when the CLI running it is older than ``, because its checks and app template only know releases up to its own version. It recommends upgrading the CLI first, where it used to report "All Clear" for an incomplete upgrade. This is a warning only; the exit code doesn't change. JSON output gains `cliVersion`, `cliBehindTarget` and `warnings`. The app-template drift finding now points at the upgrade guides for the target version instead of naming a 4.0-era change (#4154).
+- `wheels upgrade check`: the legacy plugins check no longer passes a 3.x app just because `plugins/` is empty or missing. It also reports plugin packages declared in `box.json` (installed under `plugins/`, or `cfwheels-*` packages) and code that reads `application.wheels.plugins`.
+- `wheels upgrade check`: "Direct WireBox references" now scans `public/Application.cfc`, where 3.x apps bootstrap WireBox, and skips third-party packages under `app/` (a `box.json` install path, or any folder with its own `box.json`, such as `app/lib/logbox/`). It used to miss the app's own bootstrap and flag the package's code instead.
+- Under CommandBox, a request whose path contains a backslash (`\` or `%5C`) now gets a 400 instead of a 500 from the servlet container. The fix is a new "Reject a backslash in the path" rule in the shipped `public/urlrewrite.xml` (and the `wheels new` template). Existing CommandBox apps can copy that rule from a new app's `public/urlrewrite.xml` into their own, ahead of the "Wheels pretty URLs" rule. `wheels start` (LuCLI on Tomcat) already answers 400 (#4114).
+- Validation `condition` / `unless` now handles the expression shapes that used to evaluate silently. A bare name, on its own or on the left of a comparison (`condition="isActive"`, `condition="status != 'draft'"`), is resolved as `this.` (a property value or a method call) when the record has it. Before, a lone name evaluated to `false` and skipped the rule, and a left-hand name was compared as its own text, so the rule always ran. A name the record doesn't have, a `this.` reference on the right of a comparison, or an expression that isn't one value or a complete `left operator right` comparison now throws `Wheels.InvalidValidationCondition`. A lone `true` or `false` evaluates to its value, and a bare word on the right of a comparison stays a string literal. The validation guide now documents the supported forms (#3928)
+- Validation `condition` / `unless` expressions that use a `this.` reference now fail closed for forms the evaluator never really supported, instead of silently skipping or mis-running the rule: a `this.` reference on the **right** of a comparison (e.g. `this.total gt this.limit`), and the word-form operators **`is`**, **`and`** and **`or`** on the `this.` path, now throw `Wheels.InvalidValidationCondition` naming the supported replacement. **Upgrade note:** replace `is` with `eq` (or `==`), `and` with `&&`, `or` with `||`, and put any `this.` reference on the left side of a comparison. ([#3964](https://github.com/wheels-dev/wheels/issues/3964))
+- A `where` condition that compares a function call with a value (e.g. `ABS(id) = 5`, `UPPER(title) = 'X'`, `UPPER(title) IS NULL`) now runs, with the value bound as a parameter, instead of failing with "Array index [1] out of range"
+- With `include`, a `where` condition that compares a column with a function call (e.g. `id = ABS(1)`) now qualifies the column with its table instead of failing as an ambiguous column when the joined tables share its name
+- A `where` string with a quoted pattern followed by `ESCAPE '...'`, or with lowercase or mixed-case `and` / `or` between quoted values, now runs instead of failing with an "invalid hexadecimal String" error
+- `where=`: a literal compared with another table's column inside a subquery (`id IN (SELECT book_id FROM accesses WHERE user_id = 1)`) now throws `Wheels.UnbindableWhereValue`, naming the condition (bound values shown as `?`, quoted values hidden) and the alternatives: `whereIn()` with a value array, or running the subquery first. Before, it failed with `Array index [1] out of range`. `Wheels.ColumnNotFound` from a `where=` now explains the rule too. `where=` accepts model columns compared with values, so a condition with no column, such as `where="1 = 0"`, isn't supported: for no rows use `whereIn()` with an empty array, and for every row leave the condition out.
+- A CFML date interpolated into a finder's `where` string works again. Its ODBC escape form (`{ts '2000-01-01 00:00:00'}`, `{d '...'}`, `{t '...'}`) failed with `invalid hexadecimal String` when quoted (`where="createdAt >= '#since#'"`) or `the value for cfqueryparam cannot be determined` when bare; the date is now bound as its value (#3941)
+- Fixed a data-loss bug where a `deleteAll` or `updateAll` matching zero rows, run inside a raw `transaction {}` block, silently rolled back the entire enclosing transaction on Lucee and Adobe. A zero-row bulk operation is now correctly treated as success rather than a failed operation. Wheels-managed transactions (`transaction()` / `invokeWithTransaction`) were never affected. ([#3944](https://github.com/wheels-dev/wheels/issues/3944))
+- A legacy-format CSRF cookie is no longer misread when decrypting it with the configured cipher returns a value that happens to parse as JSON (a number, say): the cookie's payload must now be the struct the cookie is written with, carrying an authenticity token, and the token reader ignores any other JSON.
+
+### Security
+
+- The `/wheels/*` development tools (info, routes, docs, test runners, packages, the deprecated HTTP MCP endpoint, …) now require the request's socket peer (`REMOTE_ADDR`) to be a loopback address, and are refused outright in the `production` environment. A dev server reached through Docker port publishing, where requests arrive from the Docker gateway, can allow specific peers with `set(devToolsAllowedRemoteAddresses="172.17.0.1")` (exact IPv4/IPv6 addresses or IPv4 CIDR ranges; honoured in development and testing only); publish the port on `127.0.0.1` when you do. **Behaviour change** for Docker-hosted development: `/wheels/*` returns 403 from the host until the gateway address is listed. The dev tools and `/wheels/cli` now treat only IP literals as loopback (a zone-scoped IPv6 loopback included); host names in `X-Forwarded-For` are not resolved. ([GHSA-6v6c-p375-vxw9](https://github.com/wheels-dev/wheels/security/advisories/GHSA-6v6c-p375-vxw9))
+- On MySQL and MariaDB, a string value that Wheels writes into the SQL text instead of binding it (every value with `parameterize=false`, and literals a `where` string leaves in place, such as function arguments) is now written as a hex literal when it contains a backslash, so it reads the same whatever the `sql_mode`; other values are unchanged. A backslash in those positions is now an ordinary character, as it already was in bound values: if you wrote `\\` there to mean one backslash, write `\`. `LIKE ... ESCAPE '\\'` and `ESCAPE '\'` both still mean a single backslash. The query-builder guide now lists which `where` values are bound. ([GHSA-mvr9-2h5x-w6cq](https://github.com/wheels-dev/wheels/security/advisories/GHSA-mvr9-2h5x-w6cq))
+- `hasManyCheckBox()` nested saves now set both keys on a new composite-key join row. Before, only the parent key was set and the insert failed with a NOT NULL error (#3884) ([GHSA-6979-65wv-7pxq](https://github.com/wheels-dev/wheels/security/advisories/GHSA-6979-65wv-7pxq))
+- `hasManyCheckBox()` now works with a surrogate-`id` join model (the shape `wheels generate model` creates). The posted keys are matched on the join model's foreign keys, so checking a box adds the row, unchecking removes it, and the box shows as checked for an existing row. Before, the first key was read as the join row's `id`, so unchecking a box could delete an unrelated join row (#3885) ([GHSA-6979-65wv-7pxq](https://github.com/wheels-dev/wheels/security/advisories/GHSA-6979-65wv-7pxq))
+- `hasManyCheckBox()` no longer throws `Element ID is undefined in ARGUMENTS` on Adobe ColdFusion when it's called without an `id` ([GHSA-6979-65wv-7pxq](https://github.com/wheels-dev/wheels/security/advisories/GHSA-6979-65wv-7pxq))
+- Nested properties now only update or delete child rows that belong to the record being saved. A nested key that names a row of another record is treated as not found (it isn't changed, moved or deleted), the child's foreign key always comes from the parent's saved key, and posted primary-key values aren't kept on a new child except the join row's own foreign keys. This applies to `hasMany`, `hasOne` and nested `belongsTo` associations, including custom `joinKey` associations; keys are compared exactly (string keys such as `01` and `1` are different, and BIGINT keys are compared without rounding), and nested struct keys above 2^31 on BIGINT columns are looked up instead of being treated as new rows. A composite-key join model needs a `belongsTo` for each key column; without one, adding a row through nested properties now raises `Wheels.InvalidNestedKey` naming the column (it used to fail in the database with a NOT NULL error) ([GHSA-6979-65wv-7pxq](https://github.com/wheels-dev/wheels/security/advisories/GHSA-6979-65wv-7pxq))
+- **Behaviour change:** `/wheels/app/tests` (and `wheels test`) now **default to the `_test` database and refuse to run if it is absent**, instead of silently falling back to the primary datasource — so test writes never reach your real database. An app with no `_test` that runs its tests gets a clear refusal (HTTP 409 / a red "Test run failed before any spec ran" message) telling you to create the `_test` datasource, pass `useTestDB=false` (`wheels test --no-test-db`), or set `allowTestsAgainstPrimaryDatasource=true`. A default `wheels new` (SQLite) app already has a `_test` datasource, so `wheels new` → `wheels test` is unaffected; only apps that configure a datasource without a matching `_test` (e.g. `wheels new --no-sqlite`) need to act. Precedence: an explicit `useTestDB=true` always requires the test database; `useTestDB=false` runs against the primary datasource; and `set(allowTestsAgainstPrimaryDatasource=true)` in `config/settings.cfm` lets an **omitted** flag (e.g. from an older CLI that cannot send `useTestDB=false`) run against the primary datasource with a warning. ([GHSA-xcj9-wm25-4cg4](https://github.com/wheels-dev/wheels/security/advisories/GHSA-xcj9-wm25-4cg4))
+- The test-runner application isolation (`_wheelsTest`, #3374) is now selected only in the `development` and `testing` environments. The runner path is matched against the request path. The `X-Wheels-Test-Context` header and `WHEELS_TEST_CONTEXT` cookie must carry the per-process runner secret and come from a loopback client. An isolated application refuses to start outside development and testing before any of its start-up code runs, and the core and app test runners refuse to run in production. **Existing apps:** test-context isolation needs `WHEELS_ENV` set to `development` or `testing` (the `wheels new` template sets it). Without it, specs run against the live application scope, and the runner logs an isolation-off warning and sends an `X-Wheels-Test-Isolation: off` response header. ([GHSA-xcj9-wm25-4cg4](https://github.com/wheels-dev/wheels/security/advisories/GHSA-xcj9-wm25-4cg4))
+- Path-containment guards that confine a resolved target to a root directory — database dump output, zip extraction, package manifest mappings, dev-UI asset and offline-docs serving, and image-tag source resolution — now compare canonical paths **exactly** (case-sensitively) with a separator-qualified boundary, through a shared `wheels.PathGuard` helper. Both the root and the candidate are canonicalised the same way, so a root configured in a different case than its on-disk spelling still resolves its own descendants. The always-on lexical checks and the existing authorization gates are unchanged. ([GHSA-m63x-8m98-225v](https://github.com/wheels-dev/wheels/security/advisories/GHSA-m63x-8m98-225v))
+- `findByKey()`, `exists(key=...)`, `updateByKey()` and `deleteByKey()` now combine the key with an incoming scope chain, query builder or `where` argument (AND) instead of replacing it, so a key outside the scope or `where` returns `false` or matches nothing. Lookups with no `where` are unchanged. `exists()` no longer throws in development when it gets both `key` and `where`: it checks the key within the `where`, the same in every environment. ([GHSA-2qh9-qcxq-f8qc](https://github.com/wheels-dev/wheels/security/advisories/GHSA-2qh9-qcxq-f8qc))
+- `wheels test --filter=` now decides whether a spec file is under the spec root (`tests/specs`, or `vendor/wheels/tests/specs` with `--core`) with an exact, separator-qualified comparison of canonical paths, the same rule as the framework path guards. ([GHSA-m63x-8m98-225v](https://github.com/wheels-dev/wheels/security/advisories/GHSA-m63x-8m98-225v))
+- `LocalDisk` storage now validates object keys per path segment instead of rejecting any key that contains `..` anywhere. A legitimate name with two dots inside a segment — `reports/q3..final.pdf`, `a..b.txt`, `v1..2` — can now be stored, while the key check is tightened: it rejects any segment that is made only of dots and/or whitespace (`.`, `..`, `...`, `. .`, and — because Windows strips trailing dots and spaces — `.. `) and any drive-letter prefix (`C:/…`, `C:foo`). Every key stays relative to the root: a leading `/`, a trailing `/`, doubled `//` and a UNC/network prefix (`//server/share`, `\\server\share`) are normalised to a relative key under the root, and keys are never URL-decoded, so `%2e%2e%2f` stays a literal segment and can't turn into `../`. As defence in depth the resolved path is lexically canonicalised (resolving `./`, `../` and `//`) and confirmed inside the root with a trailing-separator compare, so `/root-other` can't pass as inside `/root`. The containment check is lexical and uniform on every engine including the JVM-free RustCFML; it does not resolve symlinks (a symlink under `root` is still followed and trusted — see [#4020](https://github.com/wheels-dev/wheels/issues/4020)). ([#3912](https://github.com/wheels-dev/wheels/issues/3912))
+- Apps generated by `wheels deploy init` ran in development mode in production, showing development error details: the generated `config/environment.cfm` hardcoded `set(environment="development")`, so the image's `WHEELS_ENV=production` had no effect. The generated config now reads `WHEELS_ENV`: unset or empty means `development`, and any value other than `development`, `testing`, `maintenance` or `production` stops the app from starting instead of running with development-style error output. `config/settings.cfm` now reads `WHEELS_DATASOURCE` with the app name as its default. This applies to `wheels new` and the `wheels-base-template` download. `wheels info` and the `wheels test` preamble recognise the new `dataSourceName` form. **Existing apps:** templates don't reach an existing app, so replace `config/environment.cfm` with the generated version (see the Environments and Configuration guide), or hardcode `set(environment="production");` on production servers. A `WHEELS_ENV` line in `.env` wins over the process environment, so make sure a production server's `.env` doesn't say `development`. (#3946)
+- A per-tenant `config` override can no longer switch on `showDebugInformation` or `enablePublicComponent` for a tenant's requests. `$get()`'s tenant-override guard already excluded `showErrorInformation`, but not the other two debug flags it is grouped with everywhere else, so a per-tenant config could expose debug output or the public-component surface for that tenant. All three debug flags are now non-overridable per-tenant. ([#3880](https://github.com/wheels-dev/wheels/issues/3880))
+
+---
+
# [4.1.2](https://github.com/wheels-dev/wheels/releases/tag/v4.1.2) => 2026-10-01
### Added
@@ -82,14 +441,14 @@ All historical references to "CFWheels" in this changelog have been preserved fo
- `wheels generate scaffold`: a key that can't belong to the model, such as `GET /posts/abc` for an integer primary key, now returns 404 instead of a 500 `Wheels.InvalidValue` error. The generated `requireRecord()` filter treats `Wheels.InvalidValue` from its `findByKey()` lookup as a missing record and rethrows any other error. The framework's check on typed key values is unchanged. **Controllers generated with an earlier version** should wrap the lookup in their `requireRecord()` the same way; the current version is in `app/snippets/CRUDContent.txt` of a new `wheels new` app (#3868)
- `wheels generate scaffold`: a request for a record that doesn't exist (`GET /posts/999999`) now returns 404 in production too. The generated `requireRecord()` filter threw `Wheels.RecordNotFound`, which only the development error page turns into a 404, so production served its generic 500 page. The filter now calls `$throwErrorOrShow404Page()`, which sets 404 and shows the error in development or `app/events/onmissingtemplate.cfm` in production. **Controllers generated with an earlier version** should replace the `Throw(` call in their `requireRecord()` with `$throwErrorOrShow404Page(` (same arguments), and apps that keep `app/snippets/CRUDContent.txt` should make the same change there so later scaffolds pick it up (#3868)
- `sendEmail(file="reports/q3.txt")` attaches the file from the `filePath` folder on disk (`public/files`), as the argument docs say. Only a bare file name was resolved there; a relative path with a subfolder went to `cfmailparam` unchanged, which looked for it next to the framework's own templates and failed with "the file doesn't exist". Relative attachments now resolve the same way as `sendFile()`'s files; absolute paths and URLs are passed through as before ([#3852](https://github.com/wheels-dev/wheels/issues/3852)) (#3857)
-- `wheels new`: the scaffolded session cookie is now `Secure` whenever the request that creates the session arrives over HTTPS, directly or through a TLS-terminating proxy that sends `X-Forwarded-Proto: https`, as well as for `WHEELS_ENV=production`. It used to depend on `WHEELS_ENV=production` alone. An app switched to production through `config/environment.cfm` (the scaffolded `.env` still says `development`) therefore issued its session cookie without `Secure`. **Apps created with 4.0.6-4.1.1 should update `this.sessionCookie.secure` in `public/Application.cfc`** to: (#3830)
+- `wheels new`: the scaffolded session cookie is now `Secure` whenever the request that creates the session arrives over HTTPS, directly or through a TLS-terminating proxy that sends `X-Forwarded-Proto: https`, as well as for `WHEELS_ENV=production`. It used to depend on `WHEELS_ENV=production` alone. An app switched to production through `config/environment.cfm` (the scaffolded `.env` still says `development`) therefore issued its session cookie without `Secure`. The `this.sessionCookie` block has been in the app template since 4.0.4 (#2927), so **apps created with 4.0.4-4.1.1 should update `this.sessionCookie.secure` in `public/Application.cfc`** to: (#3830)
```cfm
secure: (structKeyExists(variables, "currentEnv") && currentEnv == "production")
|| (IsBoolean(cgi.server_port_secure) && cgi.server_port_secure)
|| cgi.https == "on"
|| cgi.http_x_forwarded_proto == "https"
```
- (or set `this.sessionCookie.secure = true;` in `config/app.cfm` when the site is HTTPS-only). `wheels generate auth` also adds a spec that a `GET` to the logout action does not end the session.
+ (or set `this.sessionCookie.secure = true;` in `config/app.cfm` when the site is HTTPS-only). Apps created with 4.0.0-4.0.3 have no `this.sessionCookie` block at all and should add the whole block (httpOnly, sameSite and secure) from the template. `wheels generate auth` also adds a spec that a `GET` to the logout action does not end the session.
- On SQLite and H2, boolean columns created by migrations (`t.boolean()`, `addColumn(columnType="boolean")`) are now real boolean columns on the model. SQLite created them as `INTEGER` and H2 as `TINYINT(1)`, so the model treated them as integers: automatic validation rejected `true` and `"true"` with "is not a number", and a validated `save()` failed. The migrators now emit `BOOLEAN`, which both adapters read as a boolean, so `true`, `"true"` and `"false"` pass automatic validation and save. This applies to new columns only. Existing `INTEGER` / `TINYINT` boolean columns keep the previous behaviour until a migration converts them, which keeps the stored 1/0 values (on SQLite it rebuilds the table): `changeColumn(table="posts", columnName="published", columnType="boolean");`. The new definition replaces the old one, so pass `default` and `allowNull` again if the column had them (#3870)
- After a failed `wheels start` (for example a port that is already in use), the next `wheels start` no longer refuses with "registered to a different project: ". The failed start left a registration folder that names no project; it is now treated as left over and cleaned. `wheels start` also exits non-zero when it refuses to run, both outside a Wheels project and when the server name is registered to another project, so scripts and CI can tell. (#3861)
- `wheels start` cleans a leftover registration only when it has exactly the shape a failed start leaves (LuCLI's config file and nothing else). A stopped server registration of another project with the same name but no recorded project path (from plain LuCLI or before 4.0) is no longer wiped; `wheels start` refuses and points to `--force` instead. (#3865)
diff --git a/CLAUDE.md b/CLAUDE.md
index d2d12c9585..eb04489293 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,7 +85,18 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang
**16b-ext — a closure whose first two statements are a zero-arg `$`-member call then an argumented `$`-member call, Adobe 2025.** The same `MissingNameException` fires when a closure body *opens* with `_controller.$clearCachableActions()` followed by `g.$clearCache("action")` (any receivers — local, `g = application.wo`, or `application.wo` itself). Probe-verified boundaries: the pair compiles when it is **not** the first two statements (an assignment, an argumented call, or even another zero-arg call before it rescues it), when the first call has arguments, when the first call is not `$`-prefixed (`_controller.flashClear()` then `_controller.$setFlashStorage("x")` compiles), or when the second call is zero-arg. Fix by reordering (`g.$clearCache("action")` first) or hoisting a `var`/assignment ahead of the pair. Hit by `ControllerHardenerSpec` / `ControllerHardenerShouldSpec` `afterEach` blocks (the Aug 28 matrix dispatch showed the whole adobe2025 leg at `tests="0"`).
- Bisect this class of bug with a single probe against a running container instead of CI (~13s vs ~19min):
+ **16c — a quoted-string named argument in a direct call, Adobe 2023 ONLY.** `obj.method("name" = value)` — a named argument whose *name* is a quoted string literal, in a direct method/function call — is a compile error on **Adobe CF 2023** (`MissingNameException`), but compiles on Lucee 6/7, BoxLang, **and Adobe 2025**. This is the inverse footprint of its siblings: 16a fails both Adobe engines, 16b/16b-ext are Adobe 2025 only, and this one is the lone **2023-only** shape — so an adobe2025-green smoke does NOT cover it. **Unlike the closure-sensitive 16b-ext, this trap is context-independent** — probe-verified on adobe2023/62023, it fails identically in all four call contexts tested: a single-expression arrow (`(x) => obj.method("n" = x)`), a block-bodied arrow, a regular `function()` closure, and a plain statement in a non-closure method body. The trigger is specifically a `"string"` name needed for a `$`-prefixed or hyphenated argument. Measured boundaries that compile on adobe2023 (do **not** "fix" them): an *unquoted* named argument (`name = value`), a quoted *struct-literal* key (`{"$x" = 1}` — it is a struct key, not a function-argument name), and the `argumentCollection` workaround below. Like the others, Adobe blames the enclosing `describe(...)` / `function` line, and one occurrence zeroes the **entire adobe2023 leg** (`tests="0"` on every database) because the core suite compiles via `directory=`. Fix: build the arguments in a struct and pass them through `argumentCollection`, which sidesteps argument-name parsing and is byte-identical in effect:
+ ```cfm
+ // WRONG — MissingNameException on adobe2023 (green on Lucee/BoxLang/adobe2025)
+ var out = c.URLFor(controller = "posts", action = "index", "$argsResolved" = true);
+ // RIGHT — the $-prefixed name lives on a struct key, not an argument name
+ var args = {controller = "posts", action = "index"};
+ args["$argsResolved"] = true;
+ var out = c.URLFor(argumentCollection = args);
+ ```
+ Hit by `linkToUrlForDifferentialSpec` ([#4192](https://github.com/wheels-dev/wheels/pull/4192)); it was pre-existing and only the adobe2023 matrix legs caught it (Lucee-only local verification did not).
+
+ Bisect this class of bug with a single probe against a running container instead of CI (~13s vs ~19min) — use the engine's own port (`62025` for the Adobe 2025 shapes above, `62023` for the 16c Adobe 2023 shape):
```bash
curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&format=json&cli=true" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass','COMPILE FAIL'), d.get('RootCause',{}).get('snippet',''))"
@@ -143,6 +154,25 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang
21. **On Adobe CF, a mixin UDF that creates a subclass whose pseudo-constructor copies that same UDF onto `this` fails the first time per JVM.** Adobe CF 2023 and 2025 (Lucee and BoxLang are fine): the call's epilogue pops a super scope its prologue never pushed, so it throws `java.util.EmptyStackException` (or a `NullPointerException` on `this.SymTab_superScopes`) at `NeoPageContext.popSuperScope`. The call's own body completes normally. In Wheels, `application.wo` (a plain `wheels.Global`) runs the `global/objects.cfm` mixin `$createObjectFromRoot` to create `wheels.Public`, and Global's pseudo-constructor (`$promoteIncludedGlobalsToThis()`) copies `$createObjectFromRoot` onto the new object. That made **the first request after every cold start** a 500 at the `Public` line in `onapplicationstart.cfc` ([#3730](https://github.com/wheels-dev/wheels/issues/3730)). Any earlier request absorbs the one-time failure, including the Docker healthcheck `GET /` or a readiness retry. That is why the test suite and CI never see it, and why 51edf9dce "fixed" the wrong thing (a Public.$init include nest). Fix: create **any** Global subclass outside a mixin call first, which initializes the engine state for every Global mixin. `onapplicationstart.$init()` creates `wheels.events.SuperScopePrimer` as its first statement; keep it first, and keep the primer empty. Guard: `vendor/wheels/tests/specs/events/SuperScopePrimerSpec.cfc` (structural, because the state is once per JVM). Verify a change in this area with a **cold first request, healthcheck disabled**, never the suite. CI runs exactly that on every engine in `.github/workflows/cold-start-smoke.yml` (`tools/ci/cold-start-smoke.sh`, #3735). Deep reference: [.ai/wheels/cross-engine-compatibility.md](.ai/wheels/cross-engine-compatibility.md).
+22. **On BoxLang, a request that ends with `abort` skips any `finally` whose `try` has a `catch` clause — even an unreached `catch (any) { rethrow; }`.** A catch-free `try/finally` runs, and so does one whose finally issues a `transaction action=` statement; whether the try or finally contains a transaction action makes no difference, the catch clause alone decides. Lucee 6/7 and Adobe run the finally either way, so **local Lucee and Adobe green do NOT cover this**. Keep must-run cleanup (a lock release, the rollback of a transaction on abort) in a catch-free `try/finally`; to still tell a normal throw from an abort, nest a `try/catch` that sets a flag inside it:
+ ```cfm
+ var exitState = {completed = false, threw = false};
+ try {
+ try {
+ doWork();
+ exitState.completed = true;
+ } catch (any e) {
+ exitState.threw = true;
+ rethrow;
+ }
+ } finally {
+ if (!exitState.completed && !exitState.threw) {
+ // only an abort gets here: run the must-run cleanup
+ }
+ }
+ ```
+ Hit twice: the session-lock release in `withAdvisoryLock(transaction = true)` ([#4330](https://github.com/wheels-dev/wheels/pull/4330)), and `invokeWithTransaction()`, whose rollback lived in the finally of a try with a catch, so on BoxLang an abort inside it kept the writes made before the abort (`$invokeTransactionMethod()` in `vendor/wheels/model/transactions.cfm`).
+
Verify Adobe CF fixes locally before pushing — don't iterate via CI:
```bash
curl -s "http://localhost:62023/wheels/core/tests?db=mysql&format=json" | \
@@ -352,7 +382,7 @@ For new migrator helpers or anywhere you accept a column-name argument: declare
Association foreign-key defaults resolve **either** convention: the default derivation checks which column actually exists on whichever side owns the foreign key, rather than reading the setting ([#3337](https://github.com/wheels-dev/wheels/issues/3337) — before that fix the model layer derived `` unconditionally and a stock `wheels new` app threw `key [id] doesn't exist` on any `include=`). It is schema-driven on purpose: the migrator reads the flag per call, but the model-side default is memoized for the application lifetime, so honouring the flag there would let a runtime flip change migrations without changing models. Apps holding a mix of both shapes work for the same reason.
-**Polymorphic associations are not covered.** `belongsTo(polymorphic=true)` and `hasMany`/`hasOne` with `as=` fix their foreign key to `id` at *registration* time (`vendor/wheels/model/associations.cfm:30`, `:81`, `:134`), before the schema is available, so the join-time resolution never sees a blank to fill. Against an underscore-shaped schema those still need an explicit `foreignKey="_id"`.
+**Polymorphic associations resolve the same way** ([#4457](https://github.com/wheels-dev/wheels/pull/4457)). `belongsTo(polymorphic=true)` and `hasMany`/`hasOne` with `as=` record which of `foreignKey` / `foreignType` they *derived* at registration (`$derivedPolymorphicColumns`). On first use, `$resolvePolymorphicColumns()` (`vendor/wheels/model/associations.cfm`) checks the derived names against the child's table: `id` / `type` when they exist, otherwise `_id` / `_type`. The forms without an underscore win when both exist. Names passed explicitly are kept as given, and `foreignType=` ([#4398](https://github.com/wheels-dev/wheels/pull/4398)) names a type column that matches neither convention.
## Wheels Conventions
@@ -385,7 +415,7 @@ component extends="wheels.WheelsTest" {
- **App tests**: `/wheels/app/tests` — project-specific, in `tests/specs/`. Uses `tests/populate.cfm` and `tests/TestRunner.cfc`.
- **Core tests**: `/wheels/core/tests` — framework, in `vendor/wheels/tests/specs/`. Uses `vendor/wheels/tests/populate.cfm`. **This is what CI runs across all engines × DBs.**
-**Isolated test application (#3374):** `Application.cfc` includes `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` so runner URLs (and TestClient/browser requests that send `X-Wheels-Test-Context`) bind `_wheelsTest` — a separate CFML application scope. The live `application.wheels` is not swapped. `$testClient(testContext=false)` addresses the live app. A request-scoped overlay cannot replace this (blockers B1–B9 on #3025). Existing apps without the include still use the #3373 named-lock swap on the live scope.
+**Isolated test application (#3374):** `Application.cfc` includes `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` so runner URLs (and browser requests carrying the per-process runner secret) bind `_wheelsTest` — a separate CFML application scope. The live `application.wheels` is not swapped. `$testClient(testContext=false)` addresses the live app. A request-scoped overlay cannot replace this (blockers B1–B9 on #3025). Existing apps without the include still use the #3373 named-lock swap on the live scope. **Gated:** the switch is honoured only when `WHEELS_ENV` ∈ {development, testing}; the runner path is matched against `path_info`/`script_name`; the `X-Wheels-Test-Context` header/cookie must equal the per-process `server.$wheelsTestContextSecret` from a loopback `cgi.remote_addr`. The authoritative teeth is a fail-closed backstop in `events/onapplicationstart.cfc` that refuses a `_wheelsTest` start whenever the config-resolved environment is not development/testing, before any start-up side effect; the runners also refuse to run in production and `/wheels/app/tests` defaults to the `_test` DB. The core suite therefore needs `WHEELS_ENV=development` (set in `compose.yml` and `tools/test-local.sh`); without it the runner falls back to the live scope and logs an isolation-off warning.
**Critical**: core tests use `directory="wheels.tests.specs"` which compiles EVERY CFC in the directory. One compilation error in any spec file crashes the entire suite for that engine. The "inline closure as constructor named arg" anti-pattern (#5 in Cross-Engine Invariants) is the classic example.
@@ -393,7 +423,11 @@ component extends="wheels.WheelsTest" {
- **Test infra scope**: Wheels internals (`$dbinfo`, `model()`, etc.) aren't available as bare calls in `.cfm` files included from plain CFCs like `TestRunner.cfc`. Use `application.wo.model()` or native CFML tags (`cfdbinfo`).
- **`#` escape**: HTML entities like `o` contain `#` which CFML interprets as expression delimiter. In string literals, escape: `#111;`. Comments (`//`) are fine. Unescaped `#` in strings crashes the **entire** test suite, not just that file.
-- **`$clearRoutes()` in test specs**: NOT inherited from `wheels.WheelsTest`. Copy from `linksSpec.cfc` if your spec manipulates routes.
+- **Route-state save/restore in test specs**: `wheels.WheelsTest` provides `$snapshotRoutes()`, `$restoreRoutes(snapshot)`, and `$clearRoutes()` ([#4194](https://github.com/wheels-dev/wheels/issues/4194)). Any spec that redefines the route table MUST snapshot in `beforeEach` and restore in `afterEach`, or it leaks stale state into later specs (routes, staticRoutes, namedRoutePositions, the urlFor caches, and #4183's index are all separate — a partial restore is the #4192 bug class). Use the helpers rather than hand-rolling the save/restore:
+ ```cfm
+ beforeEach(() => { variables._routes = $snapshotRoutes(); $clearRoutes(); g.mapper()...end(); g.$setNamedRoutePositions(); });
+ afterEach(() => $restoreRoutes(variables._routes));
+ ```
### Running tests locally
@@ -438,7 +472,9 @@ Specs extend `wheels.wheelstest.BrowserTest`. Install Playwright once: `wheels b
The user-facing quick references — Model, Routing, Pagination helpers,
Middleware, DI container, Package system + CLI, Migrations & Seeding,
Background Jobs, SSE, and app testing — live in
-`docs/consumer-ai/CLAUDE.md`, which ships in every distributed artifact
+`docs/consumer-ai/CLAUDE.md` (conventions, common mistakes, testing and a
+topic index, kept under 8 KB) and `docs/consumer-ai/.ai/.md`
+(the reference topics), which ship in every distributed artifact
(ForgeBox core, starter app, `wheels new` scaffolds) and is what AI tools
inside USER apps auto-load. Keep both files in sync: when a quick
reference changes here, update the consumer copy (or run
@@ -480,7 +516,7 @@ User-facing `fix`/`feat` PRs add a **fragment file**, never a direct `CHANGELOG.
Run `wheels setup agents` to write `.mcp.json` and `.opencode.json` in the current project (it merges, preserving any other servers you already list). It is `setup agents`, not `setup mcp`: LuCLI intercepts the literal token `mcp` in **any** argument position
(`wheels info mcp` is intercepted too), so no argument may be spelled `mcp`. `setup agents` avoids the token entirely — see the MCP integration guide for OpenCode/Cursor variants.
-Tools are auto-discovered from `cli/lucli/Module.cfc` public functions. Names in `tools/list` are the bare function names — NOT `wheels_*`-prefixed (verified by a stdio `tools/list` against the source module): `analyze`, `create`, `db`, `destroy`, `doctor`, `generate`, `info`, `migrate`, `notes`, `packages`, `reload`, `routes`, `seed`, `stats`, `test`, `upgrade`, `validate` (17 tools; the `wheels` server entry in `.mcp.json` namespaces them per client). CLI-only, stateful, and side-effecting commands (`main`, `mcp`, `setup`, `d`, `g`, `new`, `console`, `start`, `stop`, `engines`, `browser`, `jobs`, `coverage`, `docs`, `deploy`, …) are hidden via `mcpHiddenTools()`, which also auto-hides every public `$`-prefixed helper — read that function for the current list.
+Tools are auto-discovered from `cli/lucli/Module.cfc` public functions. Names in `tools/list` are the bare function names — NOT `wheels_*`-prefixed (verified by a stdio `tools/list` against the source module): `analyze`, `db`, `destroy`, `doctor`, `generate`, `info`, `lookup`, `migrate`, `notes`, `packages`, `reload`, `routes`, `seed`, `stats`, `test`, `upgrade`, `validate` (17 tools; the `wheels` server entry in `.mcp.json` namespaces them per client). CLI-only, stateful, and side-effecting commands (`main`, `mcp`, `setup`, `d`, `g`, `new`, `create`, `console`, `start`, `stop`, `engines`, `browser`, `jobs`, `coverage`, `docs`, `deploy`, …) are hidden via `mcpHiddenTools()`, which also auto-hides every public `$`-prefixed helper — read that function for the current list.
**Deprecated:** the in-dev-server HTTP endpoint at `/wheels/mcp`. Emits a deprecation notice on first request. Migrate to the stdio surface.
diff --git a/app/events/onabort.cfm b/app/events/onabort.cfm
index 2f73cef768..8b18381e8a 100644
--- a/app/events/onabort.cfm
+++ b/app/events/onabort.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onAbort" event.
+ // Place code here that should be executed on the "onAbort" event.
+
\ No newline at end of file
diff --git a/app/events/onapplicationend.cfm b/app/events/onapplicationend.cfm
index 4593f6476f..55a1caeec5 100644
--- a/app/events/onapplicationend.cfm
+++ b/app/events/onapplicationend.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onApplicationEnd" event.
+ // Place code here that should be executed on the "onApplicationEnd" event.
+
\ No newline at end of file
diff --git a/app/events/onapplicationstart.cfm b/app/events/onapplicationstart.cfm
index bdf4559576..c37ee3c523 100644
--- a/app/events/onapplicationstart.cfm
+++ b/app/events/onapplicationstart.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onApplicationStart" event.
+ // Place code here that should be executed on the "onApplicationStart" event.
+
\ No newline at end of file
diff --git a/app/events/onrequestend.cfm b/app/events/onrequestend.cfm
index 22e126bb47..2849fb9601 100644
--- a/app/events/onrequestend.cfm
+++ b/app/events/onrequestend.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onRequestEnd" event.
+ // Place code here that should be executed on the "onRequestEnd" event.
+
\ No newline at end of file
diff --git a/app/events/onrequeststart.cfm b/app/events/onrequeststart.cfm
index a599336eda..f52c3e4bf1 100644
--- a/app/events/onrequeststart.cfm
+++ b/app/events/onrequeststart.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onRequestStart" event.
+ // Place code here that should be executed on the "onRequestStart" event.
+
\ No newline at end of file
diff --git a/app/events/onsessionend.cfm b/app/events/onsessionend.cfm
index 209c361798..51cd06f767 100644
--- a/app/events/onsessionend.cfm
+++ b/app/events/onsessionend.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onSessionEnd" event.
+ // Place code here that should be executed on the "onSessionEnd" event.
+
\ No newline at end of file
diff --git a/app/events/onsessionstart.cfm b/app/events/onsessionstart.cfm
index aa07d94562..448d0e7dd5 100644
--- a/app/events/onsessionstart.cfm
+++ b/app/events/onsessionstart.cfm
@@ -1,3 +1,5 @@
+
-// Place code here that should be executed on the "onSessionStart" event.
+ // Place code here that should be executed on the "onSessionStart" event.
+
\ No newline at end of file
diff --git a/app/snippets/dbmigrate/change-column.txt b/app/snippets/dbmigrate/change-column.txt
index 310eea5c74..36e0507154 100644
--- a/app/snippets/dbmigrate/change-column.txt
+++ b/app/snippets/dbmigrate/change-column.txt
@@ -23,7 +23,7 @@ component extends="|DBMigrateExtends|" hint="|DBMigrateDescription|" {
var state = {};
transaction {
try {
- changeColumn(table = '|tableName|', columnType = '', columnName = 'columnName', default = '', allowNull = true);
+ changeColumn(table = '|tableName|', columnType = '', columnName = 'columnName', allowNull = true);
} catch (any e) {
state.exception = e;
}
diff --git a/app/snippets/dbmigrate/change-table.txt b/app/snippets/dbmigrate/change-table.txt
index d258d7987f..539224b568 100644
--- a/app/snippets/dbmigrate/change-table.txt
+++ b/app/snippets/dbmigrate/change-table.txt
@@ -7,7 +7,7 @@
EXAMPLE:
t = changeTable(name='employees');
- t.string(columnNames="fullName", default="", allowNull=true, limit="255");
+ t.string(columnNames="fullName", allowNull=true, limit="255");
t.change();
*/
component extends="|DBMigrateExtends|" hint="|DBMigrateDescription|" {
diff --git a/app/snippets/dbmigrate/create-table.txt b/app/snippets/dbmigrate/create-table.txt
index 101b02f545..dcffd321ea 100644
--- a/app/snippets/dbmigrate/create-table.txt
+++ b/app/snippets/dbmigrate/create-table.txt
@@ -10,17 +10,17 @@
EXAMPLE:
t = createTable(name='employees', force=false, id=true, primaryKey='empId');
- t.string(columnNames='firstName,lastName', default='', allowNull=true, limit='255');
- t.text(columnNames='bio', default='', allowNull=true);
+ t.string(columnNames='firstName,lastName', allowNull=true, limit='255');
+ t.text(columnNames='bio', allowNull=true);
t.binary(columnNames='credentials');
- t.biginteger(columnNames='sinsCommitted', default='', allowNull=true, limit='1');
- t.char(columnNames='code', default='', allowNull=true, limit='8');
- t.decimal(columnNames='hourlyWage', default='', allowNull=true, precision='1', scale='2');
- t.date(columnNames='dateOfBirth', default='', allowNull=true);
- t.datetime(columnNames='employmentStarted', default='', allowNull=true);
- t.float(columnNames='height', default='', allowNull=true);
- t.integer(columnNames='age', default='', allowNull=true, limit='1');
- t.time(columnNames='lunchStarts', default='', allowNull=true);
+ t.biginteger(columnNames='sinsCommitted', allowNull=true, limit='1');
+ t.char(columnNames='code', allowNull=true, limit='8');
+ t.decimal(columnNames='hourlyWage', allowNull=true, precision='1', scale='2');
+ t.date(columnNames='dateOfBirth', allowNull=true);
+ t.datetime(columnNames='employmentStarted', allowNull=true);
+ t.float(columnNames='height', allowNull=true);
+ t.integer(columnNames='age', allowNull=true, limit='1');
+ t.time(columnNames='lunchStarts', allowNull=true);
t.uniqueidentifier(columnNames='uid', default='newid()', allowNull=false);
t.references(referenceNames="vacation");
t.timestamps();
diff --git a/app/snippets/dbmigrate/remove-column.txt b/app/snippets/dbmigrate/remove-column.txt
index 0ccdad7840..8d640ccfed 100644
--- a/app/snippets/dbmigrate/remove-column.txt
+++ b/app/snippets/dbmigrate/remove-column.txt
@@ -34,7 +34,7 @@ component extends="|DBMigrateExtends|" hint="|DBMigrateDescription|" {
var state = {};
transaction {
try {
- addColumn(table = '|tableName|', columnType = '', columnName = 'columnName', default = '', allowNull = true);
+ addColumn(table = '|tableName|', columnType = '', columnName = 'columnName', allowNull = true);
} catch (any e) {
state.exception = e;
}
diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc
index 63d77e6d8a..b94c89ca11 100644
--- a/cli/lucli/Module.cfc
+++ b/cli/lucli/Module.cfc
@@ -118,11 +118,43 @@ component extends="modules.BaseModule" {
__arguments = [];
structDelete(this, "__arguments");
if (!structIsEmpty(arguments.callerArgs)) {
- return arguments.callerArgs;
+ // LuCLI (wheels-dev/LuCLI#17) adds a runtime-owned
+ // __lucliMcpCall=true to every MCP tools/call and never to a
+ // terminal call. Record it for this command and take it out, so no
+ // command sees it as an argument of its own. Only LuCLI's own
+ // hand-off reaches this branch; internal delegation (generate ->
+ // new) arrives empty and keeps the outer call's answer (#3980).
+ variables.$wheelsMcpCall = structKeyExists(arguments.callerArgs, "__lucliMcpCall");
+ structDelete(arguments.callerArgs, "__lucliMcpCall");
+ if (!structIsEmpty(arguments.callerArgs)) {
+ return arguments.callerArgs;
+ }
}
return argvToCollection(isArray(raw) ? raw : []);
}
+ /**
+ * Throws Wheels.InvalidArguments for an MCP tools/call that would create an
+ * application: that scaffolds into the MCP server's working directory, so it
+ * stays a terminal command.
+ */
+ private void function $refuseAppCreationOverMcp(required string command) {
+ if ($isMcpCall()) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "#arguments.command# isn't available over MCP: creating an application writes into the MCP server's working directory. Run `wheels new ` in a terminal instead."
+ );
+ }
+ }
+
+ /**
+ * True while running a command that LuCLI invoked for an MCP tools/call
+ * (see structuredArgs()). A terminal call is never one.
+ */
+ public boolean function $isMcpCall() {
+ return variables.$wheelsMcpCall ?: false;
+ }
+
/**
* Reconstruct LuCLI's structured argCollection from a raw argv array.
*
@@ -164,7 +196,7 @@ component extends="modules.BaseModule" {
// ─────────────────────────────────────────────────
/**
- * hint: Declare public functions to hide from MCP tools/list.
+ * Declare public functions to hide from MCP tools/list.
*
* These remain reachable as CLI subcommands. Hidden because they are
* stateful (start/stop), destructive (new scaffolds a whole project),
@@ -196,6 +228,7 @@ component extends="modules.BaseModule" {
"g", // alias for generate
"dbmigrate", // alias for migrate — a duplicate tool with no inputSchema otherwise
"new", // scaffolds a whole new Wheels project
+ "create", // `create app` runs new() — same reason (#3910)
"console", // interactive CFML REPL — not usable over stdio
"deploy", // SSH/pushes/restarts on remote hosts — side effects off-machine (#2963)
"start", // dev server lifecycle (stateful)
@@ -204,10 +237,12 @@ component extends="modules.BaseModule" {
"browser", // multi-step browser testing flow
"jobs", // `jobs work` is a long-lived poll loop — no single-call MCP semantics (like start/stop)
"coverage", // instruments app/ on disk then runs the suite — stateful, not single-call MCP semantics
+ "framework", // `framework install` writes the framework into vendor/wheels/ — a side-effecting install, not a query
// downloads a ~30 MB bundle from GitHub and unpacks it into the CLI
// home — a side-effecting install step, not a query
"docs",
"mcpToolSpecs", // per-tool inputSchema registry read by LuCLI — not itself a tool
+ "onMissingMethod", // unknown-command handler (#3890) — a CFML hook, not a tool
// $-prefixed internal helpers. Public ONLY so TestCommandSpec can
// unit-test them directly (the cli/CLAUDE.md "public for specs"
// carve-out) — they are not commands and must never surface as MCP
@@ -267,11 +302,11 @@ component extends="modules.BaseModule" {
public struct function mcpToolSpecs() {
return {
"analyze" = analyzeArgSpec().toInputSchema(),
- "create" = createArgSpec().toInputSchema(),
"db" = dbArgSpec().toInputSchema(),
"destroy" = destroyArgSpec().toInputSchema(),
"doctor" = verboseFlagSpec().toInputSchema(),
"generate" = generateArgSpec().toInputSchema(),
+ "lookup" = lookupArgSpec().toInputSchema(),
"migrate" = migrateArgSpec().toInputSchema(),
"notes" = notesArgSpec().toInputSchema(),
"packages" = packagesArgSpec().toInputSchema(),
@@ -292,9 +327,10 @@ component extends="modules.BaseModule" {
*/
private any function migrateArgSpec() {
return new services.ArgSpec()
- .positional(name = "action", default = "latest", choices = "latest,up,down,info,doctor,forget,pretend,rename-system-tables,diff", description = "Migration action: latest, up, down, info, doctor, forget, pretend, rename-system-tables, diff")
+ .positional(name = "action", default = "latest", choices = "latest,up,down,info,doctor,forget,pretend,unlock,rename-system-tables,diff", description = "Migration action: latest, up, down, info, doctor, forget, pretend, unlock, rename-system-tables, diff")
.positional(name = "version", default = "", description = "Version for forget/pretend")
.flag(name = "yes", default = false, description = "Confirm forget/pretend")
+ .flag(name = "force", default = false, description = "unlock: remove the migration lock even when an instance holds it")
.flag(name = "dry-run", default = false, description = "Preview rename-system-tables without writing")
.positional(name = "model", default = "", description = "Model to diff (diff action; omit for all models)")
.option(name = "rename", default = "", description = "Rename hint OLD:NEW (repeatable; Model.OLD:NEW for diffAll)")
@@ -348,10 +384,10 @@ component extends="modules.BaseModule" {
* Bind `wheels create`'s and the way ArgSpec.parse() does (a
* typed token, else the named key an MCP tools/call sends, #2963) and
* collect what to forward to new(): the name, any further tokens, then the
- * remaining named keys as flags. createArgSpec() marks both positionals
- * required for the MCP schema; this binding stays lenient so an unknown
+ * remaining named keys as flags. The binding stays lenient so an unknown
* type still reports "Unknown create type" and `create app` with no name
- * still reaches new()'s own handling. Public for specs.
+ * still reaches new()'s own handling. `create` is CLI-only (hidden from
+ * MCP, #3910), so it advertises no schema. Public for specs.
*/
public struct function $createArgs(required struct coll) {
// Non-strict on purpose: create forwards every other key to new(), whose
@@ -460,17 +496,19 @@ component extends="modules.BaseModule" {
return new services.ArgSpec()
.option(name = "environment", default = "", description = "Environment whose seed files run (defaults to the app's current environment)")
.option(name = "mode", default = "auto", choices = "auto,convention,generate", description = "Seeding mode: auto (detect), convention (app/db/seeds.cfm), or generate (random test data)")
- .flag(name = "generate", default = false, description = "Shorthand for --mode=generate");
+ .flag(name = "generate", default = false, description = "Shorthand for --mode=generate")
+ .option(name = "models", default = "", description = "generate mode: comma-delimited model names to generate seed data for (default: every model under app/models)")
+ .option(name = "count", default = "", description = "generate mode: records to create per model (default 10)");
}
private any function testArgSpec() {
return new services.ArgSpec()
- .option(name = "filter", default = "", description = "What to run: a spec directory or one spec file, as a dotted path (tests.specs.models, tests.specs.models.UserSpec) or a bare name (models, UserSpec)")
+ .option(name = "filter", default = "", description = "What to run: a spec directory or one spec file, as a path (tests/specs/models, tests/specs/models/UserSpec.cfc), a dotted path (tests.specs.models, tests.specs.models.UserSpec) or a bare name (models, UserSpec)")
.option(name = "directory", default = "", description = "Documented alias for --filter")
- .option(name = "reporter", default = "simple", choices = "simple,json,tap", description = "Output format: simple, json, or tap")
+ .option(name = "reporter", default = "simple", choices = "simple,json,tap", description = "Output format")
.option(name = "db", default = "sqlite", choices = "sqlite,h2,mysql,postgres,sqlserver,sqlserver_cicd,oracle,cockroachdb", description = "--core only: the database the framework core suite runs against. The app suite ignores it and uses the app's test datasource")
.option(name = "base-path", default = "", description = "URL prefix the app is mounted under (e.g. /myapp). Auto-derived from WHEELS_SUBPATH or set(subpath=...) when omitted.")
- .option(name = "timeout", default = "", description = "Seconds to wait for the suite to finish (default 900). Also settable with WHEELS_TEST_TIMEOUT. On the terminal use --test-timeout=: it works on every LuCLI runtime, while a plain --timeout only reaches this command on LuCLI builds that include the module-timeout fix (LuCLI ##130)")
+ .option(name = "timeout", default = "", description = "Seconds to wait for the suite to finish (default 900). Also settable with WHEELS_TEST_TIMEOUT. On the terminal use --test-timeout=: it works on every LuCLI runtime, while a plain --timeout only reaches this command on LuCLI builds that include the module-timeout fix")
.flag(name = "verbose", default = false, description = "Print per-spec detail instead of the summary rollup")
.flag(name = "ci", default = false, description = "CI mode output")
.flag(name = "core", default = false, description = "Run the framework core suite (vendor/wheels/tests) instead of the app suite")
@@ -492,23 +530,22 @@ component extends="modules.BaseModule" {
return new services.ArgSpec()
.positional(name = "type", default = "", choices = "resource,model,controller,view", description = "What to remove: resource, model, controller, or view")
.positional(name = "name", default = "", description = "Name of the artifact to remove")
- .flag(name = "force", default = false, description = "Skip the confirmation prompt");
+ .flag(name = "force", default = false, description = "Delete the files; without it, destroy only lists what it would delete");
}
+ // Feeds only the MCP inputSchema (mcpToolSpecs). `app` is no longer
+ // advertised as a type choice (#3910), since `new` and `create` are hidden
+ // from MCP. This does not stop a client that ignores the schema; refusing
+ // type=app server-side is #3980. `wheels generate app ` on the
+ // command line is unchanged.
private any function generateArgSpec() {
return new services.ArgSpec()
- .positional(name = "type", required = true, choices = "model,controller,view,scaffold,migration,api-resource,route,test,property,helper,policy,snippets,admin,auth,app", description = "What to generate: model, controller, view, scaffold, migration, api-resource, route, test, property, helper, policy, snippets, admin, auth, or app")
- .positional(name = "name", description = "Artifact name (model/controller/resource name, or the app name for `generate app`)")
- .positional(name = "attributes", description = "Column definitions for model/scaffold (space- or comma-delimited name:type pairs, e.g. 'title:string body:text')")
+ .positional(name = "type", required = true, choices = "model,controller,view,scaffold,migration,api-resource,route,test,property,helper,policy,snippets,admin,auth", description = "What to generate: model, controller, view, scaffold, migration, api-resource, route, test, property, helper, policy, snippets, admin, or auth")
+ .positional(name = "name", description = "Artifact name (model, controller or resource name)")
+ .positional(name = "attributes", description = "Column definitions for model/scaffold (space- or comma-delimited name:type pairs, e.g. 'title:string body:text'). Columns are required by default; 'name:type:optional' makes one nullable and 'name:type=value' gives it a default (both omit it from validatesPresenceOf)")
.flag(name = "dry-run", default = false, description = "Print the would-be paths and write nothing");
}
- private any function createArgSpec() {
- return new services.ArgSpec()
- .positional(name = "type", required = true, choices = "app", description = "What to create: app")
- .positional(name = "name", required = true, description = "Application name");
- }
-
private any function verboseFlagSpec() {
return new services.ArgSpec()
.flag(name = "verbose", default = false, description = "Print detailed output");
@@ -526,7 +563,7 @@ component extends="modules.BaseModule" {
.option(name = "to", default = "", description = "Target Wheels version. check: version to scan against (default: latest). apply: must match the CLI's bundled framework version")
.option(name = "format", default = "", choices = "text,json", description = "check only: text (default) or json for machine-readable output")
.flag(name = "strict", default = false, description = "check only: escalate advisory findings to a hard failure (non-zero exit) so CI can gate on them")
- .flag(name = "nobackup", default = false, description = "apply only: skip the vendor/wheels.bak- backup of the existing framework")
+ .flag(name = "nobackup", default = false, description = "apply only: skip the .wheels/backups/wheels.bak- backup of the existing framework")
.flag(name = "allow-downgrade", default = false, description = "apply only: proceed even when the CLI's bundled framework is older than the app's vendor/wheels/ (refused by default)")
.flag(name = "offline", default = false, description = "check only: skip the latest-release lookup on GitHub (pass --to). Also set by WHEELS_OFFLINE=1")
// CLI-only spellings read by parseUpgradeArgs, deliberately NOT
@@ -539,14 +576,32 @@ component extends="modules.BaseModule" {
.accept("dry-run");
}
+ private any function frameworkArgSpec() {
+ return new services.ArgSpec()
+ .positional(name = "subcommand", default = "", choices = "install,help", description = "`install` installs the CLI's bundled framework into vendor/wheels/ for an app that has none (e.g. one moving off CommandBox). Omitted/empty prints usage and changes nothing")
+ .option(name = "to", default = "", description = "install only: target Wheels version. Must match the CLI's bundled framework version — no downloads")
+ .accept("help")
+ .accept("h");
+ }
+
private any function jobsArgSpec() {
return new services.ArgSpec()
- .positional(name = "action", default = "status", description = "work (long-lived worker loop) or status (queue snapshot). Defaults to status")
- .option(name = "queue", default = "", description = "work: comma-delimited queue names to process in order. status: single queue to filter by. Empty = all queues")
+ .positional(name = "action", default = "status", description = "work (long-lived worker loop), status (queue snapshot), enqueue (add a job), drain (stop this server starting new jobs), resume, tick (run one tick: reap, then run jobs up to the per-host cap), or install (write a migration that creates the job tables). Defaults to status")
+ .positional(name = "job", default = "", description = "enqueue only: the job class under app/jobs/, e.g. SendWelcomeEmailJob or billing.InvoiceJob")
+ .option(name = "queue", default = "", description = "work: comma-delimited queue names to process in order. status: single queue to filter by. enqueue: the queue to put the job on (default: the job's own). Empty = all queues")
+ .option(name = "data", default = "", description = "enqueue only: the job's data, a JSON object passed to perform()")
+ .option(name = "priority", default = "", description = "enqueue only: a whole number; higher runs first (default: the job's own)")
+ .option(name = "in", default = "", description = "enqueue only: run it after this many seconds instead of now")
+ .option(name = "at", default = "", description = "enqueue only: run it at this time, ISO 8601 (2026-10-05T14:30:00Z, or 2026-10-05 14:30 for local time). A one-off delayed run, not a recurring schedule")
.option(name = "interval", default = 5, type = "numeric", description = "work only: seconds to wait between polls when no job is available")
.option(name = "max-jobs", default = 0, type = "numeric", description = "work only: stop after this many jobs (successes + failures count). 0 = run until stopped")
+ .flag(name = "stop-when-empty", default = false, description = "work only: exit when a poll finds no job ready to run, instead of waiting for more. For one-shot batches from cron or CI; combines with --max-jobs")
.flag(name = "quiet", default = false, description = "work only: suppress per-job completion output, only print failures")
- .option(name = "format", default = "table", description = "status only: output format, table or json");
+ .option(name = "job-timeout", default = 0, type = "numeric", description = "work only: cap, in seconds, on each job's run time. 0 = each job runs with its own timeout (this.timeout, default 300). Not --timeout: on some CLI runtimes that is the runtime's own option, which stops the whole worker")
+ .option(name = "format", default = "table", description = "status, enqueue, drain and resume: output format, table or json")
+ .option(name = "wait", default = "", type = "any", description = "drain only: after draining, wait until this server has no running jobs (--wait = up to 600 seconds, --wait= for another limit). Exits with an error if jobs are still running at the limit")
+ .option(name = "expires", default = 3600, type = "numeric", description = "drain only: seconds until the drain lifts itself if nothing resumes it. 0 = until wheels jobs resume")
+ .flag(name = "force", default = false, description = "install only: overwrite the jobs migration this command wrote before");
}
private any function dbArgSpec() {
@@ -596,7 +651,7 @@ component extends="modules.BaseModule" {
*/
public string function version() {
var nl = chr(10);
- var moduleVersion = super.version();
+ var moduleVersion = $displayVersion();
var channel = new services.ReleaseChannel().classify(moduleVersion);
var channelTag = len(channel) ? " (" & channel & ")" : "";
@@ -615,6 +670,39 @@ component extends="modules.BaseModule" {
return arrayToList(lines, nl);
}
+ /**
+ * The CLI version to show a person. A source checkout's module.json carries
+ * the unstamped `@build.version@` token, which used to be printed as is
+ * (#3891). An unstamped module run from inside the monorepo reports the root
+ * wheels.json version with `-dev`, the derivation FrameworkInstaller uses for
+ * the framework; anywhere else it reports BuildInfo's `0.0.0-dev` sentinel.
+ * Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public string function $displayVersion(string rawVersion, string moduleDir) {
+ var v = structKeyExists(arguments, "rawVersion") ? arguments.rawVersion : super.version();
+ if (!(left(v, 7) == "@build." && right(v, 1) == "@")) return v;
+ var dir = structKeyExists(arguments, "moduleDir") ? arguments.moduleDir : getDirectoryFromPath(getCurrentTemplatePath());
+ try {
+ var File = createObject("java", "java.io.File");
+ var root = File.init(dir & "/../..").getCanonicalPath();
+ for (var name in ["wheels.json", "box.json"]) {
+ var manifestPath = root & "/" & name;
+ if (!fileExists(manifestPath)) continue;
+ var manifest = deserializeJSON(fileRead(manifestPath));
+ var isMonorepo = isStruct(manifest) && (
+ (structKeyExists(manifest, "name") && manifest.name == "Wheels.fw")
+ || (structKeyExists(manifest, "slug") && manifest.slug == "wheels")
+ );
+ if (isMonorepo && structKeyExists(manifest, "version") && len(manifest.version) && left(manifest.version, 7) != "@build.") {
+ return manifest.version & "-dev";
+ }
+ }
+ } catch (any e) {
+ // Unreadable manifest: fall through to the sentinel.
+ }
+ return "0.0.0-dev";
+ }
+
private string function $detectLucliVersion() {
try {
var sys = createObject("java", "java.lang.System");
@@ -706,7 +794,7 @@ component extends="modules.BaseModule" {
}
}
- var v = super.version();
+ var v = $displayVersion();
var help = "Wheels CLI " & v & nl;
help &= " CFML MVC framework — code generation, migrations, testing, server management" & nl & nl;
help &= "Usage:" & nl;
@@ -721,11 +809,11 @@ component extends="modules.BaseModule" {
help &= " generate Generate model, controller, scaffold, migration, etc." & nl;
help &= " destroy (or d) Remove generated files" & nl & nl;
help &= "Database:" & nl;
- help &= " migrate Run database migrations (latest, up, down, info, doctor, forget, pretend, rename-system-tables, diff)" & nl;
+ help &= " migrate Run database migrations (latest, up, down, info, doctor, forget, pretend, unlock, rename-system-tables, diff)" & nl;
help &= " seed Run database seeds" & nl;
help &= " db Database management (reset, status, version)" & nl & nl;
help &= "Background Jobs:" & nl;
- help &= " jobs Job queue worker and stats (work, status)" & nl & nl;
+ help &= " jobs Job queue worker, stats and enqueue (work, status, enqueue)" & nl & nl;
help &= "Testing & Inspection:" & nl;
help &= " test Run the test suite" & nl;
help &= " browser Browser-based tests (Playwright)" & nl;
@@ -738,11 +826,13 @@ component extends="modules.BaseModule" {
help &= " stats Project statistics (lines of code, model counts, etc.)" & nl;
help &= " notes Find TODO / FIXME / OPTIMIZE comments (--annotations to customize)" & nl & nl;
help &= "Packages & Deployment:" & nl;
- help &= " packages Add, update, search Wheels packages (verb is `add`, not `install`)" & nl;
+ help &= " packages Add (or install), update, search Wheels packages" & nl;
help &= " upgrade Upgrade the Wheels framework in your app (vendor/wheels/); `check` scans, `apply` swaps" & nl;
+ help &= " framework Install the framework (vendor/wheels/) into an app that has none (e.g. moving off CommandBox)" & nl;
help &= " deploy Deploy your app (Kamal-compatible)" & nl & nl;
help &= "Other:" & nl;
help &= " setup Configure this app for external tools (setup agents: AI assistants)" & nl;
+ help &= " lookup Look up the Wheels API and guides offline (a function name or a phrase)" & nl;
help &= " version Show Wheels CLI version" & nl;
help &= " help Show this help" & nl & nl;
help &= "For command-specific help: wheels --help" & nl & nl;
@@ -750,6 +840,28 @@ component extends="modules.BaseModule" {
return help;
}
+ /**
+ * The raw docblock hint of the public command function `fnName`, or "".
+ * Kept as Lucee hands it over, including the literal "hint:" prefix:
+ * $commandHelpParts() keys on that prefix to tell command help from any
+ * other docblock. Walks up the component's `extends` chain, because
+ * getMetaData() lists only the functions a component declares itself: a
+ * subclass of Module (the spec fixtures) would otherwise find no hint for
+ * any command.
+ */
+ private string function $commandHint(required string fnName) {
+ var meta = getMetaData(this);
+ while (isStruct(meta)) {
+ for (var fn in (meta.functions ?: [])) {
+ if (lCase(fn.name ?: "") == arguments.fnName && (fn.access ?: "public") == "public") {
+ return trim(fn.hint ?: "");
+ }
+ }
+ meta = structKeyExists(meta, "extends") ? meta.extends : "";
+ }
+ return "";
+ }
+
/**
* Render per-command help for `wheels --help` from the command function's
* metadata hint. Returns "" for an unknown command so showHelp() falls back to
@@ -762,29 +874,116 @@ component extends="modules.BaseModule" {
if (fnName == "g") { fnName = "generate"; }
if (fnName == "d") { fnName = "destroy"; }
- var hint = "";
- var meta = getMetaData(this);
- for (var fn in (meta.functions ?: [])) {
- if (lCase(fn.name ?: "") == fnName && (fn.access ?: "public") == "public") {
- hint = trim(fn.hint ?: "");
- // The `/** hint: ... */` convention surfaces the value with the
- // literal "hint:" key prefix on Lucee — strip it for clean output.
- hint = trim(reReplaceNoCase(hint, "^hint\s*:\s*", ""));
- break;
- }
- }
- if (!len(hint)) {
+ var parts = $commandHelpParts($commandHint(fnName));
+ if (!len(parts.summary)) {
return "";
}
var help = "wheels " & lCase(trim(arguments.subcommand)) & nl & nl;
- help &= " " & hint & nl & nl;
+ help &= " " & parts.summary & nl & nl;
+ for (var section in parts.sections) {
+ help &= section.heading & nl;
+ for (var line in section.lines) {
+ help &= " " & line & nl;
+ }
+ help &= nl;
+ }
+ // Options come from the command's own ArgSpec (`ArgSpec()`),
+ // the declaration the parser and the MCP schema already share (#3962).
+ var optionLines = $commandOptionLines(fnName);
+ if (arrayLen(optionLines)) {
+ help &= "Options:" & nl & arrayToList(optionLines, nl) & nl & nl;
+ }
+ var examples = $commandExamples(fnName);
+ if (arrayLen(examples)) {
+ help &= "Examples:" & nl & arrayToList(examples, nl) & nl & nl;
+ }
help &= "Run 'wheels help' for the full command list." & nl;
help &= "More info: https://guides.wheels.dev";
return help;
}
+ /**
+ * Split a command function's metadata hint into what `--help` prints: the
+ * one-line `hint:` summary, then any `Usage:` / `Examples:` sections. Lucee
+ * hands the docblock over with blank lines and indentation removed, so a
+ * section runs from its heading to the next heading or the end. Lines
+ * between the summary and the first heading are returned as `stray` and
+ * never printed; maintainer notes belong in `//` comments instead. A
+ * docblock without a `hint:` key is not command help (summary is "").
+ * Public so specs can check every command's docblock shape.
+ */
+ public struct function $commandHelpParts(required string hint) {
+ var rv = {summary = "", sections = [], stray = []};
+ var lines = listToArray(replace(arguments.hint, chr(13), "", "all"), chr(10), true);
+ if (!arrayLen(lines) || !reFindNoCase("^\s*hint\s*:", lines[1])) {
+ return rv;
+ }
+ rv.summary = trim(reReplaceNoCase(lines[1], "^\s*hint\s*:\s*", ""));
+ for (var i = 2; i <= arrayLen(lines); i++) {
+ var line = trim(lines[i]);
+ if (!len(line)) {
+ continue;
+ }
+ if (line == "Usage:" || line == "Examples:") {
+ arrayAppend(rv.sections, {heading = line, lines = []});
+ } else if (arrayLen(rv.sections)) {
+ arrayAppend(rv.sections[arrayLen(rv.sections)].lines, line);
+ } else {
+ arrayAppend(rv.stray, line);
+ }
+ }
+ return rv;
+ }
+
+ /**
+ * The Options lines for `wheels --help`, rendered from the
+ * command's `ArgSpec()` when it has one; empty otherwise. Help
+ * must always render, so a spec that fails to build yields no options.
+ */
+ private array function $commandOptionLines(required string fnName) {
+ var specFnName = arguments.fnName & "ArgSpec";
+ if (!structKeyExists(variables, specFnName) || !isCustomFunction(variables[specFnName])) {
+ return [];
+ }
+ try {
+ var specFn = variables[specFnName];
+ return specFn().toHelpLines();
+ } catch (any e) {
+ return [];
+ }
+ }
+
+ /** Worked examples for `wheels --help`, where a command has them. */
+ private array function $commandExamples(required string fnName) {
+ switch (arguments.fnName) {
+ case "new":
+ return [
+ " wheels new myapp SQLite, port 8080",
+ " wheels new myapp --port=3000 --setup-h2 H2 instead of SQLite",
+ " wheels new myapp --datasource=mydb --no-sqlite Your own datasource",
+ " wheels new myapp --no-agents No .mcp.json / .opencode.json"
+ ];
+ case "test":
+ return [
+ " wheels test Run every spec under tests/specs",
+ " wheels test tests.specs.models Run one folder (a dotted path)",
+ " wheels test tests/specs/models Run one folder (a path)",
+ " wheels test --filter=UserSpec Run one spec file, by name",
+ " wheels test tests/specs/models/UserSpec.cfc Run one spec file, by path",
+ " wheels test --reporter=json Print the raw JSON result"
+ ];
+ case "framework":
+ return [
+ " wheels framework install Install the CLI's bundled framework into vendor/wheels/",
+ " wheels framework install --to=4.2.0 Install, asserting the bundled version"
+ ];
+ default:
+ return [];
+ }
+ }
+
/**
* Dry-run-aware write for generator paths inside Module.cfc (the
* migration builders and $writeGeneratedContent). Mirrors
@@ -973,6 +1172,14 @@ component extends="modules.BaseModule" {
out(" wheels generate admin User");
out(" wheels generate auth");
out(" wheels generate auth --strategy=jwt");
+ out("");
+ out("Property syntax:", "bold");
+ out(" name:type required column: NOT NULL + validatesPresenceOf (the default)");
+ out(" name:type:optional nullable column, excluded from validatesPresenceOf");
+ out(" name:type=value column DEFAULT (also excluded from presence; the default fills an absence)");
+ out(" name:type{N} string length / column size, e.g. title:string{120}");
+ out(" name:enum:a,b,c enum column with the given allowed values");
+ out(" Example: wheels generate model Post title:string body:text:optional status:string=draft");
}
/**
@@ -982,6 +1189,17 @@ component extends="modules.BaseModule" {
private any function $generateDispatch(required string type, required array remaining) {
var canonical = $canonicalGeneratorType(lCase(arguments.type));
+ // `generate app` creates a project, so it is the one generator that
+ // runs outside one. Every other generator writes under app/, and
+ // resolveProjectRoot() falls back to the cwd when no vendor/wheels is
+ // found, so without this check any directory got app files (#3909).
+ if (canonical != "app") $requireWheelsProject("wheels generate");
+
+ // Creating an application is CLI-only (#3910). Hiding `new`/`create` and
+ // dropping `app` from the advertised type enum only stops clients that
+ // validate against the schema, so refuse it here too (#3980).
+ if (canonical == "app") $refuseAppCreationOverMcp("wheels generate app");
+
switch (canonical) {
case "app":
// Delegate to wheels new — pass remaining args as __arguments.
@@ -1186,7 +1404,7 @@ component extends="modules.BaseModule" {
// ─────────────────────────────────────────────────
/**
- * hint: Run database migrations (latest, up, down, info, doctor, forget, pretend, rename-system-tables)
+ * hint: Run database migrations (latest, up, down, info, doctor, forget, pretend, unlock, rename-system-tables)
*/
public string function migrate() {
var args = $migrateArgv(structuredArgs(arguments));
@@ -1219,6 +1437,8 @@ component extends="modules.BaseModule" {
return runForgetOrPretend("forgetVersion", args);
case "pretend":
return runForgetOrPretend("pretendVersion", args);
+ case "unlock":
+ return runMigrationUnlock(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
@@ -1237,7 +1457,7 @@ component extends="modules.BaseModule" {
return runMigrationDiff(args);
default:
out("Unknown migration action: #action#", "red");
- out("Usage: wheels migrate [latest|up|down|info|doctor|forget|pretend|rename-system-tables|diff]");
+ out("Usage: wheels migrate [latest|up|down|info|doctor|forget|pretend|unlock|rename-system-tables|diff]");
throw(type = "Wheels.InvalidArguments", message = "Unknown migration action: #action#");
}
}
@@ -1255,18 +1475,44 @@ component extends="modules.BaseModule" {
*/
private struct function parseSeedArgs(required struct coll) {
var parsed = seedArgSpec().parse(arguments.coll);
+ if (len(trim(parsed.count)) && !reFind("^[0-9]+$", trim(parsed.count))) {
+ throw(type = "Wheels.InvalidArguments", message = "wheels seed: count must be a whole number, got '#parsed.count#'.");
+ }
return {
environment = parsed.environment,
- mode = parsed.generate ? "generate" : parsed.mode
+ mode = parsed.generate ? "generate" : parsed.mode,
+ models = trim(parsed.models),
+ count = trim(parsed.count)
};
}
+ /**
+ * Refuse a seed environment this project doesn't know (#3893): the server
+ * runs app/db/seeds.cfm plus seeds/.cfm when that file exists, so a
+ * typo silently ran the main seeds only. Known: Wheels' four environments,
+ * or any name with a config// directory or an app/db/seeds/.cfm.
+ */
+ private void function $assertKnownSeedEnvironment(required string environment) {
+ var env = trim(arguments.environment);
+ if (!len(env)) return;
+ if (reFind("^[A-Za-z0-9_-]+$", env)) {
+ if (listFindNoCase("development,testing,maintenance,production", env)) return;
+ if (directoryExists(variables.projectRoot & "/config/" & env)) return;
+ if (fileExists(variables.projectRoot & "/app/db/seeds/" & env & ".cfm")) return;
+ }
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "wheels seed: unknown environment '#env#'. Use development, testing, maintenance or production, or an environment with a config/#env#/ directory or app/db/seeds/#env#.cfm."
+ );
+ }
+
/**
* hint: Run database seeds (convention-based or generated)
*/
public string function seed() {
var opts = parseSeedArgs(structuredArgs(arguments));
- return runSeed(opts.mode, opts.environment);
+ $assertKnownSeedEnvironment(opts.environment);
+ return runSeed(opts.mode, opts.environment, opts.models, opts.count);
}
// ─────────────────────────────────────────────────
@@ -1341,6 +1587,20 @@ component extends="modules.BaseModule" {
};
}
+ /**
+ * The one-line warning for a timeout (--test-timeout / timeout) that $resolveTestTimeout() had to
+ * ignore, or "" when it was valid or absent. A mistyped timeout still must
+ * not stop the run, but the fallback may not be silent (maintainer
+ * decision on #2963). Public for specs; hidden from MCP by the $-prefix.
+ */
+ public string function $testTimeoutWarning(string parsedTimeout = "", required numeric resolvedSeconds) {
+ var raw = trim(arguments.parsedTimeout);
+ if (!len(raw) || (isNumeric(raw) && val(raw) > 0)) {
+ return "";
+ }
+ return 'Warning: ignoring invalid timeout "#raw#"; using #arguments.resolvedSeconds#s';
+ }
+
/**
* Seconds to wait for the test-runner response. An explicit value wins
* (`--test-timeout` on the terminal, or `--timeout` on LuCLI builds with
@@ -1359,20 +1619,6 @@ component extends="modules.BaseModule" {
* Non-numeric or non-positive input falls back to the default rather than
* throwing: a mistyped timeout should not be the thing that stops a test run.
*/
- /**
- * The one-line warning for a timeout (--test-timeout / timeout) that $resolveTestTimeout() had to
- * ignore, or "" when it was valid or absent. A mistyped timeout still must
- * not stop the run, but the fallback may not be silent (maintainer
- * decision on #2963). Public for specs; hidden from MCP by the $-prefix.
- */
- public string function $testTimeoutWarning(string parsedTimeout = "", required numeric resolvedSeconds) {
- var raw = trim(arguments.parsedTimeout);
- if (!len(raw) || (isNumeric(raw) && val(raw) > 0)) {
- return "";
- }
- return 'Warning: ignoring invalid timeout "#raw#"; using #arguments.resolvedSeconds#s';
- }
-
public numeric function $resolveTestTimeout(string parsedTimeout = "") {
if (
len(trim(arguments.parsedTimeout))
@@ -1428,7 +1674,14 @@ component extends="modules.BaseModule" {
// CLI normalizes here so `--filter=browser` does what the user
// expects. Onboarding finding #2. A bare spec name resolves to the one
// spec file it names, so it runs as a single bundle (issue 3759).
+ var requested = filter;
filter = $resolveTestFilter(filter, coreTests);
+ // Refuse a scope that would not run as asked, before anything runs
+ // (issues 3963, 3893): no full-suite fallback, no "0 bundles" afterwards.
+ var scopeProblem = $testScopeProblem(filter, coreTests, requested);
+ if (len(scopeProblem)) {
+ throw(type = "Wheels.TestScopeNotFound", message = scopeProblem);
+ }
return runTests(
filter, reporter, format, verboseOutput, coreTests,
@@ -1436,11 +1689,13 @@ component extends="modules.BaseModule" {
);
}
+ // Coverage: instrument app/ with function-level coverage counters, run the
+ // app test suite against the running server, and report a CRAP ranking
+ // (Change Risk Anti-Patterns: complexity^2 x (1 - coverage)^3 + complexity).
+ // The instrumentation is reverted afterward (originals restored exactly).
+ // Kept out of the docblock below: `wheels coverage --help` prints it.
/**
- * Coverage: instrument app/ with function-level coverage counters, run the
- * app test suite against the running server, and report a CRAP ranking
- * (Change Risk Anti-Patterns: complexity^2 x (1 - coverage)^3 + complexity).
- * The instrumentation is reverted afterward (originals restored exactly).
+ * hint: Run the app test suite with function-level coverage and report a CRAP ranking (app/ is restored afterward)
*/
public string function coverage() {
var opts = parseCoverageArgs(structuredArgs(arguments));
@@ -1476,14 +1731,17 @@ component extends="modules.BaseModule" {
}
/**
- * Parse args for `wheels coverage`: --top N (report length) and
- * --no-test-db (test-db=false).
+ * `wheels coverage` options: --top N (report length) and --no-test-db
+ * (test-db=false). Named ArgSpec so `--help` lists them.
*/
+ private any function coverageArgSpec() {
+ return new services.ArgSpec()
+ .option(name = "top", default = "15", description = "How many functions the CRAP ranking lists")
+ .flag(name = "test-db", default = true, description = "Run the suite against the test database");
+ }
+
private struct function parseCoverageArgs(required struct coll) {
- var parsed = new services.ArgSpec()
- .option(name = "top", default = "15")
- .flag(name = "test-db", default = true)
- .parse(arguments.coll);
+ var parsed = coverageArgSpec().parse(arguments.coll);
return {
top = Val(parsed.top),
useTestDb = parsed["test-db"]
@@ -1509,6 +1767,86 @@ component extends="modules.BaseModule" {
return arguments.parsedVerbose || (variables.verboseEnabled ?: false);
}
+ /**
+ * Why a resolved test scope cannot run, or "" when it can (issues 3963,
+ * 3893). The default scope (empty) always can. Otherwise the scope must
+ * be a form the runner accepts AND name a folder or spec file that exists
+ * in this project: the runner falls back to the full default suite for a
+ * scope it rejects, and reports "no test bundles ran" for one that matches
+ * nothing, so both are refused here, before anything runs. The message is
+ * self-contained because MCP clients see only the exception message.
+ */
+ public string function $testScopeProblem(
+ required string scope,
+ boolean coreTests = false,
+ string requested = ""
+ ) {
+ var s = trim(arguments.scope);
+ if (!len(s)) {
+ return "";
+ }
+ var shown = len(trim(arguments.requested)) ? trim(arguments.requested) : s;
+ var accepted = arguments.coreTests
+ ? "Pass a folder or spec file under vendor/wheels/tests/specs as a path (model, model/FooSpec.cfc) or dotted path (wheels.tests.specs.model), or a spec name (--filter=FooSpec)."
+ : "Pass a folder or spec file under tests/specs as a path (tests/specs/models, tests/specs/models/BookSpec.cfc) or dotted path (tests.specs.models), or a spec name (--filter=BookSpec).";
+ var pattern = arguments.coreTests
+ ? "^(wheels\.tests|vendor\.[a-z0-9][a-z0-9\-]*\.tests)(\.[a-zA-Z0-9_]+)*$"
+ : "^tests(\.[a-zA-Z0-9_]+)*$";
+ if (!reFindNoCase(pattern, s)) {
+ return "'#shown#' is not a test scope wheels test can run (resolved to '#s#'). #accepted# Nothing was run.";
+ }
+ var rel = replace(s, ".", "/", "all");
+ if (arguments.coreTests && reFindNoCase("^wheels/", rel)) {
+ rel = "vendor/" & rel;
+ }
+ var target = variables.projectRoot & "/" & rel;
+ if (directoryExists(target) || fileExists(target & ".cfc")) {
+ return "";
+ }
+ return "No test folder or spec file matches '#shown#' (looked for #rel#/ and #rel#.cfc). #accepted# Nothing was run.";
+ }
+
+ /**
+ * Turn a filesystem-style test scope into the dotted form the runner takes
+ * (issue 3963). Input that is not path-like (no `/` or `\`, no `.cfc`
+ * suffix) is returned unchanged. A leading `./`, trailing slashes and a
+ * `.cfc` suffix are dropped; a path under the project root may be absolute.
+ *
+ * App mode: `tests/...` maps as written; anything else is taken relative
+ * to tests/specs (`models/BookSpec.cfc` → `tests.specs.models.BookSpec`).
+ * Core mode: `vendor/wheels/tests/...` → `wheels.tests...`,
+ * `vendor//tests/...` → `vendor..tests...`; anything else is
+ * taken relative to vendor/wheels/tests/specs.
+ */
+ public string function $testPathToScope(required string filter, boolean coreTests = false) {
+ var f = trim(arguments.filter);
+ if (!len(f) || !reFind("[/\\]|\.[cC][fF][cC]$", f)) {
+ return f;
+ }
+ f = replace(f, "\", "/", "all");
+ var root = reReplace(replace(variables.projectRoot, "\", "/", "all"), "/+$", "");
+ if (len(root) && len(f) > len(root) && compare(left(f, len(root) + 1), root & "/") == 0) {
+ f = mid(f, len(root) + 2, len(f));
+ }
+ f = reReplace(f, "^(\./)+", "");
+ f = reReplace(f, "/+$", "");
+ f = reReplace(f, "\.[cC][fF][cC]$", "");
+ f = reReplace(f, "/{2,}", "/", "all");
+ if (!len(f)) {
+ return "";
+ }
+ if (arguments.coreTests) {
+ if (reFind("^vendor/wheels/tests(/|$)", f)) {
+ f = mid(f, len("vendor/") + 1, len(f));
+ } else if (!reFind("^(wheels/tests|vendor/[a-z0-9][a-z0-9\-]*/tests)(/|$)", f)) {
+ f = "wheels/tests/specs/" & f;
+ }
+ } else if (!reFind("^tests(/|$)", f)) {
+ f = "tests/specs/" & f;
+ }
+ return replace(f, "/", ".", "all");
+ }
+
/**
* Normalize a short filter name to a path the test runner's directory
* regex will accept. App mode prepends `tests.specs.`; core mode
@@ -1522,12 +1860,13 @@ component extends="modules.BaseModule" {
* "tests.specs.browser" → "tests.specs.browser"
* "wheels.tests.specs.model" → "wheels.tests.specs.model"
* "vendor.wheels-sentry.tests" → "vendor.wheels-sentry.tests"
+ * "tests/specs/models/BookSpec.cfc" → "tests.specs.models.BookSpec" (a path, issue 3963)
*/
public string function $normalizeTestFilter(
required string filter,
boolean coreTests = false
) {
- var f = trim(arguments.filter);
+ var f = $testPathToScope(trim(arguments.filter), arguments.coreTests);
if (!len(f)) return "";
if (arguments.coreTests) {
@@ -1566,8 +1905,12 @@ component extends="modules.BaseModule" {
boolean coreTests = false
) {
var raw = trim(arguments.filter);
+ // A bare spec file name ("BookSpec.cfc") is looked up like "BookSpec".
+ if (reFind("^[^/\\]+\.[cC][fF][cC]$", raw)) {
+ raw = left(raw, len(raw) - 4);
+ }
var normalized = $normalizeTestFilter(raw, arguments.coreTests);
- if (!len(raw) || find(".", raw)) {
+ if (!len(raw) || reFind("[./\\]", raw)) {
return normalized;
}
var specRoot = variables.projectRoot & (arguments.coreTests ? "/vendor/wheels/tests/specs" : "/tests/specs");
@@ -1580,14 +1923,15 @@ component extends="modules.BaseModule" {
}
var prefix = arguments.coreTests ? "wheels.tests.specs" : "tests.specs";
var rootPath = createObject("java", "java.io.File").init(specRoot).getCanonicalPath();
- // The separator is part of the prefix: without it a sibling such as
- // tests/specsOld, reached through a symlink, looks inside (issue 3800).
+ // Containment goes through $pathWithinExact(), an exact comparison with
+ // a separator boundary, so a sibling such as tests/specsOld reached
+ // through a symlink is outside (issue 3800).
var rootPrefix = rootPath & createObject("java", "java.io.File").separator;
var matches = [];
for (var path in directoryList(specRoot, true, "path", "*.cfc")) {
var filePath = createObject("java", "java.io.File").init(path).getCanonicalPath();
var fileName = listLast(replace(filePath, "\", "/", "all"), "/");
- if (compareNoCase(fileName, raw & ".cfc") != 0 || left(filePath, len(rootPrefix)) != rootPrefix) {
+ if (compareNoCase(fileName, raw & ".cfc") != 0 || !$pathWithinExact(rootPath, filePath)) {
continue;
}
var rel = replace(mid(filePath, len(rootPrefix) + 1, len(filePath)), "\", "/", "all");
@@ -1613,18 +1957,210 @@ component extends="modules.BaseModule" {
return normalized;
}
+ /**
+ * True when `candidate` is `root` itself or a descendant of it. Both must
+ * already be canonical. The comparison is exact (Compare) and qualified by
+ * a separator boundary, so `/srv/App-extra` is not inside `/srv/App`.
+ * Mirrors wheels.PathGuard.pathWithinExact(), which the CLI does not load.
+ * Keep the two in sync: a change to either belongs in both. The only
+ * intended differences are the optional `separator` argument here and
+ * $nativeSeparator() being public.
+ * Public for specs; $-prefixed, so hidden from MCP.
+ *
+ * @separator The platform separator; empty means $nativeSeparator(). Specs
+ * pass it to cover Windows behaviour on any host.
+ */
+ public boolean function $pathWithinExact(
+ required string root,
+ required string candidate,
+ string separator = ""
+ ) {
+ // Normalise ONLY the platform's native separator. A backslash is a legal filename
+ // byte on POSIX, so converting it there would merge a distinct sibling ("App\x")
+ // into the root ("App/x"); only Windows uses "\" as a path separator.
+ var windows = (len(arguments.separator) ? arguments.separator : $nativeSeparator()) == "\";
+ var cand = windows ? replace(arguments.candidate, "\", "/", "all") : arguments.candidate;
+ var baseInput = windows ? replace(arguments.root, "\", "/", "all") : arguments.root;
+ var base = reReplace(baseInput, "/+$", "");
+ if (compare(cand, base) == 0) {
+ return true;
+ }
+ return len(cand) > len(base)
+ && compare(left(cand, len(base) + 1), base & "/") == 0;
+ }
+
+ /**
+ * The platform's native path separator. Prefers java.io.File.separator; falls
+ * back to the OS name; defaults to the POSIX "/". It is never inferred from
+ * seeing a backslash in a path. Mirrors wheels.PathGuard.$nativeSeparator();
+ * keep the two in sync. Public for specs.
+ */
+ public string function $nativeSeparator() {
+ try {
+ var sep = createObject("java", "java.io.File").separator;
+ if (sep == "\" || sep == "/") {
+ return sep;
+ }
+ } catch (any e) {
+ }
+ try {
+ if (structKeyExists(server, "os") && structKeyExists(server.os, "name") && findNoCase("windows", server.os.name)) {
+ return "\";
+ }
+ } catch (any e) {
+ }
+ return "/";
+ }
+
+
+ // ─────────────────────────────────────────────────
+ // lookup — Offline API and guides lookup
+ // ─────────────────────────────────────────────────
+
+ private any function lookupArgSpec() {
+ return new services.ArgSpec()
+ .positional(name = "query", required = true, description = "A function name (findAll, model.findAll), a phrase (nested resources), or an id from an earlier result (api:model.findAll)")
+ .option(name = "kind", default = "all", choices = "all,api,guides", description = "Search the API reference, the guides, or both")
+ .option(name = "limit", default = 5, type = "numeric", description = "Most results to return (1-20)")
+ .option(name = "format", default = "text", choices = "text,json", description = "Output format");
+ }
+
+ /**
+ * hint: Look up the Wheels API and guides offline: a function name, a phrase, or an id from an earlier result
+ */
+ public string function lookup() {
+ var usage = "Usage: wheels lookup [--kind=all|api|guides] [--limit=5] [--format=text|json]";
+ var opts = {};
+ try {
+ opts = lookupArgSpec().parse(structuredArgs(arguments));
+ } catch (Wheels.CLI.MissingArgument e) {
+ $refuse("Missing a query: a function name (findAll), a phrase (nested resources), or an id from an earlier result. " & usage);
+ }
+
+ var index = new services.DocsIndex($docsIndexPath());
+ if (!index.exists()) {
+ // Self-contained: an MCP client sees only the message (it drops what was printed).
+ throw(
+ type = "Wheels.DocsIndexMissing",
+ message = "This Wheels CLI has no docs index (#$docsIndexPath()#). Release and snapshot builds include it; "
+ & "from a source checkout, build it with: node tools/build/scripts/build-docs-index.mjs"
+ );
+ }
+
+ var result = index.lookup(opts.query, opts.kind, opts.limit);
+ result["index"] = index.meta();
+ var note = $docsIndexVersionNote(result.index.frameworkVersion);
+
+ if (opts.format == "json") {
+ if (len(note)) {
+ result["note"] = note;
+ }
+ out(serializeJSON(result));
+ return "";
+ }
+
+ if (result.mode != "search") {
+ if (!arrayLen(result.entries)) {
+ out("No entry with id ""#result.query#"".");
+ }
+ for (var entry in result.entries) {
+ $printDocsEntry(entry);
+ }
+ } else if (!arrayLen(result.results)) {
+ out("No matches for ""#result.query#"". Try fewer or different words, or search https://guides.wheels.dev/");
+ } else {
+ for (var i = 1; i <= arrayLen(result.results); i++) {
+ var hit = result.results[i];
+ out("#i#. [#hit.kind#] #hit.title#", "bold");
+ if (len(hit.snippet)) {
+ out(" " & hit.snippet);
+ }
+ out(" " & hit.url);
+ out(" id: " & hit.id);
+ }
+ out("");
+ out("For a whole entry: wheels lookup ");
+ }
+ if (len(note)) {
+ out(note, "yellow");
+ }
+ return "";
+ }
+
+ /**
+ * The docs index the CLI module ships (built at release and snapshot time).
+ * Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public string function $docsIndexPath() {
+ return getDirectoryFromPath(getCurrentTemplatePath()) & "data/docs-index.json";
+ }
+
+ /**
+ * A note when the index describes another framework version than this CLI
+ * (a stale index in a source checkout). Pre-release suffixes are ignored:
+ * a 4.2.0-snapshot.N CLI reads a 4.2.0 snapshot index.
+ */
+ private string function $docsIndexVersionNote(required string indexVersion) {
+ var cliBase = reMatch("^\d+\.\d+\.\d+", $displayVersion());
+ var indexBase = reMatch("^\d+\.\d+\.\d+", arguments.indexVersion);
+ if (!arrayLen(cliBase) || !arrayLen(indexBase) || cliBase[1] == indexBase[1]) {
+ return "";
+ }
+ return "This docs index is for Wheels #arguments.indexVersion#; this CLI is #$displayVersion()#.";
+ }
+
+ private void function $printDocsEntry(required struct entry) {
+ var e = arguments.entry;
+ if (e.kind == "api") {
+ var names = [];
+ for (var p in e.params) {
+ arrayAppend(names, p.name);
+ }
+ out("#e.name#(#arrayToList(names, ", ")#) returns #e.returns#", "bold");
+ out("Available in: #arrayToList(e.scopes, ", ")# Section: #e.section#");
+ out("");
+ out(e.hint);
+ if (arrayLen(e.params)) {
+ out("");
+ out("Parameters:", "bold");
+ for (var p in e.params) {
+ var detail = p.type & (p.required ? ", required" : "") & (len(p["default"]) ? ", default " & p["default"] : "");
+ out(" #p.name# (#detail#)" & (len(p.hint) ? ": " & p.hint : ""));
+ }
+ }
+ if (len(e.example)) {
+ out("");
+ out("Example:", "bold");
+ out(e.example);
+ }
+ } else {
+ out(compare(e.page, e.heading) == 0 ? e.page : e.page & " › " & e.heading, "bold");
+ out("");
+ out(e.text);
+ }
+ out("");
+ out(e.url);
+ out("");
+ }
// ─────────────────────────────────────────────────
// docs — Local offline documentation bundle
// ─────────────────────────────────────────────────
/**
- * hint: Fetch the local offline documentation bundle (so the guides and
- * API reference work with no internet connection)
+ * hint: Fetch the local offline documentation bundle (so the guides and API reference work with no internet connection)
*/
public string function docs() {
var args = new services.ArgSpec().toArgv(structuredArgs(arguments));
- var action = arrayLen(args) ? lCase(args[1]) : "fetch";
+ // The first non-flag token, so a bare `wheels docs --offline` (or
+ // `--force`) runs the default fetch instead of being read as an action.
+ var action = "fetch";
+ for (var token in args) {
+ if (left(token, 2) != "--") {
+ action = lCase(token);
+ break;
+ }
+ }
// Resolve --force HERE, in the command that actually receives the parsed
// argv. docsFetch() takes no arguments, so a helper reading its
// `arguments` scope always saw an empty struct and the flag was
@@ -1635,6 +2171,9 @@ component extends="modules.BaseModule" {
force = true;
}
}
+ // --offline / WHEELS_OFFLINE=1: sets request.$wheelsOffline, which
+ // docsFetch() and the HttpClient download gate both honour.
+ $consumeOfflineFlag(args);
switch (action) {
case "fetch":
return docsFetch(force = force);
@@ -1672,15 +2211,24 @@ component extends="modules.BaseModule" {
out("Documentation for #version# is already installed.", "green");
out(" #target#");
out(" Re-run with --force to replace it.");
+ // The bundle cache is per CLI home and shared by every app, so a
+ // second app finds it installed and still needs its own mirror.
+ $docsMountIntoWebroot(target);
return "";
}
+ if ($isOffline()) {
+ $docsFetchFail(
+ "Offline mode is enabled (--offline / WHEELS_OFFLINE=1): downloading the docs bundle for #version# requires network access.",
+ [" Run again without --offline to download it."]
+ );
+ }
// Not `url` — a variable named after a reserved CFML scope shadows it,
// and the download then receives the URL scope struct instead of the
// string ("Can't cast Complex Object Type [URL scope] to String").
var bundleUrl = $docsBundleUrl(version);
var checksumUrl = bundleUrl & ".sha512";
- var httpClient = new services.packages.HttpClient(timeoutSeconds = 300);
+ var timeouts = $docsFetchTimeouts();
out("Fetching docs for #version#...");
out(" #bundleUrl#");
@@ -1693,7 +2241,7 @@ component extends="modules.BaseModule" {
var checksumTmp = getTempDirectory() & "wheels-docs-#version#-#runId#.zip.sha512";
var checksumText = "";
try {
- httpClient.download(checksumUrl, checksumTmp);
+ new services.packages.HttpClient(timeoutSeconds = timeouts.checksum).download(checksumUrl, checksumTmp);
checksumText = fileRead(checksumTmp, "utf-8");
} catch (any e) {
$docsFetchFail(
@@ -1719,7 +2267,7 @@ component extends="modules.BaseModule" {
var staging = home & "/docs/." & version & ".partial-" & runId;
try {
try {
- httpClient.download(bundleUrl, tmp);
+ new services.packages.HttpClient(timeoutSeconds = timeouts.bundle).download(bundleUrl, tmp);
} catch (any e) {
$docsFetchFail("Download failed: #e.message#");
}
@@ -1735,29 +2283,11 @@ component extends="modules.BaseModule" {
directoryCreate(staging, true);
// The bundle is zipped with its contents at the root (manifest.json,
// guides/, api/), so unpack straight into the staging directory.
- // Shell out to `unzip` rather than Lucee's extract(): `extract` is
- // shadowed in this module's scope and resolves to a helper with a
- // different arity. Same approach Installer::$extract() takes with
- // `tar`, for the same reason.
- var unzipResult = {};
try {
- cfexecute(
- name = "unzip",
- arguments = "-o -q #tmp# -d #staging#",
- timeout = 300,
- variable = "local.unzipOut",
- errorVariable = "local.unzipErr",
- result = "unzipResult"
- );
+ $docsUnzip(tmp, staging);
} catch (any e) {
$docsFetchFail("Could not unpack the bundle: #e.message#");
}
- if (unzipResult.exitCode != 0) {
- $docsFetchFail(
- "Could not unpack the bundle (unzip exit #unzipResult.exitCode#).",
- [" #local.unzipErr ?: ''#"]
- );
- }
try {
if (directoryExists(target)) {
@@ -1782,6 +2312,51 @@ component extends="modules.BaseModule" {
return "";
}
+ /**
+ * Unpacks a zip with java.util.zip rather than a shell `unzip`: an
+ * argument string broke on paths with spaces, and Windows has no `unzip`.
+ * Lucee's extract() is not an option either: `extract` is shadowed in
+ * this module's scope by a helper with a different arity. An entry that
+ * would land outside destDir (`../x`, an absolute path) throws instead of
+ * being skipped, so a hostile bundle installs nothing.
+ */
+ private void function $docsUnzip(required string zipPath, required string destDir) {
+ var root = createObject("java", "java.io.File").init(arguments.destDir).getCanonicalFile();
+ var rootPath = root.toPath();
+ var zip = createObject("java", "java.util.zip.ZipFile").init(arguments.zipPath);
+ try {
+ var entries = zip.entries();
+ while (entries.hasMoreElements()) {
+ var entry = entries.nextElement();
+ var outFile = createObject("java", "java.io.File").init(root, entry.getName()).getCanonicalFile();
+ // A `./` directory entry is the root itself: nothing to create.
+ if (entry.isDirectory() && outFile.equals(root)) {
+ continue;
+ }
+ if (!outFile.toPath().startsWith(rootPath) || outFile.equals(root)) {
+ throw(type = "Wheels.DocsFetchFailed", message = "the archive entry '#entry.getName()#' points outside the docs directory");
+ }
+ if (entry.isDirectory()) {
+ outFile.mkdirs();
+ continue;
+ }
+ outFile.getParentFile().mkdirs();
+ var input = zip.getInputStream(entry);
+ try {
+ createObject("java", "java.nio.file.Files").copy(
+ input,
+ outFile.toPath(),
+ [createObject("java", "java.nio.file.StandardCopyOption").REPLACE_EXISTING]
+ );
+ } finally {
+ input.close();
+ }
+ }
+ } finally {
+ zip.close();
+ }
+ }
+
/**
* Prints the failure (plus any hint lines) and throws, so `wheels docs
* fetch` exits non-zero instead of reporting success after a failure.
@@ -1994,6 +2569,18 @@ component extends="modules.BaseModule" {
}
}
+ /**
+ * Seconds each `docs fetch` download may take (cfhttp's total timeout). The
+ * checksum is a few hundred bytes; the bundle is about 40 MB, so its limit
+ * allows roughly 1 Mbit/s. Keep both as short as a real transfer allows: on
+ * Lucee, a cfhttp call whose response completes before the tag starts
+ * waiting for it only returns when the whole timeout has passed (the
+ * executor's notify can come first; seen against a local stub, #4232).
+ */
+ private struct function $docsFetchTimeouts() {
+ return {checksum: 30, bundle: 300};
+ }
+
/**
* Snapshots publish to the snapshots repo and releases to the main repo,
* mirroring how the Homebrew formulae resolve their artifacts.
@@ -2008,14 +2595,16 @@ component extends="modules.BaseModule" {
// reload — Reload application
// ─────────────────────────────────────────────────
+ // The reload password
+ // gates the HTTP `?reload=true` endpoint against remote attackers;
+ // the CLI reads it from `.env` or `config/settings.cfm` and forwards
+ // it because it runs locally with filesystem access. This matches
+ // how Rails, Laravel, Symfony, etc. treat CLI-vs-HTTP — the CLI is
+ // already trusted at the same level as the project on disk. See
+ // issue #2477 and `deployment/security-hardening.mdx`.
+ // Kept out of the docblock below: `wheels reload --help` prints it.
/**
- * hint: Reload the running Wheels application. The reload password
- * gates the HTTP `?reload=true` endpoint against remote attackers;
- * the CLI reads it from `.env` or `config/settings.cfm` and forwards
- * it because it runs locally with filesystem access. This matches
- * how Rails, Laravel, Symfony, etc. treat CLI-vs-HTTP — the CLI is
- * already trusted at the same level as the project on disk. See
- * issue #2477 and `deployment/security-hardening.mdx`.
+ * hint: Reload the running Wheels application (the CLI forwards the reload password from .env or config/settings.cfm).
*/
public string function reload() {
// Validate arguments before touching the server (#2963): an unknown or
@@ -2133,7 +2722,13 @@ component extends="modules.BaseModule" {
* hint: Start the Wheels development server via LuCLI
*/
public string function start() {
- var args = new services.ArgSpec().toArgv(structuredArgs(arguments));
+ // `--port 8931` (space form) arrives as port="true" plus a positional;
+ // without re-binding it, toArgv() reordered it to "8931 --port" and
+ // the port was silently dropped (#3895).
+ // Both may arrive that way at once; they bind by value shape.
+ var args = new services.ArgSpec().toArgv(
+ new services.ArgSpec().bindSpaceFormValues(structuredArgs(arguments), {port = "^[0-9]+$", engine = "^[A-Za-z][A-Za-z0-9_-]*$"})
+ );
// Refuse to start from a non-Wheels-project directory. LuCLI's
// `server start` derives the server name from the cwd basename and
@@ -2155,27 +2750,21 @@ component extends="modules.BaseModule" {
// recovery prompt referencing `lucli server start --force`, but `lucli`
// isn't on PATH after `brew install wheels` — the user gets an
// unactionable error from a fresh `wheels start`. Onboarding F1/F2.
- var force = false;
- var engine = "lucee";
- var enginePort = 0;
- var passThrough = [];
- for (var i = 1; i <= arrayLen(args); i++) {
- var a = args[i];
- if (a == "--force") {
- force = true;
- } else if (a == "--engine") {
- if (i < arrayLen(args)) { engine = lCase(args[i + 1]); i++; }
- } else if (left(a, 9) == "--engine=") {
- engine = lCase(mid(a, 10, len(a) - 9));
- } else if (a == "--port") {
- if (i < arrayLen(args)) { enginePort = val(args[i + 1]); i++; }
- } else if (left(a, 7) == "--port=") {
- enginePort = val(mid(a, 8, len(a) - 7));
- } else {
- arrayAppend(passThrough, a);
- }
+ var opts = $parseStartArgs(args);
+ var force = opts.force;
+ var engine = opts.engine;
+ var enginePort = opts.enginePort;
+ var passThrough = opts.passThrough;
+
+ // An unknown engine used to fall through to Lucee silently (#3895).
+ if (!listFind("lucee,rustcfml", engine)) {
+ throw(type = "Wheels.InvalidArguments", message = "Unknown engine '#engine#' for wheels start. Supported: lucee (the default), rustcfml.");
}
+ // One engine per project (#3913): a second engine on another port made
+ // the CLI's server detection pick one of them silently.
+ $refuseOtherEngine(engine);
+
// Port resolution. Two projects whose defaults overlap clash on the
// SHUTDOWN port, and LuCLI reports that as "port conflicts detected:"
// followed by an empty list — an error that names nothing. So the
@@ -2187,8 +2776,17 @@ component extends="modules.BaseModule" {
// the warning emitted further down. `--port` used to be parsed and then
// dropped for Lucee projects — only the RustCFML branch consumed it — so
// `wheels start --port=8090` silently booted on the lucee.json port.
- if (engine != "rustcfml") {
- $resolveStartPorts(enginePort);
+ if (opts.dryRun) {
+ if (engine == "rustcfml") {
+ throw(type = "Wheels.InvalidArguments", message = "wheels start --dry-run isn't supported with --engine=rustcfml.");
+ }
+ // LuCLI's own --dry-run prints the config it would start with and changes nothing, so the
+ // port goes to it rather than into lucee.json.
+ if (enginePort > 0) {
+ arrayAppend(passThrough, "--port=" & enginePort);
+ }
+ executeCommand("server", $startCommandArgs(passThrough), variables.projectRoot);
+ return "";
}
// RustCFML backend — separate lifecycle from LuCLI (no JDK/Lucee
@@ -2196,6 +2794,7 @@ component extends="modules.BaseModule" {
if (engine == "rustcfml") {
var rustSvc = $rustcfmlEngine();
try {
+ $announceRustcfmlDownload(rustSvc);
var rustState = rustSvc.start(variables.projectRoot, enginePort > 0 ? enginePort : 8513);
} catch (Wheels.RustCFML.UnsupportedPlatform e) {
$reportUnsupportedRustPlatform(e);
@@ -2217,6 +2816,10 @@ component extends="modules.BaseModule" {
return "";
}
+ // Only now that a start will happen: resolving the ports can move the shutdown port and
+ // write lucee.json, which a start of a running server must not do (#4411).
+ $resolveStartPorts(enginePort);
+
// A failed start leaves a registration holding only LuCLI's config file
// and no project path. That exact shape is a leftover, not another
// project's server: it falls through to the clean-up below instead of
@@ -2250,17 +2853,12 @@ component extends="modules.BaseModule" {
// 127.0.0.1-bound databases) and `wheels start` would boot on top of it.
// That is fixed upstream, but older LuCLI binaries still ship the bug, so
// when lucee.json pins a port we connect-probe it (both address families)
- // and warn before delegating. We only reach here when our own server is
+ // and refuse before delegating. We only reach here when our own server is
// NOT already running (the reg.alive early-return above), so an in-use
// pinned port is a genuine foreign collision.
var pinnedPort = $readPinnedPort(variables.projectRoot);
if (pinnedPort > 0 && getService("portProbe").portInUse(pinnedPort)) {
- out("");
- out("Warning: port " & pinnedPort & " (configured in lucee.json) is already in use", "yellow");
- out("by another process. The server may fail to start, or silently share the port", "yellow");
- out("(IPv4 clients reaching the other process while localhost reaches Wheels).", "yellow");
- out("Fix: stop the other process, or change the 'port' in lucee.json.", "yellow");
- out("");
+ $refuseTakenHttpPort(pinnedPort);
}
out("Starting Wheels server...", "cyan");
@@ -2287,8 +2885,7 @@ component extends="modules.BaseModule" {
// Delegate to LuCLI's server start command. Forward only args we
// haven't consumed ourselves (--force is wheels-side, not LuCLI-side).
- var cmdArgs = ["start"];
- cmdArgs.append(passThrough, true);
+ var cmdArgs = $startCommandArgs(passThrough);
try {
executeCommand("server", cmdArgs, variables.projectRoot);
@@ -2427,21 +3024,21 @@ component extends="modules.BaseModule" {
return "";
}
- // RustCFML backend — if a recorded RustCFML server is alive, stop it
- // before touching LuCLI's registry. Auto-detected, so `wheels stop`
- // works regardless of which engine was started.
- var rustSvc = new services.rustcfml.RustCFMLEngine();
+ // RustCFML backend — if a recorded RustCFML server is alive, stop it,
+ // then go on to the project's Lucee server: `wheels stop` stops
+ // whatever runs for the project, whichever engine started it (#3913).
+ var rustSvc = $rustcfmlEngine();
var rustStatus = rustSvc.status(variables.projectRoot);
+ var rustStopped = false;
if (rustStatus.running) {
rustSvc.stop(variables.projectRoot);
out("RustCFML server stopped.", "cyan");
- return "";
- }
- if ((rustStatus.staleReason ?: "") == "pid-not-server") {
+ rustStopped = true;
+ } else if ((rustStatus.staleReason ?: "") == "pid-not-server") {
out(rustStatus.message, "yellow");
}
- out("Stopping Wheels server...", "cyan");
+ if (!rustStopped) out("Stopping Wheels server...", "cyan");
// If LuCLI's stop won't find a registered server for this directory
// (cwd doesn't match any `.project-path`), enumerate the user's
@@ -2450,7 +3047,14 @@ component extends="modules.BaseModule" {
// unrelated dir, or after the project was moved/deleted — leaving
// orphan Java processes the user has to chase with `lsof`+`kill`.
// See GH #2316.
- var match = $findServerForProject(variables.projectRoot);
+ // Prefer the project's RUNNING registration: after a lucee.json `name`
+ // change the server can still run under its old name, and the first
+ // .project-path match may be a stale registration (#3994).
+ var registry = getService("serverRegistry");
+ var liveName = registry.aliveRegistrationFor(variables.projectRoot);
+ var match = len(liveName) ? liveName : $findServerForProject(variables.projectRoot);
+ // Only RustCFML was running: it is stopped, and there is nothing to report.
+ if (!len(match) && rustStopped) return "";
if (!len(match)) {
var orphans = $listRunningWheelsServers();
if (arrayLen(orphans)) {
@@ -2498,8 +3102,15 @@ component extends="modules.BaseModule" {
return "";
}
- executeCommand("server", ["stop"], variables.projectRoot);
- getService("serverRegistry").deleteStartToken(match);
+ if (rustStopped) out("Stopping Wheels server...", "cyan");
+ // A bare `server stop` resolves the name in the current lucee.json, so
+ // a live server under another registration name is named explicitly.
+ var stopArgs = ["stop"];
+ if (len(liveName) && liveName != registry.serverNameFor(variables.projectRoot)) {
+ arrayAppend(stopArgs, "--name=" & liveName);
+ }
+ executeCommand("server", stopArgs, variables.projectRoot);
+ registry.deleteStartToken(match);
return "";
}
@@ -2507,15 +3118,19 @@ component extends="modules.BaseModule" {
// engines — manage the dev-server engine backend
// ─────────────────────────────────────────────────
+ // `wheels engines rustcfml install|start|stop|status` — the RustCFML
+ // (JVM-free CFML) engine backend. Deliberately separate from
+ // `start`/`stop` (Lucee via LuCLI) so the two lifecycles never share a
+ // registry. Hidden from MCP like the other stateful server commands.
+ // Kept out of the docblock below: `wheels engines --help` prints it.
/**
- * `wheels engines rustcfml install|start|stop|status` — the RustCFML
- * (JVM-free CFML) engine backend. Deliberately separate from
- * `start`/`stop` (Lucee via LuCLI) so the two lifecycles never share a
- * registry. Hidden from MCP like the other stateful server commands.
+ * hint: `wheels engines rustcfml install|start|stop|status` — manage the RustCFML (JVM-free CFML) engine
*/
public string function engines() {
- var coll = structuredArgs(arguments);
- var opts = new services.ArgSpec()
+ var spec = new services.ArgSpec();
+ // `--port 8931` (space form), as `wheels start` accepts (#3895).
+ var coll = spec.bindSpaceFormValue(structuredArgs(arguments), "port");
+ var opts = spec
.positional(name = "engine", default = "", description = "Engine name: rustcfml")
.positional(name = "action", default = "", description = "Action: install, start, stop, status")
.option(name = "port", default = "8513", description = "Port for `start` (default 8513)")
@@ -2524,17 +3139,21 @@ component extends="modules.BaseModule" {
var engine = lCase(trim(opts.engine));
var action = lCase(trim(opts.action));
- if (engine != "rustcfml") {
- out("Unknown engine '#opts.engine#'. Supported: rustcfml", "yellow");
+ if (!len(engine)) {
out("Usage: wheels engines rustcfml install|start|stop|status [--port N]", "cyan");
return "";
}
+ if (engine != "rustcfml") {
+ out("Usage: wheels engines rustcfml install|start|stop|status [--port N]", "cyan");
+ throw(type = "Wheels.InvalidArguments", message = "Unknown engine '#opts.engine#'. Supported: rustcfml");
+ }
var svc = $rustcfmlEngine();
switch (action) {
case "install":
out("Installing RustCFML...", "cyan");
try {
+ $announceRustcfmlDownload(svc);
out("Installed: " & svc.install(), "green");
} catch (Wheels.RustCFML.UnsupportedPlatform e) {
$reportUnsupportedRustPlatform(e);
@@ -2542,7 +3161,9 @@ component extends="modules.BaseModule" {
}
break;
case "start":
+ $refuseOtherEngine("rustcfml");
try {
+ $announceRustcfmlDownload(svc);
var st = svc.start(variables.projectRoot, val(opts.port));
} catch (Wheels.RustCFML.UnsupportedPlatform e) {
$reportUnsupportedRustPlatform(e);
@@ -2571,16 +3192,63 @@ component extends="modules.BaseModule" {
out("No RustCFML server running for this project.", "yellow");
}
break;
+ case "":
+ out("Usage: wheels engines rustcfml install|start|stop|status [--port N]", "cyan");
+ break;
default:
- out("Usage: wheels engines rustcfml install|start|stop|status [--port N]", "yellow");
+ out("Usage: wheels engines rustcfml install|start|stop|status [--port N]", "cyan");
+ throw(type = "Wheels.InvalidArguments", message = "Unknown action '#opts.action#' for wheels engines rustcfml. Try: install, start, stop, status");
}
return "";
}
+ /**
+ * The value after a bare `--` in start()'s argv. Refuses a missing
+ * value or another flag, which the loop used to take as the value (so
+ * `--port --engine` booted Lucee on the default port).
+ */
+ private string function $startFlagValue(required array args, required numeric i, required string name) {
+ if (arguments.i >= arrayLen(arguments.args) || left(arguments.args[arguments.i + 1], 2) == "--") {
+ throw(type = "Wheels.InvalidArguments", message = "--#arguments.name# needs a value, e.g. --#arguments.name#=.");
+ }
+ return arguments.args[arguments.i + 1];
+ }
+
+ /**
+ * The pinned RustCFML binary is a ~57 MB download on first use. It used to
+ * happen silently inside `wheels start`; announce it and how long it took.
+ * The download itself runs in a child process whose output is not shown,
+ * so this prints before and after rather than a live progress bar.
+ */
+ private void function $announceRustcfmlDownload(required any svc) {
+ if (arguments.svc.isInstalled()) {
+ return;
+ }
+ out("Downloading RustCFML #arguments.svc.getEngineVersion()# (about 57 MB, first run only)...", "cyan");
+ var began = getTickCount();
+ arguments.svc.install();
+ out(" Downloaded and verified in #numberFormat((getTickCount() - began) / 1000, '0.0')#s.", "cyan");
+ }
+
// ─────────────────────────────────────────────────
// new — Scaffold a new Wheels project
// ─────────────────────────────────────────────────
+ /**
+ * The arguments `wheels new` accepts. Also what `wheels new --help` lists.
+ */
+ private any function newArgSpec() {
+ return new services.ArgSpec()
+ .positional(name = "appName", description = "Name of the application and of the directory it's created in")
+ .option(name = "port", default = 8080, type = "numeric", description = "Server port (default: 8080, or the first port above it that is free and no other project pins)")
+ .option(name = "datasource", default = "", description = "Datasource name (default: the app name, lowercased)")
+ .option(name = "reload-password", default = "", description = "Reload password (default: random)")
+ .flag(name = "setup-h2", default = false, description = "Use the H2 embedded database instead of SQLite")
+ .flag(name = "sqlite", default = true, description = "Set up the zero-config SQLite database")
+ .flag(name = "open-browser", default = true, description = "Open the browser when the server starts")
+ .flag(name = "agents", default = true, description = "Write .mcp.json and .opencode.json so AI assistants find the Wheels MCP server; skip with --no-agents");
+ }
+
/**
* Parse `wheels new` arguments from LuCLI's structured argCollection.
*
@@ -2591,15 +3259,7 @@ component extends="modules.BaseModule" {
* name → error" (GH #2214).
*/
private struct function parseNewArgs(required struct coll) {
- var parsed = new services.ArgSpec()
- .positional(name = "appName")
- .option(name = "port", default = 8080, type = "numeric")
- .option(name = "datasource", default = "")
- .option(name = "reload-password", default = "")
- .flag(name = "setup-h2", default = false)
- .flag(name = "sqlite", default = true)
- .flag(name = "open-browser", default = true)
- .parse(arguments.coll);
+ var parsed = newArgSpec().parse(arguments.coll);
return {
appName = parsed.appName,
@@ -2609,6 +3269,7 @@ component extends="modules.BaseModule" {
setupH2 = parsed["setup-h2"],
noSQLite = !parsed.sqlite,
openBrowser = parsed["open-browser"],
+ agents = parsed.agents,
isEmpty = structIsEmpty(arguments.coll)
};
}
@@ -2626,23 +3287,8 @@ component extends="modules.BaseModule" {
);
if (opts.isEmpty) {
- out("Usage: wheels new [options]", "yellow");
- out("");
- out("Creates a new Wheels application in the specified directory.");
- out("By default, SQLite is configured as the zero-config database.");
- out("");
- out("Options:", "bold");
- out(" --port= Server port (default: 8080)");
- out(" --datasource= Datasource name (default: app name)");
- out(" --reload-password= Reload password (default: random)");
- out(" --no-sqlite Skip default SQLite database setup");
- out(" --setup-h2 Use H2 embedded database instead of SQLite");
- out(" --no-open-browser Don't open browser on server start");
- out("");
- out("Examples:", "bold");
- out(" wheels new myapp");
- out(" wheels new myapp --port=3000 --setup-h2");
- out(" wheels new myapp --datasource=mydb --no-sqlite");
+ // The same text as `wheels new --help`, so the two can't drift.
+ out($commandHelp("new"));
return "";
}
@@ -2658,12 +3304,14 @@ component extends="modules.BaseModule" {
);
}
var options = {
- port: opts.port,
+ // 0 = not given: scaffoldNewApp() picks a free, unpinned port.
+ port: structKeyExists(newColl, "port") ? opts.port : 0,
datasource: opts.datasource,
reloadPassword: opts.reloadPassword,
setupH2: opts.setupH2,
noSQLite: opts.noSQLite,
- openBrowser: opts.openBrowser
+ openBrowser: opts.openBrowser,
+ agents: opts.agents
};
if (!len(appName)) {
@@ -2711,6 +3359,7 @@ component extends="modules.BaseModule" {
switch (type) {
case "app":
+ $refuseAppCreationOverMcp("wheels create app");
__arguments = remaining;
return new();
default:
@@ -2724,15 +3373,15 @@ component extends="modules.BaseModule" {
// routes — List application routes
// ─────────────────────────────────────────────────
- /**
- * hint: List all configured routes with method, path, and controller action
- */
private any function routesArgSpec() {
return new services.ArgSpec()
.option(name = "filter", default = "", description = "Show only routes whose name, pattern or controller##action contains this text (case-insensitive)")
.option(name = "format", default = "text", choices = "text,json", description = "Output format: text (aligned table) or json");
}
+ /**
+ * hint: List all configured routes with method, path, and controller action
+ */
public string function routes() {
// Both flags were advertised in the wrapper's help for as long as the
// command has existed, and neither was ever read — the command fetched
@@ -2878,7 +3527,7 @@ component extends="modules.BaseModule" {
public string function info() {
// Takes no arguments; enforce the schema's additionalProperties:false (#2963).
new services.ArgSpec().parse(structuredArgs(arguments));
- out("Wheels CLI v#super.version()#", "bold");
+ out("Wheels CLI v#$displayVersion()#", "bold");
out("");
if (len(variables.projectRoot) && directoryExists(variables.projectRoot & "/vendor/wheels")) {
@@ -2906,8 +3555,19 @@ component extends="modules.BaseModule" {
} catch (any e) { /* skip */ }
}
- // CFML engine
- out("Engine: Lucee (LuCLI module)");
+ // CFML engine: the one serving this project. It was always reported
+ // as Lucee, even while RustCFML served (#3895).
+ var engineLine = "Lucee (LuCLI module)";
+ try {
+ var rustSvc = $rustcfmlEngine();
+ var rustStatus = rustSvc.status(variables.projectRoot);
+ if (rustStatus.running ?: false) {
+ engineLine = "RustCFML #rustSvc.getEngineVersion()# (running at http://127.0.0.1:#rustStatus.port#)";
+ }
+ } catch (any e) {
+ // No RustCFML state to read; Lucee is the default.
+ }
+ out("Engine: #engineLine#");
// Datasource. Strip CFML/cfscript comments first so commented-out
// `set(...)` calls don't get parsed as live config, and use a
@@ -2917,9 +3577,9 @@ component extends="modules.BaseModule" {
if (fileExists(settingsFile)) {
try {
var sContent = stripCfmlComments(fileRead(settingsFile));
- var dsMatch = reFindNoCase('\bdataSourceName\s*=\s*"([^"]+)"', sContent, 1, true);
- if (arrayLen(dsMatch.match) > 1) {
- out("Database: #dsMatch.match[2]#");
+ var dsName = $settingsDataSourceName(sContent);
+ if (len(dsName)) {
+ out("Database: #dsName#");
}
} catch (any e) { /* skip */ }
}
@@ -2988,22 +3648,12 @@ component extends="modules.BaseModule" {
// mcp — MCP server instructions
// ─────────────────────────────────────────────────
- /**
- * hint: `wheels setup agents` — write .mcp.json and .opencode.json for AI assistants
- *
- * `setup agents`, and the target is deliberately NOT spelled `mcp`: LuCLI
- * intercepts that literal token in ANY argument position — not just
- * argv[1] — so `wheels setup mcp` (and even `wheels info mcp`) is routed to
- * the runtime's module runner and answers "mcp: missing module name".
- * `ai` is reserved the same way. Verified live, both. `setup` and
- * `configure` are free top-level verbs (`init` is not — BaseModule has it).
- */
+ // `map setup` shipped in snapshot 2499 before we learned that the literal
+ // token `mcp` is intercepted by the runtime in any argv position. Kept as a
+ // thin forwarder so that spelling keeps working.
+ // Kept out of the docblock below: `wheels map --help` prints it.
/**
* hint: Deprecated spelling — `wheels map setup` forwards to `setup agents`
- *
- * `map setup` shipped in snapshot 2499 before we learned that the literal
- * token `mcp` is intercepted by the runtime in any argv position. Kept as a
- * thin forwarder so that spelling keeps working.
*/
public string function map() {
var args = new services.ArgSpec().toArgv(structuredArgs(arguments));
@@ -3013,6 +3663,16 @@ component extends="modules.BaseModule" {
return setup();
}
+ // `setup agents`, and the target is deliberately NOT spelled `mcp`: LuCLI
+ // intercepts that literal token in ANY argument position — not just
+ // argv[1] — so `wheels setup mcp` (and even `wheels info mcp`) is routed to
+ // the runtime's module runner and answers "mcp: missing module name".
+ // `ai` is reserved the same way. Verified live, both. `setup` and
+ // `configure` are free top-level verbs (`init` is not — BaseModule has it).
+ // Kept out of the docblock below: `wheels setup --help` prints its hint.
+ /**
+ * hint: `wheels setup agents` — write .mcp.json and .opencode.json for AI assistants
+ */
public string function setup() {
var args = new services.ArgSpec().toArgv(structuredArgs(arguments));
var subcommand = arrayLen(args) ? lCase(args[1]) : "";
@@ -3043,7 +3703,7 @@ component extends="modules.BaseModule" {
out(' (or add by hand: {"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}})');
out("");
out("For OpenCode, Cursor, and other AI IDEs, see:");
- out(" https://guides.wheels.dev/v4-0-0/command-line-tools/mcp-integration");
+ out(" " & new services.GuidesLink().link("command-line-tools/mcp-integration", $docsFrameworkVersion()));
out("");
out("All public commands in this module are auto-discovered as MCP tools.");
out("Tool names match the command names: generate, migrate, etc. (unprefixed");
@@ -3072,12 +3732,53 @@ component extends="modules.BaseModule" {
if (a == "--force") force = true;
}
- if (!$isWheelsProjectDir(variables.projectRoot)) {
- out("Not in a Wheels project directory.", "yellow");
- out("Run this from the root of a Wheels app (the directory holding config/settings.cfm).");
+ // Outside a Wheels project, `setup agents` has nothing to write .mcp.json beside. It used to
+ // print a note and return "" — exit 0, so a scripted `wheels setup agents` in the wrong directory
+ // looked like it succeeded (#4409). Guard with the shared project check (same throw/type/guidance
+ // as `wheels generate` and `wheels destroy`) so it exits non-zero.
+ $requireWheelsProject("wheels setup agents");
+
+ var changed = $writeAgentConfigs(variables.projectRoot, force);
+
+ var wrote = [];
+ for (var c in changed) {
+ if (len(c.verb)) arrayAppend(wrote, c);
+ }
+
+ if (!arrayLen(wrote) && !force) {
+ out("Already configured — both client configs list the wheels server.", "green");
+ for (var c in changed) {
+ out(" #c.path#");
+ }
return "";
}
+ for (var c in changed) {
+ if (len(c.verb)) {
+ out("#c.verb# #c.file#", "green");
+ } else {
+ out("Already configured: #c.file#", "");
+ }
+ out(" #c.path#");
+ if (c.others > 0) {
+ out(" #c.others# other server(s) preserved.", "");
+ }
+ }
+ out("");
+ out("Next:", "bold");
+ out(" Restart your AI assistant so it picks up the new server,");
+ out(" then ask it to run `wheels routes` to confirm the connection.");
+ return "";
+ }
+
+ /**
+ * Write the wheels entry into `.mcp.json` and `.opencode.json` under `root`,
+ * merging into whatever else those files list. Returns one {file, path, verb,
+ * others} per file; `verb` is "" when the file already had the right entry.
+ * Prints nothing, so `setup agents` and `wheels new` each report it their own
+ * way. Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public array function $writeAgentConfigs(required string root, boolean force = false) {
// Two clients, two shapes. Claude Code reads `.mcp.json` (mcpServers +
// separate command/args); OpenCode reads `.opencode.json` (an `mcp` key,
// `type: "local"`, and command+args as ONE array). Both are written —
@@ -3104,7 +3805,7 @@ component extends="modules.BaseModule" {
// already rewritten.
var loaded = [];
for (var t in targets) {
- arrayAppend(loaded, $loadMcpConfig(variables.projectRoot & "/" & t.file, t));
+ arrayAppend(loaded, $loadMcpConfig(arguments.root & "/" & t.file, t));
}
var changed = [];
@@ -3120,7 +3821,7 @@ component extends="modules.BaseModule" {
}
var existing = config[t.rootKey].wheels ?: {};
var correct = $mcpEntryMatches(existing, t.entry);
- if (!correct || force) {
+ if (!correct || arguments.force) {
config[t.rootKey].wheels = t.entry;
fileWrite(state.path, serializeJSON(config));
arrayAppend(changed, {
@@ -3134,35 +3835,25 @@ component extends="modules.BaseModule" {
}
}
- var wrote = [];
- for (var c in changed) {
- if (len(c.verb)) arrayAppend(wrote, c);
- }
+ return changed;
+ }
- if (!arrayLen(wrote) && !force) {
- out("Already configured — both client configs list the wheels server.", "green");
- for (var c in changed) {
- out(" #c.path#");
+ /**
+ * `wheels new`'s AI-assistant configs: the files `setup agents` writes, in the
+ * new app's root. Never fails the scaffold, since the app works without them:
+ * returns a warning to print instead, or "" when they were written.
+ */
+ private string function $writeNewAppAgentConfigs(required string targetDir, required string appName) {
+ try {
+ for (var written in $writeAgentConfigs(arguments.targetDir)) {
+ if (len(written.verb)) {
+ printCreated(arguments.appName & "/" & written.file);
+ }
}
return "";
+ } catch (any e) {
+ return "Couldn't write the AI-assistant configs (#e.message#). Add them later with: cd #arguments.appName# && wheels setup agents";
}
-
- for (var c in changed) {
- if (len(c.verb)) {
- out("#c.verb# #c.file#", "green");
- } else {
- out("Already configured: #c.file#", "");
- }
- out(" #c.path#");
- if (c.others > 0) {
- out(" #c.others# other server(s) preserved.", "");
- }
- }
- out("");
- out("Next:", "bold");
- out(" Restart your AI assistant so it picks up the new server,");
- out(" then ask it to run `wheels routes` to confirm the connection.");
- return "";
}
/**
@@ -3290,7 +3981,7 @@ component extends="modules.BaseModule" {
// Banner
out("", "");
- out("Wheels Console v#super.version()#", "bold");
+ out("Wheels Console v#$displayVersion()#", "bold");
out("Connected to #$serverHostPort(serverPort)# (#wheelsEnv#) — Wheels #wheelsVersion#", "cyan");
out("Type expressions to evaluate in your app context. /help for commands.", "");
out("", "");
@@ -3332,6 +4023,14 @@ component extends="modules.BaseModule" {
// Handle REPL commands; unhandled input falls through to evaluation.
var verdict = $consoleHandleCommand(line, evalUrl, password, serverPort, System);
if (verdict == "exit") {
+ // A bare `exit`/`quit` usually ends a piped session, so it must
+ // not hide an earlier failed expression: fail like EOF does.
+ if ($consoleExitFailsSession(line, hadError)) {
+ throw(
+ type = "Wheels.ConsoleFailed",
+ message = "One or more console expressions failed"
+ );
+ }
running = false;
continue;
}
@@ -3483,6 +4182,15 @@ component extends="modules.BaseModule" {
}
}
+ /**
+ * Does ending the console on `line` fail the session? A bare `exit`/`quit`
+ * after a failed expression does, like EOF, so `bad expr; exit` piped in
+ * exits non-zero (#3892). `/exit` keeps returning 0, as documented above.
+ */
+ private boolean function $consoleExitFailsSession(required string line, required boolean hadError) {
+ return arguments.hadError && listFindNoCase("exit,quit", trim(arguments.line)) > 0;
+ }
+
/**
* Handle one REPL command line. Returns "exit" to end the loop, "handled"
* when the line was a slash command, "error" when a slash command's eval
@@ -3493,6 +4201,11 @@ component extends="modules.BaseModule" {
case "/exit":
case "/quit":
case "/q":
+ // Bare words too: they used to be evaluated as CFML and fail with
+ // "variable [EXIT] doesn't exist", so a piped session ending in
+ // `exit` exited 1 (#3892).
+ case "exit":
+ case "quit":
out("Bye!", "cyan");
return "exit";
@@ -3544,7 +4257,12 @@ component extends="modules.BaseModule" {
return "handled";
case "/routes":
- if (!consoleExec(arguments.evalUrl, "application.wheels.routes.map(function(r){ return r.pattern & ' -> ' & r.controller & '##' & r.action; })", arguments.password)) {
+ // Not an eval expression: formatting each route needs a per-element loop, and the console's
+ // eval endpoint runs expressions through evaluate() (consoleeval.cfm), which has no closure
+ // or arrow grammar — `.map(function(r){...})` and `.map((r)=>...)` both fail to parse
+ // ("Closing )] for function [MAP] not found", #4410). Fetch the route table from the same
+ // CLI endpoint `wheels routes` uses and format it here instead.
+ if (!$consoleRoutes(arguments.evalUrl)) {
return "error";
}
return "handled";
@@ -3565,6 +4283,46 @@ component extends="modules.BaseModule" {
return "";
}
+ /**
+ * Print the application route table for the console's `/routes` command as
+ * `pattern -> controller#action` lines. The console's eval endpoint can't
+ * format routes itself — evaluate() has no closure/arrow grammar to map over
+ * them (#4410) — so this reads the route table from the same CLI endpoint
+ * `wheels routes` uses (derived from evalUrl, which shares the server base)
+ * and formats it here. Returns false, so the REPL records a failed command
+ * and exits non-zero on EOF, when the fetch fails or the server refuses.
+ */
+ private boolean function $consoleRoutes(required string evalUrl) {
+ try {
+ var routesUrl = replace(arguments.evalUrl, "/wheels/console/eval", "/wheels/cli?command=routes&format=json");
+ var httpResult = makeHttpRequest(routesUrl);
+ if (!isJSON(httpResult)) {
+ out("Failed to fetch routes: the server returned a non-JSON response.", "red");
+ verbose(httpResult);
+ return false;
+ }
+ var result = deserializeJSON(httpResult);
+ if (!(structKeyExists(result, "success") && result.success)) {
+ out("Failed to fetch routes: #result.message ?: 'unknown error'#", "red");
+ return false;
+ }
+ var routes = structKeyExists(result, "routes") && isArray(result.routes) ? result.routes : [];
+ if (!arrayLen(routes)) {
+ out("(no routes)");
+ return true;
+ }
+ // chr(35) is '#': built by concatenation so neither the value nor out() treats it as an
+ // interpolation delimiter.
+ for (var route in routes) {
+ out((route.pattern ?: "") & " -> " & (route.controller ?: "") & chr(35) & (route.action ?: ""));
+ }
+ return true;
+ } catch (any e) {
+ out("Failed to fetch routes: #e.message#", "red");
+ return false;
+ }
+ }
+
/**
* Execute a single expression and display the result. Returns false when
* the HTTP call failed, the server reported success=false, or a model
@@ -3803,7 +4561,7 @@ component extends="modules.BaseModule" {
out(" /ds, /datasource Show current datasource");
out(" /reload Reload the application");
out(" /clear Clear the screen");
- out(" /exit, /quit, /q Exit the console");
+ out(" /exit, /quit, /q Exit the console (bare exit and quit work too)");
out("");
out("Expression Examples:", "bold");
out(' model("User").findAll() Query all users');
@@ -4086,6 +4844,10 @@ component extends="modules.BaseModule" {
throw(type = "Wheels.InvalidArguments", message = "Unknown destroy type: #type#. Valid types: #arrayToList(validTypes, ', ')#.");
}
+ // Hardening: a stray app/models/X.cfc outside a project must not be
+ // deleted just because the name matches (#3909).
+ $requireWheelsProject("wheels destroy");
+
var svc = getService("destroy");
// Show preview and confirm
@@ -4295,7 +5057,7 @@ component extends="modules.BaseModule" {
// Each command starts with an empty secret/warning registry, so a
// long-lived process never carries one command's secrets into the next.
new modules.wheels.services.deploy.lib.SecretRedaction().reset();
- var args = new services.ArgSpec().toArgv(structuredArgs(arguments));
+ var args = $deployArgv(structuredArgs(arguments));
var opts = $deployArgsToOptions(args);
if (!structKeyExists(opts, "configPath") || !len(opts.configPath)) {
opts.configPath = expandPath("config/deploy.yml");
@@ -4332,6 +5094,32 @@ component extends="modules.BaseModule" {
throw(message = "Unknown deploy subcommand: #sub#");
}
+ /**
+ * The deploy argv, with space-form values bound to their flags (#4417). LuCLI hands
+ * `--release v8` over as release="true" plus a positional after a gap, so without binding `v8`
+ * became the subcommand ("Unknown deploy subcommand: v8") and `--service myapp2` was dropped.
+ * `bindSpaceFormValues()` binds only what it can match one-to-one; a value flag still left bare
+ * (several space-form values it can't tell apart, or a flag given no value) is an error that
+ * asks for the `--flag=value` form, rather than a silent misread.
+ */
+ private array function $deployArgv(required struct coll) {
+ var valueFlags = "account,adapter,config,configPath,container,destination,from,host,image,keep,message,registry-username,release,role,service,tail,version";
+ var matchers = {};
+ for (var flag in listToArray(valueFlags)) {
+ matchers[flag] = listFind("keep,tail", flag) ? "^[0-9]+$" : "";
+ }
+ var bound = new services.ArgSpec().bindSpaceFormValues(arguments.coll, matchers);
+ for (var flag in listToArray(valueFlags)) {
+ if (structKeyExists(bound, flag) && isSimpleValue(bound[flag]) && compareNoCase(trim(toString(bound[flag])), "true") == 0) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "wheels deploy: --#flag# needs a value. Write it as --#flag#=."
+ );
+ }
+ }
+ return new services.ArgSpec().toArgv(bound);
+ }
+
/**
* Dispatch the direct DeployMainCli verbs. Extracted from deploy() to keep
* its dispatcher under the complexity gate.
@@ -4344,7 +5132,7 @@ component extends="modules.BaseModule" {
return arguments.dmc.redeploy(arguments.opts);
case "rollback":
if (arrayLen(arguments.positional) < 2) {
- throw(message = "rollback requires a version argument: wheels deploy rollback ");
+ throw(type = "DeployMainCli.MissingVersion", message = "rollback requires a version argument: wheels deploy rollback ");
}
arguments.opts.version = arguments.positional[2];
return arguments.dmc.rollback(arguments.opts);
@@ -4553,7 +5341,7 @@ component extends="modules.BaseModule" {
return bootstrapCli.bootstrap(arguments.opts);
case "exec":
if (arrayLen(arguments.positional) < 2) {
- throw(message = "wheels deploy exec requires a command");
+ throw(type = "DeployServerCli.MissingCommand", message = "wheels deploy exec requires a command");
}
// Preserve multi-token commands: join all positional args after `exec`.
var execCmdParts = [];
@@ -4573,7 +5361,7 @@ component extends="modules.BaseModule" {
var serverVerb = arguments.positional[2];
if (serverVerb == "exec") {
if (arrayLen(arguments.positional) < 3) {
- throw(message = "wheels deploy server exec requires a command");
+ throw(type = "DeployServerCli.MissingCommand", message = "wheels deploy server exec requires a command");
}
// Preserve multi-token commands: join all positional args after the verb.
var cmdParts = [];
@@ -4669,20 +5457,19 @@ component extends="modules.BaseModule" {
// packages — registry-backed package manager
// ─────────────────────────────────────────────────
+ // `add` is the documented install verb and `install` is an alias for it
+ // (see the dispatch below). Older LuCLI runtimes intercepted the literal
+ // `install` verb before it reached this module (#2713); the runtime the
+ // CLI ships on now passes it through (#4206).
+ // Kept out of the docblock below: `wheels packages --help` prints it.
/**
- * hint: Add, update, and list Wheels packages (verb is `add`, not `install`)
- *
- * The verb is `add`, NOT `install`. Typing `wheels packages install `
- * is intercepted by LuCLI's built-in extension installer before dispatch
- * reaches this module, and prints `[INFO] No git or extension dependencies
- * to install` without actually installing anything. See chapter 8 of the
- * tutorial for the explanation.
+ * hint: Add, update, and list Wheels packages (`install` is an alias of `add`)
*
* Usage:
* wheels packages list [--tag=]
* wheels packages search
* wheels packages show
- * wheels packages add [@] [--force] ← install verb
+ * wheels packages add [@] [--force] (alias: install)
* wheels packages update --yes
* wheels packages update --all --yes
* wheels packages remove
@@ -4715,8 +5502,7 @@ component extends="modules.BaseModule" {
// `--help` / `-h` short-circuits to a deterministic help string the
// module owns directly. LuCLI's auto-introspected help previously
- // drifted from the real CLI surface — advertising the dead `install`
- // verb that LuCLI itself intercepts (#2713). Owning the text here
+ // drifted from the real CLI surface (#2713). Owning the text here
// guarantees `wheels packages help`, `wheels packages --help`, and
// `wheels packages -h` all reach $packagesHelp().
//
@@ -4725,6 +5511,16 @@ component extends="modules.BaseModule" {
return $packagesHelp();
}
+ // A package update killed mid-swap leaves the package only as a hidden
+ // backup; any packages command puts it back first (#3902).
+ try {
+ for (var note in new modules.wheels.services.packages.Installer().recoverInterruptedSwaps()) {
+ out(note, "yellow");
+ }
+ } catch (any e) {
+ out("Could not check vendor/ for an interrupted package update: #e.message#", "yellow");
+ }
+
return $dispatchPackages(sub, positional, opts);
}
@@ -4744,19 +5540,13 @@ component extends="modules.BaseModule" {
arguments.opts.name = $packagesRequireArg(arguments.positional, "show requires a name: wheels packages show ");
return $packagesMainCli().show(arguments.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).
+ // `install` is an alias of `add` on every path: the CLI
+ // (older LuCLI runtimes intercepted the literal verb
+ // before dispatch; #2713, #4206), the stdio MCP server
+ // (`wheels mcp wheels`), scripted in-process clients and
+ // the spec suite. `PackagesMainCli.install()` is the same
+ // alias (#2729). Fall through to the `add` branch (same
+ // validation, same error shape, same install behavior).
case "add":
arguments.opts.target = $packagesRequireArg(arguments.positional, "add requires a name: wheels packages add [@]");
return $packagesMainCli().add(arguments.opts);
@@ -4769,7 +5559,7 @@ component extends="modules.BaseModule" {
case "registry":
return $dispatchPackagesRegistry(arguments.positional, arguments.opts);
default:
- throw(message="Unknown packages subcommand: #arguments.sub#. The install verb is `add` (not `install`): wheels packages add ");
+ throw(message="Unknown packages subcommand: #arguments.sub#. To install a package: wheels packages add ");
}
}
@@ -4777,7 +5567,7 @@ component extends="modules.BaseModule" {
* Return a fresh PackagesMainCli for a single subcommand dispatch.
*/
private any function $packagesMainCli() {
- return new modules.wheels.services.packages.PackagesMainCli();
+ return new modules.wheels.services.packages.PackagesMainCli(projectRoot = variables.projectRoot);
}
/**
@@ -4807,10 +5597,7 @@ component extends="modules.BaseModule" {
// Hand-written help for `wheels packages`. Owned by the module rather than
// auto-derived from picocli introspection because the auto-help drifted
- // from the real CLI surface (#2713 — advertised `install [--force]`
- // even though LuCLI's built-in extension installer intercepts the literal
- // `install` verb before dispatch reaches this module). Same trap that hit
- // `wheels browser install` (renamed to `setup` in #2345).
+ // from the real CLI surface (#2713).
private string function $packagesHelp() {
var nl = chr(10);
var help = "Usage: wheels packages [options]" & nl;
@@ -4822,16 +5609,12 @@ component extends="modules.BaseModule" {
help &= " add [@] [--force] Install a package into vendor// (canonical)" & nl;
help &= " update --yes Update an installed package" & nl;
help &= " update --all --yes Update every installed package" & nl;
- help &= " remove Delete an installed package from vendor/" & nl;
+ help &= " remove --yes Delete an installed package from vendor/" & nl;
help &= " registry refresh Bust the 24-hour registry cache" & nl;
help &= " registry info Show the registry URL and cache state" & nl;
help &= " help, --help, -h Show this help" & nl & nl;
- help &= "Note: the install verb is `add`, NOT `install`." & nl;
- help &= " Typing `wheels packages install ` is intercepted by LuCLI's built-in" & nl;
- help &= " extension installer before dispatch reaches this module, and prints" & nl;
- help &= " '[INFO] No git or extension dependencies to install' without installing" & nl;
- help &= " anything. Use `wheels packages add ` instead. Same trap that bit" & nl;
- help &= " `wheels browser install` (renamed to `wheels browser setup` in ##2345)." & nl & nl;
+ help &= "Note: `install` is an alias of `add`, so `wheels packages install `" & nl;
+ help &= " works the same way." & nl & nl;
help &= "Examples:" & nl;
help &= " wheels packages list" & nl;
help &= " wheels packages search ui" & nl;
@@ -4932,6 +5715,16 @@ component extends="modules.BaseModule" {
*/
private struct function parseNotesArgs(required struct coll) {
var parsed = notesArgSpec().parse(arguments.coll);
+ // Markers are matched as words in a regex, so a marker that isn't a word
+ // ("." matched every character) is refused rather than escaped (#3893).
+ for (var marker in listToArray(parsed.annotations & "," & parsed.custom)) {
+ if (!reFind("^[A-Za-z][A-Za-z0-9_-]*$", trim(marker))) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "wheels notes: annotation marker '#trim(marker)#' is not a word. Markers are letters, digits, '_' and '-' (e.g. TODO,FIXME,HACK)."
+ );
+ }
+ }
return { annotations = parsed.annotations, custom = parsed.custom };
}
@@ -5035,8 +5828,18 @@ component extends="modules.BaseModule" {
queue = trim(parsed.queue),
interval = parsed.interval,
maxJobs = parsed["max-jobs"],
+ stopWhenEmpty = parsed["stop-when-empty"],
quiet = parsed.quiet,
- format = lCase(trim(parsed.format))
+ jobTimeout = parsed["job-timeout"],
+ format = lCase(trim(parsed.format)),
+ job = trim(parsed.job),
+ data = trim(parsed.data),
+ priority = trim(parsed.priority),
+ "in" = trim(parsed["in"]),
+ at = trim(parsed.at),
+ wait = $jobsWaitSeconds(trim(parsed.wait)),
+ expires = parsed.expires,
+ force = parsed.force
};
if (!len(opts.action)) {
opts.action = "status";
@@ -5053,6 +5856,26 @@ component extends="modules.BaseModule" {
message = "--max-jobs must be zero (unlimited) or a positive number."
);
}
+ // `--wait 30` (a space, not =) reaches here as a bare --wait plus a stray positional "30",
+ // which would silently mean the 600-second default. Refuse it instead of guessing.
+ if (listFindNoCase("drain,resume", opts.action) && len(opts.job)) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "wheels jobs #opts.action# takes no value '#opts.job#'. For a wait limit write --wait=, with an equals sign."
+ );
+ }
+ if (opts.jobTimeout < 0) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "--job-timeout must be zero (each job's own timeout) or a positive number of seconds."
+ );
+ }
+ if (opts.expires < 0) {
+ throw(
+ type = "Wheels.InvalidArguments",
+ message = "--expires must be zero (until resumed) or a positive number of seconds."
+ );
+ }
if (!listFindNoCase("table,json", opts.format)) {
throw(
type = "Wheels.InvalidArguments",
@@ -5063,7 +5886,7 @@ component extends="modules.BaseModule" {
}
/**
- * hint: Background job queue — `work` runs a long-lived worker loop, `status` prints per-queue counts (--format=json for machines). retry/purge/monitor are tracked follow-ups (issue 3090).
+ * hint: Background job queue — `work` runs a long-lived worker loop, `status` prints per-queue counts and this server's running jobs, cap and drain (--format=json for machines), `enqueue ` adds a job now or after a delay, `drain [--wait]` stops this server starting new jobs (for deploys) and `resume` lifts it, `tick` runs one round (for cron/systemd timers), `install` writes a migration that creates the job tables. retry/purge/monitor are tracked follow-ups (issue 3090).
*/
public string function jobs() {
var opts = $parseJobsArgs(structuredArgs(arguments));
@@ -5073,6 +5896,16 @@ component extends="modules.BaseModule" {
return runJobsWork(opts);
case "status":
return runJobsStatus(opts);
+ case "enqueue":
+ return runJobsEnqueue(opts);
+ case "drain":
+ return runJobsDrain(opts);
+ case "resume":
+ return runJobsResume(opts);
+ case "tick":
+ return runJobsTick(opts);
+ case "install":
+ return runJobsInstall(opts);
// The framework bridge (vendor/wheels/public/views/cli.cfm) already
// implements jobsRetry/jobsPurge/jobsMonitor — the CLI verbs are
// deliberate follow-ups tracked in ##3090. Fail loudly with the
@@ -5091,7 +5924,7 @@ component extends="modules.BaseModule" {
);
default:
out("Unknown jobs action: #opts.action#", "red");
- out("Usage: wheels jobs [work|status] [--queue=] [--interval=] [--max-jobs=] [--quiet] [--format=table|json]");
+ out("Usage: wheels jobs [work|status|enqueue |drain|resume|tick|install [--force]] [--queue=] [--interval=] [--max-jobs=] [--stop-when-empty] [--quiet] [--job-timeout=] [--wait[=]] [--expires=] [--format=table|json]");
throw(type = "Wheels.InvalidArguments", message = "Unknown jobs action: #opts.action#");
}
}
@@ -5113,13 +5946,21 @@ component extends="modules.BaseModule" {
if (len(arguments.opts.queue)) {
workUrl &= "&queues=" & urlEncodedFormat(arguments.opts.queue);
}
+ // Without --job-timeout the server runs each job with its own class's timeout.
+ if (arguments.opts.jobTimeout > 0) {
+ workUrl &= "&timeout=" & arguments.opts.jobTimeout;
+ }
out("Wheels Job Worker", "cyan");
out("Queues: " & (len(arguments.opts.queue) ? arguments.opts.queue : "all"));
out("Poll interval: #arguments.opts.interval#s");
+ out("Job timeout: " & (arguments.opts.jobTimeout > 0 ? "each job's own, capped at #arguments.opts.jobTimeout#s" : "each job's own timeout"));
if (arguments.opts.maxJobs > 0) {
out("Max jobs: #arguments.opts.maxJobs#");
}
+ if (arguments.opts.stopWhenEmpty) {
+ out("Stops when no job is ready to run");
+ }
out("Press Ctrl+C to stop");
out("");
@@ -5167,10 +6008,13 @@ component extends="modules.BaseModule" {
}
if (arguments.opts.maxJobs > 0 && (counters.processed + counters.failed) >= arguments.opts.maxJobs) {
- out("");
- out("Reached max jobs limit (#arguments.opts.maxJobs#). Shutting down.", "green");
- out("Processed: #counters.processed# | Failed: #counters.failed#");
- return "";
+ return $jobsWorkStop("Reached max jobs limit (#arguments.opts.maxJobs#). Shutting down.", counters);
+ }
+ // --stop-when-empty: an idle poll means nothing is ready to run, so
+ // a cron or CI batch is done. Without it the worker waits for more,
+ // which is what a supervised long-lived worker wants.
+ if (idle && arguments.opts.stopWhenEmpty) {
+ return $jobsWorkStop("No job ready to run. Shutting down.", counters);
}
// Only sleep when the queue was empty — back-to-back pending jobs
@@ -5183,66 +6027,411 @@ component extends="modules.BaseModule" {
}
/**
- * One-shot queue snapshot via the read-only jobsStatus bridge command.
+ * `wheels jobs install`: write app/migrator/migrations/_CreateWheelsJobTables.cfc,
+ * the migration that creates the job tables (or tops up the ones Wheels already created), so a
+ * multi-server app applies the jobs schema once, under migration control. The source comes from
+ * the running app (wheels.JobSchema) so it matches what auto-create builds; nothing touches the
+ * database until `wheels migrate latest`.
*/
- private string function runJobsStatus(required struct opts) {
+ private string function runJobsInstall(required struct opts) {
var serverPort = $requireRunningServer(
- hints = ["Start one with: wheels start"]
+ hints = ["Start one with: wheels start (the migration is generated from the app's own framework version)"]
);
-
- var statusUrl = "#$serverUrlBase(serverPort)#/wheels/cli?command=jobsStatus&format=json";
- if (len(arguments.opts.queue)) {
- statusUrl &= "&queue=" & urlEncodedFormat(arguments.opts.queue);
- }
-
var httpResult = "";
try {
- httpResult = makeHttpRequest(statusUrl);
+ httpResult = makeHttpRequest("#$serverUrlBase(serverPort)#/wheels/cli?command=jobsInstallSource&format=json");
} catch (any httpErr) {
throw(
type = "Wheels.Cli.CommandFailed",
- message = "Jobs status failed (connection error): #httpErr.message#",
+ message = "Jobs install failed (connection error): #httpErr.message#",
detail = httpErr.detail ?: ""
);
}
+ var result = parseCliResponse(httpResult, "Jobs install");
+ if (!len(result.source ?: "")) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Jobs install: the server returned no migration source.");
+ }
- var result = parseCliResponse(httpResult, "Jobs status");
- var stats = structKeyExists(result, "stats") && isStruct(result.stats) ? result.stats : {};
-
- if (arguments.opts.format == "json") {
- out(serializeJSON(stats));
- return "";
+ var migrationDir = variables.projectRoot & "/app/migrator/migrations";
+ $ensureProjectDirectory(migrationDir);
+ var existing = directoryList(migrationDir, false, "name", "*_#result.migrationName#.cfc");
+ var fileName = "";
+ if (arrayLen(existing)) {
+ if (!arguments.opts.force) {
+ out("The jobs migration already exists: app/migrator/migrations/#existing[1]#", "yellow");
+ out("Run it with: wheels migrate latest. To rewrite it from this framework version, pass --force.");
+ return "";
+ }
+ // Same file name, so a migration that already ran keeps its version.
+ fileName = existing[1];
+ } else {
+ fileName = getService("helpers").generateMigrationTimestamp() & "_" & result.migrationName & ".cfc";
}
+ fileWrite(migrationDir & "/" & fileName, result.source);
- out("Job Queue Status", "cyan");
- out($formatJobsStatusTable(stats));
+ out("Wrote app/migrator/migrations/#fileName#", "green");
+ out("");
+ out("Next:");
+ out(" 1. Review it, then apply it: wheels migrate latest");
+ out(" It creates the job tables, or adds what existing ones are missing.");
+ out(" 2. In config/settings.cfm: set(jobsAutoCreateTables = false)");
+ out(" so servers never change the job tables at startup.");
return "";
}
/**
- * Render JobWorker.getStats() output ({queues: {name: counts}, totals:
- * counts}) as a fixed-width table. Public ONLY so JobsCommandSpec can
- * unit-test the rendering without a running server (the cli/CLAUDE.md
- * "public for specs" carve-out) — the mcpHiddenTools() structural
- * $-prefix sweep keeps it off the MCP surface. Defensive against partial
- * payloads: the input is deserialized JSON from the bridge. Queue names
- * are sorted — struct iteration order is not insertion order at scale.
+ * Print why the worker loop is ending, plus its counts, and return "" for
+ * runJobsWork() to return.
*/
- public string function $formatJobsStatusTable(required struct stats) {
- var nl = chr(10);
- var queues = structKeyExists(arguments.stats, "queues") && isStruct(arguments.stats.queues)
- ? arguments.stats.queues
- : {};
- var totals = structKeyExists(arguments.stats, "totals") && isStruct(arguments.stats.totals)
- ? arguments.stats.totals
- : {};
+ private string function $jobsWorkStop(required string reason, required struct counters) {
+ out("");
+ out(arguments.reason, "green");
+ out("Processed: #arguments.counters.processed# | Failed: #arguments.counters.failed#");
+ return "";
+ }
- if (structIsEmpty(queues)) {
- return "No jobs found.";
+ /**
+ * `wheels jobs enqueue `: add a job to the queue, now or after a delay
+ * (--in seconds, or --at a time). Everything that can be checked here is
+ * checked before the server call; --at becomes a delay in seconds, so the
+ * server never parses a date. The framework's jobsEnqueue command allowlists
+ * the class. Enqueueing writes a row, so this is write-side: strict server
+ * identity and POST + reload password, like `jobs work`.
+ */
+ private string function runJobsEnqueue(required struct opts) {
+ var usage = "Usage: wheels jobs enqueue [--data=] [--queue=] [--priority=] [--in= | --at=]";
+ if (!len(arguments.opts.job)) {
+ $refuse("Missing the job to enqueue. " & usage);
}
-
- var header = "| " & $padJobsColumn("Queue", 20) & " | " & $padJobsColumn("Pending", 10) & " | "
- & $padJobsColumn("Processing", 12) & " | " & $padJobsColumn("Completed", 12) & " | "
+ if (len(arguments.opts.data)) {
+ var parsedData = isJSON(arguments.opts.data) ? deserializeJSON(arguments.opts.data) : "";
+ if (!isStruct(parsedData)) {
+ $refuse("--data must be a JSON object, like {""userId"":42}.");
+ }
+ }
+ if (len(arguments.opts.priority) && !reFind("^-?[0-9]+$", arguments.opts.priority)) {
+ $refuse("--priority must be a whole number.");
+ }
+ if (len(arguments.opts["in"]) && len(arguments.opts.at)) {
+ $refuse("Use either --in or --at, not both.");
+ }
+ var delay = 0;
+ if (len(arguments.opts["in"])) {
+ if (!reFind("^[0-9]+$", arguments.opts["in"])) {
+ $refuse("--in must be zero or a positive whole number of seconds.");
+ }
+ delay = val(arguments.opts["in"]);
+ } else if (len(arguments.opts.at)) {
+ delay = $jobsDelayUntil(arguments.opts.at, getTickCount());
+ }
+
+ var serverPort = $requireOwnRunningServer([
+ "Enqueueing a job requires a running server bound to this project.",
+ "Start this project's own server with: wheels start"
+ ]);
+ var enqueueUrl = "#$serverUrlBase(serverPort)#/wheels/cli?command=jobsEnqueue&format=json&job=#URLEncodedFormat(arguments.opts.job)#"
+ & (len(arguments.opts.data) ? "&data=" & URLEncodedFormat(arguments.opts.data) : "")
+ & (len(arguments.opts.queue) ? "&queue=" & URLEncodedFormat(arguments.opts.queue) : "")
+ & (len(arguments.opts.priority) ? "&priority=" & arguments.opts.priority : "")
+ & (delay > 0 ? "&delaySeconds=" & delay : "");
+
+ var httpResult = "";
+ try {
+ httpResult = makeBridgePost(enqueueUrl);
+ } catch (any httpErr) {
+ throw(type = "Wheels.JobEnqueueFailed", message = "Enqueue failed (connection error): #httpErr.message#");
+ }
+ var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response from the server."};
+ if (!(parsed.success ?: false) || !isStruct(parsed.job ?: "")) {
+ // Self-contained: the thrown message is all the caller may see.
+ throw(type = "Wheels.JobEnqueueFailed", message = "Enqueue failed: " & (parsed.message ?: "no reason given."));
+ }
+
+ var job = parsed.job;
+ if (arguments.opts.format == "json") {
+ out(serializeJSON(job));
+ return "";
+ }
+ var when = delay > 0
+ ? "runs in #delay# seconds (#dateTimeFormat(dateAdd("s", delay, now()), "yyyy-mm-dd HH:nn:ss")# local time)"
+ : "runs now";
+ out("Enqueued #job.jobClass# (#job.id#) on queue #job.queue#, #when#.", "green");
+ out("A worker picks it up: wheels jobs work");
+ return "";
+ }
+
+ /**
+ * Seconds from `nowMs` (epoch milliseconds) until an ISO 8601 time: with a Z or
+ * an offset it is read in that offset, without one in this machine's local time
+ * ("2026-10-05T14:30", "2026-10-05 14:30:00"). Refuses an unreadable or past
+ * time. Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public numeric function $jobsDelayUntil(required string at, required numeric nowMs) {
+ var text = trim(arguments.at);
+ var epochMs = $parseIsoInstantMs(text);
+ if (epochMs < 0) {
+ $refuse("Can't read --at '#text#': use an ISO 8601 time like 2026-10-05T14:30:00Z, 2026-10-05T16:30:00+02:00, or 2026-10-05 14:30 for local time.");
+ }
+ var delayMs = epochMs - arguments.nowMs;
+ if (delayMs <= 0) {
+ $refuse("--at #text# is in the past. Leave out --in and --at to run the job now.");
+ }
+ return ceiling(delayMs / 1000);
+ }
+
+ /**
+ * Epoch milliseconds for an ISO 8601 time (see $jobsDelayUntil), or -1.
+ */
+ private numeric function $parseIsoInstantMs(required string text) {
+ try {
+ if (reFind("(Z|[+-][0-9]{2}:[0-9]{2})$", arguments.text)) {
+ return createObject("java", "java.time.OffsetDateTime").parse(arguments.text).toInstant().toEpochMilli();
+ }
+ var local8601 = replace(arguments.text, " ", "T");
+ if (reFind("T[0-9]{2}:[0-9]{2}$", local8601)) {
+ local8601 &= ":00";
+ }
+ return createObject("java", "java.time.LocalDateTime").parse(local8601)
+ .atZone(createObject("java", "java.time.ZoneId").systemDefault())
+ .toInstant()
+ .toEpochMilli();
+ } catch (any e) {
+ return -1;
+ }
+ }
+
+ /**
+ * One-shot queue snapshot via the read-only jobsStatus bridge command.
+ */
+ private string function runJobsStatus(required struct opts) {
+ var serverPort = $requireRunningServer(
+ hints = ["Start one with: wheels start"]
+ );
+
+ var statusUrl = "#$serverUrlBase(serverPort)#/wheels/cli?command=jobsStatus&format=json";
+ if (len(arguments.opts.queue)) {
+ statusUrl &= "&queue=" & urlEncodedFormat(arguments.opts.queue);
+ }
+
+ var httpResult = "";
+ try {
+ httpResult = makeHttpRequest(statusUrl);
+ } catch (any httpErr) {
+ throw(
+ type = "Wheels.Cli.CommandFailed",
+ message = "Jobs status failed (connection error): #httpErr.message#",
+ detail = httpErr.detail ?: ""
+ );
+ }
+
+ var result = parseCliResponse(httpResult, "Jobs status");
+ var stats = structKeyExists(result, "stats") && isStruct(result.stats) ? result.stats : {};
+
+ var host = structKeyExists(result, "host") && isStruct(result.host) ? result.host : {};
+
+ if (arguments.opts.format == "json") {
+ var payload = duplicate(stats);
+ if (!structIsEmpty(host)) {
+ payload.host = host;
+ }
+ out(serializeJSON(payload));
+ return "";
+ }
+
+ out("Job Queue Status", "cyan");
+ out($formatJobsStatusTable(stats));
+ if (!structIsEmpty(host)) {
+ out($formatJobsHostLine(host));
+ }
+ return "";
+ }
+
+ /**
+ * `wheels jobs drain`: this server starts no new jobs (deploys). With --wait, keep polling
+ * until it has no running jobs, so the deploy can stop it safely; refuse (non-zero exit) if
+ * jobs are still running when the wait runs out.
+ */
+ private string function runJobsDrain(required struct opts) {
+ var serverPort = $requireOwnRunningServer([
+ "Draining needs this project's running server.",
+ "Start it with: wheels start"
+ ]);
+ var base = $serverUrlBase(serverPort);
+ var httpResult = "";
+ try {
+ httpResult = makeBridgePost("#base#/wheels/cli?command=jobsDrain&format=json&expiresInSeconds=#int(arguments.opts.expires)#");
+ } catch (any httpErr) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Drain failed (connection error): #httpErr.message#");
+ }
+ var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response from the server."};
+ if (!(parsed.success ?: false) || !isStruct(parsed.host ?: "")) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Drain failed: " & (parsed.message ?: "no reason given."));
+ }
+ var host = parsed.host;
+ if (arguments.opts.wait > 0) {
+ host = $jobsWaitForDrain(base = base, host = host, waitSeconds = arguments.opts.wait);
+ }
+ if (arguments.opts.format == "json") {
+ out(serializeJSON(host));
+ return "";
+ }
+ out("Draining #host.host#: no new jobs will start there.", "green");
+ out($formatJobsHostLine(host));
+ if (arguments.opts.wait > 0) {
+ out("No jobs are running on #host.host#; it is safe to stop.", "green");
+ } else {
+ out("Resume with: wheels jobs resume");
+ }
+ return "";
+ }
+
+ /**
+ * `wheels jobs tick`: one JobRunner.tick() on this project's running server, for a cron job
+ * or systemd timer that runs jobs without a long-lived worker. Mutating, so it goes through
+ * the bridge's POST + reload-password gate like `work`.
+ */
+ private string function runJobsTick(required struct opts) {
+ var serverPort = $requireOwnRunningServer([
+ "A tick needs this project's running server.",
+ "Start it with: wheels start"
+ ]);
+ var httpResult = "";
+ try {
+ httpResult = makeBridgePost("#$serverUrlBase(serverPort)#/wheels/cli?command=jobsTick&format=json");
+ } catch (any httpErr) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Tick failed (connection error): #httpErr.message#");
+ }
+ var result = parseCliResponse(httpResult, "Jobs tick");
+ var tick = isStruct(result.tick ?: "") ? result.tick : {};
+ if (arguments.opts.format == "json") {
+ out(serializeJSON(tick));
+ return "";
+ }
+ out("Tick on #tick.host ?: '?'#: processed #tick.processed ?: 0#, failed #tick.failed ?: 0#, reaped #tick.reaped ?: 0#" & ((tick.draining ?: false) ? " (draining)" : "") & ((tick.capped ?: false) ? " (at the per-host cap)" : ""));
+ return "";
+ }
+
+ /**
+ * `wheels jobs resume`: this server starts jobs again.
+ */
+ private string function runJobsResume(required struct opts) {
+ var serverPort = $requireOwnRunningServer([
+ "Resuming needs this project's running server.",
+ "Start it with: wheels start"
+ ]);
+ var httpResult = "";
+ try {
+ httpResult = makeBridgePost("#$serverUrlBase(serverPort)#/wheels/cli?command=jobsResume&format=json");
+ } catch (any httpErr) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Resume failed (connection error): #httpErr.message#");
+ }
+ var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response from the server."};
+ if (!(parsed.success ?: false) || !isStruct(parsed.host ?: "")) {
+ throw(type = "Wheels.Cli.CommandFailed", message = "Resume failed: " & (parsed.message ?: "no reason given."));
+ }
+ if (arguments.opts.format == "json") {
+ out(serializeJSON(parsed.host));
+ return "";
+ }
+ out("Resumed #parsed.host.host#: it starts jobs again.", "green");
+ out($formatJobsHostLine(parsed.host));
+ return "";
+ }
+
+ /**
+ * Poll this server's job state until nothing is running, checking every 2 seconds for up
+ * to waitSeconds. Returns the last state; throws Wheels.JobsDrainTimeout when jobs are
+ * still running at the limit. Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public struct function $jobsWaitForDrain(required string base, required struct host, required numeric waitSeconds) {
+ var state = arguments.host;
+ var pollSeconds = 2;
+ var polls = ceiling(arguments.waitSeconds / pollSeconds);
+ for (var poll = 0; poll <= polls; poll++) {
+ if (val(state.running ?: 0) <= 0) {
+ return state;
+ }
+ if (poll == polls) {
+ break;
+ }
+ out("Waiting for #state.running# running job(s) on #state.host# to finish...");
+ $jobsPause(pollSeconds);
+ var httpResult = makeHttpRequest("#arguments.base#/wheels/cli?command=jobsHostStatus&format=json");
+ var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {};
+ if (isStruct(parsed.host ?: "")) {
+ state = parsed.host;
+ }
+ }
+ throw(
+ type = "Wheels.JobsDrainTimeout",
+ message = "#state.host# is draining, but #state.running# job(s) were still running after #arguments.waitSeconds# seconds.",
+ detail = "Don't stop the server yet: wait longer (--wait=), or check the jobs with: wheels jobs status"
+ );
+ }
+
+ /**
+ * --wait's value in seconds: "" = don't wait, a bare --wait = 600, a number = that many.
+ * Public for specs ($-prefixed, so hidden from MCP).
+ */
+ public numeric function $jobsWaitSeconds(required string wait) {
+ if (!len(arguments.wait) || arguments.wait == "false") {
+ return 0;
+ }
+ if (arguments.wait == "true") {
+ return 600;
+ }
+ if (!reFind("^[0-9]+$", arguments.wait)) {
+ throw(type = "Wheels.InvalidArguments", message = "--wait must be a whole number of seconds (or just --wait for up to 600).");
+ }
+ return val(arguments.wait);
+ }
+
+ /**
+ * Pause between drain polls. Its own method so specs can skip the wait.
+ */
+ public void function $jobsPause(required numeric seconds) {
+ sleep(arguments.seconds * 1000);
+ }
+
+ /**
+ * One line describing this server's job state, from JobRunner.status(). Public for specs.
+ */
+ public string function $formatJobsHostLine(required struct host) {
+ var hostName = arguments.host.host ?: "";
+ var running = val(arguments.host.running ?: 0);
+ var cap = val(arguments.host.maxConcurrent ?: 0) > 0 ? arguments.host.maxConcurrent : "no cap";
+ var line = "Host #hostName#: #running# running, max #cap#";
+ if (arguments.host.draining ?: false) {
+ var expires = len(arguments.host.drainExpiresAt ?: "") ? " until #arguments.host.drainExpiresAt#" : " until resumed";
+ line &= ", draining#expires#";
+ }
+ return line;
+ }
+
+ /**
+ * Render JobWorker.getStats() output ({queues: {name: counts}, totals:
+ * counts}) as a fixed-width table. Public ONLY so JobsCommandSpec can
+ * unit-test the rendering without a running server (the cli/CLAUDE.md
+ * "public for specs" carve-out) — the mcpHiddenTools() structural
+ * $-prefix sweep keeps it off the MCP surface. Defensive against partial
+ * payloads: the input is deserialized JSON from the bridge. Queue names
+ * are sorted — struct iteration order is not insertion order at scale.
+ */
+ public string function $formatJobsStatusTable(required struct stats) {
+ var nl = chr(10);
+ var queues = structKeyExists(arguments.stats, "queues") && isStruct(arguments.stats.queues)
+ ? arguments.stats.queues
+ : {};
+ var totals = structKeyExists(arguments.stats, "totals") && isStruct(arguments.stats.totals)
+ ? arguments.stats.totals
+ : {};
+
+ if (structIsEmpty(queues)) {
+ return "No jobs found.";
+ }
+
+ var header = "| " & $padJobsColumn("Queue", 20) & " | " & $padJobsColumn("Pending", 10) & " | "
+ & $padJobsColumn("Processing", 12) & " | " & $padJobsColumn("Completed", 12) & " | "
& $padJobsColumn("Failed", 10) & " | " & $padJobsColumn("Total", 10) & " |";
var separator = repeatString("-", len(header));
var lines = [separator, header, separator];
@@ -5348,34 +6537,34 @@ component extends="modules.BaseModule" {
};
}
+ // `wheels upgrade apply` performs the framework swap (#3035): it
+ // replaces the app's vendor/wheels/ with the framework bundled inside
+ // the installed CLI, parking the old copy at .wheels/backups/wheels.bak-/
+ // (outside vendor/) unless --nobackup. Recovery is a single mv (announced, with the exact
+ // backup path, before anything is touched). Only the CLI's bundled
+ // framework is available as a source for now — pair it with your package
+ // manager (`brew upgrade wheels`, `brew install wheels-be`, `scoop update
+ // wheels`) to choose what gets bundled. Downloading arbitrary --to=
+ // targets is the planned follow-up.
+ //
+ // Bare `wheels upgrade` deliberately does nothing: it prints concise
+ // usage steering at the two verbs and exits 0. Destructive commands
+ // deserve an explicit verb, and MCP clients calling wheels_upgrade with
+ // {} must never mutate — requiring `apply` fixes that transport-
+ // independently, and exit 0 matches the pre-apply-mode bare behavior so
+ // existing CI invocations see usage text, not a new failure.
+ //
+ // `wheels upgrade check` keeps the read-only scan: it reports code paths
+ // that will break against a target framework version without modifying
+ // any files. Breaking findings throw Wheels.UpgradeCheckFailed after the
+ // report is printed, so the command exits non-zero and can gate CI.
+ // --strict escalates advisory findings the same way, and so does a failed
+ // latest-release lookup when no --to= is given. (--dry-run is not
+ // supported — `check` is the preview.)
+ // Kept out of the docblock below: `wheels upgrade --help` prints it.
/**
* hint: Upgrade the Wheels framework in your app (vendor/wheels/) — `check` scans for breaking changes (read-only), `apply` performs the swap
*
- * `wheels upgrade apply` performs the framework swap (#3035): it
- * replaces the app's vendor/wheels/ with the framework bundled inside
- * the installed CLI, parking the old copy at vendor/wheels.bak-/
- * unless --nobackup. Recovery is a single mv (announced, with the exact
- * backup path, before anything is touched). Only the CLI's bundled
- * framework is available as a source for now — pair it with your package
- * manager (`brew upgrade wheels`, `brew install wheels-be`, `scoop update
- * wheels`) to choose what gets bundled. Downloading arbitrary --to=
- * targets is the planned follow-up.
- *
- * Bare `wheels upgrade` deliberately does nothing: it prints concise
- * usage steering at the two verbs and exits 0. Destructive commands
- * deserve an explicit verb, and MCP clients calling wheels_upgrade with
- * {} must never mutate — requiring `apply` fixes that transport-
- * independently, and exit 0 matches the pre-apply-mode bare behavior so
- * existing CI invocations see usage text, not a new failure.
- *
- * `wheels upgrade check` keeps the read-only scan: it reports code paths
- * that will break against a target framework version without modifying
- * any files. Breaking findings throw Wheels.UpgradeCheckFailed after the
- * report is printed, so the command exits non-zero and can gate CI.
- * --strict escalates advisory findings the same way, and so does a failed
- * latest-release lookup when no --to= is given. (--dry-run is not
- * supported — `check` is the preview.)
- *
* Examples:
* wheels upgrade apply - apply the swap, with backup
* wheels upgrade apply --nobackup - apply without the backup
@@ -5465,6 +6654,173 @@ component extends="modules.BaseModule" {
return runUpgradeApply(opts.targetVersion, opts.doBackup, opts.allowDowngrade);
}
+ /**
+ * Install the Wheels framework into an existing app that has no vendor/wheels/.
+ */
+ public string function framework() {
+ var coll = structuredArgs(arguments);
+ var opts = frameworkArgSpec().parse(coll);
+ var wantsHelp = !len(opts.subcommand)
+ || opts.subcommand == "help"
+ || (structKeyExists(opts, "help") && isBoolean(opts.help) && opts.help)
+ || (structKeyExists(opts, "h") && isBoolean(opts.h) && opts.h);
+ if (wantsHelp) {
+ return $printFrameworkHelp();
+ }
+ // frameworkArgSpec()'s choices="install,help" has already rejected any
+ // other subcommand with a non-zero exit before we get here.
+ return runFrameworkInstall(opts.to);
+ }
+
+ // Fresh-install path: drop the CLI's bundled framework into an app that has
+ // no vendor/wheels/. Replacing an EXISTING framework is `wheels upgrade
+ // apply`'s job (one path for that), so this refuses when vendor/wheels/ is
+ // already present. Prints progress via out() and returns "" — out() is the
+ // display, and returning the text too would double it in MCP results (the
+ // U4 convention for runUpgradeApply). Refusals print-then-throw (#2941).
+ private string function runFrameworkInstall(string targetVersion = "") {
+ var nl = chr(10);
+ var vendorDir = variables.projectRoot & "/vendor/wheels";
+ var upgrader = new services.FrameworkUpgrader();
+
+ // Refuse unless we're at a Wheels app root — otherwise this would create a
+ // vendor/wheels/ in whatever directory the user happened to run it from.
+ // config/settings.cfm is the canonical marker (every Wheels app has it);
+ // config/app.cfm and public/Application.cfc are accepted too.
+ if (
+ !fileExists(variables.projectRoot & "/config/settings.cfm")
+ && !fileExists(variables.projectRoot & "/config/app.cfm")
+ && !fileExists(variables.projectRoot & "/public/Application.cfc")
+ ) {
+ out("This does not look like a Wheels app root: #variables.projectRoot#", "red");
+ out("Run 'wheels framework install' from an app that has config/settings.cfm (e.g. one made with 'wheels new').");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = "Not a Wheels app root (#variables.projectRoot#) — run from an app with config/settings.cfm (or config/app.cfm / public/Application.cfc)."
+ );
+ }
+
+ if ($safeDirExists(vendorDir)) {
+ if (upgrader.looksLikeWheelsFramework(vendorDir)) {
+ var present = upgrader.readFrameworkVersion(vendorDir);
+ out("vendor/wheels/ already exists#len(present) ? ' (Wheels ' & present & ')' : ''#.", "red");
+ out("Use 'wheels upgrade apply' to replace an existing framework.");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = "vendor/wheels/ already present — use `wheels upgrade apply` to replace an existing framework."
+ );
+ }
+ out("vendor/wheels/ exists but does not look like a Wheels framework.", "red");
+ out("Remove it (or use 'wheels upgrade apply'), then re-run 'wheels framework install'.");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = "vendor/wheels/ exists but is not a Wheels framework — remove it or use `wheels upgrade apply`."
+ );
+ }
+
+ var sourceDir = $resolveBundledFrameworkSource();
+ if (!len(sourceDir)) {
+ out("Could not locate the CLI's bundled framework — the CLI install may be incomplete.", "red");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = "Could not locate the CLI's bundled framework (tried WHEELS_FRAMEWORK_PATH, then the module's own install tree)."
+ );
+ }
+
+ var bundledVersion = upgrader.readFrameworkVersion(sourceDir);
+ if (len(arguments.targetVersion) && arguments.targetVersion != bundledVersion) {
+ out("Requested --to=#arguments.targetVersion# but the CLI bundles #len(bundledVersion) ? bundledVersion : 'unknown'#.", "red");
+ out("Only the bundled version can be installed (no downloads). Pass --to=#len(bundledVersion) ? bundledVersion : ''# or omit --to. (A snapshot CLI bundles a version like 4.2.0-snapshot.NNNN, so --to=4.2.0 won't match.)");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = "--to=#arguments.targetVersion# does not match the CLI's bundled framework version (#bundledVersion#); only the bundled version is installable."
+ );
+ }
+
+ // Read the box.json pin BEFORE creating anything, so a pin-read failure
+ // leaves no half-made vendor/ behind (rev1-r3). The pin is updated after
+ // the install so a later `box install` doesn't copy an old framework over
+ // vendor/wheels/; a missing box.json is the normal case for an app moving
+ // onto the CLI.
+ var boxPin = $upgradeApplyBoxPin(upgrader);
+
+ // A fresh install may run in an app that has no vendor/ directory yet
+ // (e.g. one that never ran `box install`); create the parent so the swap
+ // has somewhere to land — validateSwap requires the parent to exist.
+ var vendorParent = getDirectoryFromPath(vendorDir);
+ if (!directoryExists(vendorParent)) {
+ directoryCreate(vendorParent, true, true);
+ }
+
+ // The service's pre-mutation refusal checks (source looks like a
+ // framework; target parent exists; no path overlap). An absent vendorDir
+ // is allowed — that is the fresh-install case.
+ var validationError = upgrader.validateSwap(sourceDir, vendorDir);
+ if (len(validationError)) {
+ out(validationError, "red");
+ throw(type = "Wheels.FrameworkInstallFailed", message = validationError);
+ }
+
+ out("Source: #sourceDir#");
+ out("Target: #vendorDir#");
+ out("Installing Wheels #bundledVersion# into vendor/wheels/ ...");
+
+ var result = {};
+ try {
+ // doBackup = false: a fresh install has nothing to back up.
+ result = upgrader.applyUpgrade(sourceDir, vendorDir, false, "");
+ } catch (Wheels.FrameworkUpgrader e) {
+ // A fresh install has no backup to restore from, so a partial copy
+ // should be removed and the command re-run (not the upgrade/re-vendor
+ // recovery the service message describes for an in-place swap).
+ out(e.message, "red");
+ out("Remove the partial vendor/wheels/ and re-run 'wheels framework install'.");
+ throw(
+ type = "Wheels.FrameworkInstallFailed",
+ message = e.message & " — remove the partial vendor/wheels/ and re-run `wheels framework install`."
+ );
+ }
+ if (!result.success) {
+ out(result.error, "red");
+ throw(type = "Wheels.FrameworkInstallFailed", message = result.error);
+ }
+
+ var summary = "Framework installed: #result.newVersion#" & nl;
+ summary &= $upgradeApplyUpdateBoxPin(upgrader, boxPin, result.newVersion);
+ summary &= nl & "Next: run 'wheels start', then 'wheels migrate latest'." & nl;
+ out(summary, "green");
+ return "";
+ }
+
+ /**
+ * Help block for `wheels framework` / `wheels framework help` / `--help`.
+ */
+ private string function $printFrameworkHelp() {
+ var nl = chr(10);
+ var help = "Usage:" & nl
+ & " wheels framework install [--to=]" & nl
+ & nl
+ & "Install the Wheels framework (vendor/wheels/) into an existing app that has" & nl
+ & "none — for example an app moving off CommandBox. To replace an existing" & nl
+ & "framework, use 'wheels upgrade apply' instead." & nl
+ & nl
+ & "Subcommands:" & nl
+ & " install Install the CLI's bundled framework into vendor/wheels/." & nl
+ & " Refuses if vendor/wheels/ already exists (use upgrade apply)." & nl
+ & nl
+ & "Options:" & nl
+ & " --to= Must match the CLI's bundled framework version (no downloads)." & nl
+ & nl
+ & "Examples:" & nl
+ & " wheels framework install - install the bundled framework" & nl
+ & " wheels framework install --to=4.2.0 - install, asserting the bundled version" & nl;
+ // out() is the sole CLI display; returning the text too would print the
+ // whole block twice (the double-print U4/#4265 fixed in upgrade apply).
+ // Specs read capturedOutput() instead of the return value.
+ out(help, "yellow");
+ return "";
+ }
+
/**
* Help block for `wheels upgrade help` / `--help`. Extracted so the
* help short-circuit and the unknown-subcommand error path stay in sync.
@@ -5484,8 +6840,8 @@ component extends="modules.BaseModule" {
& " non-zero when breaking changes are found." & nl
& " apply Apply the upgrade — replace vendor/wheels/ with" & nl
& " the CLI's bundled framework. Backs up the existing" & nl
- & " vendor/wheels/ as vendor/wheels.bak-/" & nl
- & " unless --nobackup." & nl
+ & " vendor/wheels/ as .wheels/backups/wheels.bak-/" & nl
+ & " (outside vendor/) unless --nobackup." & nl
& " (none) Print usage. Bare `wheels upgrade` never modifies" & nl
& " files — the swap requires the explicit `apply` verb." & nl
& nl
@@ -5495,7 +6851,7 @@ component extends="modules.BaseModule" {
& " CLI's bundled framework version." & nl
& " --offline Check only: never call GitHub. Without --to the" & nl
& " check fails and asks for one. Also WHEELS_OFFLINE=1." & nl
- & " --nobackup Apply only: skip the vendor/wheels.bak-/" & nl
+ & " --nobackup Apply only: skip the .wheels/backups/wheels.bak-/" & nl
& " backup. Useful when vendor/wheels/ is tracked in git." & nl
& " --allow-downgrade Apply only: proceed when the CLI's bundled framework" & nl
& " is OLDER than vendor/wheels/. Refused by default." & nl
@@ -5534,7 +6890,7 @@ component extends="modules.BaseModule" {
& " Scan the app for breaking changes (read-only)." & nl
& " wheels upgrade apply [--to=] [--nobackup]" & nl
& " Replace vendor/wheels/ with the CLI's bundled framework" & nl
- & " (backs up to vendor/wheels.bak-/ first)." & nl
+ & " (backs up to .wheels/backups/wheels.bak-/ first)." & nl
& nl
& "Run `wheels upgrade help` for full usage." & nl;
// Returned for the caller to throw: the thrown error prints it once and
@@ -5570,27 +6926,22 @@ component extends="modules.BaseModule" {
var subcommand = lCase(args[1]);
switch (subcommand) {
- // `setup` is the canonical verb. `install` is accepted but warned —
- // LuCLI intercepts `install` as its built-in extension installer
- // before it reaches a module's dispatch, so users typing
- // `wheels browser install` actually invoke the LuCLI built-in and
- // see "Reading lucee.json... No git or extension dependencies to
- // install" instead of the Playwright fetch. The case branch here
- // only fires if the user reaches us via some other path (e.g. an
- // argument vector that bypasses LuCLI's parsing). See issue #2332.
+ // `setup` is the documented verb; `install` is an alias of it, as
+ // `wheels packages install` is of `add`. LuCLI intercepts `install`
+ // only as the FIRST argument (`wheels install`), so
+ // `wheels browser install` does reach this module (#4213; the
+ // original interception was #2332).
case "setup":
- return browserInstall(args);
case "install":
- out("'wheels browser install' is intercepted by LuCLI's built-in", "yellow");
- out("extension installer and won't reach this module. Use:", "yellow");
- out(" wheels browser setup", "bold");
- return "";
+ return browserInstall(args);
case "test":
return browserTest(args);
default:
- out("Unknown browser command: #subcommand#", "red");
- out("Valid commands: setup, test");
- return "";
+ $refuse(
+ "Unknown browser command: #subcommand#",
+ "Wheels.InvalidArguments",
+ ["Valid commands: setup, test"]
+ );
}
}
@@ -5720,7 +7071,7 @@ component extends="modules.BaseModule" {
private string function generateMigration(required array args) {
if (!arrayLen(args)) {
out("Usage: wheels generate migration ", "yellow");
- out(" Example: wheels generate migration AddEmailToUsers");
+ out(" Example: wheels generate migration BackfillUserSlugs");
$refuse("wheels generate migration: missing required arguments. Usage: wheels generate migration ");
}
@@ -5741,7 +7092,53 @@ component extends="modules.BaseModule" {
// was already the path every dev-checkout install used. See CLI audit H4.
$generateWrite(filePath, buildEmptyMigration(migrationName));
- printCreated("app/migrator/migrations/#fileName#");
+ printCreated("app/migrator/migrations/#fileName#");
+ var hint = $migrationNameHint(migrationName);
+ if (len(hint)) {
+ out("");
+ out(hint, "yellow");
+ }
+ return "";
+ }
+
+ /**
+ * A next step for a migration whose name reads like a column change
+ * (`AddEmailToUsers`, `add_email_to_users`, `RemoveEmailFromUsers`).
+ * `generate migration` writes a blank up()/down() whatever the name says,
+ * so point an add at `wheels generate property`, which writes the
+ * addColumn() for you, and show the removeColumn() call for a remove.
+ * Returns "" for any other name. Public for specs; the `$` prefix keeps it
+ * off the MCP tool list.
+ */
+ public string function $migrationNameHint(required string migrationName) {
+ var name = arguments.migrationName;
+ var nl = chr(10);
+ var parts = reFind("^(?:Add([A-Z][A-Za-z0-9]*?)To([A-Z][A-Za-z0-9]*)|add_([a-z0-9_]+?)_to_([a-z0-9_]+))$", name, 1, true);
+ if (parts.pos[1] > 0) {
+ var column = parts.pos[2] > 0 ? mid(name, parts.pos[2], parts.len[2]) : mid(name, parts.pos[4], parts.len[4]);
+ var table = parts.pos[3] > 0 ? mid(name, parts.pos[3], parts.len[3]) : mid(name, parts.pos[5], parts.len[5]);
+ // Keep the words' case: lCase() first turned BlogPosts into "Blogpost".
+ // A snake_case table (blog_posts) is PascalCased first; singularize()
+ // then changes only the last word.
+ var pascalTable = "";
+ for (var word in listToArray(table, "_")) {
+ pascalTable &= uCase(left(word, 1)) & mid(word, 2, len(word));
+ }
+ var modelName = getService("helpers").singularize(pascalTable);
+ var columnName = lCase(left(column, 1)) & mid(column, 2, len(column));
+ return "This migration is blank: `generate migration` doesn't read columns from its name." & nl
+ & "To add #columnName# to #lCase(table)# with the migration written for you, use instead:" & nl
+ & " wheels generate property #modelName# #columnName#:string (or :integer, :boolean, ...)";
+ }
+ parts = reFind("^(?:Remove([A-Z][A-Za-z0-9]*?)From([A-Z][A-Za-z0-9]*)|remove_([a-z0-9_]+?)_from_([a-z0-9_]+))$", name, 1, true);
+ if (parts.pos[1] > 0) {
+ var column = parts.pos[2] > 0 ? mid(name, parts.pos[2], parts.len[2]) : mid(name, parts.pos[4], parts.len[4]);
+ var table = parts.pos[3] > 0 ? mid(name, parts.pos[3], parts.len[3]) : mid(name, parts.pos[5], parts.len[5]);
+ var columnName = lCase(left(column, 1)) & mid(column, 2, len(column));
+ return "This migration is blank: `generate migration` doesn't read columns from its name." & nl
+ & "Fill in up() with:" & nl
+ & ' removeColumn(table="#lCase(table)#", columnName="#columnName#");';
+ }
return "";
}
@@ -5815,7 +7212,7 @@ component extends="modules.BaseModule" {
out("");
out("Scaffold complete! Next steps:", "green");
out(" 1. Run migrations: wheels migrate latest");
- out(" 2. Start server: wheels start");
+ out(" 2. " & $serverNextStep());
}
} else {
$refuse("Scaffold failed: " & arrayToList(results.errors, "; "), "Wheels.Generate.Refused");
@@ -5907,10 +7304,11 @@ component extends="modules.BaseModule" {
var names = new services.GeneratorPaths();
var modelName = capitalize(names.identifier(args[1], "model"));
- var propArg = args[2];
- var parts = listToArray(propArg, ":");
- var propName = names.identifier($underscoreHyphens(arrayLen(parts) ? parts[1] : "", "property"), "property");
- var propType = names.identifier(arrayLen(parts) > 1 ? parts[2] : "string", "property type");
+ // Share the model generator's token parser so `:optional` and `=value`
+ // are understood here too (it validates the name and type as identifiers).
+ var prop = $parsePropertyArg(args[2]);
+ var propName = prop.name;
+ var propType = prop.type;
var tableName = getService("helpers").pluralize(lCase(modelName));
var timestamp = getService("helpers").generateMigrationTimestamp();
@@ -5927,7 +7325,17 @@ component extends="modules.BaseModule" {
content &= tab & 'function up() {' & nl;
content &= tab & tab & 'transaction {' & nl;
content &= tab & tab & tab & 't = changeTable(name="#tableName#");' & nl;
- content &= tab & tab & tab & 't.#colType#(columnNames="#propName#");' & nl;
+ // Columns added to an EXISTING table stay nullable (no allowNull=false) so
+ // the ALTER succeeds on a populated table; a `=value` default is emitted
+ // when given (and is safe — it backfills existing rows).
+ var colParams = 'columnNames="#propName#"';
+ if (structKeyExists(prop, "default") && len(prop.default)) {
+ // Literal string in generated CFML: double "##" (interpolation delimiter)
+ // so a "#" in the default stays literal, and double the embedded quotes.
+ var safeDefault = replace(replace(prop.default, "##", "####", "all"), '"', '""', "all");
+ colParams &= ', default="' & safeDefault & '"';
+ }
+ content &= tab & tab & tab & 't.#colType#(#colParams#);' & nl;
content &= tab & tab & tab & 't.change();' & nl;
content &= tab & tab & '}' & nl;
content &= tab & '}' & nl & nl;
@@ -5943,8 +7351,16 @@ component extends="modules.BaseModule" {
$generateWrite(migrationDir & "/" & fileName, content);
printCreated("app/migrator/migrations/#fileName#");
out("");
- out("Remember to add validation in app/models/#modelName#.cfc config():", "yellow");
- out(' validatesPresenceOf("#propName#");');
+ // Suggest a presence validation only for a required column with no default —
+ // the same rule the model generator uses. The column is added nullable (so
+ // existing rows are safe); presence then enforces it for new records. An
+ // `:optional` or defaulted column gets no suggestion.
+ var suggestPresence = (structKeyExists(prop, "required") ? prop.required : false)
+ && !(structKeyExists(prop, "default") && len(prop.default));
+ if (suggestPresence) {
+ out("Remember to add validation in app/models/#modelName#.cfc config():", "yellow");
+ out(' validatesPresenceOf("#propName#");');
+ }
return "";
}
@@ -5992,8 +7408,10 @@ component extends="modules.BaseModule" {
out("");
out("API resource complete! Next steps:", "green");
out(" 1. Run migrations: wheels migrate latest");
- out(" 2. Start server: wheels start");
- out(" 3. Test: curl http://localhost:8080/api/#lCase(controllerName)#.json");
+ out(" 2. " & $serverNextStep());
+ // The project's pinned port (lucee.json), not a hardcoded 8080.
+ var apiPort = $readPinnedPort(variables.projectRoot);
+ out(" 3. Test: curl http://localhost:#apiPort > 0 ? apiPort : 8080#/api/#lCase(controllerName)#.json");
} else {
$refuse("API resource generation failed: " & arrayToList(results.errors, "; "), "Wheels.Generate.Refused");
}
@@ -6124,9 +7542,11 @@ component extends="modules.BaseModule" {
// Look up the named snippet pattern
var snippets = getSnippetRegistry();
if (!structKeyExists(snippets, pattern)) {
- out("Unknown snippet pattern: #pattern#", "red");
- out("Run 'wheels generate snippets' for available patterns.");
- return "";
+ $refuse(
+ "Unknown snippet pattern: #pattern#",
+ "Wheels.Generate.Refused",
+ ["Run 'wheels generate snippets' for available patterns."]
+ );
}
var snippet = snippets[pattern];
@@ -6261,7 +7681,8 @@ component extends="modules.BaseModule" {
strategy = strategy,
registration = registration,
force = force,
- cliVersion = super.version()
+ // Written into the generated files' header comments: never the raw build token (#3891).
+ cliVersion = $displayVersion()
);
if (results.success) {
@@ -6286,8 +7707,10 @@ component extends="modules.BaseModule" {
out(" 3. Restart, then POST credentials to /api/session to receive a JWT.");
out(" 4. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a bcrypt derivation.");
} else {
- out(" 2. Restart, then POST credentials to /api/session to receive a bearer token.");
- out(" 3. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a bcrypt derivation.");
+ out(" 2. Create a first account (there is no sign-up endpoint): a seedOnce() in app/db/seeds.cfm, then wheels seed.");
+ out(" 3. Restart, then POST credentials to /api/session to receive a bearer token.");
+ out(" 4. Protect actions with a filter that authenticates the Authorization header (see .ai/auth.md).");
+ out(" 5. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a bcrypt derivation.");
}
out(" Generated code is yours to edit — re-run with --force and review `git diff` to upgrade.");
} else {
@@ -6434,16 +7857,16 @@ component extends="modules.BaseModule" {
"seed-data": {
name: "Seed Data",
description: "Database seeding template with seedOnce() examples",
- hint: "Run seeds with: wheels seed.",
+ hint: "Seeds go in app/db/seeds.cfm and app/db/seeds/development.cfm, where wheels seed reads them; existing files are kept unless you pass --force. Run them with: wheels seed.",
generate: function(string projectRoot, boolean force) {
var created = [];
var content = fileRead(snippetDir & "seeds.txt");
- var p = writeSnippetFile(projectRoot, "app/snippets/seeds.cfm", content, force);
+ var p = writeSnippetFile(projectRoot, "app/db/seeds.cfm", content, force);
if (len(p)) arrayAppend(created, p);
var devContent = fileRead(snippetDir & "seeds-development.txt");
- p = writeSnippetFile(projectRoot, "app/snippets/seeds-development.cfm", devContent, force);
+ p = writeSnippetFile(projectRoot, "app/db/seeds/development.cfm", devContent, force);
if (len(p)) arrayAppend(created, p);
return created;
@@ -6778,6 +8201,99 @@ component extends="modules.BaseModule" {
return "";
}
+ /**
+ * `wheels migrate unlock [--force]` (#4209). Without --force it only reports
+ * the migration lock: exit 0 when nothing live holds it, a refusal naming
+ * --force when an instance does, so nobody clears a running migration by
+ * habit. --force removes the lease row and says what it removed. --yes is
+ * not an alias: an MCP client confirming out of habit (it confirms
+ * forget/pretend) must not remove a live lock without naming force.
+ */
+ private string function runMigrationUnlock(required array args) {
+ var force = false;
+ for (var i = 2; i <= arrayLen(arguments.args); i++) {
+ if (arguments.args[i] == "--force") {
+ force = true;
+ }
+ }
+ if (force) {
+ return runMigrationUnlockForce();
+ }
+
+ var serverPort = $resolveMigrationServerPort(false);
+ var statusUrl = "#$serverUrlBase(serverPort)#/wheels/cli?command=migrationLockStatus&format=json";
+ var lock = $migrationLockResponse(makeHttpRequest(statusUrl), "unlock").lock;
+ if (!lock.held) {
+ out("No migration lock is held.", "green");
+ return "";
+ }
+ if (lock.expired) {
+ out("The migration lock is held by #$migrationLockDescription(lock)#, but its lease expired #abs(lock.expiresInSeconds)# seconds ago.", "yellow");
+ out("The next migration takes it over, so there is nothing to clear.");
+ return "";
+ }
+ // One self-contained message: an MCP client sees only the thrown message.
+ $refuse(
+ "The migration lock is held by #$migrationLockDescription(lock)#; its lease expires in #lock.expiresInSeconds# seconds. "
+ & "Nothing was removed. If that instance is gone, remove the lock with: wheels migrate unlock --force",
+ "Wheels.MigrationLocked"
+ );
+ return "";
+ }
+
+ private string function runMigrationUnlockForce() {
+ var serverPort = $requireOwnRunningServer([
+ "Removing the migration lock requires a running server bound to this project.",
+ "Start this project's own server with: wheels start (it registers the server as this project's)"
+ ]);
+ var unlockUrl = "#$serverUrlBase(serverPort)#/wheels/cli?command=migrationUnlock&force=true&format=json";
+ var httpResult = "";
+ try {
+ // migrationUnlock deletes a row — POST + reload password.
+ httpResult = makeBridgePost(unlockUrl);
+ } catch (any httpErr) {
+ throw(type = "MigrationError", message = "unlock failed (connection error): #httpErr.message#", detail = httpErr.detail ?: "");
+ }
+ var parsed = $migrationLockResponse(httpResult, "unlock");
+ if (parsed.released ?: false) {
+ out("Removed the migration lock held by #$migrationLockDescription(parsed.lock)#.", "green");
+ return "";
+ }
+ if (!parsed.lock.held) {
+ out("No migration lock was held, so there was nothing to remove.", "green");
+ return "";
+ }
+ var heldBy = parsed.heldBy ?: "";
+ if (len(heldBy) && heldBy != parsed.lock.owner) {
+ throw(
+ type = "MigrationError",
+ message = "The migration lock changed hands while it was being removed: owner #heldBy# holds it now, and it was left in place. Run wheels migrate unlock to see who holds it."
+ );
+ }
+ throw(type = "MigrationError", message = "The migration lock held by #$migrationLockDescription(parsed.lock)# was not removed.");
+ }
+
+ /**
+ * Parse a migrationLockStatus / migrationUnlock bridge response, throwing
+ * MigrationError when the server reports a failure. Public for specs.
+ */
+ public struct function $migrationLockResponse(required string body, required string verb) {
+ var parsed = isJSON(arguments.body) ? deserializeJSON(arguments.body) : {success: false, message: "Invalid response"};
+ if (!(parsed.success ?: false) || !isStruct(parsed.lock ?: "")) {
+ var msg = parsed.message ?: "";
+ out(len(msg) ? msg : "#arguments.verb# failed.", "red");
+ throw(type = "MigrationError", message = len(msg) ? msg : "#arguments.verb# failed: no lock status in the response.");
+ }
+ return parsed;
+ }
+
+ /**
+ * "host `web-1` (owner abc) for 120 seconds", from a migrationLockStatus().
+ */
+ public string function $migrationLockDescription(required struct lock) {
+ return "host `#arguments.lock.host#` (owner #arguments.lock.owner#) for #arguments.lock.heldForSeconds# seconds";
+ }
+
private string function runRenameSystemTables(boolean dryRun = false) {
var serverPort = $requireOwnRunningServer([
"Renaming system tables requires a running server bound to this project.",
@@ -7303,7 +8819,7 @@ component extends="modules.BaseModule" {
// ── Seed Execution ──────────────────────────────
- private string function runSeed(string mode = "auto", string environment = "") {
+ private string function runSeed(string mode = "auto", string environment = "", string models = "", string count = "") {
var serverPort = $requireOwnRunningServer([
"Seeding requires a running server bound to this project.",
"Start this project's own server with: wheels start (it registers the server as this project's)"
@@ -7315,6 +8831,12 @@ component extends="modules.BaseModule" {
if (len(environment)) {
seedUrl &= "&environment=#environment#";
}
+ if (len(arguments.models)) {
+ seedUrl &= "&models=#urlEncodedFormat(arguments.models)#";
+ }
+ if (len(arguments.count)) {
+ seedUrl &= "&count=#arguments.count#";
+ }
// dbSeed writes data — POST + reload password.
var httpResult = "";
@@ -7498,24 +9020,50 @@ component extends="modules.BaseModule" {
// gate never passes without having scanned anything.
var target = $upgradeResolveTargetVersion(arguments.targetVersion, jsonMode, arguments.strict);
+ // The checks below come from this CLI: its bundled app template and the
+ // rules it ships with. A CLI older than --to can't know what a newer
+ // release changed, so say so instead of a clean report (#4154). A
+ // warning only: it never changes the exit code.
+ var cliVersion = $displayVersion();
+ var cliBehind = $upgradeCliBehindTarget(cliVersion, target);
+ var cliWarning = cliBehind
+ ? "This CLI is #cliVersion#; its checks and app template only know releases up to #cliVersion#, so this report can miss what #target# changed. Upgrade the CLI first (brew upgrade wheels, scoop update wheels, or your apt/yum package) and run the check again."
+ : "";
+
if (!jsonMode) {
out("Current version: #currentVersion#", "bold");
out("Target version: #target#", "bold");
out("");
+ if (cliBehind) {
+ out(cliWarning, "yellow");
+ out("");
+ }
}
// Compare major versions
var currentMajor = val(listFirst(currentVersion, "."));
var targetMajor = val(listFirst(target, "."));
var sameMajor = (currentMajor == targetMajor);
+ var crosses42 = $upgradeCrosses42(currentVersion, target);
- if (sameMajor && !jsonMode) {
- out("Same major version — no known breaking changes.", "green");
- out("Scanning for opt-in recommendations...", "green");
+ if (crosses42 && sameMajor && !jsonMode) {
+ out("#currentVersion# -> #target#: no breaking framework changes, but 4.2 changes app-owned files and some behaviour.", "green");
+ out("Checking the 4.1 -> 4.2 items, then code left over from 3.x...", "green");
+ out("Also read the guide's database-specific changes (MySQL, Oracle, SQLite), which a source scan can't detect.", "green");
+ out("");
+ } else if (sameMajor && !jsonMode) {
+ out("Same major version — no new breaking changes in this upgrade.", "green");
+ out(currentMajor >= 4
+ ? "Scanning for code left over from 3.x and for opt-in recommendations..."
+ : "Scanning for opt-in recommendations...", "green");
out("");
}
- var checks = $upgradeBuildChecks(currentMajor, targetMajor);
+ var checks = $upgradeBuildChecks(currentMajor, targetMajor, target);
+ if (crosses42) {
+ checks = $upgradeAppendChecks4x2(checks, target);
+ }
+ checks = $upgradeAppendTemplateFixChecks(checks, target);
// Run checks. Matched checks land in `issues` (severity=breaking) or
// `advisories` (severity=advisory); unmatched land in `passed`.
@@ -7526,8 +9074,10 @@ component extends="modules.BaseModule" {
// The version-appropriate guide + the soft-landing adapter, surfaced
// whenever breaking findings are reported (and always in JSON output).
- var guideUrl = "https://guides.wheels.dev/v4-0-0/upgrading/"
- & (targetMajor >= 4 ? "3x-to-4x" : "2x-to-3x") & "/";
+ var guideUrl = crosses42 && sameMajor ? $upgradeGuide4x2Url() : new services.GuidesLink().link(
+ "upgrading/" & (targetMajor >= 4 ? "3x-to-4x" : "2x-to-3x") & "/",
+ target
+ );
// `success` must reflect every condition that produces a non-zero
// exit, otherwise `jq .success` and `$?` disagree when --strict is
@@ -7546,7 +9096,10 @@ component extends="modules.BaseModule" {
"breaking": issues,
"advisories": advisories,
"passed": passed,
- "guide": guideUrl
+ "guide": guideUrl,
+ "cliVersion": cliVersion,
+ "cliBehindTarget": cliBehind,
+ "warnings": cliBehind ? [cliWarning] : []
}));
} else {
$upgradePrintReport(issues, advisories, passed, guideUrl, targetMajor);
@@ -7596,6 +9149,30 @@ component extends="modules.BaseModule" {
currentVersion = manifestData.version ?: "unknown";
} catch (any e) {}
}
+ // 3.0 GA's vendor/wheels/box.json carries no version, and there is no
+ // wheels.json: the version only lives in the framework's own
+ // onapplicationstart (`application.$wheels.version = "3.0.0"`). 2.x
+ // kept the framework at the webroot's wheels/ (#3939).
+ if (!len(currentVersion) || currentVersion == "unknown" || find("@", currentVersion)) {
+ for (var startFile in [
+ "/vendor/wheels/events/onapplicationstart.cfc",
+ "/vendor/wheels/events/onapplicationstart.cfm",
+ "/wheels/events/onapplicationstart.cfm"
+ ]) {
+ if (!fileExists(variables.projectRoot & startFile)) continue;
+ try {
+ var hit = reFind(
+ 'application\.\$wheels\.version\s*=\s*["'']([0-9][^"'']*)["'']',
+ fileRead(variables.projectRoot & startFile),
+ 1,
+ true
+ );
+ if (hit.pos[1] && arrayLen(hit.pos) > 1) {
+ return mid(fileRead(variables.projectRoot & startFile), hit.pos[2], hit.len[2]);
+ }
+ } catch (any e) {}
+ }
+ }
return currentVersion;
}
@@ -7676,12 +9253,32 @@ component extends="modules.BaseModule" {
return response;
}
+ /**
+ * True when this CLI's own version is older than the target (#4154), so its
+ * checks and bundled template can't know what the target release changed.
+ * Compares MAJOR.MINOR.PATCH only (a `-dev` or snapshot suffix doesn't
+ * matter). An unstamped development build (major 0) or an unparseable
+ * version is treated as unknown: no warning. Public for specs.
+ */
+ public boolean function $upgradeCliBehindTarget(required string cliVersion, required string target) {
+ var semver = new services.SemVer();
+ try {
+ var cliBase = semver.format(semver.parse(arguments.cliVersion));
+ if (val(listFirst(cliBase, ".")) == 0) {
+ return false;
+ }
+ return semver.compare(cliBase, semver.format(semver.parse(arguments.target))) < 0;
+ } catch (any e) {
+ return false;
+ }
+ }
+
/**
* Build the upgrade-check definitions for the given current/target major
* version pair. Each entry may set `severity` to "breaking" (default,
* gated by major-version-bump scenarios) or "advisory" (runs regardless).
*/
- private array function $upgradeBuildChecks(required numeric currentMajor, required numeric targetMajor) {
+ private array function $upgradeBuildChecks(required numeric currentMajor, required numeric targetMajor, string target = "") {
var checks = [];
// 2.x -> 3.x
@@ -7700,41 +9297,53 @@ component extends="modules.BaseModule" {
fix: "Migrate plugins to packages installed under vendor/ (wheels packages add )"
});
}
- arrayAppend(checks, {
- description: "Old test base class (wheels.Test / wheels.Testbox)",
- pattern: 'extends\s*=\s*["'']wheels\.Test(box)?["'']',
- checkType: "grep",
- scanDir: "tests",
- extensions: "cfc",
- fix: 'Change to extends="wheels.WheelsTest"'
- });
}
- // 3.x -> 4.x
- if (arguments.currentMajor <= 3 && arguments.targetMajor >= 4) {
+ // 3.x -> 4.x. Checks for code that 4.x removed or deprecated also run
+ // on an app already on 4.x: they only match leftover code, and an app
+ // that swapped the framework before checking would otherwise hear
+ // "no known breaking changes" with 3.x code still in place (#3939).
+ // Checks for defaults that changed in 4.0, and for things that are only
+ // deprecated (plugins/, paginationLinks(): still working on 4.x, and
+ // `wheels new` itself scaffolds plugins/), are jumpOnly: they apply to
+ // an app actually coming from 3.x and are dropped below otherwise.
+ var jump34 = arguments.currentMajor <= 3 && arguments.targetMajor >= 4;
+ var on4 = arguments.currentMajor >= 4 && arguments.targetMajor >= 4;
+ if (jump34 || on4) {
+ // An empty or missing plugins/ doesn't mean the app has no plugins:
+ // 3.x apps declare them in box.json (installed into plugins/ by the
+ // next box install), and 3.0's deletePluginDirectories=true default
+ // can leave the folder empty at runtime. So this also reports
+ // box.json plugin dependencies and code that reads
+ // application.wheels.plugins.
arrayAppend(checks, {
- description: "Legacy plugin directory (deprecated as of 4.0, removed in 5.0)",
+ description: "Legacy plugins (deprecated as of 4.0, removed in 5.0)",
+ jumpOnly: true,
pattern: "",
- checkType: "directory",
+ checkType: "plugins",
path: "plugins",
- fix: "Migrate plugins to packages installed under vendor/ (wheels packages add )"
- });
- // Matches both quote styles and the silent wheels.Testbox shim
- // (deprecated alias of wheels.WheelsTest, removal target 5.0) —
- // the previous double-quote-only wheels.Test pattern missed both.
- arrayAppend(checks, {
- description: "Old test base class (wheels.Test / wheels.Testbox)",
- pattern: 'extends\s*=\s*["'']wheels\.Test(box)?["'']',
- checkType: "grep",
- scanDir: "tests",
- extensions: "cfc",
- fix: 'Change to extends="wheels.WheelsTest"'
+ references: {
+ pattern: "application\.wheels\.plugins\b",
+ checkType: "grep",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ scanTargets: [
+ {path: "Application.cfc"},
+ {path: "public/Application.cfc"},
+ {path: "config", extensions: "cfm,cfc", recurse: true}
+ ],
+ skipPackages: true
+ },
+ fix: "Migrate plugins to packages installed under vendor/ (wheels packages add ), or to the 4.x built-in that replaces them, and remove their box.json dependencies (box install puts them back in plugins/)"
});
// application.wirebox → application.wheelsdi (guide item 10). The
- // hardest real-world case is a root Application.cfc bootstrap that
+ // hardest real-world case is an Application.cfc bootstrap that
// calls `new wirebox.system.ioc.Injector(...)` — the WireBox
- // package no longer ships in vendor/wheels/ — so scan the root
- // Application.cfc and config/ in addition to app/.
+ // package no longer ships in vendor/wheels/ — so scan the root and
+ // public/ Application.cfc (where 3.x apps keep theirs) and config/
+ // in addition to app/. Packages under app/ (e.g. app/lib/logbox) are
+ // third-party code that uses WireBox itself, not the app, so they
+ // are skipped.
arrayAppend(checks, {
description: "Direct WireBox references (application.wirebox / wirebox.system.ioc)",
pattern: "application\.wirebox|wirebox\.system\.ioc",
@@ -7743,15 +9352,18 @@ component extends="modules.BaseModule" {
extensions: "cfc,cfm",
scanTargets: [
{path: "Application.cfc"},
+ {path: "public/Application.cfc"},
{path: "config", extensions: "cfm,cfc", recurse: true}
],
- fix: "Use service() / application.wheelsdi instead of application.wirebox; replace `new wirebox.system.ioc.Injector(...)` bootstraps with `new wheels.Injector()`. The legacy adapter does NOT shim this item."
+ skipPackages: true,
+ fix: "Use service() / application.wheelsdi instead of application.wirebox; replace `new wirebox.system.ioc.Injector(...)` bootstraps with `new wheels.Injector(""wheels.Bindings"")` (the constructor requires the bindings path). The legacy adapter does NOT shim this item."
});
- // renderPage()/renderPageToString() removed in 4.0 — shimmed by
- // the optional wheels-legacy-adapter package, but unshimmed apps
- // throw at first render.
+ // renderPage()/renderPageToString() were renamed in 2.0 (to renderView()
+ // and renderView(returnAs="string")); old code that still calls them
+ // throws at first render on 4.x unless the optional
+ // wheels-legacy-adapter package shims them.
arrayAppend(checks, {
- description: "Removed renderPage()/renderPageToString() helpers",
+ description: "renderPage()/renderPageToString(), renamed to renderView() in 2.0",
pattern: "renderPage(ToString)?\s*\(",
checkType: "grep",
scanDir: "app",
@@ -7763,6 +9375,7 @@ component extends="modules.BaseModule" {
// header now emits by default.
arrayAppend(checks, {
description: "SecurityHeaders middleware — HSTS defaults on in production in 4.0 (advisory)",
+ jumpOnly: true,
severity: "advisory",
pattern: "new\s+wheels\.middleware\.SecurityHeaders",
checkType: "grep",
@@ -7775,6 +9388,7 @@ component extends="modules.BaseModule" {
// but cross-site POSTs from third-party frames will break.
arrayAppend(checks, {
description: "CSRF cookie sets SameSite in 4.0 (advisory: review cross-site POST flows)",
+ jumpOnly: true,
severity: "advisory",
pattern: "protectsFromForgery",
checkType: "grep",
@@ -7786,6 +9400,7 @@ component extends="modules.BaseModule" {
// `new wheels.middleware.Cors()` accepts no requests in 4.0.
arrayAppend(checks, {
description: "CORS middleware without allowOrigins (deny-all default in 4.0)",
+ jumpOnly: true,
pattern: "new\s+wheels\.middleware\.Cors\s*\(\s*\)",
checkType: "grep",
scanDir: "config",
@@ -7800,6 +9415,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)",
+ jumpOnly: true,
severity: "advisory",
pattern: "new\s+wheels\.middleware\.RateLimiter",
checkType: "grep",
@@ -7811,6 +9427,7 @@ component extends="modules.BaseModule" {
// (#2076). Explicit `true` is now a security concern.
arrayAppend(checks, {
description: "allowEnvironmentSwitchViaUrl=true (default flipped to false in production)",
+ jumpOnly: true,
pattern: "allowEnvironmentSwitchViaUrl\s*=\s*true",
checkType: "grep",
scanDir: "config",
@@ -7824,15 +9441,22 @@ component extends="modules.BaseModule" {
// when csrfStore="cookie" (default store is "session"), and
// production throws Wheels.Security.MissingCsrfKey rather than
// auto-generating an ephemeral key.
- arrayAppend(checks, {
- description: "Missing csrfCookieEncryptionSecretKey (CSRF cookies rotate on every deploy when csrfStore=""cookie"")",
- pattern: "csrfCookieEncryptionSecretKey",
- checkType: "grep",
- scanDir: "config",
- extensions: "cfm,cfc",
- absent: true,
- fix: 'Set a stable key: set(csrfCookieEncryptionSecretKey = env("WHEELS_CSRF_KEY")).'
- });
+ // Only for apps that use the cookie store: the default (and an
+ // explicit csrfStore="session") never reads the key (#3939). Not
+ // jumpOnly: a 4.x app on the cookie store with no key throws
+ // Wheels.Security.MissingCsrfKey in production, so an app that
+ // swapped the framework first still needs to hear it.
+ if ($upgradeConfigMatches('csrfStore\s*=\s*["'']cookie["'']')) {
+ arrayAppend(checks, {
+ description: "Missing csrfCookieEncryptionSecretKey (CSRF cookies rotate on every deploy when csrfStore=""cookie"")",
+ pattern: "csrfCookieEncryptionSecretKey",
+ checkType: "grep",
+ scanDir: "config",
+ extensions: "cfm,cfc",
+ absent: true,
+ fix: 'Set a stable key: set(csrfCookieEncryptionSecretKey = env("WHEELS_CSRF_KEY")).'
+ });
+ }
// `wheels snippets` → `wheels generate snippets` rename (#1852).
// Scan build / CI scripts; the CLI command is invoked from
// outside the app's own .cfm/.cfc files.
@@ -7856,6 +9480,7 @@ component extends="modules.BaseModule" {
// would silently false-positive on every scanned file otherwise.
arrayAppend(checks, {
description: "Legacy tests/specs/functions/ directory (renamed to functional/)",
+ jumpOnly: true,
pattern: "",
checkType: "directory",
path: "tests/specs/functions",
@@ -7867,6 +9492,7 @@ component extends="modules.BaseModule" {
// user knows the default has flipped.
arrayAppend(checks, {
description: "Vite asset helpers (viteStrictManifest defaults to true in 4.0)",
+ jumpOnly: true,
pattern: "viteScriptTag|viteStyleTag|vitePreloadTag",
checkType: "grep",
scanDir: "app/views",
@@ -7876,6 +9502,7 @@ component extends="modules.BaseModule" {
// paginationLinks() deprecation grep (#2714, replacement: paginationNav() per #1930).
arrayAppend(checks, {
description: "Deprecated paginationLinks() helper (renamed to paginationNav() in 4.0)",
+ jumpOnly: true,
pattern: "paginationLinks\s*\(",
checkType: "grep",
scanDir: "app/views",
@@ -7884,6 +9511,42 @@ component extends="modules.BaseModule" {
});
}
+ if (!jump34) {
+ var kept = [];
+ for (var c in checks) {
+ if (!(c.jumpOnly ?: false)) arrayAppend(kept, c);
+ }
+ checks = kept;
+ }
+
+ // Test base classes from before 4.0, for any upgrade into 3.x or later
+ // and for 4.x apps that still have them. Added once: the 2.x and 3.x
+ // blocks used to add the same check, so a 3.0 app whose version read
+ // as unknown saw it twice. Both are advisory: wheels.Testbox is an
+ // alias of wheels.WheelsTest, and RocketUnit (wheels.Test) still runs
+ // on 4.x; switching a RocketUnit suite's base class without
+ // converting its tests breaks every test in it.
+ if (arguments.targetMajor >= 3) {
+ arrayAppend(checks, {
+ description: "Deprecated test base class wheels.Testbox (an alias of wheels.WheelsTest, removed in 5.0)",
+ severity: "advisory",
+ pattern: 'extends\s*=\s*["'']wheels\.Testbox["'']',
+ checkType: "grep",
+ scanDir: "tests",
+ extensions: "cfc",
+ fix: 'Change to extends="wheels.WheelsTest". wheels.Testbox is an alias of it, so nothing else changes.'
+ });
+ arrayAppend(checks, {
+ description: "RocketUnit test base class wheels.Test (deprecated; still runs on 4.x)",
+ severity: "advisory",
+ pattern: 'extends\s*=\s*["'']wheels\.Test["'']',
+ checkType: "grep",
+ scanDir: "tests",
+ extensions: "cfc",
+ fix: 'Keep extends="wheels.Test" until these tests are converted. RocketUnit tests (test_ methods, assert()) do not run under wheels.WheelsTest, which is BDD (describe/it/expect): convert a file, then switch its base class. See ' & new services.GuidesLink().link("upgrading/3x-to-4x/")
+ });
+ }
+
// ─── 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
@@ -7958,20 +9621,328 @@ component extends="modules.BaseModule" {
// public/index.cfm against the CLI's bundled app template. Always-on
// (not major-gated): the current release's template changes must be
// visible to patch/minor upgraders too, not just 3.x → 4.x jumpers.
- // Most recently the Adobe teardown guards (##3379). Advisory because
- // customized apps legitimately drift — the point is to surface the
- // diff, not to demand a byte-for-byte match.
+ // What changed is release-specific, so the fix points at the upgrade
+ // guides for the target version (#4154). Advisory because customized
+ // apps legitimately drift — the point is to surface the diff, not to
+ // demand a byte-for-byte match.
arrayAppend(checks, {
description: "App template drift — public/Application.cfc or public/index.cfm differs from the bundled template",
checkType: "templateDiff",
severity: "advisory",
files: ["public/Application.cfc", "public/index.cfm"],
- fix: "Diff these files against the CLI's bundled template and reconcile. The current release hardens public/Application.cfc's onError/onSessionEnd/onApplicationEnd against Adobe teardown crashes (##3379) — keep your local customizations (this.name, mappings, env loading) while adopting the framework changes."
+ fix: "Diff these files against the CLI's bundled template and reconcile: keep your local customizations (this.name, mappings, env loading) while adopting the framework changes. The upgrade guide for your target version lists what changed in these files: " & new services.GuidesLink().link("upgrading/", arguments.target)
});
return checks;
}
+ /**
+ * True when an upgrade from `currentVersion` to `target` crosses 4.2.0:
+ * the app is below it and the target is at or above it. False when either
+ * version can't be compared (a development checkout's placeholder).
+ */
+ public boolean function $upgradeCrosses42(required string currentVersion, required string target) {
+ var versionPattern = "^[vV]?\d+(\.\d+)*([-+][^\r\n]*)?$";
+ if (!reFind(versionPattern, trim(arguments.currentVersion)) || !reFind(versionPattern, trim(arguments.target))) {
+ return false;
+ }
+ var semver = new services.SemVer();
+ return semver.compare(arguments.currentVersion, "4.2.0") < 0 && semver.compare(arguments.target, "4.2.0") >= 0;
+ }
+
+ /**
+ * The fixes that live in the app-owned public/Application.cfc. A framework
+ * swap never updates that file, so each fix the target release has is
+ * checked on its own: the file lacks the code the template carries for
+ * it. The generic template-drift check only says the file differs. Each
+ * entry: since (the first release whose template has it), a pattern the
+ * template matches, and the guide section that documents the edit. The
+ * teardown routing is an error because on Adobe ColdFusion a shutdown
+ * that reads the bare application scope can leave the whole site
+ * erroring until a restart; the rest are advisory.
+ */
+ private array function $upgradeAppendTemplateFixChecks(required array checks, required string target) {
+ var guide41 = new services.GuidesLink().pinned("v4-1-0", "upgrading/4x-0-to-4x-1/");
+ var guide42 = $upgradeGuide4x2Url();
+ var adopt = "Adopt it from the app template (public/Application.cfc in a fresh wheels new app), keeping your own changes.";
+ var fixes = [
+ {since: "4.0.4", severity: "advisory", pattern: "this\.sessionCookie",
+ description: "public/Application.cfc doesn't set this.sessionCookie",
+ fix: "The session cookie's flags (including Secure over HTTPS) come from this block. #adopt# Guide: ""Session cookie Secure flag"", #guide41#"},
+ {since: "4.0.4", severity: "advisory", pattern: "StructKeyExists\(\s*application\s*,\s*""wheelsdi""\s*\)",
+ description: "public/Application.cfc onError doesn't guard the DI container",
+ fix: "Without the guard an error page can rebuild the container and wipe registered services. #adopt# Guide: ""DI container guard in onError"", #guide41#"},
+ {since: "4.0.6", severity: "breaking", pattern: "applicationScope\.wo\.\$include", requireFileMatches: "function\s+onApplicationEnd\s*\(",
+ description: "public/Application.cfc onApplicationEnd() doesn't go through arguments.applicationScope",
+ fix: "On Adobe ColdFusion, an onApplicationEnd() that reads the bare application scope can fail during shutdown and leave the whole site erroring until a service restart. Route it through arguments.applicationScope.wo with the StructKeyExists guards. #adopt# Guide: ""Adobe teardown guards in onError / onSessionEnd"", #guide41#"},
+ {since: "4.1.0", severity: "breaking", pattern: "applicationScope\.wo\.\$simpleLock", requireFileMatches: "function\s+onSessionEnd\s*\(",
+ description: "public/Application.cfc onSessionEnd() doesn't go through arguments.applicationScope",
+ fix: "On Adobe ColdFusion, session cleanup can call onSessionEnd() after the application scope is gone, and a bare application.wo then throws. Route it through arguments.applicationScope.wo, guarded with StructKeyExists. #adopt# Guide: ""Adobe teardown guards in onError / onSessionEnd"", #guide41#"},
+ {since: "4.0.6", severity: "advisory", pattern: "testcontext\.cfm",
+ description: "public/Application.cfc doesn't include the isolated test context",
+ fix: "Without it the test suites run against your live application scope: a run that uses the test database switches the live application's datasource for the whole run, so other requests to the app read and write the test database meanwhile. #adopt# Guide: ""Isolated test-application include"", #guide41#"},
+ {since: "4.1.0", severity: "advisory", pattern: "resources/java",
+ description: "public/Application.cfc doesn't put the bundled jBCrypt jar on the Java load path",
+ fix: "Without it bcryptHash()/bcryptVerify() use the slow pure-CFML fallback, and code that loads a BCrypt class can fail. #adopt# Guide: ""jBCrypt load path"", #guide41#"},
+ {since: "4.1.0", severity: "advisory", pattern: "\$authorizeReload",
+ description: "public/Application.cfc reload handling predates the template's helper functions",
+ fix: "The template's onRequestStart moved its reload handling into helper functions. #adopt# Guide: ""Reload-password handoff"", #guide41#"},
+ {since: "4.1.1", severity: "advisory", pattern: "Compare\(\s*lCase\(\s*local\.value\s*\)\s*,\s*""true""\s*\)",
+ description: "public/Application.cfc .env parser predates the 4.1.1 boolean fix",
+ fix: "The older parser turns a numeric 1 (such as 1.0) into the boolean true. #adopt# Guide: "".env boolean parser"", #guide41#"},
+ {since: "4.2.0", severity: "advisory", pattern: "startupPhase",
+ description: "public/Application.cfc onError doesn't tell a running-app error from a startup failure",
+ fix: "The older fallback shows ""Wheels failed to initialize"" for any error and may not log it. #adopt# Guide: ""onError running-app message"", #guide42#"},
+ {since: "4.2.0", severity: "advisory", pattern: "GetHttpRequestData\(\s*false\s*\)\.headers",
+ description: "public/Application.cfc lacks the reload-password header fallback",
+ fix: "Without it wheels reload against a RustCFML server fails every other run. #adopt# Guide: ""Reload-password header fallback"", #guide42#"},
+ {since: "4.2.0", severity: "advisory", pattern: "\$normaliseRedirectPath",
+ description: "public/Application.cfc reload redirect doesn't keep the subfolder",
+ fix: "An app served under a subfolder is redirected to the site root after a reload. #adopt# Guide: ""Reload redirect keeps the subfolder"", #guide42#"}
+ ];
+ var versionPattern = "^[vV]?\d+(\.\d+)*([-+][^\r\n]*)?$";
+ var targetKnown = reFind(versionPattern, trim(arguments.target)) > 0;
+ var semver = new services.SemVer();
+ for (var fixSpec in fixes) {
+ if (targetKnown && semver.compare(arguments.target, fixSpec.since) < 0) {
+ continue;
+ }
+ var check = {
+ description: fixSpec.description,
+ severity: fixSpec.severity,
+ checkType: "grep",
+ absent: true,
+ pattern: fixSpec.pattern,
+ scanTargets: [{path: "public/Application.cfc"}],
+ fix: fixSpec.fix
+ };
+ if (structKeyExists(fixSpec, "requireFileMatches")) {
+ check.requireFileMatches = fixSpec.requireFileMatches;
+ }
+ arrayAppend(arguments.checks, check);
+ }
+ return arguments.checks;
+ }
+
+ /**
+ * The 4.1 → 4.2 upgrade guide. It exists only in the v4-2-0 guide tree, so
+ * the link is pinned there: GuidesLink.link() would clamp a 4.2 target to
+ * the latest released tree until the 4.2 guides are marked released, and
+ * the page isn't in older trees at all.
+ */
+ private string function $upgradeGuide4x2Url() {
+ return new services.GuidesLink().pinned("v4-2-0", "upgrading/4x-1-to-4x-2/");
+ }
+
+ /**
+ * The 4.1 → 4.2 checks: app-owned files the 4.2 template changed, and the
+ * behaviour changes the upgrade guide lists. Severity "breaking" marks an
+ * item that fails at runtime on 4.2 (an error, a refused request, a failed
+ * validation); the rest are advisory because the app keeps running but
+ * behaves differently. Each fix names the guide section to read.
+ */
+ private array function $upgradeAppendChecks4x2(required array checks, required string target) {
+ var guide = $upgradeGuide4x2Url();
+ // Selecting the environment from anything but WHEELS_ENV: adopting the
+ // 4.2 template drops that selection, and a server without WHEELS_ENV
+ // then starts in development.
+ arrayAppend(arguments.checks, {
+ description: "config/environment.cfm selects the environment without WHEELS_ENV",
+ severity: "breaking",
+ checkType: "envSelection",
+ expect: "logic",
+ fix: "Keep your environment selection when you adopt the 4.2 config/environment.cfm: set WHEELS_ENV on every server before replacing the file, or carry your selection logic into the new file. Replacing it outright makes a server without WHEELS_ENV start in development. Guide: ""config/environment.cfm reads WHEELS_ENV"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "config/environment.cfm hardcodes the environment (4.2 reads WHEELS_ENV)",
+ severity: "advisory",
+ checkType: "envSelection",
+ expect: "literal",
+ fix: "A WHEELS_ENV=production set by the host has no effect while the file hardcodes the environment. Replace it with the 4.2 template's config/environment.cfm, or hardcode production on production servers. Guide: ""config/environment.cfm reads WHEELS_ENV"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "Event templates put blank lines before every response",
+ severity: "advisory",
+ checkType: "eventSilent",
+ files: ["onabort.cfm", "onapplicationend.cfm", "onapplicationstart.cfm", "onrequestend.cfm", "onrequeststart.cfm", "onsessionend.cfm", "onsessionstart.cfm"],
+ // chr(60) keeps a literal tag out of the source (Lucee's tag scanner).
+ fix: "Wrap each listed app/events/ file in " & chr(60) & "cfsilent>..." & chr(60) & "/cfsilent> with no trailing newline, or delete the empty ones. Leave onerror*.cfm, onmaintenance.cfm and onmissingtemplate.cfm alone. Guide: ""Event templates without leading whitespace"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: ".gitignore ignores vendor/ (4.2 apps commit it)",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "^\s*/?vendor/?\s*$",
+ scanTargets: [{path: ".gitignore"}],
+ fix: "Remove the vendor line and commit vendor/, so a clone, CI run or image build has the framework and your packages. Guide: ""Commit vendor/"", #guide#"
+ });
+ // `null` was the migration column option's name before 3.0 renamed it allowNull, with no
+ // alias until 4.2. From 3.0 through 4.1 a null=false column was created nullable; 4.2 reads
+ // null=false again, so a database rebuilt on 4.2 can be stricter than one built earlier.
+ arrayAppend(arguments.checks, {
+ description: "Migrations use null=false, the column option's name before Wheels 3.0",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "[,(]\s*null\s*=\s*[""']?(false|no|0)[""']?\s*[,)]",
+ scanTargets: [{path: "app/migrator/migrations", extensions: "cfc", recurse: true}],
+ fix: "Wheels 3.0 through 4.1 ignored null=false, so a database built or rebuilt by them from these migrations has these columns NULLABLE, and they may now hold NULL rows. 4.2 reads null=false again: a database rebuilt on 4.2 gets NOT NULL columns, stricter than the one you run. Check each column for NULL rows before you tighten it with changeColumn(allowNull=false), and rename null to allowNull in the migrations. Guide: ""Migration column option null"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "Migrations use null=true, the column option's name before Wheels 3.0",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "[,(]\s*null\s*=\s*[""']?(true|yes|1)[""']?\s*[,)]",
+ scanTargets: [{path: "app/migrator/migrations", extensions: "cfc", recurse: true}],
+ fix: "4.2 reads null as a deprecated alias of allowNull and logs a warning. Rename it to allowNull. Guide: ""Migration column option null"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "findAll(returnAs=""structs"") now returns an array",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "returnAs\s*=\s*[""']structs?[""']",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ scanTargets: [{path: "tests", extensions: "cfc,cfm", recurse: true}],
+ fix: "Struct functions (StructCount, StructKeyList...) on the result now throw, and for-in loops hand you rows instead of keys. Guide: ""findAll(returnAs=""structs"") returns an array"", #guide#"
+ });
+ if (!$upgradeConfigMatches("trustProxyHeaders\s*=\s*true")) {
+ arrayAppend(arguments.checks, {
+ description: "The app reads X-Forwarded-Proto, but trustProxyHeaders is not set",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "x[-_]forwarded[-_]proto",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ scanTargets: [{path: "config", extensions: "cfm,cfc", recurse: true}],
+ fix: "Behind a TLS-terminating proxy, 4.2 builds absolute URLs as http:// unless config/settings.cfm has set(trustProxyHeaders=true). Guide: ""X-Forwarded-Proto needs trustProxyHeaders"", #guide#"
+ });
+ }
+ arrayAppend(arguments.checks, {
+ description: "Validation condition/unless uses is, and, or with this., or this. on the right",
+ severity: "breaking",
+ checkType: "grep",
+ pattern: "\b(condition|unless)\s*=\s*[""'][^""']*(\bthis\.[^""']*\s(is|and|or)\s|(==|!=|<>|\s(eq|neq|gt|lt|gte|lte)\s)\s*this\.)",
+ scanDir: "app/models",
+ extensions: "cfc",
+ fix: "4.2 throws Wheels.InvalidValidationCondition for these shapes: replace is with eq (or ==), and with &&, or with ||, and put any this. reference on the left. Guide: ""Validation condition / unless: bare names are resolved"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "Validation condition/unless expressions are evaluated differently",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "\b(condition|unless)\s*=",
+ scanDir: "app/models",
+ extensions: "cfc",
+ fix: "A bare name now means this.: a rule 4.1 skipped may now run, and one that always ran may now be skipped. Run your model specs. Guide: ""Validation condition / unless: bare names are resolved"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "Association joinType is not inner, outer, left or left outer",
+ severity: "breaking",
+ checkType: "grep",
+ pattern: "joinType\s*=\s*[""'](?!\s*(inner|outer|left|left outer)\s*[""'])",
+ scanDir: "app/models",
+ extensions: "cfc",
+ fix: "Any other value throws Wheels.InvalidJoinType at application start. Guide: ""Association joinType values are checked"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "A route targets an action named renderNotFound (now a reserved framework helper)",
+ severity: "breaking",
+ checkType: "grep",
+ pattern: "renderNotFound",
+ scanTargets: [{path: "config/routes.cfm"}],
+ fix: "A request for that action gets Wheels.ActionNotAllowed. Rename the action and its route. Guide: ""renderNotFound is a framework helper"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "A route targets an action named isSafeRedirectUrl (now a reserved framework helper)",
+ severity: "breaking",
+ checkType: "grep",
+ pattern: "isSafeRedirectUrl",
+ scanTargets: [{path: "config/routes.cfm"}],
+ fix: "A request for that action gets Wheels.ActionNotAllowed. Rename the action and its route; a controller's own isSafeRedirectUrl helper that no route targets keeps working. Guide: ""isSafeRedirectUrl is a framework helper"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "A MySQL datasource sets tinyInt1isBit=false",
+ severity: "breaking",
+ checkType: "grep",
+ pattern: "tinyInt1isBit\s*=\s*false",
+ raw: true,
+ scanTargets: [{path: "config", extensions: "cfm,cfc", recurse: true}, {path: "lucee.json"}, {path: "server.json"}, {path: ".env"}],
+ fix: "Existing TINYINT(1) booleans then read as plain integers and fail validation as ""is not a number"". Drop the option, or convert the columns with changeColumn(columnType=""boolean""). Guide: ""MySQL boolean columns are BIT(1)"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "withAdvisoryLock() can now throw Wheels.AdvisoryLockReleaseFailed",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "withAdvisoryLock\s*\(",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ fix: "On MySQL and PostgreSQL a lock that can't be released now throws after the callback finishes. A job that relies on the lock should catch it and alert. Guide: ""withAdvisoryLock() can throw Wheels.AdvisoryLockReleaseFailed"", #guide#"
+ });
+ arrayAppend(arguments.checks, {
+ description: "LocalDisk storage keys are checked per segment",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "LocalDisk",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ scanTargets: [{path: "config", extensions: "cfm,cfc", recurse: true}],
+ fix: "Keys with a dots-only segment or a drive-letter prefix are now rejected; leading, trailing and doubled slashes are normalised. Check keys built from user input or file names. Guide: ""LocalDisk storage keys are checked per segment"", #guide#"
+ });
+ // enqueue(), enqueueIn() and enqueueAt() throw Wheels.Job.EnqueueFailed in
+ // 4.2 when the write fails, and a job deferred to a commit returns
+ // persisted: false legitimately, so a persisted check no longer means
+ // "failed". Point at status instead.
+ arrayAppend(arguments.checks, {
+ description: "Code reads persisted from a job result (4.2 throws Wheels.Job.EnqueueFailed on a failed write)",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "\.persisted\b|\[\s*[""']persisted[""']\s*\]",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ fix: "enqueue(), enqueueIn() and enqueueAt() throw Wheels.Job.EnqueueFailed when the job can't be written, so persisted: false no longer means a failure: a job enqueued inside a transaction on another datasource returns status ""deferred"" with persisted: false and is written at the commit. Check status (""pending"" or ""deferred"") instead of persisted, and where an enqueue is best-effort, catch Wheels.Job.EnqueueFailed. Guide: ""enqueue() throws Wheels.Job.EnqueueFailed"", #guide#"
+ });
+ // A tests/runner.cfm of the app's own (often a copy of an older core
+ // runner) gets the _test rule but not the built-in runner's
+ // behaviour, such as running tests/populate.cfm against the test database.
+ arrayAppend(arguments.checks, {
+ description: "tests/runner.cfm doesn't use the built-in app runner",
+ severity: "advisory",
+ checkType: "grep",
+ absent: true,
+ pattern: "wheels/tests/app-runner\.cfm",
+ scanTargets: [{path: "tests/runner.cfm"}],
+ fix: "Replace it with the runner wheels new creates, a single include of wheels/tests/app-runner.cfm, so the app gets the built-in runner's behaviour, including running tests/populate.cfm against the test database. Guide: ""App tests use the test database and test application"", #guide#"
+ });
+ // An empty string in a where condition binds as '' instead of NULL, so
+ // these conditions now match rows (and updateAll/deleteAll now change or
+ // delete them). A grep: expect false positives, hence "review these".
+ arrayAppend(arguments.checks, {
+ description: "Review these: an empty string in a where condition now matches ''",
+ severity: "advisory",
+ checkType: "grep",
+ pattern: "\b(where|updateAll|deleteAll)\b[^\r\n]*(<>|!=|=)\s*''|\bwhere\s*\(\s*[""'][^""']+[""']\s*,\s*(""""|'')\s*\)|\bfind(One|All)By\w+\s*\(\s*(""""|'')\s*\)",
+ scanDir: "app",
+ extensions: "cfc,cfm",
+ fix: "4.2 binds an empty string as '' instead of NULL: col = '' now matches rows that store '', col <> '' matches every other non-NULL row, and updateAll()/deleteAll() with such a condition now change or delete those rows. Check each line, and reject empty input before a lookup. Guide: ""An empty string in where matches empty strings"", #guide#"
+ });
+ return arguments.checks;
+ }
+
+ /**
+ * True when `pattern` matches anywhere in config/ (.cfm/.cfc, recursive),
+ * with CFML comments stripped first so a commented-out setting doesn't count.
+ */
+ private boolean function $upgradeConfigMatches(required string pattern) {
+ var configDir = variables.projectRoot & "/config";
+ if (!directoryExists(configDir)) return false;
+ for (var ext in ["cfm", "cfc"]) {
+ for (var f in directoryList(configDir, true, "path", "*." & ext)) {
+ if (reFindNoCase(arguments.pattern, stripCfmlComments(fileRead(f))) > 0) return true;
+ }
+ }
+ return false;
+ }
+
/**
* Build the file set to scan for a single grep check. Checks may use
* `scanDir` + `extensions` (recursive scan of one directory) and/or
@@ -8012,8 +9983,167 @@ component extends="modules.BaseModule" {
}
}
}
-
- return filesToScan;
+
+ // `skipPackages`: leave out third-party package code: files under a
+ // directory box.json installs a package into, or under any directory
+ // below the project root that has its own box.json (a package moved or
+ // committed somewhere its installPaths entry doesn't say).
+ if (structKeyExists(arguments.check, "skipPackages") && arguments.check.skipPackages) {
+ var packageDirs = $upgradeInstallPathDirs();
+ var root = replace(variables.projectRoot, "\", "/", "all");
+ var hasBoxJson = {};
+ var kept = [];
+ for (var f in filesToScan) {
+ var normalized = replace(f, "\", "/", "all");
+ var inPackage = false;
+ for (var packageDir in packageDirs) {
+ if (findNoCase(packageDir, normalized) == 1) {
+ inPackage = true;
+ break;
+ }
+ }
+ // Walk up from the file's directory, stopping below the root.
+ var dir = getDirectoryFromPath(normalized);
+ while (!inPackage && len(dir) > len(root) + 1 && findNoCase(root & "/", dir) == 1) {
+ if (!structKeyExists(hasBoxJson, dir)) {
+ hasBoxJson[dir] = fileExists(dir & "box.json");
+ }
+ inPackage = hasBoxJson[dir];
+ dir = getDirectoryFromPath(reReplace(dir, "/+$", ""));
+ }
+ if (!inPackage) arrayAppend(kept, f);
+ }
+ filesToScan = kept;
+ }
+
+ return filesToScan;
+ }
+
+ /**
+ * The app's box.json, parsed; an empty struct when it is missing or isn't a
+ * JSON object.
+ */
+ private struct function $upgradeReadBoxJson() {
+ var path = variables.projectRoot & "/box.json";
+ if (!fileExists(path)) return {};
+ try {
+ var parsed = deserializeJSON(fileRead(path));
+ return isStruct(parsed) ? parsed : {};
+ } catch (any e) {
+ return {};
+ }
+ }
+
+ /** A box.json installPaths value as a project-relative path: forward slashes, no leading `./` or trailing `/`. */
+ private string function $upgradeNormalizeInstallPath(required string installPath) {
+ return reReplace(replace(trim(arguments.installPath), "\", "/", "all"), "^(\./)+|/+$", "", "all");
+ }
+
+ /**
+ * The plugin packages box.json declares, as "box.json: ()".
+ * A dependency counts when its installPath is under plugins/ or, with no
+ * installPath, when its name starts with "cfwheels-" (CommandBox installs
+ * that package type into plugins/).
+ */
+ private array function $upgradePluginDependencies() {
+ var box = $upgradeReadBoxJson();
+ var installPaths = structKeyExists(box, "installPaths") && isStruct(box.installPaths) ? box.installPaths : {};
+ var found = [];
+ for (var section in ["dependencies", "devDependencies"]) {
+ if (!structKeyExists(box, section) || !isStruct(box[section])) continue;
+ var names = structKeyArray(box[section]);
+ arraySort(names, "textnocase");
+ for (var name in names) {
+ var target = structKeyExists(installPaths, name) && isSimpleValue(installPaths[name])
+ ? $upgradeNormalizeInstallPath(installPaths[name]) : "";
+ var isPlugin = len(target) ? reFindNoCase("^plugins(/|$)", target) > 0 : reFindNoCase("^cfwheels-", name) > 0;
+ if (isPlugin) {
+ arrayAppend(found, "box.json: " & name & (len(target) ? " (" & target & "/)" : ""));
+ }
+ }
+ }
+ return found;
+ }
+
+ /**
+ * The directories box.json installs packages into (`installPaths`), as
+ * absolute paths with forward slashes and a trailing slash.
+ */
+ private array function $upgradeInstallPathDirs() {
+ var box = $upgradeReadBoxJson();
+ var dirs = [];
+ if (!structKeyExists(box, "installPaths") || !isStruct(box.installPaths)) return dirs;
+ var root = replace(variables.projectRoot, "\", "/", "all");
+ for (var name in box.installPaths) {
+ if (!isSimpleValue(box.installPaths[name])) continue;
+ var rel = $upgradeNormalizeInstallPath(box.installPaths[name]);
+ if (!len(rel) || rel == ".") continue;
+ arrayAppend(dirs, root & "/" & rel & "/");
+ }
+ return dirs;
+ }
+
+ /**
+ * Whether a grep check leaves a file out of its scan: `skipIfFileMatches`
+ * skips a file that matches anywhere (the environment.cfm checks skip one
+ * that already reads WHEELS_ENV), and `requireFileMatches` skips one that
+ * doesn't (the teardown checks need the function to be declared). A
+ * skipped file doesn't count as scanned, so an `absent` check over it
+ * passes.
+ */
+ private boolean function $upgradeFileSkipped(required struct check, required string content) {
+ if (structKeyExists(arguments.check, "skipIfFileMatches") && reFindNoCase(arguments.check.skipIfFileMatches, arguments.content) > 0) {
+ return true;
+ }
+ return structKeyExists(arguments.check, "requireFileMatches") && reFindNoCase(arguments.check.requireFileMatches, arguments.content) == 0;
+ }
+
+ /**
+ * Run a "plugins" check: it matches a non-empty plugin directory, the
+ * plugin packages box.json declares, and the matches of its `references`
+ * grep. Returns {matched, matchEntry}, matchEntry empty when nothing matched.
+ */
+ private struct function $upgradePluginResult(required struct check) {
+ var found = [];
+ var pluginDir = variables.projectRoot & "/" & arguments.check.path;
+ if (directoryExists(pluginDir) && arrayLen(directoryList(pluginDir, false, "name"))) {
+ arrayAppend(found, arguments.check.path & "/");
+ }
+ for (var dependency in $upgradePluginDependencies()) {
+ arrayAppend(found, dependency);
+ }
+ // A copy, so the check definition isn't changed.
+ var references = duplicate(arguments.check.references);
+ references.description = arguments.check.description;
+ references.fix = arguments.check.fix;
+ var referenceResult = $upgradeExecuteCheck(references);
+ if (referenceResult.matched) {
+ for (var reference in referenceResult.matchEntry.matches) {
+ arrayAppend(found, reference);
+ }
+ }
+ if (!arrayLen(found)) {
+ return {matched: false, matchEntry: {}};
+ }
+ return {matched: true, matchEntry: {description: arguments.check.description, fix: arguments.check.fix, matches: found}};
+ }
+
+ /**
+ * The location line for an `absent` check that found nothing: the paths it
+ * scanned (its scanDir and each scanTarget), so the report names the file
+ * that lacks the code, e.g. "public/Application.cfc (no occurrences found)".
+ */
+ private string function $upgradeAbsentHint(required struct check) {
+ var paths = [];
+ if (structKeyExists(arguments.check, "scanDir") && len(arguments.check.scanDir)) {
+ arrayAppend(paths, arguments.check.scanDir & "/");
+ }
+ if (structKeyExists(arguments.check, "scanTargets") && isArray(arguments.check.scanTargets)) {
+ for (var target in arguments.check.scanTargets) {
+ arrayAppend(paths, target.path);
+ }
+ }
+ return arrayLen(paths) ? arrayToList(paths, ", ") & " (no occurrences found)" : "(no occurrences found)";
}
/**
@@ -8038,6 +10168,7 @@ component extends="modules.BaseModule" {
var filesToScan = $upgradeCollectScanFiles(arguments.check);
var matches = [];
+ var scanned = 0;
for (var filePath in filesToScan) {
// Strip CFML comments before grepping (Anti-Pattern #14):
// a commented-out `// t.references(...)` or
@@ -8045,7 +10176,13 @@ component extends="modules.BaseModule" {
// block comments collapse and may shift reported line
// numbers — same tradeoff other `stripCfmlComments` callers
// accept.
- var content = stripCfmlComments(fileRead(filePath));
+ // `raw: true` scans the file as written: a JDBC URL's `//` would
+ // otherwise read as a line comment and hide the rest of the line.
+ var content = structKeyExists(arguments.check, "raw") && arguments.check.raw ? fileRead(filePath) : stripCfmlComments(fileRead(filePath));
+ if ($upgradeFileSkipped(arguments.check, content)) {
+ continue;
+ }
+ scanned++;
var lines = listToArray(content, chr(10), true);
for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) {
if (reFindNoCase(arguments.check.pattern, lines[lineNum])) {
@@ -8062,12 +10199,9 @@ component extends="modules.BaseModule" {
// treat as pass to avoid noisy false positives.
var isAbsent = structKeyExists(arguments.check, "absent") && arguments.check.absent;
if (isAbsent) {
- if (arrayLen(filesToScan) && !arrayLen(matches)) {
+ if (scanned && !arrayLen(matches)) {
matched = true;
- var hint = structKeyExists(arguments.check, "scanDir") && len(arguments.check.scanDir)
- ? arguments.check.scanDir & "/ (no occurrences found)"
- : "(no occurrences found)";
- matchEntry = {description: arguments.check.description, fix: arguments.check.fix, matches: [hint]};
+ matchEntry = {description: arguments.check.description, fix: arguments.check.fix, matches: [$upgradeAbsentHint(arguments.check)]};
}
} else {
if (arrayLen(matches)) {
@@ -8075,13 +10209,28 @@ component extends="modules.BaseModule" {
matchEntry = {description: arguments.check.description, fix: arguments.check.fix, matches: matches};
}
}
+ } else if (arguments.check.checkType == "plugins") {
+ var pluginResult = $upgradePluginResult(arguments.check);
+ matched = pluginResult.matched;
+ matchEntry = pluginResult.matchEntry;
+ } else if (arguments.check.checkType == "envSelection") {
+ if ($upgradeEnvSelection() == arguments.check.expect) {
+ matched = true;
+ matchEntry = {description: arguments.check.description, fix: arguments.check.fix, matches: ["config/environment.cfm"]};
+ }
+ } else if (arguments.check.checkType == "eventSilent") {
+ var loudFiles = $upgradeLoudEventTemplates(arguments.check.files);
+ if (arrayLen(loudFiles)) {
+ matched = true;
+ matchEntry = {description: arguments.check.description, fix: arguments.check.fix, matches: loudFiles};
+ }
} else if (arguments.check.checkType == "templateDiff") {
// Compare app-owned template files against the CLI's bundled app
// template (the same source `wheels new` scaffolds from). Drift
- // means the app may be missing framework-side hardening that
- // shipped in the target release — most recently the Adobe
- // teardown guards in public/Application.cfc (##3379). Advisory:
- // customized apps legitimately drift; reconcile, never overwrite.
+ // means the app may be missing framework-side changes that
+ // shipped in the target release (the check's fix names the upgrade
+ // guide that lists them). Advisory: customized apps legitimately
+ // drift; reconcile, never overwrite.
var driftFiles = [];
for (var relFile in arguments.check.files) {
var templatePath = variables.moduleRoot & "templates/app/" & relFile;
@@ -8107,6 +10256,59 @@ component extends="modules.BaseModule" {
return {severity: severity, matched: matched, matchEntry: matchEntry};
}
+ /**
+ * How config/environment.cfm chooses the environment, comments stripped:
+ * "none" when the file is missing, reads WHEELS_ENV, or never calls
+ * set(environment=); "literal" for exactly one set(environment="...") with
+ * a plain quoted value and no branching in the file; "logic" for anything
+ * else (more than one set(), if/switch/ternary, # interpolation, an
+ * unquoted value). Replacing a "logic" file with the 4.2 template drops
+ * that selection.
+ */
+ public string function $upgradeEnvSelection() {
+ var path = variables.projectRoot & "/config/environment.cfm";
+ if (!fileExists(path)) {
+ return "none";
+ }
+ var content = stripCfmlComments(fileRead(path));
+ var setCall = "set\s*\(\s*environment\s*=";
+ var sets = arrayLen(reMatchNoCase(setCall, content));
+ if (!sets || reFindNoCase("WHEELS_ENV", content)) {
+ return "none";
+ }
+ // chr(60) keeps literal CFML tags out of the source (Lucee's tag scanner).
+ var branching = "\bif\s*\(|\bswitch\s*\(|" & chr(60) & "cf(if|elseif|switch)\b|\?";
+ var plainLiteral = setCall & "\s*([""'])[^""'##]*\1\s*\)";
+ if (sets == 1 && reFindNoCase(plainLiteral, content) && !reFindNoCase(branching, content)) {
+ return "literal";
+ }
+ return "logic";
+ }
+
+ /**
+ * The app/events/ templates among `files` that can write output before a
+ * response: the file doesn't start with a cfsilent tag, or has anything
+ * after its closing cfsilent tag (a trailing newline included). Returns
+ * their app/events/ paths.
+ */
+ private array function $upgradeLoudEventTemplates(required array files) {
+ var loud = [];
+ var open = chr(60) & "cfsilent";
+ var close = chr(60) & "/cfsilent>";
+ for (var name in arguments.files) {
+ var path = variables.projectRoot & "/app/events/" & name;
+ if (!fileExists(path)) {
+ continue;
+ }
+ var content = fileRead(path);
+ var wrapped = left(content, len(open)) == open && len(content) >= len(close) && right(content, len(close)) == close;
+ if (!wrapped) {
+ arrayAppend(loud, "app/events/" & name);
+ }
+ }
+ return loud;
+ }
+
/**
* Run all checks, bucketing matched entries into issues (breaking) or
* advisories (advisory) and unmatched descriptions into passed.
@@ -8275,6 +10477,11 @@ component extends="modules.BaseModule" {
);
}
+ // A box.json that pins wheels-core must be updatable before anything
+ // changes: left at the old version, a later `box install` copies the
+ // old framework back over vendor/wheels/.
+ var boxPin = $upgradeApplyBoxPin(upgrader);
+
out("Source: #sourceDir#");
out("Target: #vendorDir#");
out("");
@@ -8296,7 +10503,7 @@ component extends="modules.BaseModule" {
var backupPath = "";
if (arguments.doBackup) {
backupPath = upgrader.reserveBackupPath(vendorDir);
- plan &= "Backing up vendor/wheels -> vendor/#listLast(backupPath, "/")#" & nl
+ plan &= "Backing up vendor/wheels -> .wheels/backups/#listLast(backupPath, "/")#/ (outside vendor/, ignored by git)" & nl
& "If this is interrupted, restore with:" & nl
& " rm -rf ""#vendorDir#"" && mv ""#backupPath#"" ""#vendorDir#""" & nl;
} else {
@@ -8334,6 +10541,8 @@ component extends="modules.BaseModule" {
summary &= "Backup: #result.backupDir#" & nl;
summary &= "Recover with: rm -rf ""#vendorDir#"" && mv ""#result.backupDir#"" ""#vendorDir#""" & nl;
}
+ summary &= $upgradeApplyUpdateBoxPin(upgrader, boxPin, result.newVersion);
+ summary &= $upgradeApplyLeftoverBackups();
// Surface root-level manifest files the user may want to review
// after the upgrade — version refs, dependencies, etc.
@@ -8346,9 +10555,10 @@ component extends="modules.BaseModule" {
}
out(summary, "green");
- // Return value carries the pre-swap plan too, so callers (and the
- // dispatch specs) see the full command output in order.
- return plan & nl & summary;
+ // out() already printed the plan and the summary, and under the stdio MCP
+ // server out() is captured into the tool result too, so returning them as
+ // well printed the whole report twice in a terminal.
+ return "";
}
/**
@@ -8419,6 +10629,97 @@ component extends="modules.BaseModule" {
return "";
}
+ /**
+ * Read box.json's wheels-core pin before `wheels upgrade apply` changes
+ * anything. A box.json that can't be read is refused here: its old pin
+ * would stay, and a later `box install` copies that framework back over
+ * vendor/wheels/. Returns the pin (plus its path) for the post-swap update.
+ */
+ private struct function $upgradeApplyBoxPin(required any upgrader) {
+ var boxPath = variables.projectRoot & "/box.json";
+ var pin = arguments.upgrader.readBoxJsonCorePin(boxPath);
+ if (len(pin.error)) {
+ out(pin.error, "red");
+ out("Fix box.json (or remove its wheels-core dependency), then re-run wheels upgrade apply. Nothing was changed.");
+ throw(type = "Wheels.UpgradeApplyFailed", message = pin.error & " Nothing was changed.");
+ }
+ pin.path = boxPath;
+ return pin;
+ }
+
+ /**
+ * Old framework backups that an earlier `wheels upgrade apply` (before
+ * 4.2) or a manual swap left inside vendor/. The framework loads every
+ * vendor/ folder as a package, so each one logs a skipped-package error on
+ * every start. Returns a note naming them, or "" when there are none.
+ */
+ private string function $upgradeApplyLeftoverBackups() {
+ var vendorRoot = variables.projectRoot & "/vendor";
+ var leftovers = [];
+ for (var name in directoryList(vendorRoot, false, "name")) {
+ if (name != "wheels" && reFindNoCase("^wheels.*\.bak", name) && directoryExists(vendorRoot & "/" & name)) {
+ arrayAppend(leftovers, "vendor/" & name & "/");
+ }
+ }
+ if (!arrayLen(leftovers)) {
+ return "";
+ }
+ return "Old framework backups are still inside vendor/: " & arrayToList(leftovers, ", ")
+ & ". The framework loads every vendor/ folder as a package and logs an error for these on every start: move them to .wheels/backups/ or delete them." & chr(10);
+ }
+
+ /**
+ * After the swap, point box.json's wheels-core pins at the framework now in
+ * vendor/wheels/, so a later `box install` keeps it instead of copying
+ * the old version back over it. Returns the summary lines, or "" when
+ * box.json doesn't declare wheels-core. A version that isn't a release
+ * number (a development checkout's placeholder) is never written.
+ * Public so specs can drive it with a real version.
+ */
+ public string function $upgradeApplyUpdateBoxPin(required any upgrader, required struct pin, required string newVersion) {
+ var nl = chr(10);
+ if (!arguments.pin.declared) {
+ return "";
+ }
+ if (!reFind("^\d+\.\d+", arguments.newVersion)) {
+ return "box.json: wheels-core left at #arguments.pin.value# (the new framework reports version ""#arguments.newVersion#"", not a release number). Set it to the installed version yourself, or box install copies #arguments.pin.value# back over vendor/wheels/." & nl;
+ }
+ var lines = "";
+ for (var entry in arguments.pin.pins) {
+ lines &= $upgradeApplyRepinOne(arguments.upgrader, arguments.pin.path, entry, arguments.newVersion) & nl;
+ }
+ return lines;
+ }
+
+ /**
+ * Update one box.json wheels-core pin (one section) and return its summary
+ * line. A spec apply doesn't rewrite (4.x, >=4.0.0, a channel, a forgebox
+ * spec) gets a warning with the exact line to set; a change is reported
+ * only when a value was actually written.
+ */
+ private string function $upgradeApplyRepinOne(required any upgrader, required string path, required struct entry, required string newVersion) {
+ var label = arguments.entry.section == "dependencies" ? "box.json" : "box.json (#arguments.entry.section#)";
+ var newValue = arguments.upgrader.boxJsonCorePinFor(arguments.entry.value, arguments.newVersion);
+ if (!len(newValue)) {
+ return "#label#: wheels-core is ""#arguments.entry.value#"", which apply doesn't rewrite. If box install should keep this framework, change it to ""wheels-core"": ""#arguments.newVersion#"".";
+ }
+ if (newValue == arguments.entry.value) {
+ return "#label#: wheels-core already #newValue#";
+ }
+ var written = 0;
+ try {
+ written = arguments.upgrader.writeBoxJsonCorePin(arguments.path, newValue, arguments.entry.section);
+ } catch (any e) {
+ var failure = "vendor/wheels/ is now #arguments.newVersion#, but box.json could not be updated (#e.message#). Set its wheels-core dependency to ""#newValue#"" yourself, or box install copies #arguments.entry.value# back over vendor/wheels/.";
+ out(failure, "red");
+ throw(type = "Wheels.UpgradeApplyFailed", message = failure);
+ }
+ if (!written) {
+ return "#label#: wheels-core could not be located to rewrite. Set it to ""#newValue#"" yourself, or box install copies #arguments.entry.value# back over vendor/wheels/.";
+ }
+ return "#label#: wheels-core #arguments.entry.value# -> #newValue# (so box install keeps this framework)";
+ }
+
/**
* List root-level manifest / config files that may carry a wheels
* version reference the user wants to review after an upgrade.
@@ -8450,7 +10751,12 @@ component extends="modules.BaseModule" {
* sweep. bundlesDiscovered is read with structKeyExists — Lucee's
* Elvis treats 0 as empty, which would hide the exact 0-bundle case.
*/
- public boolean function $cliTestResultFailed(required struct result, numeric specsFailedToLoad = 0) {
+ public boolean function $cliTestResultFailed(
+ required struct result,
+ numeric specsFailedToLoad = 0,
+ boolean defaultScope = false,
+ boolean coreTests = false
+ ) {
// The runner's failure envelope (app-runner.cfm: a failed test-db
// populate, a missing runner) is {success: false, error, message} with
// no test counts at all. A result document never carries `success`.
@@ -8476,7 +10782,15 @@ component extends="modules.BaseModule" {
if (structKeyExists(arguments.result, "directoryRejected") && arguments.result.directoryRejected) {
return true;
}
- if (structKeyExists(arguments.result, "bundlesDiscovered") && arguments.result.bundlesDiscovered == 0) {
+ // No bundles is a failure for a scope the user asked for, but not for the
+ // default scope of an app that has no spec files yet (issue 3921). Spec
+ // files on disk that the runner did not discover (a broken mapping, the
+ // wrong webroot) stay a failure, with the runner's warning shown.
+ if (
+ structKeyExists(arguments.result, "bundlesDiscovered")
+ && arguments.result.bundlesDiscovered == 0
+ && !(arguments.defaultScope && !$specRootHasSpecFiles(arguments.coreTests))
+ ) {
return true;
}
if (arguments.specsFailedToLoad > 0) {
@@ -8485,6 +10799,66 @@ component extends="modules.BaseModule" {
return ((arguments.result.totalFail ?: 0) + (arguments.result.totalError ?: 0)) > 0;
}
+ /**
+ * The error message a failed test-runner response carries, or "" when it carries none. A run that
+ * failed before producing counts returns {success:false, error, message} — e.g. the core runner's
+ * "Test database not available" guard, or a test-db populate failure. Some engine errors instead
+ * surface as a serialized exception document whose Message / RootCause.message holds the cause, so
+ * those are checked as a fallback. Used to surface the real cause instead of "no test bundles ran"
+ * (#4408). Public only so the CLI specs can reach it (cli/CLAUDE.md "public for specs" carve-out);
+ * hidden from MCP via the $-prefix sweep.
+ */
+ public string function $runnerErrorMessage(required any result) {
+ if (!isStruct(arguments.result)) {
+ return "";
+ }
+ for (var key in ["error", "message"]) {
+ if (structKeyExists(arguments.result, key) && isSimpleValue(arguments.result[key]) && len(trim(arguments.result[key]))) {
+ return trim(arguments.result[key]);
+ }
+ }
+ if (
+ structKeyExists(arguments.result, "RootCause")
+ && isStruct(arguments.result.RootCause)
+ && structKeyExists(arguments.result.RootCause, "message")
+ && isSimpleValue(arguments.result.RootCause.message)
+ && len(trim(arguments.result.RootCause.message))
+ ) {
+ return trim(arguments.result.RootCause.message);
+ }
+ return "";
+ }
+
+ /**
+ * True when a run of the DEFAULT scope (no --filter / --directory / path)
+ * discovered no spec bundles, the spec root has no .cfc files on disk, and
+ * nothing else went wrong: an app with no specs yet, such as a fresh
+ * `wheels new` app (issue 3921). Reported as a notice with exit 0. Public
+ * for specs; hidden from MCP via the $-prefix sweep.
+ */
+ public boolean function $isEmptyDefaultRun(
+ required any result,
+ boolean defaultScope = false,
+ boolean coreTests = false
+ ) {
+ return arguments.defaultScope
+ && isStruct(arguments.result)
+ && structKeyExists(arguments.result, "bundlesDiscovered")
+ && isNumeric(arguments.result.bundlesDiscovered)
+ && arguments.result.bundlesDiscovered == 0
+ && !$specRootHasSpecFiles(arguments.coreTests)
+ && !$cliTestResultFailed(result = arguments.result, defaultScope = true, coreTests = arguments.coreTests);
+ }
+
+ /**
+ * True when the default spec root (tests/specs, or vendor/wheels/tests/specs
+ * with --core) holds any .cfc file, at any depth. Public for specs.
+ */
+ public boolean function $specRootHasSpecFiles(boolean coreTests = false) {
+ var specRoot = variables.projectRoot & (arguments.coreTests ? "/vendor/wheels/tests/specs" : "/tests/specs");
+ return directoryExists(specRoot) && arrayLen(directoryList(specRoot, true, "path", "*.cfc")) > 0;
+ }
+
/**
* True when a `wheels browser test` JSON result should map to a
* non-zero CLI exit via Wheels.TestsFailed. Public for specs;
@@ -8500,11 +10874,18 @@ component extends="modules.BaseModule" {
* flushes. Composes $cliTestResultFailed. Public for specs; hidden
* from MCP via the structural $-prefix sweep.
*/
- public void function $throwIfCliTestsFailed(required struct result, numeric specsFailedToLoad = 0) {
+ public void function $throwIfCliTestsFailed(
+ required struct result,
+ numeric specsFailedToLoad = 0,
+ boolean defaultScope = false,
+ boolean coreTests = false
+ ) {
if (
$cliTestResultFailed(
result = arguments.result,
- specsFailedToLoad = arguments.specsFailedToLoad
+ specsFailedToLoad = arguments.specsFailedToLoad,
+ defaultScope = arguments.defaultScope,
+ coreTests = arguments.coreTests
)
) {
throw(type = "Wheels.TestsFailed", message = "Tests failed — see the report above.");
@@ -8547,6 +10928,24 @@ component extends="modules.BaseModule" {
return 0;
}
+ /**
+ * What to tell someone whose `wheels test` found no running server for
+ * this project (issue 3972). tools/test-local.sh exists only in the Wheels
+ * framework repository, so it is suggested only there: an app vendors
+ * vendor/wheels/tests/specs but never has tools/test-local.sh. Public for
+ * specs; hidden from MCP via the $-prefix sweep.
+ */
+ public array function $testServerHints() {
+ var hints = ["wheels test runs the specs on this project's own running server. Start it with: wheels start"];
+ if (
+ fileExists(variables.projectRoot & "/tools/test-local.sh")
+ && directoryExists(variables.projectRoot & "/vendor/wheels/tests/specs")
+ ) {
+ arrayAppend(hints, "Or, in the Wheels framework repository: bash tools/test-local.sh (starts and stops its own server)");
+ }
+ return hints;
+ }
+
private string function runTests(
string filter = "",
string reporter = "simple",
@@ -8560,10 +10959,7 @@ component extends="modules.BaseModule" {
string basePath = "",
numeric timeoutSeconds = 900
) {
- var serverPort = $requireOwnRunningServer([
- "Start one with: wheels start",
- "Or use: bash tools/test-local.sh (auto-manages server)"
- ]);
+ var serverPort = $requireOwnRunningServer($testServerHints());
// Subfolder-mounted apps (`set(subpath="/myapp")`, #2985/#3026) serve the
// test runner under a URL prefix the rewrite layer expects — without it
@@ -8593,7 +10989,7 @@ component extends="modules.BaseModule" {
out("", "yellow");
out("Warning: --db only applies to --core tests; ignoring for the app suite.", "yellow");
out("App tests run against the configured app datasource (or", "yellow");
- out("_test when --useTestDB is set). To test against a different", "yellow");
+ out("_test when --test-db is set). To test against a different", "yellow");
out("engine, point your app's datasource env var at it (or use --core).", "yellow");
out("See: command-line-tools/wheels-commands/testing##testing-against-different-engines", "yellow");
out("", "yellow");
@@ -8634,8 +11030,13 @@ component extends="modules.BaseModule" {
// datasource so chapter-6-style manual signups in the dev DB
// don't bleed into chapter-7 specs. Core tests already pick
// datasources from url.db so leave them alone.
- if (!coreTests && useTestDB) {
- testUrl &= "&useTestDB=true";
+ // Send useTestDB EXPLICITLY for every app run. The app
+ // runner now defaults an omitted flag to true (test DB), so omitting
+ // the parameter on --no-test-db would be reinterpreted as true and
+ // defeat the advertised opt-out. Core tests pick datasources from
+ // url.db, so they never carry useTestDB.
+ if (!coreTests) {
+ testUrl &= "&useTestDB=" & (useTestDB ? "true" : "false");
}
if (len(filter)) {
testUrl &= "&directory=#filter#";
@@ -8672,7 +11073,7 @@ component extends="modules.BaseModule" {
break;
case "simple":
default:
- displayTestResults(result, verboseOutput, resolvedDir, ciMode);
+ displayTestResults(result, verboseOutput, resolvedDir, ciMode, !len(filter), coreTests);
}
}
@@ -8724,7 +11125,9 @@ component extends="modules.BaseModule" {
if (runState.hasResult) {
$throwIfCliTestsFailed(
result = runState.result,
- specsFailedToLoad = runState.specsFailedToLoad
+ specsFailedToLoad = runState.specsFailedToLoad,
+ defaultScope = !len(arguments.filter),
+ coreTests = arguments.coreTests
);
}
// A crash during the HTTP/parse phase printed red but exited 0 — the
@@ -8869,7 +11272,9 @@ component extends="modules.BaseModule" {
required any result,
boolean verboseOutput = false,
string testDirectory = "",
- boolean ciMode = false
+ boolean ciMode = false,
+ boolean defaultScope = false,
+ boolean coreTests = false
) {
if (!isStruct(result)) {
out(serializeJSON(result));
@@ -8894,7 +11299,8 @@ component extends="modules.BaseModule" {
if (specsFailedToLoad > 0) {
$printFailedToLoadWarning(specsFailedToLoad, unloadedSpecPaths, result);
- } else {
+ } else if (!$isEmptyDefaultRun(result, arguments.defaultScope, arguments.coreTests)) {
+ // An app with no specs yet gets the summary notice, not the runner warnings.
$printTestResultDiagnostics(result);
}
@@ -8905,7 +11311,7 @@ component extends="modules.BaseModule" {
// Summary line
var duration = totalDuration > 0 ? " (#numberFormat(totalDuration / 1000, '0.00')#s)" : "";
- $printTestSummaryAndDetails(result, arguments.verboseOutput, totalPass, totalFail, totalError, duration, specsFailedToLoad);
+ $printTestSummaryAndDetails(result, arguments.verboseOutput, totalPass, totalFail, totalError, duration, specsFailedToLoad, arguments.defaultScope, arguments.coreTests);
// CI mode (--ci): emit GitHub Actions-style error annotations so each
// failure/error surfaces inline in CI logs and PR-check annotations.
@@ -9065,7 +11471,9 @@ component extends="modules.BaseModule" {
required numeric totalFail,
required numeric totalError,
required string duration,
- required numeric specsFailedToLoad
+ required numeric specsFailedToLoad,
+ boolean defaultScope = false,
+ boolean coreTests = false
) {
var summary = $testSummaryLine(
result = arguments.result,
@@ -9073,9 +11481,14 @@ component extends="modules.BaseModule" {
totalFail = arguments.totalFail,
totalError = arguments.totalError,
duration = arguments.duration,
- specsFailedToLoad = arguments.specsFailedToLoad
+ specsFailedToLoad = arguments.specsFailedToLoad,
+ defaultScope = arguments.defaultScope,
+ coreTests = arguments.coreTests
);
out(summary.text, summary.color);
+ for (var skipReason in $collectSkipReasons(arguments.result)) {
+ out(" Skipped (#skipReason.count#): #skipReason.message#", "yellow");
+ }
if (arguments.totalFail > 0 || arguments.totalError > 0) {
out("");
@@ -9114,12 +11527,17 @@ component extends="modules.BaseModule" {
required numeric totalFail,
required numeric totalError,
required string duration,
- required numeric specsFailedToLoad
+ required numeric specsFailedToLoad,
+ boolean defaultScope = false,
+ boolean coreTests = false
) {
+ // Skipped specs did not run: they are counted, and a run with skips is never green.
+ var totalSkipped = isStruct(arguments.result) ? val(arguments.result.totalSkipped ?: 0) : 0;
+ var skippedStr = totalSkipped > 0 ? ", #totalSkipped# skipped" : "";
if (arguments.totalFail > 0 || arguments.totalError > 0) {
var failedToLoadStr = arguments.specsFailedToLoad > 0 ? ", #arguments.specsFailedToLoad# failed to load" : "";
return {
- text = "#arguments.totalPass# passed, #arguments.totalFail# failed, #arguments.totalError# error(s)#failedToLoadStr##arguments.duration#",
+ text = "#arguments.totalPass# passed, #arguments.totalFail# failed, #arguments.totalError# error(s)#skippedStr##failedToLoadStr##arguments.duration#",
color = "red"
};
}
@@ -9129,15 +11547,76 @@ component extends="modules.BaseModule" {
color = "yellow"
};
}
+ if ($isEmptyDefaultRun(arguments.result, arguments.defaultScope, arguments.coreTests)) {
+ return {
+ text = "No specs yet: tests/specs has no spec files, so there was nothing to run. Add one with: wheels generate test model ",
+ color = "yellow"
+ };
+ }
if (isStruct(arguments.result) && $cliTestResultFailed(result = arguments.result)) {
+ // Surface the runner's own error when it carries one — a failed test-db populate, a missing
+ // datasource ("Datasource [...] doesn't exist") — instead of masking it as "no test bundles
+ // ran for this scope", which hid the real cause and read like an empty run (#4408).
+ var runnerError = $runnerErrorMessage(arguments.result);
+ if (len(runnerError)) {
+ return {
+ text = "Test run failed: #runnerError##arguments.duration#",
+ color = "red"
+ };
+ }
return {
text = "#arguments.totalPass# passed, but no test bundles ran for this scope#arguments.duration#",
color = "red"
};
}
+ if (totalSkipped > 0) {
+ return {text = "#arguments.totalPass# passed#skippedStr##arguments.duration#", color = "yellow"};
+ }
return {text = "#arguments.totalPass# passed#arguments.duration#", color = "green"};
}
+ /**
+ * Why specs were skipped, from a TestBox result: one entry per distinct reason
+ * (the skip message), with how many specs it covers, in the order first seen.
+ * Pure, so specs can pin it.
+ */
+ public array function $collectSkipReasons(required any result) {
+ var ctx = {reasons: [], index: {}};
+ if (!isStruct(arguments.result)) {
+ return ctx.reasons;
+ }
+ for (var bundle in (arguments.result.bundleStats ?: [])) {
+ for (var suite in (bundle.suiteStats ?: [])) {
+ $skipWalkSuite(suite, ctx);
+ }
+ }
+ return ctx.reasons;
+ }
+
+ /**
+ * Recursively collect skipped specs' reasons from one suite into ctx.
+ */
+ private void function $skipWalkSuite(required any suite, required struct ctx) {
+ for (var spec in (arguments.suite.specStats ?: [])) {
+ if ((spec.status ?: "") != "Skipped") {
+ continue;
+ }
+ var reason = trim(spec.failMessage ?: "");
+ if (!len(reason)) {
+ reason = "(no reason given)";
+ }
+ if (structKeyExists(arguments.ctx.index, reason)) {
+ arguments.ctx.reasons[arguments.ctx.index[reason]].count++;
+ } else {
+ arrayAppend(arguments.ctx.reasons, {message: reason, count: 1});
+ arguments.ctx.index[reason] = arrayLen(arguments.ctx.reasons);
+ }
+ }
+ for (var inner in (arguments.suite.suiteStats ?: [])) {
+ $skipWalkSuite(inner, arguments.ctx);
+ }
+ }
+
/**
* Build GitHub Actions workflow-command annotations (one `::error` line per
* failed or errored spec) from a TestBox result memento. Returns an empty
@@ -9317,13 +11796,14 @@ component extends="modules.BaseModule" {
// Merge defaults for any missing options
var opts = {
- port: structKeyExists(options, "port") ? options.port : 8080,
+ port: structKeyExists(options, "port") && options.port > 0 ? options.port : $defaultNewPort(targetDir),
datasource: structKeyExists(options, "datasource") ? options.datasource : lCase(appName),
reloadPassword: structKeyExists(options, "reloadPassword") ? options.reloadPassword : generateRandomPassword(),
luceeAdminPassword: generateRandomPassword(),
setupH2: structKeyExists(options, "setupH2") ? options.setupH2 : false,
noSQLite: structKeyExists(options, "noSQLite") ? options.noSQLite : false,
- openBrowser: structKeyExists(options, "openBrowser") ? options.openBrowser : true
+ openBrowser: structKeyExists(options, "openBrowser") ? options.openBrowser : true,
+ agents: structKeyExists(options, "agents") ? options.agents : true
};
// Resolve the Wheels framework source BEFORE creating any files. A
@@ -9362,17 +11842,7 @@ component extends="modules.BaseModule" {
);
}
- // datasourcesBlock: SQLite pair by default; "{}" when --no-sqlite (#2621)
- var context = {
- "appName": appName,
- "datasourceName": opts.datasource,
- "reloadPassword": opts.reloadPassword,
- "luceeAdminPassword": opts.luceeAdminPassword,
- "port": opts.port,
- "shutdownPort": opts.port + 1,
- "openBrowser": opts.openBrowser ? "true" : "false",
- "datasourcesBlock": opts.noSQLite ? "{}" : buildSQLiteDatasourcesBlock(opts.datasource)
- };
+ var context = $newTemplateContext(appName, opts, targetDir);
// Copy template directory tree to target, processing placeholders.
// `rootTargetDir` is passed so recursive calls can compute paths
@@ -9408,11 +11878,15 @@ component extends="modules.BaseModule" {
// a reviewable file rather than an inline string means an edit to it
// cannot live only in a working tree and vanish without a trace.
+ // The AI-assistant configs `wheels setup agents` writes, so the new app's
+ // assistant finds the Wheels MCP server without a second command.
+ var agentsWarning = opts.agents ? $writeNewAppAgentConfigs(targetDir, appName) : "";
+
out("");
out("Application created!", "green");
out("");
out("Configuration:", "bold");
- out(" Port: #opts.port#");
+ out(" Port: #opts.port# (shutdown #context.shutdownPort#)");
out(" Datasource: #opts.datasource#");
out(" Reload password: #opts.reloadPassword#");
out(" Lucee admin password: (see .env — WHEELS_LUCEE_ADMIN_PASSWORD)");
@@ -9421,6 +11895,12 @@ component extends="modules.BaseModule" {
} else if (!opts.noSQLite) {
out(" Database: SQLite (db/development.sqlite)", "green");
}
+ if (opts.agents && !len(agentsWarning)) {
+ out(" AI assistants: .mcp.json and .opencode.json (skip with --no-agents)");
+ }
+ if (len(agentsWarning)) {
+ out(" " & agentsWarning, "yellow");
+ }
out("");
out("Next steps:", "bold");
out(" cd #appName#");
@@ -9481,14 +11961,14 @@ component extends="modules.BaseModule" {
h2Config &= tab & "// H2 embedded database (configured by wheels new --setup-h2)" & nl;
h2Config &= tab & 'this.datasources["#datasourceName#"] = {' & nl;
h2Config &= tab & tab & 'class: "org.h2.Driver",' & nl;
- h2Config &= tab & tab & 'connectionString: "jdbc:h2:file:" & expandPath("../db/h2/#datasourceName#") & ";MODE=MySQL",' & nl;
+ h2Config &= tab & tab & 'connectionString: "jdbc:h2:file:" & this.wheels.projectRoot & "db/h2/#datasourceName#;MODE=MySQL",' & nl;
h2Config &= tab & tab & 'username: "sa"' & nl;
h2Config &= tab & "};";
// Also add a test database datasource
h2Config &= nl & tab & 'this.datasources["wheelstestdb"] = {' & nl;
h2Config &= tab & tab & 'class: "org.h2.Driver",' & nl;
- h2Config &= tab & tab & 'connectionString: "jdbc:h2:file:" & expandPath("../db/h2/wheelstestdb") & ";MODE=MySQL",' & nl;
+ h2Config &= tab & tab & 'connectionString: "jdbc:h2:file:" & this.wheels.projectRoot & "db/h2/wheelstestdb;MODE=MySQL",' & nl;
h2Config &= tab & tab & 'username: "sa"' & nl;
h2Config &= tab & "};";
@@ -9540,13 +12020,13 @@ component extends="modules.BaseModule" {
sqliteConfig &= tab & "// SQLite zero-config database (configured by wheels new)" & nl;
sqliteConfig &= tab & 'this.datasources["#datasourceName#"] = {' & nl;
sqliteConfig &= tab & tab & 'class: "org.sqlite.JDBC",' & nl;
- sqliteConfig &= tab & tab & 'connectionString: "jdbc:sqlite:" & expandPath("../db/development.sqlite")' & nl;
+ sqliteConfig &= tab & tab & 'connectionString: "jdbc:sqlite:" & this.wheels.projectRoot & "db/development.sqlite"' & nl;
sqliteConfig &= tab & "};";
// Also add a test database datasource
sqliteConfig &= nl & tab & 'this.datasources["#datasourceName#_test"] = {' & nl;
sqliteConfig &= tab & tab & 'class: "org.sqlite.JDBC",' & nl;
- sqliteConfig &= tab & tab & 'connectionString: "jdbc:sqlite:" & expandPath("../db/test.sqlite")' & nl;
+ sqliteConfig &= tab & tab & 'connectionString: "jdbc:sqlite:" & this.wheels.projectRoot & "db/test.sqlite"' & nl;
sqliteConfig &= tab & "};";
// Inject into config/app.cfm at the CLI-Appends-Here marker
@@ -9571,7 +12051,7 @@ component extends="modules.BaseModule" {
block &= inner & '"class": "org.sqlite.JDBC",' & nl;
block &= inner & '"database": "#datasourceName#",' & nl;
block &= inner & '"dbdriver": "Other",' & nl;
- block &= inner & '"dsn": "jdbc:sqlite:{project}/db/development.sqlite",' & nl;
+ block &= inner & '"dsn": "jdbc:sqlite:##project:path##/db/development.sqlite",' & nl;
block &= inner & '"host": "",' & nl;
block &= inner & '"password": "",' & nl;
block &= inner & '"username": ""' & nl;
@@ -9580,7 +12060,7 @@ component extends="modules.BaseModule" {
block &= inner & '"class": "org.sqlite.JDBC",' & nl;
block &= inner & '"database": "#datasourceName#_test",' & nl;
block &= inner & '"dbdriver": "Other",' & nl;
- block &= inner & '"dsn": "jdbc:sqlite:{project}/db/test.sqlite",' & nl;
+ block &= inner & '"dsn": "jdbc:sqlite:##project:path##/db/test.sqlite",' & nl;
block &= inner & '"host": "",' & nl;
block &= inner & '"password": "",' & nl;
block &= inner & '"username": ""' & nl;
@@ -9625,7 +12105,7 @@ component extends="modules.BaseModule" {
out(" unzip wheels-core-.zip -d ~/.wheels/modules/wheels/vendor/");
out(" wheels new #appName#");
out("");
- out("See: https://guides.wheels.dev/v4-0-0/start-here/installing/");
+ out("See: " & new services.GuidesLink().link("start-here/installing/"));
throw(
type="Wheels.FrameworkNotFound",
@@ -9763,70 +12243,313 @@ component extends="modules.BaseModule" {
}
/**
- * True when the given directory has the structural fingerprint of a
- * Wheels project — currently presence of `config/settings.cfm`, the file
- * `wheels new` always writes and that no other tool creates. Used by
- * `start()` and `stop()` to refuse silent fallthrough to LuCLI's `server`
- * subcommands, which would otherwise register a phantom server context
- * named after the cwd basename. See onboarding finding F6.
- *
- * `box.json` alone is not enough — too many non-Wheels CommandBox
- * projects ship one — and `Application.cfc` is not enough either since
- * any CFML codebase has one.
+ * True when the given directory has the structural fingerprint of a
+ * Wheels project — currently presence of `config/settings.cfm`, the file
+ * `wheels new` always writes and that no other tool creates. Used by
+ * `start()` and `stop()` to refuse silent fallthrough to LuCLI's `server`
+ * subcommands, which would otherwise register a phantom server context
+ * named after the cwd basename. See onboarding finding F6.
+ *
+ * `box.json` alone is not enough — too many non-Wheels CommandBox
+ * projects ship one — and `Application.cfc` is not enough either since
+ * any CFML codebase has one.
+ */
+ private boolean function $isWheelsProjectDir(required string path) {
+ if (!len(arguments.path)) return false;
+ return fileExists(arguments.path & "/config/settings.cfm");
+ }
+
+ /**
+ * Refuse a project-scoped command outside a Wheels project: print the same
+ * guidance `wheels start` gives, then throw so the command exits non-zero.
+ * The tip is repeated in the message because an MCP client sees only the
+ * error, not the out() lines (#3863).
+ */
+ private void function $requireWheelsProject(required string command) {
+ if ($isWheelsProjectDir(variables.projectRoot)) return;
+ out("This directory does not look like a Wheels project.", "yellow");
+ out(" Expected: config/settings.cfm under the current directory.", "yellow");
+ out("");
+ out("Tip: cd into your project directory, or run `wheels new `", "cyan");
+ out(" to scaffold one.", "cyan");
+ throw(type = "Wheels.NotAWheelsProject", message = "#arguments.command#: this directory is not a Wheels project (no config/settings.cfm). cd into your project directory, or run `wheels new ` to scaffold one.");
+ }
+
+ /**
+ * Read the HTTP port pinned in the project's lucee.json, or 0 if there is no
+ * lucee.json, no "port" key, or the file can't be parsed. LuCLI writes this
+ * file on first start and honours its port on subsequent starts, so it is the
+ * deterministic port to pre-check for a collision before delegating.
+ */
+ private numeric function $readPinnedPort(required string projectRoot) {
+ return $readPinnedPorts(arguments.projectRoot).port;
+ }
+
+ /**
+ * Both ports pinned in the project's lucee.json. Each is 0 when absent, so
+ * callers can tell "not configured" from a real value.
+ */
+ private struct function $readPinnedPorts(required string projectRoot) {
+ var ports = {port: 0, shutdownPort: 0};
+ var configFile = arguments.projectRoot & "/lucee.json";
+ if (!fileExists(configFile)) return ports;
+ try {
+ var config = deserializeJSON(fileRead(configFile));
+ if (isStruct(config)) {
+ if (structKeyExists(config, "port") && isNumeric(config.port)) {
+ ports.port = config.port;
+ }
+ if (structKeyExists(config, "shutdownPort") && isNumeric(config.shutdownPort)) {
+ ports.shutdownPort = config.shutdownPort;
+ }
+ }
+ } catch (any e) {
+ // Malformed lucee.json — let LuCLI surface its own parse error.
+ }
+ return ports;
+ }
+
+ /**
+ * First free TCP port at or above `from`. Used to place a project's shutdown
+ * port next to its requested HTTP port without colliding with another
+ * project's server. Bounded so a pathological environment cannot spin
+ * forever; falls back to `from` and lets LuCLI report the conflict itself.
+ *
+ * `avoid` is a struct keyed by port number: ports to skip even when nothing
+ * listens on them right now, such as another project's lucee.json pins.
+ */
+ private numeric function $nextFreePort(required numeric from, struct avoid = {}) {
+ var probe = getService("portProbe");
+ for (var candidate = arguments.from; candidate < arguments.from + 100; candidate++) {
+ if (!structKeyExists(arguments.avoid, candidate) && !probe.portInUse(candidate)) {
+ return candidate;
+ }
+ }
+ return arguments.from;
+ }
+
+ /**
+ * Ports pinned in other projects' lucee.json files, keyed by port, each
+ * naming the project that pins it. A pin is a port that project will start
+ * on, so it collides even while that project is stopped, which a listener
+ * probe can't see. Projects come from the LuCLI server registry (every
+ * project started on this machine) and from the folders next to
+ * `targetDir` (siblings created but never started). `targetDir` itself is
+ * skipped.
+ */
+ private struct function $otherProjectPins(required string targetDir) {
+ var pins = {};
+ for (var root in $otherProjectRoots(arguments.targetDir)) {
+ var ported = $readPinnedPorts(root);
+ for (var key in ["port", "shutdownPort"]) {
+ if (ported[key] > 0 && !structKeyExists(pins, ported[key])) {
+ pins[ported[key]] = root;
+ }
+ }
+ }
+ return pins;
+ }
+
+ /**
+ * Project roots for $otherProjectPins(): each registered server's
+ * `.project-path`, then each sibling folder of `targetDir` that has a
+ * lucee.json. Unique, without `targetDir`.
+ */
+ private array function $otherProjectRoots(required string targetDir) {
+ var self = $canonicalDir(arguments.targetDir);
+ var seen = {};
+ var roots = [];
+ var candidates = [];
+ var serversDir = $resolveLucliHome() & "/servers";
+ if (directoryExists(serversDir)) {
+ for (var reg in directoryList(serversDir, false, "path")) {
+ if (fileExists(reg & "/.project-path")) {
+ arrayAppend(candidates, trim(fileRead(reg & "/.project-path")));
+ }
+ }
+ }
+ var parent = getDirectoryFromPath(reReplace(arguments.targetDir, "[\\/]+$", ""));
+ if (directoryExists(parent)) {
+ for (var sibling in directoryList(parent, false, "path")) {
+ arrayAppend(candidates, sibling);
+ }
+ }
+ for (var candidate in candidates) {
+ var dir = $canonicalDir(candidate);
+ if (len(dir) && dir != self && !structKeyExists(seen, dir) && fileExists(dir & "/lucee.json")) {
+ seen[dir] = true;
+ arrayAppend(roots, dir);
+ }
+ }
+ return roots;
+ }
+
+ /**
+ * Canonical form of a directory path (symlinks resolved, no trailing
+ * separator), or the path unchanged when it can't be resolved.
+ */
+ private string function $canonicalDir(required string path) {
+ try {
+ return createObject("java", "java.io.File").init(arguments.path).getCanonicalPath();
+ } catch (any e) {
+ return arguments.path;
+ }
+ }
+
+ /**
+ * HTTP port for a `wheels new` app created without --port: 8080, or the
+ * first port above it that nothing listens on and no other project pins
+ * in its lucee.json. Every app used to get 8080, so the second app on a
+ * machine always collided with the first.
+ */
+ private numeric function $defaultNewPort(required string targetDir) {
+ var pins = $otherProjectPins(arguments.targetDir);
+ var port = $nextFreePort(8080, pins);
+ if (port != 8080) {
+ out("Port 8080 is #structKeyExists(pins, 8080) ? 'pinned by ' & pins[8080] : 'in use'#; this app gets port #port#.", "yellow");
+ }
+ return port;
+ }
+
+ /**
+ * Placeholder values for the `wheels new` project template.
+ *
+ * The shutdown port is the first free port above the HTTP port rather than
+ * a blind port + 1: sibling apps are usually created with adjacent --port
+ * values, so port + 1 is often another running app's port. `wheels start`
+ * still moves a pinned shutdown port that is taken later
+ * ($resolveStartPorts), but the pin `wheels new` writes should be free when
+ * it is written. Ports that other projects pin in their lucee.json are
+ * skipped too ($otherProjectPins), since those projects start on them
+ * even when nothing listens there yet.
+ */
+ private struct function $newTemplateContext(required string appName, required struct opts, string targetDir = "") {
+ var pins = len(arguments.targetDir) ? $otherProjectPins(arguments.targetDir) : {};
+ if (structKeyExists(pins, arguments.opts.port)) {
+ out("Port #arguments.opts.port# is also pinned by #pins[arguments.opts.port]# (its lucee.json); the two apps can't run at the same time. Pass a different --port to avoid that.", "yellow");
+ }
+ var shutdownPort = $nextFreePort(arguments.opts.port + 1, pins);
+ if (shutdownPort != arguments.opts.port + 1) {
+ var why = structKeyExists(pins, arguments.opts.port + 1) ? "pinned by " & pins[arguments.opts.port + 1] : "in use";
+ out("Shutdown port #arguments.opts.port + 1# is #why#; using #shutdownPort#.", "yellow");
+ }
+ // datasourcesBlock: SQLite pair by default; "{}" when --no-sqlite (#2621) or --setup-h2.
+ // With --setup-h2 the H2 datasources are written into config/app.cfm (configureH2Database), so
+ // lucee.json must not also carry the SQLite pair — otherwise the app keeps stale SQLite
+ // datasource entries alongside its H2 ones (#4413).
+ var noLuceeDatasources = arguments.opts.noSQLite
+ || (structKeyExists(arguments.opts, "setupH2") && arguments.opts.setupH2);
+ return {
+ "appName": arguments.appName,
+ "datasourceName": arguments.opts.datasource,
+ "reloadPassword": arguments.opts.reloadPassword,
+ "luceeAdminPassword": arguments.opts.luceeAdminPassword,
+ "port": arguments.opts.port,
+ "shutdownPort": shutdownPort,
+ "openBrowser": arguments.opts.openBrowser ? "true" : "false",
+ "datasourcesBlock": noLuceeDatasources ? "{}" : buildSQLiteDatasourcesBlock(arguments.opts.datasource)
+ };
+ }
+
+ /**
+ * The flags `wheels start` handles itself, out of its argv: `--force`, `--engine`, `--port`
+ * (also `-p N` / `-p=N`, which LuCLI would otherwise take without moving the shutdown port off
+ * it, #4411) and `--dry-run` (noted, and still passed on). Everything else is passed on to
+ * LuCLI's `server start` in `passThrough`.
+ */
+ private struct function $parseStartArgs(required array args) {
+ var rv = {force = false, engine = "lucee", enginePort = 0, dryRun = false, passThrough = []};
+ for (var i = 1; i <= arrayLen(arguments.args); i++) {
+ var a = arguments.args[i];
+ if (a == "--force") {
+ rv.force = true;
+ } else if (a == "--engine") {
+ rv.engine = lCase($startFlagValue(arguments.args, i, "engine"));
+ i++;
+ } else if (left(a, 9) == "--engine=") {
+ rv.engine = lCase(mid(a, 10, len(a) - 9));
+ } else if (a == "--port" || a == "-p") {
+ rv.enginePort = val($startFlagValue(arguments.args, i, "port"));
+ i++;
+ } else if (left(a, 7) == "--port=" || left(a, 4) == "--p=") {
+ rv.enginePort = val(listRest(a, "="));
+ } else {
+ if (a == "--dry-run") {
+ rv.dryRun = true;
+ }
+ arrayAppend(rv.passThrough, a);
+ }
+ }
+ return rv;
+ }
+
+ /**
+ * The argv for LuCLI's `server start`: "start" and the flags `wheels start` passed on, plus the
+ * environment LuCLI took from its root `--env` / `-e` option, which never reaches the module's
+ * own arguments (#4412). An explicit `--env` / `--environment` that did reach them wins.
*/
- private boolean function $isWheelsProjectDir(required string path) {
- if (!len(arguments.path)) return false;
- return fileExists(arguments.path & "/config/settings.cfm");
+ private array function $startCommandArgs(required array passThrough) {
+ var rv = ["start"];
+ rv.append(arguments.passThrough, true);
+ var env = $lucliRootEnvironment();
+ if (len(env) && !$hasEnvironmentFlag(arguments.passThrough)) {
+ arrayAppend(rv, "--environment=" & env);
+ }
+ return rv;
}
/**
- * Read the HTTP port pinned in the project's lucee.json, or 0 if there is no
- * lucee.json, no "port" key, or the file can't be parsed. LuCLI writes this
- * file on first start and honours its port on subsequent starts, so it is the
- * deterministic port to pre-check for a collision before delegating.
+ * Whether the argv already names an environment for LuCLI's `server start`.
*/
- private numeric function $readPinnedPort(required string projectRoot) {
- return $readPinnedPorts(arguments.projectRoot).port;
+ private boolean function $hasEnvironmentFlag(required array argv) {
+ for (var a in arguments.argv) {
+ if (reFind("^--(env|environment)(=|$)", a)) {
+ return true;
+ }
+ }
+ return false;
}
/**
- * Both ports pinned in the project's lucee.json. Each is 0 when absent, so
- * callers can tell "not configured" from a real value.
+ * The environment LuCLI took from its root `--env` / `-e` option (or LUCLI_ENV), or "" when
+ * there is none. LuCLI records LUCLI_ENV in LuCLI.getCurrentEnvironment(), but on the route
+ * that runs a module under its binary name it parses `--env` / `-e` without recording them, so
+ * those are read back from this process's own command line.
*/
- private struct function $readPinnedPorts(required string projectRoot) {
- var ports = {port: 0, shutdownPort: 0};
- var configFile = arguments.projectRoot & "/lucee.json";
- if (!fileExists(configFile)) return ports;
+ private string function $lucliRootEnvironment() {
try {
- var config = deserializeJSON(fileRead(configFile));
- if (isStruct(config)) {
- if (structKeyExists(config, "port") && isNumeric(config.port)) {
- ports.port = config.port;
- }
- if (structKeyExists(config, "shutdownPort") && isNumeric(config.shutdownPort)) {
- ports.shutdownPort = config.shutdownPort;
- }
+ var env = createObject("java", "org.lucee.lucli.LuCLI").getCurrentEnvironment();
+ if (!isNull(env) && len(trim(env))) {
+ return trim(env);
}
} catch (any e) {
- // Malformed lucee.json — let LuCLI surface its own parse error.
+ // An older LuCLI without getCurrentEnvironment(): fall back to the command line.
+ }
+ try {
+ var processArgs = createObject("java", "java.lang.ProcessHandle").current().info().arguments();
+ return processArgs.isPresent() ? $environmentFromArgv(processArgs.get()) : "";
+ } catch (any e) {
+ return "";
}
- return ports;
}
/**
- * First free TCP port at or above `from`. Used to place a project's shutdown
- * port next to its requested HTTP port without colliding with another
- * project's server. Bounded so a pathological environment cannot spin
- * forever; falls back to `from` and lets LuCLI report the conflict itself.
+ * The value of a root `--env` / `-e` option in an argv (`--env=prod`, `--env prod`, `-e prod`,
+ * `-e=prod`), or "". Public ONLY so StartSideEffectsSpec can unit-test it; hidden from MCP by the
+ * $-prefix sweep in mcpHiddenTools().
*/
- private numeric function $nextFreePort(required numeric from) {
- var probe = getService("portProbe");
- for (var candidate = arguments.from; candidate < arguments.from + 100; candidate++) {
- if (!probe.portInUse(candidate)) {
- return candidate;
+ public string function $environmentFromArgv(required any argv) {
+ var n = arrayLen(arguments.argv);
+ for (var i = 1; i <= n; i++) {
+ var a = toString(arguments.argv[i]);
+ if (reFind("^(--env|-e)=.+", a)) {
+ return listRest(a, "=");
+ }
+ if ((a == "--env" || a == "-e") && i < n) {
+ return toString(arguments.argv[i + 1]);
}
}
- return arguments.from;
+ return "";
}
/**
@@ -9874,6 +12597,34 @@ component extends="modules.BaseModule" {
}
}
+ /**
+ * Stop `wheels start` when the HTTP port pinned in lucee.json is taken.
+ *
+ * Starting anyway either failed inside LuCLI, whose message names its
+ * standalone binary ("Use: lucli server stop ", not on a Wheels
+ * install's PATH), or, on a LuCLI with the IPv4-blind check, booted on top
+ * of the other listener. This names the Wheels server that holds the port
+ * when the registry knows it, says how to stop it, and suggests a free
+ * port. Throws Wheels.PortInUse.
+ */
+ private void function $refuseTakenHttpPort(required numeric port) {
+ var holder = getService("serverRegistry").registrationOnPort(arguments.port);
+ var freePort = $nextFreePort(arguments.port + 1, $otherProjectPins(variables.projectRoot));
+ var heldBy = len(holder.name) ? "the Wheels server '#holder.name#'" : "another process";
+ out("");
+ out("Port #arguments.port# (configured in lucee.json) is in use by #heldBy#.", "red");
+ if (len(holder.projectPath)) {
+ out("Stop it: cd #holder.projectPath# && wheels stop", "yellow");
+ } else if (!len(holder.name)) {
+ out("Stop that process, or use another port.", "yellow");
+ }
+ out("Or start this app on a free port: wheels start --port=#freePort#", "yellow");
+ throw(
+ type = "Wheels.PortInUse",
+ message = "wheels start: port #arguments.port# is in use by #heldBy#. Stop it, or run: wheels start --port=#freePort#"
+ );
+ }
+
/**
* Persist the HTTP and shutdown ports into the project's lucee.json, which
* is where LuCLI reads them from.
@@ -10227,8 +12978,9 @@ component extends="modules.BaseModule" {
var sourcePath = arguments.sourceDir & "/" & entry.name;
var targetName = entry.name;
- // Rename _env -> .env, _gitignore -> .gitignore
+ // Rename _env -> .env, _env.example -> .env.example, _gitignore -> .gitignore
if (targetName == "_env") targetName = ".env";
+ else if (targetName == "_env.example") targetName = ".env.example";
else if (targetName == "_gitignore") targetName = ".gitignore";
var targetPath = arguments.targetDir & "/" & targetName;
@@ -10377,6 +13129,43 @@ component extends="modules.BaseModule" {
return result;
}
+ /**
+ * `wheels ` dispatches to the Module function of that name, so an
+ * unknown command lands here instead of Lucee's raw "has no function with
+ * name" error (#3890). Print the friendly message (and a "did you mean"
+ * for a near miss), then throw so the exit stays non-zero. MCP never gets
+ * here: tools/call only reaches listed tools.
+ */
+ public any function onMissingMethod(required string missingMethodName, struct missingMethodArguments = {}) {
+ var name = arguments.missingMethodName;
+ var message = "Unknown command: #name#. Run 'wheels --help' for the commands.";
+ var hint = $closestFlag(name, $commandNames());
+ if (len(hint)) message &= " Did you mean: wheels #hint#?";
+ out(message, "red");
+ throw(type = "Wheels.UnknownCommand", message = message);
+ }
+
+ /** Public command names a user can type: no $-helpers, hooks or MCP registries. */
+ private array function $commandNames() {
+ var names = [];
+ var internal = "init,main,onMissingMethod,mcpHiddenTools,mcpToolSpecs";
+ try {
+ for (var fn in getMetaData(this).functions) {
+ if (
+ structKeyExists(fn, "access") && fn.access == "public"
+ && left(fn.name, 1) != "$"
+ && !listFindNoCase(internal, fn.name)
+ && len(fn.name) > 2
+ ) {
+ arrayAppend(names, fn.name);
+ }
+ }
+ } catch (any e) {
+ // No metadata: no suggestion, the message still names the command.
+ }
+ return names;
+ }
+
/**
* Nearest known flag for a "did you mean" hint. A typo is almost always
* within two edits of what was intended (--belogsTo -> --belongsTo is
@@ -10414,14 +13203,6 @@ component extends="modules.BaseModule" {
return prev[lb + 1];
}
- /**
- * Parse one generator property token into a name/type struct, plus
- * optional Rails-style brace modifiers (`string{50}`, `decimal{10,2}`)
- * and colon-delimited enum values (`status:enum:draft,published`).
- *
- * Brace modifiers attach to the type token only, so they never steal
- * the value list from `name:enum:a,b`.
- */
/**
* A hyphen can't appear in a CFC or property name, and people do type
* `create-users-table` or `display-name`, so hyphens in those names become
@@ -10436,22 +13217,72 @@ component extends="modules.BaseModule" {
return normalized;
}
+ /**
+ * Parse one generator property token into a name/type struct, plus
+ * optional Rails-style brace modifiers (`string{50}`, `decimal{10,2}`)
+ * and colon-delimited enum values (`status:enum:draft,published`).
+ *
+ * Brace modifiers attach to the type token only, so they never steal
+ * the value list from `name:enum:a,b`.
+ *
+ * Required-ness: a column is REQUIRED by default (`required=true` →
+ * migration `allowNull=false` + a `validatesPresenceOf`). Two markers opt
+ * out, so the generated migration and model always agree:
+ * - `name:type:optional` → nullable (`required=false`, no presence).
+ * - `name:type=value` → a column DEFAULT (`prop.default`); a defaulted
+ * column is never added to `validatesPresenceOf`
+ * (an absent value is filled by the default).
+ * The two combine: `name:type=value:optional` is nullable with a default.
+ * `:optional` uses the word (not `?`) because zsh — the macOS default
+ * shell — treats a bare `?` as a glob.
+ */
private struct function $parsePropertyArg(required string arg) {
- // Split on the FIRST two colons only — any additional colons
- // (e.g. inside the comma-separated value list) belong in the
- // values segment.
- var parts = listToArray(arguments.arg, ":");
var names = new services.GeneratorPaths();
+ var token = arguments.arg;
+
+ // A trailing ":optional" marks the column nullable. Matched as a literal
+ // SUFFIX (not a positional colon segment) so a ":" inside a "=value"
+ // default — a URL or a timestamp — is never mistaken for the marker.
+ var optional = false;
+ if (len(token) GT 9 && compareNoCase(right(token, 9), ":optional") == 0) {
+ optional = true;
+ token = left(token, len(token) - 9);
+ }
+
+ // A "=value" suffix sets a column DEFAULT. Split on the FIRST "=" so the
+ // value keeps every ":" it contains (e.g. https://host, 12:00:00).
+ // Everything after the first "=" is the default; an empty value
+ // ("name:string=") is treated as no default.
+ var columnDefault = "";
+ var hasDefault = false;
+ var eqPos = find("=", token);
+ if (eqPos > 0) {
+ columnDefault = trim(mid(token, eqPos + 1, len(token) - eqPos));
+ token = left(token, eqPos - 1);
+ hasDefault = len(columnDefault) > 0;
+ }
+
+ // What remains is name[:type[:enumvalues]] (the type may carry {N}/{P,S}).
+ var parts = listToArray(token, ":");
// Property names and types are written into generated CFML (models,
// migrations, forms), so only plain identifiers are accepted.
var propName = names.identifier($underscoreHyphens(arrayLen(parts) ? parts[1] : "", "property"), "property");
var typeToken = arrayLen(parts) > 1 ? parts[2] : "string";
+ if (!len(typeToken)) {
+ typeToken = "string";
+ }
+
var modifiers = $parseTypeModifiers(typeToken);
names.identifier(modifiers.type, "property type");
var prop = {
name: propName,
- type: modifiers.type
+ type: modifiers.type,
+ // Required by default; ":optional" makes it nullable.
+ required: !optional
};
+ if (hasDefault) {
+ prop.default = columnDefault;
+ }
if (structKeyExists(modifiers, "limit")) {
prop.limit = modifiers.limit;
}
@@ -10577,6 +13408,29 @@ component extends="modules.BaseModule" {
);
}
+ /**
+ * True when WHEELS_SERVER_FALLBACK is set to false (false/0/no) in the
+ * process environment or in the project's `.env`.
+ */
+ private boolean function $serverFallbackDisabled() {
+ var raw = "";
+ try {
+ raw = server.system.environment.WHEELS_SERVER_FALLBACK ?: "";
+ } catch (any e) {
+ }
+ if (!len(trim(raw))) {
+ var envFile = variables.projectRoot & "/.env";
+ if (fileExists(envFile)) {
+ // Anchor on start-of-file or a newline (portable across regex engines).
+ var hit = reFindNoCase("(^|[\r\n])[ \t]*WHEELS_SERVER_FALLBACK[ \t]*=[ \t]*[""']?([A-Za-z0-9]+)", fileRead(envFile), 1, true);
+ if (arrayLen(hit.match) > 2) {
+ raw = hit.match[3];
+ }
+ }
+ }
+ return listFindNoCase("false,0,no", trim(raw)) > 0;
+ }
+
/**
* Detect the port of a running Wheels dev server.
*
@@ -10602,29 +13456,6 @@ component extends="modules.BaseModule" {
* spec reaches it through TestBox `makePublic()` — see
* cli/lucli/tests/specs/services/ServerDetectionSpec.cfc (#2878 review).
*/
- /**
- * True when WHEELS_SERVER_FALLBACK is set to false (false/0/no) in the
- * process environment or in the project's `.env`.
- */
- private boolean function $serverFallbackDisabled() {
- var raw = "";
- try {
- raw = server.system.environment.WHEELS_SERVER_FALLBACK ?: "";
- } catch (any e) {
- }
- if (!len(trim(raw))) {
- var envFile = variables.projectRoot & "/.env";
- if (fileExists(envFile)) {
- // Anchor on start-of-file or a newline (portable across regex engines).
- var hit = reFindNoCase("(^|[\r\n])[ \t]*WHEELS_SERVER_FALLBACK[ \t]*=[ \t]*[""']?([A-Za-z0-9]+)", fileRead(envFile), 1, true);
- if (arrayLen(hit.match) > 2) {
- raw = hit.match[3];
- }
- }
- }
- return listFindNoCase("false,0,no", trim(raw)) > 0;
- }
-
private any function detectServerPort(
boolean requireProjectConfig = false,
array commonPorts = [8080, 60000, 3000, 8500]
@@ -10930,6 +13761,60 @@ component extends="modules.BaseModule" {
}
}
+ /**
+ * Is a Lucee server running for this project, under any of its LuCLI
+ * registrations (not only the current lucee.json name)? A seam for specs.
+ */
+ private boolean function $luceeServerAlive() {
+ return len(getService("serverRegistry").aliveRegistrationFor(variables.projectRoot)) > 0;
+ }
+
+ /**
+ * Refuse to start `engine` while the project's other engine is running
+ * (#3913). Two servers for one project on different ports left the CLI's
+ * HTTP commands targeting whichever one port detection picked.
+ */
+ private void function $refuseOtherEngine(required string engine) {
+ var other = "";
+ if (arguments.engine == "rustcfml") {
+ if ($luceeServerAlive()) other = "Lucee";
+ } else if ($rustcfmlEngine().status(variables.projectRoot).running) {
+ other = "RustCFML";
+ }
+ if (!len(other)) return;
+ out("A #other# server is already running for this project.", "yellow");
+ out("Stop it first: wheels stop", "cyan");
+ throw(
+ type = "Wheels.EngineConflict",
+ message = "A #other# server is already running for this project; run `wheels stop` before starting another engine."
+ );
+ }
+
+ /**
+ * The "server" line of generate's Next steps. A server already running for
+ * the project only picks up new routes and models after a reload; telling
+ * people to start it sent them to a 404 on the new route (#3883).
+ */
+ private string function $serverNextStep() {
+ return $serverRunningForProject()
+ ? "Reload the running server so it picks up the new routes: wheels reload"
+ : "Start server: wheels start";
+ }
+
+ /**
+ * Is a dev server (Lucee or RustCFML) running for this project? Local state
+ * only, no network: a project-owned live registration, or a running
+ * RustCFML server. A seam for specs.
+ */
+ private boolean function $serverRunningForProject() {
+ try {
+ if (len(getService("serverRegistry").aliveRegistrationFor(variables.projectRoot))) return true;
+ return $rustcfmlEngine().status(variables.projectRoot).running;
+ } catch (any e) {
+ return false;
+ }
+ }
+
/** The RustCFML engine backend (a seam for specs). */
private any function $rustcfmlEngine() {
return new services.rustcfml.RustCFMLEngine();
@@ -11007,10 +13892,7 @@ component extends="modules.BaseModule" {
var settingsFile = variables.projectRoot & "/config/settings.cfm";
if (fileExists(settingsFile)) {
var settingsContent = stripCfmlComments(fileRead(settingsFile));
- var settingsMatch = reFindNoCase('\bdataSourceName\b\s*=\s*"([^"]*)"', settingsContent, 1, true);
- if (arrayLen(settingsMatch.match) > 1 && len(trim(settingsMatch.match[2]))) {
- base = trim(settingsMatch.match[2]);
- }
+ base = $settingsDataSourceName(settingsContent);
}
}
@@ -11028,6 +13910,49 @@ component extends="modules.BaseModule" {
return reFindNoCase("_test$", base) ? base : base & "_test";
}
+ /**
+ * The datasource name a (comment-stripped) config/settings.cfm sets, or "".
+ * Reads both `set(dataSourceName="name")` and the generated
+ * `set(dataSourceName=env("WHEELS_DATASOURCE", "name"))`, with single or double
+ * quotes as CFML allows (each value's quotes must match, #3952); for the env() form the
+ * variable's value in .env wins over the default, as it does at runtime. The
+ * process environment is not consulted: it can differ from the app server's.
+ */
+ public string function $settingsDataSourceName(required string settingsContent) {
+ // Two patterns rather than one with an optional env( group: every group in
+ // each always takes part in the match, so the subexpression arrays line up.
+ // Capture groups: \1 and \3 are the quotes (backreferenced so each pair
+ // matches). In m.match (whole match first) the variable name is [3] and
+ // the default is [5].
+ var m = reFindNoCase("\bdataSourceName\b\s*=\s*env\s*\(\s*([""'])([A-Za-z_][A-Za-z0-9_]*)\1\s*,\s*([""'])([^""']*)\3", arguments.settingsContent, 1, true);
+ if (m.pos[1] > 0) {
+ var envKey = m.match[3];
+ var name = trim(m.match[5]);
+ var envFile = variables.projectRoot & "/.env";
+ if (fileExists(envFile)) {
+ // Read the value the way the app's Application.cfc loadEnvFile() does:
+ // skip blank and # lines, split on the first "=", strip one pair of
+ // matching quotes, and let the last line for the key win.
+ for (var line in listToArray(fileRead(envFile), chr(10))) {
+ line = trim(line);
+ if (!len(line) || left(line, 1) == "##" || !find("=", line) || compareNoCase(trim(listFirst(line, "=")), envKey) != 0) {
+ continue;
+ }
+ var value = trim(listRest(line, "="));
+ if (len(value) >= 2 && ((left(value, 1) == '"' && right(value, 1) == '"') || (left(value, 1) == "'" && right(value, 1) == "'"))) {
+ value = mid(value, 2, len(value) - 2);
+ }
+ if (len(value)) {
+ name = value;
+ }
+ }
+ }
+ return name;
+ }
+ m = reFindNoCase("\bdataSourceName\b\s*=\s*([""'])([^""']*)\1", arguments.settingsContent, 1, true);
+ return m.pos[1] > 0 ? trim(m.match[3]) : "";
+ }
+
/**
* Resolve the URL base path the app is mounted under, for the test-runner
* request. Precedence (issue #3026): an explicit value (the --base-path
@@ -11211,9 +14136,6 @@ component extends="modules.BaseModule" {
}
}
- /**
- * Make an HTTP GET request and return the response body
- */
/**
* Parse a /wheels/cli? JSON response and surface framework errors.
*
@@ -11747,14 +14669,14 @@ component extends="modules.BaseModule" {
return variables.services[name];
}
- /**
- * Ensure a directory exists, creating it if necessary
- */
/** ensureDirectory() for generator output: refuses a directory that resolves outside the project. */
private void function $ensureProjectDirectory(required string path) {
new services.GeneratorPaths().ensureDirectoryInside(variables.projectRoot, arguments.path);
}
+ /**
+ * Ensure a directory exists, creating it if necessary
+ */
private void function ensureDirectory(required string path) {
if (!directoryExists(path)) {
directoryCreate(path, true);
@@ -11906,27 +14828,55 @@ component extends="modules.BaseModule" {
out("");
out("Installing #browserName# browser binaries...");
- var classpath = "";
- for (var entry in manifest.classpath) {
- if (len(classpath)) classpath &= ":";
- classpath &= installDir & "/lib/" & entry.filename;
- }
+ var classpath = $browserClasspath(installDir, manifest);
- try {
- cfexecute(
- name="java",
- arguments="-cp #classpath# com.microsoft.playwright.CLI install #browserName#",
- timeout=300,
- variable="local.stdout",
- errorVariable="local.stderr"
- );
- out("Browser install OK", "green");
- } catch (any e) {
+ var install = $browserRunProcess(["java", "-cp", classpath, "com.microsoft.playwright.CLI", "install", browserName], 300);
+ if (install.timedOut || install.exitCode != 0) {
out("Browser install FAILED", "red");
- out(local.stderr ?: e.message, "red");
- return "";
+ out(install.timedOut ? "Playwright's install didn't finish within 300 seconds and was stopped." : trim(install.output), "red");
+ throw(type = "Wheels.BrowserSetupFailed", message = "wheels browser setup: installing the #browserName# binaries failed. See the output above.");
+ }
+ out("Browser install OK", "green");
+
+ return $browserFinishSetup(classpath, browserName);
+ }
+
+ /**
+ * The Java classpath for the Playwright jars in `installDir`, joined with
+ * the platform's separator (`:` on macOS and Linux, `;` on Windows).
+ * Public so specs can check it.
+ */
+ public string function $browserClasspath(required string installDir, required struct manifest) {
+ var jars = [];
+ for (var entry in arguments.manifest.classpath) {
+ arrayAppend(jars, arguments.installDir & "/lib/" & entry.filename);
}
+ return arrayToList(jars, createObject("java", "java.io.File").pathSeparator);
+ }
+ /**
+ * Launch the browser once and finish `wheels browser setup`: print "ready"
+ * only when the launch worked; otherwise print Playwright's output and the
+ * remedy, then throw so the command exits non-zero. Downloading the
+ * binaries doesn't prove they run: on a bare Linux host the browser can be
+ * missing OS libraries. Public so specs can drive it with a stubbed probe.
+ */
+ public string function $browserFinishSetup(required string classpath, required string browserName) {
+ out("Launching #arguments.browserName# to check it runs...");
+ var probe = $browserLaunchProbe(arguments.classpath, arguments.browserName);
+ if (!probe.ok) {
+ out("Browser launch FAILED", "red");
+ if (probe.timedOut) {
+ out("The browser didn't finish starting within #probe.timeoutSeconds# seconds and was stopped.", "red");
+ }
+ if (len(trim(probe.output))) {
+ out(trim(probe.output), "red");
+ }
+ out("");
+ out($browserLaunchRemedy(probe.output, arguments.classpath, arguments.browserName), "yellow");
+ throw(type = "Wheels.BrowserSetupFailed", message = "wheels browser setup: #arguments.browserName# is installed but didn't start. See the output above.");
+ }
+ out("Browser launch OK", "green");
out("");
out("Browser testing ready.", "green");
out("Run: wheels test --filter=browser (or: wheels browser test)", "green");
@@ -11940,11 +14890,9 @@ component extends="modules.BaseModule" {
var basePath = opts.basePath;
var directory = opts.directory;
- // Pre-flight: verify Playwright JARs
+ // Pre-flight: verify Playwright JARs (throws Wheels.Browser.NotInstalled, exit non-zero, if not set up)
var manifestPath = variables.projectRoot & "/vendor/wheels/browser-manifest.json";
- if (!$browserVerifyPlaywright(manifestPath)) {
- return "";
- }
+ $browserVerifyPlaywright(manifestPath);
out("Running browser tests...", "cyan");
out("Directory: #directory#");
@@ -12048,12 +14996,18 @@ component extends="modules.BaseModule" {
/**
* Pre-flight: verify the Playwright JARs in browser-manifest.json are
- * present and SHA-matched. Prints guidance and returns false when not.
+ * present and SHA-matched. Throws Wheels.Browser.NotInstalled (which exits
+ * non-zero) when the manifest is missing or a JAR is absent / SHA-mismatched,
+ * so a scripted `wheels browser test` on an un-set-up host fails instead of
+ * silently exiting 0 (#4409). Returns normally when everything is in place.
*/
- private boolean function $browserVerifyPlaywright(required string manifestPath) {
+ private void function $browserVerifyPlaywright(required string manifestPath) {
if (!fileExists(arguments.manifestPath)) {
- out("browser-manifest.json not found at: #arguments.manifestPath#", "red");
- return false;
+ $refuse(
+ "browser-manifest.json not found at: #arguments.manifestPath#",
+ "Wheels.Browser.NotInstalled",
+ ["Run: wheels browser setup"]
+ );
}
var manifest = deserializeJSON(fileRead(arguments.manifestPath));
var installDir = $resolveBrowserInstallDir();
@@ -12073,18 +15027,18 @@ component extends="modules.BaseModule" {
}
if (!allInstalled) {
- out("Playwright not installed.", "red");
if (arrayLen(missingJars)) {
out("Missing: #arrayToList(missingJars, ', ')#", "yellow");
}
if (arrayLen(mismatchedJars)) {
out("SHA mismatch: #arrayToList(mismatchedJars, ', ')#", "yellow");
}
- out("");
- out("Run: wheels browser setup");
- return false;
+ $refuse(
+ "Playwright not installed.",
+ "Wheels.Browser.NotInstalled",
+ ["Run: wheels browser setup"]
+ );
}
- return true;
}
/**
@@ -12182,6 +15136,93 @@ component extends="modules.BaseModule" {
}
}
+ /**
+ * Launch the browser once through Playwright's CLI (a headless screenshot of
+ * about:blank). It counts as running only when the process finished in
+ * time, exited 0 and wrote the screenshot; a timed-out run never counts,
+ * even if a screenshot appeared first. The screenshot is always removed.
+ * Returns {ok, timedOut, timeoutSeconds, output}.
+ */
+ public struct function $browserLaunchProbe(required string classpath, required string browserName, numeric timeoutSeconds = 120) {
+ var shot = getTempDirectory() & "wheels-browser-probe-" & createUUID() & ".png";
+ var rv = {ok: false, timedOut: false, timeoutSeconds: arguments.timeoutSeconds, output: ""};
+ try {
+ var run = $browserRunProcess(
+ ["java", "-cp", arguments.classpath, "com.microsoft.playwright.CLI", "screenshot", "--browser", arguments.browserName, "about:blank", shot],
+ arguments.timeoutSeconds
+ );
+ rv.output = run.output;
+ rv.timedOut = run.timedOut;
+ rv.ok = !run.timedOut && run.exitCode == 0 && fileExists(shot) && getFileInfo(shot).size > 0;
+ } finally {
+ if (fileExists(shot)) {
+ fileDelete(shot);
+ }
+ }
+ return rv;
+ }
+
+ /**
+ * Run a command (an argv array, so paths with spaces stay one argument) and
+ * wait at most `timeoutSeconds`. On timeout the process and everything it
+ * started are killed. Output (stdout and stderr together) goes through a
+ * temp file, so a chatty process can't block on a full pipe. Returns
+ * {exitCode, timedOut, output}. Public so specs can check the timeout.
+ */
+ public struct function $browserRunProcess(required array argv, required numeric timeoutSeconds) {
+ var logFile = getTempDirectory() & "wheels-browser-run-" & createUUID() & ".log";
+ var rv = {exitCode: -1, timedOut: false, output: ""};
+ try {
+ var builder = createObject("java", "java.lang.ProcessBuilder").init(arguments.argv);
+ builder.redirectErrorStream(true);
+ builder.redirectOutput(createObject("java", "java.io.File").init(logFile));
+ var process = builder.start();
+ var seconds = createObject("java", "java.util.concurrent.TimeUnit").SECONDS;
+ if (process.waitFor(javaCast("long", arguments.timeoutSeconds), seconds)) {
+ rv.exitCode = process.exitValue();
+ } else {
+ rv.timedOut = true;
+ $browserKillProcessTree(process);
+ }
+ rv.output = fileExists(logFile) ? fileRead(logFile) : "";
+ } finally {
+ if (fileExists(logFile)) {
+ fileDelete(logFile);
+ }
+ }
+ return rv;
+ }
+
+ /**
+ * Kill a process and every process it started, then wait briefly for it to
+ * go. The parent goes first, so it can't react to a child's death (a shell
+ * would run its next command); the children are listed before that, while
+ * they are still its descendants.
+ */
+ private void function $browserKillProcessTree(required any process) {
+ var children = arguments.process.descendants().toArray();
+ arguments.process.destroyForcibly();
+ for (var child in children) {
+ child.destroyForcibly();
+ }
+ arguments.process.waitFor(javaCast("long", 5), createObject("java", "java.util.concurrent.TimeUnit").SECONDS);
+ }
+
+ /**
+ * What to do after a failed launch probe. When Playwright reports missing
+ * host libraries (a bare Linux install), print its install-deps command for
+ * this install's classpath; otherwise point at the output above.
+ * Public so specs can check the text.
+ */
+ public string function $browserLaunchRemedy(required string output, required string classpath, required string browserName) {
+ if (findNoCase("missing dependencies", arguments.output) || findNoCase("install-deps", arguments.output)) {
+ return "The browser is installed but the system is missing libraries it needs. On Debian/Ubuntu, install them with:" & chr(10)
+ & " sudo java -cp ""#arguments.classpath#"" com.microsoft.playwright.CLI install-deps #arguments.browserName#" & chr(10)
+ & "then run wheels browser setup again.";
+ }
+ return "The browser was installed but didn't start; see the output above. Run wheels browser setup --force to reinstall, then try again.";
+ }
+
/**
* Print the closing browser-test summary line(s).
*/
diff --git a/cli/lucli/services/ArgSpec.cfc b/cli/lucli/services/ArgSpec.cfc
index 63e9b97b7e..85931edbde 100644
--- a/cli/lucli/services/ArgSpec.cfc
+++ b/cli/lucli/services/ArgSpec.cfc
@@ -35,6 +35,10 @@ component {
public any function init() {
variables.positionals = [];
variables.named = {};
+ // Declaration order of the named options: variables.named is a struct,
+ // whose iteration order on Lucee is not insertion order, and help lists
+ // options in the order the command declares them.
+ variables.namedOrder = [];
// Keys parse() accepts without declaring or advertising them. `offline`
// is the one documented GLOBAL flag ("applies to every command",
// command-line-tools guide); commands that act on it read it through
@@ -77,6 +81,7 @@ component {
boolean default = false,
string description = ""
) {
+ $rememberOrder(arguments.name);
variables.named[arguments.name] = {
"default" = arguments.default,
"type" = "boolean",
@@ -99,6 +104,7 @@ component {
string description = "",
string choices = ""
) {
+ $rememberOrder(arguments.name);
variables.named[arguments.name] = {
"default" = arguments.default,
"type" = arguments.type,
@@ -149,6 +155,13 @@ component {
public struct function parse(required struct coll, boolean strict = true) {
var result = {};
+ // LuCLI's runtime-owned MCP marker (wheels-dev/LuCLI#17) is never a
+ // command argument; Module.structuredArgs() normally removes it first.
+ if (structKeyExists(arguments.coll, "__lucliMcpCall")) {
+ arguments.coll = structCopy(arguments.coll);
+ structDelete(arguments.coll, "__lucliMcpCall");
+ }
+
// 1. Seed named defaults so every declared option is present in the result.
for (var optName in variables.named) {
result[optName] = variables.named[optName]["default"];
@@ -270,6 +283,80 @@ component {
return result;
}
+ /**
+ * Re-binds space-form option values (`--port 8931`). LuCLI hands that over
+ * as port="true" plus a positional one index past the gap the flag left
+ * (arg3 missing, arg4="8931"), so a value parser sees a bare flag.
+ *
+ * `matchers` maps each option that may arrive this way to a regex its
+ * value must match ("" matches anything). The candidates are the
+ * positionals that directly follow a gap. With one bare option and one
+ * candidate, the candidate binds when it matches. With several, each
+ * candidate must match exactly one bare option and each bare option
+ * exactly one candidate (e.g. a number for port, a name for engine);
+ * otherwise nothing is bound. Bound candidates leave the positionals.
+ */
+ public struct function bindSpaceFormValues(required struct coll, required struct matchers) {
+ var bare = [];
+ for (var key in arguments.matchers) {
+ if (
+ structKeyExists(arguments.coll, key)
+ && isSimpleValue(arguments.coll[key])
+ && compareNoCase(trim(toString(arguments.coll[key])), "true") == 0
+ ) {
+ arrayAppend(bare, key);
+ }
+ }
+ if (!arrayLen(bare)) {
+ return arguments.coll;
+ }
+ var maxIndex = 0;
+ for (var k in arguments.coll) {
+ if (reFindNoCase("^arg\d+$", k)) {
+ maxIndex = max(maxIndex, val(mid(k, 4, len(k) - 3)));
+ }
+ }
+ var candidates = [];
+ for (var i = 1; i < maxIndex; i++) {
+ if (!structKeyExists(arguments.coll, "arg" & i) && structKeyExists(arguments.coll, "arg" & (i + 1))) {
+ arrayAppend(candidates, "arg" & (i + 1));
+ }
+ }
+ if (arrayLen(candidates) != arrayLen(bare)) {
+ return arguments.coll;
+ }
+ var binding = {};
+ for (var argKey in candidates) {
+ var value = toString(arguments.coll[argKey]);
+ var owners = [];
+ for (var key in bare) {
+ var pattern = arguments.matchers[key];
+ if (!len(pattern) || reFind(pattern, value)) {
+ arrayAppend(owners, key);
+ }
+ }
+ // A single bare option takes the single candidate if it fits;
+ // several need a one-to-one match by value shape.
+ if (arrayLen(owners) != 1 || structKeyExists(binding, owners[1])) {
+ return arguments.coll;
+ }
+ binding[owners[1]] = argKey;
+ }
+ var result = duplicate(arguments.coll);
+ for (var key in binding) {
+ result[key] = result[binding[key]];
+ structDelete(result, binding[key]);
+ }
+ return result;
+ }
+
+ /** bindSpaceFormValues() for one option whose value can be anything. */
+ public struct function bindSpaceFormValue(required struct coll, required string key) {
+ var matchers = {};
+ matchers[arguments.key] = "";
+ return bindSpaceFormValues(arguments.coll, matchers);
+ }
+
/**
* Reconstruct LuCLI's ordered argv from a structured argCollection.
*
@@ -310,7 +397,8 @@ component {
// argCollections may carry native booleans, which toString() renders
// as "true"/"false" — both shapes match the exact compare.
for (var key in arguments.coll) {
- if (reFindNoCase("^arg\d+$", key)) {
+ // The runtime-owned MCP marker is never forwarded as a flag (#3980).
+ if (reFindNoCase("^arg\d+$", key) || compareNoCase(key, "__lucliMcpCall") == 0) {
continue;
}
if (isNull(arguments.coll[key])) {
@@ -323,7 +411,16 @@ component {
continue;
}
var stringValue = toString(value);
- if (compareNoCase(stringValue, "true") == 0) {
+ // A key containing ":" is not a flag — it is a generator property token
+ // (`name:type`) that LuCLI parsed as a key=value pair because it carried
+ // a "=value" default (e.g. `status:string=active`, `active:boolean=false`).
+ // Re-emit it verbatim as `key=value` so the default survives: the generic
+ // true/false -> --key/--no-key conversion below would otherwise turn
+ // `active:boolean=false` into `--no-active:boolean` and drop the default.
+ // No command names a real flag with a ":".
+ if (find(":", key)) {
+ arrayAppend(result, key & "=" & stringValue);
+ } else if (compareNoCase(stringValue, "true") == 0) {
arrayAppend(result, "--" & key);
} else if (compareNoCase(stringValue, "false") == 0) {
arrayAppend(result, "--no-" & key);
@@ -335,6 +432,82 @@ component {
return result;
}
+ /**
+ * The options section of `wheels --help` (issue 3962): one entry per
+ * positional and named option, in declaration order, with its description,
+ * accepted values and default, wrapped to `width` columns. Keys added with
+ * accept() are not listed, as they are not in the MCP schema either.
+ */
+ public array function toHelpLines(numeric width = 100) {
+ var entries = [];
+ for (var p in variables.positionals) {
+ arrayAppend(entries, {label = "<" & p.name & ">", text = $helpText(p.description, p.choices, p["default"], p.type)});
+ }
+ for (var optName in variables.namedOrder) {
+ var spec = variables.named[optName];
+ var label = "--" & optName & "=";
+ var text = $helpText(spec.description, spec.choices, spec["default"], spec.type);
+ if (spec.type == "boolean") {
+ var onByDefault = isBoolean(spec["default"]) && spec["default"];
+ label = onByDefault ? "--[no-]" & optName : "--" & optName;
+ if (onByDefault) {
+ text &= (len(text) ? " " : "") & "(on by default)";
+ }
+ }
+ arrayAppend(entries, {label = label, text = text});
+ }
+ var labelWidth = 0;
+ for (var entry in entries) {
+ labelWidth = max(labelWidth, len(entry.label));
+ }
+ labelWidth = min(labelWidth, 26);
+ var indent = 2 + labelWidth + 2;
+ var lines = [];
+ for (var entry in entries) {
+ var head = " " & entry.label;
+ var words = listToArray(entry.text, " ");
+ var current = len(head) >= indent ? head : head & repeatString(" ", indent - len(head));
+ if (len(head) >= indent) {
+ arrayAppend(lines, head);
+ current = repeatString(" ", indent);
+ }
+ var fresh = true;
+ for (var word in words) {
+ if (!fresh && len(current) + 1 + len(word) > arguments.width) {
+ arrayAppend(lines, current);
+ current = repeatString(" ", indent) & word;
+ } else {
+ current &= (fresh ? "" : " ") & word;
+ }
+ fresh = false;
+ }
+ arrayAppend(lines, reReplace(current, "\s+$", ""));
+ }
+ return lines;
+ }
+
+ private string function $helpText(
+ required string description,
+ required array choices,
+ required any default,
+ required string type
+ ) {
+ var text = trim(arguments.description);
+ if (arrayLen(arguments.choices)) {
+ text &= (len(text) ? " " : "") & "[" & arrayToList(arguments.choices, ", ") & "]";
+ }
+ if (arguments.type != "boolean" && isSimpleValue(arguments.default) && len(trim(toString(arguments.default)))) {
+ text &= (len(text) ? " " : "") & "(default: " & toString(arguments.default) & ")";
+ }
+ return text;
+ }
+
+ private void function $rememberOrder(required string name) {
+ if (!arrayFindNoCase(variables.namedOrder, arguments.name)) {
+ arrayAppend(variables.namedOrder, arguments.name);
+ }
+ }
+
/**
* Emit a JSON-Schema-compatible input schema describing this spec.
*
diff --git a/cli/lucli/services/CodeGen.cfc b/cli/lucli/services/CodeGen.cfc
index b3ca4500ac..d17047526f 100644
--- a/cli/lucli/services/CodeGen.cfc
+++ b/cli/lucli/services/CodeGen.cfc
@@ -84,12 +84,27 @@ component {
var extraLines = [];
for (var prop in arguments.properties) {
- arrayAppend(presenceProps, prop.name);
+ // Validate presence only for REQUIRED columns. Columns are required by
+ // default, so a prop with no `required` key is treated as required (the
+ // migration applies the same default, so model and schema agree). A
+ // `:optional` column (required=false) or a column with a DEFAULT is
+ // omitted — an optional/defaulted column that also carried a presence
+ // rule could never be saved blank.
+ var isRequired = structKeyExists(prop, "required") ? prop.required : true;
+ var hasDefault = structKeyExists(prop, "default") && len(prop.default);
+ var presenceRequired = isRequired && !hasDefault;
+ if (presenceRequired) {
+ arrayAppend(presenceProps, prop.name);
+ }
+ // When a column is not presence-required, its format rule must allow a
+ // blank value too, or it would reject the emptiness that makes the
+ // column optional.
+ var formatAllowBlank = presenceRequired ? "" : ", allowBlank=true";
var propType = structKeyExists(prop, "type") ? lCase(prop.type) : "string";
if (propType == "email") {
- arrayAppend(extraLines, "validatesFormatOf(property=""#prop.name#"", type=""email"");");
+ arrayAppend(extraLines, "validatesFormatOf(property=""#prop.name#"", type=""email""#formatAllowBlank#);");
} else if (propType == "url") {
- arrayAppend(extraLines, "validatesFormatOf(property=""#prop.name#"", type=""URL"");");
+ arrayAppend(extraLines, "validatesFormatOf(property=""#prop.name#"", type=""URL""#formatAllowBlank#);");
}
if (isStringLikeLengthLimit(prop, propType)) {
// allowBlank=true so an empty value only surfaces the
@@ -99,7 +114,12 @@ component {
}
}
- var lines = ["validatesPresenceOf(""#arrayToList(presenceProps)#"");"];
+ var lines = [];
+ // Only emit validatesPresenceOf when at least one column is required —
+ // an all-optional/defaulted model must not get validatesPresenceOf("").
+ if (arrayLen(presenceProps)) {
+ arrayAppend(lines, "validatesPresenceOf(""#arrayToList(presenceProps)#"");");
+ }
lines.append(extraLines, true);
// Join with newline + 2 tabs so subsequent lines align with the template's
// `\t\t{{validations}}` placeholder indent. The first line gets its indent
@@ -187,9 +207,12 @@ component {
// defaults applied next shape the controller body only, never the view files.
var requestedActions = arguments.actions;
- // Default actions based on type
- if (arrayLen(arguments.actions) == 0) {
- arguments.actions = arguments.crud ? crudActions : ["index"];
+ // Default the controller body to the CRUD actions only under --crud. With no actions and no
+ // --crud, leave the body empty: the documented contract is "passing no actions creates an empty
+ // controller with no view files", and a default index() stub with no matching view 500s at
+ // runtime (#4414).
+ if (arrayLen(arguments.actions) == 0 && arguments.crud) {
+ arguments.actions = crudActions;
}
var context = {
diff --git a/cli/lucli/services/DocsIndex.cfc b/cli/lucli/services/DocsIndex.cfc
new file mode 100644
index 0000000000..528af9ec03
--- /dev/null
+++ b/cli/lucli/services/DocsIndex.cfc
@@ -0,0 +1,285 @@
+/**
+ * The offline docs index behind `wheels lookup` and the `lookup` MCP tool.
+ *
+ * Reads cli/lucli/data/docs-index.json, which tools/build/scripts/build-docs-index.mjs
+ * writes at release and snapshot build time: one entry per API function
+ * (`kind: "api"`) and one per guide section (`kind: "guides"`). No network, no
+ * server, no project needed.
+ *
+ * lookup(query) answers in one of three modes:
+ * - "id": the query is an entry id from an earlier result (api:model.findAll,
+ * guides:basics/routing#nested-resources); returns that whole entry;
+ * - "exact": the query is a function name, optionally with () or a scope
+ * (findAll, model.findAll); returns every matching API entry in full;
+ * - "search": anything else; returns ranked results with a snippet, a link
+ * and the id to ask for the whole entry.
+ *
+ * The parsed index is cached in the server scope by path and modification time,
+ * so a long-lived MCP server parses the ~3 MB file once.
+ */
+component {
+
+ variables.KINDS = ["all", "api", "guides"];
+ variables.MAX_LIMIT = 20;
+ variables.SNIPPET_LENGTH = 300;
+
+ public any function init(required string path) {
+ variables.path = arguments.path;
+ return this;
+ }
+
+ public boolean function exists() {
+ return fileExists(variables.path);
+ }
+
+ /**
+ * frameworkVersion, apiSource and guidesSlug: what the index was built from.
+ */
+ public struct function meta() {
+ var data = $load();
+ return {
+ "frameworkVersion" = data.frameworkVersion ?: "",
+ "apiSource" = data.apiSource ?: "",
+ "guidesSlug" = data.guidesSlug ?: ""
+ };
+ }
+
+ public struct function lookup(required string query, string kind = "all", numeric limit = 5) {
+ var q = trim(arguments.query);
+ var kind = lCase(trim(arguments.kind));
+ if (!len(q)) {
+ throw(type = "Wheels.InvalidArguments", message = "Missing a query: a function name, a phrase, or an id from an earlier result.");
+ }
+ if (!arrayFindNoCase(variables.KINDS, kind)) {
+ throw(type = "Wheels.InvalidArguments", message = "Unknown kind ""#arguments.kind#"": use all, api or guides.");
+ }
+ var limit = max(1, min(variables.MAX_LIMIT, int(val(arguments.limit))));
+ var data = $load();
+ var rv = {"query" = q, "kind" = kind, "limit" = limit, "mode" = "search", "entries" = [], "results" = []};
+
+ if (reFindNoCase("^(api|guides):", q)) {
+ rv.mode = "id";
+ for (var entry in data.entries) {
+ if (compareNoCase(entry.id, q) == 0) {
+ arrayAppend(rv.entries, entry);
+ }
+ }
+ return rv;
+ }
+
+ if (kind != "guides") {
+ var exact = $exactApiMatches(data.entries, q);
+ if (arrayLen(exact)) {
+ rv.mode = "exact";
+ rv.entries = exact;
+ return rv;
+ }
+ }
+
+ rv.results = $search(data, q, kind, limit);
+ return rv;
+ }
+
+ /**
+ * API entries whose name is the query: `findAll`, `findAll()`, or a scope-qualified
+ * `model.findAll` (which must also name one of the entry's scopes).
+ */
+ public array function $exactApiMatches(required array entries, required string query) {
+ var name = reReplace(trim(arguments.query), "\(\s*\)$", "");
+ var scope = "";
+ if (listLen(name, ".") == 2) {
+ scope = listFirst(name, ".");
+ name = listLast(name, ".");
+ }
+ if (!reFind("^[A-Za-z_$][A-Za-z0-9_$]*$", name)) {
+ return [];
+ }
+ var rv = [];
+ for (var entry in arguments.entries) {
+ if (entry.kind != "api" || compareNoCase(entry.name, name) != 0) {
+ continue;
+ }
+ if (len(scope) && !arrayFindNoCase(entry.scopes, scope)) {
+ continue;
+ }
+ arrayAppend(rv, entry);
+ }
+ return rv;
+ }
+
+ private array function $search(required struct data, required string query, required string kind, required numeric limit) {
+ var tokens = $tokens(arguments.query);
+ if (!arrayLen(tokens)) {
+ return [];
+ }
+ var phrase = lCase(arrayToList(tokens, " "));
+ var scored = [];
+ var matchedAll = false;
+ for (var i = 1; i <= arrayLen(arguments.data.entries); i++) {
+ var entry = arguments.data.entries[i];
+ if (arguments.kind != "all" && entry.kind != arguments.kind) {
+ continue;
+ }
+ var hit = $score(entry, arguments.data.searchText[i], tokens, phrase);
+ if (hit.score > 0) {
+ hit.index = i;
+ arrayAppend(scored, hit);
+ if (hit.all) matchedAll = true;
+ }
+ }
+ // Every word present beats some words present: when any entry has them all,
+ // the partial matches are left out.
+ if (matchedAll) {
+ scored = arrayFilter(scored, function(hit) {
+ return hit.all;
+ });
+ }
+ arraySort(scored, function(a, b) {
+ if (a.score != b.score) {
+ return a.score > b.score ? -1 : 1;
+ }
+ return a.index < b.index ? -1 : 1;
+ });
+ var rv = [];
+ for (var hit in scored) {
+ if (arrayLen(rv) >= arguments.limit) {
+ break;
+ }
+ arrayAppend(rv, $result(arguments.data.entries[hit.index], tokens));
+ }
+ return rv;
+ }
+
+ /**
+ * Name and heading matches outweigh body text; a phrase match and every word
+ * present add a bonus.
+ */
+ private struct function $score(required struct entry, required struct text, required array tokens, required string phrase) {
+ var score = 0;
+ var found = 0;
+ for (var token in arguments.tokens) {
+ var inTitle = findNoCase(token, arguments.text.title) > 0;
+ var inBody = findNoCase(token, arguments.text.body) > 0;
+ if (inTitle || inBody) found++;
+ if (arguments.entry.kind == "api") {
+ if (compareNoCase(arguments.entry.name, token) == 0) score += 100;
+ else if (left(lCase(arguments.entry.name), len(token)) == token) score += 40;
+ else if (inTitle) score += 20;
+ } else if (inTitle) {
+ score += 10;
+ }
+ if (inBody) score += min(5, $count(arguments.text.body, token));
+ }
+ if (found == 0) {
+ return {score = 0, all = false};
+ }
+ var all = found == arrayLen(arguments.tokens);
+ if (all) score += 15;
+ if (arrayLen(arguments.tokens) > 1) {
+ if (findNoCase(arguments.phrase, arguments.text.title)) score += 30;
+ else if (findNoCase(arguments.phrase, arguments.text.body)) score += 10;
+ }
+ return {score = score, all = all};
+ }
+
+ private struct function $result(required struct entry, required array tokens) {
+ var e = arguments.entry;
+ if (e.kind == "api") {
+ return {
+ "id" = e.id,
+ "kind" = "api",
+ "title" = e.name & "()",
+ "url" = e.url,
+ "snippet" = $firstLine(e.hint)
+ };
+ }
+ return {
+ "id" = e.id,
+ "kind" = "guides",
+ "title" = compare(e.page, e.heading) == 0 ? e.page : e.page & " › " & e.heading,
+ "url" = e.url,
+ "snippet" = $snippet(e.text, arguments.tokens)
+ };
+ }
+
+ /**
+ * Up to SNIPPET_LENGTH characters of the text, around the first matching word.
+ */
+ public string function $snippet(required string text, required array tokens) {
+ var flat = trim(reReplace(arguments.text, "\s+", " ", "all"));
+ var at = 0;
+ for (var token in arguments.tokens) {
+ var pos = findNoCase(token, flat);
+ if (pos > 0 && (at == 0 || pos < at)) at = pos;
+ }
+ var start = max(1, at - 80);
+ var piece = mid(flat, start, variables.SNIPPET_LENGTH);
+ return (start > 1 ? "…" : "") & piece & (start + variables.SNIPPET_LENGTH <= len(flat) ? "…" : "");
+ }
+
+ private string function $firstLine(required string text) {
+ var line = trim(listFirst(arguments.text, chr(10)));
+ return len(line) > variables.SNIPPET_LENGTH ? left(line, variables.SNIPPET_LENGTH) & "…" : line;
+ }
+
+ /**
+ * Lower-cased words of two or more characters.
+ */
+ public array function $tokens(required string query) {
+ var rv = [];
+ for (var word in listToArray(lCase(arguments.query), " ,;:!?()[]{}""'`|/\" & chr(9))) {
+ word = reReplace(word, "^[^a-z0-9_$]+|[^a-z0-9_$]+$", "", "all");
+ if (len(word) >= 2 && !arrayFind(rv, word)) {
+ arrayAppend(rv, word);
+ }
+ }
+ return rv;
+ }
+
+ private numeric function $count(required string haystack, required string needle) {
+ var n = 0;
+ var pos = findNoCase(arguments.needle, arguments.haystack);
+ while (pos > 0 && n < 5) {
+ n++;
+ pos = findNoCase(arguments.needle, arguments.haystack, pos + len(arguments.needle));
+ }
+ return n;
+ }
+
+ /**
+ * The parsed index plus, per entry, the lower-cased title and body the search
+ * compares against. Cached in the server scope by path and modification time.
+ */
+ private struct function $load() {
+ if (!exists()) {
+ throw(type = "Wheels.DocsIndexMissing", message = "No docs index at #variables.path#.");
+ }
+ var info = getFileInfo(variables.path);
+ var key = "wheelsDocsIndex:" & variables.path;
+ var stamp = info.lastModified & "|" & info.size;
+ if (structKeyExists(server, key) && server[key].stamp == stamp) {
+ return server[key].data;
+ }
+ var data = deserializeJSON(fileRead(variables.path, "utf-8"));
+ if (!isStruct(data) || !structKeyExists(data, "entries") || !isArray(data.entries)) {
+ throw(type = "Wheels.DocsIndexMissing", message = "The docs index at #variables.path# is not a valid index.");
+ }
+ data.searchText = [];
+ for (var entry in data.entries) {
+ if (entry.kind == "api") {
+ arrayAppend(data.searchText, {
+ title = entry.name,
+ body = entry.hint & " " & entry.section & " " & entry.category
+ });
+ } else {
+ arrayAppend(data.searchText, {
+ title = entry.page & " " & entry.heading,
+ body = entry.text
+ });
+ }
+ }
+ server[key] = {stamp = stamp, data = data};
+ return data;
+ }
+
+}
diff --git a/cli/lucli/services/Doctor.cfc b/cli/lucli/services/Doctor.cfc
index 764fcb715b..0b18f8b6f4 100644
--- a/cli/lucli/services/Doctor.cfc
+++ b/cli/lucli/services/Doctor.cfc
@@ -813,7 +813,7 @@ component {
recs,
"Install or reinstall the Wheels CLI with a complete distribution, "
& "or set WHEELS_FRAMEWORK_PATH to a vendor/wheels/ directory. "
- & "See: https://guides.wheels.dev/v4-0-0/start-here/installing/"
+ & "See: " & new GuidesLink().link("start-here/installing/")
);
} else if (findNoCase("Missing required directory", combined)) {
arrayAppend(recs, "Run 'wheels new' to scaffold a complete project structure");
diff --git a/cli/lucli/services/FrameworkUpgrader.cfc b/cli/lucli/services/FrameworkUpgrader.cfc
index 60221e1e5d..83f4759394 100644
--- a/cli/lucli/services/FrameworkUpgrader.cfc
+++ b/cli/lucli/services/FrameworkUpgrader.cfc
@@ -3,8 +3,8 @@
*
* Replaces the contents of the app's vendor/wheels/ with a fresh copy
* of the framework (typically the CLI's bundled vendor/wheels/). The
- * old vendor/wheels/ is renamed to vendor/wheels.bak- by
- * default so a mistake is recoverable with a single mv.
+ * old vendor/wheels/ is renamed to .wheels/backups/wheels.bak-
+ * in the project by default so a mistake is recoverable with a single mv.
*
* Isolated from Module.cfc so tests can exercise the file-level
* behavior without the LuCLI runtime (mirrors FrameworkInstaller.cfc).
@@ -158,7 +158,8 @@ component {
* Replace `vendorDir` with the contents of `sourceDir`. Both must be
* Wheels framework directories (or vendorDir may be absent — fresh
* install). When `doBackup` is true the existing vendorDir is renamed
- * to `.bak-` before the copy. Callers that want
+ * to `/.wheels/backups/wheels.bak-` before the copy
+ * (see reserveBackupPath). Callers that want
* to announce the backup destination BEFORE invoking the swap can
* reserve it via reserveBackupPath() and pass it in as `backupPath` —
* the announced path and the actual backup are then guaranteed to
@@ -208,6 +209,7 @@ component {
if (arguments.doBackup) {
result.backupDir = len(trim(arguments.backupPath)) ? arguments.backupPath : reserveBackupPath(arguments.vendorDir);
+ $prepareBackupsDir(result.backupDir);
$renameDirectory(arguments.vendorDir, result.backupDir);
} else {
directoryDelete(arguments.vendorDir, true);
@@ -241,23 +243,163 @@ component {
}
/**
- * Build a unique backup path of the form .bak--,
- * appending a counter if a collision exists (concurrent or rapid re-runs).
- * Public so callers can announce the exact backup destination (and the
- * recovery one-liner) before invoking applyUpgrade — pass the reserved
- * path back in via `backupPath` so the plan and the swap can't disagree.
+ * Build a unique backup path of the form
+ * /.wheels/backups/wheels.bak--, where
+ * holds vendor/ (the parent of vendorDir's parent), appending a counter if a
+ * collision exists (concurrent or rapid re-runs). Never inside vendor/: the
+ * framework loads every vendor/ folder as a package, so a backup there
+ * logs a skipped-package error on every boot, and it would be committed
+ * along with vendor/. Same filesystem as vendorDir, so the backup is still
+ * a single rename. Public so callers can announce the exact backup
+ * destination (and the recovery one-liner) before invoking applyUpgrade —
+ * pass the reserved path back in via `backupPath` so the plan and the swap
+ * can't disagree.
*/
public string function reserveBackupPath(required string vendorDir) {
+ var backupsDir = backupsDirFor(arguments.vendorDir);
var ts = dateFormat(now(), "yyyymmdd") & "-" & timeFormat(now(), "HHmmss");
- var candidate = arguments.vendorDir & ".bak-" & ts;
+ var candidate = backupsDir & "/wheels.bak-" & ts;
var counter = 1;
while (directoryExists(candidate)) {
- candidate = arguments.vendorDir & ".bak-" & ts & "-" & counter;
+ candidate = backupsDir & "/wheels.bak-" & ts & "-" & counter;
counter++;
}
return candidate;
}
+ /**
+ * The backups directory for a /vendor/wheels target:
+ * /.wheels/backups.
+ */
+ public string function backupsDirFor(required string vendorDir) {
+ var vendorParent = getDirectoryFromPath(reReplace(arguments.vendorDir, "[\\/]+$", ""));
+ var project = getDirectoryFromPath(reReplace(vendorParent, "[\\/]+$", ""));
+ return project & ".wheels/backups";
+ }
+
+ /**
+ * Create the backup's parent directory and a .gitignore that ignores
+ * everything in it, so a backup is never committed even when the app's
+ * own .gitignore doesn't cover .wheels/.
+ */
+ private void function $prepareBackupsDir(required string backupDir) {
+ var backupsDir = getDirectoryFromPath(reReplace(arguments.backupDir, "[\\/]+$", ""));
+ try {
+ if (!directoryExists(backupsDir)) {
+ directoryCreate(backupsDir, true, true);
+ }
+ var ignoreFile = backupsDir & ".gitignore";
+ if (!fileExists(ignoreFile)) {
+ fileWrite(ignoreFile, "## Framework backups made by `wheels upgrade apply`. Never commit them." & chr(10) & "*" & chr(10));
+ }
+ } catch (any e) {
+ // Same contract as a refused rename: nothing has changed yet.
+ throw(
+ type = "Wheels.FrameworkUpgrader.RenameFailed",
+ message = "Could not prepare the backup directory " & backupsDir & " (" & e.message & "), so nothing was changed: vendor/wheels/ is intact. If vendor/ is a separate mount (a Docker volume, for example), copy vendor/wheels/ somewhere yourself, then run `wheels upgrade apply --nobackup`."
+ );
+ }
+ }
+
+ /**
+ * Read the wheels-core pins from a CommandBox box.json. Returns
+ * {declared, value, pins, error}: `pins` lists {section, value} for each of
+ * `dependencies` and `devDependencies` that names wheels-core, `declared`
+ * is true when there is at least one, and `value` is the first one's
+ * value. `error` is non-empty when the file exists but isn't a JSON object
+ * (a later `box install` couldn't read it either, so apply refuses before
+ * touching anything).
+ */
+ public struct function readBoxJsonCorePin(required string boxJsonPath) {
+ var rv = {declared: false, value: "", pins: [], error: ""};
+ if (!fileExists(arguments.boxJsonPath)) {
+ return rv;
+ }
+ var raw = fileRead(arguments.boxJsonPath, "utf-8");
+ if (!isJSON(raw) || !isStruct(deserializeJSON(raw))) {
+ rv.error = "box.json is not valid JSON, so its wheels-core version can't be updated: " & arguments.boxJsonPath;
+ return rv;
+ }
+ var box = deserializeJSON(raw);
+ for (var section in ["dependencies", "devDependencies"]) {
+ if (structKeyExists(box, section) && isStruct(box[section]) && structKeyExists(box[section], "wheels-core")) {
+ var value = isSimpleValue(box[section]["wheels-core"]) ? box[section]["wheels-core"] : "";
+ arrayAppend(rv.pins, {section: section, value: value});
+ if (!rv.declared) {
+ rv.declared = true;
+ rv.value = value;
+ }
+ }
+ }
+ return rv;
+ }
+
+ /**
+ * The box.json value that pins wheels-core to `newVersion`, or "" when the
+ * old value isn't one apply rewrites. Only an exact version or a ^ / ~
+ * range is rewritten: anything else (4.x, >=4.0.0, a channel such as be,
+ * a forgebox:...@ spec) says something about the user's intent that a bare
+ * version would lose, so the caller warns instead. A release version keeps
+ * the ^ or ~; a prerelease such as 4.2.0-snapshot.2867 is pinned exactly,
+ * because a range could let `box install` resolve a different build.
+ */
+ public string function boxJsonCorePinFor(required string oldValue, required string newVersion) {
+ if (!reFind("^\s*[\^~]?\d+(\.\d+){1,2}([-+][0-9A-Za-z.+-]+)?\s*$", arguments.oldValue)) {
+ return "";
+ }
+ if (find("-", arguments.newVersion)) {
+ return arguments.newVersion;
+ }
+ var prefix = reFind("^\s*[\^~]", arguments.oldValue) ? left(trim(arguments.oldValue), 1) : "";
+ return prefix & arguments.newVersion;
+ }
+
+ /**
+ * Rewrite the wheels-core value inside box.json's `dependencies` and
+ * `devDependencies` objects (or only `section`, when given) to `newValue`.
+ * Everything else stays byte for
+ * byte, including an `installPaths` entry that also names wheels-core:
+ * re-serializing would reorder and reformat the user's file. Returns the
+ * number of values rewritten.
+ */
+ public numeric function writeBoxJsonCorePin(required string boxJsonPath, required string newValue, string section = "") {
+ var raw = fileRead(arguments.boxJsonPath, "utf-8");
+ var state = {count: 0};
+ var sections = len(arguments.section) ? [arguments.section] : ["dependencies", "devDependencies"];
+ for (var sectionName in sections) {
+ var match = reFind('"' & sectionName & '"\s*:\s*\{[^{}]*\}', raw, 1, true);
+ if (match.pos[1] > 0) {
+ var block = $repinBlock(mid(raw, match.pos[1], match.len[1]), arguments.newValue, state);
+ var head = match.pos[1] > 1 ? left(raw, match.pos[1] - 1) : "";
+ raw = head & block & mid(raw, match.pos[1] + match.len[1], len(raw));
+ }
+ }
+ if (state.count) {
+ fileWrite(arguments.boxJsonPath, raw, "utf-8");
+ }
+ return state.count;
+ }
+
+ /**
+ * Replace the value of each "wheels-core": "" pair in one JSON
+ * object's text, by position (a regex replacement string would read
+ * "\1" followed by a version's digits as a two-digit group reference).
+ * Adds the number of pairs replaced to state.count.
+ */
+ private string function $repinBlock(required string block, required string newValue, required struct state) {
+ var pair = '("wheels-core"\s*:\s*")[^"]*"';
+ var out = "";
+ var pos = 1;
+ var match = reFind(pair, arguments.block, pos, true);
+ while (match.pos[1] > 0) {
+ out &= mid(arguments.block, pos, match.pos[2] + match.len[2] - pos) & arguments.newValue & '"';
+ pos = match.pos[1] + match.len[1];
+ arguments.state.count++;
+ match = reFind(pair, arguments.block, pos, true);
+ }
+ return out & mid(arguments.block, pos, len(arguments.block));
+ }
+
/**
* Atomic-ish directory rename via Java's File.renameTo. CFML's
* directoryRename isn't available on every engine the CLI may host
@@ -272,7 +414,7 @@ component {
if (!src.renameTo(dst)) {
throw(
type = "Wheels.FrameworkUpgrader.RenameFailed",
- message = "Failed to rename " & arguments.fromPath & " to " & arguments.toPath & ". The backup directory must live on the same filesystem as vendor/wheels/."
+ message = "Failed to rename " & arguments.fromPath & " to " & arguments.toPath & ", so nothing was changed: vendor/wheels/ is intact. The backup must be on the same filesystem as vendor/wheels/. If vendor/ is a separate mount (a Docker volume, for example), copy vendor/wheels/ somewhere yourself, then run `wheels upgrade apply --nobackup`."
);
}
}
diff --git a/cli/lucli/services/GuidesLink.cfc b/cli/lucli/services/GuidesLink.cfc
new file mode 100644
index 0000000000..f9e6d61791
--- /dev/null
+++ b/cli/lucli/services/GuidesLink.cfc
@@ -0,0 +1,70 @@
+/**
+ * Builds guides.wheels.dev links for CLI output (#3931); the same logic as
+ * vendor/wheels/GuidesLink.cfc. Callers pass the project's framework version
+ * (or an upgrade target); "" or an unparsable version gets the latest tree.
+ *
+ * The segment (`v4-2-0`) is clamped to the guide trees that exist: never older
+ * than 4.0, never newer than `variables.latest`, because a version whose guides
+ * aren't cut yet would 404. GuidesLinkSpec fails when any tree under
+ * web/sites/guides/src/content/docs/ is newer than `latest`, including one still
+ * in development: bump it here and in vendor/wheels/GuidesLink.cfc when a
+ * version's guides tree is cut.
+ */
+component {
+
+ variables.latest = "4.2";
+ variables.oldest = "4.0";
+
+ /**
+ * The full URL for a page fixed to one guide tree, for a page that exists
+ * only there: link() clamps to the latest released tree, where such a page
+ * would 404. Every pinned page is listed, with its reason, in
+ * vendor/wheels/tests/specs/GuidesLinkSpec.cfc ($pinnedPages()).
+ */
+ public string function pinned(required string treeSegment, required string path) {
+ return "https://guides.wheels.dev/" & arguments.treeSegment & "/" & arguments.path;
+ }
+
+ /**
+ * The full URL for a guides path, e.g. link("upgrading/3x-to-4x/").
+ */
+ public string function link(required string path, string version = "") {
+ return "https://guides.wheels.dev/" & segment(arguments.version) & "/" & arguments.path;
+ }
+
+ /**
+ * The guides version segment (`v4-2-0`) for a framework version.
+ */
+ public string function segment(string version = "") {
+ local.match = ReFind("^([0-9]+)\.([0-9]+)", arguments.version, 1, true);
+ local.target = variables.latest;
+ // Major version 0 is an unstamped dev build (0.0.0-dev): treat it as unknown.
+ if (ArrayLen(local.match.pos) >= 3 && local.match.pos[1] > 0 && Val(Mid(arguments.version, local.match.pos[2], local.match.len[2])) > 0) {
+ local.target = Mid(arguments.version, local.match.pos[2], local.match.len[2])
+ & "." & Mid(arguments.version, local.match.pos[3], local.match.len[3]);
+ if ($compareMinor(local.target, variables.latest) > 0) {
+ local.target = variables.latest;
+ } else if ($compareMinor(local.target, variables.oldest) < 0) {
+ local.target = variables.oldest;
+ }
+ }
+ return "v" & ListFirst(local.target, ".") & "-" & ListLast(local.target, ".") & "-0";
+ }
+
+ /**
+ * The newest guides version this build links to (major.minor).
+ */
+ public string function latestVersion() {
+ return variables.latest;
+ }
+
+ /**
+ * -1, 0 or 1 comparing two major.minor strings numerically.
+ */
+ public numeric function $compareMinor(required string a, required string b) {
+ local.a = ListFirst(arguments.a, ".") * 1000 + ListLast(arguments.a, ".");
+ local.b = ListFirst(arguments.b, ".") * 1000 + ListLast(arguments.b, ".");
+ return local.a == local.b ? 0 : (local.a > local.b ? 1 : -1);
+ }
+
+}
diff --git a/cli/lucli/services/Scaffold.cfc b/cli/lucli/services/Scaffold.cfc
index 5b9351cb57..76ac0a66c6 100644
--- a/cli/lucli/services/Scaffold.cfc
+++ b/cli/lucli/services/Scaffold.cfc
@@ -26,17 +26,17 @@ component {
}
+ /** Creates dir inside the project only; an existing symlinked parent pointing outside is refused. */
+ private void function $ensureDir(required string dir) {
+ new modules.wheels.services.GeneratorPaths().ensureDirectoryInside(variables.projectRoot, arguments.dir);
+ }
+
/**
* Dry-run-aware file writer. `wheels generate --dry-run` sets
* request.$wheelsGenerateDryRun; writes are then recorded (for the
* caller to print) and skipped. Creates the parent directory on the
* real path.
*/
- /** Creates dir inside the project only; an existing symlinked parent pointing outside is refused. */
- private void function $ensureDir(required string dir) {
- new modules.wheels.services.GeneratorPaths().ensureDirectoryInside(variables.projectRoot, arguments.dir);
- }
-
private string function $write(required string path, required string content) {
new modules.wheels.services.GeneratorPaths().assertInside(variables.projectRoot, arguments.path);
if (request.$wheelsGenerateDryRun ?: false) {
@@ -342,7 +342,11 @@ component {
}
}
if (!hasFK) {
- arrayAppend(props, {name: fkName, type: "integer"});
+ // A belongsTo foreign key is required by default, so the migration
+ // makes it NOT NULL and the model validates its presence — the two
+ // halves agree (a user can still relax it by listing the column
+ // explicitly as `:integer:optional`).
+ arrayAppend(props, {name: fkName, type: "integer", required: true});
}
}
}
@@ -702,22 +706,26 @@ component {
if (findNoCase('.resources(name="' & resourceName & '", except="new,edit")', content)) return false;
if (findNoCase(".resources(name='#resourceName#', except='new,edit')", content)) return false;
- // Check if an API namespace block already exists
- if (findNoCase('.namespace("api")', content) || findNoCase(".namespace('api')", content)) {
- // Append inside the existing namespace block — find the .end() that closes it
- var apiNsPos = findNoCase('.namespace("api")', content);
- if (apiNsPos == 0) apiNsPos = findNoCase(".namespace('api')", content);
-
- // Find the matching .end() after the namespace declaration
- var afterNs = mid(content, apiNsPos, len(content));
- var endPos = findNoCase(".end()", afterNs);
+ // Join an existing .namespace("api") block, unless it sits inside
+ // another generator's marked block (e.g. `wheels generate auth`'s
+ // wheels:generate-auth:routes:begin/end): that block is regenerated
+ // on --force, which would drop this route.
+ var apiNsPos = $findApiNamespace(content);
+ if (apiNsPos > 0) {
+ // Find the .end() that closes the namespace
+ var endPos = findNoCase(".end()", content, apiNsPos);
if (endPos > 0) {
- // Detect indentation of the namespace line
+ // Insert a new line at the start of the .end() line, indented
+ // one level deeper than the namespace, so .end() keeps its own
+ // indentation.
var nsIndent = detectIndent(content, apiNsPos);
var resourceLine = nsIndent & t & '.resources(name="#resourceName#", except="new,edit")';
- var insertPos = apiNsPos + endPos - 2;
- var before = mid(content, 1, insertPos);
- var after = mid(content, insertPos + 1, len(content));
+ var lineStart = endPos;
+ while (lineStart > 1 && mid(content, lineStart - 1, 1) != nl) {
+ lineStart--;
+ }
+ var before = lineStart > 1 ? left(content, lineStart - 1) : "";
+ var after = mid(content, lineStart, len(content));
content = before & resourceLine & nl & after;
$write(routesPath, content);
return true;
@@ -789,7 +797,7 @@ component {
/**
* Generate a complete authentication scaffold over the wheels.auth
- * primitives (issue ##3155): User model with PBKDF2 password hashing,
+ * primitives (issue ##3155): User model with bcrypt password hashing,
* sessions/passwords/registrations controllers + views (session
* strategy), or an api/Sessions controller (token/jwt strategies),
* a create-table migration, marked route/service/strategy blocks
@@ -844,6 +852,12 @@ component {
ctx.apiTokenColumn = strategyName == "token"
? t & t & t & t & 't.string(columnNames="apiTokenDigest", allowNull=true, limit=64);' & nl
: "";
+ // The token strategy looks every request's token up by its digest.
+ // Not unique: most rows have no token, and SQL Server counts NULLs
+ // as duplicates in a unique index.
+ ctx.apiTokenIndex = strategyName == "token"
+ ? t & t & t & t & 'addIndex(table="' & tableName & '", columnNames="apiTokenDigest");' & nl
+ : "";
// Emits `#linkTo(...)#` into the login view (## collapses to # in this
// CFC's string literal; the .txt templates are raw and keep single #).
ctx.registrationLink = withRegistration
@@ -1249,6 +1263,38 @@ component {
return arrayToList(indented, chr(10));
}
+ /**
+ * Position of the first `.namespace("api")` (either quote style) in a
+ * routes file that is not inside a generator's marked block
+ * (`// wheels:generate-:routes:begin` ... `:routes:end`), or 0.
+ */
+ public numeric function $findApiNamespace(required string content) {
+ var found = reFindNoCase("\.namespace\(\s*[""']api[""']\s*\)", arguments.content, 1, true);
+ while (found.pos[1] > 0) {
+ var before = left(arguments.content, found.pos[1] - 1);
+ var opened = $lastMatch(before, ":routes:begin");
+ var closed = $lastMatch(before, ":routes:end");
+ if (opened == 0 || closed > opened) {
+ return found.pos[1];
+ }
+ found = reFindNoCase("\.namespace\(\s*[""']api[""']\s*\)", arguments.content, found.pos[1] + found.len[1], true);
+ }
+ return 0;
+ }
+
+ /**
+ * Position of the last occurrence of `needle` in `text`, or 0.
+ */
+ public numeric function $lastMatch(required string text, required string needle) {
+ var at = 0;
+ var next = findNoCase(arguments.needle, arguments.text);
+ while (next > 0) {
+ at = next;
+ next = findNoCase(arguments.needle, arguments.text, next + 1);
+ }
+ return at;
+ }
+
/**
* Detect the indentation used before a given position in content
*/
@@ -1292,11 +1338,24 @@ component {
var cfType = mapToWheelsType(prop.type);
var params = "columnNames='#prop.name#'";
- // No `default=''` — the migrator hardener (S14) rejects empty-string
- // defaults on string/text/char columns, and for numeric/temporal
- // types `default=''` just rendered DEFAULT NULL anyway. Omitting the
- // default yields NULL for nullable columns, which is the same thing.
- params &= ", allowNull=" & (structKeyExists(prop, "required") && prop.required ? "false" : "true");
+ // Required by default (allowNull=false); only an explicit `:optional`
+ // column (required=false) is nullable. A prop with no `required` key is
+ // treated as required, the same default the model's validatesPresenceOf
+ // applies, so migration and model agree.
+ params &= ", allowNull=" & ((!structKeyExists(prop, "required") || prop.required) ? "false" : "true");
+ // A `name:type=value` property carries an explicit DEFAULT. Only emit it
+ // when non-empty: the migrator hardener (S14) rejects empty-string
+ // defaults on string/text/char columns, and for numeric/temporal types
+ // `default=''` just rendered DEFAULT NULL anyway, so an omitted default
+ // yields the same NULL for a nullable column.
+ if (structKeyExists(prop, "default") && len(prop.default)) {
+ // The default is written into a generated CFML migration, so it must
+ // be a literal string: double each "##" (CFML interpolation delimiter)
+ // so "invoice#" / "#1+1#" stay literal, and double single quotes for
+ // the quoted attribute. ("##" in this source is one literal #.)
+ var safeDefault = replace(replace(prop.default, "##", "####", "all"), "'", "''", "all");
+ params &= ", default='" & safeDefault & "'";
+ }
params &= $columnSizeParams(prop, cfType);
c &= t & t & t & t & "t.#cfType#(#params#);" & nl;
diff --git a/cli/lucli/services/ServerRegistry.cfc b/cli/lucli/services/ServerRegistry.cfc
index 136fb1d5f8..8101038fd0 100644
--- a/cli/lucli/services/ServerRegistry.cfc
+++ b/cli/lucli/services/ServerRegistry.cfc
@@ -99,6 +99,32 @@ component {
return rv;
}
+ /**
+ * The registered server that is running on `port`, as
+ * `{name, projectPath}`; both are "" when no live registration records
+ * that port. Reads each registration's `server.pid` (":") and
+ * checks the pid is alive, as inspect() does.
+ */
+ public struct function registrationOnPort(required numeric port) {
+ var rv = {name: "", projectPath: ""};
+ var serversDir = variables.lucliHome & "/servers";
+ if (!len(variables.lucliHome) || !directoryExists(serversDir)) return rv;
+ for (var name in directoryList(serversDir, false, "name")) {
+ var regDir = serversDir & "/" & name;
+ if (!fileExists(regDir & "/server.pid")) continue;
+ var parts = listToArray(trim(fileRead(regDir & "/server.pid")), ":");
+ if (
+ arrayLen(parts) == 2 && isNumeric(parts[1]) && isNumeric(parts[2])
+ && parts[2] == arguments.port && $isProcessAlive(parts[1])
+ ) {
+ rv.name = name;
+ rv.projectPath = fileExists(regDir & "/.project-path") ? trim(fileRead(regDir & "/.project-path")) : "";
+ return rv;
+ }
+ }
+ return rv;
+ }
+
/**
* Port of the project's OWN running Lucee server, or 0. Ownership is
* proven by verifyOwnServer(): registration, live server process AND the
@@ -363,6 +389,28 @@ component {
return "";
}
+ /**
+ * Name of a RUNNING registration that belongs to this project, checking the
+ * name serverNameFor() derives first and then every other registration
+ * whose `.project-path` is this project. After a lucee.json `name` change
+ * the server can still run under its old name. "" when none is alive.
+ */
+ public string function aliveRegistrationFor(required string projectRoot) {
+ var name = serverNameFor(arguments.projectRoot);
+ if (len(name)) {
+ var primary = inspect(name, arguments.projectRoot);
+ if (primary.ours && primary.alive) return name;
+ }
+ var serversDir = variables.lucliHome & "/servers";
+ if (!len(variables.lucliHome) || !directoryExists(serversDir)) return "";
+ for (var entry in directoryList(serversDir, false, "name")) {
+ if (entry == name) continue;
+ var reg = inspect(entry, arguments.projectRoot);
+ if (reg.ours && reg.alive) return entry;
+ }
+ return "";
+ }
+
/**
* Linux and macOS only; the servers dir and the registration dir are
* private to this user. On Linux the mode bits are the whole story (a
@@ -878,25 +926,37 @@ component {
return {known: true, pids: pids};
}
- private struct function $runCommand(required array cmdArgs) {
+ /**
+ * Runs an introspection command (lsof, netstat, ls) and returns its exit code
+ * and output. Bounded: output goes to a temp file (no pipe to fill or block on)
+ * and a command still running after `timeoutSeconds` is killed and reported as
+ * not run, the same as a missing command, so a stuck lsof can't hang the CLI.
+ */
+ private struct function $runCommand(required array cmdArgs, numeric timeoutSeconds = 15) {
+ var outFile = getTempFile(getTempDirectory(), "wheels-cmd");
try {
var pb = createObject("java", "java.lang.ProcessBuilder").init(arguments.cmdArgs);
pb.redirectErrorStream(true);
+ pb.redirectOutput(createObject("java", "java.io.File").init(outFile));
var proc = pb.start();
- var reader = createObject("java", "java.io.BufferedReader").init(
- createObject("java", "java.io.InputStreamReader").init(proc.getInputStream(), "UTF-8")
+ var finished = proc.waitFor(
+ javaCast("long", arguments.timeoutSeconds),
+ createObject("java", "java.util.concurrent.TimeUnit").SECONDS
);
- var sb = createObject("java", "java.lang.StringBuilder").init();
- var line = reader.readLine();
- while (!isNull(line)) {
- sb.append(line);
- sb.append(chr(10));
- line = reader.readLine();
+ if (!finished) {
+ proc.destroyForcibly();
+ return {ran: false, exitCode: -1, output: ""};
}
- proc.waitFor();
- return {ran: true, exitCode: proc.exitValue(), output: sb.toString()};
+ return {ran: true, exitCode: proc.exitValue(), output: fileRead(outFile, "utf-8")};
} catch (any e) {
return {ran: false, exitCode: -1, output: ""};
+ } finally {
+ // Best effort: on Windows a file the killed process still holds can't
+ // be deleted yet, and that must not replace the result.
+ try {
+ if (fileExists(outFile)) fileDelete(outFile);
+ } catch (any cleanupError) {
+ }
}
}
diff --git a/cli/lucli/services/Templates.cfc b/cli/lucli/services/Templates.cfc
index 3d22c9cd24..8e3d39fe76 100644
--- a/cli/lucli/services/Templates.cfc
+++ b/cli/lucli/services/Templates.cfc
@@ -801,7 +801,7 @@ component {
processed = replace(processed, '=model("|ObjectNameSingularC|").findByKey(params.key);', '=model("|ObjectNameSingularC|").findByKey(key=params.key, #includeParam#);', 'all');
// API controller template (ApiControllerContent.txt) assigns into the
// `local.` scope with spaces around `=` and the lowercase placeholder.
- processed = replace(processed, 'local.|ObjectNamePlural| = model("|ObjectNameSingular|").findAll();', 'local.|ObjectNamePlural| = model("|ObjectNameSingular|").findAll(#includeParam#);', 'all');
+ processed = replace(processed, 'local.|ObjectNamePlural| = model("|ObjectNameSingular|").findAll(returnAs="objects");', 'local.|ObjectNamePlural| = model("|ObjectNameSingular|").findAll(returnAs="objects", #includeParam#);', 'all');
processed = replace(processed, 'local.|ObjectNameSingular| = model("|ObjectNameSingular|").findByKey(params.key);', 'local.|ObjectNameSingular| = model("|ObjectNameSingular|").findByKey(key=params.key, #includeParam#);', 'all');
return processed;
diff --git a/cli/lucli/services/deploy/cli/DeployAppCli.cfc b/cli/lucli/services/deploy/cli/DeployAppCli.cfc
index a62fddc9e9..8b75272c09 100644
--- a/cli/lucli/services/deploy/cli/DeployAppCli.cfc
+++ b/cli/lucli/services/deploy/cli/DeployAppCli.cfc
@@ -48,9 +48,11 @@ component {
}
public string function details(required struct opts) {
+ // Kamal's app details needs no version: without one, list every
+ // container of the role; with one, inspect that container.
var n = $forEachHost(arguments.opts, function(cmds, role, version) {
- return cmds.status(role, version);
- }, {collect: true});
+ return len(version) ? cmds.status(role, version) : cmds.role_containers(role);
+ }, {versionOptional: true, collect: true});
return $renderResult(arguments.opts, "Collected app details on " & n & " host(s)");
}
@@ -75,7 +77,7 @@ component {
container: arguments.opts.container ?: ""
};
var n = $forEachHost(arguments.opts, function(cmds, role, version) {
- return cmds.logs(logOpts);
+ return cmds.logs(logOpts, role);
}, {versionOptional: true, collect: true});
return $renderResult(arguments.opts, "Tailed app logs on " & n & " host(s)");
}
@@ -83,14 +85,14 @@ component {
public string function live(required struct opts) {
var n = $forEachHost(arguments.opts, function(cmds, role, version) {
return cmds.live(role, version);
- });
+ }, {versionOptional: true});
return $renderResult(arguments.opts, "Marked app live on " & n & " host(s)");
}
public string function maintenance(required struct opts) {
var n = $forEachHost(arguments.opts, function(cmds, role, version) {
return cmds.maintenance(role, version);
- });
+ }, {versionOptional: true});
return $renderResult(arguments.opts, "Put app into maintenance mode on " & n & " host(s)");
}
@@ -120,7 +122,7 @@ component {
var versionOptional = arguments.flags.versionOptional ?: false;
if (!versionOptional && !len(version)) {
throw(type="DeployAppCli.MissingVersion",
- message="This verb requires --version (e.g. --version=v1.2.3). On older wrappers that pre-date the picocli rewrite, pass --release instead.");
+ message="This verb needs the release to act on: pass --release= (e.g. --release=v1.2.3). The CLI runtime keeps --version for itself.");
}
var dryRun = arguments.opts.dryRun ?: false;
// collect=true marks a read verb: remote stdout is gathered into
@@ -128,6 +130,7 @@ component {
var collect = arguments.flags.collect ?: false;
var appCmds = new modules.wheels.services.deploy.commands.AppCommands(cfg);
var roleFilter = arguments.opts.role ?: "";
+ $assertKnownRole(cfg, roleFilter);
var hostCount = 0;
// env.secret delivery (#2957): container-(re)creating verbs opt in
@@ -285,4 +288,21 @@ component {
});
return ctx.lines;
}
+
+ /**
+ * A `--role` that names no role in deploy.yml matched no host, so the verb did nothing and
+ * exited 0 (#4417). It now fails and names the roles there are.
+ */
+ public void function $assertKnownRole(required any cfg, required string roleFilter) {
+ if (!len(arguments.roleFilter)) return;
+ var names = [];
+ for (var role in arguments.cfg.roles()) {
+ if (role.name() == arguments.roleFilter) return;
+ arrayAppend(names, role.name());
+ }
+ throw(
+ type = "DeployAppCli.UnknownRole",
+ message = "Unknown role '#arguments.roleFilter#'. deploy.yml defines: #arrayToList(names, ", ")#."
+ );
+ }
}
diff --git a/cli/lucli/services/deploy/cli/DeployMainCli.cfc b/cli/lucli/services/deploy/cli/DeployMainCli.cfc
index a3481c86ca..9788ebf178 100644
--- a/cli/lucli/services/deploy/cli/DeployMainCli.cfc
+++ b/cli/lucli/services/deploy/cli/DeployMainCli.cfc
@@ -48,17 +48,91 @@ component {
var rolesMap = $roleHosts(cfg);
// Same output path as every other deploy verb: config warnings first,
// and any value resolved from .kamal/secrets redacted.
+ // Secret VALUES never appear: env.secret and registry.password list
+ // only the key names that .kamal/secrets supplies.
+ var accessories = {};
+ for (var acc in cfg.accessories()) {
+ accessories[acc.name()] = {
+ "image": acc.image(),
+ "hosts": acc.hosts(),
+ "port": acc.port(),
+ "volumes": acc.volumes()
+ };
+ }
+ var proxy = cfg.proxy();
+ var ssh = cfg.ssh();
+ var builder = cfg.builder();
return new modules.wheels.services.deploy.lib.SecretRedaction().render(yaml.dump({
- service: cfg.service(),
- image: cfg.image(),
- servers: rolesMap,
- registry: {
- server: cfg.registry().server(),
- username: cfg.registry().username()
+ "service": cfg.service(),
+ "image": cfg.image(),
+ "servers": rolesMap,
+ "registry": {
+ "server": cfg.registry().server(),
+ "username": cfg.registry().username(),
+ "password": $registryPasswordKeys(cfg)
+ },
+ "proxy": {
+ "host": proxy.host(),
+ "ssl": proxy.ssl(),
+ "app_port": proxy.appPort(),
+ "healthcheck": proxy.healthcheck()
+ },
+ "env": {
+ "clear": cfg.env().clear(),
+ "secret": cfg.env().secret()
+ },
+ "volumes": cfg.volumes(),
+ "accessories": accessories,
+ "ssh": {
+ "user": ssh.user(),
+ "port": ssh.port()
+ },
+ "builder": {
+ "arch": builder.arch(),
+ "context": builder.context(),
+ "dockerfile": builder.dockerfile()
}
}));
}
+ /**
+ * registry.password as config() prints it. Entries are meant to be the
+ * NAMES of keys in .kamal/secrets (REGISTRY_PASSWORD). An entry is shown
+ * only when .kamal/secrets defines that key; anything else is probably
+ * the password itself (a ghp_… token or a plain password look like names
+ * too), so it is masked and a warning says to move it into a secret.
+ * Without a resolver, only a strict UPPER_SNAKE name that isn't shaped
+ * like a GitHub token is shown.
+ */
+ private array function $registryPasswordKeys(required any cfg) {
+ var shown = [];
+ var masked = false;
+ var resolver = variables.loader.secretResolver();
+ for (var entry in arguments.cfg.registry().password()) {
+ var isKey = false;
+ if (isSimpleValue(entry) && len(entry)) {
+ if (isObject(resolver)) {
+ isKey = reFind("^[A-Za-z_][A-Za-z0-9_]*$", entry) && resolver.has(entry);
+ } else {
+ isKey = reFind("^[A-Z_][A-Z0-9_]*$", entry) && !reFindNoCase("^(ghp|gho|ghu|ghs|ghr|github_pat)_", entry);
+ }
+ }
+ if (isKey) {
+ arrayAppend(shown, entry);
+ } else {
+ arrayAppend(shown, "");
+ masked = true;
+ }
+ }
+ if (masked) {
+ new modules.wheels.services.deploy.lib.SecretRedaction().addWarning(
+ "registry.password lists a value that is not a key in .kamal/secrets; it is hidden here. "
+ & "List the NAME of a key in .kamal/secrets (e.g. REGISTRY_PASSWORD) and put the password there."
+ );
+ }
+ return shown;
+ }
+
public string function deploy(required struct opts) {
arrayClear(variables.dryRunBuffer);
return $deploy(arguments.opts);
@@ -97,6 +171,10 @@ component {
// Port the app listens on inside the container — the kamal-proxy
// --target. Resolved from proxy.app_port (default 80); see #3089.
var appPort = cfg.proxy().appPort();
+ // The one host that migrates the database (#4063), resolved before the
+ // lock so a bad migrate: block fails without stranding it. {} when
+ // deploy.yml has no migrate: block.
+ var migrateTarget = $migrateTarget(cfg);
// env.secret delivery (#2957): render the env-file content ONCE,
// before the lock or any remote call, so an unresolvable secret
@@ -152,8 +230,22 @@ component {
$dispatch(proxyHosts, proxy.start_or_run(), dryRun);
}
- for (var role in cfg.roles()) {
- for (var host in role.hosts()) {
+ // Kamal's rolling boot (#3925): with a boot: block, each
+ // role's hosts boot in batches of boot.limit, boot.wait
+ // seconds apart. Without one, hosts boot back to back.
+ var bootConfigured = structKeyExists(cfg.raw(), "boot");
+ var boot = cfg.boot();
+ // With migrate: (#4063) the migrate host boots and cuts over
+ // first, so its health check gates every other host.
+ for (var role in $migrateOrderedRoles(cfg.roles(), migrateTarget)) {
+ var roleHosts = $migrateOrderedHosts(role, migrateTarget);
+ var batchSize = bootConfigured ? boot.batchSize(arrayLen(roleHosts)) : arrayLen(roleHosts);
+ var hostNumber = 0;
+ for (var host in roleHosts) {
+ hostNumber++;
+ if (bootConfigured && hostNumber > 1 && (hostNumber - 1) % batchSize == 0) {
+ $bootPause(boot.wait(), dryRun, batchSize);
+ }
// A same-version redeploy would hit a guaranteed
// docker run --name conflict otherwise (#2957 DEP-11a).
$dispatch([host], app.remove_conflicting(role, ver), dryRun);
@@ -168,16 +260,26 @@ component {
dryRun
);
}
- $dispatch([host], app.run(role, ver), dryRun);
+ var isMigrateHost = $isMigrateHost(role, host, migrateTarget);
+ $dispatch([host], app.run(role, ver, isMigrateHost ? "true" : ""), dryRun);
// Only proxy-fronted roles register with kamal-proxy —
- // job/worker roles serve no traffic (#2957).
+ // job/worker roles serve no traffic (#2957). On the
+ // migrate host the health check also waits out the
+ // migrations, and a failed one stops the deploy here.
if (role.runningProxy()) {
$dispatch(
[host],
- proxy.deploy(role, app.container_name(role, ver) & ":" & appPort),
+ proxy.deploy(
+ role,
+ app.container_name(role, ver) & ":" & appPort,
+ isMigrateHost ? cfg.migrate().timeout() : 0
+ ),
dryRun
);
}
+ if (isMigrateHost) {
+ $dispatch(hosts, auditor.record("migrated the database on " & host & " for version " & ver), dryRun, true);
+ }
// Post-cutover cleanup: stop superseded versions
// (#2957 DEP-11a). Best-effort (allowFail) — a failed
// stop of an OLD container must not fail an
@@ -228,6 +330,119 @@ component {
);
}
+ /**
+ * The role and host that migrate the database (#4063), or {} when
+ * deploy.yml has no migrate: block. The default is the first host of the
+ * first role; migrate.host picks another. It must belong to a proxy-fronted
+ * role, because that role's health check is what stops a deploy whose
+ * migration failed before any host cuts over.
+ */
+ private struct function $migrateTarget(required any cfg) {
+ var migrate = arguments.cfg.migrate();
+ if (!migrate.enabled()) {
+ return {};
+ }
+ var roles = arguments.cfg.roles();
+ if (!$hasProxyRole(roles)) {
+ throw(
+ type = "DeployMainCli.InvalidMigrateConfig",
+ message = "migrate: needs a proxy-fronted role (web, or a role with proxy: true), because its health check is what stops a deploy whose migration failed. This deploy.yml has only job/worker roles, so remove migrate: and run the migrations yourself."
+ );
+ }
+ var wanted = migrate.host();
+ if (!len(wanted)) {
+ if (!roles[1].runningProxy()) {
+ throw(
+ type = "DeployMainCli.InvalidMigrateConfig",
+ message = "migrate: defaults to the first host of the first role ('#roles[1].name()#'), which is not proxy-fronted. Set migrate.host to a host of a proxy-fronted role."
+ );
+ }
+ return {role: roles[1].name(), host: roles[1].hosts()[1]};
+ }
+ var foundInRole = "";
+ for (var role in roles) {
+ for (var host in role.hosts()) {
+ if (compare(trim(host), wanted) == 0) {
+ if (role.runningProxy()) {
+ return {role: role.name(), host: host};
+ }
+ foundInRole = role.name();
+ }
+ }
+ }
+ throw(
+ type = "DeployMainCli.InvalidMigrateConfig",
+ message = len(foundInRole)
+ ? "migrate.host '#wanted#' is in the '#foundInRole#' role, which is not proxy-fronted. Pick a host of a proxy-fronted role."
+ : "migrate.host '#wanted#' is not a host of any role in servers:."
+ );
+ }
+
+ private boolean function $hasProxyRole(required array roles) {
+ for (var role in arguments.roles) {
+ if (role.runningProxy()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** The roles in deploy order: the migrate host's role first (#4063). */
+ private array function $migrateOrderedRoles(required array roles, required struct target) {
+ if (structIsEmpty(arguments.target)) {
+ return arguments.roles;
+ }
+ var first = [];
+ var rest = [];
+ for (var role in arguments.roles) {
+ if (role.name() == arguments.target.role) {
+ arrayAppend(first, role);
+ } else {
+ arrayAppend(rest, role);
+ }
+ }
+ arrayAppend(first, rest, true);
+ return first;
+ }
+
+ /** A role's hosts in deploy order: the migrate host first in its role (#4063). */
+ private array function $migrateOrderedHosts(required any role, required struct target) {
+ var hosts = arguments.role.hosts();
+ if (structIsEmpty(arguments.target) || arguments.role.name() != arguments.target.role) {
+ return hosts;
+ }
+ var ordered = [arguments.target.host];
+ for (var host in hosts) {
+ if (compare(host, arguments.target.host) != 0) {
+ arrayAppend(ordered, host);
+ }
+ }
+ return ordered;
+ }
+
+ private boolean function $isMigrateHost(required any role, required string host, required struct target) {
+ return !structIsEmpty(arguments.target)
+ && arguments.role.name() == arguments.target.role
+ && compare(arguments.host, arguments.target.host) == 0;
+ }
+
+ /**
+ * The boot.wait pause between two batches of hosts. A dry run records it
+ * instead of sleeping. Its own method so specs can stub the sleep.
+ */
+ private void function $bootPause(required numeric seconds, required boolean dryRun, required numeric batchSize) {
+ if (arguments.dryRun) {
+ arrayAppend(
+ variables.dryRunBuffer,
+ "[boot] wait " & arguments.seconds & "s before the next batch (boot.limit: " & arguments.batchSize & " host(s) per batch)"
+ );
+ return;
+ }
+ if (arguments.seconds > 0) {
+ sleep(arguments.seconds * 1000);
+ }
+ }
+
public string function redeploy(required struct opts) {
return deploy(arguments.opts);
}
@@ -557,12 +772,34 @@ component {
summary &= chr(10) & "WARNING: .kamal/secrets is already tracked by git, and .gitignore does not untrack it. "
& "Run git rm --cached .kamal/secrets, commit, and rotate every secret the file has ever held.";
}
- return summary & chr(10)
+ var steps = summary & chr(10)
& "Next steps:" & chr(10)
& " 1. Edit config/deploy.yml — update servers, proxy host, registry username." & chr(10)
& " 2. Review the generated Dockerfile — adjust COPY paths and the Lucee/CFML base if your app needs it." & chr(10)
& " 3. Populate .kamal/secrets with real values (or $(cmd) substitutions)." & chr(10)
& " 4. wheels deploy setup";
+ if ($usesSqlite(cwd)) {
+ steps &= chr(10) & chr(10)
+ & "This app uses SQLite. The image ships an empty db/ (your local db/*.sqlite files stay out of it), "
+ & "so production starts with an empty database inside the container." & chr(10)
+ & " - Keep it across redeploys by mounting db/ from the host in config/deploy.yml:" & chr(10)
+ & " volumes:" & chr(10)
+ & " - /var/lib/" & serviceName & "/db:/var/www/db" & chr(10)
+ & " and create that directory on each host first: wheels deploy exec ""mkdir -p /var/lib/" & serviceName & "/db""" & chr(10)
+ & " - Run migrations on boot: set(autoMigrateDatabase=true) in config/production/settings.cfm." & chr(10)
+ & " - For a database shared across several hosts, use a server database: wheels deploy docs accessories.";
+ }
+ return steps;
+ }
+
+ /** True when config/app.cfm defines a SQLite datasource (the `wheels new` default). */
+ private boolean function $usesSqlite(required string cwd) {
+ var appCfm = arguments.cwd & "config/app.cfm";
+ try {
+ return fileExists(appCfm) && findNoCase("org.sqlite.JDBC", fileRead(appCfm)) > 0;
+ } catch (any e) {
+ return false;
+ }
}
/**
diff --git a/cli/lucli/services/deploy/cli/DeployRegistryCli.cfc b/cli/lucli/services/deploy/cli/DeployRegistryCli.cfc
index e7c30a82fa..f9c2dada2f 100644
--- a/cli/lucli/services/deploy/cli/DeployRegistryCli.cfc
+++ b/cli/lucli/services/deploy/cli/DeployRegistryCli.cfc
@@ -42,6 +42,15 @@ component {
// stdin, so dry-run output, exception summaries, and remote argv
// stay clean.
var pw = "";
+ // The deploy path skips login without a username; an explicit login
+ // ran `docker login -u ''` and failed with an unrelated registry error.
+ if (arguments.isLogin && !len(trim(cfg.registry().username()))) {
+ throw(
+ type = "DeployRegistryCli.MissingUsername",
+ message = "registry.username is required to log in to " & cfg.registry().server()
+ & ": set it under registry: in config/deploy.yml."
+ );
+ }
if (arguments.isLogin) {
pw = arguments.opts.password ?: $resolvePassword(cfg);
if (!len(pw) && !dryRun) {
diff --git a/cli/lucli/services/deploy/cli/docs/accessories.md b/cli/lucli/services/deploy/cli/docs/accessories.md
index 54aba8efd6..4ceb866af6 100644
--- a/cli/lucli/services/deploy/cli/docs/accessories.md
+++ b/cli/lucli/services/deploy/cli/docs/accessories.md
@@ -10,7 +10,7 @@ application deploy. They are booted once (or on demand) and left alone.
redis:
image: redis:7
host: 1.2.3.5
- port: 6379
+ port: "127.0.0.1:6379:6379"
## Postgres with volume and env
@@ -18,7 +18,7 @@ application deploy. They are booted once (or on demand) and left alone.
db:
image: postgres:16
host: 1.2.3.5
- port: 5432
+ port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: app
@@ -29,6 +29,14 @@ application deploy. They are booted once (or on demand) and left alone.
files:
- config/init.sql:/docker-entrypoint-initdb.d/init.sql
+`port:` must name a bind address: "::" (IPv4, or bracketed IPv6), optionally ending in /tcp, /udp or
+/sctp. Use 127.0.0.1 for the host only, a private address such as
+10.0.0.20 for other hosts on a private network, or 0.0.0.0 to publish
+it on every interface. A bare `port: 5432` fails validation. App
+containers on the same host don't need `port:`: they reach the
+accessory as `-` over the kamal network.
+
## Named containers
Accessory containers are named `-`, e.g. the
diff --git a/cli/lucli/services/deploy/cli/docs/proxy.md b/cli/lucli/services/deploy/cli/docs/proxy.md
index 55679ac7f9..edaa3ae9f0 100644
--- a/cli/lucli/services/deploy/cli/docs/proxy.md
+++ b/cli/lucli/services/deploy/cli/docs/proxy.md
@@ -14,9 +14,9 @@ host that serves a `web` role, and it is booted once at deploy time.
proxy:
host: myapp.example.com
- app_port: 8080 # port the app listens on inside the
+ app_port: 8888 # port the app listens on inside the
# container (default 80; `wheels deploy init`
- # scaffolds 8080 to match its Dockerfile)
+ # scaffolds 8888 to match its Dockerfile)
healthcheck:
path: /up
timeout: 30
diff --git a/cli/lucli/services/deploy/cli/docs/ssh.md b/cli/lucli/services/deploy/cli/docs/ssh.md
index 75385a7461..d70120cda5 100644
--- a/cli/lucli/services/deploy/cli/docs/ssh.md
+++ b/cli/lucli/services/deploy/cli/docs/ssh.md
@@ -45,7 +45,9 @@ every host run in parallel.
## Troubleshooting
wheels deploy exec "uptime" # smoke test SSH + sudo
- wheels deploy exec --interactive "bash"
+
+`wheels deploy exec` runs a command and returns its output; it has no
+interactive mode. For a shell on a host, use `ssh` directly.
If a host is unreachable, deploy fails fast with the host in the error
message. There is no built-in retry — `wheels deploy` stays strict on
diff --git a/cli/lucli/services/deploy/commands/AccessoryCommands.cfc b/cli/lucli/services/deploy/commands/AccessoryCommands.cfc
index 6360f1a9be..9a6cdf7402 100644
--- a/cli/lucli/services/deploy/commands/AccessoryCommands.cfc
+++ b/cli/lucli/services/deploy/commands/AccessoryCommands.cfc
@@ -75,14 +75,16 @@ component extends="Base" {
private array function $portArgs(required any accessory) {
if (!len(arguments.accessory.port())) return [];
- return ["--publish", arguments.accessory.port()];
+ return ["--publish", shellEscape(arguments.accessory.port())];
}
private array function $volumeArgs(required any accessory) {
var parts = [];
+ // Quoted like every other docker argument, so a path with spaces or
+ // special characters is passed through as one value.
for (var v in arguments.accessory.volumes()) {
arrayAppend(parts, "--volume");
- arrayAppend(parts, v);
+ arrayAppend(parts, shellEscape(v));
}
return parts;
}
diff --git a/cli/lucli/services/deploy/commands/AppCommands.cfc b/cli/lucli/services/deploy/commands/AppCommands.cfc
index 009d301ed3..5fc865769d 100644
--- a/cli/lucli/services/deploy/commands/AppCommands.cfc
+++ b/cli/lucli/services/deploy/commands/AppCommands.cfc
@@ -15,7 +15,14 @@ component extends="Base" {
return this;
}
- public string function run(required any role, required string version) {
+ /**
+ * @migrateOnBoot "true" for the one container that runs the migrations
+ * (#4063). Any other container gets "false" whenever
+ * deploy.yml has a `migrate:` block, so `app boot` and the
+ * other hosts never migrate at start; without the block the
+ * variable is not set at all.
+ */
+ public string function run(required any role, required string version, string migrateOnBoot = "") {
return docker(
"run",
"--detach",
@@ -23,12 +30,25 @@ component extends="Base" {
"--name " & shellEscape(container_name(arguments.role, arguments.version)),
"--network kamal",
$labelArgs(arguments.role, arguments.version),
+ $volumeArgs(),
$envArgs(arguments.role),
+ $migrateEnvArgs(arguments.migrateOnBoot),
shellEscape(variables.config.absoluteImage(arguments.version)),
arguments.role.cmd()
);
}
+ private array function $migrateEnvArgs(required string migrateOnBoot) {
+ var value = arguments.migrateOnBoot;
+ if (!len(value) && variables.config.migrate().enabled()) {
+ value = "false";
+ }
+ if (!len(value)) {
+ return [];
+ }
+ return ["-e", shellEscape("WHEELS_MIGRATE_ON_BOOT=" & value)];
+ }
+
/**
* Idempotent same-name conflict guard (#2957 DEP-11a): `docker run
* --name X` hard-fails when ANY container — running or stopped —
@@ -79,6 +99,13 @@ component extends="Base" {
shellEscape(container_name(arguments.role, arguments.version)));
}
+ /** Every container of one role, any version (`app details` without --release). */
+ public string function role_containers(required any role) {
+ return docker("ps", "-a",
+ "--filter", shellEscape("label=service=" & variables.config.service()),
+ "--filter", shellEscape("label=role=" & arguments.role.name()));
+ }
+
public string function containers() {
return docker("ps", "--filter", shellEscape("label=service=" & variables.config.service()));
}
@@ -87,14 +114,24 @@ component extends="Base" {
return docker("images", variables.config.image());
}
- public string function logs(struct opts = {}) {
- var parts = ["logs"];
- var tail = arguments.opts.tail ?: 100;
- arrayAppend(parts, "--tail");
- arrayAppend(parts, tail);
+ /**
+ * Tail a container's logs. Without `container`, the role's newest running container (by the
+ * service / role / destination labels `run` stamps on) is piped in, as Kamal does; a bare
+ * `docker logs` without a container name is an error (#4416).
+ */
+ public string function logs(struct opts = {}, any role) {
+ var parts = ["logs", "--tail", arguments.opts.tail ?: 100];
if (arguments.opts.follow ?: false) arrayAppend(parts, "--follow");
- if (len(arguments.opts.container ?: "")) arrayAppend(parts, arguments.opts.container);
- return docker(parts);
+ if (len(arguments.opts.container ?: "")) {
+ arrayAppend(parts, shellEscape(arguments.opts.container));
+ return docker(parts);
+ }
+ var filters = ["ps", "--latest", "--quiet", "--filter", shellEscape("label=service=" & variables.config.service())];
+ if (!isNull(arguments.role)) {
+ arrayAppend(filters, ["--filter", shellEscape("label=role=" & arguments.role.name())], true);
+ arrayAppend(filters, ["--filter", shellEscape("label=destination=" & variables.config.destination())], true);
+ }
+ return pipe([docker(filters), "xargs -r " & docker(parts)]);
}
public string function container_name(required any role, required string version) {
@@ -122,6 +159,16 @@ component extends="Base" {
]);
}
+ /** Top-level `volumes:`, quoted like every other docker argument (#4018). */
+ private array function $volumeArgs() {
+ var parts = [];
+ for (var v in variables.config.volumes()) {
+ arrayAppend(parts, "--volume");
+ arrayAppend(parts, shellEscape(v));
+ }
+ return parts;
+ }
+
private array function $labelArgs(required any role, required string version) {
return [
"--label", shellEscape("service=" & variables.config.service()),
diff --git a/cli/lucli/services/deploy/commands/BuilderCommands.cfc b/cli/lucli/services/deploy/commands/BuilderCommands.cfc
index 2bb72500ae..b84e011991 100644
--- a/cli/lucli/services/deploy/commands/BuilderCommands.cfc
+++ b/cli/lucli/services/deploy/commands/BuilderCommands.cfc
@@ -9,11 +9,21 @@ component extends="Base" {
return this;
}
+ /**
+ * Build and push the release image, honouring the `builder:` block (#4415): one `--platform`
+ * list for `arch`, a `--build-arg` per `args` entry, and the service's buildx builder when
+ * `remote` is set (`build create` makes it there). The image carries a `service=` label, which
+ * is what `prune images` filters on.
+ */
public string function push(required string version) {
var b = variables.config.builder();
return docker(
"buildx", "build",
"--push",
+ "--platform", $platforms(b.arch()),
+ len(b.remote()) ? "--builder " & $builderName() : "",
+ "--label", shellEscape("service=" & variables.config.service()),
+ $buildArgs(b.args()),
"--tag", shellEscape(variables.config.absoluteImage(arguments.version)),
"--file", shellEscape(b.dockerfile()),
shellEscape(b.context())
@@ -32,8 +42,16 @@ component extends="Base" {
);
}
+ /**
+ * Create the service's buildx builder; on `builder.remote` (a docker endpoint such as
+ * ssh://user@host) when one is configured.
+ */
public string function create() {
- return docker("buildx", "create", "--name", $builderName(), "--driver=docker-container");
+ var remote = variables.config.builder().remote();
+ return docker(
+ "buildx", "create", "--name", $builderName(), "--driver=docker-container",
+ len(remote) ? shellEscape(remote) : ""
+ );
}
public string function remove() {
@@ -58,4 +76,34 @@ component extends="Base" {
public string function $builderName() {
return "kamal-" & variables.config.service();
}
+
+ /**
+ * `builder.arch` as one buildx platform list: "amd64" becomes "linux/amd64"; an entry that
+ * already names its OS is kept.
+ */
+ public string function $platforms(required array arch) {
+ var platforms = [];
+ for (var a in arguments.arch) {
+ var p = trim(toString(a));
+ if (len(p)) {
+ arrayAppend(platforms, find("/", p) ? p : "linux/" & p);
+ }
+ }
+ return arrayToList(platforms, ",");
+ }
+
+ /**
+ * A `--build-arg 'KEY=value'` pair per `builder.args` entry, in key order so the command is
+ * stable.
+ */
+ public array function $buildArgs(required struct args) {
+ var rv = [];
+ var keys = structKeyArray(arguments.args);
+ arraySort(keys, "textnocase");
+ for (var k in keys) {
+ arrayAppend(rv, "--build-arg");
+ arrayAppend(rv, shellEscape(k & "=" & toString(arguments.args[k])));
+ }
+ return rv;
+ }
}
diff --git a/cli/lucli/services/deploy/commands/ProxyCommands.cfc b/cli/lucli/services/deploy/commands/ProxyCommands.cfc
index db5ade3527..9005443787 100644
--- a/cli/lucli/services/deploy/commands/ProxyCommands.cfc
+++ b/cli/lucli/services/deploy/commands/ProxyCommands.cfc
@@ -32,7 +32,12 @@ component extends="Base" {
);
}
- public string function deploy(required any role, required string target) {
+ /**
+ * @healthCheckTimeout Seconds, overriding proxy.healthcheck.timeout for this
+ * one call: the migrate host's health check waits while
+ * the migrations run (#4063). 0 keeps the configured value.
+ */
+ public string function deploy(required any role, required string target, numeric healthCheckTimeout = 0) {
var p = variables.config.proxy();
var hc = p.healthcheck();
var innerArgs = [
@@ -49,7 +54,7 @@ component extends="Base" {
arrayAppend(innerArgs, [
"--health-check-path", shellEscape(hc.path ?: "/up"),
"--health-check-interval", $duration(hc.interval ?: 1),
- "--health-check-timeout", $duration(hc.timeout ?: 30)
+ "--health-check-timeout", $duration(arguments.healthCheckTimeout > 0 ? arguments.healthCheckTimeout : (hc.timeout ?: 30))
], true);
return docker("exec", variables.PROXY_CONTAINER_NAME) & " " & arrayToList(innerArgs, " ");
}
diff --git a/cli/lucli/services/deploy/commands/PruneCommands.cfc b/cli/lucli/services/deploy/commands/PruneCommands.cfc
index 30652268f6..05dea2b9af 100644
--- a/cli/lucli/services/deploy/commands/PruneCommands.cfc
+++ b/cli/lucli/services/deploy/commands/PruneCommands.cfc
@@ -16,9 +16,14 @@ component extends="Base" {
return chain([containers(arguments.keep), images()]);
}
+ /**
+ * Remove every image of this service that no container uses (#4416). `--all` is what makes it
+ * reach tagged release images: without it docker removes only dangling ones. The filter matches
+ * the `service=` label `build push` puts on the image.
+ */
public string function images() {
return docker(
- "image", "prune", "-f",
+ "image", "prune", "--all", "--force",
"--filter", "label=service=" & variables.config.service()
);
}
diff --git a/cli/lucli/services/deploy/config/Boot.cfc b/cli/lucli/services/deploy/config/Boot.cfc
index b69877b69d..d563aa4507 100644
--- a/cli/lucli/services/deploy/config/Boot.cfc
+++ b/cli/lucli/services/deploy/config/Boot.cfc
@@ -6,6 +6,10 @@
* time (default 10)
* wait: seconds to wait between host boots (default 5)
*
+ * `wheels deploy` applies the block only when deploy.yml has one (see
+ * DeployMainCli): each role's hosts boot in batches of batchSize(), with
+ * wait() seconds between batches.
+ *
* Both keys are single-word (`limit`/`wait`) so there is no snake_case/
* camelCase split to tolerate the way Ssh.keysOnly() handles keys_only/
* keysOnly. The defensive coercion below (string-typed numerics, the
@@ -24,6 +28,19 @@ component {
return $coerceNumber(variables.raw.limit, 10);
}
+ /**
+ * Hosts per batch for a role with `hostCount` hosts: limit() as a count,
+ * or, for the percentage form, that share of hostCount rounded up. Never
+ * less than 1, so a batch always makes progress.
+ */
+ public numeric function batchSize(required numeric hostCount) {
+ var size = limit();
+ if (structKeyExists(variables.raw, "limit") && isSimpleValue(variables.raw.limit) && right(trim(variables.raw.limit), 1) == "%") {
+ size = ceiling(arguments.hostCount * size / 100);
+ }
+ return max(1, int(size));
+ }
+
public numeric function wait() {
if (!structKeyExists(variables.raw, "wait")) return 5;
return $coerceNumber(variables.raw.wait, 5);
diff --git a/cli/lucli/services/deploy/config/Builder.cfc b/cli/lucli/services/deploy/config/Builder.cfc
index 708bcfece8..b200dc1d78 100644
--- a/cli/lucli/services/deploy/config/Builder.cfc
+++ b/cli/lucli/services/deploy/config/Builder.cfc
@@ -29,11 +29,27 @@ component {
: {};
}
+ /**
+ * The target platforms. Kamal accepts a scalar ("amd64", or "amd64,arm64") or a list. Each entry
+ * goes into `docker buildx build --platform`, a shell command, so anything but a plain platform
+ * name (letters, digits, `.`, `_`, `/`, `-`) is refused here rather than quoted there (#4423).
+ */
public array function arch() {
if (!structKeyExists(variables.raw, "arch")) return ["amd64"];
- // Kamal accepts either a scalar "amd64" or an array — normalize.
- if (isArray(variables.raw.arch)) return variables.raw.arch;
- return [variables.raw.arch];
+ var entries = isArray(variables.raw.arch) ? variables.raw.arch : listToArray(toString(variables.raw.arch), ",");
+ var rv = [];
+ for (var entry in entries) {
+ var value = trim(toString(entry));
+ if (!len(value)) continue;
+ if (!reFind("^[A-Za-z0-9._/-]+$", value)) {
+ throw(
+ type = "Wheels.Deploy.InvalidInput",
+ message = "Invalid builder.arch '#value#' in deploy.yml: use platform names such as amd64, arm64 or linux/arm/v7 (letters, digits, '.', '_', '/' and '-')."
+ );
+ }
+ arrayAppend(rv, value);
+ }
+ return arrayLen(rv) ? rv : ["amd64"];
}
public string function remote() {
diff --git a/cli/lucli/services/deploy/config/Config.cfc b/cli/lucli/services/deploy/config/Config.cfc
index afa59601ec..091896f4ea 100644
--- a/cli/lucli/services/deploy/config/Config.cfc
+++ b/cli/lucli/services/deploy/config/Config.cfc
@@ -77,6 +77,11 @@ component {
return new Boot(b);
}
+ /** The `migrate:` block (#4063); disabled when absent. */
+ public any function migrate() {
+ return new Migrate(structKeyExists(variables.raw, "migrate") ? variables.raw.migrate : false);
+ }
+
public array function roles() {
var servers = variables.raw.servers;
@@ -114,6 +119,13 @@ component {
return prefix & image() & ":" & new DeployToken().assert(arguments.version, "version");
}
+ /** Top-level `volumes:` mounted on every app container (#4018). */
+ public array function volumes() {
+ return (structKeyExists(variables.raw, "volumes") && isArray(variables.raw.volumes))
+ ? variables.raw.volumes
+ : [];
+ }
+
public array function accessories() {
var out = [];
var raw = (structKeyExists(variables.raw, "accessories") && isStruct(variables.raw.accessories))
diff --git a/cli/lucli/services/deploy/config/ConfigLoader.cfc b/cli/lucli/services/deploy/config/ConfigLoader.cfc
index 30801ca650..204c79e7f1 100644
--- a/cli/lucli/services/deploy/config/ConfigLoader.cfc
+++ b/cli/lucli/services/deploy/config/ConfigLoader.cfc
@@ -36,7 +36,22 @@ component {
* ..yml is deep-merged on top.
*/
public any function load(required string path, struct opts = {destination: ""}) {
- var raw = variables.yaml.parse(fileRead(arguments.path));
+ if (!fileExists(arguments.path)) {
+ variables.validator.$raise(
+ arguments.path,
+ "no deploy config found at #arguments.path#; run `wheels deploy init` to create config/deploy.yml"
+ );
+ }
+ var content = fileRead(arguments.path);
+ // Blank or comment-only: the YAML parser has nothing to return.
+ var hasContent = reFind("(?m)^[ \t]*[^##\s]", content) > 0;
+ var raw = hasContent ? variables.yaml.parse(content) : {};
+ if (isNull(raw) || !isStruct(raw) || structIsEmpty(raw)) {
+ variables.validator.$raise(
+ arguments.path,
+ "the deploy config is empty; it needs at least service:, image: and servers: (see `wheels deploy init`)"
+ );
+ }
var dest = arguments.opts.destination ?: "";
if (len(dest)) {
diff --git a/cli/lucli/services/deploy/config/Migrate.cfc b/cli/lucli/services/deploy/config/Migrate.cfc
new file mode 100644
index 0000000000..893dea3ea7
--- /dev/null
+++ b/cli/lucli/services/deploy/config/Migrate.cfc
@@ -0,0 +1,49 @@
+/**
+ * Migrate — immutable accessor for the `migrate:` block (#4063).
+ *
+ * migrate: true
+ *
+ * migrate:
+ * host: 10.0.0.1 # optional; default: the first host of the first role
+ * timeout: 300 # optional; seconds the health check waits on that host
+ *
+ * When enabled, `wheels deploy` boots the migrate host first with
+ * WHEELS_MIGRATE_ON_BOOT=true (the application migrates strictly at start, so a
+ * failed migration fails its health check and stops the deploy before any
+ * cutover) and every other app container with WHEELS_MIGRATE_ON_BOOT=false.
+ * Without the block (or with `migrate: false`) nothing changes.
+ */
+component {
+
+ public any function init(any raw = false) {
+ variables.raw = arguments.raw;
+ return this;
+ }
+
+ public boolean function enabled() {
+ if (isStruct(variables.raw)) return true;
+ return isBoolean(variables.raw) && variables.raw;
+ }
+
+ /** The configured migrate host, or "" for the default (first host of the first role). */
+ public string function host() {
+ if (isStruct(variables.raw) && structKeyExists(variables.raw, "host") && isSimpleValue(variables.raw.host)) {
+ return trim(variables.raw.host);
+ }
+ return "";
+ }
+
+ /** Seconds kamal-proxy's health check waits on the migrate host (default 300). */
+ public numeric function timeout() {
+ if (
+ isStruct(variables.raw)
+ && structKeyExists(variables.raw, "timeout")
+ && isNumeric(variables.raw.timeout)
+ && variables.raw.timeout > 0
+ ) {
+ return variables.raw.timeout;
+ }
+ return 300;
+ }
+
+}
diff --git a/cli/lucli/services/deploy/config/Proxy.cfc b/cli/lucli/services/deploy/config/Proxy.cfc
index fcb0669114..92bdc4ce6f 100644
--- a/cli/lucli/services/deploy/config/Proxy.cfc
+++ b/cli/lucli/services/deploy/config/Proxy.cfc
@@ -27,9 +27,11 @@ component {
public numeric function appPort() {
// Support both snake_case (YAML) and camelCase, mirroring Kamal's
// preference for snake_case but tolerating the CFML convention.
- if (structKeyExists(variables.raw, "app_port")) return variables.raw.app_port;
- if (structKeyExists(variables.raw, "appPort")) return variables.raw.appPort;
- return 80;
+ // A whole number: the default 80 (and YAML's 80) can arrive as 80.0, which `deploy config`
+ // printed as is (#4417).
+ if (structKeyExists(variables.raw, "app_port")) return javaCast("int", val(variables.raw.app_port));
+ if (structKeyExists(variables.raw, "appPort")) return javaCast("int", val(variables.raw.appPort));
+ return javaCast("int", 80);
}
public struct function healthcheck() {
diff --git a/cli/lucli/services/deploy/config/Ssh.cfc b/cli/lucli/services/deploy/config/Ssh.cfc
index d5881ba1f2..f3dd2fe4ad 100644
--- a/cli/lucli/services/deploy/config/Ssh.cfc
+++ b/cli/lucli/services/deploy/config/Ssh.cfc
@@ -20,7 +20,8 @@ component {
}
public numeric function port() {
- return variables.raw.port ?: 22;
+ // A whole number: YAML's 22 can arrive as 22.0, which `deploy config` printed as is (#4417).
+ return javaCast("int", val(variables.raw.port ?: 22));
}
public string function proxy() {
diff --git a/cli/lucli/services/deploy/config/Validator.cfc b/cli/lucli/services/deploy/config/Validator.cfc
index e05c61396f..dab33a9e39 100644
--- a/cli/lucli/services/deploy/config/Validator.cfc
+++ b/cli/lucli/services/deploy/config/Validator.cfc
@@ -19,7 +19,7 @@ component {
// accepted-and-ignored (##3088).
variables.allowedKeys = [
"service", "image", "servers", "registry", "builder", "env",
- "ssh", "proxy", "boot", "accessories"
+ "ssh", "proxy", "boot", "accessories", "volumes", "migrate"
];
// Pre-build a case-insensitive struct lookup so the hot path doesn't
// depend on arrayContainsNoCase (not available on every engine).
@@ -27,6 +27,14 @@ component {
for (var k in variables.allowedKeys) {
variables.allowedLookup[lCase(k)] = true;
}
+ // proxy: sub-keys. forward_headers and buffering are accepted for
+ // Kamal compatibility but not applied yet (config-reference documents
+ // that); anything else is a typo that would silently do nothing.
+ variables.proxyKeys = ["host", "ssl", "app_port", "appPort", "healthcheck", "forward_headers", "buffering"];
+ variables.proxyLookup = {};
+ for (var pk in variables.proxyKeys) {
+ variables.proxyLookup[lCase(pk)] = true;
+ }
return this;
}
@@ -48,6 +56,20 @@ component {
// validated rather than quoted (##2956).
$validateName(arguments.parsed.service, "service", arguments.filePath);
$validateImage(arguments.parsed.image, "image", arguments.filePath);
+ if (structKeyExists(arguments.parsed, "proxy") && isStruct(arguments.parsed.proxy)) {
+ for (var proxyKey in arguments.parsed.proxy) {
+ if (!structKeyExists(variables.proxyLookup, lCase(proxyKey))) {
+ $raise(
+ arguments.filePath,
+ "unknown key: 'proxy.#proxyKey#' (allowed proxy keys: #arrayToList(variables.proxyKeys, ', ')#)"
+ );
+ }
+ }
+ // Proxy.ssl() treats a non-boolean as false, so a typo turned TLS off.
+ if (structKeyExists(arguments.parsed.proxy, "ssl") && !isBoolean(arguments.parsed.proxy.ssl)) {
+ $raise(arguments.filePath, "proxy.ssl must be true or false (got '#arguments.parsed.proxy.ssl#')");
+ }
+ }
// kamal-proxy's --tls needs a host to request a certificate for.
if (
structKeyExists(arguments.parsed, "proxy") && isStruct(arguments.parsed.proxy)
@@ -57,25 +79,140 @@ component {
$raise(arguments.filePath, "proxy.ssl requires proxy.host (the host name TLS is issued for)");
}
$validateServers(arguments.parsed.servers, arguments.filePath);
+ $validateVolumes(arguments.parsed, arguments.filePath);
$validateBoot(arguments.parsed, arguments.filePath);
+ $validateMigrate(arguments.parsed, arguments.filePath);
if (structKeyExists(arguments.parsed, "accessories") && isStruct(arguments.parsed.accessories)) {
for (var accName in arguments.parsed.accessories) {
- $validateName(accName, "accessory", arguments.filePath);
- var acc = arguments.parsed.accessories[accName];
- if (isStruct(acc)) {
- if (structKeyExists(acc, "image")) {
- $validateImage(acc.image, "accessory #accName# image", arguments.filePath);
- }
- for (var hostKey in ["host", "hosts"]) {
- if (structKeyExists(acc, hostKey)) {
- for (var accHost in (isArray(acc[hostKey]) ? acc[hostKey] : [acc[hostKey]])) {
- $validateHost(accHost, arguments.filePath);
- }
- }
- }
+ $validateAccessory(arguments.parsed.accessories[accName], accName, arguments.filePath);
+ }
+ }
+ }
+
+ /**
+ * One accessory's checks: its name, image, port bind address, volume
+ * host paths and hosts. Kept out of validate() so that function stays
+ * under the complexity gate.
+ */
+ public void function $validateAccessory(required any acc, required string accName, required string filePath) {
+ $validateName(arguments.accName, "accessory", arguments.filePath);
+ if (!isStruct(arguments.acc)) return;
+ if (structKeyExists(arguments.acc, "image")) {
+ $validateImage(arguments.acc.image, "accessory #arguments.accName# image", arguments.filePath);
+ }
+ if (structKeyExists(arguments.acc, "port")) {
+ $validateAccessoryPort(arguments.acc.port, arguments.accName, arguments.filePath);
+ }
+ for (var volKey in ["volumes", "directories"]) {
+ if (structKeyExists(arguments.acc, volKey) && isArray(arguments.acc[volKey])) {
+ var volIndex = 0;
+ for (var vol in arguments.acc[volKey]) {
+ volIndex++;
+ $validateVolumeHost(vol, "accessory #arguments.accName# #volKey#[#volIndex#]", arguments.filePath);
}
}
}
+ for (var hostKey in ["host", "hosts"]) {
+ if (structKeyExists(arguments.acc, hostKey)) {
+ for (var accHost in (isArray(arguments.acc[hostKey]) ? arguments.acc[hostKey] : [arguments.acc[hostKey]])) {
+ $validateHost(accHost, arguments.filePath);
+ }
+ }
+ }
+ }
+
+ /**
+ * Top-level `volumes:` (Kamal): a list of `host:container` or
+ * `host:container:ro|rw` mounts for every app container, where host is a
+ * path or a named volume and container is an absolute path (#4018).
+ */
+ public void function $validateVolumes(required struct parsed, required string filePath) {
+ if (!structKeyExists(arguments.parsed, "volumes")) return;
+ if (!isArray(arguments.parsed.volumes)) {
+ $raise(arguments.filePath, "volumes must be a list of host:container mounts, e.g. - /var/lib/myapp/db:/var/www/db");
+ }
+ var i = 0;
+ for (var entry in arguments.parsed.volumes) {
+ i++;
+ var parts = isSimpleValue(entry) ? listToArray(entry, ":", true) : [];
+ var shapeOk = (arrayLen(parts) == 2 || (arrayLen(parts) == 3 && listFind("ro,rw", lCase(parts[3]))))
+ && len(trim(parts[1])) && left(parts[2], 1) == "/";
+ if (!shapeOk) {
+ $raise(
+ arguments.filePath,
+ "volumes[#i#] must be host:container or host:container:ro|rw with an absolute container path (got '#isSimpleValue(entry) ? entry : "a non-string value"#')"
+ );
+ }
+ $validateVolumeHost(entry, "volumes[#i#]", arguments.filePath);
+ }
+ }
+
+ /**
+ * Volume entries reach Docker as written (they are quoted), so a host
+ * path starting with `~` or `$` (which a shell used to expand) would be
+ * taken literally; refuse it with a clear message. A `$` later in the
+ * path is a literal character and is fine.
+ */
+ public void function $validateVolumeHost(required any entry, required string label, required string filePath) {
+ if (!isSimpleValue(arguments.entry)) return;
+ var firstChar = left(trim(arguments.entry), 1);
+ if (firstChar == "~" || firstChar == "$") {
+ $raise(
+ arguments.filePath,
+ "#arguments.label# needs an absolute host path: '#arguments.entry#' starts with '#firstChar#', which is not expanded (write /home//... instead)"
+ );
+ }
+ }
+
+ /**
+ * An accessory `port:` goes to `docker run --publish` as written, so it
+ * must name a bind address (#4067), as Kamal main (unreleased) requires
+ * since basecamp/kamal@00cb4cf1d: "::" or
+ * "[]::", optionally with "/tcp", "/udp" or
+ * "/sctp". A bare
+ * "5432" published on a random host port, and "5432:5432" on every
+ * interface, without any warning.
+ */
+ public void function $validateAccessoryPort(required any port, required string accName, required string filePath) {
+ if (!isSimpleValue(arguments.port)) {
+ $raise(arguments.filePath, "accessory #arguments.accName#: port must be a string such as ""127.0.0.1:5432:5432""");
+ }
+ var value = trim(toString(arguments.port));
+ if (!len(value)) return;
+ var hostPart = listFirst(value, "/");
+ // The ports and optional protocol after the bind address: a port or
+ // range on each side, e.g. ":5432:5432", ":8000-8010:8000-8010/udp".
+ var portsPattern = ":[0-9]+(-[0-9]+)?:[0-9]+(-[0-9]+)?(/(tcp|udp|sctp))?$";
+ var bound = false;
+ if (left(hostPart, 1) == "[") {
+ var close = find("]", hostPart);
+ if (close > 2) {
+ var ipv6 = mid(hostPart, 2, close - 2);
+ bound = reFind("^[0-9A-Fa-f:.]+$", ipv6) && find(":", ipv6)
+ && reFind("^" & portsPattern, mid(value, close + 1, len(value)));
+ }
+ } else if (listLen(hostPart, ":", true) >= 3) {
+ var ipv4 = listFirst(hostPart, ":");
+ bound = $isIPv4(ipv4) && reFind("^" & portsPattern, mid(value, len(ipv4) + 1, len(value)));
+ }
+ if (!bound) {
+ var containerPort = listLast(hostPart, ":");
+ if (!reFind("^[0-9]+$", containerPort)) containerPort = "PORT";
+ $raise(
+ arguments.filePath,
+ "accessory #arguments.accName#: port ""#value#"" must name a bind address, e.g. ""127.0.0.1:#containerPort#:#containerPort#"" to keep it on that host, "
+ & """10.0.0.20:#containerPort#:#containerPort#"" for a private network address, or ""0.0.0.0:#containerPort#:#containerPort#"" to publish it on every interface"
+ );
+ }
+ }
+
+ /** Four dot-separated decimal octets, each 0-255. */
+ public boolean function $isIPv4(required string value) {
+ if (!reFind("^[0-9]{1,3}(\.[0-9]{1,3}){3}$", arguments.value)) return false;
+ for (var octet in listToArray(arguments.value, ".")) {
+ if (val(octet) > 255) return false;
+ }
+ return true;
}
public void function $validateServers(required any servers, required string filePath) {
@@ -132,6 +269,35 @@ component {
if (structKeyExists(boot, "wait")) $validateBootNumber(boot.wait, "boot.wait", arguments.filePath);
}
+ /**
+ * Validate the `migrate:` block (#4063): true/false, or a block with an
+ * optional `host` (a valid host) and `timeout` (positive seconds). Whether
+ * the host belongs to a proxy-fronted role is checked at deploy time, where
+ * the roles are resolved.
+ */
+ public void function $validateMigrate(required struct parsed, required string filePath) {
+ if (!structKeyExists(arguments.parsed, "migrate")) return;
+ var migrate = arguments.parsed.migrate;
+ if (isSimpleValue(migrate) && isBoolean(migrate)) return;
+ if (!isStruct(migrate)) {
+ $raise(arguments.filePath, "migrate must be true, false, or a block with host and timeout");
+ }
+ for (var key in migrate) {
+ if (!listFindNoCase("host,timeout", key)) {
+ $raise(arguments.filePath, "unknown key: migrate.#key# (allowed migrate keys: host, timeout)");
+ }
+ }
+ if (structKeyExists(migrate, "host")) {
+ if (!isSimpleValue(migrate.host) || !len(trim(migrate.host))) {
+ $raise(arguments.filePath, "migrate.host must be a host name or address");
+ }
+ $validateHost(trim(migrate.host), arguments.filePath);
+ }
+ if (structKeyExists(migrate, "timeout") && !(isNumeric(migrate.timeout) && migrate.timeout > 0)) {
+ $raise(arguments.filePath, "migrate.timeout must be a positive number of seconds");
+ }
+ }
+
public void function $validateBootNumber(required any value, required string key, required string filePath) {
var ok = false;
if (isNumeric(arguments.value)) {
diff --git a/cli/lucli/services/packages/Installer.cfc b/cli/lucli/services/packages/Installer.cfc
index ff63c896d9..0a0ea0af8b 100644
--- a/cli/lucli/services/packages/Installer.cfc
+++ b/cli/lucli/services/packages/Installer.cfc
@@ -24,6 +24,14 @@
*/
component {
+ variables.BACKUP_PREFIX = ".wheels-pkg-previous-";
+ variables.INCOMING_PREFIX = ".wheels-pkg-incoming-";
+ // Held in vendor/ across a swap so a second packages command doesn't
+ // mistake a live swap for crash leftovers. A lock older than this is
+ // itself a leftover of a crash.
+ variables.LOCK_FILE = ".wheels-pkg-lock";
+ variables.LOCK_STALE_SECONDS = 600;
+
public Installer function init(
any httpClient = "",
string projectRoot = ""
@@ -69,6 +77,9 @@ component {
}
local.vendorDir = variables.projectRoot & "vendor/";
+ // An earlier update killed mid-swap may have left a package only as a
+ // hidden backup; put it back before deciding what is installed.
+ recoverInterruptedSwaps();
// Validates the name and pins the target to a direct child of vendor/
// before anything below deletes, downloads or extracts.
local.target = new modules.wheels.services.packages.PackageName().childOf(local.vendorDir, arguments.name);
@@ -121,8 +132,17 @@ component {
}
// The previous install is replaced only once the new one is verified,
// and kept beside it until the new copy is in place. The leading dot
- // keeps PackageLoader from loading the backup.
- $swapInto(local.stageDir & arguments.name, local.target, local.vendorDir & ".wheels-pkg-previous-" & local.token);
+ // keeps PackageLoader from loading the backup; the name in it lets
+ // recoverInterruptedSwaps() restore it after a crash.
+ local.lockPath = local.vendorDir & variables.LOCK_FILE;
+ FileWrite(local.lockPath, arguments.name & " " & DateTimeFormat(Now(), "iso"));
+ try {
+ $swapInto(local.stageDir & arguments.name, local.target, local.vendorDir & variables.BACKUP_PREFIX & arguments.name & "-" & local.token);
+ } finally {
+ if (FileExists(local.lockPath)) {
+ FileDelete(local.lockPath);
+ }
+ }
} finally {
if (FileExists(local.tmpFile)) {
FileDelete(local.tmpFile);
@@ -172,8 +192,129 @@ component {
}
}
+ /**
+ * Repairs vendor/ after an install or update that was killed mid-swap
+ * (#3902). $swapInto() renames vendor// to a hidden backup, then
+ * renames the new copy into place, so a crash can leave:
+ * - a backup and no vendor// (or one without package.json, from
+ * an older CLI's interrupted cross-volume copy): the backup is
+ * renamed back;
+ * - a backup and a complete vendor//: the swap finished, so the
+ * backup is deleted;
+ * - a half-copied .wheels-pkg-incoming-* dir: deleted.
+ * A backup whose package cannot be told (no name in the directory, no
+ * package.json) is left alone, with a message saying how to restore it.
+ *
+ * A backup made before the name was recorded (.wheels-pkg-previous-)
+ * is attributed by the `name` in its own package.json, which is trusted
+ * as-is. Those older CLIs copied across volumes straight into
+ * vendor//, so a vendor// beside such a backup may be a
+ * half-copy even with a package.json: the backup is restored only when
+ * vendor// is missing, and otherwise kept with a message.
+ *
+ * Nothing is touched while another packages command holds the swap lock.
+ * Returns one message per action taken, for the caller to print.
+ */
+ public array function recoverInterruptedSwaps() {
+ local.messages = [];
+ local.vendorDir = variables.projectRoot & "vendor/";
+ if (!DirectoryExists(local.vendorDir)) {
+ return local.messages;
+ }
+ local.lockPath = local.vendorDir & variables.LOCK_FILE;
+ if (FileExists(local.lockPath)) {
+ if (DateDiff("s", GetFileInfo(local.lockPath).lastmodified, Now()) < variables.LOCK_STALE_SECONDS) {
+ ArrayAppend(local.messages, "Another wheels packages command is updating vendor/ (vendor/#variables.LOCK_FILE#); not checking for interrupted updates.");
+ return local.messages;
+ }
+ FileDelete(local.lockPath);
+ }
+ for (local.entry in DirectoryList(local.vendorDir, false, "name")) {
+ if (Left(local.entry, Len(variables.INCOMING_PREFIX)) == variables.INCOMING_PREFIX) {
+ try {
+ DirectoryDelete(local.vendorDir & local.entry, true);
+ ArrayAppend(local.messages, "Removed vendor/#local.entry# left by an interrupted package install.");
+ } catch (any e) {
+ }
+ continue;
+ }
+ if (Left(local.entry, Len(variables.BACKUP_PREFIX)) != variables.BACKUP_PREFIX) {
+ continue;
+ }
+ local.backup = local.vendorDir & local.entry;
+ local.attribution = $backupPackageName(local.entry, local.backup);
+ local.name = local.attribution.name;
+ if (!Len(local.name)) {
+ ArrayAppend(
+ local.messages,
+ "Found vendor/#local.entry#, a backup left by an interrupted package update, but could not tell which package it is. "
+ & "If a package is missing, rename it back: mv vendor/#local.entry# vendor/"
+ );
+ continue;
+ }
+ local.target = new modules.wheels.services.packages.PackageName().childOf(local.vendorDir, local.name);
+ if (local.attribution.legacy && DirectoryExists(local.target)) {
+ ArrayAppend(
+ local.messages,
+ "Found vendor/#local.entry#, a backup of #local.name# left by an interrupted package update from an older CLI. "
+ & "vendor/#local.name#/ exists but may be incomplete; if #local.name# misbehaves, restore the backup: "
+ & "rm -rf vendor/#local.name# && mv vendor/#local.entry# vendor/#local.name#"
+ );
+ continue;
+ }
+ if (FileExists(local.target & "/package.json")) {
+ DirectoryDelete(local.backup, true);
+ ArrayAppend(local.messages, "Removed a leftover backup of vendor/#local.name#/ from an interrupted package update.");
+ continue;
+ }
+ if (DirectoryExists(local.target)) {
+ DirectoryDelete(local.target, true);
+ }
+ DirectoryRename(local.backup, local.target);
+ ArrayAppend(local.messages, "Restored vendor/#local.name#/ from the backup an interrupted package update left at vendor/#local.entry#.");
+ }
+ return local.messages;
+ }
+
// ── Private ─────────────────────────────────────────────
+ /**
+ * The package a backup belongs to: from its directory name
+ * (.wheels-pkg-previous--), else, for backups made before the
+ * name was recorded, the name in its package.json. Returns {name, legacy}:
+ * name is "" when neither is a valid package name; legacy is true when it
+ * came from package.json.
+ */
+ private struct function $backupPackageName(required string dirName, required string path) {
+ local.matched = REFind(
+ "^\.wheels-pkg-previous-(.+)-[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{16}$",
+ arguments.dirName,
+ 1,
+ true
+ );
+ local.candidates = [];
+ if (local.matched.pos[1] && ArrayLen(local.matched.pos) > 1) {
+ ArrayAppend(local.candidates, {name: Mid(arguments.dirName, local.matched.pos[2], local.matched.len[2]), legacy: false});
+ }
+ if (FileExists(arguments.path & "/package.json")) {
+ try {
+ local.parsed = DeserializeJSON(FileRead(arguments.path & "/package.json"));
+ if (IsStruct(local.parsed) && IsSimpleValue(local.parsed.name ?: "")) {
+ ArrayAppend(local.candidates, {name: local.parsed.name ?: "", legacy: true});
+ }
+ } catch (any e) {
+ }
+ }
+ local.validator = new modules.wheels.services.packages.PackageName();
+ for (local.candidate in local.candidates) {
+ try {
+ return {name: local.validator.assert(local.candidate.name), legacy: local.candidate.legacy};
+ } catch (any e) {
+ }
+ }
+ return {name: "", legacy: false};
+ }
+
/**
* Moves a verified staged tree to target. An existing target is renamed
* aside first (same directory, so a plain rename) and renamed back if the
@@ -205,12 +346,26 @@ component {
}
}
- /** Moves a staged tree into place; falls back to copy+delete across volumes. */
+ /**
+ * Moves a staged tree into place. Across volumes (the temp dir elsewhere)
+ * the copy goes to a hidden sibling of dest first and is renamed in only
+ * when complete, so dest is never half-written, even after a crash.
+ */
private void function $moveInto(required string src, required string dest) {
try {
DirectoryRename(arguments.src, arguments.dest);
} catch (any e) {
- DirectoryCopy(arguments.src, arguments.dest, true);
+ local.destFile = CreateObject("java", "java.io.File").init(arguments.dest);
+ local.incoming = local.destFile.getParent() & "/" & variables.INCOMING_PREFIX & local.destFile.getName() & "-" & CreateUUID();
+ try {
+ DirectoryCopy(arguments.src, local.incoming, true);
+ DirectoryRename(local.incoming, arguments.dest);
+ } catch (any copyError) {
+ if (DirectoryExists(local.incoming)) {
+ DirectoryDelete(local.incoming, true);
+ }
+ rethrow;
+ }
DirectoryDelete(arguments.src, true);
}
}
diff --git a/cli/lucli/services/packages/ManifestCache.cfc b/cli/lucli/services/packages/ManifestCache.cfc
index 6b97b1b50a..53e7f9e51f 100644
--- a/cli/lucli/services/packages/ManifestCache.cfc
+++ b/cli/lucli/services/packages/ManifestCache.cfc
@@ -11,13 +11,21 @@
*
* `refresh()` nukes the whole dir. Explicit, no partial invalidation —
* keeps the mental model simple.
+ *
+ * The default root is /cache/packages (the CLI home is
+ * -Dlucli.home or LUCLI_HOME, else ~/.wheels), which for a normal install is
+ * ~/.wheels/cache/packages: the root vendor/wheels' copy of this cache uses,
+ * so on a normal install the debug panel and the CLI share the default
+ * registry's data (a custom LUCLI_HOME deliberately separates them). Any
+ * other registry passes a registryKey and gets its own registries//
+ * subdirectory, so switching registries never serves another one's data.
*/
component {
variables.DEFAULT_TTL_SECONDS = 86400; // 24h
- public ManifestCache function init(string root = "", numeric ttlSeconds = 0) {
- variables.root = Len(arguments.root) ? arguments.root : $defaultRoot();
+ public ManifestCache function init(string root = "", numeric ttlSeconds = 0, string registryKey = "") {
+ variables.root = Len(arguments.root) ? arguments.root : $defaultRoot(arguments.registryKey);
variables.ttl = arguments.ttlSeconds > 0 ? arguments.ttlSeconds : variables.DEFAULT_TTL_SECONDS;
return this;
}
@@ -32,6 +40,11 @@ component {
return $freshFile($indexPath());
}
+ /** True when an index was ever written, fresh or not. */
+ public boolean function hasIndex() {
+ return FileExists($indexPath());
+ }
+
public array function readIndex() {
if (!FileExists($indexPath())) return [];
local.data = DeserializeJSON(FileRead($indexPath()));
@@ -52,6 +65,11 @@ component {
return $freshFile($manifestPath(arguments.name));
}
+ /** True when this package's manifest was ever written, fresh or not. */
+ public boolean function hasManifest(required string name) {
+ return FileExists($manifestPath(arguments.name));
+ }
+
public struct function readManifest(required string name) {
local.path = $manifestPath(arguments.name);
if (!FileExists(local.path)) {
@@ -132,9 +150,25 @@ component {
}
}
- private string function $defaultRoot() {
+ private string function $defaultRoot(string registryKey = "") {
+ local.root = $cliHome() & "/cache/packages";
+ if (Len(arguments.registryKey)) {
+ local.root &= "/registries/" & Left(LCase(Hash(arguments.registryKey, "SHA-256")), 16);
+ }
+ return local.root;
+ }
+
+ /**
+ * The CLI home, resolved like LuCLI: the lucli.home system property, then
+ * LUCLI_HOME, then ~/.wheels (the brew wrapper's home and the cache's
+ * historical location).
+ */
+ private string function $cliHome() {
local.sys = CreateObject("java", "java.lang.System");
- local.home = local.sys.getProperty("user.home");
- return local.home & "/.wheels/cache/packages";
+ local.prop = local.sys.getProperty("lucli.home");
+ if (!IsNull(local.prop) && Len(local.prop)) return local.prop;
+ local.env = local.sys.getenv("LUCLI_HOME");
+ if (!IsNull(local.env) && Len(local.env)) return local.env;
+ return local.sys.getProperty("user.home") & "/.wheels";
}
}
diff --git a/cli/lucli/services/packages/PackagesMainCli.cfc b/cli/lucli/services/packages/PackagesMainCli.cfc
index 32bbf7bc68..20ceefcbae 100644
--- a/cli/lucli/services/packages/PackagesMainCli.cfc
+++ b/cli/lucli/services/packages/PackagesMainCli.cfc
@@ -10,13 +10,10 @@
* update --all --yes
* remove
*
- * `add` (not `install`!) is the install verb because LuCLI's built-in
- * extension installer intercepts the literal subcommand `install`
- * across all modules — same trap that bit `wheels browser install`
- * (renamed to `wheels browser setup` in #2345). User input
- * `wheels packages install