@loopress/cli 0.33.0 → 0.35.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/README.md +236 -207
- package/dist/commands/app/push.js +2 -1
- package/dist/commands/block/push.js +9 -4
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +10 -16
- package/dist/commands/diff.d.ts +6 -4
- package/dist/commands/diff.js +49 -29
- package/dist/commands/doctor.js +13 -3
- package/dist/commands/form/push.js +3 -2
- package/dist/commands/option/list.js +6 -1
- package/dist/commands/option/push.js +1 -0
- package/dist/commands/page/push.js +1 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/audit.d.ts +1 -1
- package/dist/commands/plugin/audit.js +45 -23
- package/dist/commands/plugin/outdated.d.ts +22 -0
- package/dist/commands/plugin/outdated.js +67 -0
- package/dist/commands/plugin/push.d.ts +2 -1
- package/dist/commands/plugin/push.js +109 -23
- package/dist/commands/plugin/update.d.ts +36 -0
- package/dist/commands/plugin/update.js +121 -0
- package/dist/commands/project/config.js +0 -1
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +8 -4
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +26 -1
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +116 -40
- package/dist/commands/snippet/push.js +3 -2
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +1 -1
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +42 -13
- package/dist/commands/validate.js +3 -0
- package/dist/lib/base.d.ts +7 -0
- package/dist/lib/base.js +55 -2
- package/dist/lib/capabilities.d.ts +5 -0
- package/dist/lib/capabilities.js +16 -0
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +3 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +11 -13
- package/dist/lib/diff-command.d.ts +17 -0
- package/dist/lib/diff-command.js +125 -9
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-snippets.js +3 -5
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +26 -0
- package/dist/lib/push-command.js +78 -12
- package/dist/lib/readdir-tolerant.js +6 -3
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +25 -4
- package/dist/lib/rollback-command.js +1 -1
- package/dist/lib/rollback-materialize.js +1 -1
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +71 -12
- package/dist/lib/wp-client.d.ts +2 -0
- package/dist/lib/wp-client.js +36 -10
- package/dist/types/plugin.d.ts +2 -1
- package/dist/types/plugin.js +32 -1
- package/dist/types/project-config.generated.d.ts +15 -4
- package/dist/utils/block-format.d.ts +2 -0
- package/dist/utils/block-format.js +18 -2
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/option-format.js +14 -1
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/plugins.d.ts +3 -0
- package/dist/utils/plugins.js +6 -1
- package/dist/utils/rename-retry.d.ts +1 -0
- package/dist/utils/rename-retry.js +20 -0
- package/dist/utils/resource-dirs.js +4 -4
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/dist/utils/wporg.d.ts +5 -0
- package/dist/utils/wporg.js +41 -0
- package/oclif.manifest.json +2131 -1150
- package/package.json +14 -14
- package/schemas/project-config.schema.json +21 -4
- package/dist/commands/api/publish.d.ts +0 -9
- package/dist/commands/api/publish.js +0 -45
- package/dist/commands/snippet/publish.d.ts +0 -10
- package/dist/commands/snippet/publish.js +0 -62
- package/dist/lib/resolve-linked-project.d.ts +0 -8
- package/dist/lib/resolve-linked-project.js +0 -27
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { withSpinner } from '../../lib/spinner.js';
|
|
3
|
+
import { pinVersion } from '../../types/plugin.js';
|
|
4
|
+
import { compareVersions, versionJump } from '../../utils/version.js';
|
|
5
|
+
import { requestPluginInfo } from '../../utils/wporg.js';
|
|
6
|
+
export default class Outdated extends LoopressCommand {
|
|
7
|
+
static description = 'Compare the plugins in loopress.json to the latest WordPress.org versions (exits 1 when one is behind or cannot be checked)';
|
|
8
|
+
static enableJsonFlag = true;
|
|
9
|
+
static examples = ['$ lps plugin outdated', '$ lps plugin outdated --json'];
|
|
10
|
+
static flags = {};
|
|
11
|
+
async run() {
|
|
12
|
+
const entries = Object.entries(this.localConfig.plugins ?? {});
|
|
13
|
+
if (entries.length === 0) {
|
|
14
|
+
this.log('No plugins in loopress.json.');
|
|
15
|
+
return { outdated: 0, plugins: [] };
|
|
16
|
+
}
|
|
17
|
+
const plugins = await withSpinner('Checking plugins', async () => Promise.all(entries.map(async ([slug, pin]) => checkPlugin(slug, pinVersion(pin)))));
|
|
18
|
+
const outdated = plugins.filter((p) => p.status === 'outdated').length;
|
|
19
|
+
this.printTable(plugins);
|
|
20
|
+
const failed = plugins.filter((p) => ['closed', 'error'].includes(p.status)).length;
|
|
21
|
+
if (outdated > 0)
|
|
22
|
+
this.log(`\n${outdated} plugin(s) behind WordPress.org.`);
|
|
23
|
+
if (failed > 0)
|
|
24
|
+
this.log(`\n${failed} plugin(s) closed on WordPress.org or could not be checked.`);
|
|
25
|
+
if (outdated === 0 && failed === 0)
|
|
26
|
+
this.log('\nAll plugins are up to date.');
|
|
27
|
+
// exit() throws before oclif prints the --json result, which a CI reading it needs most here.
|
|
28
|
+
if (outdated > 0 || failed > 0) {
|
|
29
|
+
if (this.jsonEnabled())
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
else
|
|
32
|
+
this.exit(1);
|
|
33
|
+
}
|
|
34
|
+
return { outdated, plugins };
|
|
35
|
+
}
|
|
36
|
+
printTable(plugins) {
|
|
37
|
+
const rows = [
|
|
38
|
+
['PLUGIN', 'CURRENT', 'LATEST', 'JUMP'],
|
|
39
|
+
...plugins.map((p) => [p.slug, p.current, p.latest ?? '-', p.jump ?? (['closed', 'error', 'unknown'].includes(p.status) ? p.status : '-')]),
|
|
40
|
+
];
|
|
41
|
+
const widths = [0, 1, 2, 3].map((i) => Math.max(...rows.map((r) => (r[i] ?? '').length)));
|
|
42
|
+
for (const row of rows)
|
|
43
|
+
this.log(row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(' ').trimEnd());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function checkPlugin(slug, current) {
|
|
47
|
+
let info;
|
|
48
|
+
try {
|
|
49
|
+
info = await requestPluginInfo(slug);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { current, jump: null, latest: null, slug, status: 'error' };
|
|
53
|
+
}
|
|
54
|
+
if (info?.error === 'closed')
|
|
55
|
+
return { current, jump: null, latest: null, slug, status: 'closed' };
|
|
56
|
+
const latest = typeof info?.version === 'string' ? info.version : null;
|
|
57
|
+
// Not on WordPress.org (zip, premium), unknown. An unreachable API is an error.
|
|
58
|
+
if (!latest)
|
|
59
|
+
return { current, jump: null, latest: null, slug, status: 'unknown' };
|
|
60
|
+
// "latest" always installs the newest release: it is never behind.
|
|
61
|
+
const jump = versionJump(current, latest);
|
|
62
|
+
if (jump)
|
|
63
|
+
return { current, jump, latest, slug, status: 'outdated' };
|
|
64
|
+
// Equal or ahead is current; a pin that can't be ordered at all is unknown.
|
|
65
|
+
const comparable = current === 'latest' || compareVersions(current, latest) !== null;
|
|
66
|
+
return { current, jump: null, latest, slug, status: comparable ? 'current' : 'unknown' };
|
|
67
|
+
}
|
|
@@ -24,10 +24,11 @@ export default class Push extends PushCommand {
|
|
|
24
24
|
private deactivate;
|
|
25
25
|
private deactivateEndangered;
|
|
26
26
|
private fetchInstanceLock;
|
|
27
|
-
private filesForNewlyInstalled;
|
|
28
27
|
private guardForce;
|
|
28
|
+
private installedBySync;
|
|
29
29
|
private loadSource;
|
|
30
30
|
private logStillInactive;
|
|
31
|
+
private removeStaleCopies;
|
|
31
32
|
private result;
|
|
32
33
|
private sync;
|
|
33
34
|
}
|
|
@@ -3,12 +3,13 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { confirmUninstall } from '../../lib/interactive.js';
|
|
6
|
+
import { causeOf, isRecord } from '../../lib/narrow.js';
|
|
6
7
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
|
-
import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
-
import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
|
|
8
|
+
import { isLocalEnvironment, isNotFoundError, parseBody, responseOf } from '../../lib/wp-client.js';
|
|
9
|
+
import { pinActive, pinProblem, pinVersion, pinVersions, } from '../../types/plugin.js';
|
|
9
10
|
import { parseComposerJson, requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
|
|
10
11
|
import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
|
|
11
|
-
import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
|
|
12
|
+
import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins, preferredCopy, } from '../../utils/plugins.js';
|
|
12
13
|
import { isExactVersion } from '../../utils/version.js';
|
|
13
14
|
const IN_SYNC = {
|
|
14
15
|
activated: [],
|
|
@@ -64,6 +65,9 @@ export default class Push extends PushCommand {
|
|
|
64
65
|
if (!hasFloatingPin && isNoop(diff, toPrune)) {
|
|
65
66
|
this.log('Everything is already in sync.');
|
|
66
67
|
this.logStillInactive(skippedActivations);
|
|
68
|
+
// The inventory still matters to the console when nothing had to change.
|
|
69
|
+
if (!this.dryRun)
|
|
70
|
+
await this.recordDeployment('success');
|
|
67
71
|
return { ...IN_SYNC };
|
|
68
72
|
}
|
|
69
73
|
if (isNoop(diff, toPrune))
|
|
@@ -74,8 +78,9 @@ export default class Push extends PushCommand {
|
|
|
74
78
|
}
|
|
75
79
|
if (!(await confirmUninstall(diff.toRemove, this.yes)))
|
|
76
80
|
this.error('Aborted.');
|
|
81
|
+
const switchedOff = await this.deactivateEndangered(installed, diff, force);
|
|
77
82
|
// Plugins switched off for the file swap come back on, unless loopress.json wants them inactive.
|
|
78
|
-
const deactivated =
|
|
83
|
+
const deactivated = switchedOff.filter((p) => !diff.toRemove.includes(p.slug) && (fromComposer || pinActive(source.manifest[p.slug])));
|
|
79
84
|
let response;
|
|
80
85
|
try {
|
|
81
86
|
response = await this.sync(source.intent, force);
|
|
@@ -86,9 +91,14 @@ export default class Push extends PushCommand {
|
|
|
86
91
|
await this.activate(deactivated);
|
|
87
92
|
throw error;
|
|
88
93
|
}
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
94
|
+
const wasActive = new Set(switchedOff.map((p) => p.slug));
|
|
95
|
+
const activateNew = (slug) => fromComposer ? activate || wasActive.has(slug) : pinActive(source.manifest[slug]);
|
|
96
|
+
const { newlyInstalled, takenOver } = await this.installedBySync(installed, diff, force);
|
|
97
|
+
const toActivate = [
|
|
98
|
+
...deactivated.filter((p) => takenOver.every((t) => t.slug !== p.slug)),
|
|
99
|
+
...diff.toActivate,
|
|
100
|
+
...[...newlyInstalled, ...takenOver].filter((p) => activateNew(p.slug)),
|
|
101
|
+
];
|
|
92
102
|
const toDeactivate = [...diff.toDeactivate, ...installed.filter((p) => toPrune.includes(p.slug))];
|
|
93
103
|
await this.deactivate(toDeactivate);
|
|
94
104
|
await this.activate(toActivate);
|
|
@@ -105,10 +115,33 @@ export default class Push extends PushCommand {
|
|
|
105
115
|
status: 'success',
|
|
106
116
|
});
|
|
107
117
|
}
|
|
118
|
+
// WordPress refuses to activate a plugin whose `Requires Plugins` are inactive, and loopress.json
|
|
119
|
+
// has no dependency order (dokan-lite sorts before woocommerce). A refused plugin is retried
|
|
120
|
+
// after the others, until a round activates nothing.
|
|
108
121
|
async activate(plugins) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
let pending = plugins;
|
|
123
|
+
while (pending.length > 0) {
|
|
124
|
+
const blocked = [];
|
|
125
|
+
let missing = [];
|
|
126
|
+
for (const plugin of pending) {
|
|
127
|
+
this.log(` ⊙ activating ${plugin.slug}`);
|
|
128
|
+
try {
|
|
129
|
+
await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const notInstalled = missingDependencies(error);
|
|
133
|
+
if (!notInstalled)
|
|
134
|
+
throw error;
|
|
135
|
+
blocked.push(plugin);
|
|
136
|
+
missing = [...missing, ...notInstalled];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (blocked.length === pending.length) {
|
|
140
|
+
const hint = missing.length > 0 ? ` Not installed: ${[...new Set(missing)].join(', ')}.` : '';
|
|
141
|
+
this.error(`Could not activate ${blocked.map((p) => p.slug).join(', ')}: WordPress reports required plugins missing or inactive.${hint} ` +
|
|
142
|
+
'Add them with `lps plugin add <slug>`, then push again.');
|
|
143
|
+
}
|
|
144
|
+
pending = blocked;
|
|
112
145
|
}
|
|
113
146
|
}
|
|
114
147
|
async deactivate(plugins) {
|
|
@@ -145,19 +178,6 @@ export default class Push extends PushCommand {
|
|
|
145
178
|
throw error;
|
|
146
179
|
}
|
|
147
180
|
}
|
|
148
|
-
// toInstall entries carry no `file`: the plugin didn't exist on the site at diff time, so its
|
|
149
|
-
// WordPress core plugin id (folder/main-file.php) is only knowable after sync() has actually
|
|
150
|
-
// installed it. Refetch to get it, then activate like everything else.
|
|
151
|
-
async filesForNewlyInstalled(toInstall) {
|
|
152
|
-
if (toInstall.length === 0)
|
|
153
|
-
return [];
|
|
154
|
-
const installedAfterSync = parseInstalledPlugins(await this.wp.get('wp/v2/plugins'));
|
|
155
|
-
const fileBySlug = new Map(installedAfterSync.map((p) => [p.slug, p.file]));
|
|
156
|
-
return toInstall.flatMap(({ slug }) => {
|
|
157
|
-
const file = fileBySlug.get(slug);
|
|
158
|
-
return file ? [{ file, slug }] : [];
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
181
|
guardForce(diff, force, file) {
|
|
162
182
|
if (force)
|
|
163
183
|
return;
|
|
@@ -174,6 +194,21 @@ export default class Push extends PushCommand {
|
|
|
174
194
|
`migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in ${file}.`);
|
|
175
195
|
}
|
|
176
196
|
}
|
|
197
|
+
// New and taken-over plugins only get their final WordPress id (folder/main-file) once
|
|
198
|
+
// sync() has installed them: read it from the site. A taken-over plugin is activated from
|
|
199
|
+
// the folder Composer installed, with no stale copy left beside it.
|
|
200
|
+
async installedBySync(installed, diff, force) {
|
|
201
|
+
const collisions = force ? diff.collisions.map((c) => c.slug) : [];
|
|
202
|
+
if (diff.toInstall.length === 0 && collisions.length === 0)
|
|
203
|
+
return { newlyInstalled: [], takenOver: [] };
|
|
204
|
+
const afterSync = parseInstalledPlugins(await this.wp.get('wp/v2/plugins'));
|
|
205
|
+
const takenOver = filesFor(collisions, afterSync);
|
|
206
|
+
const kept = await this.removeStaleCopies(installed, afterSync, takenOver);
|
|
207
|
+
return {
|
|
208
|
+
newlyInstalled: filesFor(diff.toInstall.map((p) => p.slug), afterSync),
|
|
209
|
+
takenOver: takenOver.filter((p) => !kept.includes(p.slug)),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
177
212
|
async loadSource(activate) {
|
|
178
213
|
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
179
214
|
if (existsSync(composerJsonPath)) {
|
|
@@ -193,6 +228,10 @@ export default class Push extends PushCommand {
|
|
|
193
228
|
if (Object.keys(manifest).length === 0) {
|
|
194
229
|
this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
|
|
195
230
|
}
|
|
231
|
+
// A mistyped key (`activate`) would otherwise push the plugin with the default state.
|
|
232
|
+
const problems = Object.entries(manifest).flatMap(([slug, pin]) => pinProblem(slug, pin) ?? []);
|
|
233
|
+
if (problems.length > 0)
|
|
234
|
+
this.error(`Invalid loopress.json: ${problems.join('; ')}. Fix it, then push again.`);
|
|
196
235
|
return {
|
|
197
236
|
file: 'loopress.json',
|
|
198
237
|
intent: { plugins: pinVersions(manifest) },
|
|
@@ -207,6 +246,34 @@ export default class Push extends PushCommand {
|
|
|
207
246
|
this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
|
|
208
247
|
this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
|
|
209
248
|
}
|
|
249
|
+
// A takeover installs `wp-content/plugins/<slug>/`, but the plugin it replaces may live
|
|
250
|
+
// elsewhere: core's Hello Dolly is `hello.php`, which the site lists under the slug
|
|
251
|
+
// `hello-dolly`. Composer leaves that file in place, and both copies declare the same
|
|
252
|
+
// functions, so activating them together would fatal: delete the stale copy, as --force says.
|
|
253
|
+
// Returns the slugs whose stale copy could not be deleted: the caller must not activate
|
|
254
|
+
// their replacement beside it. Copies the sync already removed are skipped, and a failed
|
|
255
|
+
// deletion does not stop the others or the activations.
|
|
256
|
+
async removeStaleCopies(installed, afterSync, managedCopies) {
|
|
257
|
+
const replaced = installed.flatMap((old) => {
|
|
258
|
+
const copy = managedCopies.find((c) => c.slug === old.slug && c.file !== old.file);
|
|
259
|
+
return copy && afterSync.some((a) => a.file === old.file) ? [{ copy, old }] : [];
|
|
260
|
+
});
|
|
261
|
+
const kept = [];
|
|
262
|
+
for (const { copy, old } of replaced) {
|
|
263
|
+
this.log(` ✕ removing ${old.file} (replaced by ${copy.file})`);
|
|
264
|
+
try {
|
|
265
|
+
// Deactivated before the sync if it was active (deactivateEndangered): WordPress
|
|
266
|
+
// refuses to delete an active plugin.
|
|
267
|
+
await this.wp.delete(`wp/v2/plugins/${old.file}`);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
kept.push(old.slug);
|
|
271
|
+
this.warn(`Could not delete ${old.file}: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
272
|
+
`${copy.file} stays inactive until it is deleted: remove ${old.file} in wp-admin, then activate ${copy.slug}.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return kept;
|
|
276
|
+
}
|
|
210
277
|
result(diff, opts) {
|
|
211
278
|
const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
|
|
212
279
|
return {
|
|
@@ -232,6 +299,25 @@ export default class Push extends PushCommand {
|
|
|
232
299
|
}
|
|
233
300
|
}
|
|
234
301
|
}
|
|
302
|
+
// The dependency slugs not installed at all when WordPress refused an activation over unmet
|
|
303
|
+
// `Requires Plugins` (empty when they are only inactive), or null for any other error. The REST
|
|
304
|
+
// controller adds `{status: 500}` as the error data, which pushes activate_plugin()'s own data
|
|
305
|
+
// (`{not_installed, inactive}`) into `additional_data`.
|
|
306
|
+
function missingDependencies(error) {
|
|
307
|
+
const body = parseBody(responseOf(causeOf(error)).body);
|
|
308
|
+
if (body.code !== 'plugin_missing_dependencies')
|
|
309
|
+
return null;
|
|
310
|
+
const data = [body.data, ...(Array.isArray(body.additional_data) ? body.additional_data : [])];
|
|
311
|
+
return data.flatMap((d) => (isRecord(d) && isRecord(d.not_installed) ? Object.keys(d.not_installed) : []));
|
|
312
|
+
}
|
|
313
|
+
// The WordPress plugin id of each slug, read after sync() has installed it: a plugin the diff
|
|
314
|
+
// saw as missing (or as an unmanaged copy) has no id, or the wrong one, before that.
|
|
315
|
+
function filesFor(slugs, installed) {
|
|
316
|
+
return slugs.flatMap((slug) => {
|
|
317
|
+
const match = preferredCopy(installed.filter((p) => p.slug === slug), slug);
|
|
318
|
+
return match ? [{ file: match.file, slug }] : [];
|
|
319
|
+
});
|
|
320
|
+
}
|
|
235
321
|
function isNoop(diff, toPrune) {
|
|
236
322
|
return (diff.toInstall.length === 0 &&
|
|
237
323
|
diff.toPin.length === 0 &&
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type VersionJump } from '../../utils/version.js';
|
|
3
|
+
export type PluginChange = {
|
|
4
|
+
from: string;
|
|
5
|
+
jump: VersionJump;
|
|
6
|
+
slug: string;
|
|
7
|
+
to: string;
|
|
8
|
+
};
|
|
9
|
+
export type SkippedPlugin = {
|
|
10
|
+
reason: 'current' | 'error' | 'latest' | 'unknown';
|
|
11
|
+
slug: string;
|
|
12
|
+
version: string;
|
|
13
|
+
};
|
|
14
|
+
type UpdateResult = {
|
|
15
|
+
changes: PluginChange[];
|
|
16
|
+
skipped: SkippedPlugin[];
|
|
17
|
+
};
|
|
18
|
+
type Cap = 'minor' | 'patch' | undefined;
|
|
19
|
+
export default class Update extends LoopressCommand {
|
|
20
|
+
static args: {
|
|
21
|
+
slug: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
22
|
+
};
|
|
23
|
+
static description: string;
|
|
24
|
+
static enableJsonFlag: boolean;
|
|
25
|
+
static examples: string[];
|
|
26
|
+
static flags: {
|
|
27
|
+
minor: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
28
|
+
patch: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
29
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
30
|
+
};
|
|
31
|
+
static strict: boolean;
|
|
32
|
+
run(): Promise<UpdateResult>;
|
|
33
|
+
private write;
|
|
34
|
+
}
|
|
35
|
+
export declare function pickUpdate(current: string, latest: string, versions: unknown, cap: Cap): null | string;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { withSpinner } from '../../lib/spinner.js';
|
|
4
|
+
import { pinVersion } from '../../types/plugin.js';
|
|
5
|
+
import { writeLocalConfig } from '../../utils/loopress-config.js';
|
|
6
|
+
import { compareVersions, isExactVersion, versionJump } from '../../utils/version.js';
|
|
7
|
+
import { requestPluginInfo } from '../../utils/wporg.js';
|
|
8
|
+
export default class Update extends LoopressCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
slug: Args.string({ description: 'Plugin slug(s) in loopress.json to update (default: all of them)' }),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Rewrite plugin versions in loopress.json to the latest WordPress.org releases (loopress.json only, run `lps plugin push` to apply)';
|
|
13
|
+
static enableJsonFlag = true;
|
|
14
|
+
static examples = [
|
|
15
|
+
'$ lps plugin update',
|
|
16
|
+
'$ lps plugin update woocommerce akismet',
|
|
17
|
+
'$ lps plugin update --minor',
|
|
18
|
+
'$ lps plugin update --patch --json',
|
|
19
|
+
];
|
|
20
|
+
static flags = {
|
|
21
|
+
...LoopressCommand.dryRunFlag,
|
|
22
|
+
minor: Flags.boolean({ description: 'Stay on the current major version (minor and patch releases only)', exclusive: ['patch'] }),
|
|
23
|
+
patch: Flags.boolean({ description: 'Stay on the current minor version (patch releases only)', exclusive: ['minor'] }),
|
|
24
|
+
};
|
|
25
|
+
static strict = false;
|
|
26
|
+
async run() {
|
|
27
|
+
const { argv, flags } = await this.parse(Update);
|
|
28
|
+
const pins = this.localConfig.plugins ?? {};
|
|
29
|
+
const requested = [...new Set(argv.map(String))];
|
|
30
|
+
const missing = requested.filter((slug) => !Object.hasOwn(pins, slug));
|
|
31
|
+
if (missing.length > 0)
|
|
32
|
+
this.error(`Not in loopress.json: ${missing.join(', ')}. Add it with \`lps plugin add\`.`);
|
|
33
|
+
const targets = Object.entries(pins).filter(([slug]) => requested.length === 0 || requested.includes(slug));
|
|
34
|
+
if (targets.length === 0) {
|
|
35
|
+
this.log('No plugins in loopress.json.');
|
|
36
|
+
return { changes: [], skipped: [] };
|
|
37
|
+
}
|
|
38
|
+
const results = await withSpinner('Checking plugins', async () => Promise.all(targets.map(async ([slug, pin]) => resolveUpdate(slug, pinVersion(pin), capOf(flags)))));
|
|
39
|
+
const changes = results.filter((r) => 'to' in r);
|
|
40
|
+
const skipped = results.filter((r) => 'reason' in r);
|
|
41
|
+
for (const c of changes)
|
|
42
|
+
this.log(`${this.dryRun ? '[dry-run] ' : ''}${c.slug} ${c.from} -> ${c.to} (${c.jump})`);
|
|
43
|
+
const unchecked = skipped.some((s) => s.reason === 'unknown' || s.reason === 'error');
|
|
44
|
+
if (!unchecked && changes.length === 0)
|
|
45
|
+
this.log('Every plugin is already at the newest version allowed.');
|
|
46
|
+
else if (changes.length > 0 && !this.dryRun)
|
|
47
|
+
await this.write(changes);
|
|
48
|
+
const failed = skipped.filter((s) => s.reason === 'error').map((s) => s.slug);
|
|
49
|
+
if (failed.length > 0) {
|
|
50
|
+
this.warn(`Could not check ${failed.join(', ')} (WordPress.org unreachable).`);
|
|
51
|
+
// Same as `plugin outdated`: exit() would throw before oclif prints the --json result.
|
|
52
|
+
if (this.jsonEnabled())
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
else
|
|
55
|
+
this.exit(1);
|
|
56
|
+
}
|
|
57
|
+
return { changes, skipped };
|
|
58
|
+
}
|
|
59
|
+
async write(changes) {
|
|
60
|
+
const plugins = { ...this.localConfig.plugins };
|
|
61
|
+
for (const { slug, to } of changes) {
|
|
62
|
+
const pin = plugins[slug];
|
|
63
|
+
// An object pin keeps its other keys (an inactive plugin stays inactive).
|
|
64
|
+
plugins[slug] = typeof pin === 'object' ? { ...pin, version: to } : to;
|
|
65
|
+
}
|
|
66
|
+
await writeLocalConfig({ ...this.localConfig, plugins });
|
|
67
|
+
this.log(`\nUpdated ${changes.length} plugin(s) in loopress.json. Run \`lps plugin push\` to apply.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function capOf(flags) {
|
|
71
|
+
if (flags.patch)
|
|
72
|
+
return 'patch';
|
|
73
|
+
return flags.minor ? 'minor' : undefined;
|
|
74
|
+
}
|
|
75
|
+
async function resolveUpdate(slug, current, cap) {
|
|
76
|
+
if (current === 'latest')
|
|
77
|
+
return { reason: 'latest', slug, version: current };
|
|
78
|
+
// A wildcard or range constraint (9.4.x) is not an exact pin: rewriting it would widen it.
|
|
79
|
+
if (!isExactVersion(current))
|
|
80
|
+
return { reason: 'unknown', slug, version: current };
|
|
81
|
+
let info;
|
|
82
|
+
try {
|
|
83
|
+
info = await requestPluginInfo(slug, { versions: cap !== undefined });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { reason: 'error', slug, version: current };
|
|
87
|
+
}
|
|
88
|
+
const latest = typeof info?.version === 'string' ? info.version : null;
|
|
89
|
+
if (!latest || compareVersions(current, latest) === null)
|
|
90
|
+
return { reason: 'unknown', slug, version: current };
|
|
91
|
+
// A cap needs the release list: without it an eligible release could be missed silently.
|
|
92
|
+
const listed = info?.versions;
|
|
93
|
+
if (cap !== undefined && (typeof listed !== 'object' || listed === null || Object.keys(listed).length === 0)) {
|
|
94
|
+
return { reason: 'unknown', slug, version: current };
|
|
95
|
+
}
|
|
96
|
+
const to = pickUpdate(current, latest, info?.versions, cap);
|
|
97
|
+
const jump = to ? versionJump(current, to) : null;
|
|
98
|
+
return to && jump ? { from: current, jump, slug, to } : { reason: 'current', slug, version: current };
|
|
99
|
+
}
|
|
100
|
+
// The version `current` should move to: `latest` without a cap, otherwise the newest stable
|
|
101
|
+
// release from WordPress.org's version list that keeps the major (--minor) or the major and
|
|
102
|
+
// minor (--patch). Null when nothing newer qualifies.
|
|
103
|
+
export function pickUpdate(current, latest, versions, cap) {
|
|
104
|
+
if (cap === undefined)
|
|
105
|
+
return versionJump(current, latest) ? latest : null;
|
|
106
|
+
const listed = typeof versions === 'object' && versions !== null ? Object.keys(versions) : [];
|
|
107
|
+
let best = null;
|
|
108
|
+
for (const candidate of [...listed, latest]) {
|
|
109
|
+
// Skips "trunk", prereleases and anything beyond the advertised latest release.
|
|
110
|
+
if (!/^\d+(?:\.\d+){0,3}$/.test(candidate) || !isExactVersion(candidate))
|
|
111
|
+
continue;
|
|
112
|
+
if ((compareVersions(candidate, latest) ?? 1) > 0)
|
|
113
|
+
continue;
|
|
114
|
+
const jump = versionJump(current, candidate);
|
|
115
|
+
if (!jump || jump === 'major' || (cap === 'patch' && jump === 'minor'))
|
|
116
|
+
continue;
|
|
117
|
+
if (best === null || (compareVersions(candidate, best) ?? 0) > 0)
|
|
118
|
+
best = candidate;
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
@@ -94,7 +94,6 @@ export default class Config extends Command {
|
|
|
94
94
|
}
|
|
95
95
|
this.log(`✓ "${projectName}/${envName}" configured`);
|
|
96
96
|
await this.maybeBootstrapLoopressFull(env);
|
|
97
|
-
this.log('→ Run `lps project switch` to change the active project or environment');
|
|
98
97
|
}
|
|
99
98
|
async maybeBootstrapLoopressFull(env) {
|
|
100
99
|
const { name, token, url } = env;
|
|
@@ -14,6 +14,7 @@ export default class Promote extends Command {
|
|
|
14
14
|
static examples: string[];
|
|
15
15
|
static flags: {
|
|
16
16
|
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
18
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
19
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
20
|
};
|
package/dist/commands/promote.js
CHANGED
|
@@ -20,15 +20,17 @@ export default class Promote extends Command {
|
|
|
20
20
|
};
|
|
21
21
|
static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
|
|
22
22
|
static enableJsonFlag = true;
|
|
23
|
-
static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
|
|
23
|
+
static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run', '$ lps promote staging production --skip-unsupported'];
|
|
24
24
|
static flags = {
|
|
25
25
|
...LoopressCommand.dryRunFlag,
|
|
26
26
|
...LoopressCommand.yesFlag,
|
|
27
|
+
...LoopressCommand.skipUnsupportedFlag,
|
|
27
28
|
overwrite: PushCommand.baseFlags.overwrite,
|
|
28
29
|
};
|
|
29
30
|
async run() {
|
|
30
31
|
const { args, flags } = await this.parse(Promote);
|
|
31
32
|
const { overwrite, yes } = flags;
|
|
33
|
+
const skipUnsupported = flags['skip-unsupported'];
|
|
32
34
|
const dryRun = flags['dry-run'];
|
|
33
35
|
const { environments, projectName } = await this.resolveProject();
|
|
34
36
|
const from = this.requireEnvironment(environments, projectName, args.from);
|
|
@@ -52,7 +54,7 @@ export default class Promote extends Command {
|
|
|
52
54
|
try {
|
|
53
55
|
this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
|
|
54
56
|
try {
|
|
55
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
|
|
57
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false, skipUnsupported)));
|
|
56
58
|
}
|
|
57
59
|
catch (error) {
|
|
58
60
|
// A partial pull must never be pushed onward: stop before touching <to>.
|
|
@@ -61,7 +63,7 @@ export default class Promote extends Command {
|
|
|
61
63
|
this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${errorMessage(error)}`);
|
|
62
64
|
}
|
|
63
65
|
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
64
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
|
|
66
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun, skipUnsupported), ...(overwrite ? ['--overwrite'] : [])]));
|
|
65
67
|
}
|
|
66
68
|
finally {
|
|
67
69
|
if (scratch) {
|
|
@@ -114,8 +116,10 @@ export default class Promote extends Command {
|
|
|
114
116
|
// `--yes` is always forwarded: confirmPromotion above already gathered intent once, so the
|
|
115
117
|
// delegated `pull` and `push` must not prompt again (push would otherwise re-run its own
|
|
116
118
|
// production guard, and pull its orphan-deletion prompt).
|
|
117
|
-
delegateArgv(env, dryRun) {
|
|
119
|
+
delegateArgv(env, dryRun, skipUnsupported) {
|
|
118
120
|
const argv = ['--env', env, '--yes'];
|
|
121
|
+
if (skipUnsupported)
|
|
122
|
+
argv.push('--skip-unsupported');
|
|
119
123
|
if (dryRun)
|
|
120
124
|
argv.push('--dry-run');
|
|
121
125
|
return argv;
|
package/dist/commands/pull.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
|
|
|
2
2
|
type PullTargetResult = {
|
|
3
3
|
error?: string;
|
|
4
4
|
label: string;
|
|
5
|
+
reason?: string;
|
|
5
6
|
status: 'failed' | 'pulled' | 'skipped';
|
|
6
7
|
};
|
|
7
8
|
type PullResult = {
|
|
@@ -12,14 +13,18 @@ export default class Pull extends LoopressCommand {
|
|
|
12
13
|
static enableJsonFlag: boolean;
|
|
13
14
|
static examples: string[];
|
|
14
15
|
static flags: {
|
|
16
|
+
'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
17
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
18
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
19
|
};
|
|
18
20
|
private failedCount;
|
|
21
|
+
private skipUnsupported;
|
|
22
|
+
private readonly unsupported;
|
|
19
23
|
run(): Promise<PullResult>;
|
|
20
24
|
private buildArgv;
|
|
21
25
|
private globalStylesSkipNote;
|
|
22
26
|
private projectUses;
|
|
23
27
|
private unavailable;
|
|
28
|
+
private warnUnsupported;
|
|
24
29
|
}
|
|
25
30
|
export {};
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
|
-
import { missingIntegration, missingModule } from '../lib/capabilities.js';
|
|
2
|
+
import { integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
|
|
3
|
+
import { declaredPlugins } from '../lib/declared-plugins.js';
|
|
3
4
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
4
5
|
import { errorMessage } from '../lib/narrow.js';
|
|
5
6
|
import { pluralize } from '../utils/pluralize.js';
|
|
@@ -42,9 +43,15 @@ export default class Pull extends LoopressCommand {
|
|
|
42
43
|
static flags = {
|
|
43
44
|
...LoopressCommand.dryRunFlag,
|
|
44
45
|
...LoopressCommand.yesFlag,
|
|
46
|
+
...LoopressCommand.skipUnsupportedFlag,
|
|
45
47
|
};
|
|
46
48
|
failedCount = 0;
|
|
49
|
+
skipUnsupported = false;
|
|
50
|
+
// What --skip-unsupported held back: the target and why, for the final warning.
|
|
51
|
+
unsupported = [];
|
|
47
52
|
async run() {
|
|
53
|
+
const { flags } = await this.parse(Pull);
|
|
54
|
+
this.skipUnsupported = flags['skip-unsupported'];
|
|
48
55
|
const results = [];
|
|
49
56
|
const capabilities = await this.capabilities();
|
|
50
57
|
for (const target of PULL_TARGETS) {
|
|
@@ -73,12 +80,14 @@ export default class Pull extends LoopressCommand {
|
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
82
|
if (this.failedCount > 0) {
|
|
83
|
+
await this.warnUnsupported();
|
|
76
84
|
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
77
85
|
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
78
86
|
const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
|
|
79
87
|
this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
|
|
80
88
|
}
|
|
81
89
|
this.log('\nAll resources pulled.');
|
|
90
|
+
await this.warnUnsupported();
|
|
82
91
|
return { results };
|
|
83
92
|
}
|
|
84
93
|
// `--yes` is forwarded only when the user passed it and the target accepts it: unlike
|
|
@@ -116,9 +125,25 @@ export default class Pull extends LoopressCommand {
|
|
|
116
125
|
this.log(`\n→ ${target.label}: ${missing.needsFull ? missing.reason : 'not configured'}, skipped`);
|
|
117
126
|
return { label: target.label, status: 'skipped' };
|
|
118
127
|
}
|
|
128
|
+
if (this.skipUnsupported) {
|
|
129
|
+
this.unsupported.push({ label: target.label, missing });
|
|
130
|
+
this.log(`\n→ ${target.label}: ${missing.reason}, skipped (--skip-unsupported)`);
|
|
131
|
+
return { label: target.label, reason: `${missing.reason} on ${this.siteConfig.name}`, status: 'skipped' };
|
|
132
|
+
}
|
|
119
133
|
this.failedCount++;
|
|
120
134
|
const error = `${missing.reason} on ${this.siteConfig.name}`;
|
|
121
135
|
this.log(`\n✗ ${target.label} failed: ${error}`);
|
|
122
136
|
return { error, label: target.label, status: 'failed' };
|
|
123
137
|
}
|
|
138
|
+
async warnUnsupported() {
|
|
139
|
+
if (this.unsupported.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
|
|
142
|
+
const lines = this.unsupported.map(({ label, missing }) => {
|
|
143
|
+
const fix = integrationFix(missing, declared);
|
|
144
|
+
const fixSentence = fix ? ` ${fix}.` : '';
|
|
145
|
+
return `${label}: ${missing.reason} on ${this.siteConfig.name}.${fixSentence}`;
|
|
146
|
+
});
|
|
147
|
+
this.warn(`Skipped with --skip-unsupported, this site can't hold them:\n ${lines.join('\n ')}`);
|
|
148
|
+
}
|
|
124
149
|
}
|
package/dist/commands/push.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
|
|
|
2
2
|
type PushTargetResult = {
|
|
3
3
|
error?: string;
|
|
4
4
|
label: string;
|
|
5
|
+
reason?: string;
|
|
5
6
|
result?: unknown;
|
|
6
7
|
status: 'failed' | 'pushed' | 'skipped';
|
|
7
8
|
};
|
|
@@ -14,16 +15,24 @@ export default class Push extends LoopressCommand {
|
|
|
14
15
|
static examples: string[];
|
|
15
16
|
static flags: {
|
|
16
17
|
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
only: ReturnType<typeof import("@oclif/core/flags").string>;
|
|
20
|
+
skip: ReturnType<typeof import("@oclif/core/flags").string>;
|
|
17
21
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
22
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
23
|
};
|
|
24
|
+
private skipUnsupported;
|
|
25
|
+
private readonly unsupported;
|
|
20
26
|
run(): Promise<PushResult>;
|
|
21
27
|
private applySiteSteps;
|
|
22
28
|
private buildArgv;
|
|
23
29
|
private composerJsonPath;
|
|
24
30
|
private declaredPlugins;
|
|
31
|
+
private doneMessage;
|
|
25
32
|
private globalStylesSkipNote;
|
|
26
33
|
private guardProductionPush;
|
|
27
34
|
private skippedTargets;
|
|
35
|
+
private unsupportedLine;
|
|
36
|
+
private warnUnsupported;
|
|
28
37
|
}
|
|
29
38
|
export {};
|