From d03afb18b7a3e229f28cbaea4846c8cc022179a4 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sat, 16 May 2026 19:02:30 -0700 Subject: [PATCH] docs(claude): trim CLAUDE.md, extract deploy/bot/browser docs, add anti-patterns #11-14 CLAUDE.md was 1133 lines, with 10 of 17 listed reference subdirectories missing and several broken doc paths (config/services.cfm, docs/src/...). Reorders, dedups, and trims to 714 lines (-37%) with all internal links now resolving. Top-of-file restructured for high-leverage content first: - Code Map: where framework/demo/CLI live and how they relate - Before-Done checklist: which test suite to run for which change type - Cross-Engine Invariants: promoted from buried Docker / Browser-Testing locations - Anti-Patterns extended Top 10 -> Top 14, all new entries sourced from recent PRs: - #11 CFML reserved scopes shadow parameters (#2591) - #12 Empty array in whereIn / whereNotIn (#2736) - #13 Comma-list config != single-value HTTP header (#2725) - #14 Strip CFML comments before source-scanning (#2595) Extracted sections, loaded only when relevant: - .ai/wheels/deploy.md (92 lines) - wheels deploy Kamal port reference - .ai/wheels/wheels-bot.md (34 lines) - bot architecture - .ai/wheels/testing/browser-testing.md (68 lines) - browser DSL Other dedups: t.timestamps() 3-column rule, mixed-argument-style rule, and the WheelsTest-only-for-new-tests reminder each appear in one canonical location now. Reference Docs list at bottom lists only verified-to-exist files. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Peter Amiri --- .ai/wheels/deploy.md | 92 ++ .ai/wheels/testing/browser-testing.md | 68 ++ .ai/wheels/wheels-bot.md | 34 + CLAUDE.md | 1147 ++++++++----------------- 4 files changed, 558 insertions(+), 783 deletions(-) create mode 100644 .ai/wheels/deploy.md create mode 100644 .ai/wheels/testing/browser-testing.md create mode 100644 .ai/wheels/wheels-bot.md diff --git a/.ai/wheels/deploy.md b/.ai/wheels/deploy.md new file mode 100644 index 0000000000..a7e7683fed --- /dev/null +++ b/.ai/wheels/deploy.md @@ -0,0 +1,92 @@ +# Deploy Reference + +`wheels deploy` ships a Dockerized Wheels app to production Linux servers via SSH. Ported from Basecamp Kamal's developer CLI — same `config/deploy.yml` schema, same on-server conventions (container names, labels, network, lock path), invokes the same `kamal-proxy` Go binary for zero-downtime rollover. No Ruby runtime required. + +## Commands + +``` +wheels deploy init # scaffold config/deploy.yml + .kamal/secrets +wheels deploy setup # one-time server bootstrap + first deploy +wheels deploy # rolling deploy +wheels deploy --dry-run # print commands without executing +wheels deploy rollback v1 # roll back to a previous version +wheels deploy config # print resolved config as YAML +wheels deploy version # show Kamal version this port mirrors +``` + +## Subcommands + +``` +wheels deploy app # boot/start/stop/details/containers/images/logs/live/maintenance/remove +wheels deploy proxy # boot/reboot/start/stop/restart/details/logs/remove +wheels deploy accessory # boot/reboot/start/stop/restart/details/logs/remove (sidecars: db/redis/search) +wheels deploy build # deliver/push/pull/create/remove/details/dev +wheels deploy registry # setup/login/logout/remove +wheels deploy bootstrap # install Docker on every host (flat alias — preferred) +wheels deploy exec "" # run a command on every host (flat alias — preferred) +wheels deploy server # exec/bootstrap (legacy nested form — see #2677) +wheels deploy prune # all/images/containers [--keep=N] +wheels deploy lock # acquire/release/status (manual — normal deploys auto-lock) +wheels deploy fetch-secrets ... # resolve KEY=VALUE lines from an adapter (flat alias — preferred) +wheels deploy extract-secrets # pull one key from a KEY=VALUE block (flat alias — preferred) +wheels deploy print-secrets # print resolved .kamal/secrets (flat alias — preferred) +wheels deploy secrets # fetch/extract/print (legacy nested form — see #2697) +wheels deploy audit # tail /tmp/kamal-audit.log on each server +wheels deploy details # aggregate app + proxy + accessory status +wheels deploy remove --confirm # teardown all app/proxy/accessory containers +wheels deploy docs [section] # in-terminal config reference +``` + +## On-server parity contract (byte-compatible with Ruby Kamal) + +- Container names: `--` +- Labels: `service=`, `role=`, `destination=`, `version=` +- Docker network: `kamal` +- Lock file: `/tmp/kamal_deploy_lock_` +- Proxy config: `/home//.config/kamal-proxy/` +- Hook env prefix: `KAMAL_*` (never `WHEELS_*` — user hooks migrate unchanged) + +A server managed by Ruby Kamal can be taken over by `wheels deploy` without cleanup. + +## Architecture + +``` +cli/lucli/services/deploy/ +├── cli/*.cfc DeployMainCli + DeployCli +├── commands/*.cfc Base + Docker/App/Proxy/Builder/Registry/Auditor/Lock/Hook/Accessory/PruneCommands +├── config/*.cfc Config + Role/Env/Builder/Proxy/Registry/Ssh/Accessory/Validator/ConfigLoader +├── lib/*.cfc JarLoader/Mustache/Yaml/SshClient/SshPool/FakeSshPool/Output/SecretResolver +└── secrets/*.cfc BaseAdapter + OnePassword/Bitwarden/AwsSecrets/LastPass/Doppler adapters + +cli/lucli/lib/deploy/*.jar jmustache, snakeyaml, sshj + BouncyCastle transitives (URLClassLoader-isolated) +cli/lucli/templates/deploy/ Mustache templates for `wheels deploy init` output +``` + +Commands-are-strings invariant: every `*Commands.cfc` method returns a shell-command string; only `*Cli.cfc` and the orchestrator execute them. That's why `--dry-run` is trivial and unit tests run without network. + +## Critical gotchas + +1. **Kamal-compatible schema, ONE divergence.** ERB in `deploy.yml` is NOT supported (rendering it would require embedding a Ruby runtime). Kamal's native `${VAR}` env-var interpolation is preserved unchanged — uppercase-snake tokens resolve via `envOverride → .kamal/secrets → System.getenv → ""` (see `ConfigLoader.$interpolate`). Mustache (`{{...}}`) is used only by `wheels deploy init` to scaffold a fresh `deploy.yml`/`secrets`; it is NOT applied to `deploy.yml` at runtime. Everything else in `config/deploy.yml` is byte-identical to Kamal 2.4.0. +2. **Hook env prefix is `KAMAL_`, not `WHEELS_`.** Deliberate — Ruby Kamal users' existing `.kamal/hooks/` scripts work unchanged. +3. **`app live` / `app maintenance` use a marker file** (`/tmp/kamal-maintenance-`) rather than kamal-proxy native maintenance mode. Phase 2 simplification; Phase 3 follow-up will align with Kamal's proxy-native semantics. +4. **`wheels deploy remove` is destructive and requires `--confirm`.** Bare `wheels deploy remove` throws without touching anything. +5. **Lucee reserved scope names in subagent-authored deploy code.** `client`, `session`, `application` — use `ssh`/`sc`, `sess`, `app` instead. Bit us multiple times during the port. +6. **No `--dry-run` flag in Ruby Kamal 2.4.0.** The `tools/deploy-config-diff.sh` harness compares config-layer output only. Byte-identical command-string parity is aspirational; see `tools/deploy-dry-run-diff.sh` for the plan. +7. **`wheels deploy server ` collides with LuCLI's top-level `server` command.** LuCLI (the picocli runtime under the wheels brand) registers `server` for Lucee dev-server lifecycle, so picocli grabs the `server` token before it can reach the deploy dispatcher. The wheels module exposes flat aliases `wheels deploy bootstrap` and `wheels deploy exec` that sidestep the collision — these are the canonical CLI form. The nested `server ` branch is retained in `Module.cfc::deploy()` for MCP/programmatic callers. See [#2677](https://github.com/wheels-dev/wheels/issues/2677). +8. **`wheels deploy secrets ` collides with LuCLI's top-level `secrets` command.** Same shape as #2677 — LuCLI registers `secrets` for its own credential store (init/set/list/rm/get/provider). The wheels module exposes flat aliases `wheels deploy fetch-secrets`, `wheels deploy extract-secrets`, and `wheels deploy print-secrets` that sidestep the collision — these are the canonical CLI form. The nested `secrets ` branch is retained for MCP/programmatic callers. See [#2697](https://github.com/wheels-dev/wheels/issues/2697). + +## Testing + +`cli/lucli/tests/specs/deploy/` extends `wheels.wheelstest.system.BaseSpec`. Run with: + + bash tools/test-cli-local.sh + +Fixtures at `cli/lucli/tests/_fixtures/deploy/configs/` (`minimal.yml`, `full.yml`, `with-accessories.yml`, `invalid/*.yml`). `FakeSshPool.cfc` records every command for offline assertions; no sshd needed for unit tests. `SshClientSpec` + `SshPoolSpec` exercise real SSH via the fixture at `cli/lucli/tests/_fixtures/deploy/sshd/` (brought up by `tools/deploy-sshd-up.sh`). + +## 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) +- In-source CLI docs: `cli/lucli/services/deploy/cli/docs/` (per-verb) +- Design spec: [docs/superpowers/specs/2026-04-20-wheels-deploy-kamal-port-design.md](../../docs/superpowers/specs/2026-04-20-wheels-deploy-kamal-port-design.md) +- Implementation plan: [docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md](../../docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md) +- Retrospective: [docs/superpowers/plans/2026-04-21-phase1-retrospective.md](../../docs/superpowers/plans/2026-04-21-phase1-retrospective.md) diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md new file mode 100644 index 0000000000..8de36ff461 --- /dev/null +++ b/.ai/wheels/testing/browser-testing.md @@ -0,0 +1,68 @@ +# Browser Testing + +Shipped in v4.0 across PRs #2113, #2115, #2116. Specs extend `wheels.wheelstest.BrowserTest` and drive a real Chromium through `this.browser` — a fluent DSL wrapping Playwright Java. + +## Example + +```cfm +// vendor/wheels/tests/specs/browser/LoginBrowserSpec.cfc +component extends="wheels.wheelstest.BrowserTest" { + + this.browserEngine = "chromium"; // chromium only in PR 1 + + function run() { + // browserDescribe() wraps describe() with beforeEach/afterEach that + // create a fresh Page per `it`. WheelsTest's BDD lifecycle only treats + // beforeAll/afterAll as class-level, so we register per-it hooks + // 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,Hi

