plugship 1.0.2 → 1.0.4
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/README.md +282 -80
- package/package.json +1 -1
- package/src/cli.js +7 -1
- package/src/commands/ignore.js +1 -2
- package/src/commands/init.js +111 -72
- package/src/lib/constants.js +2 -9
- package/src/lib/deployer.js +114 -15
- package/src/lib/wordpress-api.js +45 -6
- package/src/lib/zipper.js +6 -1
package/README.md
CHANGED
|
@@ -2,119 +2,200 @@
|
|
|
2
2
|
|
|
3
3
|
> Deploy WordPress plugins from your terminal to any WordPress site instantly.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/plugship)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
5
8
|
A simple CLI tool to deploy local WordPress plugins to remote WordPress sites. No FTP, no cPanel — just `plugship deploy`.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
**[View on npm →](https://www.npmjs.com/package/plugship)**
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## ✨ Features
|
|
15
|
+
|
|
16
|
+
- 🚀 **One-command deploys** — `plugship deploy` and you're done
|
|
17
|
+
- 🔐 **Secure** — uses WordPress Application Passwords (not your main password)
|
|
18
|
+
- 🎯 **Multi-site** — configure once, deploy to staging/production/any site
|
|
19
|
+
- 📦 **Smart packaging** — auto-excludes dev files (node_modules, tests, src, etc.)
|
|
20
|
+
- 🔄 **Auto-updates** — replaces existing plugins automatically
|
|
21
|
+
- 🌐 **No server access needed** — works entirely via WordPress REST API
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 📦 Installation
|
|
8
26
|
|
|
9
|
-
###
|
|
27
|
+
### Global Install (Recommended)
|
|
10
28
|
|
|
11
29
|
```bash
|
|
12
30
|
npm install -g plugship
|
|
13
31
|
```
|
|
14
32
|
|
|
15
|
-
###
|
|
33
|
+
### Verify Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
plugship --version
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🚀 Quick Start
|
|
16
42
|
|
|
17
|
-
|
|
43
|
+
### Step 1: Install the Receiver Plugin
|
|
18
44
|
|
|
19
|
-
|
|
45
|
+
The `plugship-receiver` plugin must be installed on your WordPress site first. It adds secure REST endpoints to receive plugin uploads.
|
|
46
|
+
|
|
47
|
+
**[Download plugship-receiver.zip →](https://github.com/shamim0902/plugship-receiver/releases/latest/download/plugship-receiver.zip)**
|
|
20
48
|
|
|
21
49
|
1. Go to **Plugins > Add New > Upload Plugin** in WordPress admin
|
|
22
|
-
2. Upload
|
|
50
|
+
2. Upload `plugship-receiver.zip`
|
|
23
51
|
3. Activate **PlugShip Receiver**
|
|
24
52
|
|
|
25
|
-
|
|
53
|
+
**[View receiver plugin source →](https://github.com/shamim0902/plugship-receiver)**
|
|
54
|
+
|
|
55
|
+
### Step 2: Create an Application Password
|
|
56
|
+
|
|
57
|
+
1. Go to **Users > Profile** in WordPress admin
|
|
58
|
+
2. Scroll to **Application Passwords**
|
|
59
|
+
3. Enter "plugship" as the name
|
|
60
|
+
4. Click **Add New Application Password**
|
|
61
|
+
5. Copy the generated password (you'll need it in the next step)
|
|
62
|
+
|
|
63
|
+
**[Learn more about Application Passwords →](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)**
|
|
64
|
+
|
|
65
|
+
### Step 3: Configure Your First Site
|
|
26
66
|
|
|
27
67
|
```bash
|
|
28
68
|
plugship init
|
|
29
69
|
```
|
|
30
70
|
|
|
31
71
|
You'll be prompted for:
|
|
32
|
-
- Site alias
|
|
33
|
-
- WordPress site URL
|
|
34
|
-
- Admin username
|
|
35
|
-
-
|
|
72
|
+
- **Site alias** — a short name like "production" or "staging"
|
|
73
|
+
- **WordPress site URL** — e.g., `https://example.com`
|
|
74
|
+
- **Admin username** — your WordPress admin username
|
|
75
|
+
- **Application Password** — paste the password from Step 2
|
|
36
76
|
|
|
37
|
-
### 4
|
|
77
|
+
### Step 4: Deploy Your Plugin
|
|
38
78
|
|
|
39
|
-
Navigate to your plugin directory
|
|
79
|
+
Navigate to your WordPress plugin directory:
|
|
40
80
|
|
|
41
81
|
```bash
|
|
82
|
+
cd my-awesome-plugin/
|
|
42
83
|
plugship deploy
|
|
43
84
|
```
|
|
44
85
|
|
|
45
|
-
Done! Your plugin is deployed and activated.
|
|
86
|
+
✅ Done! Your plugin is deployed and activated on the remote site.
|
|
46
87
|
|
|
47
88
|
---
|
|
48
89
|
|
|
49
|
-
## Commands
|
|
90
|
+
## 📚 Commands
|
|
50
91
|
|
|
51
92
|
### `plugship deploy`
|
|
52
93
|
|
|
53
94
|
Deploy the plugin from the current directory.
|
|
54
95
|
|
|
55
96
|
```bash
|
|
56
|
-
|
|
57
|
-
plugship deploy
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
plugship deploy --
|
|
97
|
+
# Deploy to default site
|
|
98
|
+
plugship deploy
|
|
99
|
+
|
|
100
|
+
# Deploy to a specific site
|
|
101
|
+
plugship deploy --site staging
|
|
102
|
+
|
|
103
|
+
# Deploy to all configured sites
|
|
104
|
+
plugship deploy --all
|
|
105
|
+
|
|
106
|
+
# Preview what would be deployed (no upload)
|
|
107
|
+
plugship deploy --dry-run
|
|
108
|
+
|
|
109
|
+
# Deploy without activating the plugin
|
|
110
|
+
plugship deploy --no-activate
|
|
61
111
|
```
|
|
62
112
|
|
|
113
|
+
**What happens:**
|
|
114
|
+
1. Detects your plugin from PHP headers
|
|
115
|
+
2. Creates a ZIP excluding dev files
|
|
116
|
+
3. Uploads to the WordPress site
|
|
117
|
+
4. Installs/updates the plugin
|
|
118
|
+
5. Activates it (unless `--no-activate`)
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
63
122
|
### `plugship init`
|
|
64
123
|
|
|
65
|
-
Add a new WordPress site.
|
|
124
|
+
Add a new WordPress site to your configuration.
|
|
66
125
|
|
|
67
126
|
```bash
|
|
68
127
|
plugship init
|
|
69
128
|
```
|
|
70
129
|
|
|
130
|
+
Interactive prompts guide you through:
|
|
131
|
+
- Site alias
|
|
132
|
+
- URL
|
|
133
|
+
- Username
|
|
134
|
+
- Application Password
|
|
135
|
+
|
|
136
|
+
The CLI automatically tests the connection and verifies the receiver plugin is active.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
71
140
|
### `plugship status`
|
|
72
141
|
|
|
73
142
|
Check if a site is ready for deployment.
|
|
74
143
|
|
|
75
144
|
```bash
|
|
76
|
-
|
|
77
|
-
plugship status
|
|
145
|
+
# Check default site
|
|
146
|
+
plugship status
|
|
147
|
+
|
|
148
|
+
# Check a specific site
|
|
149
|
+
plugship status --site staging
|
|
78
150
|
```
|
|
79
151
|
|
|
152
|
+
Verifies:
|
|
153
|
+
- ✅ REST API is accessible
|
|
154
|
+
- ✅ Credentials are valid
|
|
155
|
+
- ✅ User has `install_plugins` capability
|
|
156
|
+
- ✅ Receiver plugin is active
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
80
160
|
### `plugship sites`
|
|
81
161
|
|
|
82
162
|
Manage your saved sites.
|
|
83
163
|
|
|
84
164
|
```bash
|
|
85
|
-
|
|
86
|
-
plugship sites
|
|
87
|
-
plugship sites remove staging # Remove a site
|
|
88
|
-
```
|
|
165
|
+
# List all configured sites
|
|
166
|
+
plugship sites list
|
|
89
167
|
|
|
90
|
-
|
|
168
|
+
# Set the default site
|
|
169
|
+
plugship sites set-default production
|
|
91
170
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
plugship ignore # Create .plugshipignore template
|
|
96
|
-
plugship ignore "src/**" "*.map" # Add patterns
|
|
171
|
+
# Remove a site
|
|
172
|
+
plugship sites remove staging
|
|
97
173
|
```
|
|
98
174
|
|
|
99
175
|
---
|
|
100
176
|
|
|
101
|
-
|
|
177
|
+
### `plugship ignore`
|
|
102
178
|
|
|
103
|
-
|
|
179
|
+
Manage file exclusions for deployment.
|
|
104
180
|
|
|
105
181
|
```bash
|
|
182
|
+
# Create .plugshipignore with default template
|
|
106
183
|
plugship ignore
|
|
184
|
+
|
|
185
|
+
# Add specific patterns
|
|
186
|
+
plugship ignore "src/**" "*.map" "composer.json"
|
|
107
187
|
```
|
|
108
188
|
|
|
109
|
-
|
|
189
|
+
Creates a `.plugshipignore` file in your plugin directory. Example:
|
|
110
190
|
|
|
111
191
|
```
|
|
112
192
|
# .plugshipignore
|
|
113
193
|
src/**
|
|
114
194
|
*.map
|
|
195
|
+
webpack.config.js
|
|
115
196
|
package.json
|
|
197
|
+
package-lock.json
|
|
116
198
|
composer.json
|
|
117
|
-
webpack.config.js
|
|
118
199
|
```
|
|
119
200
|
|
|
120
201
|
**Already excluded by default:**
|
|
@@ -122,64 +203,108 @@ webpack.config.js
|
|
|
122
203
|
|
|
123
204
|
---
|
|
124
205
|
|
|
125
|
-
##
|
|
206
|
+
## 💡 Usage Examples
|
|
126
207
|
|
|
127
|
-
|
|
128
|
-
2. **Creates a ZIP** with only the files you need (excludes dev files)
|
|
129
|
-
3. **Uploads via REST API** to the WordPress site using the receiver plugin
|
|
130
|
-
4. **Installs and activates** the plugin automatically
|
|
208
|
+
### Multi-Site Workflow
|
|
131
209
|
|
|
132
|
-
|
|
210
|
+
Configure multiple environments:
|
|
133
211
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
212
|
+
```bash
|
|
213
|
+
plugship init # Add production
|
|
214
|
+
plugship init # Add staging
|
|
215
|
+
plugship init # Add local test site
|
|
216
|
+
```
|
|
137
217
|
|
|
138
|
-
|
|
218
|
+
Deploy to each:
|
|
139
219
|
|
|
140
220
|
```bash
|
|
141
|
-
|
|
142
|
-
plugship deploy --site
|
|
221
|
+
plugship deploy --site staging # Test on staging first
|
|
222
|
+
plugship deploy --site production # Then push to prod
|
|
143
223
|
```
|
|
144
224
|
|
|
145
|
-
|
|
225
|
+
Or deploy everywhere at once:
|
|
146
226
|
|
|
147
227
|
```bash
|
|
148
228
|
plugship deploy --all
|
|
149
229
|
```
|
|
150
230
|
|
|
151
|
-
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Preview Before Deploy
|
|
234
|
+
|
|
235
|
+
See what would be deployed without uploading:
|
|
152
236
|
|
|
153
237
|
```bash
|
|
154
238
|
plugship deploy --dry-run
|
|
155
239
|
```
|
|
156
240
|
|
|
157
|
-
|
|
241
|
+
Output shows:
|
|
242
|
+
- Plugin name, version, slug
|
|
243
|
+
- ZIP file size
|
|
244
|
+
- Target site(s)
|
|
245
|
+
- Activation setting
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### Exclude Dev Files
|
|
250
|
+
|
|
251
|
+
Auto-suggest on first deploy, or create manually:
|
|
158
252
|
|
|
159
253
|
```bash
|
|
160
|
-
plugship
|
|
254
|
+
plugship ignore
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Edit `.plugshipignore` to add project-specific exclusions:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
# Custom exclusions
|
|
261
|
+
assets/src/**
|
|
262
|
+
*.scss
|
|
263
|
+
*.ts
|
|
264
|
+
tsconfig.json
|
|
161
265
|
```
|
|
162
266
|
|
|
163
267
|
---
|
|
164
268
|
|
|
165
|
-
##
|
|
269
|
+
## 🔧 How It Works
|
|
166
270
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
271
|
+
```
|
|
272
|
+
┌──────────────┐
|
|
273
|
+
│ Your Plugin │
|
|
274
|
+
│ Directory │
|
|
275
|
+
└──────┬───────┘
|
|
276
|
+
│
|
|
277
|
+
│ plugship deploy
|
|
278
|
+
▼
|
|
279
|
+
┌──────────────┐
|
|
280
|
+
│ ZIP File │ (excludes dev files)
|
|
281
|
+
└──────┬───────┘
|
|
282
|
+
│
|
|
283
|
+
│ Upload via REST API
|
|
284
|
+
▼
|
|
285
|
+
┌──────────────────────┐
|
|
286
|
+
│ WordPress Site │
|
|
287
|
+
│ (plugship-receiver) │
|
|
288
|
+
└──────┬───────────────┘
|
|
289
|
+
│
|
|
290
|
+
│ Install/Update
|
|
291
|
+
▼
|
|
292
|
+
┌──────────────┐
|
|
293
|
+
│ Plugin │
|
|
294
|
+
│ Active! │
|
|
295
|
+
└──────────────┘
|
|
296
|
+
```
|
|
170
297
|
|
|
171
|
-
|
|
298
|
+
The [plugship-receiver](https://github.com/shamim0902/plugship-receiver) plugin adds two REST endpoints:
|
|
172
299
|
|
|
173
|
-
|
|
300
|
+
- `GET /wp-json/plugship/v1/status` — Health check
|
|
301
|
+
- `POST /wp-json/plugship/v1/deploy` — Accept plugin ZIP upload
|
|
174
302
|
|
|
175
|
-
|
|
176
|
-
- Uses WordPress Application Passwords (not your main password)
|
|
177
|
-
- Only users with `install_plugins` capability can deploy
|
|
178
|
-
- All uploads are authenticated via WordPress REST API
|
|
303
|
+
WordPress's native `Plugin_Upgrader` with `overwrite_package => true` handles installation. Existing plugins are replaced automatically.
|
|
179
304
|
|
|
180
305
|
---
|
|
181
306
|
|
|
182
|
-
## Configuration
|
|
307
|
+
## ⚙️ Configuration
|
|
183
308
|
|
|
184
309
|
Config file: `~/.plugship/config.json`
|
|
185
310
|
|
|
@@ -201,43 +326,120 @@ Config file: `~/.plugship/config.json`
|
|
|
201
326
|
}
|
|
202
327
|
```
|
|
203
328
|
|
|
329
|
+
**File permissions:** `0600` (only you can read/write)
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## 🔒 Security
|
|
334
|
+
|
|
335
|
+
- ✅ **Application Passwords only** — never uses your main WordPress password
|
|
336
|
+
- ✅ **Local storage** — credentials stored in `~/.plugship/config.json` with restricted permissions
|
|
337
|
+
- ✅ **Capability checks** — only users with `install_plugins` can deploy
|
|
338
|
+
- ✅ **ZIP validation** — receiver plugin validates MIME type and ZIP integrity
|
|
339
|
+
- ✅ **Path traversal protection** — rejects ZIPs with malicious entries
|
|
340
|
+
- ✅ **50 MB upload limit** — prevents abuse
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## 🛠️ Requirements
|
|
345
|
+
|
|
346
|
+
- **Node.js** 18 or higher
|
|
347
|
+
- **WordPress** 5.8 or higher
|
|
348
|
+
- **Admin account** with Application Passwords enabled
|
|
349
|
+
|
|
204
350
|
---
|
|
205
351
|
|
|
206
|
-
## Troubleshooting
|
|
352
|
+
## ❓ Troubleshooting
|
|
207
353
|
|
|
208
354
|
### "Receiver plugin not found"
|
|
209
355
|
|
|
210
|
-
The plugship-receiver plugin isn't active on your WordPress site.
|
|
356
|
+
**Problem:** The plugship-receiver plugin isn't active on your WordPress site.
|
|
211
357
|
|
|
358
|
+
**Solution:**
|
|
212
359
|
1. Download: https://github.com/shamim0902/plugship-receiver/releases/latest/download/plugship-receiver.zip
|
|
213
|
-
2. Upload
|
|
360
|
+
2. Upload via **Plugins > Add New > Upload Plugin**
|
|
361
|
+
3. Activate **PlugShip Receiver**
|
|
362
|
+
4. Run `plugship status` to verify
|
|
363
|
+
|
|
364
|
+
---
|
|
214
365
|
|
|
215
366
|
### "Authentication failed"
|
|
216
367
|
|
|
217
|
-
Your Application Password is incorrect.
|
|
368
|
+
**Problem:** Your Application Password is incorrect or expired.
|
|
218
369
|
|
|
370
|
+
**Solution:**
|
|
219
371
|
1. Go to **Users > Profile** in WordPress admin
|
|
220
|
-
2.
|
|
221
|
-
3.
|
|
372
|
+
2. Delete the old Application Password
|
|
373
|
+
3. Create a new one
|
|
374
|
+
4. Run `plugship init` again and paste the new password
|
|
375
|
+
|
|
376
|
+
---
|
|
222
377
|
|
|
223
378
|
### "Cannot reach REST API"
|
|
224
379
|
|
|
225
|
-
|
|
380
|
+
**Problem:** The WordPress REST API isn't accessible.
|
|
226
381
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
382
|
+
**Solution:**
|
|
383
|
+
1. Check that `https://yoursite.com/wp-json/` loads in your browser
|
|
384
|
+
2. Temporarily disable security plugins (Wordfence, iThemes, etc.)
|
|
385
|
+
3. Check hosting firewall rules
|
|
386
|
+
4. Verify mod_rewrite is enabled (permalinks must work)
|
|
230
387
|
|
|
231
388
|
---
|
|
232
389
|
|
|
233
|
-
|
|
390
|
+
### Deploy fails with "permission denied"
|
|
234
391
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
392
|
+
**Problem:** User doesn't have `install_plugins` capability.
|
|
393
|
+
|
|
394
|
+
**Solution:**
|
|
395
|
+
- Ensure you're using an **Administrator** account
|
|
396
|
+
- Other roles (Editor, Author, etc.) can't install plugins
|
|
238
397
|
|
|
239
398
|
---
|
|
240
399
|
|
|
241
|
-
##
|
|
400
|
+
## 📖 Plugin Detection
|
|
401
|
+
|
|
402
|
+
PlugShip detects your plugin by scanning `.php` files in the current directory for WordPress plugin headers:
|
|
403
|
+
|
|
404
|
+
```php
|
|
405
|
+
<?php
|
|
406
|
+
/**
|
|
407
|
+
* Plugin Name: My Awesome Plugin
|
|
408
|
+
* Version: 1.0.0
|
|
409
|
+
* Text Domain: my-awesome-plugin
|
|
410
|
+
*/
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
- The `Text Domain` is used as the plugin slug
|
|
414
|
+
- If no `Text Domain` is found, the slug is derived from the plugin name
|
|
415
|
+
- Version is read from the header and displayed during deploy
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## 🌐 Links
|
|
420
|
+
|
|
421
|
+
- **npm package:** https://www.npmjs.com/package/plugship
|
|
422
|
+
- **Receiver plugin:** https://github.com/shamim0902/plugship-receiver
|
|
423
|
+
- **Report issues:** https://github.com/shamim0902/plugship/issues
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## 📄 License
|
|
428
|
+
|
|
429
|
+
MIT © [shamim0902](https://github.com/shamim0902)
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## 🙌 Contributing
|
|
434
|
+
|
|
435
|
+
Contributions are welcome! Please open an issue or PR.
|
|
436
|
+
|
|
437
|
+
1. Fork the repo
|
|
438
|
+
2. Create a feature branch: `git checkout -b feature/my-feature`
|
|
439
|
+
3. Commit your changes: `git commit -am 'Add my feature'`
|
|
440
|
+
4. Push: `git push origin feature/my-feature`
|
|
441
|
+
5. Open a Pull Request
|
|
442
|
+
|
|
443
|
+
---
|
|
242
444
|
|
|
243
|
-
|
|
445
|
+
**Made with ❤️ for the WordPress community**
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
|
2
8
|
|
|
3
9
|
const program = new Command();
|
|
4
10
|
|
|
5
11
|
program
|
|
6
12
|
.name('plugship')
|
|
7
13
|
.description('Deploy local WordPress plugins to remote sites')
|
|
8
|
-
.version(
|
|
14
|
+
.version(pkg.version);
|
|
9
15
|
|
|
10
16
|
program
|
|
11
17
|
.command('init')
|
package/src/commands/ignore.js
CHANGED
|
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE = `# .plugshipignore
|
|
|
10
10
|
#
|
|
11
11
|
# The following are always excluded by default (no need to list them):
|
|
12
12
|
# node_modules, .git, .github, .DS_Store, .env, *.log,
|
|
13
|
-
# .vscode, .idea, tests, phpunit.xml,
|
|
13
|
+
# .vscode, .idea, tests, phpunit.xml, builds
|
|
14
14
|
|
|
15
15
|
# Source files (uncomment as needed)
|
|
16
16
|
# src/**
|
|
@@ -19,7 +19,6 @@ const DEFAULT_TEMPLATE = `# .plugshipignore
|
|
|
19
19
|
# Build tools
|
|
20
20
|
package.json
|
|
21
21
|
package-lock.json
|
|
22
|
-
composer.json
|
|
23
22
|
composer.lock
|
|
24
23
|
webpack.config.js
|
|
25
24
|
`;
|
package/src/commands/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { input, password } from '@inquirer/prompts';
|
|
1
|
+
import { input, password, confirm } from '@inquirer/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { addSite } from '../lib/config.js';
|
|
4
4
|
import { WordPressApi } from '../lib/wordpress-api.js';
|
|
@@ -13,87 +13,119 @@ export async function initCommand() {
|
|
|
13
13
|
validate: (v) => (v.trim() ? true : 'Required'),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
let siteUrl, username, appPassword, api;
|
|
17
|
+
let connected = false;
|
|
18
|
+
|
|
19
|
+
// Loop until connection + auth succeed
|
|
20
|
+
while (!connected) {
|
|
21
|
+
siteUrl = await input({
|
|
22
|
+
message: 'WordPress site URL:',
|
|
23
|
+
validate: (v) => {
|
|
24
|
+
if (!v.trim()) return 'Required';
|
|
25
|
+
try {
|
|
26
|
+
const parsed = new URL(v);
|
|
27
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
28
|
+
return 'URL must start with https:// or http://';
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return 'Invalid URL';
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return 'Invalid URL';
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
});
|
|
34
|
+
},
|
|
35
|
+
});
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
message: 'WordPress username:',
|
|
34
|
-
validate: (v) => (v.trim() ? true : 'Required'),
|
|
35
|
-
});
|
|
37
|
+
siteUrl = siteUrl.replace(/\/+$/, '');
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
validate: (v) => (v.trim() ? true : 'Required'),
|
|
41
|
-
});
|
|
39
|
+
// Test connection
|
|
40
|
+
const spin = logger.spinner('Testing connection to WordPress REST API...');
|
|
41
|
+
spin.start();
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
const tempApi = new WordPressApi({ url: siteUrl, username: '', appPassword: '' });
|
|
45
|
+
await tempApi.testConnection();
|
|
46
|
+
spin.succeed('REST API is accessible');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
spin.stop();
|
|
49
|
+
spin.clear();
|
|
50
|
+
logger.error('Cannot reach WordPress REST API');
|
|
51
|
+
logger.error(`Make sure ${siteUrl}/wp-json/ is accessible.\n ${err.message}`);
|
|
52
|
+
console.log('');
|
|
53
|
+
const retry = await confirm({ message: 'Try a different URL?', default: true });
|
|
54
|
+
if (!retry) return;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
// Loop until auth succeeds or user quits
|
|
59
|
+
let authenticated = false;
|
|
60
|
+
while (!authenticated) {
|
|
61
|
+
username = await input({
|
|
62
|
+
message: 'WordPress username:',
|
|
63
|
+
validate: (v) => (v.trim() ? true : 'Required'),
|
|
64
|
+
});
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
logger.error(`Make sure ${siteUrl}/wp-json/ is accessible.\n ${err.message}`);
|
|
56
|
-
process.exitCode = 1;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
66
|
+
appPassword = await password({
|
|
67
|
+
message: 'Application password:',
|
|
68
|
+
mask: '*',
|
|
69
|
+
validate: (v) => (v.trim() ? true : 'Required'),
|
|
70
|
+
});
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
api = new WordPressApi({ url: siteUrl, username, appPassword });
|
|
73
|
+
|
|
74
|
+
const spin2 = logger.spinner('Verifying credentials...');
|
|
75
|
+
spin2.start();
|
|
76
|
+
try {
|
|
77
|
+
const user = await api.testAuth();
|
|
78
|
+
const caps = user.capabilities || {};
|
|
79
|
+
if (!caps.install_plugins) {
|
|
80
|
+
spin2.stop();
|
|
81
|
+
spin2.clear();
|
|
82
|
+
logger.error('User does not have the "install_plugins" capability');
|
|
83
|
+
logger.error('The user must be an Administrator to deploy plugins.');
|
|
84
|
+
console.log('');
|
|
85
|
+
const retry = await confirm({ message: 'Try different credentials?', default: true });
|
|
86
|
+
if (!retry) return;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
spin2.succeed(`Authenticated as "${user.name}"`);
|
|
90
|
+
authenticated = true;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
spin2.stop();
|
|
93
|
+
spin2.clear();
|
|
94
|
+
logger.error(`Authentication failed`);
|
|
95
|
+
logger.error(`Check your username and application password.\n ${err.message}`);
|
|
96
|
+
console.log('');
|
|
97
|
+
const retry = await confirm({ message: 'Try again?', default: true });
|
|
98
|
+
if (!retry) return;
|
|
99
|
+
}
|
|
70
100
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
spin.fail('Authentication failed');
|
|
74
|
-
logger.error(`Check your username and application password.\n ${err.message}`);
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
return;
|
|
101
|
+
|
|
102
|
+
connected = true;
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
// Check receiver plugin
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
spin.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
let receiverActive = false;
|
|
107
|
+
while (!receiverActive) {
|
|
108
|
+
const spin = logger.spinner('Checking for plugship-receiver plugin...');
|
|
109
|
+
spin.start();
|
|
110
|
+
try {
|
|
111
|
+
const status = await api.checkReceiver();
|
|
112
|
+
spin.succeed(`Receiver plugin active (v${status.version})`);
|
|
113
|
+
receiverActive = true;
|
|
114
|
+
} catch {
|
|
115
|
+
spin.warn('Receiver plugin not detected');
|
|
116
|
+
console.log('');
|
|
117
|
+
logger.warn(
|
|
118
|
+
'The plugship-receiver plugin must be installed and activated on your WordPress site.'
|
|
119
|
+
);
|
|
120
|
+
console.log(
|
|
121
|
+
chalk.dim(
|
|
122
|
+
` 1. Download: ${RECEIVER_DOWNLOAD_URL}\n` +
|
|
123
|
+
' 2. Upload and activate in WordPress admin (Plugins > Add New > Upload Plugin)\n'
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
const retry = await confirm({ message: 'Check again?', default: true });
|
|
127
|
+
if (!retry) break;
|
|
128
|
+
}
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
// Save config
|
|
@@ -103,5 +135,12 @@ export async function initCommand() {
|
|
|
103
135
|
appPassword,
|
|
104
136
|
});
|
|
105
137
|
|
|
106
|
-
logger.success(`Site "${name.trim()}" saved and set as default
|
|
138
|
+
logger.success(`Site "${name.trim()}" saved and set as default.`);
|
|
139
|
+
console.log(chalk.bold('\nNext steps:\n'));
|
|
140
|
+
console.log(` Navigate to your plugin directory and run:\n`);
|
|
141
|
+
console.log(chalk.cyan(` plugship deploy\n`));
|
|
142
|
+
console.log(chalk.dim(` Other useful commands:`));
|
|
143
|
+
console.log(chalk.dim(` plugship deploy --dry-run Preview without uploading`));
|
|
144
|
+
console.log(chalk.dim(` plugship deploy --site ${name.trim()} Deploy to this site`));
|
|
145
|
+
console.log(chalk.dim(` plugship ignore Set up file exclusions\n`));
|
|
107
146
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -22,17 +22,10 @@ export const PLUGIN_HEADER_FIELDS = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export const DEFAULT_EXCLUDES = [
|
|
25
|
+
'.*',
|
|
25
26
|
'node_modules/**',
|
|
26
|
-
'.git/**',
|
|
27
|
-
'.DS_Store',
|
|
28
|
-
'.env',
|
|
29
27
|
'*.log',
|
|
30
|
-
'.vscode/**',
|
|
31
|
-
'.idea/**',
|
|
32
28
|
'tests/**',
|
|
33
29
|
'phpunit.xml',
|
|
34
|
-
'
|
|
35
|
-
'.github/**',
|
|
36
|
-
'build/**',
|
|
37
|
-
'.plugshipignore',
|
|
30
|
+
'builds/**',
|
|
38
31
|
];
|
package/src/lib/deployer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { access } from 'node:fs/promises';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
3
|
import { select, confirm } from '@inquirer/prompts';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { getSite, listSites } from './config.js';
|
|
@@ -83,12 +83,37 @@ export async function deploy({ siteName, activate = true, dryRun = false, all =
|
|
|
83
83
|
// Detect plugin
|
|
84
84
|
const plugin = await detectPlugin(cwd);
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
// Check for existing ZIP
|
|
87
|
+
const existingZipPath = join(cwd, 'builds', `${plugin.slug}.zip`);
|
|
88
|
+
let zipPath, size;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const zipStat = await stat(existingZipPath);
|
|
92
|
+
const sizeMB = (zipStat.size / 1024 / 1024).toFixed(2);
|
|
93
|
+
logger.info(`Existing ZIP found: builds/${plugin.slug}.zip (${sizeMB} MB)`);
|
|
94
|
+
const action = await select({
|
|
95
|
+
message: 'What do you want to do?',
|
|
96
|
+
choices: [
|
|
97
|
+
{ name: 'Use existing ZIP', value: 'existing' },
|
|
98
|
+
{ name: 'Build a new ZIP', value: 'rebuild' },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
if (action === 'rebuild') {
|
|
102
|
+
const spin = logger.spinner('Creating ZIP archive...');
|
|
103
|
+
spin.start();
|
|
104
|
+
({ zipPath, size } = await createPluginZip(cwd, plugin.slug));
|
|
105
|
+
spin.succeed(`ZIP created (${(size / 1024 / 1024).toFixed(2)} MB)`);
|
|
106
|
+
} else {
|
|
107
|
+
zipPath = existingZipPath;
|
|
108
|
+
size = zipStat.size;
|
|
109
|
+
logger.success(`Using existing ZIP (${sizeMB} MB)`);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
const spin = logger.spinner('Creating ZIP archive...');
|
|
113
|
+
spin.start();
|
|
114
|
+
({ zipPath, size } = await createPluginZip(cwd, plugin.slug));
|
|
115
|
+
spin.succeed(`ZIP created (${(size / 1024 / 1024).toFixed(2)} MB)`);
|
|
116
|
+
}
|
|
92
117
|
|
|
93
118
|
// Dry run — show summary and exit
|
|
94
119
|
if (dryRun) {
|
|
@@ -138,24 +163,98 @@ export async function deploy({ siteName, activate = true, dryRun = false, all =
|
|
|
138
163
|
// Upload
|
|
139
164
|
s.start('Uploading plugin...');
|
|
140
165
|
let result;
|
|
166
|
+
let uploadWarnings = null;
|
|
141
167
|
try {
|
|
142
168
|
result = await api.deployPlugin(zipPath, `${plugin.slug}.zip`);
|
|
143
|
-
s.succeed('Plugin uploaded and installed');
|
|
144
169
|
} catch (err) {
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
170
|
+
// Non-JSON response — plugin might still have installed
|
|
171
|
+
if (err.body && err.body.rawWarnings) {
|
|
172
|
+
s.succeed('Plugin uploaded');
|
|
173
|
+
uploadWarnings = err.body.rawWarnings;
|
|
174
|
+
} else {
|
|
175
|
+
s.fail('Upload failed');
|
|
176
|
+
if (targets.length > 1) {
|
|
177
|
+
logger.error(`Deploy to ${site.name} failed: ${err.message}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
throw new DeployError(`Upload failed: ${err.message}`);
|
|
149
181
|
}
|
|
150
|
-
throw new DeployError(`Deploy failed: ${err.message}`);
|
|
151
182
|
}
|
|
152
183
|
|
|
184
|
+
// If we got warnings instead of clean JSON, verify installation via WP API
|
|
185
|
+
if (uploadWarnings) {
|
|
186
|
+
s.start('Verifying installation...');
|
|
187
|
+
try {
|
|
188
|
+
const pluginInfo = await api.getPlugin(`${plugin.slug}/${plugin.slug}`);
|
|
189
|
+
s.succeed('Plugin installed successfully');
|
|
190
|
+
result = {
|
|
191
|
+
success: true,
|
|
192
|
+
name: pluginInfo.name || plugin.name,
|
|
193
|
+
version: pluginInfo.version || plugin.version,
|
|
194
|
+
activated: pluginInfo.status === 'active',
|
|
195
|
+
};
|
|
196
|
+
for (const w of uploadWarnings) {
|
|
197
|
+
logger.warn(w);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Try alternative slug format (slug/main-file)
|
|
201
|
+
try {
|
|
202
|
+
const plugins = await api.getPlugins();
|
|
203
|
+
const found = plugins.find((p) => p.textdomain === plugin.slug || p.plugin.startsWith(plugin.slug + '/'));
|
|
204
|
+
if (found) {
|
|
205
|
+
s.succeed('Plugin installed successfully');
|
|
206
|
+
result = {
|
|
207
|
+
success: true,
|
|
208
|
+
name: found.name || plugin.name,
|
|
209
|
+
version: found.version || plugin.version,
|
|
210
|
+
activated: found.status === 'active',
|
|
211
|
+
};
|
|
212
|
+
for (const w of uploadWarnings) {
|
|
213
|
+
logger.warn(w);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
s.fail('Installation could not be verified');
|
|
217
|
+
for (const w of uploadWarnings) {
|
|
218
|
+
logger.error(w);
|
|
219
|
+
}
|
|
220
|
+
if (targets.length > 1) continue;
|
|
221
|
+
throw new DeployError('Plugin installation could not be verified.');
|
|
222
|
+
}
|
|
223
|
+
} catch (verifyErr) {
|
|
224
|
+
if (verifyErr instanceof DeployError) throw verifyErr;
|
|
225
|
+
s.fail('Installation could not be verified');
|
|
226
|
+
for (const w of uploadWarnings) {
|
|
227
|
+
logger.error(w);
|
|
228
|
+
}
|
|
229
|
+
if (targets.length > 1) continue;
|
|
230
|
+
throw new DeployError('Plugin installation could not be verified.');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// Clean JSON response
|
|
235
|
+
if (!result.success) {
|
|
236
|
+
s.fail('Installation failed');
|
|
237
|
+
logger.error('Plugin uploaded but installation failed.');
|
|
238
|
+
if (targets.length > 1) continue;
|
|
239
|
+
throw new DeployError('Installation failed on remote server.');
|
|
240
|
+
}
|
|
241
|
+
s.succeed('Plugin uploaded and installed');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Show warnings if any
|
|
245
|
+
if (result.warnings) {
|
|
246
|
+
logger.warn(`Warning: ${result.warnings}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Activation status
|
|
153
250
|
if (result.activated) {
|
|
154
251
|
logger.success(`Plugin "${result.name || plugin.name}" v${result.version || plugin.version} is active on ${site.url}`);
|
|
155
252
|
} else {
|
|
156
253
|
logger.success(`Plugin "${result.name || plugin.name}" v${result.version || plugin.version} installed on ${site.url}`);
|
|
157
|
-
if (activate &&
|
|
158
|
-
logger.
|
|
254
|
+
if (activate && result.activation_error) {
|
|
255
|
+
logger.error(`Activation failed: ${result.activation_error}`);
|
|
256
|
+
} else if (activate) {
|
|
257
|
+
logger.warn('Plugin installed but not activated. It may have errors — check WordPress admin.');
|
|
159
258
|
}
|
|
160
259
|
}
|
|
161
260
|
}
|
package/src/lib/wordpress-api.js
CHANGED
|
@@ -88,14 +88,53 @@ export class WordPressApi {
|
|
|
88
88
|
body: form.getBuffer(),
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
const body = contentType.includes('application/json') ? await res.json() : await res.text();
|
|
91
|
+
const rawBody = await res.text();
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
// Try to extract JSON from response (may have HTML errors prepended)
|
|
94
|
+
const body = this._extractJson(rawBody);
|
|
95
|
+
|
|
96
|
+
if (body) {
|
|
97
|
+
if (!res.ok && body.message) {
|
|
98
|
+
throw new ApiError(body.message, res.status, body);
|
|
99
|
+
}
|
|
100
|
+
return body;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
// No valid JSON found — response is pure HTML/error
|
|
104
|
+
// Extract readable error from HTML
|
|
105
|
+
const warnings = this._extractHtmlErrors(rawBody);
|
|
106
|
+
throw new ApiError(
|
|
107
|
+
'non_json_response',
|
|
108
|
+
res.status,
|
|
109
|
+
{ rawWarnings: warnings }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_extractJson(text) {
|
|
114
|
+
// Try direct parse first
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(text);
|
|
117
|
+
} catch {
|
|
118
|
+
// JSON might be buried after HTML errors — find it
|
|
119
|
+
const jsonStart = text.indexOf('{"');
|
|
120
|
+
if (jsonStart > 0) {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(text.slice(jsonStart));
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_extractHtmlErrors(html) {
|
|
132
|
+
const errors = [];
|
|
133
|
+
const regex = /<b>(Warning|Fatal error|Parse error|Notice)<\/b>:\s*(.*?)<br/gi;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = regex.exec(html)) !== null) {
|
|
136
|
+
errors.push(match[1] + ': ' + match[2].replace(/<[^>]+>/g, '').trim());
|
|
137
|
+
}
|
|
138
|
+
return errors.length > 0 ? errors : ['Server returned non-JSON response'];
|
|
100
139
|
}
|
|
101
140
|
}
|
package/src/lib/zipper.js
CHANGED
|
@@ -21,7 +21,7 @@ async function loadIgnorePatterns(sourceDir) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export async function createPluginZip(sourceDir, slug) {
|
|
24
|
-
const buildDir = join(sourceDir, '
|
|
24
|
+
const buildDir = join(sourceDir, 'builds');
|
|
25
25
|
await mkdir(buildDir, { recursive: true });
|
|
26
26
|
const zipName = `${slug}.zip`;
|
|
27
27
|
const zipPath = join(buildDir, zipName);
|
|
@@ -61,5 +61,10 @@ function matchGlob(filePath, pattern) {
|
|
|
61
61
|
if (pattern.startsWith('*.')) {
|
|
62
62
|
return filePath.endsWith(pattern.slice(1));
|
|
63
63
|
}
|
|
64
|
+
// Dotfile pattern — match root-level files/dirs starting with .
|
|
65
|
+
if (pattern === '.*') {
|
|
66
|
+
const firstSegment = filePath.split('/')[0];
|
|
67
|
+
return firstSegment.startsWith('.');
|
|
68
|
+
}
|
|
64
69
|
return filePath === pattern;
|
|
65
70
|
}
|