Docs CLI

Running Commands

How to run SSH commands, scripts, recipes and updates against one site or the whole fleet, and how bulk concurrency works.

Everything CaptainCore does on a managed site ultimately goes over SSH. This page covers the commands that put you in that path directly, plus the bulk runner that fans them out across the fleet.

ssh

captaincore ssh <site>... [--command=<commands>] [--script=<name|file>] [flags...]
Flag Description
-c, --command string WP-CLI command or script to run directly
-s, --script string Run a built-in script file
-r, --recipe string Run a built-in or custom defined recipe
-d, --debug Preview ssh command

With no flags you get an interactive shell. ssh disables Cobra flag parsing, so unknown flags are collected and passed through to the remote script or recipe:

captaincore ssh mysite --script=fetch-error-log-size --human-readable

The command builds an SSH invocation from the environment record: username, address, port, and the SSH key from <path_keys>/<captain-id>/<key>. Sites configured with use_password go through sshpass instead. Any environment variables stored on the site are exported first, then the shell changes into the site’s home directory (public/ for Kinsta, sites/* for WP Engine). Every remote invocation is suffixed with || captaincore site ssh-fail <site> so a broken connection flags itself.

The environment must use the sftp protocol, otherwise ssh refuses.

--debug prints the assembled command instead of running it, which is the fastest way to see exactly what a site will receive.

How –script pipes over stdin

Remote scripts are not copied to the site. The local file is redirected into the SSH session’s stdin and read by bash -s:

ssh <options> user@host "cd public/ && bash -s -- --site=<site> <extra flags>" < <script file>

That means editing a file in lib/remote-scripts/ takes effect on the next run with no rebuild, no deploy, and nothing left behind on the site. The site slug is always passed as --site=, and any pass-through flags are shell-quoted and appended.

--script accepts a path. If the path does not exist on disk it falls back to ~/.captaincore/lib/remote-scripts/<name>. If neither resolves, the command prints Error: Can't locate script <name> and stops. See remote scripts for the full catalogue.

--recipe works the same way, except the fallback path is <path_recipes>/<captain-id>-<recipe>.sh.

When --label is active, the remote command is wrapped in ____CC_OUTPUT_START____ and ____CC_OUTPUT_END____ markers so the SSH banner and MOTD can be stripped from the captured output.

run

captaincore run <site> --code=<base64-encoded-code>
Flag Description
-c, --code string WP-CLI command or script to run directly
-d, --debug Debug mode

run base64-decodes --code onto disk as a dated recipe file under path_recipes, named <captain-id>-<YYYY-MM-DD>-<first 8 chars of the encoded code>.sh, then invokes captaincore ssh <site> --recipe=<name>. Decoding happens via PHP writing straight to the file rather than through a bash string, so backslash-newline continuations and $ characters in the script survive intact.

captaincore run mysite --code=$(echo 'wp plugin list --status=active' | base64)

bulk

captaincore bulk <command> <target> [<arguments>]
Flag Description
-p, --parallel int Number of sites to run at same time (default 10)
-d, --debug Debug mode
--label Print colored site name headers
--fleet Fleet mode
--captain-id string Captain ID
--config string Config file

Unknown flags are passed through to the sub-command:

captaincore bulk ssh @production --command="wp option get home"

You rarely need bulk explicitly. Most commands detect a target group or more than one site argument and switch to the bulk runner on their own, so captaincore update @production and captaincore bulk update @production do the same thing.

How concurrency works

  1. Targets are resolved against the local database into a concrete list of <slug>-<environment> names, deduplicated and sorted. Explicit site lists are used as given.
  2. A semaphore bounded by --parallel limits how many run at once. The default is 10, except where a command sets its own: backup generate and logs archive default lower, update and screenshot default to 5.
  3. Each site is run as a separate child captaincore process with the same flags plus --captain-id=<id> and the site name appended.
  4. Child processes get CC_BULK_RUNNING=true. If a child would itself start a bulk run it aborts with a recursion error. There is a matching in-process guard.
  5. Without --label, child stdout and stderr stream straight through, so output from different sites interleaves. With --label, output is captured per site, the SSH banner is stripped using the output markers, blank lines are removed, and the result is printed under a green == site == header under a mutex.

In fleet mode the whole bulk run is repeated once per captain ID.

captaincore ssh @all --script=update-core is special-cased: it prints one line per site, dumps output only for failures, prints a summary with elapsed time, stores the run, and emails a failure summary. It exits non-zero if any site failed.

progress

captaincore progress [--clean] [--format=json]

Each bulk run writes ~/.captaincore/data/progress/<pid>.json with the command, total site count, PID, start time, captain ID, parallelism and arguments, plus a <pid>.log that gains one site exitcode timestamp line per completed site. Both files are removed when the run ends.

progress reads those files and reports completed and failed counts, percentage, elapsed time, parallelism and an ETA extrapolated from the average time per site so far. Runs whose PID is no longer alive are marked stale; --clean deletes their leftover files.

captaincore progress
# backup/generate: 412/1180 (34.9%) - running for 22m (parallel: 5) - eta 41m

recipe

captaincore recipe add <recipe> [--format=<format>]

--format=base64 is the supported input format. The argument is a base64-encoded recipe JSON object. It is stored in the local database and written to <path_recipes>/<captain-id>-<recipe-id>.sh, which is the file captaincore ssh --recipe=<recipe-id> later reads.

captaincore recipe add "$(cat recipe.json | base64)" --format=base64

script

captaincore script list

Lists every file in ~/.captaincore/lib/remote-scripts/ with a one-line description parsed from its header. A # Description: <text> line always wins; otherwise the first meaningful comment line after the shebang is used.

captaincore script list

update

captaincore update <site>
Flag Description
-d, --debug Debug mode. No updates will run
-p, --parallel int Number of sites to update at same time (default 5)
--skip-if-recent string Skip if updated within timeframe, for example 24h
--dry-run Preview which environments would be updated without executing

Runs theme, plugin and core updates on a site. --dry-run short-circuits into a preview of which environments would be touched, without connecting.

captaincore update @production --parallel=10 --skip-if-recent=24h

upload

captaincore upload <site> <file>
Flag Description
-p, --public Uploads to public. Defaults to private folder
captaincore upload mysite-production ./patch.zip

migrate

captaincore migrate wp-to-sqlite

A one-time migration: it reads site, environment and account data from the WordPress API, inserts it into the local SQLite database through GORM, and verifies the row counts. This is a CLI data migration, not a site migration.

Migrating an actual WordPress site from a backup URL is done with the migrate remote script:

captaincore ssh newsite --script=migrate --url=https://example.com/backup.zip

activate and deactivate

captaincore activate <site>
captaincore deactivate <site> [--name=<business-name>] [--link=<business-link>]

deactivate deploys a custom mu-plugin that takes the site offline behind a branded holding page. activate removes it.

Flag Description
--name string Business name to show on deactivate page
--link string Business link to show on deactivate page
--subject string Heading/Subject to show on deactivate page
--status string Status message to show on deactivate page
--action string Action text to show on deactivate page
captaincore deactivate mysite-production --name="Anchor Hosting" --link="https://anchor.host"

plugin-zip

captaincore plugin-zip <site> <plugin>

Generates plugin zips on a site, which is how a bespoke plugin gets pulled off a server for review or redeployment.

captaincore plugin-zip mysite-production my-custom-plugin

default-sync

captaincore default-sync [--debug]

Calls default-get on the Manager API, prints the response, and stores the returned defaults in the local configuration table. Those defaults are what captaincore site deploy-defaults later pushes to a site.

captaincore default-sync