Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,33 @@ Run `wp-ops doctor` first — it reports which of the external tools these scrip

`wp-ops init` installs `wp-ops <TAB>` 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.
Expand Down
139 changes: 84 additions & 55 deletions docs/trellis-extensions-evaluation.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
```

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <category>' 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 <command> [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 <category>' 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 <category>`. 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.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions go/cmd/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [args...]\n", scope.name)
fmt.Printf("Usage: %s %s <command> [args...]\n", cmdName(), scope.name)
return
}

Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down
Loading