x

") + .assertTitleContains("Hi"); + }); + }); + } +} +``` + +## Installation + +Install Playwright locally before first run (~370MB download: JARs + Chromium): + +```bash +wheels browser setup # downloads JARs + Chromium +``` + +Then run browser specs via the normal test suite: + +```bash +bash tools/test-local.sh # skips browser specs if JARs missing +``` + +## Implemented DSL methods + +- **Navigation:** visit, visitUrl, back, forward, refresh, visitRoute +- **Interaction:** click, press, fill, type, clear, select, check, uncheck, attach, dragAndDrop +- **Keyboard:** keys, pressEnter, pressTab, pressEscape +- **Waiting:** waitFor, waitForText, waitForUrl +- **Scoping:** within(selector, callback) +- **Cookies:** setCookie, deleteCookie, cookie, clearCookies +- **Auth:** loginAs, logout +- **Dialogs:** acceptDialog, dismissDialog, dialogMessage (Lucee-only via createDynamicProxy) +- **Viewport:** resize, resizeToMobile, resizeToTablet, resizeToDesktop +- **Script:** script (returns `page.evaluate` result), pause +- **Assertions (text/vis/presence):** assertSee, assertDontSee, assertSeeIn, assertVisible, assertMissing, assertPresent, assertNotPresent +- **Assertions (URL/title/query):** assertUrlIs, assertUrlContains, assertTitleContains, assertQueryStringHas, assertQueryStringMissing, assertRouteIs +- **Assertions (form):** assertInputValue, assertChecked, assertHasClass +- **Terminals:** currentUrl, title, pageSource, text, value, screenshot + +## Key gotchas + +- **`##` 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. +- **Fixture routes** — `/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. +- **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines. diff --git a/.ai/wheels/wheels-bot.md b/.ai/wheels/wheels-bot.md new file mode 100644 index 0000000000..267e47fcc0 --- /dev/null +++ b/.ai/wheels/wheels-bot.md @@ -0,0 +1,34 @@ +# Wheels Bot + +`wheels-bot[bot]` is a custom GitHub App that runs Claude-powered automation on issues and PRs in `wheels-dev/wheels`. Five stages, all opt-out via the `[skip-claude]` label or repo variable `WHEELS_BOT_ENABLED=false`. Slash-command prompts live in `.claude/commands/`; workflows in `.github/workflows/bot-*.yml`. Full user-facing docs: [`docs/contributing/wheels-bot.md`](../../docs/contributing/wheels-bot.md). + +## Stages + +| Stage | Trigger | Model | Output | +|---|---|---|---| +| Triage | issue opened/reopened | Opus | Comment classifying as `bug` / `framework-design` / `other` (+ confidence on `bug` path). Reads code with the allowlisted tools to resolve uncertainty before rating. | +| Research | bot triage emits `framework-design` marker | Opus | Comment comparing Rails / Laravel / Django / Phoenix / Spring Boot / +1 and recommending a Wheels-idiomatic path (+ confidence). | +| Propose Fix | bot triage emits `triage-confidence:high\|medium` OR research emits `research-confidence:high\|medium` (or `workflow_dispatch`) | Opus | TDD-mandatory draft PR on branch `fix/bot--`. Spec-then-implementation, both required by `bot-tdd-gate.yml`. | +| Reviewer A | PR opened / synchronized / ready_for_review | Sonnet | Single PR review with line comments, verdict, and `wheels-bot:review-a::` marker. | +| Reviewer B | Reviewer A submits a review | Sonnet | PR comment critiquing A for sycophancy, false positives, and missed issues. Loop cap = 3 rounds. | + +## Marker conventions (HTML comments, used for idempotency) + +- `` + `` (+ optional `` — either fires propose-fix; low omitted) +- `` (+ optional `` — either fires propose-fix; low omitted) +- `` / `` +- `` +- `` +- `` + +## Allow-listed scopes per stage + +Every bot-authored commit must conform to the `commitlint.config.js` allowlist (see CLAUDE.md § Commit Message Conventions). The bot's prompt (`.claude/commands/_shared-rails.md`) re-states the allowlist verbatim. + +## Kill switch + +Flip the repo variable `WHEELS_BOT_ENABLED` to `false` to halt every bot workflow without code changes. Add the `[skip-claude]` label (or `[skip-claude]` in the title) to halt activity on a single issue/PR. + +## Auto-fire safety net + +The bot is permitted to chain stages (triage → research → propose-fix), and handoff fires on `*-confidence:high` OR `*-confidence:medium`. Low stays manual. Sensitive areas (security, middleware, migrations, deploy, DI, cross-engine) are caught by the propose-fix prompt's own step-4 safety net, which posts a `fix-held` marker instead of opening a PR. Reviewer A and B then critique whatever propose-fix produces, escalating to the Senior Advisor on deadlock. All bot PRs land as `--draft` and require a human approving review on `develop`. diff --git a/CLAUDE.md b/CLAUDE.md index 2266b99fee..386f3b412e 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,39 +1,66 @@ # Wheels Framework -CFML MVC framework with ActiveRecord ORM. Models in `app/models/`, controllers in `app/controllers/`, views in `app/views/`, migrations in `app/migrator/migrations/`, config in `config/`, tests in `tests/`. +CFML MVC framework with ActiveRecord ORM. The framework itself lives in `vendor/wheels/` (NOT a dependency — this repo IS the framework). The repo also contains a demo app under `app/` you can hand-test against. -## Directory Layout +## Code Map (where things live) ``` -app/controllers/ app/models/ app/views/ app/views/layout.cfm -app/migrator/migrations/ app/db/seeds.cfm app/db/seeds/ -app/events/ app/global/ app/lib/ -app/mailers/ app/jobs/ app/plugins/ app/snippets/ -config/settings.cfm config/routes.cfm config/environment.cfm -plugins/ public/ tests/ vendor/ .env (never commit) +vendor/wheels/ Framework core (model/, controller/, view/, dispatch/, migrator/, middleware/, …) +vendor/wheels/tests/specs/ Framework test suite — what CI runs across every engine × DB +app/ Demo app (models, controllers, views, migrations) — exercise framework changes here +tests/specs/ Demo-app test suite (separate from the framework suite) +cli/lucli/ The `wheels` binary — branded LuCLI runtime + Module.cfc (MCP tools) +cli/lucli/services/deploy/ `wheels deploy` (Kamal port — see .ai/wheels/deploy.md) +cli/lucli/tests/specs/ CLI test suite +config/settings.cfm Demo-app config (routes.cfm, environment.cfm, services.cfm-if-present) +plugins/ DEPRECATED — legacy plugin system; modern packages live in vendor// +.ai/wheels/ Deep reference docs Claude searches when needed +.claude/commands/ Wheels-bot prompts (.github/workflows/bot-*.yml runs these) ``` -## Development Tools +**Branding:** the project name is **Wheels** (not "CFWheels"). The rebrand happened at v3.0. Use "Wheels" in code, comments, commits, PRs, and docs. -Prefer MCP tools when the Wheels MCP server is available (`mcp__wheels__*`). Fall back to CLI otherwise. +## Before Reporting a Change Complete -| Task | MCP | CLI | -|------|-----|-----| -| Generate | `wheels_generate(type, name, attributes)` | `wheels g model/controller/scaffold Name attrs` | -| Migrate | `wheels_migrate(action="latest\|up\|down\|info")` | `wheels migrate latest\|up\|down\|info` | -| Test | `wheels_test()` | `wheels test run` | -| Reload | `wheels_reload()` | `?reload=true&password=...` | -| Server | `wheels_server(action="status")` | `wheels start\|stop\|status` | -| Analyze | `wheels_analyze(target="all")` | — | -| Admin | — | `wheels g admin ModelName` | -| Seed | — | `wheels seed` (legacy alias: `wheels db:seed`) | +| If you touched | Run | Required? | +|---|---|---| +| `vendor/wheels/**` | `bash tools/test-local.sh` (full) or `bash tools/test-local.sh ` | Always | +| `app/**` only | Demo-app specs via `wheels test run` | Always | +| `cli/lucli/**` | `bash tools/test-cli-local.sh` | Always | +| Anything cross-engine-risky (closures, `obj.map()`, reserved scopes, struct literals, mixins) | `tools/test-matrix.sh adobe2023 mysql` AND `tools/test-matrix.sh lucee7 mysql` | If touched code matches any anti-pattern below | +| Added/changed a migration | `wheels migrate latest && wheels migrate down && wheels migrate up` | Always | +| Changed a public framework API | `grep -r` callers under `vendor/wheels`, `app`, `tests`, `cli/lucli/tests` | Always | + +Type checks and a green test suite verify *code correctness*. They do NOT verify *feature correctness* for UI changes — if you changed a view/form/route, hand-test it in a browser or say so explicitly. + +## Cross-Engine Invariants (apply to every change in `vendor/wheels/`) + +The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang. These rules cause more bugs than anything else combined. + +1. **`obj.map()` resolves to the built-in struct member function** on Lucee/Adobe — not your CFC method. Use `mapInstance()` on the Injector, or rename your method. +2. **`application` scope doesn't accept function members on Adobe CF.** Pass a plain struct context instead. +3. **Closure `this` captures the declaring scope** — use `var ctx = {ref: obj}` to share references across closures. +4. **`obj["key"]()` inside closures crashes Adobe CF 2021/2023's parser.** Split: `var fn = obj["key"]; fn();`. +5. **Inline closure as constructor named arg** (`new Foo(callback = function(){...})`) crashes Adobe CF with `ArrayStoreException: ASTcffunction`. **Worse: it takes down the entire TestBox bundle** because `getComponentMetadata()` triggers eager compilation. Hoist: `var fn = function(){...}; new Foo(callback = fn);`. +6. **Adobe CF copies arrays by value in struct literals.** `{arr = myArray}` then mutating `arr` inside a closure won't affect the original. Use parent struct ref: `{owner = parentStruct}` then `owner.arr`. +7. **`private` mixin functions are not integrated.** `$integrateComponents()` only copies `public` methods into model/controller objects. ALL helpers in `vendor/wheels/model/*.cfc`, view helpers, etc. MUST use `public` access with `$` prefix for internal scope. BoxLang passes; Lucee/Adobe fail. +8. **`Left(str, 0)` crashes Lucee 7.** Guard: `len > 0 ? Left(str, len) : ""`. +9. **`toBeInstanceOf("component")` fails on BoxLang** — returns the FQN, not the literal `"component"`. Use `toBeWheelsModel()` for finder results. + +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" | \ + python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass',0),'pass',d.get('totalFail',0),'fail',d.get('totalError',0),'error')" +``` + +Deep reference: [.ai/wheels/cross-engine-compatibility.md](.ai/wheels/cross-engine-compatibility.md). -## Critical Anti-Patterns (Top 10) +## Anti-Patterns (Top 14) -These are the most common mistakes when generating Wheels code. Check every time. +These are the most common mistakes when generating or modifying Wheels code. Check every time. ### 1. Mixed Argument Styles -Wheels functions cannot mix positional and named arguments. This is the #1 error source. +Wheels functions cannot mix positional and named arguments. #1 error source. ```cfm // WRONG — mixed positional + named hasMany("comments", dependent="delete"); @@ -61,7 +88,6 @@ Model finders return query objects, not arrays. Loop accordingly. ``` ### 3. Nested Resource Routes — Use Callback Syntax -Wheels supports nested resources via the `callback` parameter or `nested=true` with manual `end()`. Do NOT use Rails-style inline function blocks. ```cfm // WRONG — Rails-style inline (not supported) .resources("posts", function(r) { r.resources("comments"); }) @@ -75,16 +101,10 @@ Wheels supports nested resources via the `callback` parameter or `nested=true` w .resources(name="posts", nested=true) .resources("comments") .end() - -// RIGHT — flat separate declarations (no URL nesting) -.resources("posts") -.resources("comments") ``` -### 4. HTML5 Form Helpers Available -Wheels provides dedicated HTML5 input helpers. Use them instead of manual type attributes. +### 4. HTML5 Form Helpers Exist — Use Them ```cfm -// Object-bound helpers #emailField(objectName="user", property="email")# #urlField(objectName="user", property="website")# #numberField(objectName="product", property="quantity", min="1", max="100")# @@ -93,76 +113,119 @@ Wheels provides dedicated HTML5 input helpers. Use them instead of manual type a #colorField(objectName="theme", property="primaryColor")# #rangeField(objectName="settings", property="volume", min="0", max="100")# #searchField(objectName="search", property="query")# - -// Tag-based helpers -#emailFieldTag(name="email", value="")# -#numberFieldTag(name="qty", value="1", min="0", step="1")# +// Tag forms: emailFieldTag, numberFieldTag, etc. ``` -### 5. Migration Seed Data — Use Direct SQL -Parameter binding in `execute()` is unreliable. Use inline SQL for seed data. +### 5. Migration Seed Data — Direct SQL Only +Parameter binding in `execute()` is unreliable. Use inline SQL. ```cfm // WRONG execute(sql="INSERT INTO roles (name) VALUES (?)", parameters=[{value="admin"}]); -// RIGHT +// RIGHT — and use NOW() for database-agnostic dates (MySQL/PG/MSSQL/H2/SQLite) execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', NOW(), NOW())"); ``` ### 6. Route Order Matters -Routes are matched first-to-last. Wrong order = wrong matches. +Routes match first-to-last. Wrong order = wrong matches. ``` Order: MCP routes → resources → custom named routes → root → wildcard (last!) ``` -### 7. timestamps() Includes createdAt, updatedAt, and deletedAt -Don't also add separate datetime columns for these. +### 7. `timestamps()` Adds Three Columns (Not Two) +`createdAt`, `updatedAt`, AND `deletedAt` (soft-delete marker). Don't add separate datetime columns for these. Verified against `vendor/wheels/migrator/TableDefinition.cfc`. + +### 8. Controller Filters Must Be Private +Public filter functions become routable actions. ```cfm -// WRONG — duplicates -t.timestamps(); -t.datetime(columnNames="createdAt"); +// WRONG +function authenticate() { ... } // RIGHT -t.timestamps(); // creates createdAt, updatedAt, AND deletedAt (soft-delete) +private function authenticate() { ... } +``` + +### 9. Always cfparam View Variables +Every variable passed from controller to view needs a cfparam at the top of the view file. +```cfm + + ``` -Note: `t.timestamps()` adds three columns, not two — the third is the soft-delete marker. Verified against `vendor/wheels/migrator/TableDefinition.cfc`. -### 8. Database-Agnostic Dates in Migrations -Use `NOW()` — it works across MySQL, PostgreSQL, SQL Server, H2, SQLite. +### 10. Test Closure Scope +CFML closures can't access outer `local` vars. Use shared structs: ```cfm -// WRONG — database-specific -execute("INSERT INTO users (name, createdAt) VALUES ('Admin', CURRENT_TIMESTAMP)"); +// WRONG +var count = 0; +items.each(function(i) { count++; }); // local.count not visible // RIGHT -execute("INSERT INTO users (name, createdAt, updatedAt) VALUES ('Admin', NOW(), NOW())"); +var result = {count: 0}; +items.each(function(i) { result.count++; }); ``` -### 9. Controller Filters Must Be Private -Filter functions (authentication, data loading) must be declared `private`. +### 11. CFML Reserved Scopes Shadow Function Parameters +**Source:** [#2591](https://github.com/wheels-dev/wheels/pull/2591) — `consoleExec(url, body)` received the URL scope struct in place of the URL string, throwing `Cannot cast Object type [url] to a value of type [string]`. + +Reserved scope names in CFML: `url`, `form`, `cgi`, `client`, `session`, `application`, `cookie`, `request`, `server`, `arguments`, `variables`. Naming a function parameter, local var, or argument the same as a scope shadows it but the scope can also win depending on engine and context. + ```cfm -// WRONG — public filter becomes a routable action -function authenticate() { ... } +// WRONG +function consoleExec(required string url, required string body) { + makeHttpPost(url, body); // url = URL scope struct on Lucee, not the string +} // RIGHT -private function authenticate() { ... } +function consoleExec(required string requestUrl, required string body) { + makeHttpPost(requestUrl, body); +} ``` -### 10. Always cfparam View Variables -Every variable passed from controller to view needs a cfparam declaration. +Rule: never use a reserved scope name as a parameter, local var, or function argument name. Also avoid `client` in browser-test code (Lucee throws "client scope is not enabled" when accessed). + +### 12. Empty Array in `whereIn` / `whereNotIn` +**Source:** [#2736](https://github.com/wheels-dev/wheels/pull/2736) — `whereIn("id", [])` previously emitted literal `WHERE id IN ()`, a JDBC syntax error on every supported engine. + ```cfm -// At top of every view file - - +// As of 4.0.x — short-circuits to 1=0 (no rows) for IN, 1=1 (all rows) for NOT IN +model("Post").whereIn("id", []).count() // 0 +model("Post").whereNotIn("id", []).count() // total count +model("Post").where("status","active").whereIn("id", []).count() // 0 (composes) ``` +When writing query-builder methods or anything that interpolates arrays into SQL `IN`/`NOT IN`: always handle empty inputs explicitly. Empty inputs aren't exotic — they're what you get from form filters, sub-query results, and any runtime-built array. + +### 13. Comma-List Config ≠ Single-Value HTTP Header +**Source:** [#2725](https://github.com/wheels-dev/wheels/pull/2725) — `Cors` middleware was echoing the comma-delimited `allowOrigins` config straight into `Access-Control-Allow-Origin`, violating the CORS spec (must be a single origin or `*`) and poisoning CDN caches. + +When config accepts a list-shape (comma-delimited string or array) but the output is a single-value protocol field, you MUST resolve to one value (or omit the header). Don't pass the list through. + +```cfm +// WRONG +header("Access-Control-Allow-Origin", listed); // "https://a.com,https://b.com" + +// RIGHT — match against request origin, emit single value or omit +var resolved = $resolveAllowOrigin(allowOrigins, requestOrigin); // "" | "*" | "https://a.com" +if (len(resolved)) header("Access-Control-Allow-Origin", resolved); +``` + +Pair with `Vary: Origin` whenever the response varies by request origin ([#2724](https://github.com/wheels-dev/wheels/pull/2724)). + +### 14. Strip CFML Comments Before Source-Scanning +**Source:** [#2595](https://github.com/wheels-dev/wheels/pull/2595) — `wheels validate` checked for `extends="Model"` with raw `findNoCase()` and was satisfied by a commented-out `// component extends="Model"` line, missing real missing-inheritance bugs. + +Any validator, analyzer, scanner, or upgrade-check that does substring-matching over CFML source must strip line comments (`// …`), block comments (`/* … */`), AND tag comments (``) first. Helpers exist: +- `cli/lucli/services/Analysis.cfc::$stripCfmlComments()` +- `cli/lucli/Module.cfc::stripCfmlComments()` +- `cli/lucli/services/Doctor.cfc::$stripCfmlBlockComments()` + ## Wheels Conventions -- **config()**: All model associations/validations/callbacks and controller filters/verifies go in `config()` -- **Naming**: Models are singular PascalCase (`User.cfc`), controllers are plural PascalCase (`Users.cfc`), table names are plural lowercase (`users`) -- **Parameters**: `params.key` for URL key, `params.user` for form struct, `params.user.firstName` for nested -- **extends**: Models extend `"Model"`, controllers extend `"Controller"`, tests extend `"wheels.WheelsTest"` (legacy: `"wheels.Test"` for RocketUnit) -- **Associations**: All named params when using options: `hasMany(name="orders")`, `belongsTo(name="user")`, `hasOne(name="profile")` -- **Validations**: Property param is `property` (singular) for single, `properties` (plural) for list: `validatesPresenceOf(properties="name,email")` +- **config()**: All model associations/validations/callbacks and controller filters/verifies go in `config()`. +- **Naming**: Models singular PascalCase (`User.cfc`), controllers plural PascalCase (`Users.cfc`), tables plural lowercase (`users`). +- **Parameters**: `params.key` for URL key, `params.user` for form struct, `params.user.firstName` for nested. +- **extends**: Models extend `"Model"`, controllers extend `"Controller"`, tests extend `"wheels.WheelsTest"`. (Legacy: `"wheels.Test"` was RocketUnit — never use for new tests.) +- **Validation property param**: `property` (singular) for single, `properties` (plural) for list: `validatesPresenceOf(properties="name,email")`. ## Model Quick Reference @@ -195,28 +258,28 @@ component extends="Model" { enum(property="priority", values={low: 0, medium: 1, high: 2}); } - // Dynamic scope handler (must return struct with query keys) private struct function scopeByRole(required string role) { return {where: "role = '#arguments.role#'"}; } } ``` -Finders: `model("User").findAll()`, `model("User").findOne(where="...")`, `model("User").findByKey(params.key)`. -Create: `model("User").new(params.user)` then `.save()`, or `model("User").create(params.user)`. +Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`. +Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`. Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`. -### Scopes (Composable Query Fragments) +### Scopes / Enums / Builder / Batch + ```cfm -// Chain scopes together — each adds to the query +// Scopes — chain composably model("User").active().recent().findAll(); model("User").byRole("admin").findAll(page=1, perPage=25); -model("User").active().recent().count(); -``` -### Chainable Query Builder (Injection-Safe) -```cfm -// Fluent alternative to raw WHERE strings — values are auto-quoted +// Enums — auto-generated checkers and scopes +user.isDraft(); // true/false +model("User").draft().findAll(); + +// Chainable query builder (injection-safe; values auto-quoted) model("User") .where("status", "active") .where("age", ">", 18) @@ -224,55 +287,98 @@ model("User") .orderBy("name", "ASC") .limit(25) .get(); +// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, get -// Combine with scopes -model("User").active().where("role", "admin").get(); - -// Other builder methods: orWhere, whereNull, whereBetween, whereIn, whereNotIn +// Batch processing — memory-efficient +model("User").findEach(batchSize=1000, callback=function(user) { + user.sendReminderEmail(); +}); +model("User").findInBatches(batchSize=500, callback=function(users) { + processUserBatch(users); +}); ``` -### Enums (Named Property Values) +## Routing Quick Reference + ```cfm -// Auto-generated boolean checkers -user.isDraft(); // true/false -user.isPublished(); // true/false +mapper() + .resources("users") + .resources("products", except="delete") + .resources(name="posts", callback=function(map) { + map.resources("comments"); + }) + .get(name="login", to="sessions##new") + .post(name="authenticate", to="sessions##create") + .root(to="home##index", method="get") + .wildcard() // keep last! +.end(); +``` -// Auto-generated scopes per value -model("User").draft().findAll(); -model("User").published().findAll(); +Helpers: `linkTo(route="user", key=user.id)`, `urlFor(route="users")`, `redirectTo(route="user", key=user.id)`, `startFormTag(route="user", method="put", key=user.id)`. + +### Route Model Binding + +Resolves `params.key` into a model instance before the action runs. Lands in `params.`. Throws `Wheels.RecordNotFound` (404) if missing; silently skips if the model class doesn't exist. + +```cfm +.resources(name="users", binding=true) // params.user +.resources(name="posts", binding="BlogPost") // params.blogPost +.scope(path="/api", binding=true) // all nested resources bound +.end() +set(routeModelBinding=true); // global, in config/settings.cfm ``` -### Batch Processing (Memory-Efficient) +## Pagination View Helpers + +Requires a paginated query: `findAll(page=params.page, perPage=25)`. Recommended all-in-one helper: `paginationNav()`. + ```cfm -// Process one record at a time (loads in batches internally) -model("User").findEach(batchSize=1000, callback=function(user) { - user.sendReminderEmail(); -}); +// All-in-one nav +#paginationNav()# +#paginationNav(showInfo=true, showFirst="never", showLast="never", navClass="my-pagination")# +#paginationNav(windowSize=3)# -// Process in batch groups (callback receives query/array) -model("User").findInBatches(batchSize=500, callback=function(users) { - processUserBatch(users); -}); +// Declarative presets — Bootstrap 4/5 and Tailwind +#paginationNav(viewStyle="bootstrap5")# +#paginationNav(viewStyle="bootstrap4")# +#paginationNav(viewStyle="tailwind")# -// Works with scopes and conditions -model("User").active().findEach(batchSize=500, callback=function(user) { /* ... */ }); +// Manual composition (like-for-like swap for legacy paginationLinks) +#paginationNav( + navClass="", + prepend='
    ', + append="
