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
44 changes: 44 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# MainWP Control CLI — Environment Variables
# Copy to .env or export in your shell profile.
Comment on lines +1 to +2
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether any CLI/bootstrap path auto-loads `.env` files.
rg -n --glob '!docs/**' --glob '!**/dist/**' --glob '!**/node_modules/**' \
  'dotenv|dotenv/config|--env-file|loadEnvFile' .

Repository: mainwp/mainwp-control

Length of output: 47


🏁 Script executed:

cat -n package.json | head -50

Repository: mainwp/mainwp-control

Length of output: 1854


🏁 Script executed:

find . -name "bin" -o -name "cli.js" -o -name "index.js" -type f 2>/dev/null | grep -v node_modules | grep -v dist | head -20

Repository: mainwp/mainwp-control

Length of output: 70


🏁 Script executed:

rg -n "process\.env|\.env" --type js --type ts --max-count 5 | head -30

Repository: mainwp/mainwp-control

Length of output: 2794


🏁 Script executed:

cat -n bin/run.js

Repository: mainwp/mainwp-control

Length of output: 280


Reword .env.example — the CLI does not auto-load .env files.

Copying to .env will not work; the entrypoint (bin/run.js) uses only oclif's execute function with no dotenv loader. Users following the instruction will populate .env and then encounter missing credentials without understanding why. Either add a dotenv loader to the CLI bootstrap, or reword the template to clarify it's only a reference—credentials must be exported manually or set in CI/shell profiles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 1 - 2, Update the .env example wording or add a
dotenv loader: either modify the top lines of .env.example to explicitly state
that the CLI (bin/run.js using oclif's execute) does not auto-load .env files
and that users must export credentials to their shell or CI, or alternatively
load dotenv in the CLI bootstrap (require('dotenv').config()) before calling
oclif's execute in bin/run.js so .env is read automatically; pick one approach
and ensure the message references bin/run.js, oclif's execute, and the
.env.example header so users are not misled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one. The .env.example header already says "Copy to .env or export in your shell profile." That is standard convention for env example files. Adding a dotenv loader would mean pulling in a new dependency for something oclif does not use, and the current wording is accurate: the CLI does not auto-load .env files. Users who want that can add dotenv themselves.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dennisdornon, that's fair — the "export in your shell profile" phrasing is the standard convention for .env.example files used purely as documentation references, and avoiding a dotenv dependency for a CLI entrypoint that doesn't need it is a reasonable call. I'll note this for future reviews.


✏️ Learnings added
Learnt from: dennisdornon
Repo: mainwp/mainwp-control PR: 1
File: .env.example:1-2
Timestamp: 2026-03-24T00:59:34.367Z
Learning: In mainwp/mainwp-control, the `.env.example` file is intentionally documentation-only. The CLI bootstrap (`bin/run.js`) uses oclif's `execute()` with no dotenv loader, and does not auto-load `.env` files. Users are expected to export variables manually to their shell or CI environment. Adding a dotenv loader is explicitly out of scope. Do not flag the `.env.example` header wording ("Copy to .env or export in your shell profile") as misleading in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: phuochoit
Repo: mainwp/ai-bridge PR: 10
File: backends/mintlify/lib/validation.js:17-19
Timestamp: 2026-01-26T16:51:12.280Z
Learning: In the mainwp/ai-bridge repository, the maintainer (phuochoit) prefers NODE_ENV to default to "development" rather than "production" in backends/mintlify/lib/validation.js.


# ─── Dashboard Credentials ───────────────────────────────────────────
# Application password for Dashboard authentication.
# Required when OS keychain (keytar) is unavailable (CI, containers).
# MAINWP_APP_PASSWORD=

# Set to 1 to skip loading keytar (OS keychain). Useful in CI/containers
# where native modules are unavailable. Falls back to MAINWP_APP_PASSWORD.
# MAINWPCTL_NO_KEYTAR=1

# ─── Network ─────────────────────────────────────────────────────────
# Allow insecure HTTP connections (not recommended for production).
# MAINWP_ALLOW_HTTP=1

# ─── LLM Provider (for chat mode) ────────────────────────────────────
# Provider name: openai, anthropic, gemini, openrouter, local
# MAINWP_LLM_PROVIDER=

# Model override (e.g., gpt-4o, claude-sonnet-4-20250514, gemini-pro)
# MAINWP_LLM_MODEL=

# Generic LLM API key (provider auto-detected). Prefer provider-specific vars below.
# MAINWP_LLM_API_KEY=

# Provider-specific API keys (set the one for your provider):
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=
# OPENROUTER_API_KEY=
# LOCAL_LLM_API_KEY=
# LOCAL_LLM_URL=http://localhost:11434/v1

# ─── Output & Debug ──────────────────────────────────────────────────
# Disable colored output (https://no-color.org/)
# NO_COLOR=1

# Enable debug output
# DEBUG=1

# ─── Paths ───────────────────────────────────────────────────────────
# Override config directory (default: ~/.config/mainwpctl)
# XDG_CONFIG_HOME=
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
node: [20, 22]
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand All @@ -20,6 +20,9 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm audit --omit=dev
continue-on-error: true
- run: npm run lint
- run: npm run typecheck
- run: npm run build
- run: npm test
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

222 changes: 167 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,139 @@
# MainWP Control

Automation and AI workflows for the MainWP Dashboard. The CLI command is `mainwpctl`.
A CLI for managing your MainWP Dashboard from the terminal. The command is `mainwpctl`.

### What You Can Do

- **Site Management**: List sites, check status, sync data, add or remove child sites
- **Update Management**: Preview and apply core, plugin, and theme updates across sites
- **Batch Operations**: Run updates, sync, or reconnect across dozens of sites with `--wait`
- **CI/CD Integration**: Deterministic exit codes, JSON output, and composability with Unix tools
- **Interactive Chat**: Explore abilities through natural conversation (optional, requires LLM key)

Built for WordPress agencies and site managers who automate their MainWP workflows.
You can list sites, check status, push updates, sync data, add or remove child sites, and run batch operations across dozens of sites. Everything outputs structured JSON for piping into other tools. Exit codes are deterministic so CI pipelines can branch on them. There's also an optional chat mode if you want to explore abilities conversationally before scripting them.

---

## When to Use MainWP Control vs MCP Server

Use the **[MainWP MCP Server](https://github.com/mainwp/mainwp-mcp)** for conversational AI managementnatural language queries inside Claude, Cursor, ChatGPT, or any MCP-compatible client. The MCP server excels at exploration, ad-hoc questions, and interactive workflows.
**[MainWP MCP Server](https://github.com/mainwp/mainwp-mcp)** is for conversational AI management: natural language queries inside Claude, Cursor, ChatGPT, or any MCP-compatible client. Good for exploration, ad-hoc questions, and interactive workflows.

Use **MainWP Control** for automated and scripted workflows — cron jobs, CI/CD pipelines, monitoring scripts, and batch operations. `mainwpctl` gives you deterministic exit codes, stable JSON output, and composability with standard Unix tools. Both talk to the same Abilities API with the same safety model.
**MainWP Control** is for automation: cron jobs, CI/CD pipelines, monitoring scripts, and batch operations. `mainwpctl` gives you deterministic exit codes, stable JSON output, and composability with standard Unix tools. Both talk to the same Abilities API with the same safety model.

---

## Quick Start

**Requirements:** Node.js >=20 and MainWP Dashboard 6+ with Abilities API
### Prerequisites

1. **Node.js 20 or later** (the LTS version from [nodejs.org](https://nodejs.org/) is recommended)
2. **A MainWP Dashboard** (version 6+) with the Abilities API enabled
3. **A WordPress Application Password** (not your login password). Create one in WordPress admin under Users > Your Profile > Application Passwords. See the [WordPress Application Passwords guide](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) for details.

### Option A: Standard install (recommended)

This is the best path for most users. Pre-built keychain binaries are included for macOS, Windows, and Linux (x64 and arm64). On other platforms, you may need C++ build tools during installation.

```bash
# Install
npm install -g mainwpctl
# Install globally
npm install -g @mainwp/control

# Authenticate
# Log in (stores credentials in your OS keychain)
mainwpctl login

# List sites (JSON for scripting)
# Verify it works
mainwpctl abilities run list-sites-v1 --json
```

### Option B: Environment variable auth (no keychain)

Use this if keytar fails to build, or in CI, Docker, and headless environments where no OS keychain is available.

```bash
# Install globally
npm install -g @mainwp/control

# Set your Application Password as an env var
export MAINWP_APP_PASSWORD='xxxx xxxx xxxx xxxx xxxx xxxx'

# Log in non-interactively (credentials stay in the environment, not on disk)
mainwpctl login --url https://dashboard.example.com --username admin

# Verify it works
mainwpctl abilities run list-sites-v1 --json
```

> **Tip:** If keytar is installed but broken, set `MAINWPCTL_NO_KEYTAR=1` to skip loading it entirely.

When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` set for each run on CI, cron hosts, and headless servers.

---

<details>
<summary><strong>New to the Command Line?</strong></summary>

If you haven't used a terminal before, here's what you need to know.

### What is a terminal?

A terminal is where you type commands instead of clicking buttons. You'll see it called "command line" or "shell" in different places.

**How to open it:**
- **macOS**: Open **Terminal** (search in Spotlight, or look in Applications > Utilities)
- **Windows**: Open **PowerShell** (search in the Start menu)
- **Linux**: Open your distribution's **Terminal** app (usually in the applications menu)

### What does `npm install -g` do?

`npm` is the Node.js package manager. It downloads and installs JavaScript packages. The `-g` flag installs globally, which makes `mainwpctl` available as a command anywhere on your system, not only in one project folder.

### What is an environment variable?

An environment variable is a named value that programs can read. They're commonly used for passwords and API keys.

**Setting one:**
```bash
# macOS / Linux (lasts until you close the terminal)
export MAINWP_APP_PASSWORD='xxxx xxxx xxxx xxxx xxxx xxxx'

# Windows PowerShell (lasts until you close the window)
$env:MAINWP_APP_PASSWORD = 'xxxx xxxx xxxx xxxx xxxx xxxx'
```

For long-term storage, use the OS keychain (the default when you run `mainwpctl login`) or a restricted-permission `.env` file rather than pasting credentials into shell profile files.

### What is an Application Password?

WordPress Application Passwords let external tools like `mainwpctl` access your site without using your main login password. They look like groups of four characters separated by spaces (e.g., `abcd efgh ijkl mnop qrst uvwx`).

**To create one:** Log into WordPress admin > Users > Your Profile > scroll to **Application Passwords** > enter a name like "mainwpctl" > click **Add New Application Password** > copy the generated password.

### Reading command output

When you run a command, the output appears in your terminal. A few things to know:

- **`--json`** tells `mainwpctl` to output structured JSON (useful for scripting and piping to other tools)
- **Exit codes** indicate success (`0`) or failure (`1` through `5`). You won't see them directly, but scripts and CI use them to decide what happens next. Run `echo $?` (macOS/Linux) or `echo $LASTEXITCODE` (PowerShell) after a command to check.

</details>

---

## Real-World Workflows

Destructive operations in MainWP Control follow a safe two-step pattern preview first, then execute:
We recommend a two-step pattern for destructive operations: preview first, then execute.

```bash
# Step 1: Preview what will be deleted (nothing is modified)
# Step 1: Preview what will be deleted (nothing changes)
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--dry-run --json

# Step 2: Apply after reviewing the preview
# Step 2: Execute after reviewing the preview
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--confirm --force --json
```

Each workflow guide below is fully standalone — it walks you from creating an Application Password through a working result, with every step verified and every concept explained.
Each workflow guide below walks you from creating an Application Password through a working result, with every step verified.

| Workflow | Description |
|----------|-------------|
| [Daily Health Check](docs/workflows/daily-health-check.md) | Cron job that checks site connectivity and alerts via Slack |
| [Plugin Deployment Verification](docs/workflows/plugin-deployment-verification.md) | GitHub Actions workflow to verify a plugin exists across all sites |
| [Monthly Batch Updates](docs/workflows/monthly-batch-updates.md) | Preview and apply updates safely scripted and GitHub Actions variants |
| [Monthly Batch Updates](docs/workflows/monthly-batch-updates.md) | Preview and apply updates safely, scripted and GitHub Actions variants |
| [Input from File](docs/workflows/input-from-file.md) | Pass complex parameters via JSON files, stdin pipes, or heredocs |
| [Monitoring Integration](docs/workflows/monitoring-integration.md) | Send site metrics to Datadog, StatsD, or other monitoring tools |

Expand Down Expand Up @@ -125,15 +197,17 @@ mainwpctl profile use <profile-name>
### Authentication

```bash
# Interactive login
# Interactive login (stores credentials in OS keychain)
mainwpctl login

# Non-interactive login (CI)
# Non-interactive login for CI/containers (password from env var)
export MAINWP_APP_PASSWORD='your-application-password'
mainwpctl login --url https://dashboard.example.com --username admin
```

When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` available to each non-interactive run on CI, cron hosts, and headless servers.
To create an Application Password: log into WordPress admin > Users > Your Profile > Application Passwords > add a new password named "mainwpctl".

When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` set for each non-interactive run on CI, cron hosts, and headless servers.

### Chat Mode

Expand All @@ -148,39 +222,21 @@ mainwpctl chat "list all sites with pending updates"
```

Chat requires one of these environment variables:
- `ANTHROPIC_API_KEY` Anthropic Claude
- `OPENAI_API_KEY` OpenAI GPT
- `GOOGLE_API_KEY` Google Gemini
- `OPENROUTER_API_KEY` OpenRouter
- `LOCAL_LLM_API_KEY` Local endpoint (with optional `LOCAL_LLM_URL`)
- `ANTHROPIC_API_KEY` (Anthropic Claude)
- `OPENAI_API_KEY` (OpenAI GPT)
- `GOOGLE_API_KEY` (Google Gemini)
- `OPENROUTER_API_KEY` (OpenRouter)
- `LOCAL_LLM_API_KEY` (Local endpoint, with optional `LOCAL_LLM_URL`)

Note: In non-TTY environments (pipes, CI), `mainwpctl chat` without a message exits with guidance. Use `mainwpctl chat "message"` for single-message mode in scripts.
In non-TTY environments (pipes, CI), `mainwpctl chat` without a message exits with guidance. Use `mainwpctl chat "message"` for single-message mode in scripts.

---

## Safety Model

Destructive operations follow a two-step pattern:

1. **Preview** with `--dry-run` to see what will change
2. **Execute** with `--confirm` after reviewing the preview

In CI/scripted workflows, you can pass `--confirm --force` directly if you've
already validated the operation.
Destructive operations support a two-step workflow: preview with `--dry-run`, then execute with `--confirm`. Server-side confirmation enforcement is handled by the Abilities REST API. See the [Real-World Workflows](#real-world-workflows) section above for examples.

### Example: Deleting a Site

```bash
# Step 1: Preview what will be deleted
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--dry-run

# Step 2: Confirm deletion
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--confirm
```
In CI/scripted workflows, you can pass `--confirm --force` directly if you've already validated the operation.

---

Expand Down Expand Up @@ -227,7 +283,8 @@ mainwpctl abilities run delete-site-v1 \

| Variable | Description |
|----------|-------------|
| `MAINWP_APP_PASSWORD` | Application password for non-interactive login and for commands when keychain storage is unavailable |
| `MAINWP_APP_PASSWORD` | Application password for non-interactive login and commands when keychain storage is unavailable |
| `MAINWPCTL_NO_KEYTAR` | Set to `1` to skip keytar (keychain) loading entirely |
| `MAINWP_ALLOW_HTTP` | Set to `1` to allow insecure HTTP Dashboard URLs |

### Chat Configuration (optional)
Expand Down Expand Up @@ -291,6 +348,61 @@ source /path/to/mainwpctl/scripts/completions/mainwpctl.zsh

---

## Troubleshooting

<details>
<summary><strong>"keytar failed to build" or native module errors during install</strong></summary>

Keytar requires native C++ compilation on some platforms. If it fails:

1. **Use environment variable auth instead** (bypasses keytar entirely):
```bash
export MAINWP_APP_PASSWORD='your-application-password'
mainwpctl login --url https://dashboard.example.com --username admin
```
2. **Or skip keytar explicitly** by setting `MAINWPCTL_NO_KEYTAR=1` before running commands.

The pre-built binaries cover macOS, Windows, and Linux (x64/arm64). If you're on a different platform or architecture, you'll need C++ build tools (`gcc`, `g++`, `make`) or the env var approach.

</details>

<details>
<summary><strong>"command not found" after install</strong></summary>

This usually means your npm global bin directory isn't in your system PATH.

1. **Find where npm installs global packages:**
```bash
npm config get prefix
```
2. **Add the `bin` subdirectory to your PATH.** For example, if the prefix is `/usr/local`:
```bash
# Add to ~/.bashrc, ~/.zshrc, or your shell profile:
export PATH="/usr/local/bin:$PATH"
```
3. **Restart your terminal** (or run `source ~/.zshrc` / `source ~/.bashrc`) and try again.

On Windows, the npm global directory is usually already in PATH after installing Node.js.

</details>

<details>
<summary><strong>"connection refused" or network errors</strong></summary>

If `mainwpctl login` or commands fail with connection errors:

1. **Check the Dashboard URL.** Make sure it's the full URL with `https://` (e.g., `https://dashboard.example.com`). Don't include a trailing slash.
2. **Verify HTTPS.** `mainwpctl` requires HTTPS by default. If your Dashboard uses HTTP (not recommended), set `MAINWP_ALLOW_HTTP=1`.
3. **Check firewall/network.** Make sure your machine can reach the Dashboard:
```bash
curl -I https://dashboard.example.com
```
4. **SSL certificate issues.** If using a self-signed certificate, you can use `mainwpctl login --skip-ssl-verify` (not recommended for production).

</details>

---

## Contributing

```bash
Expand All @@ -313,10 +425,10 @@ npm run test:live
```

The live suite includes:
- **API tests** login, abilities discovery, read-only execution, safety model, exit codes
- **Workflow doc tests** validates that every jq expression, field name, and data pipeline documented in `docs/workflows/` works against the real API
- **API tests**: login, abilities discovery, read-only execution, safety model, exit codes
- **Workflow doc tests**: validates that every jq expression, field name, and data pipeline documented in `docs/workflows/` works against the real API

Live tests are safe: they only run read-only operations and `--dry-run` previews never mutations.
Live tests are safe: they only run read-only operations and `--dry-run` previews, never mutations.

---

Expand Down
12 changes: 12 additions & 0 deletions bin/_exit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Shared entrypoint setup: SIGPIPE handling and clean exit for native addons.

// SIGPIPE: exit cleanly when piped to `head`, `grep -q`, etc.
process.on('SIGPIPE', () => process.exit(0));

// Force exit to prevent native addon handles (e.g. keytar) from keeping
// the process alive. Drain stdout/stderr first to avoid truncating piped output.
const drain = (s) => new Promise((resolve) => s.write('', resolve));
export async function drainAndExit() {
await Promise.all([drain(process.stdout), drain(process.stderr)]);
process.exit(process.exitCode ?? 0);
}
Loading
Loading