feat(cli): non-blocking update check on wheels new#2556
Conversation
Adds services/UpdateChecker.cfc — channel-aware, cached, fail-silent
update check that prints a hint after `wheels new` finishes scaffolding.
Wires it into Module.cfc::new() inside a try/catch belt.
Design constraints (all enforced in the service):
- Never block app creation. All errors swallow silently.
- Hard 5s HTTP timeout, independent of CFML defaults.
- 24h per-channel cache at $LUCLI_HOME/.update-check.json. One real
HTTP request per channel per day, not per `wheels new`.
- Channel routing via existing services/ReleaseChannel.cfc:
stable hits wheels-dev/wheels, bleeding-edge hits
wheels-dev/wheels-snapshots, dev/rc skip.
- Stable: SemVer.compare()
Bleeding-edge: compare snapshot.N suffix when base is equal.
Bridges legacy "SNAPSHOT+N" and post-fix "snapshot.N" formats so
users on pre-fix brew bottles still see correct upgrade hints.
Tests cover the comparison and snapshot-number extraction directly,
plus the non-network channels (dev/rc skip path). HTTP path is
exercised by manual smoke tests + (eventually) the brew tap CI.
Closes the "predict-next-version" thread from the BE channel work —
users get a passive nudge when there's something newer on their
channel, instead of having to remember to `brew upgrade` manually.
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR adds a clean, non-blocking update-check hint to wheels new. The design constraints (5s timeout, 24h cache, channel-aware routing, silent failure) are all sound and correctly implemented. The core logic is correct across all code paths I checked. Three minor nits below — none are correctness blockers — so I'm leaving this as a comment rather than requesting changes.
Conventions
structKeyExists(variables, "verbose") guard is inconsistent and redundant
cli/lucli/Module.cfc, line 4197:
if (structKeyExists(variables, "verbose")) {
try { verbose("Update check failed: " & e.message); } catch (any ignore) {}
}The rest of the file calls verbose() directly without a guard (e.g., lines 551, 859, 1199). Since verbose() is inherited from modules.BaseModule, the check isn't protecting anything the inner try/catch doesn't already cover. The outer condition adds noise and creates an engine-portability question mark (whether inherited methods land in variables scope is engine-dependent). The simpler form already in use elsewhere is:
try { verbose("Update check failed: " & e.message); } catch (any ignore) {}Tests
Missing reason field in struct-shape assertion
cli/lucli/tests/specs/services/UpdateCheckerSpec.cfc, lines 103–112:
it("returns a populated struct even when skipped", () => {
var r = uc.check(currentVersion="");
expect(structKeyExists(r, "hasUpdate")).toBeTrue();
expect(structKeyExists(r, "skipped")).toBeTrue();
expect(structKeyExists(r, "current")).toBeTrue();
expect(structKeyExists(r, "latest")).toBeTrue();
expect(structKeyExists(r, "channel")).toBeTrue();
expect(structKeyExists(r, "upgradeCommand")).toBeTrue();
});The component-level docstring documents reason as a required field of the result struct, but the struct-shape test doesn't verify structKeyExists(r, "reason"). Worth adding to lock the contract.
Docs
No [Unreleased] entry was added to CHANGELOG.md for this user-visible feature. The wheels new output change (new hint line when a newer version is available) should appear under ### Added in the CLI section, following the project's Keep-a-Changelog convention.
Correctness (no issues)
For the record — the following were verified and are correct:
$isNewercorrectly delegates toSemVer.comparefor the base version and falls through to$snapshotNumberonly when bases tie and channel isbleeding-edge. The stable-channel early return is right.$snapshotNumber's character class[.+]correctly matches both.(post-fix format) and+(legacy format) without regex over-matching. ThereFindNoCasereturn shape check (arrayLen(m.match) < 2) correctly handles the no-match case where CFML returns[""].$readCache/$writeCacheround-trip: CFML struct keys are case-insensitive so the uppercase-key serialization quirk on Adobe CF is a non-issue here.dateDiff("s", parseDateTime(entry.checkedAt), now())for the 24h TTL is correct, and the outer try/catch in$readCachehandles a malformed timestamp gracefully.- The GitHub API endpoint choice (
releases/latestfor stable skipping pre-releases;releases?per_page=1for bleeding-edge to include them) is correct. - The test base class (
wheels.wheelstest.system.BaseSpec) matches every other spec incli/lucli/tests/specs/services/. - Commit message
feat(cli): non-blocking update check on wheels newconforms to commitlint rules.
Wheels Bot — Reviewer B (round 1)A's review is clean. No sycophancy, no meaningful false positives. The three nits (redundant verbose guard, missing SycophancyNone detected. Every approval in the "Correctness" section cites the specific code path A verified. No blanket "looks good" without evidence. False positivesOne partial false positive in finding #1: A says the Missed issues
var m = reFindNoCase("snapshot[.+]([0-9]+)", arguments.version, 1, true);
if (!arrayLen(m.match) || arrayLen(m.match) < 2) return 0;When Verdict alignmentA chose ConvergenceAligned. A has three concrete, addressable findings plus the dead-code nit above. Joint recommendation: apply all four fixes — remove the |
- Drop redundant `structKeyExists(variables, "verbose")` guard in Module.cfc — the inner try/catch already covers all failure modes, matching every other verbose() call-site in the file. - Add `structKeyExists(r, "reason")` assertion to the struct-shape test in UpdateCheckerSpec.cfc so it locks the documented contract. - Simplify $snapshotNumber guard in UpdateChecker.cfc by dropping the dead `!arrayLen(m.match)` arm; reFindNoCase with returnsubexpressions=true always returns at least [""], so the `< 2` check alone is sufficient. - Add CHANGELOG.md [Unreleased] entry under CLI & LuCLI for the non-blocking update-check feature.
Wheels Bot — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):
No findings were intentionally skipped — the four items above cover the full union of A's findings and B's missed-issue, and B explicitly aligned on all four in the converged-changes comment. Tests were not re-run locally for this round (PR branch is The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR adds a non-blocking update-check hint to wheels new. The fixup commit (round 1) correctly addresses every finding raised by the previous review round — the verbose guard is gone, the struct-shape test covers reason, the $snapshotNumber guard is simplified, and the CHANGELOG entry is present. The core logic is correct, cross-engine safe, and the test coverage is honest about what it does and does not exercise. Two minor nits below; neither is a correctness blocker, so this is a comment rather than a request-for-changes.
Correctness
Docstring for skipped field is inaccurate (minor)
cli/lucli/services/UpdateChecker.cfc, lines 39-41:
The actual behaviour when the cache is fresh and the cached tag is not newer than the current version is {hasUpdate: false, skipped: false} — skipped stays false. The clause "cache TTL not expired AND no newer version cached" describes a case where the code never sets skipped = true. The three real conditions that produce skipped = true are: (1) channel has no repo (dev/rc), (2) no release tag found on the repo, and (3) any exception in the try/catch. The docstring should reflect this so callers don't write guards against the wrong condition.
Suggested correction:
skipped: boolean, // true when channel doesn't auto-check (dev/rc),
// no release tag found on the repo, or any error
// during the check — hasUpdate is always false when skipped
Conventions
Fallback cache path is not per-user (minor)
cli/lucli/services/UpdateChecker.cfc, line 157:
return "/tmp/.wheels-update-check.json";
This is only reached when both LUCLI_HOME and user.home are unavailable — an unusual situation. However, /tmp is world-writable, so on a multi-user system two accounts running wheels new concurrently could stomp each other's cache entry. Since this is a last-ditch fallback and all write failures are already swallowed silently, the practical impact is low — but worth noting so the pattern is not copied into something less incidental.
Tests
The struct-shape test now verifies all seven documented fields including reason (added in the fixup). The decision to exclude network-path tests is explicitly documented in the spec's opening comment and is the right trade-off given the wrapping try/catch. No gaps.
Cross-engine
No issues found. The cfhttp tag-in-script form, isNull() for Java-null detection, dateTimeFormat, directoryCreate(dir, true), and the reFindNoCase struct shape are all safe across Lucee 5/6/7, Adobe CF 2018-2025, and BoxLang. The [.+] character class in $snapshotNumber is intentional and correct — it is NOT a quantifier; it matches exactly one separator character that is either . or +, covering both the post-fix and legacy snapshot formats.
Commits
Both commits conform to commitlint rules:
feat(cli): non-blocking update check on wheels new— valid type, valid scope, subject within 100 charsfix(cli): address Reviewer A/B consensus findings (round 1)— valid type, valid scope, subject within 100 chars
Wheels Bot — Reviewer B (round 1)A's review on the updated SHA is clean. Finding #1 (inaccurate SycophancyNone detected. A's approvals in the Correctness, Tests, Cross-engine, and Commits sections each cite the specific code path or commit text verified. The "no gaps" summary in Tests is backed by the explicit statement that the decision to skip network-path tests is documented in the spec's opening comment. False positivesNone detected. Finding #1 — Finding #2 — Missed issuesNone detected. Spot-checks performed:
Verdict alignmentA chose ConvergenceAligned. Finding #1 has a concrete, targeted fix: replace the inaccurate docstring clause with A's suggested text. Finding #2 requires no code change. Joint recommendation: apply the docstring correction to |
- cli/lucli/services/UpdateChecker.cfc (component-level docstring) —
Corrected the inaccuracy in the 'skipped' field description. The
previous text claimed skipped became true 'when cache TTL not
expired AND no newer version cached', but that path actually
returns {hasUpdate: false, skipped: false}. Replaced with the
three real triggers: channel has no repo (dev/rc), no release tag
found on the repo, or any error during the check.
Reviewer A's second finding (the /tmp fallback in $resolveCachePath
being world-writable) was a pattern-awareness note with no code
change required and was not part of the converged-changes alignment.
Wheels Bot — Address Review (round 2)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1 on SHA
Skipped (intentionally, not in consensus):
Tests were not re-run locally for this round (PR branch is The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Round 2 is a docstring-only fix. The corrected description of the three skipped = true conditions matches the actual code paths in check() exactly. No new issues. This PR is ready to merge.
Correctness
The round 2 change corrects the skipped field comment at cli/lucli/services/UpdateChecker.cfc, lines 34–37. The previous text described a path that does not exist — "cache TTL not expired AND no newer version cached" never produces skipped = true; that code path returns {hasUpdate: false, skipped: false}. The new text correctly identifies the three real triggers:
!len(repo)at line 74 — dev/rc channels with no release repo →"channel 'X' does not auto-check"!len(latest)at line 82 — no tag returned from the GitHub API →"no release found on repo"catch (any e)at line 93 — any unhandled exception in the check →"check failed: " & e.message
Verified against the check() implementation at lines 61–98. The docstring is now accurate.
Verification of SemVer.compare contract (for the record)
I read cli/lucli/services/SemVer.cfc to confirm the compare() function ignores pre-release labels by design (lines 61–70 — only major/minor/patch are compared, preRelease is stored but never touched in compare). This validates the $isNewer design: SemVer.compare returns 0 for any two versions with the same 4.0.0 base regardless of snapshot suffix, which is exactly why the bleeding-edge branch falls through to $snapshotNumber. The legacy format case (4.0.0-SNAPSHOT+1656 vs 4.0.0-snapshot.1790) also works: parse() splits on - first, so the +1656 lands in preRelease and never reaches the + stripping branch — base parses to {4, 0, 0} for both, and $snapshotNumber then correctly extracts 1656 and 1790 respectively.
Prior rounds — all findings resolved
Two prior rounds found and fixed: the superfluous structKeyExists(variables, "verbose") guard, the missing reason field in the struct-shape test, the CHANGELOG entry, and the inaccurate skipped docstring. The world-writable /tmp fallback in $resolveCachePath() was a pattern-awareness note; the commit message explicitly documents the deliberate deferral of that one.
Commits
All three commits conform to commitlint rules:
| Commit | Type | Scope | Length |
|---|---|---|---|
feat(cli): non-blocking update check on wheels new |
feat |
cli |
46 chars |
fix(cli): address Reviewer A/B consensus findings (round 1) |
fix |
cli |
53 chars |
fix(cli): address Reviewer A/B consensus findings (round 2) |
fix |
cli |
53 chars |
Wheels Bot — Reviewer B (round 1)A's review on the current SHA is clean. A docstring-only round with all prior findings already applied; the APPROVED verdict is correct and consistent with the evidence cited. SycophancyNone detected. A's approval cites the specific three code paths where False positivesNone detected. A's claim that the corrected docstring accurately describes the three
The previous docstring text ("cache TTL not expired AND no newer version cached") was genuinely inaccurate — that code path returns Missed issuesNone detected. Spot-checks performed:
Verdict alignmentA chose APPROVED. Consistent with the findings: one docstring correction with no runtime behavior change, all prior findings addressed, no new issues. Appropriate verdict. ConvergenceAligned. A's review is accurate and complete for a docstring-only round. Nothing remains to address. Joint recommendation: approve and merge. |
Summary
Closes the original ask from the BE-channel thread: when a user runs
wheels new myapp, print a small hint at the end if a newer wheels release is available on their channel.Constraints I made the service enforce:
wheels new— all errors swallow silently. Wrapped in a try/catch belt at the call site.$LUCLI_HOME/.update-check.json. One real network request per channel per day, not perwheels new.services/ReleaseChannel.cfc:stable→wheels-dev/wheelsbleeding-edge→wheels-dev/wheels-snapshotsrelease-candidate/development→ skip (RC users opted in explicitly; dev checkouts have no meaningful target)4.0.0-SNAPSHOT+1656. The latest published is4.0.0-snapshot.1790. The$snapshotNumberextractor handles both forms and$isNewercorrectly flags it as upgrade-worthy.What lands
cli/lucli/services/UpdateChecker.cfccli/lucli/Module.cfc(~24 LOC)new(). Try/catch belt.cli/lucli/tests/specs/services/UpdateCheckerSpec.cfcWhat it looks like at runtime
When there's nothing newer:
When there's a newer build:
Test plan
wheels new test-appwith a stale local install shows the hintwheels new test-appwith a fresh install does NOT show the hintwheels new test-appwith no network completes silently (no hang, no error)🤖 Generated with Claude Code