diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..007b880 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + + - name: Install dependencies + run: go mod download + + - name: Run fmt-check + run: make fmt-check + + - name: Run lint + run: make lint + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index 4b714c8..c8c997e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ coverage.html # Build dist/ + +# Dev tools +.tools/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..cc338a5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,168 @@ +version: "2" + +linters: + default: none + enable: + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - depguard + - dupword + - dupl + - err113 + - errcheck + - errorlint + - forbidigo + - goconst + - gocritic + - gocyclo + - gosec + - govet + - ineffassign + - makezero + - misspell + - nilerr + - nilnil + - noctx + - nolintlint + - paralleltest + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - tagliatelle + - thelper + - tparallel + - durationcheck + - unparam + - unused + - wastedassign + - whitespace + - wrapcheck + - wsl_v5 + + settings: + cyclop: + max-complexity: 50 + dupl: + threshold: 150 + depguard: + rules: + main: + list-mode: lax + files: + - $all + deny: + - pkg: io/ioutil + desc: "deprecated; use io or os" + goconst: + min-len: 3 + min-occurrences: 4 + gocyclo: + min-complexity: 50 + forbidigo: + forbid: + - pattern: "^log\\." + msg: "avoid std log; use slog" + revive: + rules: + - name: blank-imports + - name: empty-lines + - name: errorf + - name: import-shadowing + - name: range + - name: unnecessary-stmt + tagliatelle: + case: + rules: + json: snake + yaml: snake + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + disable: + - assign + - decl + govet: + enable-all: true + disable: + - fieldalignment + + exclusions: + presets: + - std-error-handling + rules: + - path: _test\.go + linters: + - errcheck + - cyclop + - gocyclo + - gosec + - dupl + - goconst + - tagliatelle + - paralleltest + - err113 + - errorlint + - wsl_v5 + - noctx + - unparam + - path: internal/cmd/.*\.go + linters: + - err113 + - wrapcheck + - dupl + - wsl_v5 + - tagliatelle + - gocritic + - gosec + - govet + - nilerr + - predeclared + - staticcheck + - unparam + - path: cmd/.*\.go + linters: + - wsl_v5 + - path: internal/(adapter|batch|cache|config|outfmt|registry|safety|scripts|ssh|wpcli)/.*\.go + linters: + - err113 + - wrapcheck + - wsl_v5 + - noctx + - gosec + - nilerr + - nilnil + - govet + - errorlint + - unparam + - tagliatelle + - gocritic + - predeclared + - staticcheck + - revive + - rowserrcheck + - linters: + - errcheck + text: "Error return value of `fmt\\.Fprint" + - linters: + - errcheck + text: "Error return value of .*.Close" + - linters: + - errcheck + text: "Error return value of .*.Signal" + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/builtbyrobben/wpssh diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c71a886 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,44 @@ +version: 2 + +project_name: wpssh + +changelog: + disable: true + +builds: + - id: wpgo + main: ./cmd/wpgo + binary: wpgo + ldflags: >- + -s -w + -X main.version={{ .Tag }} + -X main.commit={{ .ShortCommit }} + -X main.date={{ .Date }} + env: + - CGO_ENABLED=0 + targets: + - linux_amd64 + - linux_arm64 + - darwin_amd64 + - darwin_arm64 + +archives: + - builds: + - wpgo + format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +brews: + - repository: + owner: builtbyrobben + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + folder: Formula + homepage: "https://github.com/builtbyrobben/wpssh" + description: "WordPress management over SSH" + license: "MIT" + test: | + system "#{bin}/wpgo", "--version" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3f421a7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines + +## Project Structure + +- `cmd/wpgo/`: CLI entrypoint +- `internal/`: implementation packages + - `cmd/`: Kong CLI commands + - `ssh/`: SSH client + - `cache/`: Caching + - `wpcli/`: WP-CLI wrapper + - `config/`: Configuration + - `outfmt/`: JSON/plain output formatting + - `errfmt/`: User-friendly error formatting + - `safety/`: Safety checks + - `scripts/`: Script management + - `adapter/`: Adapters + - `batch/`: Batch operations + - `registry/`: Registry + +## Build, Test, and Development Commands + +- `make` / `make build`: build `bin/wpgo` +- `make tools`: install pinned dev tools into `.tools/` +- `make fmt` / `make lint` / `make test` / `make ci`: format, lint, test, full local gate +- `make test-integration`: run integration tests +- `make test-all`: run all tests including integration + +## Coding Style & Naming Conventions + +- Formatting: `make fmt` (`goimports` local prefix `github.com/builtbyrobben/wpssh` + `gofumpt`) +- Output: keep stdout parseable (`--json` / `--plain`); send human hints/progress to stderr + +## Testing Guidelines + +- Unit tests: stdlib `testing` (and `httptest` where needed) +- Integration tests: use `//go:build integration` tag under `tests/integration/` + +## Commit & Pull Request Guidelines + +- Follow Conventional Commits + action-oriented subjects (e.g. `feat(cli): add --verbose flag`) +- Group related changes; avoid bundling unrelated refactors +- PRs should summarize scope, note testing performed, and mention user-facing changes + +## Security + +- Never commit SSH keys, passwords, or credentials +- Use `--stdin` for sensitive input (avoid shell history leaks) diff --git a/Makefile b/Makefile index 1b84cdf..4645d49 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,66 @@ -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -LDFLAGS := -ldflags "-X main.version=$(VERSION)" +SHELL := /bin/bash -.PHONY: build test test-integration test-all lint install clean +.DEFAULT_GOAL := build + +.PHONY: build wpgo help fmt fmt-check lint test test-integration test-all ci tools install clean + +BIN_DIR := $(CURDIR)/bin +BIN := $(BIN_DIR)/wpgo +CMD := ./cmd/wpgo + +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo "") +DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) + +TOOLS_DIR := $(CURDIR)/.tools +GOFUMPT := $(TOOLS_DIR)/gofumpt +GOIMPORTS := $(TOOLS_DIR)/goimports +GOLANGCI_LINT := $(TOOLS_DIR)/golangci-lint + +ifneq ($(filter wpgo,$(MAKECMDGOALS)),) +RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +$(eval $(RUN_ARGS):;@:) +endif build: - go build $(LDFLAGS) -o bin/wpgo ./cmd/wpgo + @mkdir -p $(BIN_DIR) + @go build -ldflags "$(LDFLAGS)" -o $(BIN) $(CMD) + +wpgo: build + @if [ -n "$(RUN_ARGS)" ]; then \ + $(BIN) $(RUN_ARGS); \ + elif [ -z "$(ARGS)" ]; then \ + $(BIN) --help; \ + else \ + $(BIN) $(ARGS); \ + fi + +help: build + @$(BIN) --help + +tools: + @mkdir -p $(TOOLS_DIR) + @GOBIN=$(TOOLS_DIR) go install mvdan.cc/gofumpt@v0.9.2 + @GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.41.0 + @GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0 + +fmt: tools + @$(GOIMPORTS) -local github.com/builtbyrobben/wpssh -w . + @$(GOFUMPT) -w . + +fmt-check: tools + @UNFMT=$$($(GOIMPORTS) -local github.com/builtbyrobben/wpssh -l .); \ + UNFMT="$$UNFMT$$($(GOFUMPT) -l .)"; \ + if [ -n "$$UNFMT" ]; then \ + echo "Files need formatting:"; echo "$$UNFMT"; exit 1; \ + fi + +lint: tools + @$(GOLANGCI_LINT) run test: - go test -race -count=1 ./internal/... + @go test -race -count=1 ./internal/... test-integration: @if [ -n "$(shell go list -tags=integration ./tests/integration/... 2>/dev/null)" ]; then \ @@ -17,14 +70,12 @@ test-integration: fi test-all: - go test -race -tags=integration ./... - -lint: - go vet ./... - golangci-lint run + @go test -race -tags=integration ./... install: - go install $(LDFLAGS) ./cmd/wpgo + @go install -ldflags "$(LDFLAGS)" ./cmd/wpgo clean: - rm -rf bin/ dist/ + rm -rf bin/ dist/ .tools/ + +ci: fmt-check lint test diff --git a/cmd/wpgo/main.go b/cmd/wpgo/main.go index 6edb493..7559dc0 100644 --- a/cmd/wpgo/main.go +++ b/cmd/wpgo/main.go @@ -5,10 +5,16 @@ import ( "os" "github.com/alecthomas/kong" + "github.com/builtbyrobben/wpssh/internal/cmd" + "github.com/builtbyrobben/wpssh/internal/errfmt" ) -var version = "dev" +var ( + version = "dev" + commit = "" + date = "" +) func main() { var cli cmd.CLI @@ -20,11 +26,15 @@ func main() { kong.ConfigureHelp(kong.HelpOptions{ NoExpandSubcommands: true, }), - kong.Vars{"version": version}, + kong.Vars{ + "version": version, + "commit": commit, + "date": date, + }, ) err := ctx.Run(&cli.Globals) if err != nil { - fmt.Fprintf(os.Stderr, "wpgo: %v\n", err) + fmt.Fprintf(os.Stderr, "wpgo: %s\n", errfmt.Format(err)) os.Exit(1) } } diff --git a/internal/adapter/standard.go b/internal/adapter/standard.go index 6bf587b..8d579d2 100644 --- a/internal/adapter/standard.go +++ b/internal/adapter/standard.go @@ -68,7 +68,7 @@ func (a *StandardAdapter) Download(ctx context.Context, client *internalssh.SSHC if result.ExitCode != 0 { return fmt.Errorf("download failed (exit %d): %s", result.ExitCode, result.Stderr) } - if err := os.WriteFile(localPath, []byte(result.Stdout), 0644); err != nil { + if err := os.WriteFile(localPath, []byte(result.Stdout), 0o644); err != nil { return fmt.Errorf("write local file: %w", err) } return nil @@ -86,8 +86,11 @@ func siteToClientConfig(site *registry.Site) internalssh.ClientConfig { } // shellQuote wraps a string in single quotes for safe shell usage. -// Single quotes inside the string are escaped as '\'' (end quote, escaped -// literal quote, start quote). +// Embedded single quotes are escaped with the shell idiom: +// +// end-quote + backslash-quote + start-quote ('”'”') +// +// For example, “it's” becomes 'it'”'”'s'. func shellQuote(s string) string { result := "'" for _, c := range s { diff --git a/internal/adapter/wpengine.go b/internal/adapter/wpengine.go index e671f78..1237c70 100644 --- a/internal/adapter/wpengine.go +++ b/internal/adapter/wpengine.go @@ -99,7 +99,7 @@ func (a *WPEngineAdapter) Download(ctx context.Context, client *internalssh.SSHC if result.ExitCode != 0 { return fmt.Errorf("download failed (exit %d): %s", result.ExitCode, result.Stderr) } - if err := os.WriteFile(localPath, []byte(result.Stdout), 0644); err != nil { + if err := os.WriteFile(localPath, []byte(result.Stdout), 0o644); err != nil { return fmt.Errorf("write local file: %w", err) } return nil diff --git a/internal/batch/executor.go b/internal/batch/executor.go index 7bfbfb7..fa24c68 100644 --- a/internal/batch/executor.go +++ b/internal/batch/executor.go @@ -17,12 +17,12 @@ type CommandFunc func(ctx context.Context, site *registry.Site) (detail string, // Options configures batch execution. type Options struct { - Concurrency int // Max parallel executions (default 1 = sequential) - Yes bool // --yes flag - AckDestructive bool // --ack-destructive flag - DryRun bool // --dry-run: show what would run without executing + Concurrency int // Max parallel executions (default 1 = sequential) + Yes bool // --yes flag + AckDestructive bool // --ack-destructive flag + DryRun bool // --dry-run: show what would run without executing Tier safety.SafetyTier - CommandName string // Human-readable command description for progress display + CommandName string // Human-readable command description for progress display } // SiteResult holds the outcome of a command executed on a single site. @@ -38,7 +38,7 @@ type SiteResult struct { type ResultStatus int const ( - StatusOK ResultStatus = iota + StatusOK ResultStatus = iota StatusFailed StatusSkipped ) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index f3eb188..6334630 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -19,7 +19,7 @@ type Cache struct { // file is set to 0600 (owner read/write only). func New(dbPath string) (*Cache, error) { dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("create cache dir: %w", err) } @@ -51,7 +51,7 @@ func New(dbPath string) (*Cache, error) { } // Set file permissions to 0600 (owner read/write only). - if err := os.Chmod(dbPath, 0600); err != nil { + if err := os.Chmod(dbPath, 0o600); err != nil { db.Close() return nil, fmt.Errorf("set cache db permissions: %w", err) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 5820002..58c9a1c 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -524,7 +524,7 @@ func TestFilePermissions(t *testing.T) { t.Fatalf("stat error: %v", err) } perm := stat.Mode().Perm() - if perm != 0600 { + if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } diff --git a/internal/cache/models.go b/internal/cache/models.go index 80edf1d..25a8ed9 100644 --- a/internal/cache/models.go +++ b/internal/cache/models.go @@ -4,12 +4,12 @@ import "time" // Default TTLs for cacheable command categories. const ( - TTLPlugins = 3600 // 1 hour - TTLThemes = 3600 // 1 hour - TTLCore = 86400 // 24 hours - TTLUsers = 21600 // 6 hours - TTLOptions = 1800 // 30 minutes - TTLSnapshot = 14400 // 4 hours + TTLPlugins = 3600 // 1 hour + TTLThemes = 3600 // 1 hour + TTLCore = 86400 // 24 hours + TTLUsers = 21600 // 6 hours + TTLOptions = 1800 // 30 minutes + TTLSnapshot = 14400 // 4 hours ) // Cache categories for invalidation grouping. diff --git a/internal/cache/queries.go b/internal/cache/queries.go index 2f8ab22..9bb8308 100644 --- a/internal/cache/queries.go +++ b/internal/cache/queries.go @@ -21,8 +21,10 @@ const createSiteSnapshots = `CREATE TABLE IF NOT EXISTS site_snapshots ( last_checked DATETIME )` -const createIdxCategory = `CREATE INDEX IF NOT EXISTS idx_cache_category ON cache_entries(site_alias, category)` -const createIdxExpiry = `CREATE INDEX IF NOT EXISTS idx_cache_expiry ON cache_entries(fetched_at, ttl_seconds)` +const ( + createIdxCategory = `CREATE INDEX IF NOT EXISTS idx_cache_category ON cache_entries(site_alias, category)` + createIdxExpiry = `CREATE INDEX IF NOT EXISTS idx_cache_expiry ON cache_entries(fetched_at, ttl_seconds)` +) const queryGetEntry = `SELECT site_alias, cache_key, command, data, fetched_at, ttl_seconds, category FROM cache_entries diff --git a/internal/cmd/cache_cmd.go b/internal/cmd/cache_cmd.go index 36685f1..05c3861 100644 --- a/internal/cmd/cache_cmd.go +++ b/internal/cmd/cache_cmd.go @@ -10,11 +10,14 @@ type CacheCmdGroup struct { Type CacheTypeCmd `cmd:"" help:"Show cache type"` } -type CacheFlushCmd struct{} -type CacheGetCmd struct { - Key string `arg:"" help:"Cache key"` - Group string `arg:"" optional:"" help:"Cache group"` -} +type ( + CacheFlushCmd struct{} + CacheGetCmd struct { + Key string `arg:"" help:"Cache key"` + Group string `arg:"" optional:"" help:"Cache group"` + } +) + type CacheSetCmd struct { Key string `arg:"" help:"Cache key"` Value string `arg:"" help:"Cache value"` diff --git a/internal/cmd/comment.go b/internal/cmd/comment.go index 0895022..575d7b5 100644 --- a/internal/cmd/comment.go +++ b/internal/cmd/comment.go @@ -19,17 +19,41 @@ type CommentCmd struct { Count CommentCountCmd `cmd:"" help:"Count comments"` } -type CommentListCmd struct{} -type CommentGetCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentCreateCmd struct{} -type CommentUpdateCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentDeleteCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentApproveCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentUnapproveCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentSpamCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentUnspamCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentTrashCmd struct{ ID int `arg:"" help:"Comment ID"` } -type CommentUntrashCmd struct{ ID int `arg:"" help:"Comment ID"` } +type ( + CommentListCmd struct{} + CommentGetCmd struct { + ID int `arg:"" help:"Comment ID"` + } +) + +type ( + CommentCreateCmd struct{} + CommentUpdateCmd struct { + ID int `arg:"" help:"Comment ID"` + } +) + +type CommentDeleteCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentApproveCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentUnapproveCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentSpamCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentUnspamCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentTrashCmd struct { + ID int `arg:"" help:"Comment ID"` +} +type CommentUntrashCmd struct { + ID int `arg:"" help:"Comment ID"` +} type CommentCountCmd struct{} func (c *CommentListCmd) Run(g *Globals) error { diff --git a/internal/cmd/config_cmd.go b/internal/cmd/config_cmd.go index 3897f74..e8e997b 100644 --- a/internal/cmd/config_cmd.go +++ b/internal/cmd/config_cmd.go @@ -11,15 +11,23 @@ type ConfigCmdGroup struct { Path ConfigPathCmd `cmd:"" help:"Show wp-config.php path"` } -type ConfigGetCmd struct{ Name string `arg:"" help:"Constant name"` } +type ConfigGetCmd struct { + Name string `arg:"" help:"Constant name"` +} type ConfigSetCmd struct { Name string `arg:"" help:"Constant name"` Value string `arg:"" help:"Constant value"` } -type ConfigDeleteCmd struct{ Name string `arg:"" help:"Constant name"` } -type ConfigHasCmd struct{ Name string `arg:"" help:"Constant name"` } -type ConfigListCmd struct{} -type ConfigPathCmd struct{} +type ConfigDeleteCmd struct { + Name string `arg:"" help:"Constant name"` +} +type ConfigHasCmd struct { + Name string `arg:"" help:"Constant name"` +} +type ( + ConfigListCmd struct{} + ConfigPathCmd struct{} +) func (c *ConfigGetCmd) Run(g *Globals) error { return execPassthrough(g, "config", "get", c.Name) diff --git a/internal/cmd/core.go b/internal/cmd/core.go index 2ee53be..71246ad 100644 --- a/internal/cmd/core.go +++ b/internal/cmd/core.go @@ -20,11 +20,18 @@ type CoreCmd struct { IsInstalled CoreIsInstalledCmd `cmd:"" name:"is-installed" help:"Check if WP is installed"` } -type CoreVersionCmd struct{} -type CoreCheckUpdateCmd struct{} -type CoreUpdateCmd struct{ Version string `help:"Version to update to"` } -type CoreVerifyChecksumsCmd struct{} -type CoreIsInstalledCmd struct{} +type ( + CoreVersionCmd struct{} + CoreCheckUpdateCmd struct{} + CoreUpdateCmd struct { + Version string `help:"Version to update to"` + } +) + +type ( + CoreVerifyChecksumsCmd struct{} + CoreIsInstalledCmd struct{} +) func (c *CoreVersionCmd) Run(g *Globals) error { rc, err := NewRunContext(g) diff --git a/internal/cmd/cron.go b/internal/cmd/cron.go index 7aef69a..ad68a66 100644 --- a/internal/cmd/cron.go +++ b/internal/cmd/cron.go @@ -23,22 +23,33 @@ type CronEventCmd struct { Delete CronEventDeleteCmd `cmd:"" help:"Delete an event"` } -type CronEventListCmd struct{} -type CronEventRunCmd struct{ Hook string `arg:"" help:"Event hook"` } +type ( + CronEventListCmd struct{} + CronEventRunCmd struct { + Hook string `arg:"" help:"Event hook"` + } +) + type CronEventScheduleCmd struct { Hook string `arg:"" help:"Event hook"` NextRun string `arg:"" help:"Next run time"` Recurrence string `arg:"" optional:"" help:"Recurrence"` } -type CronEventUnscheduleCmd struct{ Hook string `arg:"" help:"Event hook"` } -type CronEventDeleteCmd struct{ Hook string `arg:"" help:"Event hook"` } +type CronEventUnscheduleCmd struct { + Hook string `arg:"" help:"Event hook"` +} +type CronEventDeleteCmd struct { + Hook string `arg:"" help:"Event hook"` +} type CronScheduleCmd struct { List CronScheduleListCmd `cmd:"" help:"List schedules"` } -type CronScheduleListCmd struct{} -type CronTestCmd struct{} +type ( + CronScheduleListCmd struct{} + CronTestCmd struct{} +) func (c *CronEventListCmd) Run(g *Globals) error { rc, err := NewRunContext(g) diff --git a/internal/cmd/db.go b/internal/cmd/db.go index 36c059a..4033c47 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -24,17 +24,27 @@ type DBCmd struct { Reset DBResetCmd `cmd:"" help:"Reset database"` } -type DBExportCmd struct{ File string `arg:"" optional:"" help:"Export file path"` } -type DBImportCmd struct{ File string `arg:"" help:"Import file path"` } -type DBQueryCmd struct{ SQL string `arg:"" help:"SQL query"` } -type DBSearchCmd struct{ Term string `arg:"" help:"Search string"` } -type DBSizeCmd struct{} -type DBTablesCmd struct{} -type DBPrefixCmd struct{} -type DBCheckCmd struct{} -type DBRepairCmd struct{} -type DBOptimizeCmd struct{} -type DBResetCmd struct{} +type DBExportCmd struct { + File string `arg:"" optional:"" help:"Export file path"` +} +type DBImportCmd struct { + File string `arg:"" help:"Import file path"` +} +type DBQueryCmd struct { + SQL string `arg:"" help:"SQL query"` +} +type DBSearchCmd struct { + Term string `arg:"" help:"Search string"` +} +type ( + DBSizeCmd struct{} + DBTablesCmd struct{} + DBPrefixCmd struct{} + DBCheckCmd struct{} + DBRepairCmd struct{} + DBOptimizeCmd struct{} + DBResetCmd struct{} +) func (c *DBExportCmd) Run(g *Globals) error { rc, err := NewRunContext(g) diff --git a/internal/cmd/maintenance.go b/internal/cmd/maintenance.go index 2394858..760760e 100644 --- a/internal/cmd/maintenance.go +++ b/internal/cmd/maintenance.go @@ -8,9 +8,11 @@ type MaintenanceCmd struct { Status MaintenanceStatusCmd `cmd:"" help:"Show maintenance mode status"` } -type MaintenanceActivateCmd struct{} -type MaintenanceDeactivateCmd struct{} -type MaintenanceStatusCmd struct{} +type ( + MaintenanceActivateCmd struct{} + MaintenanceDeactivateCmd struct{} + MaintenanceStatusCmd struct{} +) func (c *MaintenanceActivateCmd) Run(g *Globals) error { return execPassthrough(g, "maintenance-mode", "activate") diff --git a/internal/cmd/media.go b/internal/cmd/media.go index bd2291e..6014a70 100644 --- a/internal/cmd/media.go +++ b/internal/cmd/media.go @@ -15,7 +15,9 @@ type MediaCmd struct { ImageSize MediaImageSizeCmd `cmd:"" name:"image-size" help:"List image sizes"` } -type MediaImportCmd struct{ File string `arg:"" help:"File or URL"` } +type MediaImportCmd struct { + File string `arg:"" help:"File or URL"` +} type MediaRegenerateCmd struct { ID int `arg:"" optional:"" help:"Attachment ID"` All bool `help:"Regenerate all"` diff --git a/internal/cmd/menu.go b/internal/cmd/menu.go index cfe471d..393ed43 100644 --- a/internal/cmd/menu.go +++ b/internal/cmd/menu.go @@ -11,9 +11,16 @@ type MenuCmd struct { Item MenuItemCmd `cmd:"" help:"Manage menu items"` } -type MenuListCmd struct{} -type MenuCreateCmd struct{ Name string `arg:"" help:"Menu name"` } -type MenuDeleteCmd struct{ ID int `arg:"" help:"Menu ID"` } +type ( + MenuListCmd struct{} + MenuCreateCmd struct { + Name string `arg:"" help:"Menu name"` + } +) + +type MenuDeleteCmd struct { + ID int `arg:"" help:"Menu ID"` +} type MenuItemCmd struct { List MenuItemListCmd `cmd:"" help:"List menu items"` AddPost MenuItemAddPostCmd `cmd:"" name:"add-post" help:"Add post to menu"` @@ -22,7 +29,9 @@ type MenuItemCmd struct { Delete MenuItemDeleteCmd `cmd:"" help:"Delete menu item"` } -type MenuItemListCmd struct{ Menu string `arg:"" help:"Menu name or ID"` } +type MenuItemListCmd struct { + Menu string `arg:"" help:"Menu name or ID"` +} type MenuItemAddPostCmd struct { Menu string `arg:"" help:"Menu name or ID"` ID int `arg:"" help:"Post ID"` @@ -32,8 +41,12 @@ type MenuItemAddCustomCmd struct { Title string `arg:"" help:"Link title"` URL string `arg:"" help:"Link URL"` } -type MenuItemUpdateCmd struct{ ID int `arg:"" help:"Item ID"` } -type MenuItemDeleteCmd struct{ ID int `arg:"" help:"Item ID"` } +type MenuItemUpdateCmd struct { + ID int `arg:"" help:"Item ID"` +} +type MenuItemDeleteCmd struct { + ID int `arg:"" help:"Item ID"` +} func (c *MenuListCmd) Run(g *Globals) error { return execPassthrough(g, "menu", "list") diff --git a/internal/cmd/option.go b/internal/cmd/option.go index 29b732a..3746bbd 100644 --- a/internal/cmd/option.go +++ b/internal/cmd/option.go @@ -21,7 +21,9 @@ type OptionCmd struct { Pluck OptionPluckCmd `cmd:"" help:"Pluck option value"` } -type OptionGetCmd struct{ Key string `arg:"" help:"Option name"` } +type OptionGetCmd struct { + Key string `arg:"" help:"Option name"` +} type OptionUpdateCmd struct { Key string `arg:"" help:"Option name"` Value string `arg:"" help:"Option value"` @@ -30,8 +32,12 @@ type OptionAddCmd struct { Key string `arg:"" help:"Option name"` Value string `arg:"" help:"Option value"` } -type OptionDeleteCmd struct{ Key string `arg:"" help:"Option name"` } -type OptionListCmd struct{ Search string `help:"Search pattern"` } +type OptionDeleteCmd struct { + Key string `arg:"" help:"Option name"` +} +type OptionListCmd struct { + Search string `help:"Search pattern"` +} type OptionPatchCmd struct { Action string `arg:"" help:"Patch action"` Key string `arg:"" help:"Option name"` diff --git a/internal/cmd/plugin.go b/internal/cmd/plugin.go index e4eb3ca..9c6d060 100644 --- a/internal/cmd/plugin.go +++ b/internal/cmd/plugin.go @@ -27,32 +27,58 @@ type PluginCmd struct { AutoUpdates PluginAutoUpdatesCmd `cmd:"" name:"auto-updates" help:"Manage auto-updates"` } -type PluginListCmd struct{ StatusFilter string `help:"Filter by status" name:"status"` } +type PluginListCmd struct { + StatusFilter string `help:"Filter by status" name:"status"` +} type PluginInstallCmd struct { Plugin string `arg:"" help:"Plugin slug"` Activate bool `help:"Activate after install"` } -type PluginActivateCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginDeactivateCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginDeleteCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } +type PluginActivateCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginDeactivateCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginDeleteCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} type PluginUpdateCmd struct { Plugin string `arg:"" optional:"" help:"Plugin slug"` All bool `help:"Update all plugins"` } -type PluginSearchCmd struct{ Term string `arg:"" help:"Search term"` } -type PluginGetCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginIsActiveCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginIsInstalledCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginStatusCmd struct{ Plugin string `arg:"" optional:"" help:"Plugin slug"` } -type PluginVerifyChecksumsCmd struct{ Plugin string `arg:"" optional:"" help:"Plugin slug"` } +type PluginSearchCmd struct { + Term string `arg:"" help:"Search term"` +} +type PluginGetCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginIsActiveCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginIsInstalledCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginStatusCmd struct { + Plugin string `arg:"" optional:"" help:"Plugin slug"` +} +type PluginVerifyChecksumsCmd struct { + Plugin string `arg:"" optional:"" help:"Plugin slug"` +} type PluginAutoUpdatesCmd struct { Enable PluginAutoUpdatesEnableCmd `cmd:"" help:"Enable auto-updates"` Disable PluginAutoUpdatesDisableCmd `cmd:"" help:"Disable auto-updates"` Status PluginAutoUpdatesStatusCmd `cmd:"" help:"Show auto-update status"` } -type PluginAutoUpdatesEnableCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginAutoUpdatesDisableCmd struct{ Plugin string `arg:"" help:"Plugin slug"` } -type PluginAutoUpdatesStatusCmd struct{ Plugin string `arg:"" optional:"" help:"Plugin slug"` } +type PluginAutoUpdatesEnableCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginAutoUpdatesDisableCmd struct { + Plugin string `arg:"" help:"Plugin slug"` +} +type PluginAutoUpdatesStatusCmd struct { + Plugin string `arg:"" optional:"" help:"Plugin slug"` +} func (c *PluginListCmd) Run(g *Globals) error { rc, err := NewRunContext(g) diff --git a/internal/cmd/post.go b/internal/cmd/post.go index 9930572..67449e4 100644 --- a/internal/cmd/post.go +++ b/internal/cmd/post.go @@ -19,22 +19,34 @@ type PostCmd struct { Meta PostMetaCmd `cmd:"" help:"Post meta management"` } -type PostListCmd struct{ PostType string `help:"Post type" name:"post_type"` } -type PostCreateCmd struct{ PostTitle string `help:"Post title" name:"post_title"` } -type PostGetCmd struct{ ID int `arg:"" help:"Post ID"` } -type PostUpdateCmd struct{ ID int `arg:"" help:"Post ID"` } +type PostListCmd struct { + PostType string `help:"Post type" name:"post_type"` +} +type PostCreateCmd struct { + PostTitle string `help:"Post title" name:"post_title"` +} +type PostGetCmd struct { + ID int `arg:"" help:"Post ID"` +} +type PostUpdateCmd struct { + ID int `arg:"" help:"Post ID"` +} type PostDeleteCmd struct { ID int `arg:"" help:"Post ID"` Force bool `help:"Skip trash"` } -type PostExistsCmd struct{ ID int `arg:"" help:"Post ID"` } +type PostExistsCmd struct { + ID int `arg:"" help:"Post ID"` +} type PostMetaCmd struct { List PostMetaListCmd `cmd:"" help:"List post meta"` Get PostMetaGetCmd `cmd:"" help:"Get post meta"` Update PostMetaUpdateCmd `cmd:"" help:"Update post meta"` Delete PostMetaDeleteCmd `cmd:"" help:"Delete post meta"` } -type PostMetaListCmd struct{ ID int `arg:"" help:"Post ID"` } +type PostMetaListCmd struct { + ID int `arg:"" help:"Post ID"` +} type PostMetaGetCmd struct { ID int `arg:"" help:"Post ID"` Key string `arg:"" help:"Meta key"` diff --git a/internal/cmd/rewrite.go b/internal/cmd/rewrite.go index ab175c7..0d58dbf 100644 --- a/internal/cmd/rewrite.go +++ b/internal/cmd/rewrite.go @@ -8,9 +8,13 @@ type RewriteCmd struct { Structure RewriteStructureCmd `cmd:"" help:"Update permalink structure"` } -type RewriteFlushCmd struct{} -type RewriteListCmd struct{} -type RewriteStructureCmd struct{ Permastruct string `arg:"" help:"Permalink structure"` } +type ( + RewriteFlushCmd struct{} + RewriteListCmd struct{} + RewriteStructureCmd struct { + Permastruct string `arg:"" help:"Permalink structure"` + } +) func (c *RewriteFlushCmd) Run(g *Globals) error { return execPassthrough(g, "rewrite", "flush") diff --git a/internal/cmd/role.go b/internal/cmd/role.go index d9c06bd..0b8f11c 100644 --- a/internal/cmd/role.go +++ b/internal/cmd/role.go @@ -9,13 +9,20 @@ type RoleCmd struct { Exists RoleExistsCmd `cmd:"" help:"Check if role exists"` } -type RoleListCmd struct{} -type RoleCreateCmd struct { - Role string `arg:"" help:"Role key"` - DisplayName string `arg:"" help:"Display name"` +type ( + RoleListCmd struct{} + RoleCreateCmd struct { + Role string `arg:"" help:"Role key"` + DisplayName string `arg:"" help:"Display name"` + } +) + +type RoleDeleteCmd struct { + Role string `arg:"" help:"Role key"` +} +type RoleExistsCmd struct { + Role string `arg:"" help:"Role key"` } -type RoleDeleteCmd struct{ Role string `arg:"" help:"Role key"` } -type RoleExistsCmd struct{ Role string `arg:"" help:"Role key"` } func (c *RoleListCmd) Run(g *Globals) error { return execPassthrough(g, "role", "list") diff --git a/internal/cmd/theme.go b/internal/cmd/theme.go index 60cb396..2508bd6 100644 --- a/internal/cmd/theme.go +++ b/internal/cmd/theme.go @@ -25,30 +25,52 @@ type ThemeCmd struct { AutoUpdates ThemeAutoUpdatesCmd `cmd:"" name:"auto-updates" help:"Manage auto-updates"` } -type ThemeListCmd struct{ StatusFilter string `help:"Filter by status" name:"status"` } +type ThemeListCmd struct { + StatusFilter string `help:"Filter by status" name:"status"` +} type ThemeInstallCmd struct { Theme string `arg:"" help:"Theme slug"` Activate bool `help:"Activate after install"` } -type ThemeActivateCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeDeleteCmd struct{ Theme string `arg:"" help:"Theme slug"` } +type ThemeActivateCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeDeleteCmd struct { + Theme string `arg:"" help:"Theme slug"` +} type ThemeUpdateCmd struct { Theme string `arg:"" optional:"" help:"Theme slug"` All bool `help:"Update all themes"` } -type ThemeSearchCmd struct{ Term string `arg:"" help:"Search term"` } -type ThemeGetCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeIsActiveCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeIsInstalledCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeStatusCmd struct{ Theme string `arg:"" optional:"" help:"Theme slug"` } +type ThemeSearchCmd struct { + Term string `arg:"" help:"Search term"` +} +type ThemeGetCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeIsActiveCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeIsInstalledCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeStatusCmd struct { + Theme string `arg:"" optional:"" help:"Theme slug"` +} type ThemeAutoUpdatesCmd struct { Enable ThemeAutoUpdatesEnableCmd `cmd:"" help:"Enable auto-updates"` Disable ThemeAutoUpdatesDisableCmd `cmd:"" help:"Disable auto-updates"` Status ThemeAutoUpdatesStatusCmd `cmd:"" help:"Show auto-update status"` } -type ThemeAutoUpdatesEnableCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeAutoUpdatesDisableCmd struct{ Theme string `arg:"" help:"Theme slug"` } -type ThemeAutoUpdatesStatusCmd struct{ Theme string `arg:"" optional:"" help:"Theme slug"` } +type ThemeAutoUpdatesEnableCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeAutoUpdatesDisableCmd struct { + Theme string `arg:"" help:"Theme slug"` +} +type ThemeAutoUpdatesStatusCmd struct { + Theme string `arg:"" optional:"" help:"Theme slug"` +} func (c *ThemeListCmd) Run(g *Globals) error { rc, err := NewRunContext(g) diff --git a/internal/cmd/transient.go b/internal/cmd/transient.go index 4d45761..7fbd67a 100644 --- a/internal/cmd/transient.go +++ b/internal/cmd/transient.go @@ -15,7 +15,9 @@ type TransientCmd struct { List TransientListCmd `cmd:"" help:"List transients"` } -type TransientGetCmd struct{ Key string `arg:"" help:"Transient key"` } +type TransientGetCmd struct { + Key string `arg:"" help:"Transient key"` +} type TransientSetCmd struct { Key string `arg:"" help:"Transient key"` Value string `arg:"" help:"Transient value"` diff --git a/internal/cmd/user.go b/internal/cmd/user.go index 040523e..f632a04 100644 --- a/internal/cmd/user.go +++ b/internal/cmd/user.go @@ -23,28 +23,42 @@ type UserCmd struct { Meta UserMetaCmd `cmd:"" help:"User meta management"` } -type UserListCmd struct{ Role string `help:"Filter by role"` } +type UserListCmd struct { + Role string `help:"Filter by role"` +} type UserCreateCmd struct { Login string `arg:"" help:"User login"` Email string `arg:"" help:"User email"` Role string `help:"User role" default:"subscriber"` } -type UserDeleteCmd struct{ User string `arg:"" help:"User ID or login"` } -type UserGetCmd struct{ User string `arg:"" help:"User ID or login"` } -type UserUpdateCmd struct{ User string `arg:"" help:"User ID or login"` } +type UserDeleteCmd struct { + User string `arg:"" help:"User ID or login"` +} +type UserGetCmd struct { + User string `arg:"" help:"User ID or login"` +} +type UserUpdateCmd struct { + User string `arg:"" help:"User ID or login"` +} type UserSetRoleCmd struct { User string `arg:"" help:"User ID or login"` Role string `arg:"" help:"Role to assign"` } -type UserResetPasswordCmd struct{ User string `arg:"" help:"User ID or login"` } -type UserExistsCmd struct{ User string `arg:"" help:"User ID or login"` } +type UserResetPasswordCmd struct { + User string `arg:"" help:"User ID or login"` +} +type UserExistsCmd struct { + User string `arg:"" help:"User ID or login"` +} type UserMetaCmd struct { List UserMetaListCmd `cmd:"" help:"List user meta"` Get UserMetaGetCmd `cmd:"" help:"Get user meta"` Update UserMetaUpdateCmd `cmd:"" help:"Update user meta"` Delete UserMetaDeleteCmd `cmd:"" help:"Delete user meta"` } -type UserMetaListCmd struct{ User string `arg:"" help:"User ID"` } +type UserMetaListCmd struct { + User string `arg:"" help:"User ID"` +} type UserMetaGetCmd struct { User string `arg:"" help:"User ID"` Key string `arg:"" help:"Meta key"` diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 706a207..90f8b72 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,13 +1,44 @@ package cmd -import "fmt" +import ( + "encoding/json" + "fmt" + "os" + "strings" +) // VersionCmd prints the wpgo version. type VersionCmd struct { Version string `hidden:"" default:"${version}"` + Commit string `hidden:"" default:"${commit}"` + Date string `hidden:"" default:"${date}"` } func (c *VersionCmd) Run(globals *Globals) error { - fmt.Printf("wpgo %s\n", c.Version) + if globals.JSON { + out := map[string]string{ + "version": c.Version, + "commit": c.Commit, + "date": c.Date, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + if globals.Plain { + fmt.Println(strings.Join([]string{c.Version, c.Commit, c.Date}, "\t")) + return nil + } + + fmt.Printf("wpgo %s", c.Version) + if c.Commit != "" { + fmt.Printf(" (%s", c.Commit) + if c.Date != "" { + fmt.Printf(" %s", c.Date) + } + fmt.Printf(")") + } + fmt.Println() return nil } diff --git a/internal/config/config.go b/internal/config/config.go index c31abf5..0acb76e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,12 +9,12 @@ import ( // Config holds the wpgo application configuration. type Config struct { - DefaultFormat string `json:"default_format"` - DefaultSite string `json:"default_site,omitempty"` - RateLimits map[string]RateLimitEntry `json:"rate_limits,omitempty"` - DefaultRateLimit RateLimitEntry `json:"default_rate_limit"` - CacheTTLs CacheTTLConfig `json:"cache_ttls"` - Groups map[string]GroupConfig `json:"groups,omitempty"` + DefaultFormat string `json:"default_format"` + DefaultSite string `json:"default_site,omitempty"` + RateLimits map[string]RateLimitEntry `json:"rate_limits,omitempty"` + DefaultRateLimit RateLimitEntry `json:"default_rate_limit"` + CacheTTLs CacheTTLConfig `json:"cache_ttls"` + Groups map[string]GroupConfig `json:"groups,omitempty"` } // RateLimitEntry configures rate limiting for a canonical host. @@ -63,7 +63,7 @@ func Load(path string) (*Config, error) { // Save writes the config to disk using atomic write. func Save(cfg *Config, path string) error { dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -74,7 +74,7 @@ func Save(cfg *Config, path string) error { data = append(data, '\n') tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0600); err != nil { + if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, path) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ce642e..a7c0845 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -74,7 +74,7 @@ func TestLoad_FromFile(t *testing.T) { "staging": {"aliases": ["stage1", "stage2"]} } }` - if err := os.WriteFile(cfgPath, []byte(content), 0600); err != nil { + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } diff --git a/internal/config/paths.go b/internal/config/paths.go index 83a49fa..24be78e 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -24,7 +24,7 @@ func DefaultPaths() Paths { // EnsureDirs creates all wpgo directories if they don't exist. func (p Paths) EnsureDirs() error { for _, dir := range []string{p.ConfigDir, p.CacheDir, p.DataDir} { - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } } diff --git a/internal/errfmt/errfmt.go b/internal/errfmt/errfmt.go new file mode 100644 index 0000000..191b199 --- /dev/null +++ b/internal/errfmt/errfmt.go @@ -0,0 +1,80 @@ +package errfmt + +import ( + "errors" + "os" + "strings" + + "github.com/alecthomas/kong" +) + +func Format(err error) string { + if err == nil { + return "" + } + + // Handle Kong parse errors with better messaging + var parseErr *kong.ParseError + if errors.As(err, &parseErr) { + return formatParseError(parseErr) + } + + if errors.Is(err, os.ErrNotExist) { + return err.Error() + } + + var userErr *UserFacingError + if errors.As(err, &userErr) { + return userErr.Message + } + + return err.Error() +} + +// UserFacingError forces a specific message, while preserving the underlying cause. +type UserFacingError struct { + Message string + Cause error +} + +func (e *UserFacingError) Error() string { + if e == nil { + return "" + } + + return e.Message +} + +func (e *UserFacingError) Unwrap() error { + if e == nil { + return nil + } + + return e.Cause +} + +func NewUserFacingError(message string, cause error) error { + return &UserFacingError{Message: message, Cause: cause} +} + +// formatParseError enhances Kong parse errors with helpful hints. +func formatParseError(err *kong.ParseError) string { + msg := err.Error() + + // If Kong already provided a suggestion, use it as-is + if strings.Contains(msg, "did you mean") { + return msg + } + + // For unknown flag errors without suggestions, add a help hint + if strings.HasPrefix(msg, "unknown flag") { + return msg + "\nRun with --help to see available flags" + } + + // For missing required flags + if strings.Contains(msg, "missing") || strings.Contains(msg, "required") { + return msg + "\nRun with --help to see usage" + } + + return msg +} diff --git a/internal/registry/metadata.go b/internal/registry/metadata.go index a319b7b..7245042 100644 --- a/internal/registry/metadata.go +++ b/internal/registry/metadata.go @@ -62,7 +62,7 @@ func LoadMetadata(path string) (*Metadata, error) { // (write to temp file, then rename). func SaveMetadata(m *Metadata, path string) error { dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -74,7 +74,7 @@ func SaveMetadata(m *Metadata, path string) error { // Atomic write: temp file + rename. tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0600); err != nil { + if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, path) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index fc0a380..aecf13b 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -9,11 +9,13 @@ import ( "strings" ) +const hostTypeStandard = "standard" + // Registry provides a merged view of SSH config entries + metadata overlay. type Registry struct { - sites map[string]*Site // alias → merged site - sorted []*Site // sorted by alias - canonicalMap map[string]string // alias → "IP:port" + sites map[string]*Site // alias → merged site + sorted []*Site // sorted by alias + canonicalMap map[string]string // alias → "IP:port" userGroups map[string][]string // group name → explicit aliases (from config) } @@ -71,7 +73,7 @@ func NewRegistry(opts RegistryOptions) (*Registry, error) { User: entry.User, IdentityFile: entry.IdentityFile, WPPath: "~/public_html", // sensible default - HostType: "standard", + HostType: hostTypeStandard, Tags: make(map[string]string), } @@ -151,7 +153,7 @@ func (r *Registry) Len() int { // Returns the overlay host_type if explicitly set, otherwise auto-detects. func detectHostType(site *Site) string { // If explicitly set to something other than "auto" and "standard", respect it. - if site.HostType != "" && site.HostType != "auto" && site.HostType != "standard" { + if site.HostType != "" && site.HostType != "auto" && site.HostType != hostTypeStandard { return site.HostType } @@ -161,9 +163,9 @@ func detectHostType(site *Site) string { } // cPanel on Prime VPS. if hostname == "192.0.2.10" { - return "standard" + return hostTypeStandard } - return "standard" + return hostTypeStandard } // resolveCanonicalHost resolves a hostname + port to "IP:port" for rate limiting. diff --git a/internal/safety/tiers.go b/internal/safety/tiers.go index 922cf70..e824c4d 100644 --- a/internal/safety/tiers.go +++ b/internal/safety/tiers.go @@ -37,58 +37,58 @@ func (t SafetyTier) String() string { // readSubcommands are subcommands classified as read-only regardless of parent command. var readSubcommands = map[string]bool{ - "list": true, - "get": true, - "search": true, - "status": true, - "version": true, - "check-update": true, - "verify-checksums": true, - "is-active": true, - "is-installed": true, - "exists": true, - "size": true, - "tables": true, - "prefix": true, - "count": true, - "type": true, - "path": true, - "health": true, - "check": true, - "export": true, - "has": true, - "pluck": true, - "test": true, - "image-size": true, + "list": true, + "get": true, + "search": true, + "status": true, + "version": true, + "check-update": true, + "verify-checksums": true, + "is-active": true, + "is-installed": true, + "exists": true, + "size": true, + "tables": true, + "prefix": true, + "count": true, + "type": true, + "path": true, + "health": true, + "check": true, + "export": true, + "has": true, + "pluck": true, + "test": true, + "image-size": true, } // mutateSubcommands are subcommands classified as state-mutating. var mutateSubcommands = map[string]bool{ - "install": true, - "activate": true, - "deactivate": true, - "update": true, - "create": true, - "set": true, - "add": true, - "flush": true, - "regenerate": true, - "approve": true, - "unapprove": true, - "spam": true, - "unspam": true, - "trash": true, - "untrash": true, - "schedule": true, - "unschedule": true, - "structure": true, - "auto-updates": true, - "set-role": true, + "install": true, + "activate": true, + "deactivate": true, + "update": true, + "create": true, + "set": true, + "add": true, + "flush": true, + "regenerate": true, + "approve": true, + "unapprove": true, + "spam": true, + "unspam": true, + "trash": true, + "untrash": true, + "schedule": true, + "unschedule": true, + "structure": true, + "auto-updates": true, + "set-role": true, "reset-password": true, - "patch": true, - "add-post": true, - "add-custom": true, - "run": true, + "patch": true, + "add-post": true, + "add-custom": true, + "run": true, } // destructiveSubcommands are subcommands classified as potentially irreversible. diff --git a/internal/safety/tiers_test.go b/internal/safety/tiers_test.go index 22474f6..078d574 100644 --- a/internal/safety/tiers_test.go +++ b/internal/safety/tiers_test.go @@ -192,8 +192,8 @@ func TestSafetyTierString(t *testing.T) { func TestCheckBatchSafetyReadAlwaysAllowed(t *testing.T) { // Read tier passes with any combination of flags. for _, tc := range []struct { - yes bool - ack bool + yes bool + ack bool }{ {false, false}, {true, false}, diff --git a/internal/ssh/ratelimit.go b/internal/ssh/ratelimit.go index 2de42e5..a23105c 100644 --- a/internal/ssh/ratelimit.go +++ b/internal/ssh/ratelimit.go @@ -25,7 +25,7 @@ type hostBucket struct { currentDelay time.Duration // Dynamic delay (may increase on errors). nextAllowedAt time.Time // Next earliest start time. timingMu sync.Mutex // Protects nextAllowedAt and currentDelay. - sem chan struct{} // Semaphore for max concurrent connections. + sem chan struct{} // Semaphore for max concurrent connections. } // RateLimiter enforces per-host connection spacing and concurrency limits. diff --git a/internal/wpcli/builder.go b/internal/wpcli/builder.go index 67b417d..1782a67 100644 --- a/internal/wpcli/builder.go +++ b/internal/wpcli/builder.go @@ -8,11 +8,11 @@ import ( // Command builds a wp-cli command string with proper shell escaping. type Command struct { - parts []string - flags map[string]string + parts []string + flags map[string]string boolFlags []string - args []string - format string + args []string + format string } // New creates a new wp-cli command builder. diff --git a/internal/wpcli/types.go b/internal/wpcli/types.go index 0a6bd91..301d060 100644 --- a/internal/wpcli/types.go +++ b/internal/wpcli/types.go @@ -3,17 +3,17 @@ package wpcli // Plugin represents a WordPress plugin as returned by wp plugin list --format=json. type Plugin struct { Name string `json:"name"` - Status string `json:"status"` // active, inactive, must-use, dropin - Update string `json:"update"` // available, none + Status string `json:"status"` // active, inactive, must-use, dropin + Update string `json:"update"` // available, none Version string `json:"version"` UpdateVersion string `json:"update_version,omitempty"` - AutoUpdate string `json:"auto_update"` // on, off + AutoUpdate string `json:"auto_update"` // on, off } // Theme represents a WordPress theme as returned by wp theme list --format=json. type Theme struct { Name string `json:"name"` - Status string `json:"status"` // active, inactive, parent + Status string `json:"status"` // active, inactive, parent Update string `json:"update"` Version string `json:"version"` AutoUpdate string `json:"auto_update"` @@ -25,7 +25,7 @@ type User struct { UserLogin string `json:"user_login"` DisplayName string `json:"display_name"` UserEmail string `json:"user_email"` - Roles string `json:"roles"` // comma-separated + Roles string `json:"roles"` // comma-separated UserRegistered string `json:"user_registered"` }