Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3110-reload-refire-contract.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels reload` and `wheels packages add` no longer claim that `?reload=true` skips `onApplicationStart`. An authorized reload calls `applicationStop()`, so the next request re-fires `onApplicationStart` in full (re-running `config/services.cfm` and the package loader) — the CLI now says a reload activates an installed package and notes that only a missing/wrong reload password silently skips the restart (#3110)
15 changes: 9 additions & 6 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -838,12 +838,15 @@ component extends="modules.BaseModule" {
var reloadUrl = "http://localhost:#serverPort#/?reload=true&password=#password#";
var httpResult = makeHttpRequest(reloadUrl);
out("Application reloaded successfully.", "green");
// Surface the hot-vs-cold reload contract — Wheels does NOT
// re-fire onApplicationStart on `?reload=true`. Users editing
// app/events/onapplicationstart.cfm or config/services.cfm need
// a full restart. See finding #8 in the 2026-04-29 fresh-VM
// triage.
out("Note: onApplicationStart does NOT re-fire. For init-code edits, run `wheels stop && wheels start`.", "cyan");
// Surface the actual reload contract (verified live on Lucee 7,
// see #3110): an authorized `?reload=true&password=...` calls
// applicationStop(), so the next request re-fires onApplicationStart
// in full — app/events/onapplicationstart.cfm, config/services.cfm,
// and the PackageLoader all re-run. Caveat: the restart only
// happens when the reload password resolves; a missing or wrong
// password silently serves the request without restarting
// (#3059 / #3062).
out("Note: an authorized reload re-fires onApplicationStart (re-runs config/services.cfm and the package loader). A missing or wrong reload password silently skips the restart.", "cyan");
verbose("URL: http://localhost:#serverPort#/?reload=true&password=***");
} catch (any e) {
out("Failed to reload: #e.message#", "red");
Expand Down
14 changes: 8 additions & 6 deletions cli/lucli/services/packages/PackagesMainCli.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,15 @@ component {
local.manifest = variables.registry.fetchManifest(arguments.name);
local.picked = variables.resolver.pick(local.manifest, variables.runtime, arguments.pin);
local.vendor = variables.installer.install(arguments.name, local.picked, arguments.force);
// Activation requires a cold restart, not a soft reload: PackageLoader
// runs in onApplicationStart and `wheels reload` doesn't re-fire that
// hook. Telling users to `wheels reload` here directly contradicts
// the chapter-8 caveat and produces "uiCard not defined"-style errors
// when the helper resolution fails. Onboarding F14.
// Activation only needs a reload, not a cold restart: an authorized
// `wheels reload` calls applicationStop(), so the next request re-fires
// onApplicationStart — which runs the PackageLoader ($loadPackages).
// Verified live on Lucee 7 (see #3110). A full `wheels stop && wheels
// start` also works but is no longer required. Caveat: a reload only
// restarts when its password resolves; a missing/wrong password
// silently skips the restart (#3059 / #3062).
return "Installed " & arguments.name & "@" & local.picked.version & " → " & local.vendor & Chr(10)
& "Run `wheels stop && wheels start` to activate it." & Chr(10);
& "Run `wheels reload` (or restart) to activate it." & Chr(10);
}

