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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm audit --omit=dev
continue-on-error: true
- run: npm audit --omit=dev --audit-level=high
- run: npm run lint
- run: npm run typecheck
- run: npm run build
Expand Down
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.1.0-beta.1] - 2026-03-26

### Added

- Required params shown in abilities list examples

### Fixed

- Debug redaction, ctrl-c exit code, CI audit
- Test fixtures: `site_id` → `site_id_or_domain` to match real API
- Windows tester feedback

### Changed

- Git Bash promoted as primary Windows recommendation

## [1.0.1] - 2026-03-24

### Added

- Windows/PowerShell guidance and skip links to docs

## [1.0.0] - 2026-03-24

### Added

- CLI binary (`mainwpcontrol`) with oclif command framework
- Commands: `login`, `profile list|use|delete`, `abilities list|info|run`, `jobs watch`, `doctor`, `chat`, `config show`
- Batch operations with polling and timeout
- Shell completion for bash and zsh
- Streaming chat with sliding window context management
- Audit logging for destructive actions
- Centralized secret masking utility
- Actionable recovery hints on error messages
- `--json` output flag and `defaultJsonOutput` setting
- Golden tests and E2E integration tests
- Profile validation in ProfileStore

### Fixed

- Keytar ESM/CJS interop
- Security vulnerabilities from audit (OWASP mapped)
- Exit code handling consistency

### Changed

- Binary renamed from `mainwpctl` to `mainwpcontrol`

### Removed

- Unimplemented `cancelJob` and `listJobs` from BatchManager

[Unreleased]: https://github.com/mainwp/mainwp-control/compare/v1.1.0-beta.1...HEAD
[1.1.0-beta.1]: https://github.com/mainwp/mainwp-control/compare/v1.0.1...v1.1.0-beta.1
[1.0.1]: https://github.com/mainwp/mainwp-control/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/mainwp/mainwp-control/releases/tag/v1.0.0
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A CLI for managing your MainWP Dashboard from the terminal. List sites, push upd

## Quick Start

