diff --git a/.goreleaser.yml b/.goreleaser.yml index 24da6e1..5ebe960 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -70,6 +70,20 @@ homebrew_casks: directory: Casks homepage: "https://github.com/imagewize/wp-ops" description: "Unified CLI for WordPress operations, Trellis, and Bedrock workflows" + # trellis-cli discovers plugins by scanning $PATH for executables named + # `trellis-*` and turns each into a subcommand, exec'ing the binary with + # the remaining argv (roots/trellis-cli plugin/finder.go, cmd/passthrough.go). + # So a second symlink to the same binary is the entire integration: no + # plugin API, no new code, no separate repo. The name must be + # `trellis-ops` — the finder splits on "-" and joins the rest with + # spaces, so `trellis-wp-ops` would become `trellis wp ops`. + # + # goreleaser's homebrew_casks has no field for a second `binary` stanza + # with a target, hence custom_block. It renders at the top of the cask + # body rather than beside the generated `binary "wp-ops"` — cask stanza + # order is cosmetic, and an unofficial tap isn't `brew audit`ed. + custom_block: | + binary "wp-ops", target: "trellis-ops" # The binary isn't code-signed/notarized (no Apple Developer ID), so # macOS quarantines it on download and Gatekeeper kills it outright on # first run (exit 137) rather than showing the usual "open anyway" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9177e1d..c8d0003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.6.0] - 2026-08-21 + +### Added + +- **`trellis ops` — wp-ops as a trellis-cli plugin.** trellis-cli scans `$PATH` + for executables named `trellis-*`, drops the first `-`-separated segment, and + registers what's left as a subcommand, exec'ing the binary with the remaining + argv (roots/trellis-cli `plugin/finder.go`, `cmd/passthrough.go`). That's a + git/kubectl-style plugin model with no API to implement, so the whole + integration is a second symlink to the same binary: the Homebrew cask now + installs `trellis-ops` alongside `wp-ops`, and every wp-ops command is + reachable as `trellis ops <...>` from inside the tool Trellis users already + have open. + + The name has to be exactly `trellis-ops`. The finder joins the remaining + segments with *spaces*, so `trellis-wp-ops` would register the three-word + `trellis wp ops`; and a first segment matching a core root command is + silently skipped with no error (`isUnderCoreRootCommands`), which rules out + `db`, `backup`-adjacent core names, and anything else on `trellis --help`. + `ops` is free. + + Plugins are registered from `$PATH` before trellis-cli resolves a project, so + unlike core subcommands `trellis ops` runs anywhere — the playbook commands + locate the Trellis directory through wp-ops's own `detect.TrellisDir`, exactly + as they do under bare `wp-ops`. + +### Changed + +- **Help text and suggestions now name the command you actually typed.** Every + "Run `wp-ops ...`" line, usage string, and did-you-mean suggestion is rendered + against `filepath.Base(os.Args[0])`, so a `trellis ops` user is pointed at + `trellis ops backup` rather than a command they may not know exists. Output + under bare `wp-ops` is byte-for-byte unchanged. + +- **`trellis ops` scopes its listing to `@platform trellis`.** The bare listing, + `list`, and per-category views show the 27 Trellis-tagged commands across 6 + categories instead of all 74 — someone at a `trellis` prompt isn't looking for + the image converters or release scripts. An explicit `--platform` overrides + it, a category with nothing Trellis-tagged still lists in full rather than + claiming to be empty, and *execution* is never scoped: any command runs if you + name it. `--json` stays the full catalog, since it's a contract for external + tooling. + ## [5.5.0] - 2026-08-07 ### Added diff --git a/README.md b/README.md index f256bb4..64f1980 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,33 @@ Run `wp-ops doctor` first — it reports which of the external tools these scrip `wp-ops init` installs `wp-ops ` completion for zsh, bash, or fish, auto-detected from `$SHELL`. Worth running right after install — the Homebrew cask this ships as doesn't wire up completions on its own the way a Homebrew formula would. +### As a trellis-cli plugin + +The Homebrew cask installs the same binary under a second name, `trellis-ops`, +which [trellis-cli](https://github.com/roots/trellis-cli) picks up as a plugin — +it scans `$PATH` for `trellis-*` executables and turns each into a subcommand. So +everything below is also reachable from inside the tool you already have open: + +```bash +trellis ops # Trellis-relevant categories +trellis ops backup database-pull # same as: wp-ops backup database-pull +trellis ops search backup +``` + +`trellis ops` scopes its **listing** to the commands tagged `@platform trellis`; +run plain `wp-ops` for the full catalog. Running a command is never scoped — name +any command and it works. Unlike core `trellis` subcommands, a plugin doesn't need +you to be inside a Trellis project; the playbook commands find the project the same +way `wp-ops` always has. + +Requires trellis-cli new enough to have plugin support (v1.19.0 or later) and the +default `load_plugins: true`. If you built from source instead of installing the +cask, make the alias yourself: + +```bash +ln -s "$(command -v wp-ops)" /usr/local/bin/trellis-ops +``` + Two categories resolve their project directory from an environment variable — but you don't need to export it by hand if you're standing inside the project: wp-ops detects it by walking up from your current directory and asks before using what it finds. diff --git a/docs/trellis-extensions-evaluation.md b/docs/trellis-extensions-evaluation.md index 0ae8c23..3020e38 100644 --- a/docs/trellis-extensions-evaluation.md +++ b/docs/trellis-extensions-evaluation.md @@ -1,6 +1,8 @@ # Publishing wp-ops Trellis tools as Trellis extensions -**Status:** evaluation, nothing implemented. +**Status:** Path A **shipped** (see [Path A](#path-a-a-trellis-ops-plugin-shim)); +Path B still an evaluation. The trellis-sync issues are closed and the repo is +archived. **Date:** 2026-08-21. What this repo already has that the Trellis ecosystem wants, which of the two @@ -20,7 +22,7 @@ better evidence. - [The core-namespace guard](#the-core-namespace-guard) - [What the ecosystem already has](#what-the-ecosystem-already-has) - [What we have that overlaps](#what-we-have-that-overlaps) -- [Path A: a `trellis-wpops` plugin shim](#path-a-a-trellis-wpops-plugin-shim) +- [Path A: a `trellis-ops` plugin shim](#path-a-a-trellis-ops-plugin-shim) - [Path B: an Ansible role on Galaxy](#path-b-an-ansible-role-on-galaxy) - [The trellis-sync repo](#the-trellis-sync-repo) - [The open issues are a distribution channel](#the-open-issues-are-a-distribution-channel) @@ -85,14 +87,14 @@ executable** — a Bash script, or the existing `wp-ops` Go binary. Verified by running it: ```console -$ printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > /somewhere/on/path/trellis-wpopstest -$ chmod +x /somewhere/on/path/trellis-wpopstest +$ printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > /somewhere/on/path/trellis-demoplugin +$ chmod +x /somewhere/on/path/trellis-demoplugin $ trellis --help ... Available plugin commands: - wpopstest + demoplugin -$ trellis wpopstest hello world +$ trellis demoplugin hello world plugin works: hello world ``` @@ -107,11 +109,14 @@ joined **with spaces** (`plugin/finder.go:62-67`): | Binary name | Subcommand | | --- | --- | -| `trellis-wpops` | `trellis wpops` | +| `trellis-ops` | `trellis ops` | | `trellis-backup` | `trellis backup` | | `trellis-wp-ops` | `trellis wp ops` ← two words, almost certainly not what you want | -So the shim must be named `trellis-wpops`, not `trellis-wp-ops`. +So the shim must be a single segment after the prefix. **Shipped as +`trellis-ops` → `trellis ops`**; `trellis-wp-ops` would have become the +three-word `trellis wp ops`. An earlier draft of this document proposed +`trellis-wpops`, which works but reads like a typo. ### The core-namespace guard @@ -146,7 +151,7 @@ command: `alias check db deploy dotenv exec galaxy info init key logs new open provision rollback server shell-init ssh valet vault vm xdebug-tunnel`, plus `droplet` and `venv`. -Free and relevant: `backup`, `monitor`, `wpops`, `security`, `seo`, `scan`. +Free and relevant: `backup`, `monitor`, `ops`, `security`, `seo`, `scan`. Note that `trellis db` currently only has `open` — the obvious `trellis db pull` / `db push` namespace is reserved by core but unimplemented. @@ -228,77 +233,102 @@ fourth database push/pull tool. --- -## Path A: a `trellis-wpops` plugin shim +## Path A: a `trellis-ops` plugin shim -The cheapest distribution win available to this repo. +**Shipped.** The cheapest distribution win available to this repo, and it cost +one config block plus a display-name fix. `wp-ops` is already a single Go binary that runs all 13 Trellis commands. Because `PassthroughCommand` execs an arbitrary binary with the remaining argv, making it a `trellis` subcommand requires **no new code** — just a second name -on `PATH`: +on `PATH`. wp-ops ships as a Homebrew *cask*, generated by goreleaser, so the +symlink is declared in `.goreleaser.yml` rather than edited into the tap: -```ruby -# in the Homebrew formula -bin.install "wp-ops" -bin.install_symlink bin/"wp-ops" => "trellis-wpops" +```yaml +homebrew_casks: + - name: wp-ops + binaries: [wp-ops] + custom_block: | + binary "wp-ops", target: "trellis-ops" ``` +goreleaser's `homebrew_casks` has no field for a second `binary` stanza with a +target, hence `custom_block`. It renders at the top of the cask body rather +than beside the generated `binary "wp-ops"`; cask stanza order is cosmetic and +an unofficial tap isn't `brew audit`ed. + Verified working with the real binary, not a stub: ```console -$ ln -s /path/to/wp-ops /somewhere/on/path/trellis-wpops - $ trellis --help ... Available plugin commands: - wpops + ops -$ trellis wpops # full category listing, as bare wp-ops +$ trellis ops # scoped to @platform trellis wp-ops — WordPress Operations Tools - Monitoring (17) Log monitoring, uptime checks, and traffic analysis - Backup (10) Database and file backups — Ansible and shell - ... -$ trellis wpops backup # category navigation works + Monitoring (11) Log monitoring, uptime checks, and traffic analysis + Backup ( 9) Database and file backups — Ansible and shell + Content ( 2) Block pattern screenshots, page creation, and pattern validation + Security ( 2) Malware scanning, fail2ban, IP blocking, and admin recovery + Misc ( 2) Trellis updater, WooCommerce variations, and one-off utilities + Diagnostics ( 1) WordPress transient and post-count diagnostics + +Run 'trellis ops ' to see a category's commands (e.g. 'trellis ops backup') +Showing @platform trellis commands only — run 'wp-ops' for the full catalog + +$ trellis ops backup # category navigation works Backup Commands: db-backup Back up a remote site's database over SSH... database-pull Pull a site's database from a remote environment... - -$ trellis wpops search backup # subcommands work -10 matches for 'backup': - trellis/backup/database-pull [trellis] Pull a site's database from a remote... +Usage: trellis ops backup [args...] ``` Argv handling needs no changes — Cobra parses `os.Args[1:]`, and -`syscall.Exec` hands it exactly the post-`wpops` arguments. +`syscall.Exec` hands it exactly the post-`ops` arguments. -What this buys: every Trellis user who installs wp-ops discovers it from inside -the tool they already use, and it shows up in `trellis --help` on a machine -where they've forgotten it's installed. What it costs: one symlink line. +**Plugins don't need a Trellis project.** Registration is a `$PATH` walk at +startup (`main.go:232-235`), before trellis-cli resolves a project at all, so +`trellis ops` runs from anywhere while core `trellis info` refuses: -**The one real rough edge.** `go/cmd/root.go:20` sets `Use: "wp-ops"`, so the -help footer instructs users in terms of a command they did not type: +```console +$ cd /tmp && trellis info +No Trellis project detected in the current directory or any of its parent directories. +$ cd /tmp && trellis ops doctor +wp-ops doctor 5.5.0 ``` -Run 'wp-ops ' to see a category's commands (e.g. 'wp-ops backup') -Run 'wp-ops doctor' to check dependencies and environment -``` -Under the alias that should read `trellis wpops `. It is cosmetic — -every command still runs — but it is the difference between a shim that feels -deliberate and one that feels like a leak. Deriving the display name from -`filepath.Base(os.Args[0])` fixes it for both invocation paths at once. +The Ansible commands still need a project, but they always did — wp-ops finds it +through its own `detect.TrellisDir` (`go/internal/detect/detect.go:30`), which +walks up from the cwd exactly like trellis-cli's. Nothing is passed between the +two. + +**The rough edge, now fixed.** `go/cmd/root.go:20` set `Use: "wp-ops"`, so the +help footer instructed users in terms of a command they did not type. Every +usage line, footer, and did-you-mean suggestion now renders against +`filepath.Base(os.Args[0])` (`go/cmd/invoked.go`), which fixes both invocation +paths at once; output under bare `wp-ops` is byte-for-byte unchanged. + +The same argv[0] check scopes the *listing* to `@platform trellis` — 27 commands +across 6 categories rather than all 74. Someone at a `trellis` prompt isn't +looking for the image converters. Execution is deliberately not scoped: name any +command and it runs. -Remaining caveats for the formula comment: +Caveats, all documented in the config comments: -- Name it `trellis-wpops`. `trellis-wp-ops` becomes the two-word `trellis wp ops`. +- Name it `trellis-ops`. `trellis-wp-ops` becomes the three-word `trellis wp ops`. - Gated on `load_plugins`, which defaults true but users can disable. - Requires trellis-cli new enough to have `plugin/`. Confirmed present in v1.19.0. +- `ops` is generic. If Roots ever ships a core `ops` command, the plugin is + silently skipped by `isUnderCoreRootCommands` — no error, no warning. Lower + risk than claiming `backup` or `monitor`, but it is the trade for the better + name. -A narrower alternative is `trellis-backup` → `trellis backup`, exposing only -the backup group. That reads better as a command name, but it claims a generic -namespace that Roots might want later, and it fragments the entry point. Prefer -one vendor-named plugin. +A narrower alternative was `trellis-backup` → `trellis backup`, exposing only +the backup group. It claims a generic namespace Roots is more likely to want, +and it fragments the entry point. One plugin, one door. --- @@ -422,10 +452,9 @@ rather than announcing a suite. ## Recommendation -**1. Ship the `trellis-wpops` symlink now.** One line in the Homebrew formula, -no new code, no new repo, no maintenance surface. Verify the `argv[0]` behavior -and the alias name, then ship it. This is the highest ratio of reach to effort -anywhere in either evaluation document. +**1. ~~Ship the `trellis-wpops` symlink now.~~ Done, as `trellis ops`.** One +`custom_block` in `.goreleaser.yml`, no new repo, no maintenance surface. This +was the highest ratio of reach to effort anywhere in either evaluation document. **2. Then package the monitoring playbooks as a Galaxy role.** Not the backup ones. Backup is a crowded field where we'd be the fourth entrant offering a @@ -454,12 +483,12 @@ requires deciding anything about the WP-CLI package first. ```bash # The plugin mechanism, end to end S=$(mktemp -d) -printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > "$S/trellis-wpopstest" -cp "$S/trellis-wpopstest" "$S/trellis-backup" # free namespace -cp "$S/trellis-wpopstest" "$S/trellis-db-pull" # blocked by core 'db' +printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > "$S/trellis-demoplugin" +cp "$S/trellis-demoplugin" "$S/trellis-backup" # free namespace +cp "$S/trellis-demoplugin" "$S/trellis-db-pull" # blocked by core 'db' chmod +x "$S"/trellis-* -PATH="$S:$PATH" trellis --help | tail -6 # 'backup' + 'wpopstest' listed; 'db pull' absent +PATH="$S:$PATH" trellis --help | tail -6 # 'backup' + 'demoplugin' listed; 'db pull' absent PATH="$S:$PATH" trellis backup foo # => plugin works: foo PATH="$S:$PATH" trellis db pull # => core 'db' help; plugin never ran diff --git a/go/cmd/dispatch.go b/go/cmd/dispatch.go index df56dc6..3377f1f 100644 --- a/go/cmd/dispatch.go +++ b/go/cmd/dispatch.go @@ -165,7 +165,7 @@ func rootBasenameCompletions(cc *cobra.Command, args []string, toComplete string func runCategory(c *catalog.Catalog, scope categoryScope, args []string) { if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { printCategoryCommands(scope.name, scope.members(c)) - fmt.Printf("Usage: wp-ops %s [args...]\n", scope.name) + fmt.Printf("Usage: %s %s [args...]\n", cmdName(), scope.name) return } @@ -350,9 +350,9 @@ func printUnknownCommand(c *catalog.Catalog, candidate string) { fmt.Fprintf(os.Stderr, "Unknown command or category: %s\n\n", candidate) if best, ok := suggestSimilar(c, candidate); ok { fmt.Fprintln(os.Stderr, "Did you mean:") - fmt.Fprintf(os.Stderr, " wp-ops %s\n\n", best) + fmt.Fprintf(os.Stderr, " %s %s\n\n", cmdName(), best) } - fmt.Fprintf(os.Stderr, "Try wp-ops search %s, or wp-ops to browse everything.\n", candidate) + fmt.Fprintf(os.Stderr, "Try %s search %s, or %s to browse everything.\n", cmdName(), candidate, cmdName()) } // printAmbiguous ports print_ambiguous (wp-ops:1517). @@ -362,7 +362,7 @@ func printAmbiguous(name string, matches []catalog.Entry) { fmt.Fprintf(os.Stderr, " %-40s %s\n", m.Key, m.Description) } fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, "Run it by its full name, e.g. wp-ops %s\n", matches[0].Key) + fmt.Fprintf(os.Stderr, "Run it by its full name, e.g. %s %s\n", cmdName(), matches[0].Key) } // suggestSimilar ports suggest_similar (wp-ops:1533): a substring match on diff --git a/go/cmd/docs.go b/go/cmd/docs.go index 96c38d1..0d50f8a 100644 --- a/go/cmd/docs.go +++ b/go/cmd/docs.go @@ -87,7 +87,7 @@ func runDocs(term string, pathsOnly, wholeWord bool) int { if len(results) == 0 { fmt.Printf("No documents match '%s'.\n\n", term) - fmt.Printf("Try wp-ops search %s to look for a command instead.\n", term) + fmt.Printf("Try %s search %s to look for a command instead.\n", cmdName(), term) return 1 } @@ -120,9 +120,9 @@ func runDocs(term string, pathsOnly, wholeWord bool) int { } if wholeWord { - fmt.Printf("Paths only (for piping to an editor): wp-ops docs %s -l\n\n", term) + fmt.Printf("Paths only (for piping to an editor): %s docs %s -l\n\n", cmdName(), term) } else { - fmt.Printf("Whole words only: wp-ops docs %s -w · paths only: wp-ops docs %s -l\n\n", term, term) + fmt.Printf("Whole words only: %s docs %s -w · paths only: %s docs %s -l\n\n", cmdName(), term, cmdName(), term) } return 0 @@ -140,7 +140,7 @@ func printDocsList(root string) { fmt.Printf(" %s\n", relDocPath(root, d)) } fmt.Println() - fmt.Println("Search inside them with: wp-ops docs ") + fmt.Printf("Search inside them with: %s docs \n", cmdName()) fmt.Println() } diff --git a/go/cmd/invoked.go b/go/cmd/invoked.go new file mode 100644 index 0000000..6a101a2 --- /dev/null +++ b/go/cmd/invoked.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// trellis-cli discovers plugins by scanning $PATH for executables named +// `trellis-*`, then splits the filename on "-", drops the first segment, +// and joins the rest with spaces to form the subcommand (trellis-cli +// plugin/finder.go:62-67). So the binary must be named `trellis-ops` for +// the one-word `trellis ops`: `trellis-wp-ops` would become the +// three-word `trellis wp ops`. +// +// The Homebrew cask ships this name as a second symlink to the same +// binary (.goreleaser.yml, homebrew_casks.custom_block), so wp-ops is +// literally the same executable under both names — which is why the only +// thing distinguishing the two invocations is argv[0]. +const trellisPluginBinary = "trellis-ops" + +// invokedAs is argv[0]'s basename. A var rather than a func so tests can +// swap it; nothing else should write to it. +var invokedAs = filepath.Base(os.Args[0]) + +// asTrellisPlugin reports whether we were run as `trellis ops ...` rather +// than as bare `wp-ops ...`. +func asTrellisPlugin() bool { return invokedAs == trellisPluginBinary } + +// cmdName is how the user actually typed us, for help text and +// suggestions. Printing "wp-ops " at someone who typed +// "trellis ops" tells them to run a command they may not know exists. +func cmdName() string { + if asTrellisPlugin() { + return "trellis ops" + } + return "wp-ops" +} + +// defaultPlatform scopes the *listing* views when we're running inside +// trellis-cli: someone at a `trellis` prompt wants the 27 commands tagged +// @platform trellis, not the image converters and release scripts. It +// deliberately does not scope *execution* — every command still runs if +// you name it, and an explicit --platform always wins. +func defaultPlatform() string { + if asTrellisPlugin() { + return "trellis" + } + return "" +} diff --git a/go/cmd/invoked_test.go b/go/cmd/invoked_test.go new file mode 100644 index 0000000..209174c --- /dev/null +++ b/go/cmd/invoked_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/imagewize/wp-ops/go/internal/catalog" +) + +// withInvokedAs runs fn with argv[0]'s basename swapped, restoring it after. +func withInvokedAs(t *testing.T, name string, fn func()) { + t.Helper() + prev := invokedAs + invokedAs = name + defer func() { invokedAs = prev }() + fn() +} + +func TestCmdNameAndPlatformFollowArgv0(t *testing.T) { + cases := []struct { + argv0 string + wantName string + wantPlat string + }{ + {"wp-ops", "wp-ops", ""}, + {"trellis-ops", "trellis ops", "trellis"}, + // Only the exact plugin name flips behavior — a user who renames + // the binary to something else still gets plain wp-ops. + {"trellis-wpops", "wp-ops", ""}, + {"wp-ops-dev", "wp-ops", ""}, + } + for _, tc := range cases { + withInvokedAs(t, tc.argv0, func() { + if got := cmdName(); got != tc.wantName { + t.Errorf("argv0 %q: cmdName() = %q, want %q", tc.argv0, got, tc.wantName) + } + if got := defaultPlatform(); got != tc.wantPlat { + t.Errorf("argv0 %q: defaultPlatform() = %q, want %q", tc.argv0, got, tc.wantPlat) + } + }) + } +} + +// The whole point of the argv[0] plumbing: a `trellis ops` user must never +// be told to run a command they didn't type. +func TestRootLongUsesInvokedName(t *testing.T) { + long := rootLong("trellis ops") + if strings.Contains(long, "wp-ops") { + t.Errorf("rootLong(\"trellis ops\") still mentions wp-ops:\n%s", long) + } + for _, want := range []string{"trellis ops list", "trellis ops doctor", "trellis ops --version"} { + if !strings.Contains(long, want) { + t.Errorf("rootLong missing %q:\n%s", want, long) + } + } +} + +// Under bare wp-ops the help must be exactly what it was before the +// template — this is the regression guard on the rewrite. +func TestRootLongUnderWpOpsIsUnchanged(t *testing.T) { + long := rootLong("wp-ops") + for _, want := range []string{ + "wp-ops / [args...] Run a command by its full key", + "wp-ops --where Print the path to a command's script", + "wp-ops list List every command by category", + "wp-ops --version Show version", + } { + if !strings.Contains(long, want) { + t.Errorf("rootLong(\"wp-ops\") missing exact line %q:\n%s", want, long) + } + } + if strings.Contains(long, "trellis ops") { + t.Error("rootLong(\"wp-ops\") leaked the plugin name") + } +} + +func TestFilterEntriesByPlatform(t *testing.T) { + entries := []catalog.Entry{ + {Key: "trellis/backup/database-pull", Platform: "trellis"}, + {Key: "scripts/images/jpg-to-webp", Platform: "any"}, + {Key: "wp-cli/security/scanner", Platform: "wordpress"}, + } + + if got := filterEntriesByPlatform(entries, ""); len(got) != 3 { + t.Errorf("empty platform must not filter: got %d, want 3", len(got)) + } + + got := filterEntriesByPlatform(entries, "trellis") + if len(got) != 1 || got[0].Key != "trellis/backup/database-pull" { + t.Errorf("platform trellis: got %v, want just the trellis entry", got) + } +} diff --git a/go/cmd/list.go b/go/cmd/list.go index 802fc24..c0f3727 100644 --- a/go/cmd/list.go +++ b/go/cmd/list.go @@ -32,7 +32,7 @@ func validatePlatform(platform string) error { return nil } } - return fmt.Errorf("wp-ops: unknown platform %q — expected one of: %s", + return fmt.Errorf("%s: unknown platform %q — expected one of: %s", cmdName(), platform, strings.Join(catalog.Platforms, ", ")) } @@ -45,7 +45,14 @@ var listCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) os.Exit(1) } - filtered := c.FilterByPlatform(platformFlag) + platform := platformFlag + if platform == "" { + // Unset means "the default for how we were invoked": no + // filter under wp-ops, @platform trellis under `trellis ops`. + // An explicit --platform always wins over that default. + platform = defaultPlatform() + } + filtered := c.FilterByPlatform(platform) switch { case jsonFlag: printJSON(filtered) @@ -79,13 +86,21 @@ func printCategorizedList(c *catalog.Catalog) { fmt.Printf(" %-22s (%2d) %s\n", catalog.CategoryDisplayNames[category], len(entries), catalog.CategoryBlurbs[category]) } + n := cmdName() fmt.Println() - fmt.Println("Run 'wp-ops ' to see a category's commands (e.g. 'wp-ops backup')") - fmt.Println("Run 'wp-ops list --all' to see every command with its description") - fmt.Println("Run 'wp-ops list --platform wordpress' to see only what runs on any WP site") - fmt.Println("Run 'wp-ops search ' to find a command") - fmt.Println("Run 'wp-ops doctor' to check dependencies and environment") - fmt.Println("Run 'wp-ops --json' for machine-readable command list") + fmt.Printf("Run '%s ' to see a category's commands (e.g. '%s backup')\n", n, n) + fmt.Printf("Run '%s list --all' to see every command with its description\n", n) + if asTrellisPlugin() { + // The scoped view hides ~two thirds of the catalog, so say where + // the rest is. Same binary, one word away — the cask installs + // both names. + fmt.Println("Showing @platform trellis commands only — run 'wp-ops' for the full catalog") + } else { + fmt.Printf("Run '%s list --platform wordpress' to see only what runs on any WP site\n", n) + } + fmt.Printf("Run '%s search ' to find a command\n", n) + fmt.Printf("Run '%s doctor' to check dependencies and environment\n", n) + fmt.Printf("Run '%s --json' for machine-readable command list\n", n) } // printAllCommands is the original full listing: every command in every @@ -102,10 +117,11 @@ func printAllCommands(c *catalog.Catalog) { fmt.Println() } - fmt.Println("Run 'wp-ops list' for a compact category summary") - fmt.Println("Run 'wp-ops search ' to find a command") - fmt.Println("Run 'wp-ops doctor' to check dependencies and environment") - fmt.Println("Run 'wp-ops --json' for machine-readable command list") + n := cmdName() + fmt.Printf("Run '%s list' for a compact category summary\n", n) + fmt.Printf("Run '%s search ' to find a command\n", n) + fmt.Printf("Run '%s doctor' to check dependencies and environment\n", n) + fmt.Printf("Run '%s --json' for machine-readable command list\n", n) } func printCategoryCommands(category string, entries []catalog.Entry) { @@ -113,11 +129,42 @@ func printCategoryCommands(category string, entries []catalog.Entry) { fmt.Printf("No commands found in category: %s\n", catalog.CategoryDisplayNames[category]) return } + + // Scope the listing the same way the category summary is scoped, so + // the counts agree. But a category with nothing tagged @platform + // trellis (SEO, Images, Git...) still has commands that run fine here, + // so fall back to the whole category rather than claiming it's empty. + scoped := filterEntriesByPlatform(entries, defaultPlatform()) + unscoped := len(scoped) == 0 + if unscoped { + scoped = entries + } + fmt.Printf("%s Commands:\n\n", catalog.CategoryDisplayNames[category]) - printCategoryEntries(entries) + printCategoryEntries(scoped) + if unscoped { + fmt.Printf("\nNothing here is Trellis-specific — these run on any WordPress site.\n") + } fmt.Println() } +// filterEntriesByPlatform keeps the per-category listing consistent with +// the counts in the category summary. Execution is deliberately not +// filtered — naming a non-trellis command under `trellis ops` still runs +// it; only what we *advertise* is scoped. +func filterEntriesByPlatform(entries []catalog.Entry, platform string) []catalog.Entry { + if platform == "" { + return entries + } + var kept []catalog.Entry + for _, e := range entries { + if e.Platform == platform { + kept = append(kept, e) + } + } + return kept +} + func printCategoryEntries(entries []catalog.Entry) { for _, e := range entries { tag := "" diff --git a/go/cmd/root.go b/go/cmd/root.go index 9b57018..027d514 100644 --- a/go/cmd/root.go +++ b/go/cmd/root.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -19,24 +20,8 @@ var jsonFlag bool var rootCmd = &cobra.Command{ Use: "wp-ops", Short: "Unified CLI wrapper for wp-ops tools", - Long: `wp-ops is a single entry point for WordPress operations tools, -scripts, and utilities: backups, monitoring, WP-CLI helpers, Trellis -playbooks, and more. - - wp-ops / [args...] Run a command by its full key - wp-ops [args...] Run a command by category + name - wp-ops [args...] Run a command by its bare name - wp-ops --where Print the path to a command's script - wp-ops --help Show a command's own help - - wp-ops list List every command by category - wp-ops search Search commands by name or description - wp-ops docs [term] [-l] Search the guides (no term lists them) - wp-ops doctor Check dependencies and environment - wp-ops init Install shell completions - wp-ops mcp-register Show MCP registration snippets for Claude/Mistral/Codex - wp-ops --json Output the command list as JSON - wp-ops --version Show version`, + // Long is filled in by Execute() so every usage line spells the name + // the user actually typed — see rootLong and cmdName(). // Arbitrary args: a bare command name that isn't one of the explicitly // registered children (list/search/doctor/category commands/full-key // commands) falls through here and is resolved as a basename against @@ -62,6 +47,10 @@ playbooks, and more. func rootRunE(cc *cobra.Command, args []string) error { c := mustCatalog() + // What we *show* is scoped to how we were invoked (see + // defaultPlatform); what we *run*, below, never is. --json stays + // unscoped too — it's a stable contract for external tooling. + listing := c.FilterByPlatform(defaultPlatform()) if len(args) == 0 { // Port of main()'s `[[ -t 0 && -t 1 ]]` branch (wp-ops:2154-2157): @@ -69,10 +58,10 @@ func rootRunE(cc *cobra.Command, args []string) error { // piped/redirected invocation keeps printing list-equivalent output // so `wp-ops | less` etc. still work. if detect.IsTerminal(os.Stdin) && detect.IsTerminal(os.Stdout) { - os.Exit(runInteractive(c)) + os.Exit(runInteractive(listing)) return nil } - printCategorizedList(c) + printCategorizedList(listing) return nil } @@ -84,7 +73,7 @@ func rootRunE(cc *cobra.Command, args []string) error { printVersion() return nil case "--help", "-h": - printCategorizedList(c) + printCategorizedList(listing) return nil } @@ -109,8 +98,58 @@ func rootRunE(cc *cobra.Command, args []string) error { return nil } +// rootLong renders the root help against the invoked name, so a +// `trellis ops` user is told to run `trellis ops backup`, not +// `wp-ops backup`. Under bare wp-ops the output is byte-for-byte what it +// was before this became a template. +func rootLong(name string) string { + pad := func(usage string) string { + // Descriptions start at column 40, as they did when this was a + // literal string built around "wp-ops". The longest wp-ops row + // ("wp-ops / [args...]") is 37, so nothing + // overflows under the original name; "trellis ops" is 5 wider and + // pushes its three longest rows out, hence the guard. + if len(usage) >= 40 { + return usage + " " + } + return usage + strings.Repeat(" ", 40-len(usage)) + } + rows := [][2]string{ + {name + " / [args...]", "Run a command by its full key"}, + {name + " [args...]", "Run a command by category + name"}, + {name + " [args...]", "Run a command by its bare name"}, + {name + " --where", "Print the path to a command's script"}, + {name + " --help", "Show a command's own help"}, + {"", ""}, + {name + " list", "List every command by category"}, + {name + " search ", "Search commands by name or description"}, + {name + " docs [term] [-l]", "Search the guides (no term lists them)"}, + {name + " doctor", "Check dependencies and environment"}, + {name + " init", "Install shell completions"}, + {name + " mcp-register", "Show MCP registration snippets for Claude/Mistral/Codex"}, + {name + " --json", "Output the command list as JSON"}, + {name + " --version", "Show version"}, + } + + var b strings.Builder + fmt.Fprintf(&b, `%s is a single entry point for WordPress operations tools, +scripts, and utilities: backups, monitoring, WP-CLI helpers, Trellis +playbooks, and more. +`, name) + for _, r := range rows { + if r[0] == "" { + b.WriteString("\n") + continue + } + fmt.Fprintf(&b, " %s%s\n", pad(r[0]), r[1]) + } + return strings.TrimRight(b.String(), "\n") +} + // Execute runs the root command. func Execute() error { + rootCmd.Use = cmdName() + rootCmd.Long = rootLong(cmdName()) registerCatalogCommands(mustCatalog()) if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/go/cmd/search.go b/go/cmd/search.go index 1e4f91f..2e73245 100644 --- a/go/cmd/search.go +++ b/go/cmd/search.go @@ -15,7 +15,7 @@ var searchCmd = &cobra.Command{ Args: cobra.ArbitraryArgs, RunE: func(cc *cobra.Command, args []string) error { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "Usage: wp-ops search ") + fmt.Fprintf(os.Stderr, "Usage: %s search \n", cmdName()) os.Exit(1) } runSearch(args[0]) @@ -42,9 +42,9 @@ func runSearch(term string) { if len(matches) == 0 { fmt.Printf("No commands match '%s'.\n\n", term) if hasDocMatches(term) { - fmt.Printf("The documentation mentions it though — try wp-ops docs %s.\n\n", term) + fmt.Printf("The documentation mentions it though — try %s docs %s.\n\n", cmdName(), term) } - fmt.Println("Run wp-ops to browse everything by category.") + fmt.Printf("Run %s to browse everything by category.\n", cmdName()) os.Exit(1) } diff --git a/go/cmd/serverside.go b/go/cmd/serverside.go index 9a207d3..4dae613 100644 --- a/go/cmd/serverside.go +++ b/go/cmd/serverside.go @@ -146,8 +146,8 @@ func printServerSideGuidance(w io.Writer, e catalog.Entry) { fmt.Fprintln(w, "That leaves the archive on the server. To back up *and* retrieve a copy") fmt.Fprintln(w, "to this machine in one step, use the Ansible playbooks instead:") fmt.Fprintln(w) - fmt.Fprintln(w, " wp-ops database-pull example.com production") - fmt.Fprintln(w, " wp-ops files-pull example.com production") + fmt.Fprintf(w, " %s database-pull example.com production\n", cmdName()) + fmt.Fprintf(w, " %s files-pull example.com production\n", cmdName()) return } @@ -168,7 +168,7 @@ func printServerSideGuidance(w io.Writer, e catalog.Entry) { if hasGNUDate() { fmt.Fprintln(w, "Already have a log file on this machine? Pass its path and it runs here:") - fmt.Fprintf(w, " wp-ops %s /path/to/access.log\n", e.CommandName()) + fmt.Fprintf(w, " %s %s /path/to/access.log\n", cmdName(), e.CommandName()) } else { fmt.Fprintln(w, "The SSH route above is unaffected by what's installed here — date and") fmt.Fprintln(w, "gawk both resolve on the server. Only running it against a log copied") diff --git a/go/cmd/trellis.go b/go/cmd/trellis.go index 5e59524..91b5cb3 100644 --- a/go/cmd/trellis.go +++ b/go/cmd/trellis.go @@ -35,7 +35,7 @@ func resolveTrellisDir() (string, bool) { fmt.Fprintln(os.Stderr, "TRELLIS_DIR is not set.") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Ansible playbook commands run against a real Trellis project (they read") - fmt.Fprintln(os.Stderr, "its ansible.cfg, inventory, and group_vars/), so wp-ops needs to know") + fmt.Fprintf(os.Stderr, "its ansible.cfg, inventory, and group_vars/), so %s needs to know\n", cmdName()) fmt.Fprintln(os.Stderr, "where that project lives. Either run this from inside the project, or:") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, " export TRELLIS_DIR=/path/to/your/trellis")