@loopress/cli 0.34.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 (44) hide show
  1. package/README.md +190 -174
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/form/push.js +3 -2
  4. package/dist/commands/option/list.js +6 -1
  5. package/dist/commands/plugin/audit.d.ts +1 -1
  6. package/dist/commands/plugin/audit.js +45 -23
  7. package/dist/commands/plugin/outdated.d.ts +22 -0
  8. package/dist/commands/plugin/outdated.js +67 -0
  9. package/dist/commands/plugin/push.d.ts +2 -1
  10. package/dist/commands/plugin/push.js +70 -19
  11. package/dist/commands/plugin/update.d.ts +36 -0
  12. package/dist/commands/plugin/update.js +121 -0
  13. package/dist/commands/push.js +12 -1
  14. package/dist/commands/snippet/push.js +3 -2
  15. package/dist/lib/base.d.ts +1 -0
  16. package/dist/lib/base.js +22 -1
  17. package/dist/lib/capabilities.d.ts +5 -0
  18. package/dist/lib/capabilities.js +16 -0
  19. package/dist/lib/load-snippets.js +1 -4
  20. package/dist/lib/push-command.d.ts +22 -0
  21. package/dist/lib/push-command.js +41 -11
  22. package/dist/lib/validate-local.js +4 -4
  23. package/dist/lib/wp-client.d.ts +1 -0
  24. package/dist/lib/wp-client.js +30 -10
  25. package/dist/types/plugin.d.ts +2 -1
  26. package/dist/types/plugin.js +32 -1
  27. package/dist/utils/option-format.d.ts +1 -0
  28. package/dist/utils/option-format.js +14 -1
  29. package/dist/utils/plugins.d.ts +3 -0
  30. package/dist/utils/plugins.js +6 -1
  31. package/dist/utils/rename-retry.d.ts +1 -0
  32. package/dist/utils/rename-retry.js +20 -0
  33. package/dist/utils/version.d.ts +2 -0
  34. package/dist/utils/version.js +21 -0
  35. package/dist/utils/wporg.d.ts +5 -0
  36. package/dist/utils/wporg.js +41 -0
  37. package/oclif.manifest.json +1401 -1301
  38. package/package.json +10 -10
  39. package/dist/commands/api/publish.d.ts +0 -9
  40. package/dist/commands/api/publish.js +0 -45
  41. package/dist/commands/snippet/publish.d.ts +0 -10
  42. package/dist/commands/snippet/publish.js +0 -62
  43. package/dist/lib/resolve-linked-project.d.ts +0 -8
  44. package/dist/lib/resolve-linked-project.js +0 -27
@@ -4,7 +4,7 @@ import {} from '../lib/capabilities.js';
4
4
  import { errorMessage } from '../lib/narrow.js';
5
5
  import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
6
6
  import { findTempAdmins } from '../lib/temp-admin.js';