> **On Windows?** Use [Git Bash](https://gitforwindows.org/) and every example below works without changes.
> **On Windows?** Use [Git Bash](https://gitforwindows.org/) and every example below works without changes. For scheduled workflows (cron), see [WSL](https://learn.microsoft.com/en-us/windows/wsl/install).

You need Node.js 20+ and a MainWP Dashboard (v6+) with an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/).

Expand Down Expand Up @@ -65,10 +65,10 @@ mainwpcontrol abilities run list-updates-v1 --json
**Get details for a specific site:**

```bash
mainwpcontrol abilities run get-site-v1 --input '{"site_id": 1}' --json
mainwpcontrol abilities run get-site-v1 --input '{"site_id_or_domain": 1}' --json
```

> **Windows?** This works as-is in [Git Bash](https://gitforwindows.org/). In PowerShell, escape the inner quotes: `'{\"site_id\": 1}'`. Or skip quoting entirely with `--input-file` ([details](docs/workflows/input-from-file.md)).
> **Windows?** This works as-is in [Git Bash](https://gitforwindows.org/). In PowerShell, escape the inner quotes: `'{\"site_id_or_domain\": 1}'`. Or skip quoting entirely with `--input-file` ([details](docs/workflows/input-from-file.md)).

**Preview a destructive action before running it:**

Expand Down Expand Up @@ -175,10 +175,10 @@ When you pass JSON with `--input`, quoting depends on your shell:

```bash
# macOS / Linux / Git Bash on Windows
mainwpcontrol abilities run get-site-v1 --input '{"site_id": 1}' --json
mainwpcontrol abilities run get-site-v1 --input '{"site_id_or_domain": 1}' --json

# Windows PowerShell
mainwpcontrol abilities run get-site-v1 --input '{\"site_id\": 1}' --json
mainwpcontrol abilities run get-site-v1 --input '{\"site_id_or_domain\": 1}' --json
```

**Git Bash on Windows** (comes with [Git for Windows](https://gitforwindows.org/)) handles quoting the same way macOS and Linux do. If you use Git Bash, all the examples in this documentation work without changes.
Expand Down Expand Up @@ -215,10 +215,10 @@ mainwpcontrol abilities info list-sites-v1
mainwpcontrol abilities run list-sites-v1 --json

# Run with input parameters
mainwpcontrol abilities run get-site-v1 --input '{"site_id": 1}' --json
mainwpcontrol abilities run get-site-v1 --input '{"site_id_or_domain": 1}' --json

# Windows PowerShell: escape inner quotes (Git Bash doesn't need this)
mainwpcontrol abilities run get-site-v1 --input '{\"site_id\": 1}' --json
mainwpcontrol abilities run get-site-v1 --input '{\"site_id_or_domain\": 1}' --json

# Or use a file (works everywhere)
mainwpcontrol abilities run get-site-v1 --input-file params.json --json
Expand Down Expand Up @@ -401,11 +401,11 @@ fi
mainwpcontrol abilities run update-site-plugins-v1 --input-file params.json --json

# From stdin
echo '{"site_id": 1}' | mainwpcontrol abilities run get-site-v1 --input - --json
echo '{"site_id_or_domain": 1}' | mainwpcontrol abilities run get-site-v1 --input - --json

# Heredoc
mainwpcontrol abilities run get-site-v1 --input - --json <<EOF
{"site_id": 1}
{"site_id_or_domain": 1}
EOF
```

Expand Down Expand Up @@ -585,7 +585,7 @@ npm run lint # Check code style
```bash
export MAINWP_API_URL=https://your-dashboard.example.com
export MAINWP_USER=your-admin-username
export MAINWP_APP_PASSWORD=your-application-password
export MAINWP_APP_PASSWORD='your-application-password'

npm run test:live
```
Expand Down
2 changes: 1 addition & 1 deletion docs/workflows/daily-health-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ If the doctor command reports authentication issues, run `mainwpcontrol login` a
Cron runs in a minimal environment and may not have access to your system keychain where MainWP Control stores credentials. If the health check works when you run it manually but fails from cron, you can set the credentials as environment variables directly in your crontab:

```
MAINWP_APP_PASSWORD=your-app-password
MAINWP_APP_PASSWORD='your-app-password'
0 7 * * * /full/path/to/mainwp-health-check.sh
```

Expand Down
2 changes: 1 addition & 1 deletion docs/workflows/monitoring-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ Look at the actual structure and adjust the jq expressions accordingly.
Cron runs in a minimal environment and may not have access to your system keychain where MainWP Control stores credentials. If the script works when you run it manually but fails from cron, set the credentials as environment variables directly in your crontab:

```
MAINWP_APP_PASSWORD=your-app-password
MAINWP_APP_PASSWORD='your-app-password'
*/5 * * * * /full/path/to/mainwp-metrics.sh
```

Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mainwp/control",
"version": "1.0.1",
"version": "1.1.0-beta.1",
"description": "Command-line interface for scripting and automating MainWP Dashboard operations",
"type": "module",
"exports": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/e2e/non-tty-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ describe('E2E: Non-TTY Behavior', () => {
json: false,
quiet: false,
debug: false,
input: '{"site_id": 1}',
input: '{"site_id_or_domain": 1}',
'dry-run': false,
confirm: true,
force: false, // No --force
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/process/abilities-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ describe('abilities info command', () => {
expect(envelope.success).toBe(true);
expect(envelope.data.name).toBe('mainwp/get-site-v1');

// get-site-v1 has site_id as a required input parameter
// get-site-v1 has site_id_or_domain as a required input parameter
expect(envelope.data.inputSchema.type).toBe('object');
expect(envelope.data.inputSchema.properties).toHaveProperty('site_id');
expect(envelope.data.inputSchema.required).toContain('site_id');
expect(envelope.data.inputSchema.properties).toHaveProperty('site_id_or_domain');
expect(envelope.data.inputSchema.required).toContain('site_id_or_domain');
});

// ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/process/abilities-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ describe('abilities list command', () => {
expect(output).toContain('run-updates-v1');
expect(output).toContain('list-updates-v1');
expect(output).toContain('get-site-plugins-v1');

// Abilities with required params show --input in their example
expect(output).toContain('abilities run get-site-v1 --input');
expect(output).toContain('"site_id_or_domain"');

// Abilities without required params show bare command (no --input)
const listSitesLine = output.split('\n').find(
(l: string) => l.includes('abilities run list-sites-v1'),
);
expect(listSitesLine).toBeDefined();
expect(listSitesLine).not.toContain('--input');
});

// ---------------------------------------------------------------------------
Expand Down
20 changes: 10 additions & 10 deletions src/__tests__/process/abilities-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ describe('abilities run', () => {
});

// -------------------------------------------------------------------------
// 2. get-site-v1 --input '{"site_id": 5}' --json → GET with query params
// 2. get-site-v1 --input '{"site_id_or_domain": 5}' --json → GET with query params
// -------------------------------------------------------------------------
describe('get-site-v1 --input (inline JSON)', () => {
it('exits 0 and sends input as query params on a GET request', async () => {
server.setRunResponse('get-site-v1', abilityRunSuccess(singleSitePayload));

const result = await run([
'abilities', 'run', 'get-site-v1',
'--input', '{"site_id": 5}',
'--input', '{"site_id_or_domain": 5}',
'--json',
]);

Expand All @@ -129,7 +129,7 @@ describe('abilities run', () => {
const req = server.getLastRequest('/run');
expect(req).toBeDefined();
expect(req!.method).toBe('GET');
expect(req!.query['input[site_id]']).toBe('5');
expect(req!.query['input[site_id_or_domain]']).toBe('5');
});
});

Expand All @@ -148,7 +148,7 @@ describe('abilities run', () => {
});

it('reads input from the specified file and sends it as query params', async () => {
await writeFile(tmpFile, JSON.stringify({ site_id: 5 }), 'utf-8');
await writeFile(tmpFile, JSON.stringify({ site_id_or_domain: 5 }), 'utf-8');
server.setRunResponse('get-site-v1', abilityRunSuccess(singleSitePayload));

const result = await run([
Expand All @@ -162,36 +162,36 @@ describe('abilities run', () => {
const envelope = result.json as { success: boolean; data: Record<string, unknown> };
expect(envelope.success).toBe(true);

// Verify the server received site_id in query params (GET for readonly)
// Verify the server received site_id_or_domain in query params (GET for readonly)
const req = server.getLastRequest('/run');
expect(req).toBeDefined();
expect(req!.method).toBe('GET');
expect(req!.query['input[site_id]']).toBe('5');
expect(req!.query['input[site_id_or_domain]']).toBe('5');
});
});

// -------------------------------------------------------------------------
// 4. echo '{"site_id": 5}' | mainwpcontrol abilities run get-site-v1 --input - --json
// 4. echo '{"site_id_or_domain": 5}' | mainwpcontrol abilities run get-site-v1 --input - --json
// -------------------------------------------------------------------------
describe('get-site-v1 --input - (stdin pipe)', () => {
it('reads input from stdin and sends it as query params', async () => {
server.setRunResponse('get-site-v1', abilityRunSuccess(singleSitePayload));

const result = await run(
['abilities', 'run', 'get-site-v1', '--input', '-', '--json'],
{ stdin: '{"site_id": 5}' },
{ stdin: '{"site_id_or_domain": 5}' },
);

expect(result.exitCode).toBe(0);

const envelope = result.json as { success: boolean; data: Record<string, unknown> };
expect(envelope.success).toBe(true);

// Verify the server received site_id in query params (GET for readonly)
// Verify the server received site_id_or_domain in query params (GET for readonly)
const req = server.getLastRequest('/run');
expect(req).toBeDefined();
expect(req!.method).toBe('GET');
expect(req!.query['input[site_id]']).toBe('5');
expect(req!.query['input[site_id_or_domain]']).toBe('5');
});
});

Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/process/fixtures/api-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ export const STANDARD_ABILITIES = [
category: 'sites',
input_schema: {
type: 'object',
properties: { site_id: { type: 'integer', description: 'Site ID' } },
required: ['site_id'],
properties: { site_id_or_domain: { type: 'integer', description: 'Site ID or domain' } },
required: ['site_id_or_domain'],
},
}),
mockAbility({
Expand All @@ -110,8 +110,8 @@ export const STANDARD_ABILITIES = [
category: 'sites',
input_schema: {
type: 'object',
properties: { site_id: { type: 'integer', description: 'Site ID' } },
required: ['site_id'],
properties: { site_id_or_domain: { type: 'integer', description: 'Site ID or domain' } },
required: ['site_id_or_domain'],
},
}),
mockAbility({ name: 'mainwp/sync-sites-v1', category: 'sites' }),
Expand All @@ -123,8 +123,8 @@ export const STANDARD_ABILITIES = [
category: 'plugins',
input_schema: {
type: 'object',
properties: { site_id: { type: 'integer', description: 'Site ID' } },
required: ['site_id'],
properties: { site_id_or_domain: { type: 'integer', description: 'Site ID or domain' } },
required: ['site_id_or_domain'],
},
}),
mockAbility({
Expand Down
Loading
Loading