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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ wpgo setup
| Variable | Description |
|----------|-------------|
| `WPGO_SITE` | Default target site alias |
| `WPGO_LOCAL_BACKUP_DIR` | Local dir for `db export` and `backup` dumps (default `~/wpgo-backups`) |

### Site Registry

Expand Down Expand Up @@ -170,7 +171,9 @@ wpgo -s mysite core verify-checksums
### db -- Database operations

```bash
wpgo -s mysite db export
wpgo -s mysite db export # this machine: ~/wpgo-backups/mysite/mysite_DB_export_<timestamp>_<random>.sql
wpgo -s mysite db export pre-update.sql # this machine: ./pre-update.sql
(umask 077 && wpgo -s mysite db export - > dump.sql) # stdout; see below
wpgo -s mysite db import dump.sql
wpgo -s mysite db query "SELECT COUNT(*) FROM wp_posts"
wpgo -s mysite db size
Expand All @@ -179,6 +182,14 @@ wpgo -s mysite db optimize
wpgo -s mysite db repair
```

`db export` and `backup` run `wp db export -` on the server and stream the dump over SSH to the machine running wpgo. Nothing is written on the server, and wpgo has no option to write one there. The default destination is `${WPGO_LOCAL_BACKUP_DIR:-~/wpgo-backups}/<site>/<site>_DB_<desc>_<timestamp>_<ms>_<random>.sql` (dir 0700, file 0600). wpgo writes to a temp file, fsyncs it and links it into place, then prints the path, byte size and sha256. It never replaces an existing file, including one created while the dump was streaming. If the remote export exits non-zero, the dump is empty, or its last non-empty line does not start with mysqldump's `-- Dump completed`, wpgo deletes the partial file and exits non-zero.

`db export -` applies the same checks and exits non-zero on failure, but it cannot take back bytes already written to stdout, so check the exit status before using the output. Redirect it under `umask 077` so the dump is not world-readable:

```bash
(umask 077 && wpgo -s mysite db export - > dump.sql) || rm -f dump.sql
```

### user -- User management

```bash
Expand Down Expand Up @@ -276,18 +287,38 @@ wpgo -s mysite maintenance disable
wpgo -s mysite eval "echo get_option('siteurl');"
```

`eval` runs any PHP, so it can write files on the server. Take backups and exports with `wpgo db export`, never with `eval`.

### raw -- Pass-through to wp-cli

```bash
wpgo -s mysite raw "wp option list"
wpgo -s mysite raw -- option list
wpgo -s mysite raw -- post meta update 7 title "'Spring cleaning tips'"
```

Put wp-cli arguments after `--`. `raw` joins them with spaces and the server shell parses that line, so quote any argument that holds spaces or shell characters, in single quotes. Before connecting, `raw` refuses:

- shell syntax outside single quotes: `;`, `&`, `|`, `<`, `>`, a newline, `(`, `)`, `{`, `}`, a leading `#`, `$` or a backtick (also inside double quotes), a line continuation, an unbalanced quote, the glob characters `*`, `?` and `[`, or a `~` at the start of a word or after `=` or `:`. Quote or backslash-escape them to pass them literally;
- `db export` (or `db dump`) with a file argument other than `-`, with no file argument, with a flag outside a short mysqldump allowlist (blocks `--result-file`, `--tab`, their prefixes and `--defaults`), or with a `--tables` or `--exclude_tables` entry that starts with `-`. Use `wpgo db export`;
- `export` (the WXR exporter);
- `search-replace` or `db search-replace` with any `--export` flag;
- `db query` whose SQL contains `OUTFILE` or `DUMPFILE` in any case, a mysql client command that runs or writes files (`system`, `tee`, `pager`, `source` or `edit` at the start of a statement, or `\!`, `\T`, `\P`, `\.` or `\e` anywhere, in any case), or the same in an `--execute` value;
- `db query`, `import`, `create`, `drop`, `reset`, `clean`, `check`, `optimize`, `repair`, `tables`, `size`, `columns` and `prefix` with a flag outside a short per-command allowlist. wp-cli hands extra flags to mysql or mysqlcheck, so this blocks `--tee`, `--pager`, `--init-command`, `--execute` (except on `db query`, where its SQL is checked), `--defaults` and `--defaults-*` (`--no-defaults` passes), and their prefixes. `wpgo db query` and `wpgo db import` apply the same checks;
- `db cli` and `db connect`;
- the `--exec` and `--require` globals.

`sql` counts as `db`, and any run of `--` delimiters is skipped. On WP Engine, whose gateway parses each word a second time, `raw` checks that second layer too.

`eval` and `eval-file` are the one exception: `raw` checks only their shell syntax, never their PHP, and allows `--exec` and `--require` with them. PHP can write files on the server, so take backups and exports with `wpgo db export`, never with `eval`.

These checks stop agents and operators from writing dumps on the server by accident or in passing. `eval` and `eval-file` are the deliberate escape hatch. The checks are not a sandbox against an operator who sets out to get around them.

### Shortcut Commands