private string function $updateAll(struct opts) {
Expand Down
15 changes: 10 additions & 5 deletions cli/lucli/tests/specs/commands/ReloadCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@
* the relevant strings appear in Module.cfc::reload(). A heavier integration
* test for reload behavior is out of scope here.
*
* See finding #8 in
* docs/superpowers/plans/2026-04-29-fresh-vm-onboarding-findings.md
* Contract (verified live on Lucee 7, see #3110): an authorized
* `?reload=true&password=...` calls applicationStop(), so the next request
* re-fires onApplicationStart in full — config/services.cfm and the
* PackageLoader re-run. The earlier "does NOT re-fire" note was wrong; it
* stemmed from reloads whose password never resolved (a missing/wrong
* password silently skips the restart, see #3059 / #3062).
*/
component extends="wheels.wheelstest.system.BaseSpec" {

function run() {

describe("wheels reload — output hints", () => {

it("emits a note that onApplicationStart does NOT re-fire on a hot reload", () => {
it("emits a note that an authorized reload re-fires onApplicationStart", () => {
var moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc"));
expect(moduleSource).toInclude("onApplicationStart does NOT re-fire");
expect(moduleSource).toInclude("wheels stop && wheels start");
expect(moduleSource).toInclude("re-fires onApplicationStart");
// The old, false claim must be gone.
expect(moduleSource).notToInclude("onApplicationStart does NOT re-fire");
});

it("honors an explicit --password override before falling back to auto-detect", () => {
Expand Down
14 changes: 14 additions & 0 deletions cli/lucli/tests/specs/packages/PackagesMainCliSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ component extends="wheels.wheelstest.system.BaseSpec" {
DirectoryDelete(proj, true);
});

it("install tells the user a reload activates the package (reload re-fires onApplicationStart)", () => {
// An authorized `wheels reload` calls applicationStop(), so the
// next request re-fires onApplicationStart — which runs the
// PackageLoader. A cold restart is no longer required to
// activate an installed package (see #3110).
var proj = $scratch();
var stack = $buildStack(proj);
var out = stack.cli.install({target: "wheels-fake"});
expect(out).toInclude("wheels reload");
expect(out).notToInclude("wheels stop && wheels start");
stack.cache.refresh();
DirectoryDelete(proj, true);
});

it("install honours @version pin", () => {
var proj = $scratch();
var stack = $buildStack(proj);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sidebar:
order: 4
---

Adds a package into `vendor/<name>/` from the registry. To activate it, do a cold restart (`wheels stop && wheels start`) — `PackageLoader` discovers the new `vendor/<name>/package.json` at startup. A `wheels reload` is not enough to pick up a newly-installed package.
Adds a package into `vendor/<name>/` from the registry. To activate it, run `wheels reload` (or `wheels stop && wheels start`) — `PackageLoader` discovers the new `vendor/<name>/package.json` when `onApplicationStart` re-fires. An authorized `wheels reload` is sufficient; a missing or wrong reload password silently skips the restart.

:::note[Why `add`, not `install`?]
LuCLI's built-in extension installer intercepts the literal subcommand `install` across every module. Typing `wheels packages install <name>` runs LuCLI's own dependency resolver — it prints `No git or extension dependencies to install` and exits without touching `vendor/`. Use `add` instead. (The same rename happened to `wheels browser setup`, which was previously `wheels browser install`.)
Expand Down Expand Up @@ -51,9 +51,9 @@ The same `SemVer` matcher that `PackageLoader` uses at runtime.
```sh title="illustrative — terminal session"
$ wheels packages add wheels-sentry
Installed wheels-sentry@1.0.0 → /Users/me/app/vendor/wheels-sentry
Restart the server (`wheels stop && wheels start`) to activate it.
Run `wheels reload` (or restart) to activate it.

$ wheels packages add wheels-sentry@1.0.0 --force
Installed wheels-sentry@1.0.0 → /Users/me/app/vendor/wheels-sentry
Restart the server (`wheels stop && wheels start`) to activate it.
Run `wheels reload` (or restart) to activate it.
```
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Install a package:

```bash title="your shell"
wheels packages add wheels-hotwire
wheels stop && wheels start
wheels reload
```

Browse, search, inspect, update, or remove:
Expand All @@ -57,7 +57,7 @@ Deactivation is a single command — `remove` deletes the `vendor/<name>/` direc
The registry caches its index for 24 hours. If you want to see a just-published version immediately, `wheels packages registry refresh` busts the cache. `wheels packages registry info` shows the configured registry URL and cache state.
</Aside>

The restart re-runs `PackageLoader` at startup, which rediscovers what's in `vendor/` and reconciles the mixin, service, and middleware tables with the new state. A plain `wheels reload` is not enough — `PackageLoader` only re-scans `vendor/` on a cold start.
The reload re-runs `PackageLoader`, which rediscovers what's in `vendor/` and reconciles the mixin, service, and middleware tables with the new state. An authorized `wheels reload` calls `applicationStop()` so the next request re-fires `onApplicationStart` in full — `wheels stop && wheels start` also works. Caveat: a missing or wrong reload password silently skips the restart.

## First-party packages

Expand Down Expand Up @@ -156,7 +156,7 @@ component output="false" {
}
```

After installing the package into `vendor/myfeature/` and restarting the server (`wheels stop && wheels start`), every controller has `myHelper()` available. The framework collects public methods from the package instance and merges them into the application mixin tables — the same machinery that runs for plugins, but scoped to the targets you named in the manifest.
After installing the package into `vendor/myfeature/` and reloading (`wheels reload` or `wheels stop && wheels start`), every controller has `myHelper()` available. The framework collects public methods from the package instance and merges them into the application mixin tables — the same machinery that runs for plugins, but scoped to the targets you named in the manifest.

Lifecycle hook names the loader specifically skips when collecting mixins: `init`, `onPluginLoad`, `onPluginActivate`, `register`, `boot`. If you name a method any of those, it won't be mixed in — the loader treats them as package infrastructure.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsIns

Don't put this in `config/app.cfm` — that file is for `Application.cfc` `this`-scope settings (`this.name`, `this.datasources`, `this.sessionTimeout`, etc.), not for init code. The DI container isn't initialized when `config/app.cfm` runs, so `application.wheelsdi` doesn't exist yet and the registration silently no-ops.

Both `wheels reload` and a full `wheels stop && wheels start` re-fire `onApplicationStart` — an authorized reload stops the application and the next request boots it fresh, re-running this file and `config/services.cfm`. Either way, this registers the strategy exactly once, and the `hasStrategy` check keeps repeated restarts from stacking duplicates. (Some CLI messages still claim `wheels reload` skips `onApplicationStart`; that's outdated — tracked in [#3110](https://github.com/wheels-dev/wheels/issues/3110).)
Both `wheels reload` and a full `wheels stop && wheels start` re-fire `onApplicationStart` — an authorized reload stops the application and the next request boots it fresh, re-running this file and `config/services.cfm`. Either way, this registers the strategy exactly once, and the `hasStrategy` check keeps repeated restarts from stacking duplicates. (CLI versions predating the [#3110](https://github.com/wheels-dev/wheels/issues/3110) fix printed a note claiming `wheels reload` skips `onApplicationStart`; that note was wrong.)

### Rewrite the Sessions controller

Expand Down Expand Up @@ -744,7 +744,7 @@ Whether you stopped at 6a or continued to 6b, the user-facing behavior should be

**"The password always mismatches, even for a user I just created."** The `beforeValidation` callback isn't firing, or the form is storing plaintext. Open the database and look at the `users` row directly: `passwordHash` should be exactly 64 uppercase hex characters, `passwordSalt` should be populated with a ~44-char base64 string. If either is blank, check that you named the callback correctly in `config()` (`beforeValidation("hashPassword")` — case-sensitive) and that the form submits `user[password]`, not just `password`.

**"6b: `authenticator` service not found."** Three things to check, in this order. **(1) Does the file have a `<cfscript>` wrapper?** Without it, Lucee treats the body as markup — you'll see the bare `local.di = injector();` lines printed at the top of every page after a cold restart, and the registration code never runs. Compare against `config/settings.cfm` if unsure of the shape. **(2) Did the application actually restart?** Both `wheels reload` and `wheels stop && wheels start` re-fire `onApplicationStart` and re-run `config/services.cfm` — but a reload with a wrong or missing reload password silently skips the restart. If in doubt, `wheels stop && wheels start` removes that variable. (CLI messages claiming `wheels reload` never re-fires `onApplicationStart` are outdated — see [#3110](https://github.com/wheels-dev/wheels/issues/3110).) **(3) Component path typo?** The `.to(...)` argument must be the exact dotted path — `wheels.auth.Authenticator`, not `Authenticator` — and the `injector()` call has to come first.
**"6b: `authenticator` service not found."** Three things to check, in this order. **(1) Does the file have a `<cfscript>` wrapper?** Without it, Lucee treats the body as markup — you'll see the bare `local.di = injector();` lines printed at the top of every page after a cold restart, and the registration code never runs. Compare against `config/settings.cfm` if unsure of the shape. **(2) Did the application actually restart?** Both `wheels reload` and `wheels stop && wheels start` re-fire `onApplicationStart` and re-run `config/services.cfm` — but a reload with a wrong or missing reload password silently skips the restart. If in doubt, `wheels stop && wheels start` removes that variable. (CLI messages claiming `wheels reload` never re-fires `onApplicationStart` predate the [#3110](https://github.com/wheels-dev/wheels/issues/3110) fix.) **(3) Component path typo?** The `.to(...)` argument must be the exact dotted path — `wheels.auth.Authenticator`, not `Authenticator` — and the `injector()` call has to come first.

## What's next

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The canonical way to install a Wheels package is `wheels packages add <name>`. T

```text title="expected output"
Installed wheels-basecoat@<version> → /your/path/blog/vendor/wheels-basecoat
Run `wheels stop && wheels start` to activate it.
Run `wheels reload` (or restart) to activate it.
```

2. Verify the install:
Expand All @@ -83,7 +83,7 @@ The canonical way to install a Wheels package is `wheels packages add <name>`. T
wheels reload
```

`wheels reload` works because an authorized reload stops and restarts the application, re-running `onApplicationStart` — and with it the package loader. A full `wheels stop && wheels start` (which the install message suggests) does the same thing, just more heavily. (The CLI message claiming only a stop/start activates a package is outdated — tracked in [#3110](https://github.com/wheels-dev/wheels/issues/3110).)
`wheels reload` works because an authorized reload stops and restarts the application, re-running `onApplicationStart` — and with it the package loader. A full `wheels stop && wheels start` (as the install message also hints) does the same thing, just more heavily. (CLI versions predating the [#3110](https://github.com/wheels-dev/wheels/issues/3110) fix claimed only a stop/start activates a package; that claim was wrong.)

</Steps>

Expand Down Expand Up @@ -296,7 +296,7 @@ Three things to verify:

**`No matching function [UIBUTTON] found`** — the package didn't activate. Both `wheels reload` and `wheels stop && wheels start` re-run `PackageLoader` (an authorized reload restarts the application, re-firing `onApplicationStart`), so the usual cause is a reload that silently no-op'd — a wrong or missing reload password — or the package landing somewhere other than `vendor/`. Run `wheels stop && wheels start` to rule out the password variable, and `ls vendor/wheels-basecoat` to confirm the files are there.

**`No matching function [$UIBUILDID] found`** (or `[$UILUCIDEICON]`) — the package activated but its internal helpers can't be found. Fixed in `wheels-basecoat 1.0.3`. Older versions declared the `$`-prefixed helpers `private`, but Wheels' `PackageLoader` only carries PUBLIC methods across the mixin boundary, so public callers like `uiField` couldn't reach them. Run `wheels packages update wheels-basecoat --yes && wheels stop && wheels start`.
**`No matching function [$UIBUILDID] found`** (or `[$UILUCIDEICON]`) — the package activated but its internal helpers can't be found. Fixed in `wheels-basecoat 1.0.3`. Older versions declared the `$`-prefixed helpers `private`, but Wheels' `PackageLoader` only carries PUBLIC methods across the mixin boundary, so public callers like `uiField` couldn't reach them. Run `wheels packages update wheels-basecoat --yes` then `wheels reload` (or restart).

**`No version of 'wheels-basecoat' satisfies runtime '0.0.0-dev'`** — the framework can't read its own version. Likely on a Wheels release older than `4.0.0-SNAPSHOT+1670` (the snapshot that includes the runtime-detection fix). Upgrade with `brew upgrade wheels` (macOS/Linux) or `scoop update wheels` (Windows) and re-run.

Expand Down
Loading