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
- 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. - A semaphore bounded by
--parallellimits how many run at once. The default is 10, except where a command sets its own:backup generateandlogs archivedefault lower,updateandscreenshotdefault to 5. - Each site is run as a separate child
captaincoreprocess with the same flags plus--captain-id=<id>and the site name appended. - 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. - 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