plugship 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/bin/plugship.js +3 -0
- package/package.json +44 -0
- package/src/cli.js +77 -0
- package/src/commands/deploy.js +16 -0
- package/src/commands/ignore.js +70 -0
- package/src/commands/init.js +107 -0
- package/src/commands/sites/list.js +23 -0
- package/src/commands/sites/remove.js +23 -0
- package/src/commands/sites/set-default.js +12 -0
- package/src/commands/status.js +89 -0
- package/src/lib/config.js +71 -0
- package/src/lib/constants.js +38 -0
- package/src/lib/deployer.js +164 -0
- package/src/lib/errors.js +36 -0
- package/src/lib/logger.js +22 -0
- package/src/lib/plugin-detector.js +67 -0
- package/src/lib/wordpress-api.js +101 -0
- package/src/lib/zipper.js +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shamim0902
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# plugship
|
|
2
|
+
|
|
3
|
+
A CLI tool to deploy local WordPress plugins to remote WordPress sites.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 18+
|
|
8
|
+
- A WordPress site with REST API enabled
|
|
9
|
+
- An Administrator account with an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g plugship
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
### 1. Install the Receiver Plugin
|
|
20
|
+
|
|
21
|
+
The `plugship-receiver` companion plugin must be installed on your WordPress site. It adds a REST endpoint that accepts plugin ZIP uploads.
|
|
22
|
+
|
|
23
|
+
1. Get [plugship-receiver](https://github.com/plugship/plugship-receiver) and copy `plugship-receiver.php` to your site's `wp-content/plugins/` directory
|
|
24
|
+
2. Activate **PlugShip Receiver** from the WordPress admin Plugins page
|
|
25
|
+
|
|
26
|
+
### 2. Create an Application Password
|
|
27
|
+
|
|
28
|
+
1. Go to **Users > Profile** in WordPress admin
|
|
29
|
+
2. Scroll to **Application Passwords**
|
|
30
|
+
3. Enter a name (e.g. "plugship") and click **Add New Application Password**
|
|
31
|
+
4. Copy the generated password
|
|
32
|
+
|
|
33
|
+
### 3. Configure a Site
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
plugship init
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You will be prompted for:
|
|
40
|
+
|
|
41
|
+
- **Site alias** — a short name for this site (e.g. "staging")
|
|
42
|
+
- **Site URL** — your WordPress site URL (e.g. `https://example.com`)
|
|
43
|
+
- **Username** — your WordPress admin username
|
|
44
|
+
- **Application password** — the password from step 2
|
|
45
|
+
|
|
46
|
+
The command will verify the connection, credentials, and receiver plugin status.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Deploy a Plugin
|
|
51
|
+
|
|
52
|
+
Navigate to your WordPress plugin directory and run:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
plugship deploy
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This will:
|
|
59
|
+
|
|
60
|
+
1. Detect the plugin from PHP file headers
|
|
61
|
+
2. Create a ZIP archive in the `build/` directory
|
|
62
|
+
3. Upload and install the plugin on the remote site
|
|
63
|
+
4. Activate the plugin
|
|
64
|
+
|
|
65
|
+
If you have multiple sites configured, you will be prompted to select one.
|
|
66
|
+
|
|
67
|
+
#### Options
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
plugship deploy --site <name> # Deploy to a specific site
|
|
71
|
+
plugship deploy --no-activate # Deploy without activating the plugin
|
|
72
|
+
plugship deploy --dry-run # Preview what would be deployed without uploading
|
|
73
|
+
plugship deploy --all # Deploy to all configured sites
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Check Site Status
|
|
77
|
+
|
|
78
|
+
Verify connection, credentials, and receiver plugin before deploying:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
plugship status # Check default or select a site
|
|
82
|
+
plugship status --site <name> # Check a specific site
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Manage Sites
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
plugship sites list # List all saved sites
|
|
89
|
+
plugship sites remove <name> # Remove a saved site
|
|
90
|
+
plugship sites set-default <name> # Set the default site
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
94
|
+
|
|
95
|
+
| Command | Description |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `plugship init` | Configure a new WordPress site |
|
|
98
|
+
| `plugship deploy` | Deploy the plugin from the current directory |
|
|
99
|
+
| `plugship deploy --dry-run` | Preview deploy without uploading |
|
|
100
|
+
| `plugship deploy --all` | Deploy to all configured sites |
|
|
101
|
+
| `plugship status` | Check site connection and receiver status |
|
|
102
|
+
| `plugship sites list` | List all saved sites |
|
|
103
|
+
| `plugship sites remove <name>` | Remove a saved site |
|
|
104
|
+
| `plugship sites set-default <name>` | Set the default site |
|
|
105
|
+
| `plugship ignore` | Create `.plugshipignore` with default template |
|
|
106
|
+
| `plugship ignore <patterns...>` | Add patterns to `.plugshipignore` |
|
|
107
|
+
| `plugship --help` | Show help |
|
|
108
|
+
| `plugship --version` | Show version |
|
|
109
|
+
|
|
110
|
+
## Plugin Detection
|
|
111
|
+
|
|
112
|
+
The CLI detects your plugin by scanning `.php` files in the current directory for a standard WordPress plugin header:
|
|
113
|
+
|
|
114
|
+
```php
|
|
115
|
+
<?php
|
|
116
|
+
/**
|
|
117
|
+
* Plugin Name: My Plugin
|
|
118
|
+
* Version: 1.0.0
|
|
119
|
+
* Text Domain: my-plugin
|
|
120
|
+
*/
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The `Text Domain` is used as the plugin slug. If not provided, the slug is derived from the plugin name.
|
|
124
|
+
|
|
125
|
+
## Ignoring Files
|
|
126
|
+
|
|
127
|
+
Use the `ignore` command to create a `.plugshipignore` file with a default template:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
plugship ignore
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or add specific patterns directly:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
plugship ignore "src/**" "*.map" composer.json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
You can also manually create or edit `.plugshipignore` in your plugin directory to exclude files and folders from the deployment ZIP:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
# .plugshipignore
|
|
143
|
+
src/**
|
|
144
|
+
assets/scss/**
|
|
145
|
+
webpack.config.js
|
|
146
|
+
package.json
|
|
147
|
+
package-lock.json
|
|
148
|
+
composer.json
|
|
149
|
+
composer.lock
|
|
150
|
+
*.map
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- One pattern per line
|
|
154
|
+
- Lines starting with `#` are comments
|
|
155
|
+
- Blank lines are ignored
|
|
156
|
+
- Supports `dir/**` (directory and contents), `*.ext` (extension match), and exact names
|
|
157
|
+
|
|
158
|
+
The following are always excluded by default:
|
|
159
|
+
|
|
160
|
+
`node_modules`, `.git`, `.DS_Store`, `.env`, `*.log`, `.vscode`, `.idea`, `tests`, `phpunit.xml`, `.phpunit.result.cache`, `.github`, `build`
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
Site credentials are stored in `~/.plugship/config.json` with `0600` file permissions. The config file looks like:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"defaultSite": "staging",
|
|
169
|
+
"sites": {
|
|
170
|
+
"staging": {
|
|
171
|
+
"url": "https://staging.example.com",
|
|
172
|
+
"username": "admin",
|
|
173
|
+
"appPassword": "xxxx xxxx xxxx xxxx"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## How It Works
|
|
180
|
+
|
|
181
|
+
The WordPress REST API does not support direct ZIP upload for plugin installation. The `plugship-receiver` companion plugin adds two custom endpoints:
|
|
182
|
+
|
|
183
|
+
- `GET /wp-json/plugship/v1/status` — Health check
|
|
184
|
+
- `POST /wp-json/plugship/v1/deploy` — Accepts a ZIP file and installs it using WordPress's built-in `Plugin_Upgrader` with `overwrite_package => true`
|
|
185
|
+
|
|
186
|
+
If the plugin already exists on the site, it is replaced with the uploaded version.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/bin/plugship.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plugship",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deploy local WordPress plugins to remote sites from the command line",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"plugship": "bin/plugship.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/shamim0902/plugship.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/shamim0902/plugship#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/shamim0902/plugship/issues"
|
|
25
|
+
},
|
|
26
|
+
"author": "shamim0902",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"wordpress",
|
|
29
|
+
"deploy",
|
|
30
|
+
"plugin",
|
|
31
|
+
"cli",
|
|
32
|
+
"wp",
|
|
33
|
+
"plugship"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"archiver": "^7.0.1",
|
|
38
|
+
"chalk": "^5.3.0",
|
|
39
|
+
"commander": "^12.1.0",
|
|
40
|
+
"@inquirer/prompts": "^7.2.1",
|
|
41
|
+
"form-data": "^4.0.1",
|
|
42
|
+
"ora": "^8.1.1"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
const program = new Command();
|
|
4
|
+
|
|
5
|
+
program
|
|
6
|
+
.name('plugship')
|
|
7
|
+
.description('Deploy local WordPress plugins to remote sites')
|
|
8
|
+
.version('1.0.0');
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.command('init')
|
|
12
|
+
.description('Configure a WordPress site for deployment')
|
|
13
|
+
.action(async () => {
|
|
14
|
+
const { initCommand } = await import('./commands/init.js');
|
|
15
|
+
await initCommand();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('deploy')
|
|
20
|
+
.description('Deploy the plugin from the current directory')
|
|
21
|
+
.option('--site <name>', 'Target site alias')
|
|
22
|
+
.option('--no-activate', 'Skip plugin activation after deploy')
|
|
23
|
+
.option('--dry-run', 'Preview deploy without uploading')
|
|
24
|
+
.option('--all', 'Deploy to all configured sites')
|
|
25
|
+
.action(async (options) => {
|
|
26
|
+
const { deployCommand } = await import('./commands/deploy.js');
|
|
27
|
+
await deployCommand(options);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command('status')
|
|
32
|
+
.description('Check connection and receiver status for a site')
|
|
33
|
+
.option('--site <name>', 'Target site alias')
|
|
34
|
+
.action(async (options) => {
|
|
35
|
+
const { statusCommand } = await import('./commands/status.js');
|
|
36
|
+
await statusCommand(options);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('ignore [patterns...]')
|
|
41
|
+
.description('Create .plugshipignore or add patterns to it')
|
|
42
|
+
.action(async (patterns) => {
|
|
43
|
+
const { ignoreCommand } = await import('./commands/ignore.js');
|
|
44
|
+
await ignoreCommand(patterns);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const sites = program
|
|
48
|
+
.command('sites')
|
|
49
|
+
.description('Manage saved WordPress sites');
|
|
50
|
+
|
|
51
|
+
sites
|
|
52
|
+
.command('list')
|
|
53
|
+
.description('List all saved sites')
|
|
54
|
+
.action(async () => {
|
|
55
|
+
const { listSitesCommand } = await import('./commands/sites/list.js');
|
|
56
|
+
await listSitesCommand();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
sites
|
|
60
|
+
.command('remove <name>')
|
|
61
|
+
.description('Remove a saved site')
|
|
62
|
+
.action(async (name) => {
|
|
63
|
+
const { removeSiteCommand } = await import('./commands/sites/remove.js');
|
|
64
|
+
await removeSiteCommand(name);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
sites
|
|
68
|
+
.command('set-default <name>')
|
|
69
|
+
.description('Set the default site')
|
|
70
|
+
.action(async (name) => {
|
|
71
|
+
const { setDefaultCommand } = await import('./commands/sites/set-default.js');
|
|
72
|
+
await setDefaultCommand(name);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export async function run() {
|
|
76
|
+
await program.parseAsync(process.argv);
|
|
77
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { deploy } from '../lib/deployer.js';
|
|
2
|
+
import * as logger from '../lib/logger.js';
|
|
3
|
+
|
|
4
|
+
export async function deployCommand(options) {
|
|
5
|
+
try {
|
|
6
|
+
await deploy({
|
|
7
|
+
siteName: options.site,
|
|
8
|
+
activate: options.activate,
|
|
9
|
+
dryRun: options.dryRun,
|
|
10
|
+
all: options.all,
|
|
11
|
+
});
|
|
12
|
+
} catch (err) {
|
|
13
|
+
logger.error(err.message);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
3
|
+
import * as logger from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
const IGNORE_FILE = '.plugshipignore';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TEMPLATE = `# .plugshipignore
|
|
8
|
+
# Add patterns here to exclude files from the deployment ZIP.
|
|
9
|
+
# One pattern per line. Supports dir/**, *.ext, and exact names.
|
|
10
|
+
#
|
|
11
|
+
# The following are always excluded by default (no need to list them):
|
|
12
|
+
# node_modules, .git, .github, .DS_Store, .env, *.log,
|
|
13
|
+
# .vscode, .idea, tests, phpunit.xml, build
|
|
14
|
+
|
|
15
|
+
# Source files (uncomment as needed)
|
|
16
|
+
# src/**
|
|
17
|
+
# *.map
|
|
18
|
+
|
|
19
|
+
# Build tools
|
|
20
|
+
package.json
|
|
21
|
+
package-lock.json
|
|
22
|
+
composer.json
|
|
23
|
+
composer.lock
|
|
24
|
+
webpack.config.js
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export async function ignoreCommand(patterns) {
|
|
28
|
+
const filePath = join(process.cwd(), IGNORE_FILE);
|
|
29
|
+
|
|
30
|
+
// No patterns provided — create template file
|
|
31
|
+
if (patterns.length === 0) {
|
|
32
|
+
try {
|
|
33
|
+
await access(filePath);
|
|
34
|
+
logger.info(`${IGNORE_FILE} already exists.`);
|
|
35
|
+
} catch {
|
|
36
|
+
await writeFile(filePath, DEFAULT_TEMPLATE);
|
|
37
|
+
logger.success(`Created ${IGNORE_FILE} with default template.`);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Append patterns to existing file or create new one
|
|
43
|
+
let existing = '';
|
|
44
|
+
try {
|
|
45
|
+
existing = await readFile(filePath, 'utf-8');
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist yet
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const existingPatterns = new Set(
|
|
51
|
+
existing.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const newPatterns = patterns.filter((p) => !existingPatterns.has(p));
|
|
55
|
+
|
|
56
|
+
if (newPatterns.length === 0) {
|
|
57
|
+
logger.info('All patterns already in .plugshipignore.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const content = existing
|
|
62
|
+
? existing.trimEnd() + '\n' + newPatterns.join('\n') + '\n'
|
|
63
|
+
: newPatterns.join('\n') + '\n';
|
|
64
|
+
|
|
65
|
+
await writeFile(filePath, content);
|
|
66
|
+
|
|
67
|
+
for (const p of newPatterns) {
|
|
68
|
+
logger.success(`Added: ${p}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { input, password } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { addSite } from '../lib/config.js';
|
|
4
|
+
import { WordPressApi } from '../lib/wordpress-api.js';
|
|
5
|
+
import { RECEIVER_DOWNLOAD_URL } from '../lib/constants.js';
|
|
6
|
+
import * as logger from '../lib/logger.js';
|
|
7
|
+
|
|
8
|
+
export async function initCommand() {
|
|
9
|
+
console.log(chalk.bold('\nConfigure a WordPress site for deployment\n'));
|
|
10
|
+
|
|
11
|
+
const name = await input({
|
|
12
|
+
message: 'Site alias (e.g. "my-site"):',
|
|
13
|
+
validate: (v) => (v.trim() ? true : 'Required'),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const url = await input({
|
|
17
|
+
message: 'WordPress site URL:',
|
|
18
|
+
validate: (v) => {
|
|
19
|
+
if (!v.trim()) return 'Required';
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(v);
|
|
22
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
23
|
+
return 'URL must start with https:// or http://';
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return 'Invalid URL';
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const username = await input({
|
|
33
|
+
message: 'WordPress username:',
|
|
34
|
+
validate: (v) => (v.trim() ? true : 'Required'),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const appPassword = await password({
|
|
38
|
+
message: 'Application password:',
|
|
39
|
+
mask: '*',
|
|
40
|
+
validate: (v) => (v.trim() ? true : 'Required'),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const siteUrl = url.replace(/\/+$/, '');
|
|
44
|
+
const api = new WordPressApi({ url: siteUrl, username, appPassword });
|
|
45
|
+
|
|
46
|
+
// Test connection
|
|
47
|
+
const spin = logger.spinner('Testing connection to WordPress REST API...');
|
|
48
|
+
spin.start();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await api.testConnection();
|
|
52
|
+
spin.succeed('REST API is accessible');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
spin.fail('Cannot reach WordPress REST API');
|
|
55
|
+
logger.error(`Make sure ${siteUrl}/wp-json/ is accessible.\n ${err.message}`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Test authentication
|
|
61
|
+
spin.start('Verifying credentials...');
|
|
62
|
+
try {
|
|
63
|
+
const user = await api.testAuth();
|
|
64
|
+
const caps = user.capabilities || {};
|
|
65
|
+
if (!caps.install_plugins) {
|
|
66
|
+
spin.fail('User does not have the "install_plugins" capability');
|
|
67
|
+
logger.error('The user must be an Administrator to deploy plugins.');
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
spin.succeed(`Authenticated as "${user.name}"`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
spin.fail('Authentication failed');
|
|
74
|
+
logger.error(`Check your username and application password.\n ${err.message}`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check receiver plugin
|
|
80
|
+
spin.start('Checking for plugship-receiver plugin...');
|
|
81
|
+
try {
|
|
82
|
+
const status = await api.checkReceiver();
|
|
83
|
+
spin.succeed(`Receiver plugin active (v${status.version})`);
|
|
84
|
+
} catch {
|
|
85
|
+
spin.warn('Receiver plugin not detected');
|
|
86
|
+
console.log('');
|
|
87
|
+
logger.warn(
|
|
88
|
+
'The plugship-receiver plugin must be installed and activated on your WordPress site.'
|
|
89
|
+
);
|
|
90
|
+
console.log(
|
|
91
|
+
chalk.dim(
|
|
92
|
+
` 1. Download: ${RECEIVER_DOWNLOAD_URL}\n` +
|
|
93
|
+
' 2. Upload and activate in WordPress admin (Plugins > Add New > Upload Plugin)\n' +
|
|
94
|
+
' 3. Run "plugship init" again to verify\n'
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Save config
|
|
100
|
+
await addSite(name.trim(), {
|
|
101
|
+
url: siteUrl,
|
|
102
|
+
username: username.trim(),
|
|
103
|
+
appPassword,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
logger.success(`Site "${name.trim()}" saved and set as default.\n`);
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { listSites } from '../../lib/config.js';
|
|
3
|
+
import * as logger from '../../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
export async function listSitesCommand() {
|
|
6
|
+
const { sites, defaultSite } = await listSites();
|
|
7
|
+
const names = Object.keys(sites);
|
|
8
|
+
|
|
9
|
+
if (names.length === 0) {
|
|
10
|
+
logger.info('No sites configured. Run "plugship init" to add one.');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
console.log(chalk.bold('\nSaved sites:\n'));
|
|
15
|
+
for (const name of names) {
|
|
16
|
+
const site = sites[name];
|
|
17
|
+
const isDefault = name === defaultSite;
|
|
18
|
+
const label = isDefault ? chalk.green(`${name} (default)`) : name;
|
|
19
|
+
console.log(` ${label}`);
|
|
20
|
+
console.log(` URL: ${site.url}`);
|
|
21
|
+
console.log(` User: ${site.username}\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { removeSite } from '../../lib/config.js';
|
|
3
|
+
import * as logger from '../../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
export async function removeSiteCommand(name) {
|
|
6
|
+
const confirmed = await confirm({
|
|
7
|
+
message: `Remove site "${name}"?`,
|
|
8
|
+
default: false,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
if (!confirmed) {
|
|
12
|
+
logger.info('Cancelled.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await removeSite(name);
|
|
18
|
+
logger.success(`Site "${name}" removed.`);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
logger.error(err.message);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { setDefaultSite } from '../../lib/config.js';
|
|
2
|
+
import * as logger from '../../lib/logger.js';
|
|
3
|
+
|
|
4
|
+
export async function setDefaultCommand(name) {
|
|
5
|
+
try {
|
|
6
|
+
await setDefaultSite(name);
|
|
7
|
+
logger.success(`Default site set to "${name}".`);
|
|
8
|
+
} catch (err) {
|
|
9
|
+
logger.error(err.message);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { getSite, listSites } from '../lib/config.js';
|
|
3
|
+
import { WordPressApi } from '../lib/wordpress-api.js';
|
|
4
|
+
import { RECEIVER_DOWNLOAD_URL } from '../lib/constants.js';
|
|
5
|
+
import * as logger from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
export async function statusCommand(options) {
|
|
8
|
+
let site;
|
|
9
|
+
try {
|
|
10
|
+
if (options.site) {
|
|
11
|
+
site = await getSite(options.site);
|
|
12
|
+
} else {
|
|
13
|
+
const { sites, defaultSite } = await listSites();
|
|
14
|
+
const names = Object.keys(sites);
|
|
15
|
+
|
|
16
|
+
if (names.length === 0) {
|
|
17
|
+
logger.error('No sites configured. Run "plugship init" first.');
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (names.length === 1) {
|
|
23
|
+
site = { name: names[0], ...sites[names[0]] };
|
|
24
|
+
} else {
|
|
25
|
+
const chosen = await select({
|
|
26
|
+
message: 'Which site do you want to check?',
|
|
27
|
+
choices: names.map((n) => ({
|
|
28
|
+
name: n === defaultSite ? `${n} (default)` : n,
|
|
29
|
+
value: n,
|
|
30
|
+
})),
|
|
31
|
+
default: defaultSite,
|
|
32
|
+
});
|
|
33
|
+
site = { name: chosen, ...sites[chosen] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.error(err.message);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const api = new WordPressApi(site);
|
|
43
|
+
console.log(`\nChecking ${site.name} (${site.url})...\n`);
|
|
44
|
+
|
|
45
|
+
// Connection
|
|
46
|
+
const spin = logger.spinner('Testing REST API connection...');
|
|
47
|
+
spin.start();
|
|
48
|
+
try {
|
|
49
|
+
await api.testConnection();
|
|
50
|
+
spin.succeed('REST API is accessible');
|
|
51
|
+
} catch {
|
|
52
|
+
spin.fail('Cannot reach REST API');
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auth
|
|
58
|
+
spin.start('Verifying credentials...');
|
|
59
|
+
try {
|
|
60
|
+
const user = await api.testAuth();
|
|
61
|
+
const caps = user.capabilities || {};
|
|
62
|
+
if (!caps.install_plugins) {
|
|
63
|
+
spin.fail(`Authenticated as "${user.name}" but missing install_plugins capability`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
spin.succeed(`Authenticated as "${user.name}"`);
|
|
68
|
+
} catch {
|
|
69
|
+
spin.fail('Authentication failed');
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Receiver
|
|
75
|
+
spin.start('Checking receiver plugin...');
|
|
76
|
+
try {
|
|
77
|
+
const status = await api.checkReceiver();
|
|
78
|
+
spin.succeed(`Receiver plugin active (v${status.version})`);
|
|
79
|
+
} catch {
|
|
80
|
+
spin.fail('Receiver plugin not found');
|
|
81
|
+
logger.info(`Download: ${RECEIVER_DOWNLOAD_URL}`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('');
|
|
87
|
+
logger.success('All checks passed. Ready to deploy.');
|
|
88
|
+
console.log('');
|
|
89
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
|
2
|
+
import { CONFIG_DIR, CONFIG_FILE, CONFIG_PERMISSIONS } from './constants.js';
|
|
3
|
+
import { ConfigError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
const EMPTY_CONFIG = { defaultSite: null, sites: {} };
|
|
6
|
+
|
|
7
|
+
export async function loadConfig() {
|
|
8
|
+
try {
|
|
9
|
+
await access(CONFIG_FILE);
|
|
10
|
+
const data = await readFile(CONFIG_FILE, 'utf-8');
|
|
11
|
+
return JSON.parse(data);
|
|
12
|
+
} catch {
|
|
13
|
+
return { ...EMPTY_CONFIG };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function saveConfig(config) {
|
|
18
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
19
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', {
|
|
20
|
+
mode: CONFIG_PERMISSIONS,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getSite(name) {
|
|
25
|
+
const config = await loadConfig();
|
|
26
|
+
const siteName = name || config.defaultSite;
|
|
27
|
+
if (!siteName) {
|
|
28
|
+
throw new ConfigError('No site specified and no default site configured. Run "wpdeploy init" first.');
|
|
29
|
+
}
|
|
30
|
+
const site = config.sites[siteName];
|
|
31
|
+
if (!site) {
|
|
32
|
+
throw new ConfigError(`Site "${siteName}" not found. Run "wpdeploy sites list" to see available sites.`);
|
|
33
|
+
}
|
|
34
|
+
return { name: siteName, ...site };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function addSite(name, siteConfig) {
|
|
38
|
+
const config = await loadConfig();
|
|
39
|
+
config.sites[name] = siteConfig;
|
|
40
|
+
if (!config.defaultSite || Object.keys(config.sites).length === 1) {
|
|
41
|
+
config.defaultSite = name;
|
|
42
|
+
}
|
|
43
|
+
await saveConfig(config);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function removeSite(name) {
|
|
47
|
+
const config = await loadConfig();
|
|
48
|
+
if (!config.sites[name]) {
|
|
49
|
+
throw new ConfigError(`Site "${name}" not found.`);
|
|
50
|
+
}
|
|
51
|
+
delete config.sites[name];
|
|
52
|
+
if (config.defaultSite === name) {
|
|
53
|
+
const remaining = Object.keys(config.sites);
|
|
54
|
+
config.defaultSite = remaining.length > 0 ? remaining[0] : null;
|
|
55
|
+
}
|
|
56
|
+
await saveConfig(config);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function setDefaultSite(name) {
|
|
60
|
+
const config = await loadConfig();
|
|
61
|
+
if (!config.sites[name]) {
|
|
62
|
+
throw new ConfigError(`Site "${name}" not found.`);
|
|
63
|
+
}
|
|
64
|
+
config.defaultSite = name;
|
|
65
|
+
await saveConfig(config);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function listSites() {
|
|
69
|
+
const config = await loadConfig();
|
|
70
|
+
return { sites: config.sites, defaultSite: config.defaultSite };
|
|
71
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const CONFIG_DIR = join(homedir(), '.plugship');
|
|
5
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
6
|
+
export const CONFIG_PERMISSIONS = 0o600;
|
|
7
|
+
export const RECEIVER_DOWNLOAD_URL = 'https://github.com/shamim0902/plugship-receiver/releases/latest/download/plugship-receiver.zip';
|
|
8
|
+
|
|
9
|
+
export const PLUGIN_HEADER_FIELDS = {
|
|
10
|
+
'Plugin Name': 'name',
|
|
11
|
+
'Plugin URI': 'pluginUri',
|
|
12
|
+
'Description': 'description',
|
|
13
|
+
'Version': 'version',
|
|
14
|
+
'Author': 'author',
|
|
15
|
+
'Author URI': 'authorUri',
|
|
16
|
+
'Text Domain': 'textDomain',
|
|
17
|
+
'Domain Path': 'domainPath',
|
|
18
|
+
'Requires at least': 'requiresWp',
|
|
19
|
+
'Requires PHP': 'requiresPhp',
|
|
20
|
+
'License': 'license',
|
|
21
|
+
'License URI': 'licenseUri',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_EXCLUDES = [
|
|
25
|
+
'node_modules/**',
|
|
26
|
+
'.git/**',
|
|
27
|
+
'.DS_Store',
|
|
28
|
+
'.env',
|
|
29
|
+
'*.log',
|
|
30
|
+
'.vscode/**',
|
|
31
|
+
'.idea/**',
|
|
32
|
+
'tests/**',
|
|
33
|
+
'phpunit.xml',
|
|
34
|
+
'.phpunit.result.cache',
|
|
35
|
+
'.github/**',
|
|
36
|
+
'build/**',
|
|
37
|
+
'.plugshipignore',
|
|
38
|
+
];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { getSite, listSites } from './config.js';
|
|
6
|
+
import { detectPlugin } from './plugin-detector.js';
|
|
7
|
+
import { createPluginZip } from './zipper.js';
|
|
8
|
+
import { WordPressApi } from './wordpress-api.js';
|
|
9
|
+
import { DeployError, ConfigError } from './errors.js';
|
|
10
|
+
import { RECEIVER_DOWNLOAD_URL } from './constants.js';
|
|
11
|
+
import * as logger from './logger.js';
|
|
12
|
+
|
|
13
|
+
async function resolveSite(siteName) {
|
|
14
|
+
if (siteName) return getSite(siteName);
|
|
15
|
+
|
|
16
|
+
const { sites, defaultSite } = await listSites();
|
|
17
|
+
const names = Object.keys(sites);
|
|
18
|
+
|
|
19
|
+
if (names.length === 0) {
|
|
20
|
+
throw new ConfigError('No sites configured. Run "plugship init" first.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (names.length === 1) {
|
|
24
|
+
return { name: names[0], ...sites[names[0]] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const chosen = await select({
|
|
28
|
+
message: 'Which site do you want to deploy to?',
|
|
29
|
+
choices: names.map((name) => ({
|
|
30
|
+
name: name === defaultSite ? `${name} (default)` : name,
|
|
31
|
+
value: name,
|
|
32
|
+
})),
|
|
33
|
+
default: defaultSite,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return { name: chosen, ...sites[chosen] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getAllSites() {
|
|
40
|
+
const { sites } = await listSites();
|
|
41
|
+
const names = Object.keys(sites);
|
|
42
|
+
|
|
43
|
+
if (names.length === 0) {
|
|
44
|
+
throw new ConfigError('No sites configured. Run "plugship init" first.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return names.map((name) => ({ name, ...sites[name] }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function checkIgnoreFile(cwd) {
|
|
51
|
+
try {
|
|
52
|
+
await access(join(cwd, '.plugshipignore'));
|
|
53
|
+
} catch {
|
|
54
|
+
logger.warn('No .plugshipignore file found.');
|
|
55
|
+
const create = await confirm({
|
|
56
|
+
message: 'Create one with default template?',
|
|
57
|
+
default: true,
|
|
58
|
+
});
|
|
59
|
+
if (create) {
|
|
60
|
+
const { ignoreCommand } = await import('../commands/ignore.js');
|
|
61
|
+
await ignoreCommand([]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function printPluginInfo(plugin, site) {
|
|
67
|
+
console.log('');
|
|
68
|
+
logger.info(`Plugin: ${plugin.name}`);
|
|
69
|
+
logger.info(`Version: ${plugin.version}`);
|
|
70
|
+
logger.info(`Slug: ${plugin.slug}`);
|
|
71
|
+
if (site) {
|
|
72
|
+
logger.info(`Site: ${site.name} (${site.url})`);
|
|
73
|
+
}
|
|
74
|
+
console.log('');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deploy({ siteName, activate = true, dryRun = false, all = false }) {
|
|
78
|
+
const cwd = process.cwd();
|
|
79
|
+
|
|
80
|
+
// Check for .plugshipignore
|
|
81
|
+
await checkIgnoreFile(cwd);
|
|
82
|
+
|
|
83
|
+
// Detect plugin
|
|
84
|
+
const plugin = await detectPlugin(cwd);
|
|
85
|
+
|
|
86
|
+
// Build ZIP once (shared across all targets)
|
|
87
|
+
const spin = logger.spinner('Creating ZIP archive...');
|
|
88
|
+
spin.start();
|
|
89
|
+
const { zipPath, size } = await createPluginZip(cwd, plugin.slug);
|
|
90
|
+
const sizeMB = (size / 1024 / 1024).toFixed(2);
|
|
91
|
+
spin.succeed(`ZIP created (${sizeMB} MB)`);
|
|
92
|
+
|
|
93
|
+
// Dry run — show summary and exit
|
|
94
|
+
if (dryRun) {
|
|
95
|
+
const targets = all ? await getAllSites() : [await resolveSite(siteName)];
|
|
96
|
+
console.log(chalk.bold('\n--- Dry Run ---\n'));
|
|
97
|
+
logger.info(`Plugin: ${plugin.name}`);
|
|
98
|
+
logger.info(`Version: ${plugin.version}`);
|
|
99
|
+
logger.info(`Slug: ${plugin.slug}`);
|
|
100
|
+
logger.info(`ZIP: ${zipPath} (${sizeMB} MB)`);
|
|
101
|
+
logger.info(`Activate: ${activate ? 'yes' : 'no'}`);
|
|
102
|
+
console.log('');
|
|
103
|
+
logger.info('Target sites:');
|
|
104
|
+
for (const s of targets) {
|
|
105
|
+
console.log(` - ${s.name} (${s.url})`);
|
|
106
|
+
}
|
|
107
|
+
console.log(chalk.dim('\nNo changes were made. Remove --dry-run to deploy.\n'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Resolve targets
|
|
112
|
+
const targets = all ? await getAllSites() : [await resolveSite(siteName)];
|
|
113
|
+
|
|
114
|
+
for (const site of targets) {
|
|
115
|
+
if (targets.length > 1) {
|
|
116
|
+
console.log(chalk.bold(`\n--- Deploying to ${site.name} ---`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
printPluginInfo(plugin, site);
|
|
120
|
+
|
|
121
|
+
const api = new WordPressApi(site);
|
|
122
|
+
|
|
123
|
+
// Check receiver
|
|
124
|
+
const s = logger.spinner('Checking receiver plugin...');
|
|
125
|
+
s.start();
|
|
126
|
+
try {
|
|
127
|
+
await api.checkReceiver();
|
|
128
|
+
s.succeed('Receiver plugin is active');
|
|
129
|
+
} catch {
|
|
130
|
+
s.fail('Receiver plugin not found');
|
|
131
|
+
logger.error(
|
|
132
|
+
`The plugship-receiver plugin is not active on ${site.name}. Download and install it first.\n ${RECEIVER_DOWNLOAD_URL}`
|
|
133
|
+
);
|
|
134
|
+
if (targets.length > 1) continue;
|
|
135
|
+
throw new DeployError('Receiver plugin not found.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Upload
|
|
139
|
+
s.start('Uploading plugin...');
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = await api.deployPlugin(zipPath, `${plugin.slug}.zip`);
|
|
143
|
+
s.succeed('Plugin uploaded and installed');
|
|
144
|
+
} catch (err) {
|
|
145
|
+
s.fail('Upload failed');
|
|
146
|
+
if (targets.length > 1) {
|
|
147
|
+
logger.error(`Deploy to ${site.name} failed: ${err.message}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
throw new DeployError(`Deploy failed: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result.activated) {
|
|
154
|
+
logger.success(`Plugin "${result.name || plugin.name}" v${result.version || plugin.version} is active on ${site.url}`);
|
|
155
|
+
} else {
|
|
156
|
+
logger.success(`Plugin "${result.name || plugin.name}" v${result.version || plugin.version} installed on ${site.url}`);
|
|
157
|
+
if (activate && !result.activated) {
|
|
158
|
+
logger.info('Plugin was not activated (may already be active or activation was skipped).');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('');
|
|
164
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class WpDeployError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'WpDeployError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ConfigError extends WpDeployError {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ConfigError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ApiError extends WpDeployError {
|
|
16
|
+
constructor(message, statusCode, body) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'ApiError';
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
this.body = body;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PluginDetectionError extends WpDeployError {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'PluginDetectionError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DeployError extends WpDeployError {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'DeployError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
|
|
4
|
+
export function info(message) {
|
|
5
|
+
console.log(chalk.blue('ℹ'), message);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function success(message) {
|
|
9
|
+
console.log(chalk.green('✔'), message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function warn(message) {
|
|
13
|
+
console.log(chalk.yellow('⚠'), message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function error(message) {
|
|
17
|
+
console.error(chalk.red('✖'), message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function spinner(text) {
|
|
21
|
+
return ora({ text, color: 'cyan' });
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { PLUGIN_HEADER_FIELDS } from './constants.js';
|
|
4
|
+
import { PluginDetectionError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export async function detectPlugin(directory) {
|
|
7
|
+
const entries = await readdir(directory);
|
|
8
|
+
const phpFiles = entries.filter((f) => f.endsWith('.php'));
|
|
9
|
+
|
|
10
|
+
if (phpFiles.length === 0) {
|
|
11
|
+
throw new PluginDetectionError('No PHP files found in current directory. Are you in a WordPress plugin directory?');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const file of phpFiles) {
|
|
15
|
+
const filePath = join(directory, file);
|
|
16
|
+
const content = await readFile(filePath, 'utf-8');
|
|
17
|
+
const headers = parseHeaders(content);
|
|
18
|
+
|
|
19
|
+
if (headers.name) {
|
|
20
|
+
const slug = deriveSlug(headers.textDomain, headers.name);
|
|
21
|
+
return {
|
|
22
|
+
name: headers.name,
|
|
23
|
+
version: headers.version || '0.0.0',
|
|
24
|
+
slug,
|
|
25
|
+
textDomain: headers.textDomain || slug,
|
|
26
|
+
description: headers.description || '',
|
|
27
|
+
directory: basename(directory),
|
|
28
|
+
mainFile: file,
|
|
29
|
+
headers,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new PluginDetectionError(
|
|
35
|
+
'No WordPress plugin header found. Ensure a PHP file contains a "Plugin Name:" header comment.'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseHeaders(content) {
|
|
40
|
+
const headerBlock = content.match(/\/\*\*?[\s\S]*?\*\//);
|
|
41
|
+
if (!headerBlock) return {};
|
|
42
|
+
|
|
43
|
+
const block = headerBlock[0];
|
|
44
|
+
const result = {};
|
|
45
|
+
|
|
46
|
+
for (const [field, key] of Object.entries(PLUGIN_HEADER_FIELDS)) {
|
|
47
|
+
const regex = new RegExp(`^\\s*\\*?\\s*${escapeRegex(field)}:\\s*(.+)$`, 'mi');
|
|
48
|
+
const match = block.match(regex);
|
|
49
|
+
if (match) {
|
|
50
|
+
result[key] = match[1].trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deriveSlug(textDomain, name) {
|
|
58
|
+
if (textDomain) return textDomain;
|
|
59
|
+
return name
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
62
|
+
.replace(/^-|-$/g, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeRegex(str) {
|
|
66
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import FormData from 'form-data';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { ApiError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export class WordPressApi {
|
|
6
|
+
constructor({ url, username, appPassword }) {
|
|
7
|
+
this.baseUrl = url.replace(/\/+$/, '');
|
|
8
|
+
this.auth = 'Basic ' + Buffer.from(`${username}:${appPassword}`).toString('base64');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async request(path, options = {}) {
|
|
12
|
+
const url = `${this.baseUrl}/wp-json${path}`;
|
|
13
|
+
const headers = { ...options.headers };
|
|
14
|
+
|
|
15
|
+
if (options.auth !== false) {
|
|
16
|
+
headers['Authorization'] = this.auth;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const res = await fetch(url, { ...options, headers });
|
|
20
|
+
const contentType = res.headers.get('content-type') || '';
|
|
21
|
+
const body = contentType.includes('application/json') ? await res.json() : await res.text();
|
|
22
|
+
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const msg = typeof body === 'object' && body.message ? body.message : `HTTP ${res.status}`;
|
|
25
|
+
throw new ApiError(msg, res.status, body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async testConnection() {
|
|
32
|
+
return this.request('/', { auth: false });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async testAuth() {
|
|
36
|
+
return this.request('/wp/v2/users/me?context=edit');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getPlugins() {
|
|
40
|
+
return this.request('/wp/v2/plugins');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getPlugin(slug) {
|
|
44
|
+
return this.request(`/wp/v2/plugins/${slug}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async activatePlugin(plugin) {
|
|
48
|
+
return this.request(`/wp/v2/plugins/${plugin}`, {
|
|
49
|
+
method: 'PUT',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ status: 'active' }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async deactivatePlugin(plugin) {
|
|
56
|
+
return this.request(`/wp/v2/plugins/${plugin}`, {
|
|
57
|
+
method: 'PUT',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ status: 'inactive' }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async deletePlugin(plugin) {
|
|
64
|
+
return this.request(`/wp/v2/plugins/${plugin}`, {
|
|
65
|
+
method: 'DELETE',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async checkReceiver() {
|
|
70
|
+
return this.request('/plugship/v1/status');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async deployPlugin(zipPath, filename) {
|
|
74
|
+
const fileBuffer = await readFile(zipPath);
|
|
75
|
+
const form = new FormData();
|
|
76
|
+
form.append('plugin', fileBuffer, {
|
|
77
|
+
filename,
|
|
78
|
+
contentType: 'application/zip',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const url = `${this.baseUrl}/wp-json/plugship/v1/deploy`;
|
|
82
|
+
const res = await fetch(url, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'Authorization': this.auth,
|
|
86
|
+
...form.getHeaders(),
|
|
87
|
+
},
|
|
88
|
+
body: form.getBuffer(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const contentType = res.headers.get('content-type') || '';
|
|
92
|
+
const body = contentType.includes('application/json') ? await res.json() : await res.text();
|
|
93
|
+
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const msg = typeof body === 'object' && body.message ? body.message : `Upload failed (HTTP ${res.status})`;
|
|
96
|
+
throw new ApiError(msg, res.status, body);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return body;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stat, mkdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import archiver from 'archiver';
|
|
5
|
+
import { DEFAULT_EXCLUDES } from './constants.js';
|
|
6
|
+
|
|
7
|
+
async function loadIgnorePatterns(sourceDir) {
|
|
8
|
+
const patterns = [...DEFAULT_EXCLUDES];
|
|
9
|
+
try {
|
|
10
|
+
const content = await readFile(join(sourceDir, '.plugshipignore'), 'utf-8');
|
|
11
|
+
for (const line of content.split('\n')) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
if (trimmed && !trimmed.startsWith('#')) {
|
|
14
|
+
patterns.push(trimmed);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// No .plugshipignore file — use defaults only
|
|
19
|
+
}
|
|
20
|
+
return patterns;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function createPluginZip(sourceDir, slug) {
|
|
24
|
+
const buildDir = join(sourceDir, 'build');
|
|
25
|
+
await mkdir(buildDir, { recursive: true });
|
|
26
|
+
const zipName = `${slug}.zip`;
|
|
27
|
+
const zipPath = join(buildDir, zipName);
|
|
28
|
+
const excludes = await loadIgnorePatterns(sourceDir);
|
|
29
|
+
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
const output = createWriteStream(zipPath);
|
|
32
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
33
|
+
|
|
34
|
+
output.on('close', resolve);
|
|
35
|
+
archive.on('error', reject);
|
|
36
|
+
archive.on('warning', (err) => {
|
|
37
|
+
if (err.code !== 'ENOENT') reject(err);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
archive.pipe(output);
|
|
41
|
+
archive.directory(sourceDir, slug, (entry) => {
|
|
42
|
+
for (const pattern of excludes) {
|
|
43
|
+
if (matchGlob(entry.name, pattern)) return false;
|
|
44
|
+
}
|
|
45
|
+
return entry;
|
|
46
|
+
});
|
|
47
|
+
archive.finalize();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const zipStat = await stat(zipPath);
|
|
51
|
+
return { zipPath, size: zipStat.size };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchGlob(filePath, pattern) {
|
|
55
|
+
// Strip trailing /** for directory matching
|
|
56
|
+
if (pattern.endsWith('/**')) {
|
|
57
|
+
const dir = pattern.slice(0, -3);
|
|
58
|
+
return filePath === dir || filePath.startsWith(dir + '/');
|
|
59
|
+
}
|
|
60
|
+
// Exact match or wildcard prefix
|
|
61
|
+
if (pattern.startsWith('*.')) {
|
|
62
|
+
return filePath.endsWith(pattern.slice(1));
|
|
63
|
+
}
|
|
64
|
+
return filePath === pattern;
|
|
65
|
+
}
|