@loopress/cli 0.23.0 → 0.24.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 +515 -83
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/{page → option}/pull.d.ts +3 -12
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/push.d.ts +0 -1
- package/dist/commands/plugin/push.js +3 -10
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/push.d.ts +0 -1
- package/dist/commands/theme/push.js +3 -10
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +12 -22
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/project-config.generated.d.ts +8 -4
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/oclif.manifest.json +1156 -149
- package/package.json +10 -7
- package/schemas/project-config.schema.json +10 -5
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
import { authManager } from '../config/auth.manager.js';
|
|
5
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
6
|
+
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
7
|
+
import { ApiClient } from './api-client.js';
|
|
8
|
+
import { LoopressCommand } from './base.js';
|
|
9
|
+
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
10
|
+
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
11
|
+
import { PushCommand } from './push-command.js';
|
|
12
|
+
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
13
|
+
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
14
|
+
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
15
|
+
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
16
|
+
const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
|
|
17
|
+
function pathArg(pathNoun) {
|
|
18
|
+
return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
|
|
19
|
+
}
|
|
20
|
+
// relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects '/',
|
|
21
|
+
// same as any URL or import path.
|
|
22
|
+
async function loadPhpFiles(command, dir) {
|
|
23
|
+
return loadDirectoryFiles(dir, {
|
|
24
|
+
extension: '.php',
|
|
25
|
+
onSkip(message) { command.warn(message); },
|
|
26
|
+
parse: (raw, filePath) => ({
|
|
27
|
+
content: raw,
|
|
28
|
+
filename: relative(dir, filePath).slice(0, -'.php'.length).split(sep).join('/'),
|
|
29
|
+
}),
|
|
30
|
+
recursive: true,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function resourcePushCommand(spec) {
|
|
34
|
+
class ResourcePush extends PushCommand {
|
|
35
|
+
static args = pathArg(spec.pathNoun);
|
|
36
|
+
static description = spec.pushDescription;
|
|
37
|
+
static enableJsonFlag = true;
|
|
38
|
+
static examples = [`$ lps ${spec.cliName} push`, `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
|
|
39
|
+
static flags = { ...PushCommand.dryRunFlag, ...PushCommand.yesFlag };
|
|
40
|
+
async run() {
|
|
41
|
+
const { args } = await this.parse(ResourcePush);
|
|
42
|
+
const { url } = this.siteConfig;
|
|
43
|
+
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
44
|
+
this.log(`Pushing ${spec.label} to ${url}`);
|
|
45
|
+
this.log(`${spec.pathLabel} path: ${path}`);
|
|
46
|
+
const files = await this.loadFiles(path);
|
|
47
|
+
this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
|
|
48
|
+
const pushed = [];
|
|
49
|
+
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
50
|
+
await this.pushFile(file, task);
|
|
51
|
+
pushed.push(file.filename);
|
|
52
|
+
});
|
|
53
|
+
if (this.failedCount > 0) {
|
|
54
|
+
this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
|
|
55
|
+
}
|
|
56
|
+
if (this.dryRun)
|
|
57
|
+
return { pushed, status: 'dry-run' };
|
|
58
|
+
await this.recordSuccess();
|
|
59
|
+
await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
|
|
60
|
+
this.log(`All ${spec.label} pushed.`);
|
|
61
|
+
return { pushed, status: 'success' };
|
|
62
|
+
}
|
|
63
|
+
async loadFiles(path) {
|
|
64
|
+
return loadPhpFiles(this, path);
|
|
65
|
+
}
|
|
66
|
+
async pushFile(file, task) {
|
|
67
|
+
if (!spec.filenamePattern.test(file.filename)) {
|
|
68
|
+
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
69
|
+
this.reportTaskFailure(message, new Error(message), task);
|
|
70
|
+
}
|
|
71
|
+
const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
72
|
+
if (declareCount !== 1) {
|
|
73
|
+
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
74
|
+
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
75
|
+
this.reportTaskFailure(message, new Error(message), task);
|
|
76
|
+
}
|
|
77
|
+
if (this.dryRun) {
|
|
78
|
+
if (task)
|
|
79
|
+
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const result = await this.wp.put(spec.endpoint, {
|
|
84
|
+
content: file.content,
|
|
85
|
+
filename: file.filename,
|
|
86
|
+
});
|
|
87
|
+
if (task) {
|
|
88
|
+
task.output =
|
|
89
|
+
result.syntax_check === 'skipped'
|
|
90
|
+
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
91
|
+
: `Pushed: ${file.filename}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ResourcePush;
|
|
100
|
+
}
|
|
101
|
+
export function resourcePullCommand(spec) {
|
|
102
|
+
class ResourcePull extends LoopressCommand {
|
|
103
|
+
static args = pathArg(spec.pathNoun);
|
|
104
|
+
static description = spec.pullDescription;
|
|
105
|
+
static enableJsonFlag = true;
|
|
106
|
+
static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
|
|
107
|
+
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
108
|
+
async run() {
|
|
109
|
+
const { args } = await this.parse(ResourcePull);
|
|
110
|
+
const { url } = this.siteConfig;
|
|
111
|
+
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
112
|
+
this.log(`Pulling ${spec.label} from ${url}`);
|
|
113
|
+
this.log(`${spec.pathLabel} path: ${path}`);
|
|
114
|
+
const files = await this.wp.get(spec.endpoint);
|
|
115
|
+
// A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
|
|
116
|
+
// additive-only, but pull already cleans up locally, same as `snippet pull`).
|
|
117
|
+
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
118
|
+
extensions: ['.php'],
|
|
119
|
+
key: basenameKey,
|
|
120
|
+
recursive: true,
|
|
121
|
+
});
|
|
122
|
+
const pulled = files.map((file) => file.filename);
|
|
123
|
+
await this.pullDirectory(path, files, orphans, {
|
|
124
|
+
alwaysCreateDir: true,
|
|
125
|
+
dryRunMessage: `Would pull ${pluralize(files.length, spec.noun)} to ${path}`,
|
|
126
|
+
orphanReason: spec.orphanReason,
|
|
127
|
+
pulledMessage: `Pulled ${pluralize(files.length, spec.noun)} to ${path}`,
|
|
128
|
+
title: (file) => file.filename,
|
|
129
|
+
async write(file, writeDir) {
|
|
130
|
+
const filePath = join(writeDir, `${file.filename}.php`);
|
|
131
|
+
// filename can contain '/' (a nested slug, e.g. content/filters or a path-param
|
|
132
|
+
// route invoice-pdf/[order_id]): writeFile() doesn't create parent directories on
|
|
133
|
+
// its own the way mkdir()'s recursive option does above for the top-level path.
|
|
134
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
135
|
+
await writeFile(filePath, file.content);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
if (this.dryRun)
|
|
139
|
+
return { orphans, pulled, status: 'dry-run' };
|
|
140
|
+
return { orphans, pulled, status: 'success' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return ResourcePull;
|
|
144
|
+
}
|
|
145
|
+
export function resourceListCommand(spec) {
|
|
146
|
+
class ResourceList extends LoopressCommand {
|
|
147
|
+
static description = spec.listDescription;
|
|
148
|
+
static enableJsonFlag = true;
|
|
149
|
+
static examples = [`$ lps ${spec.cliName} list`];
|
|
150
|
+
async run() {
|
|
151
|
+
const files = await this.wp.get(spec.endpoint);
|
|
152
|
+
if (files.length === 0) {
|
|
153
|
+
this.log(spec.emptyListMessage);
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
this.log(`Found ${pluralize(files.length, spec.noun)}:`);
|
|
157
|
+
this.log('');
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
this.log(` ${file.filename}`);
|
|
160
|
+
}
|
|
161
|
+
return files;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return ResourceList;
|
|
165
|
+
}
|
|
166
|
+
export const API_FILES_RESOURCE = {
|
|
167
|
+
// Best-effort report of the current route list to the Loopress cloud, purely for console
|
|
168
|
+
// visibility (US-18): the routes are already live on WordPress by this point, so this can
|
|
169
|
+
// never block or fail the push, same reasoning as `recordDeployment` in PushCommand.
|
|
170
|
+
async afterPush({ filenames, siteConfig }) {
|
|
171
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
|
|
172
|
+
const { apiEnvironmentId } = siteConfig;
|
|
173
|
+
if (!token || !apiEnvironmentId)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
await new ApiClient(token).put('api-routes', { environmentId: apiEnvironmentId, filenames });
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// non-blocking: reporting the route list must never interrupt the push flow
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
cliName: 'api',
|
|
183
|
+
dirKind: 'api',
|
|
184
|
+
emptyListMessage: 'No API route files found',
|
|
185
|
+
endpoint: 'loopress/v1/api-files',
|
|
186
|
+
// Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
|
|
187
|
+
// segment like [order_id], joined by '/'.
|
|
188
|
+
filenamePattern: /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
|
|
189
|
+
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php")',
|
|
190
|
+
label: 'API routes',
|
|
191
|
+
listDescription: 'List custom API route files from WordPress',
|
|
192
|
+
noun: 'route file',
|
|
193
|
+
orphanReason: 'whose route no longer exists on WordPress',
|
|
194
|
+
pathLabel: 'API',
|
|
195
|
+
pathNoun: 'api directory',
|
|
196
|
+
pullDescription: 'Pull custom API route files from WordPress',
|
|
197
|
+
pushDescription: 'Push custom API route files to WordPress',
|
|
198
|
+
};
|
|
199
|
+
export const HOOK_FILES_RESOURCE = {
|
|
200
|
+
cliName: 'hook',
|
|
201
|
+
dirKind: 'hooks',
|
|
202
|
+
emptyListMessage: 'No hook files found',
|
|
203
|
+
endpoint: 'loopress/v1/hook-files',
|
|
204
|
+
// Mirrors HookFilesController::isValidFilename(): kebab-case segments joined by '/', no
|
|
205
|
+
// bracketed dynamic-segment alternative (a hook slug is never a URL path).
|
|
206
|
+
filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
|
|
207
|
+
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
|
|
208
|
+
label: 'hooks',
|
|
209
|
+
listDescription: 'List hook files (actions, filters, cron) from WordPress',
|
|
210
|
+
noun: 'hook file',
|
|
211
|
+
orphanReason: 'no longer present on WordPress',
|
|
212
|
+
pathLabel: 'Hooks',
|
|
213
|
+
pathNoun: 'hooks directory',
|
|
214
|
+
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
|
|
215
|
+
pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
|
|
216
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isNotFoundError } from './wp-client.js';
|
|
2
|
-
// Update-or-create dance shared by every id-based resource (form,
|
|
2
|
+
// Update-or-create dance shared by every id-based resource (form, seo redirect,
|
|
3
3
|
// snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
|
|
4
4
|
// fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
|
|
5
5
|
// database reset.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
2
|
export async function readdirTolerant(dir, options) {
|
|
3
3
|
try {
|
|
4
|
-
return options?.withFileTypes
|
|
4
|
+
return options?.withFileTypes
|
|
5
|
+
? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
|
|
6
|
+
: await readdir(dir, { recursive: options?.recursive });
|
|
5
7
|
}
|
|
6
8
|
catch (error) {
|
|
7
9
|
if (error.code === 'ENOENT')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ResourceDirKind } from '../utils/resource-dirs.js';
|
|
2
|
+
import { type ResourceState } from './diff-state.js';
|
|
3
|
+
import { type WpClient } from './wp-client.js';
|
|
4
|
+
export type ResourceStateProvider = {
|
|
5
|
+
dirKind: ResourceDirKind;
|
|
6
|
+
local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
|
|
7
|
+
remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
|
|
8
|
+
resource: string;
|
|
9
|
+
title: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const RESOURCE_STATE_PROVIDERS: ResourceStateProvider[];
|
|
12
|
+
export declare function getResourceStateProvider(resource: string): ResourceStateProvider;
|
|
13
|
+
export declare function composerRemoteState(wp: WpClient): Promise<ResourceState>;
|
|
14
|
+
export declare function composerLocalState(rootDir: string): Promise<ResourceState>;
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
|
+
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
|
+
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
6
|
+
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
7
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
8
|
+
import { loadFiles } from './load-files.js';
|
|
9
|
+
import { loadSnippets } from './load-snippets.js';
|
|
10
|
+
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
11
|
+
function readJson(raw) {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
14
|
+
throw new Error('not a JSON object');
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
// Removes top-level keys that the server rewrites without the tracked configuration itself
|
|
19
|
+
// changing (save timestamps, hit counters), so a `diff` between two independently-configured
|
|
20
|
+
// environments reports real differences only. Top-level only: a nested user field that happens
|
|
21
|
+
// to share a name is never touched.
|
|
22
|
+
function omit(object, keys) {
|
|
23
|
+
return Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key)));
|
|
24
|
+
}
|
|
25
|
+
// ACF's export JSON carries `modified` (a unix timestamp bumped on every save in wp-admin).
|
|
26
|
+
const ACF_VOLATILE_KEYS = ['modified'];
|
|
27
|
+
// A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
|
|
28
|
+
const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
|
|
29
|
+
// A RankMath redirect's `hits` is a live visit counter.
|
|
30
|
+
const REDIRECT_VOLATILE_KEYS = ['hits'];
|
|
31
|
+
// ---- snippets -------------------------------------------------------------------------------
|
|
32
|
+
// `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
|
|
33
|
+
// push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
|
|
34
|
+
// becomes the map key. `code` has its leading `<?php` stripped on both sides: `snippet pull`
|
|
35
|
+
// re-adds it to PHP files for editor friendliness, WordPress stores only the body.
|
|
36
|
+
function canonicalSnippet(snippet) {
|
|
37
|
+
return {
|
|
38
|
+
active: snippet.active,
|
|
39
|
+
code: stripPhpOpeningTag(snippet.code),
|
|
40
|
+
insertMethod: snippet.insertMethod,
|
|
41
|
+
location: snippet.location,
|
|
42
|
+
name: snippet.name,
|
|
43
|
+
priority: snippet.priority,
|
|
44
|
+
shortcodeAttributes: [...snippet.shortcodeAttributes],
|
|
45
|
+
tags: [...snippet.tags],
|
|
46
|
+
type: snippet.type,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const snippetProvider = {
|
|
50
|
+
dirKind: 'snippets',
|
|
51
|
+
async local(dir) {
|
|
52
|
+
const state = new Map();
|
|
53
|
+
let snippets;
|
|
54
|
+
try {
|
|
55
|
+
snippets = await loadSnippets(dir);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// A directory that was never created is "nothing tracked locally", the same tolerance
|
|
59
|
+
// loadFiles/readdirTolerant already have; loadSnippets wraps the ENOENT in its own Error.
|
|
60
|
+
if (error.cause?.code === 'ENOENT')
|
|
61
|
+
return state;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
for (const snippet of snippets) {
|
|
65
|
+
const id = snippet.id === undefined ? `local:${basename(snippet.path)}` : String(snippet.id);
|
|
66
|
+
state.set(id, canonicalSnippet(snippet));
|
|
67
|
+
}
|
|
68
|
+
return state;
|
|
69
|
+
},
|
|
70
|
+
async remote(wp) {
|
|
71
|
+
const raw = await wp.get(SNIPPETS_ENDPOINT);
|
|
72
|
+
const state = new Map();
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
const snippet = normalizeSnippet(item);
|
|
75
|
+
state.set(String(snippet.id), canonicalSnippet(snippet));
|
|
76
|
+
}
|
|
77
|
+
return state;
|
|
78
|
+
},
|
|
79
|
+
resource: 'snippet',
|
|
80
|
+
title: 'Snippets',
|
|
81
|
+
};
|
|
82
|
+
// ---- forms -------------------------------------------------------------------------------
|
|
83
|
+
// The form plugin's own JSON (WPForms today) round-trips untouched through pull/push, so
|
|
84
|
+
// (minus save timestamps) the whole object is the compared value, same "deliberately loose"
|
|
85
|
+
// stance as form-format.ts.
|
|
86
|
+
const formProvider = {
|
|
87
|
+
dirKind: 'form',
|
|
88
|
+
async local(dir, onWarn) {
|
|
89
|
+
const entries = await loadFiles(dir, {
|
|
90
|
+
extension: '.json',
|
|
91
|
+
onSkip: onWarn,
|
|
92
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
93
|
+
});
|
|
94
|
+
const state = new Map();
|
|
95
|
+
for (const { file, value } of entries) {
|
|
96
|
+
const formId = getFormId(value);
|
|
97
|
+
state.set(formId === null ? `local:${file}` : String(formId), omit(value, FORM_VOLATILE_KEYS));
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
},
|
|
101
|
+
async remote(wp) {
|
|
102
|
+
const raw = await wp.get(FORM_ENDPOINT);
|
|
103
|
+
const state = new Map();
|
|
104
|
+
for (const form of raw) {
|
|
105
|
+
const formId = getFormId(form);
|
|
106
|
+
if (formId !== null)
|
|
107
|
+
state.set(String(formId), omit(form, FORM_VOLATILE_KEYS));
|
|
108
|
+
}
|
|
109
|
+
return state;
|
|
110
|
+
},
|
|
111
|
+
resource: 'form',
|
|
112
|
+
title: 'Forms',
|
|
113
|
+
};
|
|
114
|
+
// ---- ACF --------------------------------------------------------------------------------
|
|
115
|
+
// ACF's export JSON is large, deeply nested, and versioned by ACF itself; `key` is the stable
|
|
116
|
+
// identity and the whole object (minus the `modified` timestamp) round-trips untouched, so it
|
|
117
|
+
// is all compared. Ids are namespaced by object type (`field-groups/group_x`) since keys are
|
|
118
|
+
// only unique within a type.
|
|
119
|
+
const acfProvider = {
|
|
120
|
+
dirKind: 'acf',
|
|
121
|
+
async local(dir, onWarn) {
|
|
122
|
+
const state = new Map();
|
|
123
|
+
for (const type of ACF_OBJECT_TYPES) {
|
|
124
|
+
const objects = await loadFiles(join(dir, type), {
|
|
125
|
+
extension: '.json',
|
|
126
|
+
onSkip: onWarn,
|
|
127
|
+
parse: readJson,
|
|
128
|
+
});
|
|
129
|
+
for (const object of objects) {
|
|
130
|
+
const key = getAcfKey(object);
|
|
131
|
+
if (key !== null)
|
|
132
|
+
state.set(`${type}/${key}`, omit(object, ACF_VOLATILE_KEYS));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return state;
|
|
136
|
+
},
|
|
137
|
+
async remote(wp) {
|
|
138
|
+
const state = new Map();
|
|
139
|
+
for (const type of ACF_OBJECT_TYPES) {
|
|
140
|
+
const raw = await wp.get(acfEndpoint(type));
|
|
141
|
+
for (const object of raw) {
|
|
142
|
+
const key = getAcfKey(object);
|
|
143
|
+
if (key !== null)
|
|
144
|
+
state.set(`${type}/${key}`, omit(object, ACF_VOLATILE_KEYS));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return state;
|
|
148
|
+
},
|
|
149
|
+
resource: 'acf',
|
|
150
|
+
title: 'ACF',
|
|
151
|
+
};
|
|
152
|
+
// ---- PHP file directories (API routes, hooks) -----------------------------------------
|
|
153
|
+
// api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
|
|
154
|
+
// verbatim (line diff on change), no server-side normalization to undo. Only the directory
|
|
155
|
+
// kind, the endpoint and the labels differ.
|
|
156
|
+
function phpFilesProvider(spec) {
|
|
157
|
+
const toState = (files) => {
|
|
158
|
+
const state = new Map();
|
|
159
|
+
for (const file of files)
|
|
160
|
+
state.set(file.filename, file.content);
|
|
161
|
+
return state;
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
dirKind: spec.dirKind,
|
|
165
|
+
async local(dir, onWarn) {
|
|
166
|
+
return toState(await loadFiles(dir, {
|
|
167
|
+
extension: '.php',
|
|
168
|
+
onSkip: onWarn,
|
|
169
|
+
parse: (raw, filePath) => ({
|
|
170
|
+
content: raw,
|
|
171
|
+
filename: relative(dir, filePath).slice(0, -'.php'.length).split(sep).join('/'),
|
|
172
|
+
}),
|
|
173
|
+
recursive: true,
|
|
174
|
+
}));
|
|
175
|
+
},
|
|
176
|
+
async remote(wp) {
|
|
177
|
+
return toState(await wp.get(spec.endpoint));
|
|
178
|
+
},
|
|
179
|
+
resource: spec.resource,
|
|
180
|
+
title: spec.title,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const apiProvider = phpFilesProvider({
|
|
184
|
+
dirKind: 'api',
|
|
185
|
+
endpoint: 'loopress/v1/api-files',
|
|
186
|
+
resource: 'api',
|
|
187
|
+
title: 'API routes',
|
|
188
|
+
});
|
|
189
|
+
const hookProvider = phpFilesProvider({
|
|
190
|
+
dirKind: 'hooks',
|
|
191
|
+
endpoint: 'loopress/v1/hook-files',
|
|
192
|
+
resource: 'hook',
|
|
193
|
+
title: 'Hooks',
|
|
194
|
+
});
|
|
195
|
+
// ---- SEO --------------------------------------------------------------------------------
|
|
196
|
+
function canonicalRedirect(redirect) {
|
|
197
|
+
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
198
|
+
}
|
|
199
|
+
const seoProvider = {
|
|
200
|
+
dirKind: 'seo',
|
|
201
|
+
async local(dir, onWarn) {
|
|
202
|
+
const state = new Map();
|
|
203
|
+
// The provider settings blob (Yoast / RankMath) is compared whole. If a real payload turns
|
|
204
|
+
// out to carry install-specific noise (a plugin version, a license hash), add those keys
|
|
205
|
+
// to an omit() list here, the e2e round-trip is what will surface them.
|
|
206
|
+
const settingsPath = join(dir, 'settings.json');
|
|
207
|
+
try {
|
|
208
|
+
const settings = readJson(await readFile(settingsPath, 'utf8'));
|
|
209
|
+
state.set('settings', settings);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== 'ENOENT') {
|
|
213
|
+
onWarn(`Skipping "${settingsPath}": ${error.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const postType of DEFAULT_POST_TYPES) {
|
|
217
|
+
const metaDir = join(dir, 'post-meta', postType);
|
|
218
|
+
const entries = await loadFiles(metaDir, {
|
|
219
|
+
extension: '.json',
|
|
220
|
+
onSkip: onWarn,
|
|
221
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
222
|
+
});
|
|
223
|
+
for (const { file, value } of entries) {
|
|
224
|
+
const slug = typeof value.slug === 'string' && value.slug !== '' ? value.slug : `local:${file}`;
|
|
225
|
+
state.set(`post-meta/${postType}/${slug}`, value);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const redirects = await loadFiles(join(dir, 'redirects'), {
|
|
229
|
+
extension: '.json',
|
|
230
|
+
onSkip: onWarn,
|
|
231
|
+
parse: readJson,
|
|
232
|
+
});
|
|
233
|
+
for (const redirect of redirects) {
|
|
234
|
+
const id = Number(redirect.id);
|
|
235
|
+
if (Number.isSafeInteger(id) && id > 0)
|
|
236
|
+
state.set(`redirects/${id}`, canonicalRedirect(redirect));
|
|
237
|
+
}
|
|
238
|
+
return state;
|
|
239
|
+
},
|
|
240
|
+
async remote(wp, onWarn) {
|
|
241
|
+
const state = new Map();
|
|
242
|
+
const settings = await wp.get(SEO_SETTINGS_ENDPOINT);
|
|
243
|
+
state.set('settings', settings);
|
|
244
|
+
for (const postType of DEFAULT_POST_TYPES) {
|
|
245
|
+
const posts = await wp.get(seoPostMetaEndpoint(postType));
|
|
246
|
+
for (const post of posts)
|
|
247
|
+
state.set(`post-meta/${postType}/${post.slug}`, post);
|
|
248
|
+
}
|
|
249
|
+
// Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
|
|
250
|
+
// as `seo pull`.
|
|
251
|
+
try {
|
|
252
|
+
const redirects = await wp.get(SEO_REDIRECTS_ENDPOINT);
|
|
253
|
+
for (const redirect of redirects) {
|
|
254
|
+
state.set(`redirects/${redirect.id}`, canonicalRedirect(redirect));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (!isNotFoundError(error))
|
|
259
|
+
throw error;
|
|
260
|
+
onWarn(`Skipping redirects: not supported by the active SEO plugin`);
|
|
261
|
+
}
|
|
262
|
+
return state;
|
|
263
|
+
},
|
|
264
|
+
resource: 'seo',
|
|
265
|
+
title: 'SEO',
|
|
266
|
+
};
|
|
267
|
+
// ---- Options ------------------------------------------------------------------------------
|
|
268
|
+
// Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
|
|
269
|
+
// lists names, never values (by design, see OptionsController), so both sides compare exactly
|
|
270
|
+
// the locally tracked set, read from `dir` on the remote side too (see ResourceStateProvider's
|
|
271
|
+
// docstring). `readonly` is a local policy flag with no WordPress counterpart, left out of both
|
|
272
|
+
// sides so it can never itself show up as drift.
|
|
273
|
+
const optionsProvider = {
|
|
274
|
+
dirKind: 'options',
|
|
275
|
+
async local(dir, onWarn) {
|
|
276
|
+
const entries = await loadFiles(dir, {
|
|
277
|
+
extension: '.json',
|
|
278
|
+
onSkip: onWarn,
|
|
279
|
+
parse(raw) {
|
|
280
|
+
const option = parseLocalOption(raw);
|
|
281
|
+
return { autoload: option.autoload, name: option.name, value: option.value };
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const state = new Map();
|
|
285
|
+
for (const { autoload, name, value } of entries)
|
|
286
|
+
state.set(name, { autoload, value });
|
|
287
|
+
return state;
|
|
288
|
+
},
|
|
289
|
+
async remote(wp, onWarn, dir) {
|
|
290
|
+
const tracked = await loadFiles(dir, {
|
|
291
|
+
extension: '.json',
|
|
292
|
+
onSkip: onWarn,
|
|
293
|
+
parse: (raw) => parseLocalOption(raw).name,
|
|
294
|
+
});
|
|
295
|
+
const state = new Map();
|
|
296
|
+
for (const name of tracked) {
|
|
297
|
+
try {
|
|
298
|
+
const option = await wp.get(optionEndpoint(name));
|
|
299
|
+
state.set(name, { autoload: option.autoload, value: option.value });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!isNotFoundError(error))
|
|
303
|
+
throw error;
|
|
304
|
+
// Absent on this environment (never pushed here, or deleted there): left out of the
|
|
305
|
+
// map, which reads as "removed" on this side, exactly the right signal.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return state;
|
|
309
|
+
},
|
|
310
|
+
resource: 'option',
|
|
311
|
+
title: 'Options',
|
|
312
|
+
};
|
|
313
|
+
export const RESOURCE_STATE_PROVIDERS = [
|
|
314
|
+
snippetProvider,
|
|
315
|
+
formProvider,
|
|
316
|
+
acfProvider,
|
|
317
|
+
apiProvider,
|
|
318
|
+
hookProvider,
|
|
319
|
+
seoProvider,
|
|
320
|
+
optionsProvider,
|
|
321
|
+
];
|
|
322
|
+
export function getResourceStateProvider(resource) {
|
|
323
|
+
const provider = RESOURCE_STATE_PROVIDERS.find((candidate) => candidate.resource === resource);
|
|
324
|
+
if (!provider)
|
|
325
|
+
throw new Error(`No resource-state provider for "${resource}"`);
|
|
326
|
+
return provider;
|
|
327
|
+
}
|
|
328
|
+
// ---- Composer ---------------------------------------------------------------------------
|
|
329
|
+
// Composer isn't a directory of items but two files at the project root, so it sits outside
|
|
330
|
+
// RESOURCE_STATE_PROVIDERS. The compared value is each file's text.
|
|
331
|
+
const COMPOSER_JSON_ENDPOINT = 'loopress/v1/composer/json';
|
|
332
|
+
const COMPOSER_LOCK_ENDPOINT = 'loopress/v1/composer/lock';
|
|
333
|
+
export async function composerRemoteState(wp) {
|
|
334
|
+
const state = new Map();
|
|
335
|
+
const { composerJson } = await wp.get(COMPOSER_JSON_ENDPOINT);
|
|
336
|
+
state.set('composer.json', composerJson);
|
|
337
|
+
try {
|
|
338
|
+
const { composerLock } = await wp.get(COMPOSER_LOCK_ENDPOINT);
|
|
339
|
+
state.set('composer.lock', composerLock);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
// A site that never pushed Composer dependencies has no lock yet, the same tolerated 404
|
|
343
|
+
// as `composer pull`.
|
|
344
|
+
if (!isApplicative404(error, 'composer.lock not found'))
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
return state;
|
|
348
|
+
}
|
|
349
|
+
export async function composerLocalState(rootDir) {
|
|
350
|
+
const state = new Map();
|
|
351
|
+
for (const file of ['composer.json', 'composer.lock']) {
|
|
352
|
+
try {
|
|
353
|
+
state.set(file, await readFile(join(rootDir, file), 'utf8'));
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (error.code !== 'ENOENT')
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return state;
|
|
361
|
+
}
|
|
@@ -6,12 +6,12 @@ import { loadSnippets } from './load-snippets.js';
|
|
|
6
6
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
7
7
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
8
8
|
// and api routes (`.php`, not JSON) are checked separately.
|
|
9
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', '
|
|
9
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir'];
|
|
10
10
|
const DEFAULT_DIR = {
|
|
11
11
|
acfDir: 'acf',
|
|
12
12
|
apiDir: 'api',
|
|
13
13
|
formDir: 'forms',
|
|
14
|
-
|
|
14
|
+
hooksDir: 'hooks',
|
|
15
15
|
seoDir: 'seo',
|
|
16
16
|
snippetsDir: 'snippets',
|
|
17
17
|
};
|
|
@@ -35,20 +35,28 @@ export async function validateLocal(cwd) {
|
|
|
35
35
|
checked += await checkJsonDir(resolve(key), problems);
|
|
36
36
|
}
|
|
37
37
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
38
|
-
checked += await
|
|
38
|
+
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
39
|
+
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
39
40
|
return { checked, problems, valid: problems.length === 0 };
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
43
|
+
// (invoice-pdf/[order_id].php, content/filters.php), a non-recursive scan would silently
|
|
44
|
+
// never check those.
|
|
45
|
+
async function checkPhpDir(dir, problems, emptyMessage) {
|
|
46
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
43
47
|
let checked = 0;
|
|
44
48
|
for (const entry of entries) {
|
|
45
49
|
if (!entry.isFile() || !entry.name.endsWith('.php'))
|
|
46
50
|
continue;
|
|
47
51
|
checked += 1;
|
|
48
|
-
|
|
52
|
+
// parentPath is the entry's actual containing directory, which only equals `dir` itself
|
|
53
|
+
// for a top-level file: recursive readdir() nests entry.name to the basename alone, so
|
|
54
|
+
// join(dir, entry.name) would silently point at the wrong (top-level) path for anything
|
|
55
|
+
// one level deep or more.
|
|
56
|
+
const filePath = join(entry.parentPath, entry.name);
|
|
49
57
|
const content = await readFile(filePath, 'utf8');
|
|
50
58
|
if (content.trim() === '')
|
|
51
|
-
problems.push({ file: filePath, message:
|
|
59
|
+
problems.push({ file: filePath, message: emptyMessage });
|
|
52
60
|
}
|
|
53
61
|
return checked;
|
|
54
62
|
}
|