@loopress/cli 0.22.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 (117) hide show
  1. package/README.md +794 -85
  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/app/list.d.ts +17 -0
  13. package/dist/commands/app/list.js +35 -0
  14. package/dist/commands/{page → app}/pull.d.ts +7 -5
  15. package/dist/commands/app/pull.js +94 -0
  16. package/dist/commands/app/push.d.ts +28 -0
  17. package/dist/commands/app/push.js +130 -0
  18. package/dist/commands/app/remove.d.ts +20 -0
  19. package/dist/commands/app/remove.js +42 -0
  20. package/dist/commands/composer/pull.js +6 -16
  21. package/dist/commands/composer/push.d.ts +1 -0
  22. package/dist/commands/composer/push.js +43 -19
  23. package/dist/commands/dev.js +1 -1
  24. package/dist/commands/diff.d.ts +13 -0
  25. package/dist/commands/diff.js +42 -0
  26. package/dist/commands/form/diff.d.ts +2 -0
  27. package/dist/commands/form/diff.js +5 -0
  28. package/dist/commands/hook/diff.d.ts +2 -0
  29. package/dist/commands/hook/diff.js +5 -0
  30. package/dist/commands/hook/list.d.ts +2 -0
  31. package/dist/commands/hook/list.js +2 -0
  32. package/dist/commands/hook/pull.d.ts +2 -0
  33. package/dist/commands/hook/pull.js +2 -0
  34. package/dist/commands/hook/push.d.ts +2 -0
  35. package/dist/commands/hook/push.js +2 -0
  36. package/dist/commands/init.js +2 -1
  37. package/dist/commands/option/add.d.ts +15 -0
  38. package/dist/commands/option/add.js +47 -0
  39. package/dist/commands/option/diff.d.ts +2 -0
  40. package/dist/commands/option/diff.js +5 -0
  41. package/dist/commands/{page → option}/list.d.ts +5 -1
  42. package/dist/commands/option/list.js +151 -0
  43. package/dist/commands/option/pull.d.ts +16 -0
  44. package/dist/commands/option/pull.js +75 -0
  45. package/dist/commands/{page → option}/push.d.ts +3 -8
  46. package/dist/commands/option/push.js +66 -0
  47. package/dist/commands/option/remove.d.ts +22 -0
  48. package/dist/commands/option/remove.js +69 -0
  49. package/dist/commands/plugin/add.d.ts +1 -0
  50. package/dist/commands/plugin/add.js +18 -8
  51. package/dist/commands/plugin/audit.d.ts +30 -0
  52. package/dist/commands/plugin/audit.js +138 -0
  53. package/dist/commands/plugin/pull.d.ts +1 -2
  54. package/dist/commands/plugin/pull.js +11 -11
  55. package/dist/commands/plugin/push.d.ts +13 -8
  56. package/dist/commands/plugin/push.js +161 -67
  57. package/dist/commands/plugin/status.d.ts +22 -0
  58. package/dist/commands/plugin/status.js +65 -0
  59. package/dist/commands/pull.js +3 -2
  60. package/dist/commands/push.js +4 -3
  61. package/dist/commands/seo/diff.d.ts +2 -0
  62. package/dist/commands/seo/diff.js +5 -0
  63. package/dist/commands/snippet/diff.d.ts +2 -0
  64. package/dist/commands/snippet/diff.js +5 -0
  65. package/dist/commands/theme/add.d.ts +13 -0
  66. package/dist/commands/theme/add.js +35 -0
  67. package/dist/commands/theme/pull.d.ts +21 -0
  68. package/dist/commands/theme/pull.js +37 -0
  69. package/dist/commands/theme/push.d.ts +22 -0
  70. package/dist/commands/theme/push.js +123 -0
  71. package/dist/commands/theme/status.d.ts +20 -0
  72. package/dist/commands/theme/status.js +51 -0
  73. package/dist/lib/app-manifest.d.ts +36 -0
  74. package/dist/lib/app-manifest.js +162 -0
  75. package/dist/lib/base.d.ts +3 -1
  76. package/dist/lib/base.js +14 -19
  77. package/dist/lib/dev-watch.d.ts +1 -1
  78. package/dist/lib/dev-watch.js +1 -2
  79. package/dist/lib/diff-command.d.ts +47 -0
  80. package/dist/lib/diff-command.js +180 -0
  81. package/dist/lib/diff-state.d.ts +22 -0
  82. package/dist/lib/diff-state.js +88 -0
  83. package/dist/lib/find-orphaned-files.js +1 -1
  84. package/dist/lib/interactive.d.ts +1 -0
  85. package/dist/lib/interactive.js +10 -0
  86. package/dist/lib/php-files-command.d.ts +54 -0
  87. package/dist/lib/php-files-command.js +216 -0
  88. package/dist/lib/put-or-create.js +1 -1
  89. package/dist/lib/readdir-tolerant.d.ts +1 -0
  90. package/dist/lib/readdir-tolerant.js +3 -1
  91. package/dist/lib/resource-state.d.ts +14 -0
  92. package/dist/lib/resource-state.js +361 -0
  93. package/dist/lib/validate-local.js +15 -7
  94. package/dist/lib/wp-client.d.ts +5 -0
  95. package/dist/lib/wp-client.js +56 -0
  96. package/dist/types/plugin.d.ts +1 -0
  97. package/dist/types/project-config.generated.d.ts +19 -5
  98. package/dist/utils/option-format.d.ts +31 -0
  99. package/dist/utils/option-format.js +51 -0
  100. package/dist/utils/plugin-sync.d.ts +26 -0
  101. package/dist/utils/plugin-sync.js +31 -0
  102. package/dist/utils/plugins.d.ts +15 -2
  103. package/dist/utils/plugins.js +57 -12
  104. package/dist/utils/resource-dirs.d.ts +13 -0
  105. package/dist/utils/resource-dirs.js +34 -0
  106. package/dist/utils/themes.d.ts +30 -0
  107. package/dist/utils/themes.js +29 -0
  108. package/dist/utils/version.d.ts +2 -0
  109. package/dist/utils/version.js +21 -0
  110. package/oclif.manifest.json +1897 -244
  111. package/package.json +26 -19
  112. package/schemas/project-config.schema.json +32 -9
  113. package/dist/commands/page/list.js +0 -19
  114. package/dist/commands/page/pull.js +0 -57
  115. package/dist/commands/page/push.js +0 -132
  116. package/dist/utils/page-format.d.ts +0 -7
  117. package/dist/utils/page-format.js +0 -78