```bash
wpgo -s mysite health # Full site health check
wpgo -s mysite status # Quick site status overview
wpgo -s mysite backup # Database backup
wpgo -s mysite backup # Database backup streamed to ~/wpgo-backups/mysite/ on this machine
wpgo -s mysite backup "Pre-update snapshot" # Backup with description
wpgo -s mysite update-all -y # Update core + plugins + themes
wpgo -s mysite clear-cache # Full cache clear
Expand Down
5 changes: 5 additions & 0 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package adapter

import (
"context"
"io"
"time"

"github.com/builtbyrobben/wpssh/internal/registry"
Expand All @@ -22,6 +23,10 @@ type Adapter interface {
// The wpCmd should be the wp-cli arguments (e.g., "plugin list --format=json").
Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error)

// ExecStream runs a wp-cli command like Exec but copies its stdout to
// stdout as it arrives; the returned ExecResult has an empty Stdout.
ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error)

// Upload transfers a local file to the remote host.
Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error

Expand Down
6 changes: 6 additions & 0 deletions internal/adapter/standard.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package adapter
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
Expand Down Expand Up @@ -35,6 +36,11 @@ func (a *StandardAdapter) Exec(ctx context.Context, client *internalssh.SSHClien
return client.Exec(ctx, cfg, site.CanonicalHost, wpCmd)
}

// ExecStream runs a pre-built remote shell command and streams its stdout.
func (a *StandardAdapter) ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) {
return client.ExecStream(ctx, siteToClientConfig(site), site.CanonicalHost, wpCmd, stdout)
}

// Upload streams a local file to the remote host via stdin.
// Runs: cat > {remotePath} with the file content piped to stdin.
func (a *StandardAdapter) Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error {
Expand Down
25 changes: 17 additions & 8 deletions internal/adapter/wpengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
"strings"
"time"
Expand Down Expand Up @@ -38,20 +39,28 @@ func (a *WPEngineAdapter) Capabilities() AdapterCapabilities {
// Callers (via wpcli.Command.Build) already include the cd + wp prefix.
// WP Engine's SSH environment places sites at ~/sites/{user}/ when configured.
func (a *WPEngineAdapter) Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error) {
cfg := wpengineClientConfig(site)
execCtx, cancel := wpengineSessionContext(ctx)
defer cancel()
return client.Exec(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd)
}

// ExecStream runs a pre-built remote shell command on WP Engine and streams
// its stdout, within the same session timeout as Exec.
func (a *WPEngineAdapter) ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) {
execCtx, cancel := wpengineSessionContext(ctx)
defer cancel()
return client.ExecStream(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd, stdout)
}

// Enforce WP Engine's 10-minute session timeout.
// wpengineSessionContext enforces WP Engine's 10-minute session timeout.
func wpengineSessionContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := 10 * time.Minute
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < timeout {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

return client.Exec(execCtx, cfg, site.CanonicalHost, wpCmd)
return context.WithTimeout(ctx, timeout)
}

// Upload transfers a local file to WP Engine via stdin piping.
Expand Down
15 changes: 15 additions & 0 deletions internal/cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ func (rc *RunContext) ExecWP(ctx context.Context, site *registry.Site, wpCmd str
return a.Exec(ctx, rc.SSHClient, site, wpCmd)
}

// ExecWPStream executes a wp-cli command on a site and copies its stdout to
// stdout as it arrives. In dry-run mode it prints the command and writes
// nothing.
func (rc *RunContext) ExecWPStream(ctx context.Context, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) {
a := adapter.ForSite(site)
if rc.Globals.Verbose {
fmt.Fprintf(rc.Stderr, "[adapter: %s] %s\n", a.Name(), wpCmd)
}
if rc.Globals.DryRun {
fmt.Fprintf(rc.Stderr, "[dry-run] %s\n", wpCmd)
return internalssh.ExecResult{}, nil
}
return a.ExecStream(ctx, rc.SSHClient, site, wpCmd, stdout)
}

// CacheGet checks the cache for a stored result. Returns the cached data
// string or empty string on miss. Handles nil cache gracefully.
func (rc *RunContext) CacheGet(siteAlias, commandCacheKey string) string {
Expand Down
30 changes: 20 additions & 10 deletions internal/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"time"

"github.com/builtbyrobben/wpssh/internal/cache"
"github.com/builtbyrobben/wpssh/internal/wpcli"
Expand All @@ -25,7 +26,7 @@ type DBCmd struct {
}

type DBExportCmd struct {
File string `arg:"" optional:"" help:"Export file path"`
File string `arg:"" optional:"" help:"Local file on this machine (default $WPGO_LOCAL_BACKUP_DIR/<site>/, else ~/wpgo-backups/<site>/); '-' streams to stdout. Nothing is written on the server."`
}
type DBImportCmd struct {
File string `arg:"" help:"Import file path"`
Expand Down Expand Up @@ -57,22 +58,28 @@ func (c *DBExportCmd) Run(g *Globals) error {
return err
}

builder := wpcli.New("db", "export")
if c.File != "" {
builder.Arg(c.File)
if c.File == "-" {
return exportToStdout(rc, site)
}
result, err := rc.ExecWP(context.Background(), site, builder.Build(site.WPPath))

path := c.File
if path == "" {
if path, err = defaultDumpPath(site.Alias, "export", time.Now()); err != nil {
return err
}
}
dump, err := exportToLocal(rc, site, path)
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("wp db export: %s", result.Stderr)
}
fmt.Fprint(rc.Stdout, result.Stdout)
return nil
return printDump(rc, "Exported to:", dump)
}

func (c *DBImportCmd) Run(g *Globals) error {
// wp-cli reads a file argument that starts with -- as a mysql flag.
if err := checkWPArgv([]string{"db", "import", c.File}); err != nil {
return err
}
rc, err := NewRunContext(g)
if err != nil {
return err
Expand Down Expand Up @@ -101,6 +108,9 @@ func (c *DBImportCmd) Run(g *Globals) error {
}

func (c *DBQueryCmd) Run(g *Globals) error {
if err := checkWPArgv([]string{"db", "query", c.SQL}); err != nil {
return err
}
rc, err := NewRunContext(g)
if err != nil {
return err
Expand Down
Loading
Loading