@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.
Files changed (92) hide show
  1. package/README.md +236 -207
  2. package/dist/commands/app/push.js +2 -1
  3. package/dist/commands/block/push.js +9 -4
  4. package/dist/commands/dev.d.ts +2 -2
  5. package/dist/commands/dev.js +10 -16
  6. package/dist/commands/diff.d.ts +6 -4
  7. package/dist/commands/diff.js +49 -29
  8. package/dist/commands/doctor.js +13 -3
  9. package/dist/commands/form/push.js +3 -2
  10. package/dist/commands/option/list.js +6 -1
  11. package/dist/commands/option/push.js +1 -0
  12. package/dist/commands/page/push.js +1 -0
  13. package/dist/commands/plugin/add.d.ts +1 -0
  14. package/dist/commands/plugin/add.js +50 -9
  15. package/dist/commands/plugin/audit.d.ts +1 -1
  16. package/dist/commands/plugin/audit.js +45 -23
  17. package/dist/commands/plugin/outdated.d.ts +22 -0
  18. package/dist/commands/plugin/outdated.js +67 -0
  19. package/dist/commands/plugin/push.d.ts +2 -1
  20. package/dist/commands/plugin/push.js +109 -23
  21. package/dist/commands/plugin/update.d.ts +36 -0
  22. package/dist/commands/plugin/update.js +121 -0
  23. package/dist/commands/project/config.js +0 -1
  24. package/dist/commands/promote.d.ts +1 -0
  25. package/dist/commands/promote.js +8 -4
  26. package/dist/commands/pull.d.ts +5 -0
  27. package/dist/commands/pull.js +26 -1
  28. package/dist/commands/push.d.ts +9 -0
  29. package/dist/commands/push.js +116 -40
  30. package/dist/commands/snippet/push.js +3 -2
  31. package/dist/commands/theme/style/diff.js +1 -1
  32. package/dist/commands/theme/style/push.js +1 -1
  33. package/dist/commands/theme/style/rollback.js +1 -1
  34. package/dist/commands/theme/template/push.d.ts +2 -0
  35. package/dist/commands/theme/template/push.js +42 -13
  36. package/dist/commands/validate.js +3 -0
  37. package/dist/lib/base.d.ts +7 -0
  38. package/dist/lib/base.js +55 -2
  39. package/dist/lib/capabilities.d.ts +5 -0
  40. package/dist/lib/capabilities.js +16 -0
  41. package/dist/lib/declared-plugins.d.ts +9 -0
  42. package/dist/lib/declared-plugins.js +21 -0
  43. package/dist/lib/declared-type-command.js +3 -0
  44. package/dist/lib/dev-watch.d.ts +1 -1
  45. package/dist/lib/dev-watch.js +11 -13
  46. package/dist/lib/diff-command.d.ts +17 -0
  47. package/dist/lib/diff-command.js +125 -9
  48. package/dist/lib/environment-urls.d.ts +6 -0
  49. package/dist/lib/environment-urls.js +59 -0
  50. package/dist/lib/exclude.d.ts +16 -0
  51. package/dist/lib/exclude.js +102 -0
  52. package/dist/lib/load-snippets.js +3 -5
  53. package/dist/lib/php-files-command.js +5 -3
  54. package/dist/lib/push-command.d.ts +26 -0
  55. package/dist/lib/push-command.js +78 -12
  56. package/dist/lib/readdir-tolerant.js +6 -3
  57. package/dist/lib/resource-selection.d.ts +9 -0
  58. package/dist/lib/resource-selection.js +49 -0
  59. package/dist/lib/resource-state.d.ts +2 -0
  60. package/dist/lib/resource-state.js +25 -4
  61. package/dist/lib/rollback-command.js +1 -1
  62. package/dist/lib/rollback-materialize.js +1 -1
  63. package/dist/lib/validate-local.d.ts +1 -0
  64. package/dist/lib/validate-local.js +71 -12
  65. package/dist/lib/wp-client.d.ts +2 -0
  66. package/dist/lib/wp-client.js +36 -10
  67. package/dist/types/plugin.d.ts +2 -1
  68. package/dist/types/plugin.js +32 -1
  69. package/dist/types/project-config.generated.d.ts +15 -4
  70. package/dist/utils/block-format.d.ts +2 -0
  71. package/dist/utils/block-format.js +18 -2
  72. package/dist/utils/option-format.d.ts +1 -0
  73. package/dist/utils/option-format.js +14 -1
  74. package/dist/utils/page-format.js +2 -1
  75. package/dist/utils/plugins.d.ts +3 -0
  76. package/dist/utils/plugins.js +6 -1
  77. package/dist/utils/rename-retry.d.ts +1 -0
  78. package/dist/utils/rename-retry.js +20 -0
  79. package/dist/utils/resource-dirs.js +4 -4
  80. package/dist/utils/version.d.ts +2 -0
  81. package/dist/utils/version.js +21 -0
  82. package/dist/utils/wporg.d.ts +5 -0
  83. package/dist/utils/wporg.js +41 -0
  84. package/oclif.manifest.json +2131 -1150
  85. package/package.json +14 -14
  86. package/schemas/project-config.schema.json +21 -4
  87. package/dist/commands/api/publish.d.ts +0 -9
  88. package/dist/commands/api/publish.js +0 -45
  89. package/dist/commands/snippet/publish.d.ts +0 -10
  90. package/dist/commands/snippet/publish.js +0 -62
  91. package/dist/lib/resolve-linked-project.d.ts +0 -8
  92. 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 = (await this.deactivateEndangered(installed, diff, force)).filter((p) => !diff.toRemove.includes(p.slug) && (fromComposer || pinActive(source.manifest[p.slug])));
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 activateNew = (slug) => (fromComposer ? activate : pinActive(source.manifest[slug]));
90
- const newlyInstalled = await this.filesForNewlyInstalled(diff.toInstall);
91
- const toActivate = [...deactivated, ...diff.toActivate, ...newlyInstalled.filter((p) => activateNew(p.slug))];
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
- for (const plugin of plugins) {
110
- this.log(` ⊙ activating ${plugin.slug}`);
111
- await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
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
  };
@@ -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;
@@ -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 {};
@@ -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
  }
@@ -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 {};