Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -899,12 +899,15 @@ component extends="modules.BaseModule" {
}

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=***");
return "";
}
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
14 changes: 11 additions & 3 deletions cli/lucli/tests/specs/commands/ReloadCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
* a spec (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).
*
* The #3059 blocks cover reporting honesty: reload() used to print
* "Application reloaded successfully." whenever the HTTP exchange completed,
* never inspecting the status code. The framework's reload gate restarts the
Expand Down Expand Up @@ -60,10 +67,11 @@ component extends="wheels.wheelstest.system.BaseSpec" {

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
77 changes: 77 additions & 0 deletions public/testbox/system/stubs/F952D54F1096E25C030C8E3149ABD8C4.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<cfscript>
variables[ "$requireRunningServer" ] = variables[ "tmp_$requireRunningServer_F952D54F1096E25C030C8E3149ABD8C4" ];
this[ "$requireRunningServer" ] = variables[ "tmp_$requireRunningServer_F952D54F1096E25C030C8E3149ABD8C4" ];

// Clean up
structDelete( variables, "tmp_$requireRunningServer_F952D54F1096E25C030C8E3149ABD8C4" );
structDelete( this, "tmp_$requireRunningServer_F952D54F1096E25C030C8E3149ABD8C4" );
private numeric function tmp_$requireRunningServer_F952D54F1096E25C030C8E3149ABD8C4(

) output=true {

var results = this._mockResults;
var resultsKey = "$requireRunningServer";
var resultsCounter = 0;
var internalCounter = 0;
var resultsLen = 0;
var callbackLen = 0;
var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments );
var fCallBack = "";

// If Method & argument Hash Results, switch the results struct
if (structKeyExists( this._mockArgResults, argsHashKey) ) {
// Check if it is a callback
if (isStruct( this._mockArgResults[ argsHashKey ]) &&
structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) &&
structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) {
fCallBack = this._mockArgResults[ argsHashKey ].target;
} else {
// switch context and key
results = this._mockArgResults;
resultsKey = argsHashKey;
}
}

// Get the statemachine counter
if (isSimpleValue( fCallBack) ) {
resultsLen = arrayLen( results[ resultsKey ] );
}

// Get the callback counter, if it exists
if (structKeyExists( this._mockCallbacks, resultsKey) ) {
callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] );
}

// Log the Method Call
this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1;

// Get the CallCounter Reference
internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")];
arrayAppend( this._mockCallLoggers["$requireRunningServer"], arguments );

throw(
"spec capture — abort before HTTP",
"TestAbort.ServerGuard",
"",
""
);

if (resultsLen neq 0) {
if (internalCounter gt resultsLen) {
resultsCounter = internalCounter - ( resultsLen * fix( ( internalCounter - 1 ) / resultsLen ) );
return results[ resultsKey ][ resultsCounter ];
} else {
return results[ resultsKey ][ internalCounter ];
}
}

if ( callbackLen neq 0 ) {
fCallBack = this._mockCallbacks[ resultsKey ].first();
return fCallBack( argumentCollection : arguments );
}

if ( not isSimpleValue( fCallBack ) ){
return fCallBack( argumentCollection : arguments );
}
}
</cfscript>
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