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.