7
- import { isApplicationPasswordsDisabled, isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
7
+ import { isApplicationPasswordsDisabled, isFirewallBlock, isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
8
8
  import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
9
9
  import { pluralize } from '../utils/pluralize.js';
10
10
  export default class Doctor extends LoopressCommand {
@@ -156,7 +156,7 @@ export default class Doctor extends LoopressCommand {
156
156
  catch (error) {
157
157
  lookup = { error };
158
158
  }
159
- if ('error' in lookup && isForbiddenError(lookup.error)) {
159
+ if ('error' in lookup && isForbiddenError(lookup.error) && !isFirewallBlock(lookup.error)) {
160
160
  this.out('- Leftover temporary admin accounts: not checked, these credentials cannot list users.');
161
161
  return;
162
162
  }
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { errorMessage, isRecord } from '../../lib/narrow.js';
5
5
  import { PushCommand } from '../../lib/push-command.js';
@@ -9,6 +9,7 @@ import { getResourceStateProvider } from '../../lib/resource-state.js';
9
9
  import { isNotFoundError } from '../../lib/wp-client.js';
10
10
  import { FORM_ENDPOINT, formEndpoint, getFormId, getFormTitle } from '../../utils/form-format.js';
11
11
  import { pluralize } from '../../utils/pluralize.js';
12
+ import { renameWithRetry } from '../../utils/rename-retry.js';
12
13
  import { toSlug } from '../../utils/to-slug.js';
13
14
  export default class Push extends PushCommand {
14
15
  static args = {
@@ -66,7 +67,7 @@ export default class Push extends PushCommand {
66
67
  async ensureCanonicalFilename(filePath, id, title) {
67
68
  const canonicalPath = join(dirname(filePath), `${id}-${toSlug(title, 'untitled')}.json`);
68
69
  if (filePath !== canonicalPath)
69
- await rename(filePath, canonicalPath);
70
+ await renameWithRetry(filePath, canonicalPath);
70
71
  }
71
72
  // One file is read in isolation: a corrupted or hand-broken JSON file must only skip that
72
73
  // form, not abort loading the rest of the directory, same principle as loadObjects() in
@@ -1,9 +1,14 @@
1
1
  import { Flags, ux } from '@oclif/core';
2
- import ttyTable from 'tty-table';
2
+ import ttyTableModule, {} from 'tty-table';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
4
  import { withSpinner } from '../../lib/spinner.js';
5
5
  import { OPTIONS_ENDPOINT } from '../../utils/option-format.js';
6
6
  import { pluralize } from '../../utils/pluralize.js';
7
+ // tty-table 7 ships CJS-flavored typings (`types` before `import` in its exports map), so under
8
+ // Node16 resolution TS types the default import as the whole CJS namespace, while at runtime the
9
+ // ESM build's default export is the factory itself (it exports no named `Table`).
10
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
11
+ const ttyTable = ttyTableModule;
7
12
  // Same green/yellow pairing drives both SOURCE? and PLUGIN, so a row's whole "where did this come
8
13
  // from" story reads as one color at a glance instead of requiring the "?" to be spotted in text.
9
14
  function confidenceColor(option) {
@@ -23,8 +23,8 @@ export default class Audit extends LoopressCommand {
23
23
  static flags: {};
24
24
  run(): Promise<AuditResult>;
25
25
  private auditOne;
26
- private fetchHealth;
27
26
  private fetchVulnerabilities;
28
27
  private report;
28
+ private reportToConsole;
29
29
  }
30
30
  export {};
@@ -1,11 +1,14 @@
1
1
  import got from 'got';
2
+ import { authManager } from '../../config/auth.manager.js';
3
+ import { API_URL } from '../../lib/api-client.js';
2
4
  import { LoopressCommand } from '../../lib/base.js';
3
5
  import { errorMessage, isRecord } from '../../lib/narrow.js';
4
6
  import { withSpinner } from '../../lib/spinner.js';
7
+ import {} from '../../types/config.js';
5
8
  import { pinVersion } from '../../types/plugin.js';
6
9
  import { compareVersions } from '../../utils/version.js';
10
+ import { fetchPluginInfo } from '../../utils/wporg.js';
7
11
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
8
- const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
9
12
  export default class Audit extends LoopressCommand {
10
13
  static description = 'Check the plugins in loopress.json for known vulnerabilities (wpvulnerability.net) and health issues';
11
14
  static enableJsonFlag = true;
@@ -25,10 +28,20 @@ export default class Audit extends LoopressCommand {
25
28
  advisories.push(...found.advisories);
26
29
  health.push(...found.health);
27
30
  }
28
- return this.report(advisories, health, slugs.length);
31
+ // Print first: report() may set the exit code, and the console request must not delay the output.
32
+ const result = this.report(advisories, health, slugs.length);
33
+ await this.reportToConsole(advisories, health, manifest);
34
+ // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
35
+ if (advisories.length > 0) {
36
+ if (this.jsonEnabled())
37
+ process.exitCode = 1;
38
+ else
39
+ this.exit(1);
40
+ }
41
+ return result;
29
42
  }
30
43
  async auditOne(slug, pinned) {
31
- const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
44
+ const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), fetchPluginInfo(slug)]);
32
45
  // "latest" is the newest WordPress.org release, the one a push installs: audit that version.
33
46
  // Unknown (WordPress.org unreachable), every advisory is reported, safer than none.
34
47
  const version = pinned === 'latest' && typeof info?.version === 'string' ? info.version : pinned;
@@ -38,18 +51,6 @@ export default class Audit extends LoopressCommand {
38
51
  const health = info ? describeHealth(slug, pinned, info) : [];
39
52
  return { advisories, health };
40
53
  }
41
- async fetchHealth(slug) {
42
- try {
43
- const body = await got(WPORG_INFO, {
44
- searchParams: { action: 'plugin_information', 'request[slug]': slug },
45
- timeout: { request: 10_000 },
46
- }).json();
47
- return typeof body === 'object' && !('error' in body) ? body : null;
48
- }
49
- catch {
50
- return null;
51
- }
52
- }
53
54
  async fetchVulnerabilities(slug) {
54
55
  let body;
55
56
  try {
@@ -64,7 +65,8 @@ export default class Audit extends LoopressCommand {
64
65
  'Audit aborted so a service outage is not reported as a clean result.', { cause: error });
65
66
  }
66
67
  // A 200 with the wrong shape (degraded service) must not read as "no advisories" either.
67
- const list = body?.data?.vulnerability;
68
+ // wpvulnerability.net answers `"vulnerability": null` when it has no advisories: that is an empty list.
69
+ const list = body?.data?.vulnerability === null ? [] : body?.data?.vulnerability;
68
70
  if (!Array.isArray(list) || list.some((raw) => !isRecord(raw))) {
69
71
  throw new TypeError(`Unexpected answer from the vulnerability database (wpvulnerability.net) for "${slug}". ` +
70
72
  'Audit aborted so a malformed response is not reported as a clean result.');
@@ -84,15 +86,35 @@ export default class Audit extends LoopressCommand {
84
86
  const noun = advisories.length === 1 ? 'advisory' : 'advisories';
85
87
  this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
86
88
  'Vulnerability data: wpvulnerability.net.');
87
- // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
88
- if (advisories.length > 0) {
89
- if (this.jsonEnabled())
90
- process.exitCode = 1;
91
- else
92
- this.exit(1);
93
- }
94
89
  return { advisories, health, status: 'issues' };
95
90
  }
91
+ // Best-effort, like `recordDeployment`: when a token is present, sends the console the advisories
92
+ // and health notes (slug, version, CVE, severity, fixed version, title). Silent on any failure,
93
+ // and it never touches the output or the exit code.
94
+ async reportToConsole(advisories, health, manifest) {
95
+ try {
96
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
97
+ const url = this.siteConfig?.url;
98
+ if (!token || !url)
99
+ return;
100
+ const sent = advisories.map(({ cve, fixedIn, severity, slug, title }) => ({
101
+ cve,
102
+ fixedIn,
103
+ severity,
104
+ slug,
105
+ title,
106
+ version: pinVersion(manifest[slug] ?? 'latest'),
107
+ }));
108
+ await got.post(`${API_URL}/plugin-audits`, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ json: { advisories: sent, health, url },
111
+ timeout: { request: 3000 },
112
+ });
113
+ }
114
+ catch {
115
+ // non-blocking: reporting must never change the audit's outcome
116
+ }
117
+ }
96
118
  }
97
119
  function advisoryMeta(a) {
98
120
  const fixed = a.fixedIn ? `fixed in ${a.fixedIn}` : undefined;
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type VersionJump } from '../../utils/version.js';
3
+ export type OutdatedPlugin = {
4
+ current: string;
5
+ jump: null | VersionJump;
6
+ latest: null | string;
7
+ slug: string;
8
+ status: 'closed' | 'current' | 'error' | 'outdated' | 'unknown';
9
+ };
10
+ type OutdatedResult = {
11
+ outdated: number;
12
+ plugins: OutdatedPlugin[];
13
+ };
14
+ export default class Outdated extends LoopressCommand {
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {};
19
+ run(): Promise<OutdatedResult>;
20
+ private printTable;
21
+ }
22
+ export {};
@@ -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
  }
@@ -6,10 +6,10 @@ import { confirmUninstall } from '../../lib/interactive.js';
6
6
  import { causeOf, isRecord } from '../../lib/narrow.js';
7
7
  import { PushCommand } from '../../lib/push-command.js';
8
8
  import { isLocalEnvironment, isNotFoundError, parseBody, responseOf } from '../../lib/wp-client.js';
9
- import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
+ import { pinActive, pinProblem, pinVersion, pinVersions, } from '../../types/plugin.js';
10
10
  import { parseComposerJson, requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
11
11
  import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
12
- import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
12
+ import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins, preferredCopy, } from '../../utils/plugins.js';
13
13
  import { isExactVersion } from '../../utils/version.js';
14
14
  const IN_SYNC = {
15
15
  activated: [],
@@ -65,6 +65,9 @@ export default class Push extends PushCommand {
65
65
  if (!hasFloatingPin && isNoop(diff, toPrune)) {
66
66
  this.log('Everything is already in sync.');
67
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');
68
71
  return { ...IN_SYNC };
69
72
  }
70
73
  if (isNoop(diff, toPrune))
@@ -75,8 +78,9 @@ export default class Push extends PushCommand {
75
78
  }
76
79
  if (!(await confirmUninstall(diff.toRemove, this.yes)))
77
80
  this.error('Aborted.');
81
+ const switchedOff = await this.deactivateEndangered(installed, diff, force);
78
82
  // Plugins switched off for the file swap come back on, unless loopress.json wants them inactive.
79
- 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])));
80
84
  let response;
81
85
  try {
82
86
  response = await this.sync(source.intent, force);
@@ -87,9 +91,14 @@ export default class Push extends PushCommand {
87
91
  await this.activate(deactivated);
88
92
  throw error;
89
93
  }
90
- const activateNew = (slug) => (fromComposer ? activate : pinActive(source.manifest[slug]));
91
- const newlyInstalled = await this.filesForNewlyInstalled(diff.toInstall);
92
- 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
+ ];
93
102
  const toDeactivate = [...diff.toDeactivate, ...installed.filter((p) => toPrune.includes(p.slug))];
94
103
  await this.deactivate(toDeactivate);
95
104
  await this.activate(toActivate);
@@ -169,19 +178,6 @@ export default class Push extends PushCommand {
169
178
  throw error;
170
179
  }
171
180
  }
172
- // toInstall entries carry no `file`: the plugin didn't exist on the site at diff time, so its
173
- // WordPress core plugin id (folder/main-file.php) is only knowable after sync() has actually
174
- // installed it. Refetch to get it, then activate like everything else.
175
- async filesForNewlyInstalled(toInstall) {
176
- if (toInstall.length === 0)
177
- return [];
178
- const installedAfterSync = parseInstalledPlugins(await this.wp.get('wp/v2/plugins'));
179
- const fileBySlug = new Map(installedAfterSync.map((p) => [p.slug, p.file]));
180
- return toInstall.flatMap(({ slug }) => {
181
- const file = fileBySlug.get(slug);
182
- return file ? [{ file, slug }] : [];
183
- });
184
- }
185
181
  guardForce(diff, force, file) {
186
182
  if (force)
187
183
  return;
@@ -198,6 +194,21 @@ export default class Push extends PushCommand {
198
194
  `migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in ${file}.`);
199
195
  }
200
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
+ }
201
212
  async loadSource(activate) {
202
213
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
203
214
  if (existsSync(composerJsonPath)) {
@@ -217,6 +228,10 @@ export default class Push extends PushCommand {
217
228
  if (Object.keys(manifest).length === 0) {
218
229
  this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
219
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.`);
220
235
  return {
221
236
  file: 'loopress.json',
222
237
  intent: { plugins: pinVersions(manifest) },
@@ -231,6 +246,34 @@ export default class Push extends PushCommand {
231
246
  this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
232
247
  this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
233
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
+ }
234
277
  result(diff, opts) {
235
278
  const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
236
279
  return {
@@ -267,6 +310,14 @@ function missingDependencies(error) {
267
310
  const data = [body.data, ...(Array.isArray(body.additional_data) ? body.additional_data : [])];
268
311
  return data.flatMap((d) => (isRecord(d) && isRecord(d.not_installed) ? Object.keys(d.not_installed) : []));
269
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
+ }
270
321
  function isNoop(diff, toPrune) {
271
322
  return (diff.toInstall.length === 0 &&
272
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
+ }
@@ -6,7 +6,7 @@ import { integrationFix, missingIntegration, missingModule } from '../lib/capabi
6
6
  import { guardProductionPush } from '../lib/guard-production-push.js';
7
7
  import { stdoutToStderr } from '../lib/json-delegation.js';
8
8
  import { errorMessage } from '../lib/narrow.js';
9
- import { PushCommand } from '../lib/push-command.js';
9
+ import { postDeployment, PushCommand, startDeploymentCollector, stopDeploymentCollector } from '../lib/push-command.js';
10
10
  import { parseSelectionFlag, selectionFlags, selectResources } from '../lib/resource-selection.js';
11
11
  import { isLocalEnvironment } from '../lib/wp-client.js';
12
12
  import { pinActive } from '../types/plugin.js';
@@ -75,6 +75,15 @@ export default class Push extends LoopressCommand {
75
75
  const argv = this.buildArgv();
76
76
  const results = [];
77
77
  const skips = await this.skippedTargets(selected, skipFlag.includes('composer'));
78
+ // The steps run in process and would each post their own record: collect, and post once.
79
+ const collected = startDeploymentCollector();
80
+ const record = async (status) => {
81
+ stopDeploymentCollector();
82
+ if (this.dryRun)
83
+ return;
84
+ const steps = results.map((result, index) => ({ command: PUSH_TARGETS[index]?.commandId ?? '', status: result.status }));
85
+ await postDeployment({ command: 'push', status, steps, url: this.siteConfig.url, ...(collected.plugins && { plugins: collected.plugins }) });
86
+ };
78
87
  for (const target of PUSH_TARGETS) {
79
88
  const skip = skips.get(target.commandId);
80
89
  if (skip) {
@@ -99,9 +108,11 @@ export default class Push extends LoopressCommand {
99
108
  .map((next) => next.label);
100
109
  const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
101
110
  this.warnUnsupported();
111
+ await record('failure');
102
112
  this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
103
113
  }
104
114
  }
115
+ await record('success');
105
116
  this.log(this.doneMessage(selected.size < PUSH_IDS.length));
106
117
  this.warnUnsupported();
107
118
  return { results };