@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.
- package/README.md +190 -174
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/form/push.js +3 -2
- package/dist/commands/option/list.js +6 -1
- 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 +70 -19
- package/dist/commands/plugin/update.d.ts +36 -0
- package/dist/commands/plugin/update.js +121 -0
- package/dist/commands/push.js +12 -1
- package/dist/commands/snippet/push.js +3 -2
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +22 -1
- package/dist/lib/capabilities.d.ts +5 -0
- package/dist/lib/capabilities.js +16 -0
- package/dist/lib/load-snippets.js +1 -4
- package/dist/lib/push-command.d.ts +22 -0
- package/dist/lib/push-command.js +41 -11
- package/dist/lib/validate-local.js +4 -4
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +30 -10
- package/dist/types/plugin.d.ts +2 -1
- package/dist/types/plugin.js +32 -1
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/option-format.js +14 -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/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 +1401 -1301
- package/package.json +10 -10
- 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
package/dist/commands/doctor.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
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),
|
|
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
|
-
|
|
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 =
|
|
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
|
|
91
|
-
const
|
|
92
|
-
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
|
+
];
|
|
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
|
+
}
|
package/dist/commands/push.js
CHANGED
|
@@ -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 };
|