@loopress/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -207
- package/dist/commands/app/push.js +2 -1
- package/dist/commands/block/push.js +9 -4
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +10 -16
- package/dist/commands/diff.d.ts +6 -4
- package/dist/commands/diff.js +49 -29
- package/dist/commands/doctor.js +13 -3
- package/dist/commands/form/push.js +3 -2
- package/dist/commands/option/list.js +6 -1
- package/dist/commands/option/push.js +1 -0
- package/dist/commands/page/push.js +1 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/audit.d.ts +1 -1
- package/dist/commands/plugin/audit.js +45 -23
- package/dist/commands/plugin/outdated.d.ts +22 -0
- package/dist/commands/plugin/outdated.js +67 -0
- package/dist/commands/plugin/push.d.ts +2 -1
- package/dist/commands/plugin/push.js +109 -23
- package/dist/commands/plugin/update.d.ts +36 -0
- package/dist/commands/plugin/update.js +121 -0
- package/dist/commands/project/config.js +0 -1
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +8 -4
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +26 -1
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +116 -40
- package/dist/commands/snippet/push.js +3 -2
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +1 -1
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +42 -13
- package/dist/commands/validate.js +3 -0
- package/dist/lib/base.d.ts +7 -0
- package/dist/lib/base.js +55 -2
- package/dist/lib/capabilities.d.ts +5 -0
- package/dist/lib/capabilities.js +16 -0
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +3 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +11 -13
- package/dist/lib/diff-command.d.ts +17 -0
- package/dist/lib/diff-command.js +125 -9
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-snippets.js +3 -5
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +26 -0
- package/dist/lib/push-command.js +78 -12
- package/dist/lib/readdir-tolerant.js +6 -3
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +25 -4
- package/dist/lib/rollback-command.js +1 -1
- package/dist/lib/rollback-materialize.js +1 -1
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +71 -12
- package/dist/lib/wp-client.d.ts +2 -0
- package/dist/lib/wp-client.js +36 -10
- package/dist/types/plugin.d.ts +2 -1
- package/dist/types/plugin.js +32 -1
- package/dist/types/project-config.generated.d.ts +15 -4
- package/dist/utils/block-format.d.ts +2 -0
- package/dist/utils/block-format.js +18 -2
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/option-format.js +14 -1
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/plugins.d.ts +3 -0
- package/dist/utils/plugins.js +6 -1
- package/dist/utils/rename-retry.d.ts +1 -0
- package/dist/utils/rename-retry.js +20 -0
- package/dist/utils/resource-dirs.js +4 -4
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/dist/utils/wporg.d.ts +5 -0
- package/dist/utils/wporg.js +41 -0
- package/oclif.manifest.json +2131 -1150
- package/package.json +14 -14
- package/schemas/project-config.schema.json +21 -4
- package/dist/commands/api/publish.d.ts +0 -9
- package/dist/commands/api/publish.js +0 -45
- package/dist/commands/snippet/publish.d.ts +0 -10
- package/dist/commands/snippet/publish.js +0 -62
- package/dist/lib/resolve-linked-project.d.ts +0 -8
- package/dist/lib/resolve-linked-project.js +0 -27
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
2
|
import { type ResourceState } from './diff-state.js';
|
|
3
|
+
import { type ScannedItem } from './environment-urls.js';
|
|
3
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>;
|
|
4
25
|
export type PushChange = {
|
|
5
26
|
id: string;
|
|
6
27
|
status: 'changed' | 'created' | 'removed' | 'unchanged';
|
|
@@ -19,6 +40,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
19
40
|
'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
41
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
42
|
};
|
|
43
|
+
static honorsExclude: boolean;
|
|
22
44
|
protected failedCount: number;
|
|
23
45
|
protected previewChanges: PushChange[];
|
|
24
46
|
protected previewStates?: {
|
|
@@ -28,6 +50,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
28
50
|
private refusedByGuard;
|
|
29
51
|
protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
|
|
30
52
|
catch(err: Error): Promise<void>;
|
|
53
|
+
finally(error: Error | undefined): Promise<void>;
|
|
31
54
|
protected guardProductionPush(): Promise<void>;
|
|
32
55
|
init(): Promise<void>;
|
|
33
56
|
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
|
|
@@ -40,6 +63,9 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
40
63
|
protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
|
|
41
64
|
output: string;
|
|
42
65
|
}) => Promise<void>): Promise<void>;
|
|
66
|
+
protected warnEnvironmentUrls(items: ScannedItem[]): void;
|
|
43
67
|
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
44
68
|
private guardOutsideEdits;
|
|
69
|
+
private pushedPlugins;
|
|
45
70
|
}
|
|
71
|
+
export {};
|
package/dist/lib/push-command.js
CHANGED
|
@@ -2,19 +2,64 @@ import { confirm } from '@inquirer/prompts';
|
|
|
2
2
|
import { Flags } from '@oclif/core';
|
|
3
3
|
import got from 'got';
|
|
4
4
|
import { Listr } from 'listr2';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import { resolve } from 'node:path';
|
|
6
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
8
|
import { authManager } from '../config/auth.manager.js';
|
|
8
9
|
import {} from '../types/config.js';
|
|
10
|
+
import { pinActive, pinVersion } from '../types/plugin.js';
|
|
9
11
|
import { API_URL } from './api-client.js';
|
|
10
12
|
import { LoopressCommand } from './base.js';
|
|
11
13
|
import {} from './diff-state.js';
|
|
14
|
+
import { environmentUrlWarnings } from './environment-urls.js';
|
|
15
|
+
import { heldBackPaths } from './exclude.js';
|
|
12
16
|
import { guardProductionPush } from './guard-production-push.js';
|
|
13
17
|
import { isInteractive } from './interactive.js';
|
|
14
18
|
import { errorMessage } from './narrow.js';
|
|
15
19
|
import {} from './resource-state.js';
|
|
16
20
|
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
17
21
|
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
22
|
+
// The Git state the deployment record carries: the HEAD commit and whether the working tree has
|
|
23
|
+
// uncommitted changes. Empty outside a Git repository (or without git), so nothing is sent then.
|
|
24
|
+
function gitState() {
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line sonarjs/no-os-command-from-path -- git is resolved from PATH like in every dev tool
|
|
27
|
+
const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
|
|
28
|
+
const commit = git(['rev-parse', 'HEAD']);
|
|
29
|
+
if (!commit)
|
|
30
|
+
return {};
|
|
31
|
+
return { commit, dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '' };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
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
|
+
}
|
|
18
63
|
export function pushChanges(local, remote, options = {}) {
|
|
19
64
|
const changes = [...local.keys()]
|
|
20
65
|
.filter((id) => options.writes?.(id) ?? true)
|
|
@@ -44,6 +89,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
44
89
|
}),
|
|
45
90
|
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
46
91
|
};
|
|
92
|
+
static honorsExclude = true;
|
|
47
93
|
failedCount = 0;
|
|
48
94
|
// A dry run's per-item preview, set by captureBeforePushState: what the push would create,
|
|
49
95
|
// change or leave as it is on the site.
|
|
@@ -101,6 +147,16 @@ export class PushCommand extends LoopressCommand {
|
|
|
101
147
|
}
|
|
102
148
|
await super.catch(err);
|
|
103
149
|
}
|
|
150
|
+
// Lists what the environment's `exclude` globs (loopress.json) kept out of this push, so a
|
|
151
|
+
// feature held back from it is never forgotten there.
|
|
152
|
+
async finally(error) {
|
|
153
|
+
const paths = error ? [] : heldBackPaths();
|
|
154
|
+
if (paths.length > 0) {
|
|
155
|
+
const prefix = this.dryRun ? '[dry-run] ' : '';
|
|
156
|
+
this.log(`${prefix}Held back from ${this.siteConfig.name} (exclude in loopress.json): ${paths.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
await super.finally(error);
|
|
159
|
+
}
|
|
104
160
|
async guardProductionPush() {
|
|
105
161
|
await guardProductionPush({
|
|
106
162
|
dryRun: this.dryRun,
|
|
@@ -129,19 +185,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
129
185
|
}
|
|
130
186
|
}
|
|
131
187
|
async recordDeployment(status) {
|
|
132
|
-
|
|
133
|
-
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;
|
|
134
193
|
return;
|
|
135
|
-
try {
|
|
136
|
-
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
137
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
138
|
-
json: { status, url: this.siteConfig.url },
|
|
139
|
-
timeout: { request: 3000 },
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
// non-blocking: recording must never interrupt the push flow
|
|
144
194
|
}
|
|
195
|
+
await postDeployment({ command: this.id, status, url: this.siteConfig.url, ...this.pushedPlugins(status) });
|
|
145
196
|
}
|
|
146
197
|
async recordSuccess() {
|
|
147
198
|
if (this.dryRun)
|
|
@@ -191,12 +242,19 @@ export class PushCommand extends LoopressCommand {
|
|
|
191
242
|
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
192
243
|
});
|
|
193
244
|
}
|
|
245
|
+
// Absolute URLs to another environment of the project in what this push sends: warned about,
|
|
246
|
+
// never rewritten, on a dry run too.
|
|
247
|
+
warnEnvironmentUrls(items) {
|
|
248
|
+
const prefix = this.dryRun ? '[dry-run] ' : '';
|
|
249
|
+
for (const warning of environmentUrlWarnings(items, this.siteConfig, this.projectEnvironments))
|
|
250
|
+
this.warn(`${prefix}${warning}`);
|
|
251
|
+
}
|
|
194
252
|
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
195
253
|
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
196
254
|
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
197
255
|
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
198
256
|
// partial-push semantics: a readonly option skipped by `option push`, a snippet or form
|
|
199
|
-
// that gets a server-assigned id on create, a theme-
|
|
257
|
+
// that gets a server-assigned id on create, a theme-style push that only touches the
|
|
200
258
|
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
201
259
|
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
202
260
|
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
@@ -273,6 +331,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
273
331
|
if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
|
|
274
332
|
this.error('Aborted.');
|
|
275
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
|
+
}
|
|
276
342
|
}
|
|
277
343
|
function sameEntry(left, right, id) {
|
|
278
344
|
return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {} from 'node:fs';
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
-
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { withoutExcluded } from './exclude.js';
|
|
4
5
|
import { errorCode } from './narrow.js';
|
|
5
6
|
export async function readdirTolerant(dir, options) {
|
|
6
7
|
try {
|
|
8
|
+
// Excluded paths (lib/exclude.ts) are hidden here, so every resource listing through this
|
|
9
|
+
// helper leaves them out of a push or a diff on the environment that excludes them.
|
|
7
10
|
return options?.withFileTypes
|
|
8
|
-
? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
|
|
9
|
-
: await readdir(dir, { recursive: options?.recursive });
|
|
11
|
+
? withoutExcluded(dir, await readdir(dir, { recursive: options.recursive, withFileTypes: true }), (entry) => resolve(entry.parentPath, entry.name))
|
|
12
|
+
: withoutExcluded(dir, await readdir(dir, { recursive: options?.recursive }), (entry) => entry);
|
|
10
13
|
}
|
|
11
14
|
catch (error) {
|
|
12
15
|
if (errorCode(error) === 'ENOENT')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
export declare const RESOURCE_IDS: readonly ["plugin", "composer", "cpt", "taxonomy", "meta", "role", "acf", "api", "hook", "block", "form", "pattern", "template", "part", "css", "theme-style", "page", "menu", "option", "snippet"];
|
|
3
|
+
export type ResourceId = (typeof RESOURCE_IDS)[number];
|
|
4
|
+
export declare function selectionFlags(noun: string, valid: readonly string[]): {
|
|
5
|
+
only: ReturnType<typeof Flags.string>;
|
|
6
|
+
skip: ReturnType<typeof Flags.string>;
|
|
7
|
+
};
|
|
8
|
+
export declare function parseSelectionFlag(raw: string[] | undefined, flagName: 'only' | 'skip', valid: readonly string[], fail: (message: string) => never): string[];
|
|
9
|
+
export declare function selectResources<T extends string>(all: readonly T[], only?: readonly string[], skip?: readonly string[]): T[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
// One singular id per resource, shared by every command taking `--only` / `--skip`
|
|
3
|
+
// (`lps dev`, `lps diff`, `lps push`). Each command accepts the subset it can act on.
|
|
4
|
+
export const RESOURCE_IDS = [
|
|
5
|
+
'plugin',
|
|
6
|
+
'composer',
|
|
7
|
+
'cpt',
|
|
8
|
+
'taxonomy',
|
|
9
|
+
'meta',
|
|
10
|
+
'role',
|
|
11
|
+
'acf',
|
|
12
|
+
'api',
|
|
13
|
+
'hook',
|
|
14
|
+
'block',
|
|
15
|
+
'form',
|
|
16
|
+
'pattern',
|
|
17
|
+
'template',
|
|
18
|
+
'part',
|
|
19
|
+
'css',
|
|
20
|
+
'theme-style',
|
|
21
|
+
'page',
|
|
22
|
+
'menu',
|
|
23
|
+
'option',
|
|
24
|
+
'snippet',
|
|
25
|
+
];
|
|
26
|
+
// Comma-separated or repeated: `--only a,b`, `--only a --only b`, or both.
|
|
27
|
+
export function selectionFlags(noun, valid) {
|
|
28
|
+
return {
|
|
29
|
+
only: Flags.string({ description: `Only ${noun} these resources (comma-separated or repeated): ${valid.join(', ')}`, multiple: true }),
|
|
30
|
+
skip: Flags.string({ description: `${noun.charAt(0).toUpperCase()}${noun.slice(1)} every resource except these (comma-separated or repeated)`, multiple: true }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Splits the raw flag values and rejects any id outside `valid`.
|
|
34
|
+
export function parseSelectionFlag(raw, flagName, valid, fail) {
|
|
35
|
+
return (raw ?? [])
|
|
36
|
+
.flatMap((value) => value.split(','))
|
|
37
|
+
.map((value) => value.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((value) => {
|
|
40
|
+
if (!valid.includes(value))
|
|
41
|
+
fail(`Unknown resource "${value}" in --${flagName}. Valid resources: ${valid.join(', ')}`);
|
|
42
|
+
return value;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// `only` narrows `all`, `skip` then removes from whatever that is, so a combined
|
|
46
|
+
// `--only=a,b --skip=b` reads as: a, b, then drop b. The result always follows `all`'s order.
|
|
47
|
+
export function selectResources(all, only = [], skip = []) {
|
|
48
|
+
return all.filter((id) => (only.length === 0 || only.includes(id)) && !skip.includes(id));
|
|
49
|
+
}
|
|
@@ -3,6 +3,8 @@ import { type ResourceState } from './diff-state.js';
|
|
|
3
3
|
import { type WpClient } from './wp-client.js';
|
|
4
4
|
export type ResourceStateProvider = {
|
|
5
5
|
dirKind: ResourceDirKind;
|
|
6
|
+
fileExtension?: string;
|
|
7
|
+
fileName?(id: string): string | undefined;
|
|
6
8
|
local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
|
|
7
9
|
remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
|
|
8
10
|
resource: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
-
import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
|
|
4
|
+
import { blockFilesToPush, BLOCKS_ENDPOINT, excludedBlockFiles, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
|
|
5
5
|
import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
|
|
6
6
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
7
7
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
@@ -91,6 +91,7 @@ const snippetProvider = {
|
|
|
91
91
|
// stance as form-format.ts.
|
|
92
92
|
const formProvider = {
|
|
93
93
|
dirKind: 'form',
|
|
94
|
+
fileExtension: '.json',
|
|
94
95
|
async local(dir, onWarn) {
|
|
95
96
|
const entries = await loadFiles(dir, {
|
|
96
97
|
extension: '.json',
|
|
@@ -137,6 +138,7 @@ async function normalizeAcf(wp, type, objects) {
|
|
|
137
138
|
}
|
|
138
139
|
const acfProvider = {
|
|
139
140
|
dirKind: 'acf',
|
|
141
|
+
fileExtension: '.json',
|
|
140
142
|
async local(dir, onWarn, wp) {
|
|
141
143
|
const state = new Map();
|
|
142
144
|
for (const type of ACF_OBJECT_TYPES) {
|
|
@@ -182,6 +184,7 @@ function filesToState(files) {
|
|
|
182
184
|
function phpFilesProvider(spec) {
|
|
183
185
|
return {
|
|
184
186
|
dirKind: spec.dirKind,
|
|
187
|
+
fileExtension: '.php',
|
|
185
188
|
async local(dir, onWarn) {
|
|
186
189
|
return filesToState(await loadFiles(dir, {
|
|
187
190
|
extension: '.php',
|
|
@@ -217,11 +220,17 @@ const hookProvider = phpFilesProvider({
|
|
|
217
220
|
// resource, the same stance as pages: pushing would stop on it anyway.
|
|
218
221
|
const blockProvider = {
|
|
219
222
|
dirKind: 'block',
|
|
220
|
-
|
|
223
|
+
// A block is a folder: only the bare `blocks/<slug>` path names a site-only block.
|
|
224
|
+
fileExtension: '',
|
|
225
|
+
// A file the environment excludes inside a block folder keeps the site's copy, as the push
|
|
226
|
+
// sends it back: without `wp`, that file is just left out.
|
|
227
|
+
async local(dir, _onWarn, wp) {
|
|
221
228
|
const { blocks, problems } = await readLocalBlocks(dir);
|
|
222
229
|
if (problems.length > 0)
|
|
223
230
|
throw new Error(formatBlockProblems(problems));
|
|
224
|
-
|
|
231
|
+
const withExcluded = wp !== undefined && blocks.some((block) => excludedBlockFiles(dir, block.slug).length > 0);
|
|
232
|
+
const site = withExcluded ? new Map((await wp.get(BLOCKS_ENDPOINT)).map((block) => [block.slug, block.files])) : undefined;
|
|
233
|
+
return new Map(blocks.map((block) => [block.slug, site ? blockFilesToPush(dir, block, site.get(block.slug)) : block.files]));
|
|
225
234
|
},
|
|
226
235
|
async remote(wp) {
|
|
227
236
|
const blocks = await wp.get(BLOCKS_ENDPOINT);
|
|
@@ -271,6 +280,7 @@ async function childThemeState(wp, kind) {
|
|
|
271
280
|
}
|
|
272
281
|
const templateProvider = {
|
|
273
282
|
dirKind: 'template',
|
|
283
|
+
fileExtension: '.html',
|
|
274
284
|
async local(dir) {
|
|
275
285
|
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
|
|
276
286
|
if (problems.length > 0)
|
|
@@ -283,6 +293,7 @@ const templateProvider = {
|
|
|
283
293
|
};
|
|
284
294
|
const partProvider = {
|
|
285
295
|
dirKind: 'part',
|
|
296
|
+
fileExtension: '.html',
|
|
286
297
|
async local(dir) {
|
|
287
298
|
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
|
|
288
299
|
if (problems.length > 0)
|
|
@@ -311,6 +322,12 @@ function canonicalMenu(menu) {
|
|
|
311
322
|
}
|
|
312
323
|
const menuProvider = {
|
|
313
324
|
dirKind: 'menu',
|
|
325
|
+
fileExtension: '.json',
|
|
326
|
+
fileName(id) {
|
|
327
|
+
if (id === MENU_LOCATIONS_FILE)
|
|
328
|
+
return `${id}.json`;
|
|
329
|
+
return id.startsWith('menu/') ? `${id.slice('menu/'.length)}.json` : undefined;
|
|
330
|
+
},
|
|
314
331
|
async local(dir, onWarn) {
|
|
315
332
|
const state = new Map();
|
|
316
333
|
const entries = await loadFiles(dir, {
|
|
@@ -348,6 +365,7 @@ const menuProvider = {
|
|
|
348
365
|
function declaredTypeProvider(spec, title) {
|
|
349
366
|
return {
|
|
350
367
|
dirKind: spec.dirKind,
|
|
368
|
+
fileExtension: fileExtension(spec),
|
|
351
369
|
async local(dir, onWarn) {
|
|
352
370
|
const entries = await loadFiles(dir, {
|
|
353
371
|
extension: fileExtension(spec),
|
|
@@ -377,6 +395,7 @@ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
|
|
|
377
395
|
// sides so it can never itself show up as drift.
|
|
378
396
|
const optionsProvider = {
|
|
379
397
|
dirKind: 'options',
|
|
398
|
+
fileExtension: '.json',
|
|
380
399
|
async local(dir, onWarn) {
|
|
381
400
|
const entries = await loadFiles(dir, {
|
|
382
401
|
extension: '.json',
|
|
@@ -422,6 +441,8 @@ const optionsProvider = {
|
|
|
422
441
|
// individually rather than colliding on a single key.
|
|
423
442
|
const themeStylesProvider = {
|
|
424
443
|
dirKind: 'themeStyles',
|
|
444
|
+
fileExtension: '.json',
|
|
445
|
+
fileName: (stylesheet) => `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`,
|
|
425
446
|
async local(dir, onWarn) {
|
|
426
447
|
const entries = await loadFiles(dir, {
|
|
427
448
|
extension: '.json',
|
|
@@ -445,7 +466,7 @@ const themeStylesProvider = {
|
|
|
445
466
|
const item = await wp.get(globalStylesEndpoint(id));
|
|
446
467
|
return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
|
|
447
468
|
},
|
|
448
|
-
resource: 'theme-
|
|
469
|
+
resource: 'theme-style',
|
|
449
470
|
title: 'Theme styles',
|
|
450
471
|
};
|
|
451
472
|
export const RESOURCE_STATE_PROVIDERS = [
|
|
@@ -34,7 +34,7 @@ function indentPatch(patch) {
|
|
|
34
34
|
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
35
35
|
// state and the state the original push expected to leave behind, and requires explicit
|
|
36
36
|
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
37
|
-
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-
|
|
37
|
+
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
|
|
38
38
|
export function resourceRollbackCommand(resource, options) {
|
|
39
39
|
const command = options.command ?? resource;
|
|
40
40
|
class ResourceRollback extends LoopressCommand {
|
|
@@ -115,7 +115,7 @@ const MATERIALIZERS = {
|
|
|
115
115
|
role: declaredType(ROLE_SPEC),
|
|
116
116
|
snippet,
|
|
117
117
|
taxonomy: declaredType(TAXONOMY_SPEC),
|
|
118
|
-
'theme-
|
|
118
|
+
'theme-style': themeStyles,
|
|
119
119
|
};
|
|
120
120
|
// Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
|
|
121
121
|
// have produced, so the existing `<resource> push` command can push it unmodified: a rollback
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
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';
|
|
@@ -11,23 +11,24 @@ import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
|
11
11
|
import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
12
12
|
import { parseType } from '../utils/snippet-format.js';
|
|
13
13
|
import { readLocalTemplates } from '../utils/template-format.js';
|
|
14
|
+
import { globToRegExp, matchesAny } from './exclude.js';
|
|
14
15
|
import { loadSnippets } from './load-snippets.js';
|
|
15
16
|
import { errorCode, errorMessage, isRecord } from './narrow.js';
|
|
16
17
|
import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
|
|
17
18
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
18
19
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
19
20
|
// and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
|
|
20
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', '
|
|
21
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStyleDir'];
|
|
21
22
|
const DEFAULT_DIR = {
|
|
22
23
|
acfDir: 'acf',
|
|
23
24
|
apiDir: 'api',
|
|
24
25
|
blockDir: RESOURCE_DIR_DEFAULTS.block,
|
|
25
26
|
cptDir: 'cpt',
|
|
26
27
|
formDir: 'forms',
|
|
27
|
-
|
|
28
|
+
hookDir: 'hooks',
|
|
28
29
|
menuDir: 'menus',
|
|
29
30
|
metaDir: 'meta',
|
|
30
|
-
|
|
31
|
+
optionDir: 'options',
|
|
31
32
|
pageDir: 'pages',
|
|
32
33
|
partDir: RESOURCE_DIR_DEFAULTS.part,
|
|
33
34
|
patternDir: RESOURCE_DIR_DEFAULTS.pattern,
|
|
@@ -35,7 +36,7 @@ const DEFAULT_DIR = {
|
|
|
35
36
|
snippetsDir: 'snippets',
|
|
36
37
|
taxonomyDir: 'taxonomies',
|
|
37
38
|
templateDir: RESOURCE_DIR_DEFAULTS.template,
|
|
38
|
-
|
|
39
|
+
themeStyleDir: 'theme',
|
|
39
40
|
};
|
|
40
41
|
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
41
42
|
// and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
|
|
@@ -58,7 +59,7 @@ export async function validateLocal(cwd) {
|
|
|
58
59
|
}
|
|
59
60
|
// Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
|
|
60
61
|
// there with a warning, so it's a problem here.
|
|
61
|
-
checked += await checkJsonDir(resolve('
|
|
62
|
+
checked += await checkJsonDir(resolve('optionDir'), problems, parseLocalOption);
|
|
62
63
|
checked += await checkJsonDir(resolve('cptDir'), problems, declaredFileChecker(CPT_SPEC));
|
|
63
64
|
checked += await checkJsonDir(resolve('taxonomyDir'), problems, declaredFileChecker(TAXONOMY_SPEC));
|
|
64
65
|
checked += await checkJsonDir(resolve('metaDir'), problems, declaredFileChecker(META_SPEC));
|
|
@@ -66,7 +67,7 @@ export async function validateLocal(cwd) {
|
|
|
66
67
|
checked += await checkPatterns(resolve('patternDir'), problems);
|
|
67
68
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
68
69
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
|
|
69
|
-
checked += await checkPhpDir(resolve('
|
|
70
|
+
checked += await checkPhpDir(resolve('hookDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
|
|
70
71
|
const blocks = await readLocalBlocks(resolve('blockDir'));
|
|
71
72
|
checked += blocks.blocks.length;
|
|
72
73
|
problems.push(...blocks.problems);
|
|
@@ -76,7 +77,65 @@ export async function validateLocal(cwd) {
|
|
|
76
77
|
const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
|
|
77
78
|
checked += templates.templates.length + templates.parts.length + templates.problems.length;
|
|
78
79
|
problems.push(...templates.problems);
|
|
79
|
-
|
|
80
|
+
const warnings = await checkExcludes(localConfig, join(cwd, rootDir), problems);
|
|
81
|
+
return { checked, problems, valid: problems.length === 0, warnings };
|
|
82
|
+
}
|
|
83
|
+
// `environments.<name>.exclude` (lib/exclude.ts): a list of globs per environment. A glob that
|
|
84
|
+
// matches no file is a warning, not a problem: likely a typo or a renamed file, so the feature it
|
|
85
|
+
// was meant to hold back would reach that environment.
|
|
86
|
+
async function checkExcludes(localConfig, root, problems) {
|
|
87
|
+
const { environments } = localConfig;
|
|
88
|
+
if (environments === undefined)
|
|
89
|
+
return [];
|
|
90
|
+
if (!isRecord(environments) || Array.isArray(environments)) {
|
|
91
|
+
problems.push({ file: 'loopress.json', message: '"environments" must be an object of environment name -> settings' });
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let files;
|
|
96
|
+
for (const [name, settings] of Object.entries(environments)) {
|
|
97
|
+
const parsed = ENVIRONMENT_SETTINGS.safeParse(settings);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
problems.push({ file: 'loopress.json', message: `environments.${name} must be an object with an optional "exclude" list of glob strings` });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const pattern of parsed.data.exclude ?? []) {
|
|
103
|
+
files ??= await projectFiles(root);
|
|
104
|
+
const matcher = globToRegExp(pattern);
|
|
105
|
+
if (files.every((segments) => !matchesAny([matcher], segments))) {
|
|
106
|
+
warnings.push({ file: 'loopress.json', message: `environments.${name}.exclude: "${pattern}" matches no file` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return warnings;
|
|
111
|
+
}
|
|
112
|
+
const ENVIRONMENT_SETTINGS = z.strictObject({ exclude: z.array(z.string()).optional() });
|
|
113
|
+
// Every file under `root` as '/'-split segments, without node_modules and .git.
|
|
114
|
+
async function projectFiles(root) {
|
|
115
|
+
const files = [];
|
|
116
|
+
const walk = async (dir) => {
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (errorCode(error) === 'ENOENT')
|
|
123
|
+
return;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const path = join(dir, entry.name);
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
if (entry.name !== 'node_modules' && entry.name !== '.git')
|
|
130
|
+
await walk(path);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
files.push(relative(root, path).split(sep));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
await walk(root);
|
|
138
|
+
return files;
|
|
80
139
|
}
|
|
81
140
|
// Same directory rules as pages (flat, .html only), then the same checks as `pattern push`.
|
|
82
141
|
async function checkPatterns(dir, problems) {
|
|
@@ -200,9 +259,9 @@ function checkLoopressJson(localConfig, problems) {
|
|
|
200
259
|
}
|
|
201
260
|
for (const [slug, version] of Object.entries(manifest.data)) {
|
|
202
261
|
// The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
262
|
+
const message = pinProblem(slug, version);
|
|
263
|
+
if (message)
|
|
264
|
+
problems.push({ file: 'loopress.json', message });
|
|
206
265
|
}
|
|
207
266
|
}
|
|
208
267
|
async function checkSnippets(dir, problems) {
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ 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;
|
|
28
|
+
export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
|
|
27
29
|
export declare function isApplicative404(error: unknown, marker: string): boolean;
|
|
28
30
|
export declare function isConflictCode(error: unknown, code: string): boolean;
|
|
29
31
|
export declare function isTimeoutError(error: unknown): boolean;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -100,6 +100,23 @@ 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
|
+
}
|
|
114
|
+
// WordPress core answers the authenticated request with this `code` when Application
|
|
115
|
+
// Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
|
|
116
|
+
// filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
|
|
117
|
+
export function isApplicationPasswordsDisabled(error) {
|
|
118
|
+
return parseBody(responseOf(causeOf(error)).body).code === 'application_passwords_disabled_for_user';
|
|
119
|
+
}
|
|
103
120
|
// A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
|
|
104
121
|
// (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
|
|
105
122
|
// isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
|
|
@@ -146,16 +163,8 @@ export function parseBody(body) {
|
|
|
146
163
|
}
|
|
147
164
|
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
148
165
|
const { body, statusCode: status } = responseOf(error);
|
|
149
|
-
if (status === 401 || status === 403)
|
|
150
|
-
|
|
151
|
-
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
152
|
-
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
153
|
-
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
154
|
-
const refusal = extractLoopressError(body);
|
|
155
|
-
if (refusal)
|
|
156
|
-
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
157
|
-
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
158
|
-
}
|
|
166
|
+
if (status === 401 || status === 403)
|
|
167
|
+
return formatAuthError(status, body, url);
|
|
159
168
|
if (status === 404) {
|
|
160
169
|
const reason = extractServerErrorMessage(body);
|
|
161
170
|
return reason
|
|
@@ -180,6 +189,23 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
180
189
|
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
181
190
|
return `Request to ${url} failed: ${message || (code ?? String(error))}`;
|
|
182
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
|
+
}
|
|
183
209
|
const NETWORK_ERROR_HINTS = {
|
|
184
210
|
ECONNREFUSED: 'connection refused, the site may be down',
|
|
185
211
|
ECONNRESET: 'connection reset by the server',
|