plugship 1.0.3 → 1.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugship",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Deploy local WordPress plugins to remote sites from the command line",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,10 +34,10 @@
34
34
  ],
35
35
  "license": "MIT",
36
36
  "dependencies": {
37
+ "@inquirer/prompts": "^7.2.1",
37
38
  "archiver": "^7.0.1",
38
39
  "chalk": "^5.3.0",
39
40
  "commander": "^12.1.0",
40
- "@inquirer/prompts": "^7.2.1",
41
41
  "form-data": "^4.0.1",
42
42
  "ora": "^8.1.1"
43
43
  }
@@ -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, build
13
+ # .vscode, .idea, tests, phpunit.xml, builds
14
14
 
15
15
  # Source files (uncomment as needed)
16
16
  # src/**
@@ -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
- 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://';
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
- return true;
26
- } catch {
27
- return 'Invalid URL';
28
- }
29
- },
30
- });
34
+ },
35
+ });
31
36
 
32
- const username = await input({
33
- message: 'WordPress username:',
34
- validate: (v) => (v.trim() ? true : 'Required'),
35
- });
37
+ siteUrl = siteUrl.replace(/\/+$/, '');
36
38
 
37
- const appPassword = await password({
38
- message: 'Application password:',
39
- mask: '*',
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
- const siteUrl = url.replace(/\/+$/, '');
44
- const api = new WordPressApi({ url: siteUrl, username, appPassword });
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
- // Test connection
47
- const spin = logger.spinner('Testing connection to WordPress REST API...');
48
- spin.start();
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
- 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
- }
66
+ appPassword = await password({
67
+ message: 'Application password:',
68
+ mask: '*',
69
+ validate: (v) => (v.trim() ? true : 'Required'),
70
+ });
59
71
 
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;
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
- 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;
101
+
102
+ connected = true;
77
103
  }
78
104
 
79
105
  // 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
- );
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.\n`);
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
  }
@@ -27,5 +27,5 @@ export const DEFAULT_EXCLUDES = [
27
27
  '*.log',
28
28
  'tests/**',
29
29
  'phpunit.xml',
30
- 'build/**',
30
+ 'builds/**',
31
31
  ];
@@ -1,10 +1,10 @@
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';
6
6
  import { detectPlugin } from './plugin-detector.js';
7
- import { createPluginZip } from './zipper.js';
7
+ import { createPluginZip, getIgnoreSource } from './zipper.js';
8
8
  import { WordPressApi } from './wordpress-api.js';
9
9
  import { DeployError, ConfigError } from './errors.js';
10
10
  import { RECEIVER_DOWNLOAD_URL } from './constants.js';
@@ -48,18 +48,19 @@ async function getAllSites() {
48
48
  }
49
49
 
50
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
- }
51
+ const source = await getIgnoreSource(cwd);
52
+ if (source) {
53
+ logger.info(`Using ${source} for file exclusions`);
54
+ return;
55
+ }
56
+ logger.warn('No .plugshipignore or .distignore file found.');
57
+ const create = await confirm({
58
+ message: 'Create .plugshipignore with default template?',
59
+ default: true,
60
+ });
61
+ if (create) {
62
+ const { ignoreCommand } = await import('../commands/ignore.js');
63
+ await ignoreCommand([]);
63
64
  }
64
65
  }
65
66
 
@@ -83,12 +84,60 @@ export async function deploy({ siteName, activate = true, dryRun = false, all =
83
84
  // Detect plugin
84
85
  const plugin = await detectPlugin(cwd);
85
86
 
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)`);
87
+ // Check for existing ZIP
88
+ const existingZipPath = join(cwd, 'builds', `${plugin.slug}.zip`);
89
+ let zipPath, size;
90
+
91
+ try {
92
+ const zipStat = await stat(existingZipPath);
93
+ const sizeMB = (zipStat.size / 1024 / 1024).toFixed(2);
94
+
95
+ // Check if ignore file was modified after the ZIP was built
96
+ let ignoreStale = false;
97
+ for (const ignoreFile of ['.plugshipignore', '.distignore']) {
98
+ try {
99
+ const ignoreStat = await stat(join(cwd, ignoreFile));
100
+ if (ignoreStat.mtimeMs > zipStat.mtimeMs) {
101
+ ignoreStale = true;
102
+ }
103
+ break;
104
+ } catch {
105
+ // file not found, try next
106
+ }
107
+ }
108
+
109
+ if (ignoreStale) {
110
+ logger.warn('Ignore file changed since last build — rebuilding ZIP...');
111
+ const spin = logger.spinner('Creating ZIP archive...');
112
+ spin.start();
113
+ ({ zipPath, size } = await createPluginZip(cwd, plugin.slug));
114
+ spin.succeed(`ZIP created (${(size / 1024 / 1024).toFixed(2)} MB)`);
115
+ } else {
116
+ logger.info(`Existing ZIP found: builds/${plugin.slug}.zip (${sizeMB} MB)`);
117
+ const action = await select({
118
+ message: 'What do you want to do?',
119
+ choices: [
120
+ { name: 'Use existing ZIP', value: 'existing' },
121
+ { name: 'Build a new ZIP', value: 'rebuild' },
122
+ ],
123
+ });
124
+ if (action === 'rebuild') {
125
+ const spin = logger.spinner('Creating ZIP archive...');
126
+ spin.start();
127
+ ({ zipPath, size } = await createPluginZip(cwd, plugin.slug));
128
+ spin.succeed(`ZIP created (${(size / 1024 / 1024).toFixed(2)} MB)`);
129
+ } else {
130
+ zipPath = existingZipPath;
131
+ size = zipStat.size;
132
+ logger.success(`Using existing ZIP (${sizeMB} MB)`);
133
+ }
134
+ }
135
+ } catch {
136
+ const spin = logger.spinner('Creating ZIP archive...');
137
+ spin.start();
138
+ ({ zipPath, size } = await createPluginZip(cwd, plugin.slug));
139
+ spin.succeed(`ZIP created (${(size / 1024 / 1024).toFixed(2)} MB)`);
140
+ }
92
141
 
