From 9517fc1731f5ce55a9bd5098676a02777d647684 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Fri, 7 Aug 2026 07:07:34 +0700 Subject: [PATCH] Rewrite the third-party extensions design against the real catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft surveyed four generic plugin architectures and recommended one without reference to how wp-ops actually resolves and runs a command. Rewrite it as a single decision — build-time embedded catalog vs. runtime discovery — with the code that decision breaks named by file and line. The substantive findings the draft missed: - Entry has no source root. ScriptPath is repo-relative and joined against repoRoot() at six sites (dispatch.go's four executors, docs.go's @doc resolution, manifest.Lint's doc check). Nothing external is runnable before that changes, and it is the whole of Phase 1. - The binary embeds its own asset tree and extracts it to a cache dir, so a Homebrew install has no checkout at all — "drop your script into the repo" serves the least common install shape. - catalog.go states "no filesystem scan happens at runtime" as an invariant; runtime discovery inverts it. Restated as a two-tier rule so core keeps its build-time guarantee and a malformed extension can never be fatal. - A @category outside DisplayOrder makes a command invisible in every listing and the picker. Falls back to misc. - mcp-server reads catalog.json off disk from REPO_ROOT, so extensions are invisible to MCP. Made an explicit decision rather than an omission. - Completion runs the scan on every , so the mtime cache is load-bearing; budgeted at 5ms. Dropped, each with a reason recorded in an out-of-scope table: the four duplicate-prevention strategies (ext// keys make full-key collisions impossible, and basename collisions are already handled by ShortName and printAmbiguous), the permissions sandbox block (unenforceable against an exec'd shell script), the package manager and registry, and the test-helper library (core scripts aren't tested either — ext validate covers the real failure mode). Co-Authored-By: Claude Opus 5 --- docs/third-party-extensions.md | 1007 ++++++++++++-------------------- 1 file changed, 362 insertions(+), 645 deletions(-) diff --git a/docs/third-party-extensions.md b/docs/third-party-extensions.md index 5b42980..e2b95cc 100644 --- a/docs/third-party-extensions.md +++ b/docs/third-party-extensions.md @@ -1,754 +1,471 @@ # Third-Party Extensions for wp-ops -This document outlines strategies for allowing third parties to add new scripts or Ansible playbooks to wp-ops while preventing duplicates, ensuring testability, and enabling easy drop-in functionality. +**Status:** design proposal, nothing implemented. -## Table of Contents - -- [Current Architecture Overview](#current-architecture-overview) -- [Proposed Extension Strategies](#proposed-extension-strategies) - - [Option A: External Commands Directory](#option-a-external-commands-directory) - - [Option B: Configuration-Driven Plugins](#option-b-configuration-driven-plugins) - - [Option C: Git Submodules Approach](#option-c-git-submodules-approach) - - [Option D: Package Manager Style](#option-d-package-manager-style) -- [Duplicate Prevention](#duplicate-prevention) -- [Testing Framework](#testing-framework) -- [Drop-in Installation](#drop-in-installation) -- [Metadata Standards](#metadata-standards) -- [Security Considerations](#security-considerations) -- [Recommended Implementation](#recommended-implementation) -- [Example Workflow](#example-workflow) - ---- - -## Current Architecture Overview - -wp-ops currently uses a **manifest-based catalog system** where: - -1. Each script/playbook contains metadata in comments (YAML frontmatter for playbooks) -2. The `gen` tool scans the repository at build time and generates `catalog.json` -3. The Go binary embeds this catalog and provides CLI discovery, help, and execution -4. Commands are organized by category and platform +How someone outside this repository could add a command that `wp-ops` +discovers, lists, and runs — without forking, and without wp-ops growing a +plugin ecosystem it has no demand for. -### Existing Metadata Schema +This document is deliberately narrow. It names one architectural decision, +the code that decision breaks, and the smallest change that makes extensions +work. Everything that isn't required to get one third-party script running is +in [Out of scope](#out-of-scope) with a reason. -**For Bash scripts (in comments):** -```bash -# @desc Short description of what the script does -# @category backup|monitoring|git|migration|diagnostics|security|release|misc -# @platform any|wordpress|trellis -# @runs local|server|either -# @requires dependency1 dependency2 -# @arg name required/optional {default} Description -# @flag --flag-name optional/required {default} Description -# @example wp-ops command arg1 arg2 -# @doc path/to/documentation.md -``` +## Table of Contents -**For Ansible playbooks (in YAML comments):** -```yaml -# @desc Short description -# @category backup|monitoring|provision|security|updater -# @platform trellis -# @runs local|server -# @requires ansible-playbook -# @arg site required {example.com} Site name -# @arg env required {production} Environment -# @example wp-ops playbook-name site.com production -# @doc trellis/category/README.md -``` +- [Is this needed?](#is-this-needed) +- [What the current architecture constrains](#what-the-current-architecture-constrains) +- [The decision: build-time catalog vs. runtime discovery](#the-decision-build-time-catalog-vs-runtime-discovery) +- [Design](#design) + - [1. Entries need a root](#1-entries-need-a-root) + - [2. Discovery](#2-discovery) + - [3. Namespacing makes collisions structurally impossible](#3-namespacing-makes-collisions-structurally-impossible) + - [4. Category placement](#4-category-placement) + - [5. Degradation and caching](#5-degradation-and-caching) +- [Consumers that must change](#consumers-that-must-change) +- [The author contract](#the-author-contract) +- [Security: what wp-ops can and cannot enforce](#security-what-wp-ops-can-and-cannot-enforce) +- [Validation, not a test framework](#validation-not-a-test-framework) +- [Implementation phases](#implementation-phases) +- [Out of scope](#out-of-scope) +- [Open questions](#open-questions) --- -## Proposed Extension Strategies +## Is this needed? -### Option A: External Commands Directory +Answer this before building anything. -**Concept:** Allow users to specify external directories containing additional scripts/playbooks that wp-ops will discover and include in its catalog. +Today, someone who wants their own command has two options: open a PR against +this repo, or run a fork with `WP_OPS_ROOT` pointed at it +(`go/cmd/env.go:46`). The fork path already works, costs nothing to maintain, +and survives upgrades badly — which is the actual complaint an extension +system would fix. -**Implementation:** -```go -// In configuration -type Config struct { - ExtraCommandPaths []string `yaml:"extra_command_paths"` -} -``` - -**Usage:** -```yaml -# ~/.config/wp-ops/config.yml -extra_command_paths: - - ~/my-wp-ops-extensions/scripts - - /opt/wp-ops-plugins -``` +So the question is not "would extensions be nice" but **who is blocked, and on +what.** Three plausible answers, with very different price tags: -**Pros:** -- Simple to implement -- Familiar pattern (similar to PATH environment variable) -- Easy for users to organize their own extensions -- No changes to core wp-ops needed for basic functionality - -**Cons:** -- Need runtime filesystem scanning (current system is build-time only) -- Potential performance impact with many external paths -- Need to handle duplicate command names - -### Option B: Configuration-Driven Plugins - -**Concept:** Use a configuration file to register external commands with their metadata, similar to how trellis-cli handles plugins. - -**Implementation:** -```yaml -# ~/.config/wp-ops/plugins.yml -plugins: - my-backup-tool: - path: /path/to/backup-script.sh - desc: "Custom backup solution for special hosting" - category: backup - platform: any - runs: local - requires: [curl, aws] - args: - - name: site - required: true - description: "Site URL to backup" - examples: - - "wp-ops my-backup-tool https://example.com" - - custom-monitor: - path: /path/to/monitor.yml - type: ansible - desc: "Custom monitoring playbook" - category: monitoring - platform: trellis - runs: local - requires: [ansible-playbook] - args: - - name: site - required: true - - name: env - required: true -``` - -**Pros:** -- Explicit registration prevents ambiguity -- Full control over metadata -- Can validate inputs before execution -- Easy to enable/disable plugins +| Need | Fix | Cost | +| --- | --- | --- | +| A few people at Imagewize want private client-specific scripts | One env var + a directory convention | ~1 day | +| External contributors want to publish shareable command packs | This document | ~1 week | +| A public plugin ecosystem with discovery and versioning | A registry, a package format, and ongoing curation | Months, forever | -**Cons:** -- Requires maintaining a separate configuration file -- More complex setup for simple scripts +**Everything below assumes the middle row.** If the real need is the first +row, implement [Phase 1](#implementation-phases) only and stop — it is +self-contained and already solves it. -### Option C: Git Submodules Approach +The cost that is easy to miss: every field of `catalog.Entry` becomes a public +contract the moment a third party writes a manifest against it. `Entry` is +currently free to change (`go/internal/catalog/catalog.go:26-83`). After +extensions ship, it is not. -**Concept:** Allow extensions to be added as Git submodules in a designated directory. +--- -**Structure:** -``` -wp-ops/ -├── extensions/ -│ ├── my-company-extensions/ (git submodule) -│ │ ├── scripts/ -│ │ │ └── custom-tool.sh -│ │ ├── playbooks/ -│ │ │ └── custom-playbook.yml -│ │ └── README.md -│ └── community-plugins/ (git submodule) -│ └── ... -``` +## What the current architecture constrains + +The previous draft of this document described the manifest format correctly +and then designed against a codebase that doesn't exist. These are the facts +that actually decide the design. + +**The catalog is generated at build time and embedded.** `gen` walks the +repo, parses every manifest, and writes `catalog.json` +(`go/internal/catalog/gen/main.go:89-200`); `catalog.go:19-20` embeds it. +`catalog.go:1-4` states the invariant outright: + +> No filesystem scan happens at runtime: a malformed manifest fails the +> build, not the CLI's behavior. + +**A malformed manifest is fatal.** `gen` collects lint errors and exits +non-zero on any hard error (`gen/main.go:180-193`). That is correct for a +repo you control and unacceptable for a directory a stranger writes into. + +**The binary ships its own scripts.** `repoRoot()` tries `WP_OPS_ROOT`, then +a live checkout found by walking up from the binary and the cwd, and finally +extracts the embedded asset tree into +`~/Library/Caches/wp-ops/assets-/` (`go/cmd/env.go:45-111`). A +Homebrew install has **no checkout on disk at all**. Any design phrased as +"drop your script into the repo" only serves the least common install shape. + +**`ScriptPath` is repo-relative and joined against that single root.** Every +execution path does it: `--where` (`go/cmd/dispatch.go:222`), shell +(`:256`), Ansible (`:293`), WP-CLI (`:309`). So does `@doc` resolution in +`wp-ops docs` (`go/cmd/docs.go:71`) and in `manifest.Lint` +(`go/internal/manifest/manifest.go:272`). + +**Human-facing listings are gated on a hardcoded slice.** `DisplayCategories()` +filters against `DisplayOrder` (`catalog.go:340-353`), and every grouped +surface iterates it — `go/cmd/list.go:77,99` and `go/internal/ui/model.go:60`. +A command whose `@category` isn't in that slice is dispatchable by key but +appears in no listing and no picker, and its header renders as `" commands"` +because `CategoryDisplayNames` misses. + +**Short names are computed, not stored.** `Load` assigns `ShortName` from +basename uniqueness across the whole catalog and falls back to the full key +on collision (`catalog.go:134-144`); `printAmbiguous` handles the rest +(`dispatch.go:359`). Collision handling already exists. + +**The MCP server reads `catalog.json` off disk.** `mcp-server/src/tools/catalog.ts` +resolves it from `REPO_ROOT` and parses it directly, deliberately, so that +command search works without a built Go binary. Runtime-discovered extensions +are invisible to it unless the same merge lands in TypeScript. + +**`wp-ops list --json` is a stable contract.** Called that in +`go/cmd/list.go:134`. Its `category` field is directory-based. Whatever +extensions report there is a promise. -**Pros:** -- Version-controlled extensions -- Easy to share and distribute -- Can be updated independently -- Built-in dependency management +--- -**Cons:** -- Requires Git knowledge -- Can bloat the main repository -- Need to handle submodule initialization +## The decision: build-time catalog vs. runtime discovery -### Option D: Package Manager Style +This is the whole design. Everything else follows. -**Concept:** Create a simple package manager for wp-ops extensions. +Extensions cannot be in the embedded catalog — they aren't in the repo when +`gen` runs. So either the CLI scans the filesystem at startup, or extensions +are registered explicitly through a config file listing their metadata. -**Implementation:** -```bash -# Install an extension -wp-ops plugin install github.com/user/wp-ops-custom-backup +**Take runtime discovery.** Explicit registration means an author writes +their manifest twice — once in the script header, once in the user's config — +and the two drift. It also duplicates executor selection, which today is +inferred from the file extension (`dispatch.go:227-235`); a config `type: +ansible` field is a second source of truth for something already knowable. -# List installed extensions -wp-ops plugin list +The cost is that `catalog.go:1-4`'s invariant no longer holds unconditionally. +Restate it as a two-tier rule: -# Update all extensions -wp-ops plugin update +> The **embedded** catalog is validated at build time and cannot be malformed +> at runtime. **Extension** entries are scanned at startup, and a malformed +> one is skipped with a warning — never fatal. -# Remove an extension -wp-ops plugin remove custom-backup -``` - -**Extension manifest format:** -```yaml -# .wp-ops-extensions/manifest.yml -name: custom-backup -description: "Custom backup solution" -version: 1.0.0 -author: "Your Name" -repository: github.com/user/wp-ops-custom-backup -commands: - - path: scripts/backup.sh - category: backup - platform: any - # ... other metadata -``` - -**Pros:** -- Professional plugin ecosystem -- Easy discovery and installation -- Version management -- Dependency resolution - -**Cons:** -- Significant implementation effort -- Need to host extension registry -- More complex infrastructure +That asymmetry is the design, not a compromise. Core commands keep their +build-time guarantee; extensions can never take the CLI down. --- -## Duplicate Prevention +## Design -### Strategy 1: Namespace Prefixing +### 1. Entries need a root -Require all third-party commands to use a namespace prefix to prevent conflicts with core commands. +Nothing works before this. Add one field: -```bash -# Third-party commands must use vendor:command-name format -wp-ops acme:backup -wp-ops mycompany:migrate +```go +// Root is the absolute directory ScriptPath is resolved against. Empty +// for embedded/core entries, which resolve against repoRoot() as they +// always have. Never serialized into the embedded catalog.json — it is a +// property of where an entry was discovered, not of the file. +Root string `json:"-"` ``` -**Implementation:** -- Validate command names during registration -- Reject commands that don't follow the naming convention -- Automatically prefix discovered commands from external sources - -### Strategy 2: Priority System - -Implement a priority system where core commands take precedence over third-party ones. +Then introduce a single accessor and route **every** join through it: ```go -type CommandSource int - -const ( - CoreCommand CommandSource = iota - BuiltinExtension - UserExtension - ThirdPartyExtension -) - -// When duplicate is found, higher priority wins -func resolveDuplicate(existing, new CommandEntry) CommandEntry { - if existing.Priority >= new.Priority { - return existing +func (e Entry) AbsPath(repoRoot string) string { + if e.Root != "" { + return filepath.Join(e.Root, e.ScriptPath) } - return new + return filepath.Join(repoRoot, e.ScriptPath) } ``` -### Strategy 3: Explicit Override Configuration - -Allow users to explicitly override or disable conflicting commands. - -```yaml -# ~/.config/wp-ops/config.yml -command_overrides: - # Disable core command in favor of plugin - disable: - - backup - - migration/export - - # Alias third-party command to simpler name - aliases: - my-backup: acme:backup-tool -``` +Call sites to convert: `dispatch.go:222` (`--where`), `:256` (shell), `:293` +(Ansible), `:309` (WP-CLI), `docs.go:71` (`@doc`), and `manifest.Lint`'s doc +check (`manifest.go:272`). That list is the complete blast radius. -### Strategy 4: Duplicate Detection and Warning +Note that Ansible playbooks additionally get `WithWPOpsRoot(playbookArgs, +root)` (`dispatch.go:294`) — an extension playbook still needs the *core* +root for that variable, so it stays `repoRoot()`, not the extension root. +Two different roots, deliberately. -Implement detection and warning system: +### 2. Discovery -```go -func checkDuplicates(catalog *Catalog) []Conflict { - nameCount := make(map[string][]string) - - for _, entry := range catalog.Entries { - nameCount[entry.Name] = append(nameCount[entry.Name], entry.Source) - } - - var conflicts []Conflict - for name, sources := range nameCount { - if len(sources) > 1 { - conflicts = append(conflicts, Conflict{ - Name: name, - Sources: sources, - }) - } - } - return conflicts -} -``` +One directory, one env var: -**CLI Output:** ``` -Warning: Command 'backup' is defined in multiple locations: - - Core: scripts/backup/db-backup.sh - - Plugin: ~/extensions/my-backup/backup.sh - -Using core version. To use the plugin version, add to config: - command_overrides: - disable: - - backup +$WP_OPS_EXTENSIONS_DIR default: ~/.wp-ops-extensions ``` ---- - -## Testing Framework +Layout — each immediate subdirectory is one extension, named by its vendor: -### Test Command Structure - -Create a standardized way to test scripts/playbooks: - -```bash -# Directory structure for extensions with tests -my-extension/ -├── scripts/ -│ └── custom-tool.sh -├── playbooks/ -│ └── custom-playbook.yml -└── tests/ - ├── custom-tool_test.sh # Test script - ├── custom-tool_test.yml # Test playbook - └── fixtures/ # Test data - └── test-site/ ``` - -### Test Metadata - -Add testing metadata to command annotations: - -```bash -# @test path/to/test-script.sh -# @test-data path/to/fixtures -# @test-cmd ./test-script.sh -# @test-args --site test-site +~/.wp-ops-extensions/ +└── acme/ # vendor namespace + ├── scripts/ + │ └── client-backup.sh + └── trellis/ + └── client-deploy.yml ``` -### Test Runner - -Implement a test runner that can: - -1. **Syntax validation:** Check scripts for syntax errors - ```bash - wp-ops test validate my-extension/ - ``` +Discovery reuses `gen`'s walk verbatim: same per-category extensions +(`gen/main.go:47-59`), same excluded dirs, same `manifest.Parse`. That means +**factoring the walk out of `package main`** into something both `gen` and +the runtime scanner import — otherwise the two discovery rules drift, and a +script that works in-repo behaves differently as an extension. Proposed +home: `go/internal/catalog/discover`. -2. **Unit testing:** Run test scripts in isolation - ```bash - wp-ops test run my-extension/tests/custom-tool_test.sh - ``` +Merge order: embedded entries first, extension entries appended, then `Load`'s +`ShortName` computation runs over the combined set so basename uniqueness +accounts for both. -3. **Integration testing:** Test with actual dependencies (optional) - ```bash - wp-ops test integrate my-extension/ --with-ansible --with-wp-cli - ``` +A single directory, not a list of paths. A `$PATH`-style list invites +precedence questions that namespacing (below) makes moot, and nobody has +asked for more than one. -4. **Continuous integration:** Generate GitHub Actions workflows - ```bash - wp-ops test generate-ci > .github/workflows/test.yml - ``` +### 3. Namespacing makes collisions structurally impossible -### Test Script Template +Extension keys are minted as `ext//`: -```bash -#!/bin/bash -# test-custom-tool.sh - Test script for custom-tool - -set -euo pipefail - -# Test helper functions -source "$(wp-ops test-helpers)" - -setup() { - # Create temporary directory - TEST_DIR=$(mktemp -d) - cd "$TEST_DIR" - - # Set up test environment - mkdir -p test-site/wp-content - echo "test" > test-site/wp-content/test.txt -} - -teardown() { - rm -rf "$TEST_DIR" -} - -test_basic_functionality() { - # Run the script with test arguments - output=$("${EXTENSION_DIR}/scripts/custom-tool.sh" --site "$TEST_DIR/test-site") - - # Assertions - assert_contains "$output" "Success" - assert_file_exists "$TEST_DIR/test-site/wp-content/output.txt" -} - -test_error_handling() { - # Test error conditions - assert_throws "${EXTENSION_DIR}/scripts/custom-tool.sh" --invalid-arg -} - -# Run tests -main() { - setup - - run_test test_basic_functionality "Basic functionality test" - run_test test_error_handling "Error handling test" - - teardown - echo "All tests passed!" -} - -main "$@" ``` - ---- - -## Drop-in Installation - -### Single File Installation - -For simple scripts, allow installation via a single file: - -```bash -# Install a single script -wp-ops install-script https://gist.githubusercontent.com/user/123456/custom-tool.sh - -# This will: -# 1. Download the script -# 2. Extract metadata from annotations -# 3. Place it in ~/.wp-ops-extensions/scripts/ -# 4. Update the catalog cache +~/.wp-ops-extensions/acme/scripts/client-backup.sh + → key: ext/acme/scripts/client-backup ``` -### Directory Installation +Because every extension key starts with `ext/`, it can never equal a core +key. Duplicate *full keys* are therefore impossible without any priority +system, override config, or conflict resolver. + +Duplicate *basenames* remain possible — `ext/acme/scripts/db-backup` vs. +`scripts/backup/db-backup` — and are **already handled**: both lose their +`ShortName` and fall back to full keys (`catalog.go:134-144`), and +`printAmbiguous` prints both with instructions (`dispatch.go:359`). Nothing +new is needed. Two extensions from different vendors can't collide on a full +key either, since the vendor segment is in it. + +This deletes the previous draft's four duplicate-prevention strategies. In +particular it deletes `command_overrides.disable`: letting a machine-local +config silently repoint `wp-ops db-backup` at someone else's script is worse +than the ambiguity error, because the error is visible and the redirect +isn't. + +Register `"ext"` in `catalog.Categories` so `wp-ops ext ` works as a +category scope via the existing hidden-alias path (`dispatch.go:77-82`). +`gen` will `os.Stat` a repo-level `ext/` directory, not find one, and +`continue` (`gen/main.go:102-105`) — harmless, but worth a comment there so +it doesn't read as an oversight. + +For `wp-ops list --json`, extension entries report `"category": "ext"`. That +keeps the existing contract's meaning intact (`category` is still the +top-level directory) and gives external tooling a trivial filter. + +### 4. Category placement + +An extension declaring `@category backup` should group with the other backup +commands. An extension declaring `@category deployment` — a value not in +`DisplayOrder` — must not vanish from every listing. + +Rule: if `@category` is present in `DisplayOrder`, use it. Otherwise fall +back to `misc`, which is already the catch-all and already carries a blurb +(`catalog.go:392`). Do **not** append unknown categories to `DisplayOrder` at +runtime: it's a curated ordering with hand-written blurbs and display names, +and a stranger's `@category` should not be able to insert a top-level group +into `wp-ops --help`. + +### 5. Degradation and caching + +**Degradation.** A malformed extension manifest gets one line on stderr and +is skipped. The rules from `gen/main.go:180-193` carry over as *warnings* +rather than fatals — with the same carve-out for missing `@desc`, which falls +back to the header scrape (`gen/main.go:163-167`). An unreadable extensions +directory is not an error; it means no extensions. + +**Caching is not optional.** Scanning happens on every invocation, including +shell completion (`dispatch.go:115`, `:143`) — which runs on every `` +and is the most latency-sensitive path in the CLI. Cache the scan result in +`~/.cache/wp-ops/extensions-.json`, keyed on the extensions dir's +recursive max mtime; on a hit, skip the walk and the manifest parsing +entirely. The `~/.cache/wp-ops/` parent already exists for asset extraction +(`env.go:81`). + +Budget: a cache hit should add **under 5ms** to startup. Measure it before +merging; if that isn't achievable, the discovery approach is wrong and +explicit registration deserves a second look. -For collections of scripts/playbooks: +--- -```bash -# Install from a Git repository -wp-ops install-extension https://github.com/user/wp-ops-custom-tools +## Consumers that must change -# Install from a local directory -wp-ops install-extension /path/to/my-tools -``` +Checklist. Each of these reads the catalog and would otherwise be silently +wrong. -### Installation Process +| Consumer | Change | +| --- | --- | +| `dispatch.go` execution paths (4 sites) | Route through `Entry.AbsPath` | +| `docs.go` `@doc` resolution | Resolve against the entry's root | +| `manifest.Lint` doc check | Take the extension root, not `repoRoot` | +| `list.go` grouped views | Nothing — `DisplayCategory` fallback covers it | +| `list.go` `printJSON` | Emit `"category": "ext"`; contract note | +| `ui/model.go` picker | Nothing, same reason | +| `doctor.go` | Report extension count and any skipped/malformed entries | +| `mcp-server/src/tools/catalog.ts` | **Decide explicitly** — see below | +| Shell completion | Nothing functional; the cache is what keeps it usable | -``` -1. Validate the source (Git repo, URL, or local path) -2. Clone/download to ~/.wp-ops-extensions// -3. Scan for scripts and playbooks with valid metadata -4. Validate metadata and command names -5. Check for duplicates and warn user -6. Update catalog cache -7. Verify installation -``` +The MCP bridge is a real fork in the road, not an afterthought. It reads +`catalog.json` from `REPO_ROOT` on disk by design, so extensions are invisible +to it for free. Two honest options: -### Installation Locations +1. **CLI-only extensions.** Document it in one line in `mcp-server/README.md`. + Zero work, and defensible: MCP tools are an agent-facing surface where + arbitrary third-party commands are a larger trust question anyway. +2. **Mirror the scan in TypeScript.** Real work, and a second implementation + of discovery rules that must track the Go one. -``` -# Default locations (configurable) -Extensions directory: ~/.wp-ops-extensions/ -Catalog cache: ~/.cache/wp-ops/catalog.json -Config file: ~/.config/wp-ops/config.yml -Plugins directory: ~/.local/share/wp-ops/plugins/ -``` +Recommend (1) for the first release. Revisit only if someone asks. --- -## Metadata Standards +## The author contract -### Required Metadata +Unchanged from the existing manifest format — that's the point. An extension +script is an ordinary wp-ops script that happens to live elsewhere. -Every third-party command MUST include: +```bash +#!/usr/bin/env bash +# +# @desc Nightly backup to the client's own S3 bucket +# @category backup +# @platform wordpress +# @runs local +# @requires wp aws +# @arg site required {example.com} Site to back up +# @example wp-ops ext/acme/scripts/client-backup example.com +``` -- **@desc**: Clear description of what the command does -- **@category**: Primary category (must be existing or new reasonable category) -- **@platform**: Target platform (any, wordpress, trellis) -- **@runs**: Execution location (local, server) +Required for an extension: `@desc`, `@category`, `@platform`, `@runs`. +`@desc` is technically optional (the header scrape covers it, +`gen/main.go:163-167`) but a scraped description reads badly in the picker. -### Recommended Metadata +Recognized values are enforced by the existing linter and unchanged: +`@platform` ∈ {`trellis`, `wordpress`, `any`} (`manifest.go:251-257`), +`@runs` ∈ {`local`, `server`, `either`} (`manifest.go:238-244`). -- **@requires**: List of dependencies -- **@arg**: Argument definitions -- **@flag**: Flag definitions -- **@example**: Usage examples -- **@doc**: Documentation path -- **@author**: Author information -- **@version**: Command version -- **@license**: License information +Two notes for authors: -### Metadata Validation +- **`@doc` resolves against your extension root**, not wp-ops'. +- **`@example` should use the full `ext/...` key.** Your basename may or may + not survive as a short name depending on what else the user has installed, + so an example written against the short form can be wrong on someone + else's machine. (The repo pins this for core commands in a test — see + commit db25031.) -```go -func validateMetadata(entry CommandEntry) error { - // Required fields - if entry.Desc == "" { - return errors.New("missing @desc") - } - if entry.Category == "" { - return errors.New("missing @category") - } - if entry.Platform == "" { - return errors.New("missing @platform") - } - if entry.Runs == "" { - return errors.New("missing @runs") - } - - // Validate enumerated values - validPlatforms := []string{"any", "wordpress", "trellis"} - if !contains(validPlatforms, entry.Platform) { - return fmt.Errorf("invalid @platform: %s, must be one of %v", - entry.Platform, validPlatforms) - } - - validRuns := []string{"local", "server", "either"} - if !contains(validRuns, entry.Runs) { - return fmt.Errorf("invalid @runs: %s, must be one of %v", - entry.Runs, validRuns) - } - - return nil -} -``` +Unrecognized directives like `@version` and `@author` are silently ignored by +the parser (`manifest.go:96-99`) and are fine to include as documentation. --- -## Security Considerations +## Security: what wp-ops can and cannot enforce -### Sandboxing +Be blunt here, because the previous draft was not. -1. **Read-only mode:** Commands can be marked as read-only -2. **Confirmation prompts:** Require confirmation for destructive operations -3. **Dry-run support:** All commands should support --dry-run flag +**wp-ops cannot sandbox an extension.** It `exec`s the script as the invoking +user (`internal/exec/shell.go`). There is no filesystem allowlist, no network +policy, no blocked-commands list, and no way to add one without a container +or an OS sandbox that wp-ops does not have. A `permissions:` block in a +manifest would be decoration that reads as a guarantee — strictly worse than +saying nothing. -### Permission Model +**wp-ops cannot enforce `--dry-run` either.** Every dispatch path sets +`DisableFlagParsing` and passes argv through untouched (`dispatch.go:36,52`), +because the underlying scripts own their flag grammars. Whether an extension +honors `--dry-run` is between its author and its user. -```yaml -# In extension manifest -permissions: - filesystem: - read: ["/path/patterns"] - write: [] # Empty means no write access - network: - allowed_hosts: ["api.example.com", "github.com"] - commands: - allowed: ["wp", "git", "rsync"] - blocked: ["rm", "chmod", "chown"] -``` +So the security model is one sentence: -### Code Review +> Installing an extension is equivalent to running a shell script you +> downloaded. Trust is binary and it is yours to grant. -1. **Signature verification:** Allow signed extensions -2. **Source review:** Maintain a curated list of trusted sources -3. **Sandbox execution:** Run untrusted commands in containers +What wp-ops can usefully do: -### Safe Defaults +- `wp-ops ext where ` — print the file's absolute path so it can be read + before it's run. `--where` already does this (`dispatch.go:216-224`); it + just needs to work for extensions, which falls out of `Entry.AbsPath`. +- `wp-ops ext list` — show every extension, its vendor, and its path, so + "what is installed" is answerable without `find`. +- Warn once, on first discovery of a new vendor directory, that its scripts + run with the user's privileges. +- Preserve the existing server-side guard (`serverSideGuard`, + `dispatch.go:246`) for extensions declaring `@runs server`. -```bash -# Always run with safe defaults -set -euo pipefail - -# Validate inputs -validate_input "$1" "site" "required" - -# Use dry-run by default for destructive operations -DRY_RUN=${DRY_RUN:-true} -if [ "$DRY_RUN" = "true" ]; then - echo "[DRY RUN] Would perform: $ACTION" - return 0 -fi -``` +No signature verification, no curated trusted-source list. Both require +infrastructure and a trust authority this project doesn't have. --- -## Recommended Implementation +## Validation, not a test framework -Based on analysis of the current architecture and requirements, **Option A (External Commands Directory)** with enhancements from other options provides the best balance: +Extension authors need to know their manifest is correct before a user hits +it. They do not need wp-ops to ship an assertion library. -### Phase 1: External Paths Support - -1. **Add configuration support for external paths** - ```yaml - # ~/.config/wp-ops/config.yml - extra_command_paths: - - ~/my-extensions - - /opt/wp-ops-plugins - ``` +```bash +wp-ops ext validate [path] +``` -2. **Modify catalog loading to include external paths** - - Scan external paths at startup (not just build-time) - - Merge with embedded catalog - - Handle duplicates with priority system +What it does, all of it reusing existing code: -3. **Add CLI commands for extension management** - ```bash - wp-ops extension path add ~/my-extensions - wp-ops extension path remove ~/my-extensions - wp-ops extension list - wp-ops extension validate - ``` +1. Walk the extension the way discovery does (shared `discover` package). +2. `manifest.Parse` + `manifest.Lint` each file, with `@doc` resolved against + the extension root (`manifest.go:231-278`). +3. Syntax-check by extension: `bash -n` for `.sh`, `ansible-playbook + --syntax-check` for `.yml`, `php -l` for `.php`, `node --check` for `.js`. +4. Report basename collisions against the installed catalog — informational, + since they resolve to full keys rather than failing. +5. Exit non-zero on any hard error, so it drops into an author's CI unchanged. -### Phase 2: Plugin System +That's roughly 100 lines and catches the failures that actually reach users. -1. **Implement plugin manifest format** - - Simple YAML files describing extensions - - Support for versioning and dependencies +The previous draft proposed `wp-ops test-helpers`, a fixture convention, an +assertion library, and `wp-ops test generate-ci`. Dropped: this repo does not +test its own ~60 shell scripts, and shipping a test harness for third parties +before testing your own commands is the wrong order. If script testing is +wanted, it should land for core commands first and extensions should inherit +whatever that turns out to be. -2. **Add plugin installation commands** - ```bash - wp-ops plugin install github.com/user/my-plugin - wp-ops plugin remove my-plugin - wp-ops plugin update - wp-ops plugin search - ``` +--- -### Phase 3: Testing Framework +## Implementation phases -1. **Add test command** - ```bash - wp-ops test validate my-extension/ - wp-ops test run my-extension/tests/ - ``` +**Phase 1 — make external paths executable.** `Entry.Root` + `AbsPath`, all +six call sites converted, and `$WP_OPS_EXTENSIONS_DIR` scanned with `ext/` +keys. No caching yet, no new subcommands. This is independently useful: it +alone solves the "private client scripts" case. -2. **Create test helpers library** - - Common assertion functions - - Mock utilities - - Test fixtures management +**Phase 2 — make it usable.** The mtime cache, the `DisplayOrder` → `misc` +fallback, degradation warnings, `doctor` reporting, and the `discover` +package factored out of `gen`. ---- +**Phase 3 — make it publishable.** `wp-ops ext list | where | validate`, plus +a short authoring guide. Distribution is `git clone` into the extensions +directory — no installer. -## Example Workflow - -### For Extension Developers - -1. **Create extension structure** - ```bash - mkdir my-wp-ops-extension - cd my-wp-ops-extension - mkdir -p scripts playbooks tests - ``` - -2. **Add a script with proper metadata** - ```bash - #!/bin/bash - # - # @desc Custom backup for special hosting - # @category backup - # @platform any - # @runs local - # @requires curl aws - # @arg site required {example.com} Site URL - # @arg dest optional {s3://backups/} Destination - # @example wp-ops custom-backup https://example.com - # @author Your Name - # @version 1.0.0 - # @license MIT - ``` - -3. **Add tests** - ```bash - #!/bin/bash - # tests/custom-backup_test.sh - - source "$(wp-ops test-helpers)" - - test_backup_creation() { - # ... test logic - } - ``` - -4. **Create extension manifest** - ```yaml - # extension.yml - name: custom-backup - description: "Custom backup solution" - version: 1.0.0 - author: "Your Name" - commands: - - scripts/custom-backup.sh - ``` - -5. **Package and distribute** - ```bash - git init - git add . - git commit -m "Initial version" - git tag v1.0.0 - git push origin main - ``` - -### For Users - -1. **Install extension** - ```bash - wp-ops extension install github.com/yourname/my-wp-ops-extension - ``` - -2. **Use the new command** - ```bash - wp-ops custom-backup https://example.com - ``` - -3. **List installed extensions** - ```bash - wp-ops extension list - ``` - -4. **Test an extension** - ```bash - wp-ops test validate my-wp-ops-extension - wp-ops test run my-wp-ops-extension/tests/ - ``` +Stop there. Reassess only with evidence of extensions in the wild. --- -## Migration Path - -For existing wp-ops users wanting to add custom scripts: +## Out of scope -1. **Before (manual):** - - Add scripts to wp-ops repository - - Modify catalog manually - - Rebuild wp-ops binary - - Handle merge conflicts +Each of these was in the previous draft. Each is dropped for a stated reason, +not forgotten. -2. **After (with extensions):** - - Create separate extension repository - - Install via `wp-ops extension install` - - No need to modify core wp-ops - - Easy to share with team +| Dropped | Why | +| --- | --- | +| Package manager (`ext install/update/search`) | `git clone` into one directory covers it. `search` needs a registry that doesn't exist. | +| Extension registry | Requires hosting, curation, and a trust authority. No demand. | +| Priority system, override config, aliases | Namespacing makes full-key collisions impossible; basename collisions are already handled (`catalog.go:134-144`). Three mechanisms for a solved problem. | +| `permissions:` sandboxing | Unenforceable against an `exec`d shell script. Documenting controls that don't exist is worse than documenting none. | +| Signature verification, trusted sources | Needs a trust authority. | +| Test-helper library, `test generate-ci` | Core scripts aren't tested either. `ext validate` covers the real failure mode. | +| Config-file registration of metadata (previous Option B) | Second source of truth; duplicates extension-based executor selection (`dispatch.go:227-235`). | +| Git submodules (previous Option C) | Bloats this repo with third-party code and requires a PR to add anything — the opposite of the goal. | +| Multiple extension search paths | Precedence questions namespacing already removes. | --- -## Conclusion - -The recommended approach combines external command directory scanning with a simple plugin system. This provides: - -- **Easy drop-in:** Just add scripts to configured directories -- **No duplicates:** Priority system and namespace support -- **Testability:** Built-in test framework -- **Flexibility:** Works with simple scripts or complex plugin collections -- **Security:** Sandboxing and permission controls -- **Maintainability:** No changes to core wp-ops needed - -This approach allows third parties to extend wp-ops while maintaining the existing architecture's simplicity and performance. +## Open questions + +1. **Does anyone actually want this?** See [Is this needed?](#is-this-needed). + Not rhetorical — Phase 1 is worth doing regardless, Phases 2–3 are not. +2. **MCP: option (1) or (2)?** Recommend CLI-only for the first release, but + it should be a decision on the record rather than an omission. +3. **Does the cache hold the 5ms completion budget?** If not, the whole + runtime-discovery premise needs revisiting. +4. **Should `ext/` appear in `wp-ops --help` at all?** Core categories are + visible; the directory aliases are hidden. An `ext` group with a vendor's + commands in it is arguably neither.