@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.
Files changed (82) hide show
  1. package/README.md +515 -83
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/composer/pull.js +6 -16
  13. package/dist/commands/dev.js +1 -1
  14. package/dist/commands/diff.d.ts +13 -0
  15. package/dist/commands/diff.js +42 -0
  16. package/dist/commands/form/diff.d.ts +2 -0
  17. package/dist/commands/form/diff.js +5 -0
  18. package/dist/commands/hook/diff.d.ts +2 -0
  19. package/dist/commands/hook/diff.js +5 -0
  20. package/dist/commands/hook/list.d.ts +2 -0
  21. package/dist/commands/hook/list.js +2 -0
  22. package/dist/commands/hook/pull.d.ts +2 -0
  23. package/dist/commands/hook/pull.js +2 -0
  24. package/dist/commands/hook/push.d.ts +2 -0
  25. package/dist/commands/hook/push.js +2 -0
  26. package/dist/commands/init.js +1 -1
  27. package/dist/commands/option/add.d.ts +15 -0
  28. package/dist/commands/option/add.js +47 -0
  29. package/dist/commands/option/diff.d.ts +2 -0
  30. package/dist/commands/option/diff.js +5 -0
  31. package/dist/commands/{page → option}/list.d.ts +5 -1
  32. package/dist/commands/option/list.js +151 -0
  33. package/dist/commands/{page → option}/pull.d.ts +3 -12
  34. package/dist/commands/option/pull.js +75 -0
  35. package/dist/commands/{page → option}/push.d.ts +3 -8
  36. package/dist/commands/option/push.js +66 -0
  37. package/dist/commands/option/remove.d.ts +22 -0
  38. package/dist/commands/option/remove.js +69 -0
  39. package/dist/commands/plugin/push.d.ts +0 -1
  40. package/dist/commands/plugin/push.js +3 -10
  41. package/dist/commands/pull.js +3 -2
  42. package/dist/commands/push.js +4 -3
  43. package/dist/commands/seo/diff.d.ts +2 -0
  44. package/dist/commands/seo/diff.js +5 -0
  45. package/dist/commands/snippet/diff.d.ts +2 -0
  46. package/dist/commands/snippet/diff.js +5 -0
  47. package/dist/commands/theme/push.d.ts +0 -1
  48. package/dist/commands/theme/push.js +3 -10
  49. package/dist/lib/base.d.ts +2 -1
  50. package/dist/lib/base.js +12 -22
  51. package/dist/lib/dev-watch.d.ts +1 -1
  52. package/dist/lib/dev-watch.js +1 -2
  53. package/dist/lib/diff-command.d.ts +47 -0
  54. package/dist/lib/diff-command.js +180 -0
  55. package/dist/lib/diff-state.d.ts +22 -0
  56. package/dist/lib/diff-state.js +88 -0
  57. package/dist/lib/find-orphaned-files.js +1 -1
  58. package/dist/lib/interactive.d.ts +1 -0
  59. package/dist/lib/interactive.js +10 -0
  60. package/dist/lib/php-files-command.d.ts +54 -0
  61. package/dist/lib/php-files-command.js +216 -0
  62. package/dist/lib/put-or-create.js +1 -1
  63. package/dist/lib/readdir-tolerant.d.ts +1 -0
  64. package/dist/lib/readdir-tolerant.js +3 -1
  65. package/dist/lib/resource-state.d.ts +14 -0
  66. package/dist/lib/resource-state.js +361 -0
  67. package/dist/lib/validate-local.js +15 -7
  68. package/dist/lib/wp-client.d.ts +5 -0
  69. package/dist/lib/wp-client.js +56 -0
  70. package/dist/types/project-config.generated.d.ts +8 -4
  71. package/dist/utils/option-format.d.ts +31 -0
  72. package/dist/utils/option-format.js +51 -0
  73. package/dist/utils/resource-dirs.d.ts +13 -0
  74. package/dist/utils/resource-dirs.js +34 -0
  75. package/oclif.manifest.json +1156 -149
  76. package/package.json +10 -7
  77. package/schemas/project-config.schema.json +10 -5
  78. package/dist/commands/page/list.js +0 -19
  79. package/dist/commands/page/pull.js +0 -57
  80. package/dist/commands/page/push.js +0 -132
  81. package/dist/utils/page-format.d.ts +0 -7
  82. 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, page, seo redirect,
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,5 +1,6 @@
1
1
  import { type Dirent } from 'node:fs';
2
2
  export declare function readdirTolerant(dir: string): Promise<string[]>;
3
3
  export declare function readdirTolerant(dir: string, options: {
4
+ recursive?: boolean;
4
5
  withFileTypes: true;
5
6
  }): Promise<Dirent[]>;
@@ -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 ? await readdir(dir, { withFileTypes: true }) : await readdir(dir);
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', 'pageDir', 'seoDir'];
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
- pageDir: 'pages',
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 checkApiDir(resolve('apiDir'), problems);
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
- async function checkApiDir(dir, problems) {
42
- const entries = await readdirTolerant(dir, { withFileTypes: true });
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
- const filePath = join(dir, entry.name);
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: 'API route file is empty' });
59
+ problems.push({ file: filePath, message: emptyMessage });
52
60
  }
53
61
  return checked;
54
62
  }