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 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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+ run();
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
+ }