Skip to content

Commit 66a9878

Browse files
bpamiriclaude
andauthored
fix(cli): restore read-side common-port fallback for migrate info/doctor (#3149)
* fix(cli): restore read-side common-port fallback for migrate info/doctor #2879 documented that read-only commands keep the legacy common-port fallback (8080, 60000, 3000, 8500), but runMigration() gated every migrate subcommand behind requireProjectConfig=true — so 'wheels migrate info' and 'wheels migrate doctor' refused a server running on 8080, the first entry of their own documented fallback list, with Wheels.ServerNotRunning. The schema-mutating actions (latest/up/down) keep the strict project-bound gate from #2878. info/doctor now pass requireProjectConfig=false, and because a fallback attach can reach a sibling project's server (reporting the WRONG app's migration state), they print a yellow notice naming the attached port whenever the fallback was used, with the lucee.json / PORT pin hint. New call-site gating specs in ServerDetectionSpec capture the exact requireProjectConfig flag runMigration() hands $requireRunningServer() per action (info/doctor false; latest/up/down true) plus an end-to-end no-config case asserting the project-bound refusal is gone. Fixes #3080 Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): document the restored read-side fallback for migrate info/doctor Replace the migrations-guide aside paragraph that deferred to #3080 with the resolved contract: info/doctor keep the common-port fallback like the other read-side commands, and print a yellow notice when the fallback (not lucee.json/.env) chose the port. Adds the changelog.d fragment for the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 531f126 commit 66a9878

4 files changed

Lines changed: 123 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `wheels migrate info` and `wheels migrate doctor` honor the read-side common-port fallback again. #2879 documented that read-only commands keep the legacy port probe (8080, 60000, 3000, 8500) when no `lucee.json` / `.env` port is configured, but `cli.lucli.Module::runMigration()` gated every migrate subcommand behind `requireProjectConfig = true`, so the two read-only inspectors refused a server on 8080 — the first entry of their own documented fallback list — with `Wheels.ServerNotRunning`. The schema-mutating actions (`latest`, `up`, `down`) keep the strict project-bound gate, and because a fallback attach can reach a *sibling* project's server (and report the wrong app's migration state), `info`/`doctor` now print a yellow notice naming the port whenever the fallback was used, with the `lucee.json` / `PORT` hint to pin the project. Covered by new call-site gating specs in `cli/lucli/tests/specs/services/ServerDetectionSpec.cfc` (#3080)

cli/lucli/Module.cfc

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4322,13 +4322,41 @@ component extends="modules.BaseModule" {
43224322
}
43234323

43244324
private string function runMigration(required string action) {
4325-
var serverPort = $requireRunningServer(
4326-
hints = [
4327-
"Migrations require a running server bound to this project.",
4328-
"Set 'port' in lucee.json (or PORT in .env), then start with: wheels start"
4329-
],
4330-
requireProjectConfig = true
4331-
);
4325+
// latest/up/down change the schema — they must only ever target the
4326+
// server bound to this project's own lucee.json/.env port (#2878).
4327+
// info/doctor are read-only and keep the legacy common-port fallback,
4328+
// matching the other read-side commands (info, routes, console,
4329+
// dbStatus, dbVersion) — the contract #2879 documented but the gate
4330+
// here didn't honor (#3080).
4331+
var mutatingAction = listFindNoCase("latest,up,down", arguments.action) > 0;
4332+
4333+
var serverPort = 0;
4334+
if (mutatingAction) {
4335+
serverPort = $requireRunningServer(
4336+
hints = [
4337+
"Migrations require a running server bound to this project.",
4338+
"Set 'port' in lucee.json (or PORT in .env), then start with: wheels start"
4339+
],
4340+
requireProjectConfig = true
4341+
);
4342+
} else {
4343+
serverPort = $requireRunningServer(
4344+
hints = ["Start one with: wheels start"],
4345+
requireProjectConfig = false
4346+
);
4347+
// Transparency for the fallback attach: with no project-bound port
4348+
// we cannot prove the server on a common port belongs to this
4349+
// project — a sibling app's server would report the WRONG
4350+
// project's migration state. Say which port we attached to and
4351+
// how to pin it.
4352+
if (!detectServerPort(requireProjectConfig = true)) {
4353+
out(
4354+
"Attached to localhost:#serverPort# via the common-port fallback (no project-bound port in lucee.json / .env).",
4355+
"yellow"
4356+
);
4357+
out("If this is not this project's server, set 'port' in lucee.json (or PORT in .env) and re-run.", "yellow");
4358+
}
4359+
}
43324360

43334361
out("Running migration: #action#...", "cyan");
43344362

@@ -4346,8 +4374,8 @@ component extends="modules.BaseModule" {
43464374
// latest/up/down change the schema — the framework's /wheels/cli
43474375
// bridge requires POST + the reload password for state-changing
43484376
// commands. info/doctor are read-only and stay on GET.
4349-
var mutatingAction = listFindNoCase("latest,up,down", arguments.action) > 0;
4350-
4377+
// (`mutatingAction` is resolved at the top of this function — the
4378+
// same read/write split also decides the server-identity gate.)
43514379
var httpResult = "";
43524380
try {
43534381
httpResult = mutatingAction ? makeBridgePost(migrateUrl) : makeHttpRequest(migrateUrl);

cli/lucli/tests/specs/services/ServerDetectionSpec.cfc

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,38 @@ component extends="wheels.wheelstest.system.BaseSpec" {
5353
testHelper.cleanupTempProject(variables.tempRoot);
5454
}
5555

56+
/**
57+
* Capture the requireProjectConfig flag runMigration() hands to
58+
* $requireRunningServer() for a given migrate action (#3080). The mocked
59+
* guard throws so the command aborts before any HTTP probing — the call
60+
* log then exposes the exact named arguments the call site passed.
61+
*/
62+
private boolean function capturedRequireProjectConfig(required string action) {
63+
// MockBox writes its generated method stubs to /testbox/system/stubs
64+
// (webroot-relative) and removes them after mixing in — make sure the
65+
// directory exists. java.io.File.mkdirs() recurses parents on every
66+
// engine and is a no-op when the directory already exists (same
67+
// workaround as vendor/wheels/tests/specs/controller/channelSpec.cfc).
68+
createObject("java", "java.io.File").init(expandPath("/testbox/system/stubs")).mkdirs();
69+
70+
var m = new cli.lucli.Module(cwd = variables.tempRoot);
71+
prepareMock(m);
72+
m.$(
73+
method = "$requireRunningServer",
74+
throwException = true,
75+
throwType = "TestAbort.ServerGuard",
76+
throwMessage = "spec capture — abort before HTTP"
77+
);
78+
try {
79+
m.migrate(arg1 = arguments.action);
80+
} catch (any e) {
81+
// expected: the mocked guard throws TestAbort.ServerGuard
82+
}
83+
var log = m.$callLog()["$requireRunningServer"];
84+
expect(arrayLen(log)).toBeGTE(1, "migrate #arguments.action# never reached $requireRunningServer()");
85+
return log[1].requireProjectConfig;
86+
}
87+
5688
function run() {
5789

5890
describe("detectServerPort — server-identity guard (##2878)", () => {
@@ -109,6 +141,58 @@ component extends="wheels.wheelstest.system.BaseSpec" {
109141

110142
});
111143

144+
describe("read-side migrate gating — info + doctor (##3080)", () => {
145+
146+
// #2879 documented that read-side commands keep the legacy
147+
// common-port fallback, but runMigration() gated EVERY migrate
148+
// subcommand behind requireProjectConfig=true — so `migrate info`
149+
// and `migrate doctor` refused a server on 8080 (the first
150+
// documented fallback port). These specs pin the call-site wiring:
151+
// info/doctor pass requireProjectConfig=false, the schema-mutating
152+
// actions keep requireProjectConfig=true.
153+
154+
it("migrate info keeps the read-side common-port fallback (requireProjectConfig=false)", () => {
155+
expect(capturedRequireProjectConfig("info")).toBeFalse();
156+
});
157+
158+
it("migrate doctor keeps the read-side common-port fallback (requireProjectConfig=false)", () => {
159+
expect(capturedRequireProjectConfig("doctor")).toBeFalse();
160+
});
161+
162+
it("migrate latest still refuses the common-port fallback (requireProjectConfig=true)", () => {
163+
expect(capturedRequireProjectConfig("latest")).toBeTrue();
164+
});
165+
166+
it("migrate up still refuses the common-port fallback (requireProjectConfig=true)", () => {
167+
expect(capturedRequireProjectConfig("up")).toBeTrue();
168+
});
169+
170+
it("migrate down still refuses the common-port fallback (requireProjectConfig=true)", () => {
171+
expect(capturedRequireProjectConfig("down")).toBeTrue();
172+
});
173+
174+
it("migrate info in a no-config project never throws the project-bound refusal", () => {
175+
// End-to-end through the real (unmocked) guard. Environment
176+
// tolerant: when something IS listening on a common port the
177+
// command proceeds past the guard (and fails later on HTTP /
178+
// response parsing — fine); when nothing is listening it must
179+
// throw the READ-SIDE ServerNotRunning message (which names
180+
// the probed ports), never the project-bound refusal.
181+
if (fileExists(tempRoot & "/lucee.json")) fileDelete(tempRoot & "/lucee.json");
182+
if (fileExists(tempRoot & "/.env")) fileDelete(tempRoot & "/.env");
183+
var state = {sawProjectBoundRefusal = false};
184+
try {
185+
mod.migrate(arg1 = "info");
186+
} catch (any e) {
187+
if (e.type == "Wheels.ServerNotRunning" && !findNoCase("8080", e.message)) {
188+
state.sawProjectBoundRefusal = true;
189+
}
190+
}
191+
expect(state.sawProjectBoundRefusal).toBeFalse();
192+
});
193+
194+
});
195+
112196
describe("write-side command gating — reload + generate admin", () => {
113197

114198
// Drive the real callers (not detectServerPort) to prove the call

web/sites/guides/src/content/docs/v4-0-0/basics/migrations.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ wheels --version
3939
<Aside type="caution" title="Write commands require a project-bound server">
4040
`wheels migrate latest`, `migrate up`, `migrate down`, `migrate forget`, `migrate pretend`, `migrate rename-system-tables`, `wheels seed`, and `wheels db reset` (which runs migrate + seed) only connect to a server whose port is explicitly configured for this project — via the `port` field in `lucee.json` (created by `wheels new`, or set manually) or the `PORT` variable in `.env`. They refuse the common-port fallback (8080, etc.) to prevent silently targeting a sibling app's server when you work on multiple projects. If you see `Wheels.ServerNotRunning`, run `wheels start` in this project's directory first.
4141

42-
`wheels migrate info` and `wheels migrate doctor` are read-only, but they currently go through the same project-bound check — there is no common-port fallback for them either, so they too need the explicit port config ([#3080](https://github.com/wheels-dev/wheels/issues/3080) tracks whether the read-side fallback comes back).
42+
`wheels migrate info` and `wheels migrate doctor` are read-only, so they keep the common-port fallback like the other read-side commands (`wheels info`, `wheels routes`, …): with no project-bound port configured they probe 8080, 60000, 3000, and 8500 and attach to the first responding server. Because a sibling project's server on one of those ports would report the *wrong* app's migration state, they print a yellow notice whenever the fallback was used — set `port` in `lucee.json` (or `PORT` in `.env`) to pin them to this project ([#3080](https://github.com/wheels-dev/wheels/issues/3080)).
4343
</Aside>
4444

4545
The runner wraps each migration in a transaction so a failing `up()` or `down()` rolls back cleanly on databases that support transactional DDL — but that support varies, so read the caution below before relying on it. One more caveat for scripts and CI: a failed migration is loud in the command output but the CLI currently still exits `0`, so don't gate a pipeline on the exit code alone ([#3081](https://github.com/wheels-dev/wheels/issues/3081)).

0 commit comments

Comments
 (0)