@loopress/cli 0.28.0 → 0.30.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 (190) hide show
  1. package/README.md +1460 -337
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/push.js +1 -1
  9. package/dist/commands/composer/push.js +3 -3
  10. package/dist/commands/cpt/diff.js +5 -0
  11. package/dist/commands/cpt/list.d.ts +2 -0
  12. package/dist/commands/cpt/list.js +4 -0
  13. package/dist/commands/cpt/pull.d.ts +2 -0
  14. package/dist/commands/cpt/pull.js +4 -0
  15. package/dist/commands/cpt/push.d.ts +2 -0
  16. package/dist/commands/cpt/push.js +4 -0
  17. package/dist/commands/cpt/rm.d.ts +2 -0
  18. package/dist/commands/cpt/rm.js +3 -0
  19. package/dist/commands/cpt/rollback.js +5 -0
  20. package/dist/commands/dev.js +14 -4
  21. package/dist/commands/diff.js +1 -1
  22. package/dist/commands/doctor.d.ts +1 -0
  23. package/dist/commands/doctor.js +30 -2
  24. package/dist/commands/form/pull.d.ts +9 -1
  25. package/dist/commands/form/pull.js +3 -1
  26. package/dist/commands/form/push.d.ts +7 -1
  27. package/dist/commands/form/push.js +3 -1
  28. package/dist/commands/init.js +6 -38
  29. package/dist/commands/menu/pull.d.ts +7 -1
  30. package/dist/commands/menu/pull.js +2 -0
  31. package/dist/commands/menu/push.d.ts +7 -1
  32. package/dist/commands/menu/push.js +3 -1
  33. package/dist/commands/meta/diff.d.ts +2 -0
  34. package/dist/commands/meta/diff.js +5 -0
  35. package/dist/commands/meta/list.d.ts +19 -0
  36. package/dist/commands/meta/list.js +21 -0
  37. package/dist/commands/meta/pull.d.ts +2 -0
  38. package/dist/commands/meta/pull.js +4 -0
  39. package/dist/commands/meta/push.d.ts +2 -0
  40. package/dist/commands/meta/push.js +4 -0
  41. package/dist/commands/meta/rm.d.ts +2 -0
  42. package/dist/commands/meta/rm.js +3 -0
  43. package/dist/commands/meta/rollback.d.ts +2 -0
  44. package/dist/commands/meta/rollback.js +5 -0
  45. package/dist/commands/option/list.js +2 -8
  46. package/dist/commands/option/pull.js +4 -4
  47. package/dist/commands/option/push.js +2 -0
  48. package/dist/commands/page/push.js +3 -0
  49. package/dist/commands/pattern/diff.d.ts +2 -0
  50. package/dist/commands/pattern/diff.js +5 -0
  51. package/dist/commands/pattern/list.d.ts +2 -0
  52. package/dist/commands/pattern/list.js +3 -0
  53. package/dist/commands/pattern/pull.d.ts +2 -0
  54. package/dist/commands/pattern/pull.js +4 -0
  55. package/dist/commands/pattern/push.d.ts +2 -0
  56. package/dist/commands/pattern/push.js +4 -0
  57. package/dist/commands/pattern/rm.d.ts +2 -0
  58. package/dist/commands/pattern/rm.js +3 -0
  59. package/dist/commands/pattern/rollback.d.ts +2 -0
  60. package/dist/commands/pattern/rollback.js +5 -0
  61. package/dist/commands/plugin/audit.js +2 -1
  62. package/dist/commands/plugin/pull.d.ts +4 -0
  63. package/dist/commands/plugin/pull.js +56 -10
  64. package/dist/commands/plugin/push.js +5 -4
  65. package/dist/commands/project/config.d.ts +1 -0
  66. package/dist/commands/project/config.js +20 -1
  67. package/dist/commands/project/list.d.ts +1 -1
  68. package/dist/commands/project/list.js +6 -6
  69. package/dist/commands/project/pull.js +11 -8
  70. package/dist/commands/project/push.js +23 -18
  71. package/dist/commands/project/remove.js +1 -1
  72. package/dist/commands/project/switch.d.ts +0 -1
  73. package/dist/commands/project/switch.js +16 -36
  74. package/dist/commands/pull.js +7 -2
  75. package/dist/commands/push.js +14 -6
  76. package/dist/commands/role/diff.d.ts +2 -0
  77. package/dist/commands/role/diff.js +5 -0
  78. package/dist/commands/role/list.d.ts +2 -0
  79. package/dist/commands/role/list.js +4 -0
  80. package/dist/commands/role/pull.d.ts +2 -0
  81. package/dist/commands/role/pull.js +4 -0
  82. package/dist/commands/role/push.d.ts +2 -0
  83. package/dist/commands/role/push.js +4 -0
  84. package/dist/commands/role/rm.d.ts +2 -0
  85. package/dist/commands/role/rm.js +3 -0
  86. package/dist/commands/role/rollback.d.ts +2 -0
  87. package/dist/commands/role/rollback.js +5 -0
  88. package/dist/commands/snippet/publish.js +1 -1
  89. package/dist/commands/snippet/pull.js +1 -1
  90. package/dist/commands/snippet/push.js +1 -1
  91. package/dist/commands/status.d.ts +3 -3
  92. package/dist/commands/status.js +21 -53
  93. package/dist/commands/taxonomy/diff.d.ts +2 -0
  94. package/dist/commands/taxonomy/diff.js +5 -0
  95. package/dist/commands/taxonomy/list.d.ts +2 -0
  96. package/dist/commands/taxonomy/list.js +5 -0
  97. package/dist/commands/taxonomy/pull.d.ts +2 -0
  98. package/dist/commands/taxonomy/pull.js +4 -0
  99. package/dist/commands/taxonomy/push.d.ts +2 -0
  100. package/dist/commands/taxonomy/push.js +4 -0
  101. package/dist/commands/taxonomy/rm.d.ts +2 -0
  102. package/dist/commands/taxonomy/rm.js +3 -0
  103. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  104. package/dist/commands/taxonomy/rollback.js +5 -0
  105. package/dist/commands/theme/pull.d.ts +6 -10
  106. package/dist/commands/theme/pull.js +30 -32
  107. package/dist/commands/theme/push.d.ts +8 -9
  108. package/dist/commands/theme/push.js +54 -111
  109. package/dist/commands/theme/style/pull.d.ts +7 -1
  110. package/dist/commands/theme/style/pull.js +3 -1
  111. package/dist/commands/theme/style/push.d.ts +8 -1
  112. package/dist/commands/theme/style/push.js +4 -1
  113. package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
  114. package/dist/commands/{template → theme/template}/diff.js +3 -3
  115. package/dist/commands/{template → theme/template}/list.d.ts +2 -2
  116. package/dist/commands/{template → theme/template}/list.js +5 -5
  117. package/dist/commands/{template → theme/template}/push.d.ts +3 -1
  118. package/dist/commands/theme/template/push.js +65 -0
  119. package/dist/commands/theme/version/pull.d.ts +23 -0
  120. package/dist/commands/theme/version/pull.js +43 -0
  121. package/dist/commands/theme/version/push.d.ts +22 -0
  122. package/dist/commands/theme/version/push.js +123 -0
  123. package/dist/config/project-config.manager.d.ts +3 -3
  124. package/dist/config/project-config.manager.js +22 -31
  125. package/dist/hooks/finally.d.ts +2 -1
  126. package/dist/hooks/finally.js +38 -7
  127. package/dist/lib/api-client.js +2 -1
  128. package/dist/lib/app-build.js +3 -1
  129. package/dist/lib/app-manifest.js +2 -2
  130. package/dist/lib/base.d.ts +2 -2
  131. package/dist/lib/base.js +33 -38
  132. package/dist/lib/declared-type-command.d.ts +35 -0
  133. package/dist/lib/declared-type-command.js +192 -0
  134. package/dist/lib/dev-watch.d.ts +1 -1
  135. package/dist/lib/dev-watch.js +7 -3
  136. package/dist/lib/diff-state.js +1 -0
  137. package/dist/lib/github-release.js +4 -1
  138. package/dist/lib/local-callback-server.d.ts +12 -6
  139. package/dist/lib/local-callback-server.js +24 -23
  140. package/dist/lib/php-files-command.js +3 -1
  141. package/dist/lib/push-command.d.ts +6 -0
  142. package/dist/lib/push-command.js +61 -18
  143. package/dist/lib/put-or-create.js +3 -4
  144. package/dist/lib/resource-state.js +40 -86
  145. package/dist/lib/rollback-materialize.js +13 -24
  146. package/dist/lib/snapshot-store.js +2 -2
  147. package/dist/lib/spinner.d.ts +2 -0
  148. package/dist/lib/spinner.js +72 -0
  149. package/dist/lib/temp-admin.d.ts +10 -1
  150. package/dist/lib/temp-admin.js +27 -2
  151. package/dist/lib/validate-local.js +31 -9
  152. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  153. package/dist/lib/wp-authorize-flow.js +24 -28
  154. package/dist/lib/wp-client.d.ts +1 -0
  155. package/dist/lib/wp-client.js +7 -1
  156. package/dist/lib/wp-site-diagnostic.js +39 -1
  157. package/dist/types/global-config.generated.d.ts +1 -5
  158. package/dist/types/project-config.generated.d.ts +28 -4
  159. package/dist/utils/composer.d.ts +3 -1
  160. package/dist/utils/composer.js +16 -7
  161. package/dist/utils/declared-type-format.d.ts +52 -0
  162. package/dist/utils/declared-type-format.js +233 -0
  163. package/dist/utils/option-format.d.ts +3 -1
  164. package/dist/utils/option-format.js +14 -3
  165. package/dist/utils/page-format.d.ts +5 -1
  166. package/dist/utils/page-format.js +7 -3
  167. package/dist/utils/plugin-sync.js +1 -1
  168. package/dist/utils/resource-dirs.d.ts +9 -3
  169. package/dist/utils/resource-dirs.js +18 -6
  170. package/dist/utils/template-format.d.ts +3 -1
  171. package/dist/utils/template-format.js +10 -3
  172. package/dist/utils/theme-css.d.ts +19 -0
  173. package/dist/utils/theme-css.js +100 -0
  174. package/oclif.manifest.json +3542 -1087
  175. package/package.json +40 -21
  176. package/schemas/global-config.schema.json +2 -6
  177. package/schemas/project-config.schema.json +37 -7
  178. package/dist/commands/seo/diff.js +0 -5
  179. package/dist/commands/seo/list.d.ts +0 -18
  180. package/dist/commands/seo/list.js +0 -58
  181. package/dist/commands/seo/pull.d.ts +0 -17
  182. package/dist/commands/seo/pull.js +0 -87
  183. package/dist/commands/seo/push.d.ts +0 -23
  184. package/dist/commands/seo/push.js +0 -176
  185. package/dist/commands/seo/rollback.js +0 -5
  186. package/dist/commands/template/push.js +0 -42
  187. package/dist/utils/seo-format.d.ts +0 -29
  188. package/dist/utils/seo-format.js +0 -18
  189. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  190. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -1,13 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
