From e249130fe97e2c572f41cb543c3bbbc0ca34389e Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sat, 3 Oct 2026 07:49:54 -0700 Subject: [PATCH 1/4] fix(cli): print only user help text in wheels --help $commandHelp() printed a command function's whole hint docblock, so maintainer notes (history, internals, issue numbers) showed up as help for reload, map, upgrade and packages. --help now prints the one-line `hint:` summary plus the docblock's `Usage:` and `Examples:` sections (indented), via $commandHelpParts(). Anything between the summary and the first section is never printed. A docblock without a `hint:` key is not command help. - reload, map, upgrade, packages: maintainer notes moved to // comments - docs: the wrapped hint joined into one line - coverage, engines: plain docblocks got one-line `hint:` summaries - mcpHiddenTools: a runtime hook, so its docblock drops the `hint:` key - test --timeout option text: dropped a LuCLI issue reference CommandHelpTextSpec renders --help for every public command and fails on docblock text outside the hint line and sections, or on an issue reference in the output. MainCommandSpec's source checks follow the code into $commandHelpParts(). Closes #4168 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- changelog.d/4168-cli-help-user-text.fixed.md | 1 + cli/lucli/Module.cfc | 161 +++++++++++------- .../specs/commands/CommandHelpTextSpec.cfc | 122 +++++++++++++ .../tests/specs/commands/MainCommandSpec.cfc | 8 +- 4 files changed, 228 insertions(+), 64 deletions(-) create mode 100644 changelog.d/4168-cli-help-user-text.fixed.md create mode 100644 cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc diff --git a/changelog.d/4168-cli-help-user-text.fixed.md b/changelog.d/4168-cli-help-user-text.fixed.md new file mode 100644 index 0000000000..219ec681c6 --- /dev/null +++ b/changelog.d/4168-cli-help-user-text.fixed.md @@ -0,0 +1 @@ +- `wheels --help` prints only the command's summary line and its `Usage:` / `Examples:` sections. Internal notes no longer appear in the help for `reload`, `map`, `upgrade` and `packages`, `wheels coverage --help` and `wheels engines --help` print a one-line summary, and Usage/Examples lines are indented. diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 58e68bc876..0af085bed3 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -196,7 +196,7 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * hint: Declare public functions to hide from MCP tools/list. + * Declare public functions to hide from MCP tools/list. * * These remain reachable as CLI subcommands. Hidden because they are * stateful (start/stop), destructive (new scaffolds a whole project), @@ -505,7 +505,7 @@ component extends="modules.BaseModule" { .option(name = "reporter", default = "simple", choices = "simple,json,tap", description = "Output format") .option(name = "db", default = "sqlite", choices = "sqlite,h2,mysql,postgres,sqlserver,sqlserver_cicd,oracle,cockroachdb", description = "--core only: the database the framework core suite runs against. The app suite ignores it and uses the app's test datasource") .option(name = "base-path", default = "", description = "URL prefix the app is mounted under (e.g. /myapp). Auto-derived from WHEELS_SUBPATH or set(subpath=...) when omitted.") - .option(name = "timeout", default = "", description = "Seconds to wait for the suite to finish (default 900). Also settable with WHEELS_TEST_TIMEOUT. On the terminal use --test-timeout=: it works on every LuCLI runtime, while a plain --timeout only reaches this command on LuCLI builds that include the module-timeout fix (LuCLI ##130)") + .option(name = "timeout", default = "", description = "Seconds to wait for the suite to finish (default 900). Also settable with WHEELS_TEST_TIMEOUT. On the terminal use --test-timeout=: it works on every LuCLI runtime, while a plain --timeout only reaches this command on LuCLI builds that include the module-timeout fix") .flag(name = "verbose", default = false, description = "Print per-spec detail instead of the summary rollup") .flag(name = "ci", default = false, description = "CI mode output") .flag(name = "core", default = false, description = "Run the framework core suite (vendor/wheels/tests) instead of the app suite") @@ -834,18 +834,23 @@ component extends="modules.BaseModule" { for (var fn in (meta.functions ?: [])) { if (lCase(fn.name ?: "") == fnName && (fn.access ?: "public") == "public") { hint = trim(fn.hint ?: ""); - // The `/** hint: ... */` convention surfaces the value with the - // literal "hint:" key prefix on Lucee — strip it for clean output. - hint = trim(reReplaceNoCase(hint, "^hint\s*:\s*", "")); break; } } - if (!len(hint)) { + var parts = $commandHelpParts(hint); + if (!len(parts.summary)) { return ""; } var help = "wheels " & lCase(trim(arguments.subcommand)) & nl & nl; - help &= " " & hint & nl & nl; + help &= " " & parts.summary & nl & nl; + for (var section in parts.sections) { + help &= section.heading & nl; + for (var line in section.lines) { + help &= " " & line & nl; + } + help &= nl; + } // Options come from the command's own ArgSpec (`ArgSpec()`), // the declaration the parser and the MCP schema already share (#3962). var optionLines = $commandOptionLines(fnName); @@ -862,6 +867,39 @@ component extends="modules.BaseModule" { } + /** + * Split a command function's metadata hint into what `--help` prints: the + * one-line `hint:` summary, then any `Usage:` / `Examples:` sections. Lucee + * hands the docblock over with blank lines and indentation removed, so a + * section runs from its heading to the next heading or the end. Lines + * between the summary and the first heading are returned as `stray` and + * never printed; maintainer notes belong in `//` comments instead. A + * docblock without a `hint:` key is not command help (summary is ""). + * Public so specs can check every command's docblock shape. + */ + public struct function $commandHelpParts(required string hint) { + var rv = {summary = "", sections = [], stray = []}; + var lines = listToArray(replace(arguments.hint, chr(13), "", "all"), chr(10), true); + if (!arrayLen(lines) || !reFindNoCase("^\s*hint\s*:", lines[1])) { + return rv; + } + rv.summary = trim(reReplaceNoCase(lines[1], "^\s*hint\s*:\s*", "")); + for (var i = 2; i <= arrayLen(lines); i++) { + var line = trim(lines[i]); + if (!len(line)) { + continue; + } + if (line == "Usage:" || line == "Examples:") { + arrayAppend(rv.sections, {heading = line, lines = []}); + } else if (arrayLen(rv.sections)) { + arrayAppend(rv.sections[arrayLen(rv.sections)].lines, line); + } else { + arrayAppend(rv.stray, line); + } + } + return rv; + } + /** * The Options lines for `wheels --help`, rendered from the * command's `ArgSpec()` when it has one; empty otherwise. Help @@ -1592,11 +1630,13 @@ component extends="modules.BaseModule" { ); } + // Coverage: instrument app/ with function-level coverage counters, run the + // app test suite against the running server, and report a CRAP ranking + // (Change Risk Anti-Patterns: complexity^2 x (1 - coverage)^3 + complexity). + // The instrumentation is reverted afterward (originals restored exactly). + // Kept out of the docblock below: `wheels coverage --help` prints it. /** - * Coverage: instrument app/ with function-level coverage counters, run the - * app test suite against the running server, and report a CRAP ranking - * (Change Risk Anti-Patterns: complexity^2 x (1 - coverage)^3 + complexity). - * The instrumentation is reverted afterward (originals restored exactly). + * hint: Run the app test suite with function-level coverage and report a CRAP ranking (app/ is restored afterward) */ public string function coverage() { var opts = parseCoverageArgs(structuredArgs(arguments)); @@ -1916,8 +1956,7 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * hint: Fetch the local offline documentation bundle (so the guides and - * API reference work with no internet connection) + * hint: Fetch the local offline documentation bundle (so the guides and API reference work with no internet connection) */ public string function docs() { var args = new services.ArgSpec().toArgv(structuredArgs(arguments)); @@ -2352,16 +2391,16 @@ component extends="modules.BaseModule" { // reload — Reload application // ───────────────────────────────────────────────── + // The reload password + // gates the HTTP `?reload=true` endpoint against remote attackers; + // the CLI reads it from `.env` or `config/settings.cfm` and forwards + // it because it runs locally with filesystem access. This matches + // how Rails, Laravel, Symfony, etc. treat CLI-vs-HTTP — the CLI is + // already trusted at the same level as the project on disk. See + // issue #2477 and `deployment/security-hardening.mdx`. + // Kept out of the docblock below: `wheels reload --help` prints it. /** * hint: Reload the running Wheels application (the CLI forwards the reload password from .env or config/settings.cfm). - * - * The reload password - * gates the HTTP `?reload=true` endpoint against remote attackers; - * the CLI reads it from `.env` or `config/settings.cfm` and forwards - * it because it runs locally with filesystem access. This matches - * how Rails, Laravel, Symfony, etc. treat CLI-vs-HTTP — the CLI is - * already trusted at the same level as the project on disk. See - * issue #2477 and `deployment/security-hardening.mdx`. */ public string function reload() { // Validate arguments before touching the server (#2963): an unknown or @@ -2885,11 +2924,13 @@ component extends="modules.BaseModule" { // engines — manage the dev-server engine backend // ───────────────────────────────────────────────── + // `wheels engines rustcfml install|start|stop|status` — the RustCFML + // (JVM-free CFML) engine backend. Deliberately separate from + // `start`/`stop` (Lucee via LuCLI) so the two lifecycles never share a + // registry. Hidden from MCP like the other stateful server commands. + // Kept out of the docblock below: `wheels engines --help` prints it. /** - * `wheels engines rustcfml install|start|stop|status` — the RustCFML - * (JVM-free CFML) engine backend. Deliberately separate from - * `start`/`stop` (Lucee via LuCLI) so the two lifecycles never share a - * registry. Hidden from MCP like the other stateful server commands. + * hint: `wheels engines rustcfml install|start|stop|status` — manage the RustCFML (JVM-free CFML) engine */ public string function engines() { var spec = new services.ArgSpec(); @@ -3418,12 +3459,12 @@ component extends="modules.BaseModule" { // mcp — MCP server instructions // ───────────────────────────────────────────────── + // `map setup` shipped in snapshot 2499 before we learned that the literal + // token `mcp` is intercepted by the runtime in any argv position. Kept as a + // thin forwarder so that spelling keeps working. + // Kept out of the docblock below: `wheels map --help` prints it. /** * hint: Deprecated spelling — `wheels map setup` forwards to `setup agents` - * - * `map setup` shipped in snapshot 2499 before we learned that the literal - * token `mcp` is intercepted by the runtime in any argv position. Kept as a - * thin forwarder so that spelling keeps working. */ public string function map() { var args = new services.ArgSpec().toArgv(structuredArgs(arguments)); @@ -5125,15 +5166,15 @@ component extends="modules.BaseModule" { // packages — registry-backed package manager // ───────────────────────────────────────────────── + // The verb is `add`, NOT `install`. Typing `wheels packages install ` + // is intercepted by LuCLI's built-in extension installer before dispatch + // reaches this module, and prints `[INFO] No git or extension dependencies + // to install` without actually installing anything. See chapter 8 of the + // tutorial for the explanation. + // Kept out of the docblock below: `wheels packages --help` prints it. /** * hint: Add, update, and list Wheels packages (verb is `add`, not `install`) * - * The verb is `add`, NOT `install`. Typing `wheels packages install ` - * is intercepted by LuCLI's built-in extension installer before dispatch - * reaches this module, and prints `[INFO] No git or extension dependencies - * to install` without actually installing anything. See chapter 8 of the - * tutorial for the explanation. - * * Usage: * wheels packages list [--tag=] * wheels packages search @@ -5824,34 +5865,34 @@ component extends="modules.BaseModule" { }; } + // `wheels upgrade apply` performs the framework swap (#3035): it + // replaces the app's vendor/wheels/ with the framework bundled inside + // the installed CLI, parking the old copy at vendor/wheels.bak-/ + // unless --nobackup. Recovery is a single mv (announced, with the exact + // backup path, before anything is touched). Only the CLI's bundled + // framework is available as a source for now — pair it with your package + // manager (`brew upgrade wheels`, `brew install wheels-be`, `scoop update + // wheels`) to choose what gets bundled. Downloading arbitrary --to= + // targets is the planned follow-up. + // + // Bare `wheels upgrade` deliberately does nothing: it prints concise + // usage steering at the two verbs and exits 0. Destructive commands + // deserve an explicit verb, and MCP clients calling wheels_upgrade with + // {} must never mutate — requiring `apply` fixes that transport- + // independently, and exit 0 matches the pre-apply-mode bare behavior so + // existing CI invocations see usage text, not a new failure. + // + // `wheels upgrade check` keeps the read-only scan: it reports code paths + // that will break against a target framework version without modifying + // any files. Breaking findings throw Wheels.UpgradeCheckFailed after the + // report is printed, so the command exits non-zero and can gate CI. + // --strict escalates advisory findings the same way, and so does a failed + // latest-release lookup when no --to= is given. (--dry-run is not + // supported — `check` is the preview.) + // Kept out of the docblock below: `wheels upgrade --help` prints it. /** * hint: Upgrade the Wheels framework in your app (vendor/wheels/) — `check` scans for breaking changes (read-only), `apply` performs the swap * - * `wheels upgrade apply` performs the framework swap (#3035): it - * replaces the app's vendor/wheels/ with the framework bundled inside - * the installed CLI, parking the old copy at vendor/wheels.bak-/ - * unless --nobackup. Recovery is a single mv (announced, with the exact - * backup path, before anything is touched). Only the CLI's bundled - * framework is available as a source for now — pair it with your package - * manager (`brew upgrade wheels`, `brew install wheels-be`, `scoop update - * wheels`) to choose what gets bundled. Downloading arbitrary --to= - * targets is the planned follow-up. - * - * Bare `wheels upgrade` deliberately does nothing: it prints concise - * usage steering at the two verbs and exits 0. Destructive commands - * deserve an explicit verb, and MCP clients calling wheels_upgrade with - * {} must never mutate — requiring `apply` fixes that transport- - * independently, and exit 0 matches the pre-apply-mode bare behavior so - * existing CI invocations see usage text, not a new failure. - * - * `wheels upgrade check` keeps the read-only scan: it reports code paths - * that will break against a target framework version without modifying - * any files. Breaking findings throw Wheels.UpgradeCheckFailed after the - * report is printed, so the command exits non-zero and can gate CI. - * --strict escalates advisory findings the same way, and so does a failed - * latest-release lookup when no --to= is given. (--dry-run is not - * supported — `check` is the preview.) - * * Examples: * wheels upgrade apply - apply the swap, with backup * wheels upgrade apply --nobackup - apply without the backup diff --git a/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc b/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc new file mode 100644 index 0000000000..146efec436 --- /dev/null +++ b/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc @@ -0,0 +1,122 @@ +/** + * `wheels --help` prints the command's `hint:` line plus its + * `Usage:` / `Examples:` sections, and nothing else from the docblock. + * + * It used to print the whole docblock, so maintainer notes (history, internals, + * issue numbers) showed up as help text for reload, map, upgrade and packages. + * Those notes now live in `//` comments; this spec keeps them out of help. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.mod = new cli.lucli.Module(cwd = getTempDirectory()); + variables.commands = []; + for (var fn in getMetaData(variables.mod).functions) { + if ((fn.access ?: "public") == "public" && left(fn.name, 1) != "$") { + arrayAppend(variables.commands, {name = fn.name, hint = fn.hint ?: ""}); + } + } + } + + function run() { + + describe("wheels --help", () => { + + it("finds the commands to check", () => { + expect(arrayLen(commands)).toBeGT(20); + }); + + it("has no docblock text between the hint line and a Usage: or Examples: section", () => { + var offenders = []; + for (var command in commands) { + var parts = mod.$commandHelpParts(command.hint); + if (arrayLen(parts.stray)) { + arrayAppend(offenders, command.name & " (" & parts.stray[1] & ")"); + } + } + expect(arrayToList(offenders, "; ")).toBe( + "", + "move maintainer notes to // comments above the docblock: --help shows only the hint line and Usage:/Examples: sections" + ); + }); + + it("prints no issue references for any command", () => { + var offenders = []; + for (var command in commands) { + var help = mod.showHelp(command.name); + if (reFind("##[0-9]{3,}", help)) { + arrayAppend(offenders, command.name); + } + } + expect(arrayToList(offenders, ", ")).toBe("", "--help text is for users, not maintainers"); + }); + + it("prints the hint line but not the maintainer notes for reload, map, upgrade and packages", () => { + var reload = mod.showHelp("reload"); + expect(reload).toInclude("Reload the running Wheels application"); + expect(reload).notToInclude("Rails"); + + var map = mod.showHelp("map"); + expect(map).toInclude("forwards to `setup agents`"); + expect(map).notToInclude("snapshot"); + + var upgrade = mod.showHelp("upgrade"); + expect(upgrade).toInclude("Upgrade the Wheels framework in your app"); + expect(upgrade).notToInclude("deliberately does nothing"); + + var packages = mod.showHelp("packages"); + expect(packages).toInclude("verb is `add`, not `install`"); + expect(packages).notToInclude("chapter 8"); + }); + + it("keeps the Usage: and Examples: sections, indented", () => { + var deploy = mod.showHelp("deploy"); + expect(deploy).toInclude("Usage:" & chr(10) & " wheels deploy "); + expect(deploy).toInclude(" wheels deploy --dry-run"); + + var packages = mod.showHelp("packages"); + expect(packages).toInclude("Usage:" & chr(10) & " wheels packages list"); + + var upgrade = mod.showHelp("upgrade"); + expect(upgrade).toInclude("Examples:" & chr(10) & " wheels upgrade apply "); + }); + + it("prints a command's own help for coverage and engines", () => { + expect(mod.showHelp("coverage")).toInclude("wheels coverage"); + expect(mod.showHelp("coverage")).toInclude("CRAP ranking"); + expect(mod.showHelp("engines")).toInclude("wheels engines"); + expect(mod.showHelp("engines")).toInclude("RustCFML"); + }); + + }); + + describe("$commandHelpParts()", () => { + + it("splits the hint line from the Usage: and Examples: sections", () => { + var parts = mod.$commandHelpParts( + "hint: Do a thing" & chr(10) & "Usage:" & chr(10) & "wheels thing a" & chr(10) + & "Examples:" & chr(10) & "wheels thing b" + ); + expect(parts.summary).toBe("Do a thing"); + expect(arrayLen(parts.sections)).toBe(2); + expect(parts.sections[1].heading).toBe("Usage:"); + expect(parts.sections[1].lines).toBe(["wheels thing a"]); + expect(parts.sections[2].lines).toBe(["wheels thing b"]); + expect(parts.stray).toBeEmpty(); + }); + + it("returns text before the first section as stray, which is never printed", () => { + var parts = mod.$commandHelpParts("hint: Do a thing" & chr(10) & "Why this exists" & chr(10) & "Usage:" & chr(10) & "wheels thing"); + expect(parts.stray).toBe(["Why this exists"]); + expect(arrayLen(parts.sections)).toBe(1); + }); + + it("treats a docblock without a hint: key as no command help", () => { + var parts = mod.$commandHelpParts("Internal notes about a hook."); + expect(parts.summary).toBe(""); + }); + + }); + } + +} diff --git a/cli/lucli/tests/specs/commands/MainCommandSpec.cfc b/cli/lucli/tests/specs/commands/MainCommandSpec.cfc index ca89c9d0cc..718d7dd7cc 100644 --- a/cli/lucli/tests/specs/commands/MainCommandSpec.cfc +++ b/cli/lucli/tests/specs/commands/MainCommandSpec.cfc @@ -117,9 +117,9 @@ component extends="wheels.wheelstest.system.BaseSpec" { it("strips the literal 'hint:' prefix from function metadata", () => { // Lucee surfaces /** hint: ... */ values with the literal "hint:" - // prefix; the helper must regex-strip it before rendering. Match - // the case-insensitive ^hint\s*:\s* anchor. - expect(reFindNoCase("reReplaceNoCase\s*\(\s*hint\s*,\s*""\^hint", variables.source)).toBeGT(0); + // prefix; $commandHelpParts() strips it from the hint line before + // rendering. Match the case-insensitive ^\s*hint\s*:\s* anchor. + expect(variables.source).toInclude('rv.summary = trim(reReplaceNoCase(lines[1], "^\s*hint\s*:\s*", ""));'); }); it("returns empty string for unknown commands so showHelp() falls through", () => { @@ -132,7 +132,7 @@ component extends="wheels.wheelstest.system.BaseSpec" { var startIdx = reFindNoCase("(?m)^[ \t]*private\s+string\s+function\s+\$commandHelp\s*\(", variables.source); expect(startIdx).toBeGT(0); var body = mid(variables.source, startIdx, 1200); - expect(body).toInclude("if (!len(hint))"); + expect(body).toInclude("if (!len(parts.summary))"); expect(body).toInclude("return """""); }); From 592854bdb1b934696b02eb76b157ea203c420c21 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sat, 3 Oct 2026 09:35:42 -0700 Subject: [PATCH 2/4] fix(cli): describe packages install as an alias of add The packages help, error message and comments said `wheels packages install` is intercepted by LuCLI and never installs anything. On the current runtime it reaches the module and installs exactly like `add`. - packages hint, global help line, `wheels packages help` note and the unknown-subcommand error: `install` is an alias of `add`; `add` stays the documented verb - the `case "install":` and PackagesMainCli comments keep the interception as history (older runtimes) instead of current fact - the app template's plugins/README drops the "not install" note - specs follow: PackagesCommandSpec gets a running block for the alias text (its help block is skipped since #2829), CommandHelpTextSpec, and the core PackagesCommandHelpSpec / install-verb guard comments Closes #4206 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../4206-packages-install-alias-help.fixed.md | 1 + cli/lucli/Module.cfc | 53 +++++++------------ .../services/packages/PackagesMainCli.cfc | 19 +++---- cli/lucli/templates/app/plugins/README.md | 2 - .../specs/commands/CommandHelpTextSpec.cfc | 2 +- .../specs/commands/PackagesCommandSpec.cfc | 41 ++++++++++---- .../specs/cli/PackagesCommandHelpSpec.cfc | 11 ++-- .../packages/CliTemplateInstallVerbSpec.cfc | 13 +++-- .../PackagesAddVerbAdvertisingSpec.cfc | 6 ++- 9 files changed, 74 insertions(+), 74 deletions(-) create mode 100644 changelog.d/4206-packages-install-alias-help.fixed.md diff --git a/changelog.d/4206-packages-install-alias-help.fixed.md b/changelog.d/4206-packages-install-alias-help.fixed.md new file mode 100644 index 0000000000..b0bf3fc3c9 --- /dev/null +++ b/changelog.d/4206-packages-install-alias-help.fixed.md @@ -0,0 +1 @@ +- `wheels packages` help and messages no longer say `wheels packages install` doesn't work. `install` is an alias of `add`, and the help now says so; `add` stays the documented verb. diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 0af085bed3..307aaa7e38 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -805,7 +805,7 @@ component extends="modules.BaseModule" { help &= " stats Project statistics (lines of code, model counts, etc.)" & nl; help &= " notes Find TODO / FIXME / OPTIMIZE comments (--annotations to customize)" & nl & nl; help &= "Packages & Deployment:" & nl; - help &= " packages Add, update, search Wheels packages (verb is `add`, not `install`)" & nl; + help &= " packages Add (or install), update, search Wheels packages" & nl; help &= " upgrade Upgrade the Wheels framework in your app (vendor/wheels/); `check` scans, `apply` swaps" & nl; help &= " deploy Deploy your app (Kamal-compatible)" & nl & nl; help &= "Other:" & nl; @@ -5166,20 +5166,19 @@ component extends="modules.BaseModule" { // packages — registry-backed package manager // ───────────────────────────────────────────────── - // The verb is `add`, NOT `install`. Typing `wheels packages install ` - // is intercepted by LuCLI's built-in extension installer before dispatch - // reaches this module, and prints `[INFO] No git or extension dependencies - // to install` without actually installing anything. See chapter 8 of the - // tutorial for the explanation. + // `add` is the documented install verb and `install` is an alias for it + // (see the dispatch below). Older LuCLI runtimes intercepted the literal + // `install` verb before it reached this module (#2713); the runtime the + // CLI ships on now passes it through (#4206). // Kept out of the docblock below: `wheels packages --help` prints it. /** - * hint: Add, update, and list Wheels packages (verb is `add`, not `install`) + * hint: Add, update, and list Wheels packages (`install` is an alias of `add`) * * Usage: * wheels packages list [--tag=] * wheels packages search * wheels packages show - * wheels packages add [@] [--force] ← install verb + * wheels packages add [@] [--force] (alias: install) * wheels packages update --yes * wheels packages update --all --yes * wheels packages remove @@ -5212,8 +5211,7 @@ component extends="modules.BaseModule" { // `--help` / `-h` short-circuits to a deterministic help string the // module owns directly. LuCLI's auto-introspected help previously - // drifted from the real CLI surface — advertising the dead `install` - // verb that LuCLI itself intercepts (#2713). Owning the text here + // drifted from the real CLI surface (#2713). Owning the text here // guarantees `wheels packages help`, `wheels packages --help`, and // `wheels packages -h` all reach $packagesHelp(). // @@ -5251,19 +5249,13 @@ component extends="modules.BaseModule" { arguments.opts.name = $packagesRequireArg(arguments.positional, "show requires a name: wheels packages show "); return $packagesMainCli().show(arguments.opts); case "install": - // LuCLI's built-in extension installer intercepts the - // literal verb `install` on the user-facing CLI surface - // — same trap that bit `wheels browser install` (renamed - // to `wheels browser setup` in #2345). But every other - // caller path reaches this dispatch directly: the - // stdio MCP server (`wheels mcp wheels`), scripted - // in-process clients, and the bundle's own spec suite. - // `PackagesMainCli.install()` has been a transparent - // alias for `add()` since #2729, so the dispatch layer - // must match — otherwise `install ` silently - // no-ops on the only paths LuCLI does NOT intercept. - // Fall through to the `add` branch (same validation, - // same error shape, same install behavior). + // `install` is an alias of `add` on every path: the CLI + // (older LuCLI runtimes intercepted the literal verb + // before dispatch; #2713, #4206), the stdio MCP server + // (`wheels mcp wheels`), scripted in-process clients and + // the spec suite. `PackagesMainCli.install()` is the same + // alias (#2729). Fall through to the `add` branch (same + // validation, same error shape, same install behavior). case "add": arguments.opts.target = $packagesRequireArg(arguments.positional, "add requires a name: wheels packages add [@]"); return $packagesMainCli().add(arguments.opts); @@ -5276,7 +5268,7 @@ component extends="modules.BaseModule" { case "registry": return $dispatchPackagesRegistry(arguments.positional, arguments.opts); default: - throw(message="Unknown packages subcommand: #arguments.sub#. The install verb is `add` (not `install`): wheels packages add "); + throw(message="Unknown packages subcommand: #arguments.sub#. To install a package: wheels packages add "); } } @@ -5314,10 +5306,7 @@ component extends="modules.BaseModule" { // Hand-written help for `wheels packages`. Owned by the module rather than // auto-derived from picocli introspection because the auto-help drifted - // from the real CLI surface (#2713 — advertised `install [--force]` - // even though LuCLI's built-in extension installer intercepts the literal - // `install` verb before dispatch reaches this module). Same trap that hit - // `wheels browser install` (renamed to `setup` in #2345). + // from the real CLI surface (#2713). private string function $packagesHelp() { var nl = chr(10); var help = "Usage: wheels packages [options]" & nl; @@ -5333,12 +5322,8 @@ component extends="modules.BaseModule" { help &= " registry refresh Bust the 24-hour registry cache" & nl; help &= " registry info Show the registry URL and cache state" & nl; help &= " help, --help, -h Show this help" & nl & nl; - help &= "Note: the install verb is `add`, NOT `install`." & nl; - help &= " Typing `wheels packages install ` is intercepted by LuCLI's built-in" & nl; - help &= " extension installer before dispatch reaches this module, and prints" & nl; - help &= " '[INFO] No git or extension dependencies to install' without installing" & nl; - help &= " anything. Use `wheels packages add ` instead. Same trap that bit" & nl; - help &= " `wheels browser install` (renamed to `wheels browser setup` in ##2345)." & nl & nl; + help &= "Note: `install` is an alias of `add`, so `wheels packages install `" & nl; + help &= " works the same way." & nl & nl; help &= "Examples:" & nl; help &= " wheels packages list" & nl; help &= " wheels packages search ui" & nl; diff --git a/cli/lucli/services/packages/PackagesMainCli.cfc b/cli/lucli/services/packages/PackagesMainCli.cfc index f0e76acca0..20ceefcbae 100644 --- a/cli/lucli/services/packages/PackagesMainCli.cfc +++ b/cli/lucli/services/packages/PackagesMainCli.cfc @@ -10,13 +10,10 @@ * update --all --yes * remove * - * `add` (not `install`!) is the install verb because LuCLI's built-in - * extension installer intercepts the literal subcommand `install` - * across all modules — same trap that bit `wheels browser install` - * (renamed to `wheels browser setup` in #2345). User input - * `wheels packages install ` never reaches Module.cfc; LuCLI - * runs its own dependency installer against `lucee.json` instead and - * prints "No git or extension dependencies to install". + * `add` is the documented install verb; `install` is an alias (see + * install() below). Older LuCLI runtimes intercepted the literal + * subcommand `install` before Module.cfc dispatched it (#2713); the + * runtime the CLI ships on now passes it through (#4206). * * Outputs plain text suitable for a terminal. Exit-code semantics are * the caller's job (Module.cfc throws to signal non-zero on fatal error; @@ -168,12 +165,8 @@ component { return $doInstall(local.parsed.name, local.parsed.pin, local.force); } - // `install` is preserved as an alias for any in-process callers - // (specs, scripted clients) that haven't migrated to `add`. Note - // that the public CLI surface — `wheels packages install ` — - // never reaches this method because LuCLI's built-in extension - // installer intercepts the literal `install` subcommand before - // Module.cfc dispatches. See the component header. + // `install` is an alias for `add` on every path: the CLI, the MCP + // server, specs and scripted clients. See the component header. public string function install(struct opts = {}) { return add(argumentCollection = arguments); } diff --git a/cli/lucli/templates/app/plugins/README.md b/cli/lucli/templates/app/plugins/README.md index 419c3d068d..0dee33dbf3 100644 --- a/cli/lucli/templates/app/plugins/README.md +++ b/cli/lucli/templates/app/plugins/README.md @@ -15,8 +15,6 @@ wheels packages add wheels-hotwire wheels stop && wheels start ``` -Note: the install verb is `add`, not `install`. - See [Packages](https://guides.wheels.dev/v4-2-0/digging-deeper/packages/) in the guides for details. ## Migrating from a 3.x plugin diff --git a/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc b/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc index 146efec436..759ed907b8 100644 --- a/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc +++ b/cli/lucli/tests/specs/commands/CommandHelpTextSpec.cfc @@ -65,7 +65,7 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(upgrade).notToInclude("deliberately does nothing"); var packages = mod.showHelp("packages"); - expect(packages).toInclude("verb is `add`, not `install`"); + expect(packages).toInclude("`install` is an alias of `add`"); expect(packages).notToInclude("chapter 8"); }); diff --git a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc index 65acae8ba6..f196a897bc 100644 --- a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc +++ b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc @@ -1,10 +1,9 @@ /** * Tests `wheels packages help` / `wheels packages --help` via Module.cfc. * - * Issue #2713: the help output must document `add` (not `install`) as the - * canonical install verb, and must explain why `install` does not work - * (LuCLI's built-in extension installer intercepts the literal verb before - * dispatch reaches this module). + * Issue #2713: the help output documents `add` as the canonical install + * verb. Issue #4206: `install` is an alias of `add` that works on the CLI + * too, so the help says so instead of warning that it is intercepted. */ component extends="wheels.wheelstest.system.BaseSpec" { @@ -60,6 +59,28 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + // `help` returns the module-owned text before any registry call, so this + // block runs in every environment, unlike the skipped one below (#4206). + describe("wheels packages help — `install` is an alias of `add`", () => { + + it("says so in `wheels packages help`, without the old intercept warning", () => { + mod.__arguments = ["help"]; + var out = mod.packages(); + expect(out).toInclude("wheels packages add"); + expect(out).toInclude("`install` is an alias of `add`"); + expect(REFindNoCase("intercept", out)).toBe(0); + expect(out).notToInclude("NOT `install`"); + }); + + it("does not warn against `install` in the command or global help", () => { + var commandHelp = mod.showHelp("packages"); + expect(commandHelp).toInclude("`install` is an alias of `add`"); + expect(commandHelp).notToInclude("not `install`"); + expect(mod.showHelp()).notToInclude("not `install`"); + }); + + }); + xdescribe("wheels packages help", () => { it("treats `help` positional as a help request (no network call)", () => { @@ -89,19 +110,19 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(out).toInclude("wheels packages add"); }); - it("does not advertise `install ` as a working verb", () => { + it("lists `add`, not `install`, as the subcommand row", () => { mod.__arguments = ["help"]; var out = mod.packages(); - // The historic help row "install [--force] Install a package" - // must not appear — it advertises a verb that LuCLI intercepts. + // `add` is the documented verb; `install` appears only as its alias. expect(REFindNoCase("install[[:space:]]+[[:space:]]+\[--force\][[:space:]]+Install a package", out)).toBe(0); }); - it("explains that `install` is intercepted by LuCLI", () => { + it("says `install` is an alias of `add`, not that it is intercepted", () => { mod.__arguments = ["help"]; var out = mod.packages(); - expect(out).toInclude("LuCLI"); - expect(REFindNoCase("intercept", out)).toBeGT(0); + expect(out).toInclude("`install` is an alias of `add`"); + expect(REFindNoCase("intercept", out)).toBe(0); + expect(out).notToInclude("NOT `install`"); }); it("lists every canonical sub-verb", () => { diff --git a/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc b/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc index 05cdf17c01..72f67abdf3 100644 --- a/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc +++ b/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc @@ -8,7 +8,9 @@ * * Issue #2706. The help summary line must stop leading with the broken * `Install` verb and point at `add` (the same trap that earlier renamed - * `wheels browser install` to `wheels browser setup`). + * `wheels browser install` to `wheels browser setup`). Since #4206 the + * current runtime passes `install` through as an alias of `add`; `add` + * stays the documented verb because older CLIs still intercept it. */ component extends="wheels.WheelsTest" { @@ -75,8 +77,7 @@ component extends="wheels.WheelsTest" { & "(the canonical verb) so auto-introspected help matches showHelp()." ); expect(source contains "hint: Add, update, and list Wheels packages").toBeTrue( - "packages() hint should lead with `Add, update, and list ...` " - & "and mention that the verb is `add`, not `install`." + "packages() hint should lead with `Add, update, and list ...`." ); }); @@ -86,8 +87,8 @@ component extends="wheels.WheelsTest" { expect(source contains "Unknown packages subcommand").toBeTrue( "Expected the packages() default branch to throw an unknown-subcommand error." ); - expect(source contains "The install verb is `add` (not `install`): wheels packages add ").toBeTrue( - "The unknown-subcommand error should tell users the install verb is `add`." + expect(source contains "To install a package: wheels packages add ").toBeTrue( + "The unknown-subcommand error should point users at `wheels packages add`." ); }); diff --git a/vendor/wheels/tests/specs/packages/CliTemplateInstallVerbSpec.cfc b/vendor/wheels/tests/specs/packages/CliTemplateInstallVerbSpec.cfc index 00709a0c90..00d4547248 100644 --- a/vendor/wheels/tests/specs/packages/CliTemplateInstallVerbSpec.cfc +++ b/vendor/wheels/tests/specs/packages/CliTemplateInstallVerbSpec.cfc @@ -4,13 +4,12 @@ component extends="wheels.WheelsTest" { describe("Generated app templates — package install verb", () => { - // Regression guard for issue #2610: `wheels packages install ` - // is intercepted by LuCLI's built-in extension installer and never - // reaches Module.cfc, so it prints "No git or extension - // dependencies to install" and exits without installing anything. - // The canonical install verb is `wheels packages add`. Templates - // that ship with every new app (via `wheels new`) must not - // advertise the broken verb. + // Regression guard for issue #2610: on older LuCLI runtimes + // `wheels packages install ` is intercepted by the built-in + // extension installer and never reaches Module.cfc. The current + // runtime passes it through as an alias of `add` (#4206), but + // `wheels packages add` works on every CLI version, so templates + // that ship with every new app (via `wheels new`) advertise `add`. it("the generated app's _gitignore does not reference `wheels install`", () => { var path = ExpandPath("/cli/lucli/templates/app/_gitignore"); diff --git a/vendor/wheels/tests/specs/packages/PackagesAddVerbAdvertisingSpec.cfc b/vendor/wheels/tests/specs/packages/PackagesAddVerbAdvertisingSpec.cfc index 18ad605fd8..33f248437b 100644 --- a/vendor/wheels/tests/specs/packages/PackagesAddVerbAdvertisingSpec.cfc +++ b/vendor/wheels/tests/specs/packages/PackagesAddVerbAdvertisingSpec.cfc @@ -1,7 +1,9 @@ /** * User-facing surfaces must advertise `wheels packages add`, not - * `wheels packages install`. LuCLI intercepts the literal `install` - * subcommand before Module.cfc runs (#2610, #2706, #3378). + * `wheels packages install`. Older LuCLI runtimes intercept the literal + * `install` subcommand before Module.cfc runs (#2610, #2706, #3378); the + * current one passes it through as an alias (#4206), but `add` works on + * every CLI version. * * Mentions of `install` that explicitly say it is not the verb are fine; * copy-to-clipboard snippets and recommended commands are not. From 21060fd6e440d0cae931544fe3a09ceaaa99a73d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sat, 3 Oct 2026 09:45:25 -0700 Subject: [PATCH 3/4] test(cli): drop the remaining install-is-intercepted wording rev1-r3 follow-ups on #4211: PackagesCommandHelpSpec header, it() titles and failure messages, a PackagesCommandSpec comment, and the packagelist.cfm comment no longer say install is intercepted. Assertions unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../specs/commands/PackagesCommandSpec.cfc | 4 +- vendor/wheels/public/views/packagelist.cfm | 5 +-- .../specs/cli/PackagesCommandHelpSpec.cfc | 38 ++++++++----------- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc index f196a897bc..d55bd4e9a5 100644 --- a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc +++ b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc @@ -148,9 +148,7 @@ component extends="wheels.wheelstest.system.BaseSpec" { // silently got nothing back when typing `install` — even though // `PackagesMainCli.install()` itself has always been a true alias // for `add()`. The alias must be wired through the dispatch - // layer too, so that the only place `install` ever no-ops is the - // LuCLI extension-installer intercept (which we cannot patch), - // and every in-process caller gets the same behavior as `add`. + // layer too, so every caller gets the same behavior as `add`. it("throws the same BadInput error as `add` when name is missing", () => { mod.__arguments = ["install"]; var threw = {flag: false, message: ""}; diff --git a/vendor/wheels/public/views/packagelist.cfm b/vendor/wheels/public/views/packagelist.cfm index 85c0702f4f..0799e0d471 100644 --- a/vendor/wheels/public/views/packagelist.cfm +++ b/vendor/wheels/public/views/packagelist.cfm @@ -162,9 +162,8 @@ for (local.key in packageMeta) { + `add` is the canonical verb and works on every 4.x CLI; `install` is an alias of it + in Wheels 4.2 (#4206). See cli/lucli/services/packages/PackagesMainCli.cfc. ---> wheels packages add #EncodeForHTML(local.rp.name)#