Skip to content

Commit 0a73584

Browse files
committed
fix(cli): commands requiring server throw typed exception instead of silent exit (#2229)
Nine LuCLI commands printed a red "No running Wheels server detected" diagnostic then `return ""` — producing exit 0. MCP clients and shell automation couldn't distinguish "succeeded with no output" from "server down, nothing ran". The issue listed four (routes, reload, test, console); the same-root-cause scan surfaced five more with an identical pattern (migrate, seed, db status, db version, generate admin). All nine fixed together — the issue checklist explicitly covers "any others surfaced by audit". Mirrors the #2211/#2214/#2215 pattern: keep the out() diagnostic, throw a typed `Wheels.*` exception so LuCLI's Picocli ExecutionExceptionHandler surfaces exit 1. - New `$requireRunningServer(hints)` private helper consolidates the guard — returns the detected port on success, throws `Wheels.ServerNotRunning` on failure. - Nine call sites converted: reload(), routes(), console(), generateAdmin, runMigration, runSeed, dbStatus, dbVersion, runTests. - `info()` status probe at line 596 intentionally left alone — it reports server status, doesn't require server. Net -4 LOC across Module.cfc despite adding the helper + docblock, because the guard boilerplate collapses from ~5 lines per site to 1. Regression test `test-server-required-exit-codes.sh` invokes each command with no server and asserts exit != 0 plus the diagnostic. Console is excluded from the live test (its success path reads stdin, making headless exercise awkward); it's covered by type parity with the other throws.
1 parent 5de0f90 commit 0a73584

3 files changed

Lines changed: 148 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
141141

142142
### Fixed
143143

144+
- `wheels routes`, `reload`, `test`, `console`, `migrate`, `seed`, `db status`, `db version`, and `generate admin` now exit non-zero when no Wheels dev server is running. Previously these commands printed a red diagnostic but returned `""`, producing exit 0 — MCP clients and shell automation couldn't distinguish "succeeded with no output" from "server down, nothing ran". A shared `$requireRunningServer()` helper now throws a typed `Wheels.ServerNotRunning` exception that LuCLI's `ExecutionExceptionHandler` maps to exit 1. (#2229)
144145
- `changeColumn` on SQLite now works by implementing the SQLite-standard recreate-table pattern in `SQLiteMigrator`. Previously, SQLite migrations inherited MySQL's `ALTER TABLE ... CHANGE` syntax from `Abstract.cfc` and failed with `near "CHANGE": syntax error`. The migrator's `$execute` now accepts an array of statements so adapters can return multi-step DDL. v1 limitations: foreign-key constraints declared inline on `CREATE TABLE` and triggers are not preserved across the recreate. (#2207)
145146
- Framework-internal browser-test fixture controllers, views, and the `/_browser/*` routes no longer leak into application-level files. Moved from `app/controllers/BrowserTest*.cfc`, `app/views/browsertest*/`, and `config/routes.cfm` into `vendor/wheels/public/browser-fixtures/`, auto-mounted by `$lockedLoadRoutes` when environment is `testing` or `development` and the new opt-in setting `loadBrowserTestFixtures=true` is set. Apps upgrading from a 4.0 snapshot that had custom `/_browser/*` routes must opt in explicitly or re-declare them in `config/routes.cfm`. (#2135, #2138)
146147
- Stray `app/mailers/UserNotificationsMailer.cfc` demo removed from the framework repo root (byte-identical copies remain in the example apps under `examples/tweet/` and `examples/starter-app/`). (#2138)

cli/lucli/Module.cfc

Lines changed: 46 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,7 @@ component extends="modules.BaseModule" {
329329
* hint: Reload the running Wheels application
330330
*/
331331
public string function reload() {
332-
var serverPort = detectServerPort();
333-
if (!serverPort) {
334-
out("No running Wheels server detected. Start one with: wheels start", "red");
335-
return "";
336-
}
332+
var serverPort = $requireRunningServer();
337333

338334
var password = detectReloadPassword();
339335

@@ -506,11 +502,7 @@ component extends="modules.BaseModule" {
506502
* hint: List all configured routes with method, path, and controller action
507503
*/
508504
public string function routes() {
509-
var serverPort = detectServerPort();
510-
if (!serverPort) {
511-
out("No running Wheels server detected. Start one with: wheels start", "red");
512-
return "";
513-
}
505+
var serverPort = $requireRunningServer();
514506

515507
try {
516508
var routesUrl = "http://localhost:#serverPort#/wheels/ai?context=routing";
@@ -659,12 +651,10 @@ component extends="modules.BaseModule" {
659651
}
660652

661653
// Detect server
662-
var serverPort = detectServerPort();
663-
if (!serverPort) {
664-
out("No running Wheels server detected.", "red");
665-
out("The console requires a running server. Start with: wheels start");
666-
return "";
667-
}
654+
var serverPort = $requireRunningServer([
655+
"The console requires a running server.",
656+
"Start one with: wheels start"
657+
]);
668658

669659
// Auto-detect reload password if not provided
670660
if (!len(password)) {
@@ -2337,12 +2327,10 @@ component extends="modules.BaseModule" {
23372327
if (arguments.args[i] == "--no-routes") noRoutes = true;
23382328
}
23392329

2340-
var serverPort = detectServerPort();
2341-
if (!serverPort) {
2342-
out("No running server detected. Start with 'wheels start' first.", "red");
2343-
out("Admin generation requires a running server for model introspection.");
2344-
return "";
2345-
}
2330+
var serverPort = $requireRunningServer([
2331+
"Admin generation requires a running server for model introspection.",
2332+
"Start one with: wheels start"
2333+
]);
23462334

23472335
// Introspect the model via the server
23482336
out("Introspecting model: #modelName#...", "cyan");
@@ -2648,12 +2636,10 @@ component extends="modules.BaseModule" {
26482636
// ── Migration Execution ──────────────────────────
26492637

26502638
private string function runMigration(required string action) {
2651-
var serverPort = detectServerPort();
2652-
if (!serverPort) {
2653-
out("No running Wheels server detected.", "red");
2654-
out("Migrations require a running server. Start with: wheels start");
2655-
return "";
2656-
}
2639+
var serverPort = $requireRunningServer([
2640+
"Migrations require a running server.",
2641+
"Start one with: wheels start"
2642+
]);
26572643

26582644
out("Running migration: #action#...", "cyan");
26592645

@@ -2695,12 +2681,10 @@ component extends="modules.BaseModule" {
26952681
// ── Seed Execution ──────────────────────────────
26962682

26972683
private string function runSeed(string mode = "auto", string environment = "") {
2698-
var serverPort = detectServerPort();
2699-
if (!serverPort) {
2700-
out("No running Wheels server detected.", "red");
2701-
out("Seeding requires a running server. Start with: wheels start");
2702-
return "";
2703-
}
2684+
var serverPort = $requireRunningServer([
2685+
"Seeding requires a running server.",
2686+
"Start one with: wheels start"
2687+
]);
27042688

27052689
out("Running database seeds...", "cyan");
27062690

@@ -2782,11 +2766,7 @@ component extends="modules.BaseModule" {
27822766
if (arg == "--pending") pendingOnly = true;
27832767
}
27842768

2785-
var serverPort = detectServerPort();
2786-
if (!serverPort) {
2787-
out("No running server detected. Start with 'wheels start' first.", "red");
2788-
return "";
2789-
}
2769+
var serverPort = $requireRunningServer();
27902770

27912771
try {
27922772
var statusUrl = "http://localhost:#serverPort#/wheels/cli?command=dbStatus&format=json";
@@ -2832,11 +2812,7 @@ component extends="modules.BaseModule" {
28322812
if (arg == "--detailed") detailed = true;
28332813
}
28342814

2835-
var serverPort = detectServerPort();
2836-
if (!serverPort) {
2837-
out("No running server detected. Start with 'wheels start' first.", "red");
2838-
return "";
2839-
}
2815+
var serverPort = $requireRunningServer();
28402816

28412817
try {
28422818
var versionUrl = "http://localhost:#serverPort#/wheels/cli?command=dbVersion&format=json";
@@ -3059,13 +3035,10 @@ component extends="modules.BaseModule" {
30593035
string db = "sqlite",
30603036
boolean ciMode = false
30613037
) {
3062-
var serverPort = detectServerPort();
3063-
if (!serverPort) {
3064-
out("No running Wheels server detected.", "red");
3065-
out("Start with: wheels start", "yellow");
3066-
out("Or use: bash tools/test-local.sh (auto-manages server)", "yellow");
3067-
return "";
3068-
}
3038+
var serverPort = $requireRunningServer([
3039+
"Start one with: wheels start",
3040+
"Or use: bash tools/test-local.sh (auto-manages server)"
3041+
]);
30693042

30703043
var testPath = coreTests ? "/wheels/core/tests" : "/wheels/app/tests";
30713044
out("Running #(coreTests ? 'core' : 'app')# tests (#db#)...", "cyan");
@@ -3738,6 +3711,28 @@ component extends="modules.BaseModule" {
37383711
return false;
37393712
}
37403713

3714+
/**
3715+
* Guard for commands that require a live Wheels dev server. Returns the
3716+
* detected port on success; prints a red diagnostic + any yellow hints
3717+
* and throws `Wheels.ServerNotRunning` on failure, so LuCLI's Picocli
3718+
* ExecutionExceptionHandler surfaces a non-zero exit instead of the
3719+
* previous silent `return ""` (GH #2229).
3720+
*/
3721+
private numeric function $requireRunningServer(array hints = []) {
3722+
var serverPort = detectServerPort();
3723+
if (serverPort) return serverPort;
3724+
3725+
out("No running Wheels server detected.", "red");
3726+
var hintList = arrayLen(arguments.hints) ? arguments.hints : ["Start one with: wheels start"];
3727+
for (var hint in hintList) {
3728+
out(hint, "yellow");
3729+
}
3730+
throw(
3731+
type="Wheels.ServerNotRunning",
3732+
message="No running Wheels server detected on any expected port (checked lucee.json, .env, 8080/60000/3000/8500)"
3733+
);
3734+
}
3735+
37413736
/**
37423737
* Detect the reload password from .env or config/settings.cfm
37433738
*/
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env bash
2+
# Regression tests for commands that require a running Wheels dev server.
3+
#
4+
# GH #2229 — commands that detected "no running server" previously printed a
5+
# red diagnostic and `return ""`, producing exit 0. MCP clients and shell
6+
# automation couldn't distinguish "succeeded with no output" from "server
7+
# down, nothing ran". All such paths now throw `Wheels.ServerNotRunning` via
8+
# the shared `$requireRunningServer()` helper so LuCLI's ExecutionException-
9+
# Handler surfaces a non-zero exit.
10+
#
11+
# Commands exercised (issue-listed + same-bug-pattern paths found in audit):
12+
# wheels routes — issue-listed
13+
# wheels reload — issue-listed
14+
# wheels test — issue-listed (via private runTests)
15+
# wheels migrate info — surfaced by audit (same pattern)
16+
# wheels seed — surfaced by audit (same pattern)
17+
# wheels db status — surfaced by audit (same pattern)
18+
# wheels db version — surfaced by audit (same pattern)
19+
#
20+
# `wheels console` is excluded — its success path reads from stdin, so driving
21+
# it from a non-interactive shell test without a server is already covered by
22+
# type-parity with the other throws. (Same rationale as the TemplateNotFound
23+
# case in test-new-exit-codes.sh.)
24+
#
25+
# Prerequisites:
26+
# - wheels binary on PATH
27+
# - No Wheels dev server running on 8080/60000/3000/8500
28+
#
29+
# Usage:
30+
# bash cli/lucli/tests/test-server-required-exit-codes.sh
31+
32+
# NOTE: no `set -e` — we want to observe non-zero exits.
33+
set -uo pipefail
34+
35+
PASS=0
36+
FAIL=0
37+
38+
pass() { echo " PASS: $1"; PASS=$((PASS+1)); }
39+
fail() { echo " FAIL: $1"; FAIL=$((FAIL+1)); }
40+
41+
if ! command -v wheels &>/dev/null; then
42+
echo "ERROR: wheels not found on PATH"
43+
exit 1
44+
fi
45+
46+
# Isolate in a tmpdir so the commands don't find any project files (lucee.json,
47+
# .env) that might hint at a port. The common-port probes (8080/60000/3000/
48+
# 8500) still run, so ensure nothing is listening there before invoking.
49+
TMPDIR=$(mktemp -d)
50+
cleanup() { rm -rf "$TMPDIR"; }
51+
trap cleanup EXIT
52+
cd "$TMPDIR"
53+
54+
check_common_ports() {
55+
for port in 8080 60000 3000 8500; do
56+
if (echo > /dev/tcp/127.0.0.1/$port) &>/dev/null; then
57+
echo "ERROR: something is listening on port $port — stop it before running this test"
58+
exit 1
59+
fi
60+
done
61+
}
62+
check_common_ports
63+
64+
assert_exits_nonzero_with_diagnostic() {
65+
local label="$1"
66+
local cmd="$2"
67+
68+
echo ""
69+
echo "--- $label ---"
70+
OUT=$(eval "$cmd" 2>&1)
71+
CODE=$?
72+
73+
echo "$OUT" | tail -5
74+
echo "exit code: $CODE"
75+
76+
if [ "$CODE" -ne 0 ]; then
77+
pass "$label exits non-zero"
78+
else
79+
fail "$label exited 0 despite no running server"
80+
fi
81+
82+
if echo "$OUT" | grep -qi "No running Wheels server"; then
83+
pass "$label emits 'No running Wheels server' diagnostic"
84+
else
85+
fail "$label missing 'No running Wheels server' diagnostic"
86+
fi
87+
}
88+
89+
echo "=== GH #2229: silent-exit regressions for server-required commands ==="
90+
91+
assert_exits_nonzero_with_diagnostic "wheels routes" "wheels routes"
92+
assert_exits_nonzero_with_diagnostic "wheels reload" "wheels reload"
93+
assert_exits_nonzero_with_diagnostic "wheels test" "wheels test"
94+
assert_exits_nonzero_with_diagnostic "wheels migrate info" "wheels migrate info"
95+
assert_exits_nonzero_with_diagnostic "wheels seed" "wheels seed"
96+
assert_exits_nonzero_with_diagnostic "wheels db status" "wheels db status"
97+
assert_exits_nonzero_with_diagnostic "wheels db version" "wheels db version"
98+
99+
echo ""
100+
echo "=== Summary: $PASS pass, $FAIL fail ==="
101+
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)