4
+ import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
4
5
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
5
6
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
6
7
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
7
8
  import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
8
- import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
9
9
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
10
- import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile } from '../utils/template-format.js';
10
+ import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
11
11
  import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
12
12
  import { loadFiles } from './load-files.js';
13
13
  import { loadSnippets } from './load-snippets.js';
@@ -30,8 +30,6 @@ function omit(object, keys) {
30
30
  const ACF_VOLATILE_KEYS = ['modified'];
31
31
  // A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
32
32
  const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
33
- // A RankMath redirect's `hits` is a live visit counter.
34
- const REDIRECT_VOLATILE_KEYS = ['hits'];
35
33
  // ---- snippets -------------------------------------------------------------------------------
36
34
  // `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
37
35
  // push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
@@ -236,7 +234,7 @@ async function childThemeState(wp, kind) {
236
234
  const templateProvider = {
237
235
  dirKind: 'template',
238
236
  async local(dir) {
239
- const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile);
237
+ const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
240
238
  if (problems.length > 0)
241
239
  throw new Error(formatPageProblems(problems));
242
240
  return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
@@ -248,7 +246,7 @@ const templateProvider = {
248
246
  const partProvider = {
249
247
  dirKind: 'part',
250
248
  async local(dir) {
251
- const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile);
249
+ const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
252
250
  if (problems.length > 0)
253
251
  throw new Error(formatPageProblems(problems));
254
252
  return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
@@ -257,82 +255,6 @@ const partProvider = {
257
255
  resource: 'part',
258
256
  title: 'Template parts',
259
257
  };
260
- // ---- SEO --------------------------------------------------------------------------------
261
- function canonicalRedirect(redirect) {
262
- return omit(redirect, REDIRECT_VOLATILE_KEYS);
263
- }
264
- const seoProvider = {
265
- dirKind: 'seo',
266
- async local(dir, onWarn) {
267
- const state = new Map();
268
- // The provider settings blob (Yoast / RankMath) is compared whole. If a real payload turns
269
- // out to carry install-specific noise (a plugin version, a license hash), add those keys
270
- // to an omit() list here, the e2e round-trip is what will surface them.
271
- const settingsPath = join(dir, 'settings.json');
272
- try {
273
- const settings = readJson(await readFile(settingsPath, 'utf8'));
274
- state.set('settings', settings);
275
- }
276
- catch (error) {
277
- if (error.code !== 'ENOENT') {
278
- onWarn(`Skipping "${settingsPath}": ${error.message}`);
279
- }
280
- }
281
- for (const postType of DEFAULT_POST_TYPES) {
282
- const metaDir = join(dir, 'post-meta', postType);
283
- const entries = await loadFiles(metaDir, {
284
- extension: '.json',
285
- onSkip: onWarn,
286
- parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
287
- });
288
- for (const { file, value } of entries) {
289
- const slug = typeof value.slug === 'string' && value.slug !== '' ? value.slug : `local:${file}`;
290
- state.set(`post-meta/${postType}/${slug}`, value);
291
- }
292
- }
293
- const redirects = await loadFiles(join(dir, 'redirects'), {
294
- extension: '.json',
295
- onSkip: onWarn,
296
- parse: readJson,
297
- });
298
- for (const redirect of redirects) {
299
- const id = Number(redirect.id);
300
- if (Number.isSafeInteger(id) && id > 0)
301
- state.set(`redirects/${id}`, canonicalRedirect(redirect));
302
- }
303
- return state;
304
- },
305
- async remote(wp, onWarn) {
306
- const state = new Map();
307
- // `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
308
- // RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
309
- // configuration, and comparing it here would flag drift whenever the mere act of reading
310
- // changed nothing meaningful.
311
- const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
312
- state.set('settings', settings);
313
- for (const postType of DEFAULT_POST_TYPES) {
314
- const posts = await wp.get(seoPostMetaEndpoint(postType));
315
- for (const { meta, slug, title } of posts)
316
- state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
317
- }
318
- // Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
319
- // as `seo pull`.
320
- try {
321
- const redirects = await wp.get(SEO_REDIRECTS_ENDPOINT);
322
- for (const redirect of redirects) {
323
- state.set(`redirects/${redirect.id}`, canonicalRedirect(redirect));
324
- }
325
- }
326
- catch (error) {
327
- if (!isNotFoundError(error))
328
- throw error;
329
- onWarn(`Skipping redirects: not supported by the active SEO plugin`);
330
- }
331
- return state;
332
- },
333
- resource: 'seo',
334
- title: 'SEO',
335
- };
336
258
  // ---- Menus --------------------------------------------------------------------------------
337
259
  // Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
338
260
  // REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
@@ -382,6 +304,33 @@ const menuProvider = {
382
304
  resource: 'menu',
383
305
  title: 'Menus',
384
306
  };
307
+ // ---- Custom post types, taxonomies, post meta, roles and patterns ---------------------------------------
308
+ // Keyed by slug (the local file name), compared on the bare arguments: `revision` is push
309
+ // bookkeeping, never part of the file.
310
+ function declaredTypeProvider(spec, title) {
311
+ return {
312
+ dirKind: spec.dirKind,
313
+ async local(dir, onWarn) {
314
+ const entries = await loadFiles(dir, {
315
+ extension: fileExtension(spec),
316
+ onSkip: onWarn,
317
+ parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
318
+ });
319
+ return new Map(entries.map(({ args, slug }) => [slug, args]));
320
+ },
321
+ async remote(wp) {
322
+ const items = await wp.get(spec.endpoint);
323
+ return new Map(items.map(({ args, slug }) => [slug, args]));
324
+ },
325
+ resource: spec.cliName,
326
+ title,
327
+ };
328
+ }
329
+ const cptProvider = declaredTypeProvider(CPT_SPEC, 'Post types');
330
+ const taxonomyProvider = declaredTypeProvider(TAXONOMY_SPEC, 'Taxonomies');
331
+ const metaProvider = declaredTypeProvider(META_SPEC, 'Post meta');
332
+ const roleProvider = declaredTypeProvider(ROLE_SPEC, 'Roles');
333
+ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
385
334
  // ---- Options ------------------------------------------------------------------------------
386
335
  // Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
387
336
  // lists names, never values (by design, see OptionsController), so both sides compare exactly
@@ -408,12 +357,13 @@ const optionsProvider = {
408
357
  const tracked = await loadFiles(dir, {
409
358
  extension: '.json',
410
359
  onSkip: onWarn,
411
- parse: (raw) => parseLocalOption(raw).name,
360
+ parse: parseLocalOption,
412
361
  });
413
362
  const state = new Map();
414
- for (const name of tracked) {
363
+ for (const { name, refs } of tracked) {
415
364
  try {
416
- const option = await wp.get(optionEndpoint(name));
365
+ // With the file's refs, so post references compare as paths on both sides.
366
+ const option = await wp.get(optionEndpoint(name, refs));
417
367
  state.set(name, { autoload: option.autoload, value: option.value });
418
368
  }
419
369
  catch (error) {
@@ -469,8 +419,12 @@ export const RESOURCE_STATE_PROVIDERS = [
469
419
  templateProvider,
470
420
  partProvider,
471
421
  pageProvider,
472
- seoProvider,
473
422
  menuProvider,
423
+ cptProvider,
424
+ taxonomyProvider,
425
+ metaProvider,
426
+ roleProvider,
427
+ patternProvider,
474
428
  optionsProvider,
475
429
  themeStylesProvider,
476
430
  ];
@@ -1,10 +1,10 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { getAcfKey } from '../utils/acf-format.js';
4
+ import { CPT_SPEC, fileExtension, formatDeclaredFile, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
4
5
  import { getFormId, getFormTitle } from '../utils/form-format.js';
5
6
  import { getMenuSlug } from '../utils/menu-format.js';
6
7
  import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
7
- import { redirectFileBase } from '../utils/seo-format.js';
8
8
  import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
9
9
  import { themeStylesFileName } from '../utils/theme-styles-format.js';
10
10
  import { toSlug } from '../utils/to-slug.js';
@@ -51,28 +51,6 @@ async function phpFile(id, value, dir) {
51
51
  await mkdir(dirname(filePath), { recursive: true });
52
52
  await writeFile(filePath, String(value));
53
53
  }
54
- // ids are 'settings', `post-meta/<postType>/<slug>`, or `redirects/<id>` (see resource-state.ts's
55
- // seoProvider). Each value round-trips to the exact file `seo pull` would have written.
56
- async function seo(id, value, dir) {
57
- if (id === 'settings') {
58
- await writeJson(join(dir, 'settings.json'), value);
59
- return;
60
- }
61
- if (id.startsWith('post-meta/')) {
62
- const rest = id.slice('post-meta/'.length);
63
- const separator = rest.indexOf('/');
64
- const postType = rest.slice(0, separator);
65
- const slug = rest.slice(separator + 1);
66
- await writeJson(join(dir, 'post-meta', postType, `${slug}.json`), value);
67
- return;
68
- }
69
- if (id.startsWith('redirects/')) {
70
- const redirect = value;
71
- await writeJson(join(dir, 'redirects', `${redirectFileBase(redirect)}.json`), redirect);
72
- return;
73
- }
74
- throw new Error(`Unrecognized SEO snapshot id "${id}"`);
75
- }
76
54
  // ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
77
55
  async function menu(id, value, dir) {
78
56
  if (id === 'menu-locations') {
@@ -97,15 +75,26 @@ async function option(id, value, dir) {
97
75
  async function themeStyles(id, value, dir) {
98
76
  await writeJson(join(dir, themeStylesFileName(id)), value);
99
77
  }
78
+ // id is the slug, value the bare register_*() arguments (resource-state.ts's declaredTypeProvider),
79
+ // written in the spec's file format like `<resource> pull` does.
80
+ function declaredType(spec) {
81
+ return async (id, value, dir) => {
82
+ await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, value));
83
+ };
84
+ }
100
85
  const MATERIALIZERS = {
101
86
  acf,
102
87
  api: phpFile,
88
+ cpt: declaredType(CPT_SPEC),
103
89
  form,
104
90
  hook: phpFile,
105
91
  menu,
92
+ meta: declaredType(META_SPEC),
106
93
  option,
107
- seo,
94
+ pattern: declaredType(PATTERN_SPEC),
95
+ role: declaredType(ROLE_SPEC),
108
96
  snippet,
97
+ taxonomy: declaredType(TAXONOMY_SPEC),
109
98
  'theme-styles': themeStyles,
110
99
  };
111
100
  // Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
@@ -7,7 +7,7 @@ export const SNAPSHOT_RETENTION = 10;
7
7
  // representation; snapshots round-trip through a plain object instead. Keys are sorted so two
8
8
  // snapshots of the same state serialize identically (stable diffs, deterministic tests).
9
9
  export function stateToRecord(state) {
10
- return Object.fromEntries([...state].sort(([a], [b]) => a.localeCompare(b)));
10
+ return Object.fromEntries([...state].toSorted(([a], [b]) => a.localeCompare(b)));
11
11
  }
12
12
  export function recordToState(record) {
13
13
  return new Map(Object.entries(record));
@@ -113,7 +113,7 @@ export async function listSnapshots(rootDir, resource, environment) {
113
113
  // rather than failing the whole listing; it stays on disk for a human to inspect.
114
114
  }
115
115
  }
116
- return summaries.sort((a, b) => Number(b.id) - Number(a.id));
116
+ return summaries.toSorted((a, b) => Number(b.id) - Number(a.id));
117
117
  }
118
118
  // `id` undefined reads the most recent snapshot for this environment (the common
119
119
  // `lps <resource> rollback` case). Throws a message ready to show the user directly: no
@@ -0,0 +1,2 @@
1
+ export declare function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T>;
2
+ export declare function withoutSpinner<T>(work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,72 @@
1
+ // The one spinner behind every pending wait: WpClient and ApiClient requests, the browser
2
+ // callback, a build, a third-party lookup. Refcounted so overlapping waits share one line (the
3
+ // outermost label wins), on stderr and only on an interactive one, so `--json`, pipes and CI
4
+ // output never see a frame. While it spins, every stdout/stderr write clears the line first and
5
+ // redraws it after, so a this.log() mid-wait lands above it instead of on it.
6
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ let depth = 0;
8
+ let paused = 0;
9
+ let label = '';
10
+ let frame = 0;
11
+ let stopSpinning;
12
+ function isEnabled() {
13
+ return process.stderr.isTTY && !process.env.CI;
14
+ }
15
+ function start() {
16
+ const rawOut = process.stdout.write;
17
+ const rawErr = process.stderr.write;
18
+ const draw = () => {
19
+ const text = `${FRAMES[frame]} ${label}`.slice(0, (process.stderr.columns ?? 80) - 1);
20
+ rawErr.call(process.stderr, `\r\u{1B}[2K${text}`);
21
+ };
22
+ const clear = () => rawErr.call(process.stderr, '\r\u{1B}[2K');
23
+ const intercept = (stream, raw) => (...args) => {
24
+ clear();
25
+ const result = raw.apply(stream, args);
26
+ draw();
27
+ return result;
28
+ };
29
+ process.stdout.write = intercept(process.stdout, rawOut);
30
+ process.stderr.write = intercept(process.stderr, rawErr);
31
+ draw();
32
+ const timer = setInterval(() => {
33
+ frame = (frame + 1) % FRAMES.length;
34
+ draw();
35
+ }, 80);
36
+ timer.unref();
37
+ stopSpinning = () => {
38
+ clearInterval(timer);
39
+ process.stdout.write = rawOut;
40
+ process.stderr.write = rawErr;
41
+ clear();
42
+ stopSpinning = undefined;
43
+ };
44
+ }
45
+ export async function withSpinner(message, work) {
46
+ if (!isEnabled())
47
+ return work();
48
+ if (depth++ === 0)
49
+ label = message;
50
+ if (paused === 0 && !stopSpinning)
51
+ start();
52
+ try {
53
+ return await work();
54
+ }
55
+ finally {
56
+ if (--depth === 0)
57
+ stopSpinning?.();
58
+ }
59
+ }
60
+ // For code that draws its own progress (Listr renderers, which already spin per task) or reads
61
+ // from the terminal (prompts): the spinner steps aside, then comes back if a wait is still pending.
62
+ export async function withoutSpinner(work) {
63
+ paused++;
64
+ stopSpinning?.();
65
+ try {
66
+ return await work();
67
+ }
68
+ finally {
69
+ if (--paused === 0 && depth > 0 && isEnabled())
70
+ start();
71
+ }
72
+ }
@@ -16,4 +16,13 @@ export declare function createTempAdmin(wp: WpClient): Promise<TempAdmin>;
16
16
  * cleanup that silently fails would leave a live administrator with a random password on the
17
17
  * target site, discoverable only by someone reading logs closely.
18
18
  */
19
- export declare function deleteTempAdmin(wp: WpClient, admin: TempAdmin): Promise<void>;
19
+ export declare function deleteTempAdmin(wp: WpClient, admin: Pick<TempAdmin, 'id' | 'username'>): Promise<void>;
20
+ /**
21
+ * Lists temporary admins a previous bootstrap left behind: a process killed with SIGKILL, a
22
+ * machine that lost power, or a network drop during deleteTempAdmin() skips every cleanup path.
23
+ * Matching both the username prefix and the `.invalid` email keeps a real user who happens to
24
+ * be named `lps-temp-...` out of it. `context=edit` exposes `username` and `email` and lists
25
+ * every user, not only those with published posts.
26
+ */
27
+ export declare function findTempAdmins(wp: WpClient): Promise<Array<Pick<TempAdmin, 'id' | 'username'>>>;
28
+ export declare function sweepTempAdmins(wp: WpClient): Promise<string[]>;
@@ -1,5 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { isNotFoundError } from './wp-client.js';
3
+ const TEMP_ADMIN_PREFIX = 'lps-temp-';
4
+ const TEMP_ADMIN_EMAIL_DOMAIN = '@lps-temp.invalid';
3
5
  // Only role that carries `install_plugins`/`activate_plugins`; there's nothing narrower to grant.
4
6
  const TEMP_ADMIN_ROLE = 'administrator';
5
7
  /**
@@ -8,10 +10,10 @@ const TEMP_ADMIN_ROLE = 'administrator';
8
10
  * addresses guaranteed not to be real, appropriate for an account that has no real mailbox.
9
11
  */
10
12
  export async function createTempAdmin(wp) {
11
- const username = `lps-temp-${Date.now().toString(36)}`;
13
+ const username = `${TEMP_ADMIN_PREFIX}${Date.now().toString(36)}`;
12
14
  const password = randomBytes(24).toString('base64url');
13
15
  const user = await wp.post('wp/v2/users', {
14
- email: `${username}@lps-temp.invalid`,
16
+ email: `${username}${TEMP_ADMIN_EMAIL_DOMAIN}`,
15
17
  password,
16
18
  roles: [TEMP_ADMIN_ROLE],
17
19
  username,
@@ -44,3 +46,26 @@ export async function deleteTempAdmin(wp, admin) {
44
46
  throw new Error(`Temporary admin account "${admin.username}" (id ${admin.id}) still exists after deletion. Remove it manually from wp-admin.`);
45
47
  }
46
48
  }
49
+ /**
50
+ * Lists temporary admins a previous bootstrap left behind: a process killed with SIGKILL, a
51
+ * machine that lost power, or a network drop during deleteTempAdmin() skips every cleanup path.
52
+ * Matching both the username prefix and the `.invalid` email keeps a real user who happens to
53
+ * be named `lps-temp-...` out of it. `context=edit` exposes `username` and `email` and lists
54
+ * every user, not only those with published posts.
55
+ */
56
+ export async function findTempAdmins(wp) {
57
+ const users = await wp.getAll(`wp/v2/users?context=edit&search=${TEMP_ADMIN_PREFIX}`);
58
+ return users
59
+ .filter((user) => user.username.startsWith(TEMP_ADMIN_PREFIX) && user.email.endsWith(TEMP_ADMIN_EMAIL_DOMAIN))
60
+ .map(({ id, username }) => ({ id, username }));
61
+ }
62
+ // Deletes every leftover temp admin, returning the usernames removed. Throws on the first one
63
+ // that can't be removed, naming it (deleteTempAdmin's own error).
64
+ export async function sweepTempAdmins(wp) {
65
+ const leftovers = await findTempAdmins(wp);
66
+ for (const admin of leftovers) {
67
+ // Sequential on purpose: the first failure stops the sweep, naming its account.
68
+ await deleteTempAdmin(wp, admin);
69
+ }
70
+ return leftovers.map((admin) => admin.username);
71
+ }
@@ -1,27 +1,33 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
+ import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
4
5
  import { parseLocalOption } from '../utils/option-format.js';
5
- import { readLocalPages } from '../utils/page-format.js';
6
+ import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
7
+ import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
6
8
  import { parseType } from '../utils/snippet-format.js';
7
9
  import { readLocalTemplates } from '../utils/template-format.js';
8
10
  import { loadSnippets } from './load-snippets.js';
9
11
  import { readdirTolerant } from './readdir-tolerant.js';
10
12
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
11
13
  // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
12
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir', 'themeStylesDir'];
14
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStylesDir'];
13
15
  const DEFAULT_DIR = {
14
16
  acfDir: 'acf',
15
17
  apiDir: 'api',
18
+ cptDir: 'cpt',
16
19
  formDir: 'forms',
17
20
  hooksDir: 'hooks',
18
21
  menuDir: 'menus',
22
+ metaDir: 'meta',
19
23
  optionsDir: 'options',
20
24
  pageDir: 'pages',
21
- partDir: 'parts',
22
- seoDir: 'seo',
25
+ partDir: RESOURCE_DIR_DEFAULTS.part,
26
+ patternDir: RESOURCE_DIR_DEFAULTS.pattern,
27
+ roleDir: RESOURCE_DIR_DEFAULTS.role,
23
28
  snippetsDir: 'snippets',
24
- templateDir: 'templates',
29
+ taxonomyDir: 'taxonomies',
30
+ templateDir: RESOURCE_DIR_DEFAULTS.template,
25
31
  themeStylesDir: 'theme',
26
32
  };
27
33
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
@@ -46,6 +52,11 @@ export async function validateLocal(cwd) {
46
52
  // Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
47
53
  // there with a warning, so it's a problem here.
48
54
  checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
55
+ checked += await checkJsonDir(resolve('cptDir'), problems, declaredFileChecker(CPT_SPEC));
56
+ checked += await checkJsonDir(resolve('taxonomyDir'), problems, declaredFileChecker(TAXONOMY_SPEC));
57
+ checked += await checkJsonDir(resolve('metaDir'), problems, declaredFileChecker(META_SPEC));
58
+ checked += await checkJsonDir(resolve('roleDir'), problems, declaredFileChecker(ROLE_SPEC));
59
+ checked += await checkPatterns(resolve('patternDir'), problems);
49
60
  checked += await checkSnippets(resolve('snippetsDir'), problems);
50
61
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
51
62
  checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
@@ -57,6 +68,18 @@ export async function validateLocal(cwd) {
57
68
  problems.push(...templates.problems);
58
69
  return { checked, problems, valid: problems.length === 0 };
59
70
  }
71
+ // Same directory rules as pages (flat, .html only), then the same checks as `pattern push`.
72
+ async function checkPatterns(dir, problems) {
73
+ const check = declaredFileChecker(PATTERN_SPEC);
74
+ const { files, problems: found } = await readLocalHtmlFiles(dir, (slug, raw) => {
75
+ check(raw, join(dir, `${slug}.html`));
76
+ }, {
77
+ message: `the file name must be 1 to ${PATTERN_SPEC.slugMaxLength} lowercase letters, digits, "_" or "-"`,
78
+ pattern: new RegExp(`^[a-z0-9_-]{1,${PATTERN_SPEC.slugMaxLength}}$`),
79
+ });
80
+ problems.push(...found);
81
+ return files.length + found.length;
82
+ }
60
83
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
61
84
  // (invoice-pdf/[order_id].php, content/filters.php), a non-recursive scan would silently
62
85
  // never check those.
@@ -103,9 +126,8 @@ async function checkComposerJson(cwd, problems) {
103
126
  }
104
127
  return 1;
105
128
  }
106
- // Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json) and SEO its
107
- // post meta and redirects (seo/post-meta/<type>/*.json, seo/redirects/*.json); a top-level-only
108
- // scan silently never checked any of those. `shape` runs on files that are JSON objects and
129
+ // Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json); a
130
+ // top-level-only scan silently never checked those. `shape` runs on files that are JSON objects and
109
131
  // throws to report a resource-specific problem.
110
132
  async function checkJsonDir(dir, problems, shape) {
111
133
  const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
@@ -130,7 +152,7 @@ async function checkJsonDir(dir, problems, shape) {
130
152
  continue;
131
153
  }
132
154
  try {
133
- shape?.(raw);
155
+ shape?.(raw, filePath);
134
156
  }
135
157
  catch (error) {
136
158
  problems.push({ file: filePath, message: error.message });
@@ -3,24 +3,17 @@ export type AuthorizeResult = {
3
3
  userLogin: string;
4
4
  };
5
5
  /**
6
- * Relays the authorization through the Loopress API so that WordPress's
7
- * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
8
- * redirect from WordPress and forwards the credentials back to a local callback
9
- * server via a form POST (keeping them out of the browser's address bar).
6
+ * Asks WordPress for an Application Password through its own authorize-application.php page,
7
+ * with the CLI's loopback server as `success_url` / `reject_url`:
10
8
  *
11
- * Flow:
12
- * 1. Start a local HTTP server on `127.0.0.1`.
13
- * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
14
- * callback URL and the target WordPress site as query params.
15
- * 3. That page redirects the user to WordPress's authorize-application.php,
16
- * passing the API's callback endpoint as `success_url` (HTTPS).
17
- * 4. After the user approves, WordPress redirects to the API callback.
18
- * 5. The API callback returns an HTML page that POSTs a form with the
19
- * Application Password to the local callback server.
20
- * 6. The local server extracts the credentials and resolves the promise.
9
+ * 1. Start a local HTTP server on 127.0.0.1.
10
+ * 2. Open the browser on `<site>/wp-admin/authorize-application.php`.
11
+ * 3. After the user approves, WordPress redirects to the loopback URL with `user_login` and
12
+ * `password` appended to its query string (`state` is kept, WordPress only adds params).
21
13
  *
22
- * The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
23
- * form's POST target), so it comes back as a query param on the credential POST and
24
- * `waitForLocalCallback` can match it before this handler runs.
14
+ * The callback host must be the literal `127.0.0.1`, not `localhost`: WordPress 7.0+ only exempts
15
+ * `127.0.0.1` and `[::1]` from its HTTPS requirement on these URLs. Older versions are screened
16
+ * out beforehand by `diagnoseWpSite`. The credentials land in the browser history as part of the
17
+ * callback URL, so the result page rewrites that history entry (see `renderResultPage`).
25
18
  */
26
19
  export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
@@ -1,44 +1,40 @@
1
1
  import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
2
2
  /**
3
- * Relays the authorization through the Loopress API so that WordPress's
4
- * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
5
- * redirect from WordPress and forwards the credentials back to a local callback
6
- * server via a form POST (keeping them out of the browser's address bar).
3
+ * Asks WordPress for an Application Password through its own authorize-application.php page,
4
+ * with the CLI's loopback server as `success_url` / `reject_url`:
7
5
  *
8
- * Flow:
9
- * 1. Start a local HTTP server on `127.0.0.1`.
10
- * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
11
- * callback URL and the target WordPress site as query params.
12
- * 3. That page redirects the user to WordPress's authorize-application.php,
13
- * passing the API's callback endpoint as `success_url` (HTTPS).
14
- * 4. After the user approves, WordPress redirects to the API callback.
15
- * 5. The API callback returns an HTML page that POSTs a form with the
16
- * Application Password to the local callback server.
17
- * 6. The local server extracts the credentials and resolves the promise.
6
+ * 1. Start a local HTTP server on 127.0.0.1.
7
+ * 2. Open the browser on `<site>/wp-admin/authorize-application.php`.
8
+ * 3. After the user approves, WordPress redirects to the loopback URL with `user_login` and
9
+ * `password` appended to its query string (`state` is kept, WordPress only adds params).
18
10
  *
19
- * The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
20
- * form's POST target), so it comes back as a query param on the credential POST and
21
- * `waitForLocalCallback` can match it before this handler runs.
11
+ * The callback host must be the literal `127.0.0.1`, not `localhost`: WordPress 7.0+ only exempts
12
+ * `127.0.0.1` and `[::1]` from its HTTPS requirement on these URLs. Older versions are screened
13
+ * out beforehand by `diagnoseWpSite`. The credentials land in the browser history as part of the
14
+ * callback URL, so the result page rewrites that history entry (see `renderResultPage`).
22
15
  */
23
16
  export async function authorizeWithBrowser(siteUrl, log) {
24
17
  return waitForLocalCallback({
25
- allowedOrigins: ['https://api.loopress.dev'],
18
+ // WordPress reaches the callback through a top-level GET redirect, which carries no Origin,
19
+ // so any request that does carry one isn't WordPress.
20
+ allowedOrigins: [],
26
21
  buildUrl(callbackBaseUrl, state) {
27
- const relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
22
+ const callbackUrl = `${callbackBaseUrl}/?state=${state}`;
28
23
  const params = new URLSearchParams({
29
- callbackUrl: `${callbackBaseUrl}?state=${state}`,
30
- wpUrl: siteUrl,
24
+ app_name: 'Loopress',
25
+ reject_url: `${callbackUrl}&cancelled=1`,
26
+ success_url: callbackUrl,
31
27
  });
32
- return `${relayUrl}?${params}`;
28
+ return `${siteUrl}/wp-admin/authorize-application.php?${params}`;
33
29
  },
34
- handleRequest(url, { resolveWithPage, rejectWithPage, respondBadRequest, body }) {
35
- if (url.searchParams.has('cancelled') || body.cancelled) {
30
+ callbackHost: '127.0.0.1',
31
+ handleRequest(url, { resolveWithPage, rejectWithPage, respondBadRequest }) {
32
+ if (url.searchParams.has('cancelled')) {
36
33
  rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
37
34
  return;
38
35
  }
39
- // Credentials come from the relay's form POST only. Reading them from the query string
40
- // would let them land in shell history, proxy logs and the browser address bar (F24).
41
- const { password, user_login: userLogin } = body;
36
+ const password = url.searchParams.get('password');
37
+ const userLogin = url.searchParams.get('user_login');
42
38
  if (!password || !userLogin) {
43
39
  respondBadRequest('Missing password or user_login');
44
40
  return;
@@ -46,7 +42,7 @@ export async function authorizeWithBrowser(siteUrl, log) {
46
42
  resolveWithPage(SUCCESS_PAGE, { password, userLogin });
47
43
  },
48
44
  log,
49
- openingMessage: 'Opening WordPress in your browser to authorize Loopress...',
45
+ openingMessage: 'Opening WordPress in your browser to authorize Loopress...\nIf WordPress says the URL must be served over a secure connection (WordPress older than 7.0), press Ctrl-C and run `lps project config` again, choosing manual entry.',
50
46
  timeoutMessage: 'Authorization timed out after 5 minutes.',
51
47
  });
52
48
  }
@@ -23,6 +23,7 @@ type RequestOptions = {
23
23
  timeoutMs?: number;
24
24
  };
25
25
  export declare function isNotFoundError(error: unknown): boolean;
26
+ export declare function isForbiddenError(error: unknown): boolean;
26
27
  export declare function isApplicative404(error: unknown, marker: string): boolean;
27
28
  export declare function isTimeoutError(error: unknown): boolean;
28
29
  export declare function isInvalidPageNumber(error: unknown): boolean;