Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ All historical references to "CFWheels" in this changelog have been preserved fo

# [Unreleased]

### Changed

- CLI user-error paths now exit non-zero instead of silently returning success. Several commands printed a red error message and then `return ""`, which LuCLI maps to exit code 0 — so a typo'd subcommand or a failed migration looked like success to CI pipelines, deploy scripts, and pre-commit hooks. The following now throw a typed exception (`Wheels.InvalidArguments` for unknown input; the original error is re-thrown for runtime failures), which LuCLI maps to a non-zero exit while still printing the same friendly diagnostic first: `wheels generate <unknown-type>`, `wheels create <unknown-type>`, `wheels migrate <unknown-action>`, `wheels db <unknown-subcommand>`, a failed `wheels migrate latest|up|down|info|doctor|rename-system-tables` (re-throws the underlying `MigrationError` instead of swallowing it), and `wheels routes` when the server returns an unparseable or unsuccessful response (`Wheels.RoutesFailed`). Help/no-args paths (`wheels generate`, `wheels db` with no subcommand) and non-error states (`wheels routes` with zero configured routes, `wheels db reset` without `--force`) are unchanged and still exit 0. Over MCP these surface as proper tool errors instead of empty results. **Scripts that previously relied on these error paths exiting 0 will now see a non-zero exit — that is the intended fix.**

### Fixed

- `wheels reload` and `wheels generate admin` now refuse to attach to a server that isn't bound to the current project, closing the same #2878 gap for two more server-dependent commands that #2879 fixed for the write-side migrators. Both reached `cli.lucli.Module::$requireRunningServer()` without the `requireProjectConfig` flag, so in a project with no `lucee.json` / `.env` port they still fell back to the hardcoded common-port probe (`[8080, 60000, 3000, 8500]`) and could silently attach to a sibling app: `reload` would reset the wrong app's state, and `generate admin` would introspect the wrong schema and scaffold its controller/views into the current project from a sibling's model — wrong-schema output written into the right project. Both now pass `requireProjectConfig = true`; with no project-bound port they throw `Wheels.ServerNotRunning` with a "set 'port' in lucee.json (or PORT in .env), then start with: wheels start" diagnostic instead of proceeding. `generate admin` is gated (rather than left on the read-side fallback alongside `info` / `routes`) precisely because it both reads a schema and writes files into cwd, so a wrong-server attach is a correctness bug, not just a wrong read. Covered by new server-free specs in `cli/lucli/tests/specs/services/ServerDetectionSpec.cfc` that drive `reload()` and `generateAdmin()` in a no-config project and assert the guard refuses to attach (#2878)
Expand Down
31 changes: 21 additions & 10 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,9 @@ component extends="modules.BaseModule" {
default:
out("Unknown generator type: #type#", "red");
out("Run 'wheels generate' for available types.");
return "";
// Throw so LuCLI exits non-zero — a typo'd type is a user error
// CI / scripts must be able to detect, not a silent success.
throw(type = "Wheels.InvalidArguments", message = "Unknown generator type: #type#");
}
}

Expand All @@ -458,14 +460,16 @@ component extends="modules.BaseModule" {
return runMigration(action);
} catch (MigrationError e) {
out("Migration failed: #e.message#", "red");
return "";
// Propagate so LuCLI exits non-zero — a failed migration must
// be detectable by CI / deploy scripts, not swallowed to exit 0.
rethrow;
}
case "doctor":
try {
return runMigration("doctor");
} catch (MigrationError e) {
out("Doctor failed: #e.message#", "red");
return "";
rethrow;
}
case "forget":
return runForgetOrPretend("forgetVersion", args);
Expand All @@ -483,12 +487,12 @@ component extends="modules.BaseModule" {
return runRenameSystemTables(dryRun);
} catch (MigrationError e) {
out("Rename failed: #e.message#", "red");
return "";
rethrow;
}
default:
out("Unknown migration action: #action#", "red");
out("Usage: wheels migrate [latest|up|down|info|doctor|forget|pretend|rename-system-tables]");
return "";
throw(type = "Wheels.InvalidArguments", message = "Unknown migration action: #action#");
}
}

Expand Down Expand Up @@ -1026,7 +1030,7 @@ component extends="modules.BaseModule" {
default:
out("Unknown create type: #type#", "red");
out("Run 'wheels create' for available types.");
return "";
throw(type = "Wheels.InvalidArguments", message = "Unknown create type: #type#");
}
}

Expand Down Expand Up @@ -1054,12 +1058,12 @@ component extends="modules.BaseModule" {
} catch (any jsonErr) {
out("Failed to parse routes response", "red");
verbose(httpResult);
return "";
throw(type = "Wheels.RoutesFailed", message = "Failed to parse routes response");
}

if (!structKeyExists(result, "success") || !result.success) {
out("Failed to fetch routes: #result.message ?: 'unknown error'#", "red");
return "";
throw(type = "Wheels.RoutesFailed", message = "Failed to fetch routes: #result.message ?: 'unknown error'#");
}

if (!structKeyExists(result, "routes") || !arrayLen(result.routes)) {
Expand Down Expand Up @@ -1104,7 +1108,14 @@ component extends="modules.BaseModule" {
out("");
out("#arrayLen(result.routes)# route(s)", "cyan");
} catch (any e) {
out("Failed to fetch routes: #e.message#", "red");
// Inner failure paths already printed a specific diagnostic and
// threw Wheels.RoutesFailed; only a genuine HTTP/unexpected error
// needs a message here. Either way, propagate non-zero — a failed
// routes fetch is an error CI / scripts must detect, not exit 0.
if (e.type != "Wheels.RoutesFailed") {
out("Failed to fetch routes: #e.message#", "red");
}
rethrow;
}
return "";
}
Expand Down Expand Up @@ -2663,7 +2674,7 @@ component extends="modules.BaseModule" {
default:
out("Unknown db command: #subcommand#", "red");
out("Valid commands: reset, status, version");
return "";
throw(type = "Wheels.InvalidArguments", message = "Unknown db command: #subcommand#");
}
}

Expand Down
5 changes: 2 additions & 3 deletions cli/lucli/tests/specs/commands/DbCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(true).toBeTrue();
});

it("handles unknown subcommand without throwing", () => {
it("throws Wheels.InvalidArguments on an unknown subcommand", () => {
mod.__arguments = ["invalid"];
mod.db();
expect(true).toBeTrue();
expect(() => mod.db()).toThrow(type = "Wheels.InvalidArguments");
});

it("accepts status subcommand", () => {
Expand Down
6 changes: 2 additions & 4 deletions cli/lucli/tests/specs/commands/GenerateCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,9 @@ component extends="wheels.wheelstest.system.BaseSpec" {

describe("unknown type handling", () => {

it("does not throw for unknown generator type", () => {
it("throws Wheels.InvalidArguments for an unknown generator type", () => {
mod.__arguments = ["nonexistent"];
// Should output error message but not throw
mod.generate();
expect(true).toBeTrue();
expect(() => mod.generate()).toThrow(type = "Wheels.InvalidArguments");
});

});
Expand Down
5 changes: 2 additions & 3 deletions cli/lucli/tests/specs/commands/MigrateCommandSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,9 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(true).toBeTrue();
});

it("rejects unknown action without throwing", () => {
it("throws Wheels.InvalidArguments on an unknown action", () => {
mod.__arguments = ["invalid"];
mod.migrate();
expect(true).toBeTrue();
expect(() => mod.migrate()).toThrow(type = "Wheels.InvalidArguments");
});

});
Expand Down
Loading