@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
|
-
import { readFile,
|
|
2
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, extname, join } from 'node:path';
|
|
4
4
|
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
5
5
|
import { errorCode, errorMessage, isRecord } from '../../lib/narrow.js';
|
|
@@ -10,6 +10,7 @@ import { isNotFoundError } from '../../lib/wp-client.js';
|
|
|
10
10
|
import {} from '../../types/snippet.generated.js';
|
|
11
11
|
import {} from '../../types/snippet.js';
|
|
12
12
|
import { pluralize } from '../../utils/pluralize.js';
|
|
13
|
+
import { renameWithRetry } from '../../utils/rename-retry.js';
|
|
13
14
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
14
15
|
import { toSlug } from '../../utils/to-slug.js';
|
|
15
16
|
export default class Push extends PushCommand {
|
|
@@ -89,7 +90,7 @@ export default class Push extends PushCommand {
|
|
|
89
90
|
return;
|
|
90
91
|
const newPath = join(dir, `${canonicalBase}${ext}`);
|
|
91
92
|
const newMetaPath = join(dir, `${canonicalBase}.json`);
|
|
92
|
-
await
|
|
93
|
+
await renameWithRetry(snippet.path, newPath);
|
|
93
94
|
await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
94
95
|
await rm(oldMetaPath, { force: true });
|
|
95
96
|
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
50
50
|
protected resolveOptionsPath(override?: string): string;
|
|
51
51
|
protected resolveSnippetsPath(override?: string): string;
|
|
52
52
|
protected resolveThemeStylesPath(override?: string): string;
|
|
53
|
+
private assertSiteSupportsResource;
|
|
53
54
|
private pickEnvironment;
|
|
54
55
|
private resolveEnvironment;
|
|
55
56
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -8,7 +8,8 @@ import {} from '../types/config.js';
|
|
|
8
8
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
9
9
|
import { pluralize } from '../utils/pluralize.js';
|
|
10
10
|
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
11
|
-
import { getCapabilities } from './capabilities.js';
|
|
11
|
+
import { commandResourceKind, getCapabilities, missingIntegration, unsupportedMessage } from './capabilities.js';
|
|
12
|
+
import { declaredPlugins } from './declared-plugins.js';
|
|
12
13
|
import { activateExclusions } from './exclude.js';
|
|
13
14
|
import { isInteractive } from './interactive.js';
|
|
14
15
|
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
@@ -104,6 +105,7 @@ export class LoopressCommand extends Command {
|
|
|
104
105
|
honors: this.ctor.honorsExclude,
|
|
105
106
|
});
|
|
106
107
|
await this.maybeAutoRotate();
|
|
108
|
+
await this.assertSiteSupportsResource();
|
|
107
109
|
}
|
|
108
110
|
// Silent, best-effort: a stale app password still works, so a failed rotation attempt
|
|
109
111
|
// (offline, site down) just tries again on the next command instead of blocking this one.
|
|
@@ -218,6 +220,25 @@ export class LoopressCommand extends Command {
|
|
|
218
220
|
resolveThemeStylesPath(override) {
|
|
219
221
|
return resolveResourceDir('themeStyles', this.localConfig, override);
|
|
220
222
|
}
|
|
223
|
+
// A command on a single kind of resource (`lps hook push`) says what `lps push` says when the
|
|
224
|
+
// site can't hold it, instead of failing on the 404. A site whose capabilities can't be read
|
|
225
|
+
// (too old a plugin, unreachable) lets the command run and report its own error.
|
|
226
|
+
async assertSiteSupportsResource() {
|
|
227
|
+
const kind = commandResourceKind(this.id);
|
|
228
|
+
if (!kind)
|
|
229
|
+
return;
|
|
230
|
+
let missing;
|
|
231
|
+
try {
|
|
232
|
+
missing = missingIntegration(kind, await this.capabilities());
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!missing)
|
|
238
|
+
return;
|
|
239
|
+
const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
|
|
240
|
+
this.error(unsupportedMessage(missing, this.siteConfig.name, declared));
|
|
241
|
+
}
|
|
221
242
|
pickEnvironment(project, envName) {
|
|
222
243
|
const env = project.environments[envName];
|
|
223
244
|
if (!env) {
|
|
@@ -36,6 +36,11 @@ export declare function integrationFix(missing: MissingIntegration, declared: {
|
|
|
36
36
|
fromComposer: boolean;
|
|
37
37
|
plugins: string[];
|
|
38
38
|
}): string | undefined;
|
|
39
|
+
export declare function commandResourceKind(commandId: string | undefined): ResourceDirKind | undefined;
|
|
40
|
+
export declare function unsupportedMessage(missing: MissingIntegration, site: string, declared: {
|
|
41
|
+
fromComposer: boolean;
|
|
42
|
+
plugins: string[];
|
|
43
|
+
}): string;
|
|
39
44
|
export type ThemeActivation = {
|
|
40
45
|
active: string;
|
|
41
46
|
changed: boolean;
|
package/dist/lib/capabilities.js
CHANGED
|
@@ -78,6 +78,22 @@ export function integrationFix(missing, declared) {
|
|
|
78
78
|
const where = declared.fromComposer ? `wpackagist-plugin/${first} in composer.json` : `"${first}" in loopress.json plugins`;
|
|
79
79
|
return `Declare ${first}${or} (${where}), then activate it: ${activate(first)}`;
|
|
80
80
|
}
|
|
81
|
+
// The command topics that act on one kind of resource the site may be unable to hold
|
|
82
|
+
// (`lps hook push`, `lps acf list`...), so they can say so instead of surfacing the 404. `publish`
|
|
83
|
+
// goes through the console, not the site. `scf` is the alias of `acf`.
|
|
84
|
+
const TOPIC_KINDS = { acf: 'acf', api: 'api', block: 'block', form: 'form', hook: 'hooks', page: 'page', pattern: 'pattern', scf: 'acf', snippet: 'snippets' };
|
|
85
|
+
export function commandResourceKind(commandId) {
|
|
86
|
+
const [topic, action] = (commandId ?? '').split(':', 2);
|
|
87
|
+
if (!topic || !action || action === 'publish')
|
|
88
|
+
return undefined;
|
|
89
|
+
return TOPIC_KINDS[topic];
|
|
90
|
+
}
|
|
91
|
+
// "Not supported by <site>: <reason>. <fix>.", for the single-resource commands.
|
|
92
|
+
export function unsupportedMessage(missing, site, declared) {
|
|
93
|
+
const fix = integrationFix(missing, declared);
|
|
94
|
+
const fixSentence = fix ? ` ${fix}.` : '';
|
|
95
|
+
return `Not supported by ${site}: ${missing.reason}.${fixSentence}`;
|
|
96
|
+
}
|
|
81
97
|
export async function activateTheme(wp, stylesheet) {
|
|
82
98
|
const activation = await wp.post(THEME_ACTIVATE_ENDPOINT, { stylesheet });
|
|
83
99
|
cache.delete(wp);
|
|
@@ -69,10 +69,7 @@ async function readSidecar(metaPath) {
|
|
|
69
69
|
throw new Error('not a JSON object');
|
|
70
70
|
return meta;
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
// (publishes to the Loopress api as a shared source): both read the same local `snippets/`
|
|
74
|
-
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
75
|
-
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
72
|
+
// Reads the local `snippets/` directory. `onSkip` is optional: skipped files are simply left out either way.
|
|
76
73
|
export async function loadSnippets(path, onSkip) {
|
|
77
74
|
let files;
|
|
78
75
|
try {
|
|
@@ -2,6 +2,26 @@ import { LoopressCommand } from './base.js';
|
|
|
2
2
|
import { type ResourceState } from './diff-state.js';
|
|
3
3
|
import { type ScannedItem } from './environment-urls.js';
|
|
4
4
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
5
|
+
export type DeploymentStep = {
|
|
6
|
+
command: string;
|
|
7
|
+
status: 'failed' | 'pushed' | 'skipped';
|
|
8
|
+
};
|
|
9
|
+
type DeploymentPlugins = Array<{
|
|
10
|
+
active: boolean;
|
|
11
|
+
slug: string;
|
|
12
|
+
version: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function startDeploymentCollector(): {
|
|
15
|
+
plugins?: DeploymentPlugins;
|
|
16
|
+
};
|
|
17
|
+
export declare function stopDeploymentCollector(): void;
|
|
18
|
+
export declare function postDeployment(body: {
|
|
19
|
+
command: string | undefined;
|
|
20
|
+
plugins?: DeploymentPlugins;
|
|
21
|
+
status: 'failure' | 'success';
|
|
22
|
+
steps?: DeploymentStep[];
|
|
23
|
+
url: string;
|
|
24
|
+
}): Promise<void>;
|
|
5
25
|
export type PushChange = {
|
|
6
26
|
id: string;
|
|
7
27
|
status: 'changed' | 'created' | 'removed' | 'unchanged';
|
|
@@ -46,4 +66,6 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
46
66
|
protected warnEnvironmentUrls(items: ScannedItem[]): void;
|
|
47
67
|
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
48
68
|
private guardOutsideEdits;
|
|
69
|
+
private pushedPlugins;
|
|
49
70
|
}
|
|
71
|
+
export {};
|
package/dist/lib/push-command.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolve } from 'node:path';
|
|
|
7
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
8
8
|
import { authManager } from '../config/auth.manager.js';
|
|
9
9
|
import {} from '../types/config.js';
|
|
10
|
+
import { pinActive, pinVersion } from '../types/plugin.js';
|
|
10
11
|
import { API_URL } from './api-client.js';
|
|
11
12
|
import { LoopressCommand } from './base.js';
|
|
12
13
|
import {} from './diff-state.js';
|
|
@@ -33,6 +34,32 @@ function gitState() {
|
|
|
33
34
|
return {};
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
// Set while `lps push` runs its steps in process: they hand over what they would have posted
|
|
38
|
+
// instead of posting, so the run sends one record (and waits at most one timeout).
|
|
39
|
+
let deploymentCollector;
|
|
40
|
+
export function startDeploymentCollector() {
|
|
41
|
+
deploymentCollector = {};
|
|
42
|
+
return deploymentCollector;
|
|
43
|
+
}
|
|
44
|
+
export function stopDeploymentCollector() {
|
|
45
|
+
deploymentCollector = undefined;
|
|
46
|
+
}
|
|
47
|
+
// Best-effort: recording must never interrupt the push flow.
|
|
48
|
+
export async function postDeployment(body) {
|
|
49
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
50
|
+
if (!token)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
54
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
55
|
+
json: { ...body, ...gitState() },
|
|
56
|
+
timeout: { request: 3000 },
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// non-blocking
|
|
61
|
+
}
|
|
62
|
+
}
|
|
36
63
|
export function pushChanges(local, remote, options = {}) {
|
|
37
64
|
const changes = [...local.keys()]
|
|
38
65
|
.filter((id) => options.writes?.(id) ?? true)
|
|
@@ -158,19 +185,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
158
185
|
}
|
|
159
186
|
}
|
|
160
187
|
async recordDeployment(status) {
|
|
161
|
-
|
|
162
|
-
if (
|
|
188
|
+
// Inside `lps push`, the steps only report the plugin inventory: the run posts one record.
|
|
189
|
+
if (deploymentCollector) {
|
|
190
|
+
const { plugins } = this.pushedPlugins(status);
|
|
191
|
+
if (plugins)
|
|
192
|
+
deploymentCollector.plugins = plugins;
|
|
163
193
|
return;
|
|
164
|
-
try {
|
|
165
|
-
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
166
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
167
|
-
json: { status, url: this.siteConfig.url, command: this.id, ...gitState() },
|
|
168
|
-
timeout: { request: 3000 },
|
|
169
|
-
}));
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
// non-blocking: recording must never interrupt the push flow
|
|
173
194
|
}
|
|
195
|
+
await postDeployment({ command: this.id, status, url: this.siteConfig.url, ...this.pushedPlugins(status) });
|
|
174
196
|
}
|
|
175
197
|
async recordSuccess() {
|
|
176
198
|
if (this.dryRun)
|
|
@@ -309,6 +331,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
309
331
|
if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
|
|
310
332
|
this.error('Aborted.');
|
|
311
333
|
}
|
|
334
|
+
// The plugin inventory of a landed `plugin:push` (a full `lps push` runs it as a step), read from
|
|
335
|
+
// loopress.json: slug, pinned version and whether it must be active. Other commands send none.
|
|
336
|
+
pushedPlugins(status) {
|
|
337
|
+
if (status !== 'success' || this.id !== 'plugin:push')
|
|
338
|
+
return {};
|
|
339
|
+
const plugins = Object.entries(this.localConfig.plugins ?? {}).map(([slug, pin]) => ({ active: pinActive(pin), slug, version: pinVersion(pin) }));
|
|
340
|
+
return { plugins };
|
|
341
|
+
}
|
|
312
342
|
}
|
|
313
343
|
function sameEntry(left, right, id) {
|
|
314
344
|
return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
|
|
@@ -2,7 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { configManager } from '../config/project-config.manager.js';
|
|
5
|
-
import {
|
|
5
|
+
import { pinProblem } from '../types/plugin.js';
|
|
6
6
|
import { readLocalBlocks } from '../utils/block-format.js';
|
|
7
7
|
import { COMPOSER_JSON } from '../utils/composer.js';
|
|
8
8
|
import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
|
|
@@ -259,9 +259,9 @@ function checkLoopressJson(localConfig, problems) {
|
|
|
259
259
|
}
|
|
260
260
|
for (const [slug, version] of Object.entries(manifest.data)) {
|
|
261
261
|
// The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
262
|
+
const message = pinProblem(slug, version);
|
|
263
|
+
if (message)
|
|
264
|
+
problems.push({ file: 'loopress.json', message });
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
async function checkSnippets(dir, problems) {
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ type RequestOptions = {
|
|
|
24
24
|
};
|
|
25
25
|
export declare function isNotFoundError(error: unknown): boolean;
|
|
26
26
|
export declare function isForbiddenError(error: unknown): boolean;
|
|
27
|
+
export declare function isFirewallBlock(error: unknown): boolean;
|
|
27
28
|
export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
|
|
28
29
|
export declare function isApplicative404(error: unknown, marker: string): boolean;
|
|
29
30
|
export declare function isConflictCode(error: unknown, code: string): boolean;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -100,6 +100,17 @@ export function isNotFoundError(error) {
|
|
|
100
100
|
export function isForbiddenError(error) {
|
|
101
101
|
return responseOf(causeOf(error)).statusCode === 403;
|
|
102
102
|
}
|
|
103
|
+
// A 403 whose body is not a WordPress JSON error (`code`/`message`) nor a Loopress `error`
|
|
104
|
+
// refusal: the answer of a firewall (ModSecurity, Imunify360, Wordfence...) in front of PHP.
|
|
105
|
+
export function isFirewallBlock(error) {
|
|
106
|
+
return isFirewallResponse(responseOf(causeOf(error)));
|
|
107
|
+
}
|
|
108
|
+
function isFirewallResponse({ body, statusCode }) {
|
|
109
|
+
if (statusCode !== 403)
|
|
110
|
+
return false;
|
|
111
|
+
const parsed = parseBody(body);
|
|
112
|
+
return typeof parsed.code !== 'string' && typeof parsed.message !== 'string' && typeof parsed.error !== 'string';
|
|
113
|
+
}
|
|
103
114
|
// WordPress core answers the authenticated request with this `code` when Application
|
|
104
115
|
// Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
|
|
105
116
|
// filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
|
|
@@ -152,16 +163,8 @@ export function parseBody(body) {
|
|
|
152
163
|
}
|
|
153
164
|
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
154
165
|
const { body, statusCode: status } = responseOf(error);
|
|
155
|
-
if (status === 401 || status === 403)
|
|
156
|
-
|
|
157
|
-
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
158
|
-
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
159
|
-
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
160
|
-
const refusal = extractLoopressError(body);
|
|
161
|
-
if (refusal)
|
|
162
|
-
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
163
|
-
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
164
|
-
}
|
|
166
|
+
if (status === 401 || status === 403)
|
|
167
|
+
return formatAuthError(status, body, url);
|
|
165
168
|
if (status === 404) {
|
|
166
169
|
const reason = extractServerErrorMessage(body);
|
|
167
170
|
return reason
|
|
@@ -186,6 +189,23 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
186
189
|
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
187
190
|
return `Request to ${url} failed: ${message || (code ?? String(error))}`;
|
|
188
191
|
}
|
|
192
|
+
function formatAuthError(status, body, url) {
|
|
193
|
+
if (isFirewallResponse({ body, statusCode: status }))
|
|
194
|
+
return firewallMessage(url);
|
|
195
|
+
// A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
|
|
196
|
+
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
197
|
+
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
198
|
+
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
199
|
+
const refusal = extractLoopressError(body);
|
|
200
|
+
if (refusal)
|
|
201
|
+
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
202
|
+
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
203
|
+
}
|
|
204
|
+
// A 403 that is not a WordPress JSON error never came from WordPress: a web application
|
|
205
|
+
// firewall answered before PHP ran, and no credential change will fix it.
|
|
206
|
+
function firewallMessage(url) {
|
|
207
|
+
return `Request blocked (403) on ${url}: the response is not a WordPress error, so a firewall or security plugin (ModSecurity, Imunify360, Wordfence) probably blocked the request. Allow-list the request or your IP in the firewall, then retry.`;
|
|
208
|
+
}
|
|
189
209
|
const NETWORK_ERROR_HINTS = {
|
|
190
210
|
ECONNREFUSED: 'connection refused, the site may be down',
|
|
191
211
|
ECONNRESET: 'connection reset by the server',
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type WpNativePlugin = {
|
|
|
16
16
|
export declare const PLUGIN_PIN: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
17
17
|
active: z.ZodExactOptional<z.ZodBoolean>;
|
|
18
18
|
version: z.ZodString;
|
|
19
|
-
}, z.core.$
|
|
19
|
+
}, z.core.$strict>]>;
|
|
20
|
+
export declare function pinProblem(slug: string, pin: unknown): string | undefined;
|
|
20
21
|
export type PluginPin = z.infer<typeof PLUGIN_PIN>;
|
|
21
22
|
export type PluginManifest = Record<string, PluginPin>;
|
|
22
23
|
export declare const pinVersion: (pin: PluginPin) => string;
|
package/dist/types/plugin.js
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
// A bare version string means "installed and active"; the object form records a plugin that
|
|
3
3
|
// must stay installed but inactive.
|
|
4
|
-
|
|
4
|
+
// Strict: an unknown key is a typo (`activate` for `active`) that would otherwise be dropped.
|
|
5
|
+
export const PLUGIN_PIN = z.union([z.string(), z.strictObject({ active: z.boolean().exactOptional(), version: z.string() })]);
|
|
6
|
+
const PIN_KEYS = ['active', 'version'];
|
|
7
|
+
// Levenshtein distance, enough to tell a typo of a pin key ("activate", "verison") from another key.
|
|
8
|
+
function editDistance(a, b) {
|
|
9
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
10
|
+
for (let i = 1; i <= a.length; i++) {
|
|
11
|
+
const current = [i];
|
|
12
|
+
for (let j = 1; j <= b.length; j++) {
|
|
13
|
+
const substitution = (previous[j - 1] ?? 0) + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1);
|
|
14
|
+
current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution));
|
|
15
|
+
}
|
|
16
|
+
previous = current;
|
|
17
|
+
}
|
|
18
|
+
return previous[b.length] ?? 0;
|
|
19
|
+
}
|
|
20
|
+
// Why a loopress.json plugin pin is invalid, or undefined when it is fine.
|
|
21
|
+
export function pinProblem(slug, pin) {
|
|
22
|
+
if (PLUGIN_PIN.safeParse(pin).success)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (pin !== null && typeof pin === 'object' && !Array.isArray(pin)) {
|
|
25
|
+
const unknown = Object.keys(pin).filter((key) => !PIN_KEYS.includes(key));
|
|
26
|
+
if (unknown.length > 0) {
|
|
27
|
+
const hints = unknown.map((key) => {
|
|
28
|
+
const near = PIN_KEYS.find((known) => editDistance(key.toLowerCase(), known) <= 2);
|
|
29
|
+
return near ? `"${key}" (did you mean "${near}"?)` : `"${key}"`;
|
|
30
|
+
});
|
|
31
|
+
return `plugin "${slug}" has unknown key(s) ${hints.join(', ')}: a pin is a version string or {version, active}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return `plugin "${slug}" must map to a version string or {version, active}`;
|
|
35
|
+
}
|
|
5
36
|
export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
|
|
6
37
|
// No pin at all (a slug loopress.json doesn't list) keeps the default: active.
|
|
7
38
|
export const pinActive = (pin) => typeof pin === 'string' || pin?.active !== false;
|
|
@@ -26,6 +26,7 @@ export type ListedOption = {
|
|
|
26
26
|
};
|
|
27
27
|
export declare function isReservedOptionName(name: string): boolean;
|
|
28
28
|
export declare function defaultReadonlyFor(name: string): boolean;
|
|
29
|
+
export declare function isWindowsReservedName(name: string): boolean;
|
|
29
30
|
export declare function optionFileName(name: string): string;
|
|
30
31
|
export declare function partitionByReadonly(tracked: LocalOption[]): {
|
|
31
32
|
skipped: LocalOption[];
|
|
@@ -38,7 +38,17 @@ export function isReservedOptionName(name) {
|
|
|
38
38
|
export function defaultReadonlyFor(name) {
|
|
39
39
|
return READONLY_BY_DEFAULT_OPTION_NAMES.includes(name);
|
|
40
40
|
}
|
|
41
|
+
// Windows refuses these device names as a file name, with or without an extension (`NUL.json`).
|
|
42
|
+
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
|
43
|
+
export function isWindowsReservedName(name) {
|
|
44
|
+
return WINDOWS_RESERVED_NAME.test(name.split('.', 1)[0]?.trimEnd() ?? '');
|
|
45
|
+
}
|
|
46
|
+
function reservedNameMessage(name) {
|
|
47
|
+
return `Option name "${name}" is a reserved Windows file name (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9) and cannot be tracked as ${name}.json`;
|
|
48
|
+
}
|
|
41
49
|
export function optionFileName(name) {
|
|
50
|
+
if (isWindowsReservedName(name))
|
|
51
|
+
throw new Error(reservedNameMessage(name));
|
|
42
52
|
return `${name}.json`;
|
|
43
53
|
}
|
|
44
54
|
// Splits tracked options into what `push` may write and what it must skip: readonly is a local
|
|
@@ -54,7 +64,10 @@ const REFS_ERROR = '"refs" must map a value path to a post type, e.g. {"purchase
|
|
|
54
64
|
// Each check's message is the error thrown for it, the first failing one wins. Loose: unknown
|
|
55
65
|
// keys (`readonly`, future ones) pass through untouched.
|
|
56
66
|
const LOCAL_OPTION = z.looseObject({
|
|
57
|
-
name: z
|
|
67
|
+
name: z
|
|
68
|
+
.string('missing a "name" string')
|
|
69
|
+
.min(1, 'missing a "name" string')
|
|
70
|
+
.refine((name) => !isWindowsReservedName(name), { error: (issue) => reservedNameMessage(String(issue.input)) }),
|
|
58
71
|
autoload: z.string('missing an "autoload" string'),
|
|
59
72
|
// `value` itself is deliberately untyped (a WP option can hold any JSON-safe value, including
|
|
60
73
|
// null); only the key's presence is checked, so a hand-edited file that dropped it entirely is
|
package/dist/utils/plugins.d.ts
CHANGED
|
@@ -37,5 +37,8 @@ export type MergeResult<P extends PluginPin = PluginPin> = {
|
|
|
37
37
|
export declare function describePin(pin: PluginPin): string;
|
|
38
38
|
export declare function mergePluginManifest<P extends PluginPin>(existing: Record<string, P>, incoming: Record<string, P>): MergeResult<P>;
|
|
39
39
|
export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
|
|
40
|
+
export declare function preferredCopy<P extends {
|
|
41
|
+
file: string;
|
|
42
|
+
}>(copies: P[], slug: string): P | undefined;
|
|
40
43
|
export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
|
|
41
44
|
export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;
|
package/dist/utils/plugins.js
CHANGED
|
@@ -46,12 +46,17 @@ export function parseInstalledPlugins(raw) {
|
|
|
46
46
|
}))
|
|
47
47
|
.filter((plugin) => !LOOPRESS_PLUGIN_SLUGS.has(plugin.slug));
|
|
48
48
|
}
|
|
49
|
+
// Composer installs a plugin into `<slug>/`; a site can also hold another copy under the same
|
|
50
|
+
// slug (core's `hello.php` is Hello Dolly). Prefer the one in the slug's folder.
|
|
51
|
+
export function preferredCopy(copies, slug) {
|
|
52
|
+
return copies.find((p) => p.file.startsWith(`${slug}/`)) ?? copies[0];
|
|
53
|
+
}
|
|
49
54
|
export function diffPlugins(manifest, installed,
|
|
50
55
|
// Slugs Loopress currently manages, from the local composer.lock's wpackagist-plugin/*
|
|
51
56
|
// entries. Empty for a loopress.json-only project that doesn't keep a lockfile in the repo:
|
|
52
57
|
// `toRemove` then stays empty (removals still happen server-side, they just aren't previewed).
|
|
53
58
|
managedSlugs = new Set()) {
|
|
54
|
-
const installedMap = new Map(installed.map((p) => [p.slug, p]));
|
|
59
|
+
const installedMap = new Map(installed.map((p) => [p.slug, preferredCopy(installed.filter((q) => q.slug === p.slug), p.slug) ?? p]));
|
|
55
60
|
const toInstall = [];
|
|
56
61
|
const toPin = [];
|
|
57
62
|
const toActivate = [];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renameWithRetry(from: string, to: string, attempts?: number, baseDelayMs?: number): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { rename } from 'node:fs/promises';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
const RETRYABLE_CODES = new Set(['EBUSY', 'EPERM']);
|
|
4
|
+
// On Windows, an editor, an antivirus or the search indexer can briefly hold a file open and make
|
|
5
|
+
// rename() fail with EPERM/EBUSY. Those clear within milliseconds, so retry a few times with a
|
|
6
|
+
// growing delay before giving up. Other errors (ENOENT, EEXIST...) are thrown right away.
|
|
7
|
+
export async function renameWithRetry(from, to, attempts = 5, baseDelayMs = 50) {
|
|
8
|
+
for (let attempt = 1;; attempt++) {
|
|
9
|
+
try {
|
|
10
|
+
await rename(from, to);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const code = error instanceof Error && 'code' in error ? String(error.code) : '';
|
|
15
|
+
if (!RETRYABLE_CODES.has(code) || attempt >= attempts)
|
|
16
|
+
throw error;
|
|
17
|
+
await sleep(baseDelayMs * attempt);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/utils/version.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export declare function isExactVersion(value: string): boolean;
|
|
2
2
|
export declare function compareVersions(a: string, b: string): null | number;
|
|
3
|
+
export type VersionJump = 'major' | 'minor' | 'patch';
|
|
4
|
+
export declare function versionJump(from: string, to: string): null | VersionJump;
|
package/dist/utils/version.js
CHANGED
|
@@ -19,3 +19,24 @@ export function isExactVersion(value) {
|
|
|
19
19
|
export function compareVersions(a, b) {
|
|
20
20
|
return validate(a) && validate(b) ? cmp(a, b) : null;
|
|
21
21
|
}
|
|
22
|
+
// How far `from` is behind `to`: the first numeric segment that differs, major, minor, then
|
|
23
|
+
// anything finer is a patch. Null when `from` is not behind `to` or either isn't comparable.
|
|
24
|
+
export function versionJump(from, to) {
|
|
25
|
+
const order = compareVersions(from, to);
|
|
26
|
+
if (order === null || order >= 0)
|
|
27
|
+
return null;
|
|
28
|
+
const a = numericSegments(from);
|
|
29
|
+
const b = numericSegments(to);
|
|
30
|
+
if (a[0] !== b[0])
|
|
31
|
+
return 'major';
|
|
32
|
+
if ((a[1] ?? 0) !== (b[1] ?? 0))
|
|
33
|
+
return 'minor';
|
|
34
|
+
return 'patch';
|
|
35
|
+
}
|
|
36
|
+
function numericSegments(version) {
|
|
37
|
+
const core = (version.split('-', 1)[0] ?? version).replace(/^v/i, '');
|
|
38
|
+
return core.split('.').map((part) => {
|
|
39
|
+
const n = Number(part);
|
|
40
|
+
return Number.isNaN(n) ? 0 : Math.trunc(n);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const WPORG_INFO = "https://api.wordpress.org/plugins/info/1.2/";
|
|
2
|
+
export declare function requestPluginInfo(slug: string, { versions }?: {
|
|
3
|
+
versions?: boolean;
|
|
4
|
+
}): Promise<null | Record<string, unknown>>;
|
|
5
|
+
export declare function fetchPluginInfo(slug: string): Promise<null | Record<string, unknown>>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
export const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
|
|
3
|
+
// The WordPress.org plugin_information answer for one slug, or null when the plugin isn't
|
|
4
|
+
// listed there (zip, premium), or {error: 'closed'} for a closed one. Throws when the API can't be reached or answers with an error.
|
|
5
|
+
// `versions` adds every released version (`versions`: version -> download URL) to the answer.
|
|
6
|
+
export async function requestPluginInfo(slug, { versions = false } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const body = await got(WPORG_INFO, {
|
|
9
|
+
searchParams: {
|
|
10
|
+
action: 'plugin_information',
|
|
11
|
+
'request[slug]': slug,
|
|
12
|
+
...(versions && { 'request[fields][versions]': '1' }),
|
|
13
|
+
},
|
|
14
|
+
timeout: { request: 10_000 },
|
|
15
|
+
}).json();
|
|
16
|
+
// A removed plugin answers {error: 'closed'}: kept so callers can tell it from an unlisted one.
|
|
17
|
+
if (typeof body === 'object' && 'error' in body && body.error === 'closed')
|
|
18
|
+
return body;
|
|
19
|
+
return typeof body === 'object' && !('error' in body) ? body : null;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (isNotFound(error))
|
|
23
|
+
return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Same lookup, but an unreachable API is reported as null too.
|
|
28
|
+
export async function fetchPluginInfo(slug) {
|
|
29
|
+
try {
|
|
30
|
+
return await requestPluginInfo(slug);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isNotFound(error) {
|
|
37
|
+
if (typeof error !== 'object' || error === null || !('response' in error))
|
|
38
|
+
return false;
|
|
39
|
+
const { response } = error;
|
|
40
|
+
return typeof response === 'object' && response !== null && 'statusCode' in response && response.statusCode === 404;
|
|
41
|
+
}
|