@@ -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
  }
@@ -9,6 +9,9 @@ export declare class WpClient {
9
9
  constructor(siteUrl: string, token: string);
10
10
  delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
11
11
  get<T>(path: string, options?: RequestOptions): Promise<T>;
12
+ getAll<T>(path: string, options?: RequestOptions & {
13
+ perPage?: number;
14
+ }): Promise<T[]>;
12
15
  post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
13
16
  put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
14
17
  private request;
@@ -17,6 +20,8 @@ type RequestOptions = {
17
20
  timeoutMs?: number;
18
21
  };
19
22
  export declare function isNotFoundError(error: unknown): boolean;
23
+ export declare function isApplicative404(error: unknown, marker: string): boolean;
20
24
  export declare function isTimeoutError(error: unknown): boolean;
25
+ export declare function isInvalidPageNumber(error: unknown): boolean;
21
26
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
22
27
  export {};
@@ -22,6 +22,32 @@ export class WpClient {
22
22
  async get(path, options) {
23
23
  return this.request('get', path, undefined, options);
24
24
  }
25
+ // Fetches every page of a paginated WP REST collection by walking `?page=N`, stopping on the
26
+ // first short page or on WordPress core's 400 "rest_post_invalid_page_number" past the last
27
+ // one. `path` may already carry query params. Use for `wp/v2/*` list endpoints, which cap at
28
+ // 100 items per request.
29
+ async getAll(path, options) {
30
+ const perPage = options?.perPage ?? 100;
31
+ const separator = path.includes('?') ? '&' : '?';
32
+ const all = [];
33
+ for (let page = 1;; page += 1) {
34
+ let batch;
35
+ try {
36
+ batch = await this.get(`${path}${separator}per_page=${perPage}&page=${page}`, options);
37
+ }
38
+ catch (error) {
39
+ if (isInvalidPageNumber(error))
40
+ break;
41
+ throw error;
42
+ }
43
+ if (!Array.isArray(batch) || batch.length === 0)
44
+ break;
45
+ all.push(...batch);
46
+ if (batch.length < perPage)
47
+ break;
48
+ }
49
+ return all;
50
+ }
25
51
  async post(path, json, options) {
26
52
  return this.request('post', path, json, options);
27
53
  }
@@ -43,10 +69,40 @@ export function isNotFoundError(error) {
43
69
  const cause = error?.cause;
44
70
  return cause?.response?.statusCode === 404;
45
71
  }
72
+ // A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
73
+ // (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
74
+ // isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
75
+ // installed or predates the feature, which callers must still surface normally.
76
+ export function isApplicative404(error, marker) {
77
+ if (!isNotFoundError(error))
78
+ return false;
79
+ const body = error.cause?.response?.body;
80
+ if (!body)
81
+ return false;
82
+ try {
83
+ return JSON.parse(body).error === marker;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
46
89
  export function isTimeoutError(error) {
47
90
  const cause = error?.cause;
48
91
  return cause?.name === 'TimeoutError';
49
92
  }
93
+ // WordPress core answers 400 with this `code` when `?page=N` is past the last page of a
94
+ // collection, which is how `WpClient.getAll` knows it has walked them all.
95
+ export function isInvalidPageNumber(error) {
96
+ const body = error.cause?.response?.body;
97
+ if (!body)
98
+ return false;
99
+ try {
100
+ return JSON.parse(body).code === 'rest_post_invalid_page_number';
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
50
106
  export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
51
107
  const err = error;
52
108
  const status = err.response?.statusCode;
@@ -8,6 +8,7 @@ export type InstalledPlugin = {
8
8
  export type WpNativePlugin = {
9
9
  name: string;
10
10
  plugin: string;
11
+ plugin_uri: string;
11
12
  status: 'active' | 'inactive' | 'network-active';
12
13
  version: string;
13
14
  };
@@ -23,22 +23,36 @@ export interface LoopressProjectConfiguration {
23
23
  * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
24
  */
25
25
  formDir?: string;
26
- /**
27
- * Directory where WordPress pages are read from and written to, relative to rootDir.
28
- */
29
- pageDir?: string;
30
26
  /**
31
27
  * Directory where custom API route files are read from and written to, relative to rootDir.
32
28
  */
33
29
  apiDir?: string;
30
+ /**
31
+ * Directory where hook files (WordPress actions, filters, and scheduled tasks) are read from and written to, relative to rootDir.
32
+ */
33
+ hooksDir?: string;
34
+ /**
35
+ * Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
36
+ */
37
+ appsDir?: string;
38
+ /**
39
+ * Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
40
+ */
41
+ optionsDir?: string;
34
42
  /**
35
43
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
36
44
  */
37
45
  projectId?: string;
38
46
  /**
39
- * WordPress.org plugins managed by Loopress. Keys are plugin slugs; values are written as "latest" since installs go through WordPress's native plugin API, which only installs the current version. Older files may still have a pinned version string here from before this was the case; it is read but no longer acted on.
47
+ * WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
40
48
  */
41
49
  plugins?: {
42
50
  [k: string]: string;
43
51
  };
52
+ /**
53
+ * WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme. Ignored when a composer.json exists in rootDir.
54
+ */
55
+ themes?: {
56
+ [k: string]: string;
57
+ };
44
58
  }
@@ -0,0 +1,31 @@
1
+ export declare const OPTIONS_ENDPOINT = "loopress/v1/options";
2
+ export declare function optionEndpoint(name: string): string;
3
+ export declare const RESERVED_OPTION_NAMES: readonly ["active_plugins", "stylesheet", "template"];
4
+ export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "cron", "rewrite_rules", "WPLANG"];
5
+ export type LocalOption = {
6
+ autoload: string;
7
+ name: string;
8
+ readonly?: boolean;
9
+ value: unknown;
10
+ };
11
+ export type RemoteOption = {
12
+ autoload: string;
13
+ name: string;
14
+ value: unknown;
15
+ };
16
+ export type ListedOption = {
17
+ autoload: string;
18
+ confirmed: boolean;
19
+ core: boolean;
20
+ guess: null | string;
21
+ name: string;
22
+ pluginName: null | string;
23
+ };
24
+ export declare function isReservedOptionName(name: string): boolean;
25
+ export declare function defaultReadonlyFor(name: string): boolean;
26
+ export declare function optionFileName(name: string): string;
27
+ export declare function partitionByReadonly(tracked: LocalOption[]): {
28
+ skipped: LocalOption[];
29
+ writable: LocalOption[];
30
+ };
31
+ export declare function parseLocalOption(raw: string): LocalOption;
@@ -0,0 +1,51 @@
1
+ export const OPTIONS_ENDPOINT = 'loopress/v1/options';
2
+ export function optionEndpoint(name) {
3
+ return `${OPTIONS_ENDPOINT}/${encodeURIComponent(name)}`;
4
+ }
5
+ // Mirrors OptionsService::RESERVED_NAMES on the WordPress side (the actual, unbypassable
6
+ // enforcement lives there). Checked here too so `option add` fails fast with a clear pointer to
7
+ // the right command, instead of a round trip just to hit the server's 409.
8
+ export const RESERVED_OPTION_NAMES = ['active_plugins', 'stylesheet', 'template'];
9
+ // Options WordPress core owns or regenerates itself: legitimate to track for visibility (`diff`
10
+ // still shows drift), but never something `push` should overwrite by default. siteurl/home in
11
+ // particular: pushing a value copied from another environment would repoint the target site's
12
+ // own URLs, potentially locking out wp-admin.
13
+ export const READONLY_BY_DEFAULT_OPTION_NAMES = ['siteurl', 'home', 'db_version', 'cron', 'rewrite_rules', 'WPLANG'];
14
+ export function isReservedOptionName(name) {
15
+ return RESERVED_OPTION_NAMES.includes(name);
16
+ }
17
+ export function defaultReadonlyFor(name) {
18
+ return READONLY_BY_DEFAULT_OPTION_NAMES.includes(name);
19
+ }
20
+ export function optionFileName(name) {
21
+ return `${name}.json`;
22
+ }
23
+ // Splits tracked options into what `push` may write and what it must skip: readonly is a local
24
+ // policy flag (see READONLY_BY_DEFAULT_OPTION_NAMES), never something WordPress itself enforces.
25
+ export function partitionByReadonly(tracked) {
26
+ const writable = [];
27
+ const skipped = [];
28
+ for (const option of tracked)
29
+ (option.readonly ? skipped : writable).push(option);
30
+ return { skipped, writable };
31
+ }
32
+ export function parseLocalOption(raw) {
33
+ const parsed = JSON.parse(raw);
34
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
35
+ throw new Error('not a JSON object');
36
+ }
37
+ const { autoload, name, value } = parsed;
38
+ if (typeof name !== 'string' || name === '') {
39
+ throw new Error('missing a "name" string');
40
+ }
41
+ if (typeof autoload !== 'string') {
42
+ throw new TypeError('missing an "autoload" string');
43
+ }
44
+ // `value` itself is deliberately untyped (a WP option can hold any JSON-safe value, including
45
+ // null); only the key's presence is checked, so a hand-edited file that dropped it entirely is
46
+ // rejected here rather than silently pushing an `undefined` value to WordPress.
47
+ if (value === undefined) {
48
+ throw new Error('missing a "value" field');
49
+ }
50
+ return parsed;
51
+ }
@@ -0,0 +1,26 @@
1
+ export declare const SYNC_TIMEOUT_MS = 600000;
2
+ export type SyncIntent = {
3
+ libraries?: Record<string, string>;
4
+ plugins?: Record<string, string>;
5
+ themes?: Record<string, string>;
6
+ };
7
+ export type SyncPayload = {
8
+ force: boolean;
9
+ intent: SyncIntent;
10
+ lock: null | string;
11
+ };
12
+ export type SyncResponse = {
13
+ composerJson: string;
14
+ composerLock: string;
15
+ message: string;
16
+ output: string;
17
+ removed: string[];
18
+ };
19
+ export type Collision = {
20
+ installedVersion: string;
21
+ path: string;
22
+ slug: string;
23
+ type: 'plugin' | 'theme';
24
+ };
25
+ export declare function parseCollisions(error: unknown): Collision[] | null;
26
+ export declare function isDowngrade(from: string, to: string): boolean;