", + prependToPage='
  • ', + appendToPage="
  • ", + class="page-link", + classForCurrent="active", + addActiveClassToPrependedParent=true +)# + +// Individual helpers +#paginationInfo()# #firstPageLink()# #previousPageLink()# +#pageNumberLinks()# #nextPageLink()# #lastPageLink()# ``` +`showFirst` / `showLast` / `showPrevious` / `showNext` accept `"auto"` (default), `"always"`, or `"never"`. Under `"auto"` the first/last anchors are hidden when the window already reaches the boundary; previous/next render disabled `` at boundaries to preserve position. Booleans coerce (`true`→`"always"`, `false`→`"never"`). + +`viewStyle` accepts `"plain"` (default), `"bootstrap5"`, `"bootstrap4"`, `"tailwind"`. Bootstrap presets emit `
  • N
  • `. Non-plain presets ignore manual-composition args. + +In development, `paginationNav()` throws `Wheels.PaginationNav.InvalidArgument` for unknown sub-helper args. `windowSize` is consumed by `paginationNav` itself (not forwarded). Accepted pass-through: `format, text, name, class, disabledClass, showDisabled, pageNumberAsParam, classForCurrent, linkToCurrentPage, prependToPage, appendToPage, addActiveClassToPrependedParent, route, controller, action, key, anchor, onlyPath, host, protocol, port, params`. Named route segment variables are auto-exempted from the check. + ## Middleware Quick Reference Middleware runs at the dispatch level, before controller instantiation. Each implements `handle(request, next)`. ```cfm -// config/settings.cfm — global middleware (runs on every request) +// config/settings.cfm — global middleware set(middleware = [ new wheels.middleware.RequestId(), new wheels.middleware.SecurityHeaders(), new wheels.middleware.Cors(allowOrigins="https://myapp.com") ]); -``` -```cfm -// config/routes.cfm — route-scoped middleware +// config/routes.cfm — route-scoped mapper() .scope(path="/api", middleware=["app.middleware.ApiAuth"]) .resources("users") @@ -280,74 +386,40 @@ mapper() .end(); ``` -Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`, `wheels.middleware.RateLimiter`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`. - -## DI Container Quick Reference - -Register services in `config/services.cfm` (loaded at app start, environment overrides supported): - -```cfm -var di = injector(); -di.map("emailService").to("app.lib.EmailService").asSingleton(); -di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped(); -di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton(); -``` - -Resolve with `service()` anywhere, or use `inject()` in controller `config()`: - -```cfm -// In any controller/view -var svc = service("emailService"); +Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`, `wheels.middleware.RateLimiter`. Custom: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`. -// Declarative injection in controller config() -function config() { - inject("emailService, currentUser"); -} -function create() { - this.emailService.send(to=user.email); // resolved per-request -} -``` - -Scopes: transient (default, new each call), `.asSingleton()` (app lifetime), `.asRequestScoped()` (per-request via `request.$wheelsDICache`). Auto-wiring: `init()` params matching registered names are auto-resolved when no `initArguments` passed. `bind()` = semantic alias for `map()`. ### Rate Limiting ```cfm -// Fixed window (default) — 60 requests per 60 seconds -new wheels.middleware.RateLimiter() - -// Sliding window — smoother enforcement +new wheels.middleware.RateLimiter() // fixed window, 60 req / 60s new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=120, strategy="slidingWindow") - -// Token bucket — allows bursts up to capacity, refills steadily new wheels.middleware.RateLimiter(maxRequests=50, windowSeconds=60, strategy="tokenBucket") - -// Database-backed storage (auto-creates wheels_rate_limits table) -new wheels.middleware.RateLimiter(storage="database") - -// Custom key function (rate limit per API key instead of IP) -new wheels.middleware.RateLimiter(keyFunction=function(req) { +new wheels.middleware.RateLimiter(storage="database") // auto-creates wheels_rate_limits +new wheels.middleware.RateLimiter(keyFunction=function(req) { // rate-limit per API key return req.cgi.http_x_api_key ?: "anonymous"; }) ``` -Strategies: `fixedWindow` (default), `slidingWindow`, `tokenBucket`. Storage: `memory` (default) or `database`. Adds `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` headers. Returns `429 Too Many Requests` with `Retry-After` when limit exceeded. +Strategies: `fixedWindow` (default), `slidingWindow`, `tokenBucket`. Storage: `memory` or `database`. Emits `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. Returns `429` with `Retry-After` when exceeded. -`windowSeconds` must be > 0; `maxRequests` must be >= 0. Invalid values throw `Wheels.RateLimiter.InvalidConfiguration` at construction. `maxRequests = 0` is a valid kill-switch that blocks all requests. +`windowSeconds` must be > 0; `maxRequests` must be >= 0. Invalid values throw `Wheels.RateLimiter.InvalidConfiguration` at construction. `maxRequests = 0` is a valid kill-switch. -## Package System +## DI Container Quick Reference -Optional first-party modules are distributed as standalone repositories and installed into `vendor//`. The framework auto-discovers `vendor/*/package.json` on startup via `PackageLoader.cfc` with per-package error isolation. +Register services in `config/services.cfm` (loaded at app start; environment overrides supported): -Public author-facing guide: [Packages](web/sites/guides/src/content/docs/v4-0-1-snapshot/digging-deeper/packages.mdx) — manifest fields (including `mapping`), mixin targets, lifecycle, service providers, lazy loading, testing, publishing flow. Submission workflow: [wheels-packages/CONTRIBUTING.md](https://github.com/wheels-dev/wheels-packages/blob/main/CONTRIBUTING.md). +```cfm +var di = injector(); +di.map("emailService").to("app.lib.EmailService").asSingleton(); +di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped(); +di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton(); +``` + +Resolve with `service("emailService")` anywhere, or `inject("emailService, currentUser")` in controller `config()`. Scopes: transient (default), `.asSingleton()`, `.asRequestScoped()`. Auto-wiring: `init()` params matching registered names are auto-resolved when no `initArguments` passed. -Six first-party packages live in standalone repos under `wheels-dev/`, indexed by the `wheels-dev/wheels-packages` registry: +## Package System -- `wheels-dev/wheels-sentry` — error tracking -- `wheels-dev/wheels-hotwire` — Turbo/Stimulus -- `wheels-dev/wheels-basecoat` — UI components -- `wheels-dev/wheels-legacy-adapter` — 3.x → 4.x compatibility shims -- `wheels-dev/wheels-i18n` — internationalization (JSON or DB-backed translations, pluralization) -- `wheels-dev/wheels-seo-suite` — SEO tooling (meta tags, Open Graph, sitemaps, robots.txt, debug panel) +Optional first-party modules distributed as standalone repos and installed into `vendor//`. Auto-discovered from `vendor/*/package.json` on startup via `PackageLoader.cfc` with per-package error isolation. ``` vendor/ # Runtime: framework core + installed packages @@ -356,182 +428,55 @@ vendor/ # Runtime: framework core + installed packages plugins/ # DEPRECATED: legacy plugins still work with warning ``` +First-party packages live in standalone repos under `wheels-dev/`, indexed by `wheels-dev/wheels-packages`: +- `wheels-sentry` — error tracking +- `wheels-hotwire` — Turbo/Stimulus +- `wheels-basecoat` — UI components +- `wheels-legacy-adapter` — 3.x → 4.x compatibility shims +- `wheels-i18n` — internationalization +- `wheels-seo-suite` — SEO tooling + ### package.json Manifest ```json { "name": "wheels-sentry", "version": "1.0.0", - "author": "PAI Industries", - "description": "Sentry error tracking", "wheelsVersion": ">=3.0", - "mappings": { - "plugins.sentry": "." - }, - "provides": { - "mixins": "controller", - "services": [], - "middleware": [] - }, - "requires": {}, - "replaces": {}, - "suggests": {} + "mappings": {"plugins.sentry": "."}, + "provides": {"mixins": "controller", "services": [], "middleware": []}, + "requires": {}, "replaces": {}, "suggests": {} } ``` -**`mapping`**: Optional CFML-identifier-safe alias registered as a CFML mapping at load time. Lets CFCs inside the package use `new wheelsSentry.SentryClient()` instead of `CreateObject("component", "vendor.wheels-sentry.SentryClient")`. Defaults to lower-camel-case of `name` (`wheels-sentry` → `wheelsSentry`). Must match `[A-Za-z_][A-Za-z0-9_]*`. Two packages that compute the same alias: the second fails with `Duplicate package mapping alias`. Inspect registered aliases via `PackageLoader.getPackageMappings()`. - -**`mappings`** (plural): Optional struct of additional dotted CFML mapping aliases beyond the singular `mapping`. Keys are dotted names (e.g. `plugins.sentry`); values are paths relative to the package directory (`"."` for the root, `"sub"` for a subdirectory). Each dot-separated segment must match `[A-Za-z_][A-Za-z0-9_]*`. Absolute paths and `..` traversal are rejected. Collisions with another package's singular OR plural alias fail the package and unwind its singular alias from the registry. Use this to declare legacy compatibility paths so callsites like `new plugins.sentry.SentryClient()` keep resolving when a package is renamed or relocated. See GH#2705. - -**`provides.mixins`**: Comma-delimited targets from the allowlist `application,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test`, plus the special values `global` (inject into all targets) and `none` (explicit opt-out). Determines which framework components receive the package's public methods. Default: `none` (explicit opt-in, unlike legacy plugins which default to `global`). Unknown targets (typos, `view`, `service`, etc.) are rejected with a clear error — view helpers belong in `controller` mixins since Wheels views execute in the controller's variables scope. - -**`requires` / `replaces` / `suggests`**: Inter-package relationships, each a map of package name → semver constraint. `requires` is a hard dependency (missing target or version mismatch fails this package). `replaces` excludes the named package from loading when present and version-matched (migration path). `suggests` is a soft edge — influences load order but never fails on absence. The loader reads these fields, not legacy `dependencies` (which belongs to the 3.x plugin shape and is ignored on the package surface). - -### Installing a Package - -Use the `wheels packages` CLI. Resolves names against the `wheels-dev/wheels-packages` registry, verifies sha256, extracts to `vendor//`. - -```bash -wheels packages list # browse the registry -wheels packages search # name/description/tag match -wheels packages show # detail page -wheels packages add # latest compat version (canonical verb) -wheels packages add @ # pin -wheels packages add --force # overwrite an existing vendor/ -wheels packages update --yes # explicit update -wheels packages update --all --yes # update every installed package -wheels packages remove # delete vendor/ -wheels packages registry refresh # bust the 24h cache -wheels packages registry info # show registry URL + cache state -``` - -Override the registry with `WHEELS_PACKAGES_REGISTRY=/` (defaults to `wheels-dev/wheels-packages`). Restart or `wheels reload` after install. - -### Error Isolation - -Each package loads in its own try/catch. A broken package is logged and skipped — the app and other packages continue normally. +- **`mapping`** (singular): CFML-identifier-safe alias registered as a CFML mapping. Defaults to lower-camel-case of `name`. Lets package CFCs use `new wheelsSentry.SentryClient()`. +- **`mappings`** (plural): struct of dotted aliases beyond the singular. Use for legacy compatibility paths (e.g., `plugins.sentry` keeps old call sites resolving). See [#2705](https://github.com/wheels-dev/wheels/pull/2705). +- **`provides.mixins`**: comma-delimited from `application,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test`, plus `global` or `none`. Default `none`. View helpers belong in `controller` mixins (views execute in controller's `variables` scope). +- **`requires` / `replaces` / `suggests`**: package name → semver constraint. Loader uses these, NOT legacy `dependencies`. -### Testing Packages +### CLI ```bash -# Run a specific package's tests (package must be in vendor/) -curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.wheels-sentry.tests" +wheels packages list # browse registry +wheels packages search +wheels packages show +wheels packages add # latest compat version (canonical verb) +wheels packages add @ # pin +wheels packages add --force # overwrite existing +wheels packages update --yes +wheels packages update --all --yes +wheels packages remove +wheels packages registry refresh # bust 24h cache ``` -## Routing Quick Reference - -```cfm -// config/routes.cfm -mapper() - .resources("users") // standard CRUD - .resources("products", except="delete") // skip actions - .resources(name="posts", callback=function(map) { // nested resources - map.resources("comments"); - map.resources("tags"); - }) - .get(name="login", to="sessions##new") // named route - .post(name="authenticate", to="sessions##create") - .root(to="home##index", method="get") // homepage - .wildcard() // keep last! -.end(); -``` - -Helpers: `linkTo(route="user", key=user.id, text="View")`, `urlFor(route="users")`, `redirectTo(route="user", key=user.id)`, `startFormTag(route="user", method="put", key=user.id)`. - -### Route Model Binding - -Automatically resolves `params.key` into a model instance before the controller action runs. The instance lands in `params.` (e.g., `params.user`). Throws `Wheels.RecordNotFound` (404) if the record doesn't exist; silently skips if the model class doesn't exist. - -```cfm -// Per-resource — convention: singularize controller name → model -.resources(name="users", binding=true) - -// Explicit model name override -.resources(name="posts", binding="BlogPost") // resolves BlogPost, stored in params.blogPost - -// Scope-level — all nested resources inherit binding -.scope(path="/api", binding=true) - .resources("users") // params.user - .resources("products") // params.product -.end() - -// Global — enable for all resource routes -set(routeModelBinding=true); // in config/settings.cfm -``` - -In the controller, use the resolved instance directly: -```cfm -function show() { - user = params.user; // already a model object, no findByKey needed -} -``` - -## Pagination View Helpers - -Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recommended all-in-one helper is `paginationNav()`. - -```cfm -// All-in-one nav (wraps first/prev/page-numbers/next/last in