93
142
  // Dry run — show summary and exit
94
143
  if (dryRun) {
package/src/lib/zipper.js CHANGED
@@ -4,24 +4,51 @@ import { stat, mkdir, readFile } from 'node:fs/promises';
4
4
  import archiver from 'archiver';
5
5
  import { DEFAULT_EXCLUDES } from './constants.js';
6
6
 
7
+ async function loadIgnoreFile(filePath) {
8
+ const content = await readFile(filePath, 'utf-8');
9
+ const patterns = [];
10
+ for (const line of content.split('\n')) {
11
+ const trimmed = line.trim();
12
+ if (trimmed && !trimmed.startsWith('#')) {
13
+ patterns.push(trimmed);
14
+ }
15
+ }
16
+ return patterns;
17
+ }
18
+
19
+ export async function getIgnoreSource(sourceDir) {
20
+ try {
21
+ await readFile(join(sourceDir, '.plugshipignore'), 'utf-8');
22
+ return '.plugshipignore';
23
+ } catch {
24
+ try {
25
+ await readFile(join(sourceDir, '.distignore'), 'utf-8');
26
+ return '.distignore';
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ }
32
+
7
33
  async function loadIgnorePatterns(sourceDir) {
8
34
  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
- }
35
+
36
+ // .plugshipignore takes priority, then .distignore
37
+ for (const file of ['.plugshipignore', '.distignore']) {
38
+ try {
39
+ const extra = await loadIgnoreFile(join(sourceDir, file));
40
+ patterns.push(...extra);
41
+ return patterns;
42
+ } catch {
43
+ // file not found, try next
16
44
  }
17
- } catch {
18
- // No .plugshipignore file — use defaults only
19
45
  }
46
+
20
47
  return patterns;
21
48
  }
22
49
 
23
50
  export async function createPluginZip(sourceDir, slug) {
24
- const buildDir = join(sourceDir, 'build');
51
+ const buildDir = join(sourceDir, 'builds');
25
52
  await mkdir(buildDir, { recursive: true });
26
53
  const zipName = `${slug}.zip`;
27
54
  const zipPath = join(buildDir, zipName);
@@ -52,11 +79,16 @@ export async function createPluginZip(sourceDir, slug) {
52
79
  }
53
80
 
54
81
  function matchGlob(filePath, pattern) {
55
- // Strip trailing /** for directory matching
56
- if (pattern.endsWith('/**')) {
57
- const dir = pattern.slice(0, -3);
82
+ // Strip trailing /**, /* or / for directory matching
83
+ if (pattern.endsWith('/**') || pattern.endsWith('/*') || pattern.endsWith('/')) {
84
+ const dir = pattern.replace(/\/\*{0,2}$/, '');
58
85
  return filePath === dir || filePath.startsWith(dir + '/');
59
86
  }
87
+ // **/*.ext — match extension at any depth
88
+ if (pattern.startsWith('**/')) {
89
+ const sub = pattern.slice(3);
90
+ return matchGlob(filePath, sub) || filePath.split('/').some((seg) => matchGlob(seg, sub));
91
+ }
60
92
  // Exact match or wildcard prefix
61
93
  if (pattern.startsWith('*.')) {
62
94
  return filePath.endsWith(pattern.slice(1));
@@ -66,5 +98,10 @@ function matchGlob(filePath, pattern) {
66
98
  const firstSegment = filePath.split('/')[0];
67
99
  return firstSegment.startsWith('.');
68
100
  }
69
- return filePath === pattern;
101
+ // Exact match (file or directory name)
102
+ // "resources" matches "resources", "resources/file.js", "resources/sub/file.js"
103
+ if (filePath === pattern || filePath.startsWith(pattern + '/')) {
104
+ return true;
105
+ }
106
+ return false;
70
107
  }