Skip to content

Commit 979b200

Browse files
authored
feat(cli): wheels doctor detects stale installed module shadowing source checkout (#2223) (#2225)
When invoked as `wheels`, LuCLI loads modules from ~/.wheels/modules/wheels/. If that path is a real directory holding a pre-install copy of Module.cfc, edits a contributor makes under cli/lucli/ in a source checkout silently do not take effect — surfaced during #2222, where the merged #2216 fix kept exit-0'ing locally because the installed module was a pre-fix copy. `wheels doctor` now detects this when run from a wheels source checkout (projectRoot contains cli/lucli/Module.cfc + vendor/wheels/). The installed Module.cfc is inspected three ways: - symlink to the checkout → pass - real file, bytes match → pass (stale but harmless this moment) - real file, bytes diverge → warn, with a `rm -rf && ln -s` remediation command in the recommendations section The check is a no-op outside source checkouts and when the installed path is the checkout's own cli/lucli/ (dev-symlinked install). Symlink detection and byte comparison go through java.nio.file.Files for cross-engine parity. Closes #2223.
1 parent a83f16d commit 979b200

4 files changed

Lines changed: 246 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
8585
- Legacy compatibility adapter for 3.x → 4.0 migration soft-landing (#2015)
8686

8787
**CLI & LuCLI**
88+
- `wheels doctor` now detects a stale installed CLI module at `~/.wheels/modules/wheels/` that shadows a source checkout and warns with a remediation command (symlink). Previously, contributors running `wheels` from a checkout could silently execute a pre-install Module.cfc, making merged fixes appear not to take effect. (#2223)
8889
- LuCLI Phase 2: zero-Docker local testing via `tools/test-local.sh` (#2063)
8990
- LuCLI Phase 2: service layer, generators, MCP annotations (#1941)
9091
- LuCLI Phase 3–4: scaffold, seed, in-process services (#2065)

cli/lucli/Module.cfc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3877,7 +3877,8 @@ component extends="modules.BaseModule" {
38773877
break;
38783878
case "doctor":
38793879
variables.services.doctor = new services.Doctor(
3880-
projectRoot = variables.projectRoot
3880+
projectRoot = variables.projectRoot,
3881+
installedModuleRoot = variables.moduleRoot
38813882
);
38823883
break;
38833884
case "stats":

cli/lucli/services/Doctor.cfc

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Health check service for diagnosing Wheels application issues.
33
*
4-
* Performs 7 categories of checks: required dirs, recommended dirs,
4+
* Performs 8 categories of checks: required dirs, recommended dirs,
55
* required files, config validation, write permissions, database config,
6-
* and test coverage. All checks are local file operations — no running
7-
* server required.
6+
* test coverage, and — when running from a source checkout — CLI install
7+
* freshness. All checks are local file operations — no running server
8+
* required.
89
*/
910
component {
1011

11-
public function init(required string projectRoot) {
12+
public function init(required string projectRoot, string installedModuleRoot = "") {
1213
variables.projectRoot = arguments.projectRoot;
14+
variables.installedModuleRoot = arguments.installedModuleRoot;
1315
return this;
1416
}
1517

@@ -26,6 +28,7 @@ component {
2628
checkWritePermissions(results);
2729
checkDatabaseConfig(results);
2830
checkTestCoverage(results);
31+
checkCliInstallFreshness(results);
2932

3033
// Determine overall status
3134
if (arrayLen(results.issues)) {
@@ -188,6 +191,89 @@ component {
188191
}
189192
}
190193

194+
/**
195+
* Detect a stale installed CLI module that shadows the source checkout.
196+
*
197+
* When invoked as `wheels`, LuCLI loads modules from ~/.wheels/modules/.
198+
* If ~/.wheels/modules/wheels/ is a real directory holding a pre-install
199+
* copy of Module.cfc, edits a contributor makes in cli/lucli/ of their
200+
* checkout silently do not take effect — exit-0 behavior persists even
201+
* after a fix is merged. See issue #2223.
202+
*
203+
* This check runs only when all three signals are present:
204+
* - installedModuleRoot was supplied (we know where LuCLI loaded us from)
205+
* - projectRoot looks like a wheels source checkout (has cli/lucli/Module.cfc
206+
* and vendor/wheels/)
207+
* - the installed module is NOT the checkout's cli/lucli/ directory
208+
* (i.e., not a dev-install pointing directly at the checkout)
209+
*/
210+
private void function checkCliInstallFreshness(required struct results) {
211+
if (!len(variables.installedModuleRoot)) return;
212+
213+
var checkoutModulePath = variables.projectRoot & "/cli/lucli/Module.cfc";
214+
var vendorWheelsPath = variables.projectRoot & "/vendor/wheels";
215+
if (!fileExists(checkoutModulePath) || !directoryExists(vendorWheelsPath)) return;
216+
217+
var installedDir = $normalizePath(variables.installedModuleRoot);
218+
var checkoutDir = $normalizePath(variables.projectRoot & "/cli/lucli");
219+
if (installedDir == checkoutDir) return;
220+
221+
var installedModulePath = variables.installedModuleRoot & "/Module.cfc";
222+
if (!fileExists(installedModulePath)) return;
223+
224+
if ($isSymbolicLink(installedModulePath) || $isSymbolicLink(variables.installedModuleRoot)) {
225+
arrayAppend(arguments.results.passed, "Installed CLI module is a symlink (source changes take effect immediately)");
226+
return;
227+
}
228+
229+
if ($filesBytesEqual(installedModulePath, checkoutModulePath)) {
230+
arrayAppend(arguments.results.passed, "Installed CLI module matches source checkout");
231+
return;
232+
}
233+
234+
arrayAppend(
235+
arguments.results.warnings,
236+
"Installed CLI module at #variables.installedModuleRoot# diverges from this source checkout. "
237+
& "Edits under cli/lucli/ will not take effect until you reinstall or replace the installed copy with a symlink."
238+
);
239+
}
240+
241+
// ── Path helpers ─────────────────────────────────────────
242+
243+
private string function $normalizePath(required string path) {
244+
var p = replace(arguments.path, "\", "/", "all");
245+
while (len(p) > 1 && right(p, 1) == "/") {
246+
p = left(p, len(p) - 1);
247+
}
248+
return p;
249+
}
250+
251+
private boolean function $isSymbolicLink(required string path) {
252+
try {
253+
var Paths = createObject("java", "java.nio.file.Paths");
254+
var Files = createObject("java", "java.nio.file.Files");
255+
var javaPath = Paths.get(javacast("string", arguments.path), javacast("string[]", []));
256+
return Files.isSymbolicLink(javaPath);
257+
} catch (any e) {
258+
return false;
259+
}
260+
}
261+
262+
private boolean function $filesBytesEqual(required string a, required string b) {
263+
try {
264+
var Paths = createObject("java", "java.nio.file.Paths");
265+
var Files = createObject("java", "java.nio.file.Files");
266+
var pa = Paths.get(javacast("string", arguments.a), javacast("string[]", []));
267+
var pb = Paths.get(javacast("string", arguments.b), javacast("string[]", []));
268+
if (Files.size(pa) != Files.size(pb)) return false;
269+
var bytesA = Files.readAllBytes(pa);
270+
var bytesB = Files.readAllBytes(pb);
271+
return createObject("java", "java.util.Arrays").equals(bytesA, bytesB);
272+
} catch (any e) {
273+
return false;
274+
}
275+
}
276+
191277
// ── Recommendations ──────────────────────────────────────
192278

193279
private array function buildRecommendations(required struct results) {
@@ -209,6 +295,14 @@ component {
209295
if (findNoCase("Missing required directory", combined)) {
210296
arrayAppend(recs, "Run 'wheels new' to scaffold a complete project structure");
211297
}
298+
if (findNoCase("Installed CLI module", combined) && findNoCase("diverges", combined)) {
299+
arrayAppend(
300+
recs,
301+
"Replace the stale installed module with a symlink to your checkout: "
302+
& "rm -rf #variables.installedModuleRoot# && "
303+
& "ln -s #variables.projectRoot#/cli/lucli #variables.installedModuleRoot#"
304+
);
305+
}
212306

213307
return recs;
214308
}

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,153 @@ component extends="wheels.wheelstest.system.BaseSpec" {
116116
expect(passedText).toInclude("Write permission");
117117
});
118118

119+
describe("CLI install freshness (##2223)", () => {
120+
121+
it("is silent when no installedModuleRoot is provided", () => {
122+
var doctor = new cli.lucli.services.Doctor(projectRoot = tempRoot);
123+
var results = doctor.runChecks();
124+
var combined = arrayToList(results.warnings, " ") & " " & arrayToList(results.passed, " ");
125+
expect(combined).notToInclude("Installed CLI module");
126+
});
127+
128+
it("is silent when projectRoot is not a wheels source checkout", () => {
129+
// tempRoot has no cli/lucli/Module.cfc — should not trigger the check
130+
var fakeInstalled = getTempDirectory() & "wheels-install-" & createUUID();
131+
directoryCreate(fakeInstalled, true);
132+
fileWrite(fakeInstalled & "/Module.cfc", "component { }");
133+
134+
var doctor = new cli.lucli.services.Doctor(
135+
projectRoot = tempRoot,
136+
installedModuleRoot = fakeInstalled
137+
);
138+
var results = doctor.runChecks();
139+
var combined = arrayToList(results.warnings, " ") & " " & arrayToList(results.passed, " ");
140+
expect(combined).notToInclude("Installed CLI module");
141+
142+
directoryDelete(fakeInstalled, true);
143+
});
144+
145+
it("warns when installed Module.cfc diverges from checkout", () => {
146+
var checkout = makeFakeCheckout("component { /* checkout version */ }");
147+
var installed = getTempDirectory() & "wheels-install-" & createUUID();
148+
directoryCreate(installed, true);
149+
fileWrite(installed & "/Module.cfc", "component { /* stale installed version */ }");
150+
151+
var doctor = new cli.lucli.services.Doctor(
152+
projectRoot = checkout,
153+
installedModuleRoot = installed
154+
);
155+
var results = doctor.runChecks();
156+
157+
var warningText = arrayToList(results.warnings, " ");
158+
expect(warningText).toInclude("Installed CLI module");
159+
expect(warningText).toInclude("diverges");
160+
161+
var recText = arrayToList(results.recommendations, " ");
162+
expect(recText).toInclude("ln -s");
163+
164+
directoryDelete(checkout, true);
165+
directoryDelete(installed, true);
166+
});
167+
168+
it("passes when installed Module.cfc matches checkout bytes", () => {
169+
var src = "component { /* identical bytes */ }";
170+
var checkout = makeFakeCheckout(src);
171+
var installed = getTempDirectory() & "wheels-install-" & createUUID();
172+
directoryCreate(installed, true);
173+
fileWrite(installed & "/Module.cfc", src);
174+
175+
var doctor = new cli.lucli.services.Doctor(
176+
projectRoot = checkout,
177+
installedModuleRoot = installed
178+
);
179+
var results = doctor.runChecks();
180+
181+
var warningText = arrayToList(results.warnings, " ");
182+
expect(warningText).notToInclude("Installed CLI module");
183+
184+
var passedText = arrayToList(results.passed, " ");
185+
expect(passedText).toInclude("matches source checkout");
186+
187+
directoryDelete(checkout, true);
188+
directoryDelete(installed, true);
189+
});
190+
191+
it("skips when installedModuleRoot is the checkout's own cli/lucli/", () => {
192+
var checkout = makeFakeCheckout("component { }");
193+
var selfInstalled = checkout & "/cli/lucli";
194+
195+
var doctor = new cli.lucli.services.Doctor(
196+
projectRoot = checkout,
197+
installedModuleRoot = selfInstalled
198+
);
199+
var results = doctor.runChecks();
200+
201+
var combined = arrayToList(results.warnings, " ") & " " & arrayToList(results.passed, " ");
202+
expect(combined).notToInclude("Installed CLI module");
203+
204+
directoryDelete(checkout, true);
205+
});
206+
207+
it("passes when installed module is a symlink", () => {
208+
var checkout = makeFakeCheckout("component { /* target */ }");
209+
var installedParent = getTempDirectory() & "wheels-install-" & createUUID();
210+
directoryCreate(installedParent, true);
211+
var installed = installedParent & "/wheels";
212+
213+
var linkCreated = tryCreateSymlink(installed, checkout & "/cli/lucli");
214+
if (!linkCreated) {
215+
// Symlink unsupported on this FS — skip the assertion
216+
directoryDelete(checkout, true);
217+
directoryDelete(installedParent, true);
218+
return;
219+
}
220+
221+
var doctor = new cli.lucli.services.Doctor(
222+
projectRoot = checkout,
223+
installedModuleRoot = installed
224+
);
225+
var results = doctor.runChecks();
226+
227+
var warningText = arrayToList(results.warnings, " ");
228+
expect(warningText).notToInclude("Installed CLI module");
229+
230+
var passedText = arrayToList(results.passed, " ");
231+
expect(passedText).toInclude("symlink");
232+
233+
// Delete symlink first, then dirs
234+
try { fileDelete(installed); } catch (any e) {}
235+
directoryDelete(installedParent, true);
236+
directoryDelete(checkout, true);
237+
});
238+
239+
});
240+
119241
});
120242

121243
}
122244

245+
// ── Spec helpers ─────────────────────────────────────────
246+
247+
private string function makeFakeCheckout(required string moduleContent) {
248+
var root = getTempDirectory() & "wheels-checkout-" & createUUID();
249+
directoryCreate(root & "/cli/lucli", true);
250+
directoryCreate(root & "/vendor/wheels", true);
251+
fileWrite(root & "/cli/lucli/Module.cfc", arguments.moduleContent);
252+
return root;
253+
}
254+
255+
private boolean function tryCreateSymlink(required string link, required string target) {
256+
try {
257+
var Paths = createObject("java", "java.nio.file.Paths");
258+
var Files = createObject("java", "java.nio.file.Files");
259+
var linkPath = Paths.get(javacast("string", arguments.link), javacast("string[]", []));
260+
var targetPath = Paths.get(javacast("string", arguments.target), javacast("string[]", []));
261+
Files.createSymbolicLink(linkPath, targetPath, javacast("java.nio.file.attribute.FileAttribute[]", []));
262+
return true;
263+
} catch (any e) {
264+
return false;
265+
}
266+
}
267+
123268
}

0 commit comments

Comments
 (0)