@loopress/cli 0.24.1 → 0.26.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 (106) hide show
  1. package/README.md +721 -90
  2. package/dist/commands/acf/push.d.ts +1 -0
  3. package/dist/commands/acf/push.js +25 -2
  4. package/dist/commands/acf/rollback.d.ts +2 -0
  5. package/dist/commands/acf/rollback.js +5 -0
  6. package/dist/commands/api/rollback.d.ts +2 -0
  7. package/dist/commands/api/rollback.js +5 -0
  8. package/dist/commands/diff.js +1 -1
  9. package/dist/commands/doctor.d.ts +17 -1
  10. package/dist/commands/doctor.js +55 -38
  11. package/dist/commands/form/pull.js +4 -1
  12. package/dist/commands/form/push.d.ts +1 -0
  13. package/dist/commands/form/push.js +29 -3
  14. package/dist/commands/form/rollback.d.ts +2 -0
  15. package/dist/commands/form/rollback.js +5 -0
  16. package/dist/commands/hook/rollback.d.ts +2 -0
  17. package/dist/commands/hook/rollback.js +5 -0
  18. package/dist/commands/init.js +2 -0
  19. package/dist/commands/menu/diff.d.ts +2 -0
  20. package/dist/commands/menu/diff.js +5 -0
  21. package/dist/commands/menu/list.d.ts +13 -0
  22. package/dist/commands/menu/list.js +32 -0
  23. package/dist/commands/menu/pull.d.ts +15 -0
  24. package/dist/commands/menu/pull.js +72 -0
  25. package/dist/commands/menu/push.d.ts +17 -0
  26. package/dist/commands/menu/push.js +120 -0
  27. package/dist/commands/menu/rollback.d.ts +2 -0
  28. package/dist/commands/menu/rollback.js +5 -0
  29. package/dist/commands/option/add.js +1 -1
  30. package/dist/commands/option/push.d.ts +1 -0
  31. package/dist/commands/option/push.js +26 -1
  32. package/dist/commands/option/rollback.d.ts +2 -0
  33. package/dist/commands/option/rollback.js +5 -0
  34. package/dist/commands/page/diff.d.ts +2 -0
  35. package/dist/commands/page/diff.js +5 -0
  36. package/dist/commands/page/list.d.ts +8 -0
  37. package/dist/commands/page/list.js +22 -0
  38. package/dist/commands/page/push.d.ts +20 -0
  39. package/dist/commands/page/push.js +60 -0
  40. package/dist/commands/plugin/push.d.ts +1 -0
  41. package/dist/commands/plugin/push.js +14 -0
  42. package/dist/commands/promote.d.ts +8 -1
  43. package/dist/commands/promote.js +2 -0
  44. package/dist/commands/pull.d.ts +11 -1
  45. package/dist/commands/pull.js +9 -2
  46. package/dist/commands/push.d.ts +11 -1
  47. package/dist/commands/push.js +10 -2
  48. package/dist/commands/seo/pull.js +6 -2
  49. package/dist/commands/seo/push.d.ts +1 -0
  50. package/dist/commands/seo/push.js +33 -3
  51. package/dist/commands/seo/rollback.d.ts +2 -0
  52. package/dist/commands/seo/rollback.js +5 -0
  53. package/dist/commands/snippet/push.d.ts +1 -0
  54. package/dist/commands/snippet/push.js +31 -1
  55. package/dist/commands/snippet/rollback.d.ts +2 -0
  56. package/dist/commands/snippet/rollback.js +5 -0
  57. package/dist/commands/theme-styles/diff.d.ts +2 -0
  58. package/dist/commands/theme-styles/diff.js +5 -0
  59. package/dist/commands/theme-styles/pull.d.ts +12 -0
  60. package/dist/commands/theme-styles/pull.js +31 -0
  61. package/dist/commands/theme-styles/push.d.ts +12 -0
  62. package/dist/commands/theme-styles/push.js +45 -0
  63. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  64. package/dist/commands/theme-styles/rollback.js +5 -0
  65. package/dist/lib/app-manifest.js +4 -27
  66. package/dist/lib/base.d.ts +2 -0
  67. package/dist/lib/base.js +6 -0
  68. package/dist/lib/dev-watch.d.ts +1 -1
  69. package/dist/lib/dev-watch.js +2 -1
  70. package/dist/lib/find-orphaned-files.js +5 -47
  71. package/dist/lib/load-files.js +4 -42
  72. package/dist/lib/php-files-command.d.ts +1 -0
  73. package/dist/lib/php-files-command.js +84 -47
  74. package/dist/lib/push-command.d.ts +4 -0
  75. package/dist/lib/push-command.js +49 -0
  76. package/dist/lib/readdir-tolerant.d.ts +1 -0
  77. package/dist/lib/readdir-tolerant.js +12 -0
  78. package/dist/lib/resource-state.js +116 -3
  79. package/dist/lib/rollback-command.d.ts +21 -0
  80. package/dist/lib/rollback-command.js +180 -0
  81. package/dist/lib/rollback-materialize.d.ts +1 -0
  82. package/dist/lib/rollback-materialize.js +121 -0
  83. package/dist/lib/snapshot-store.d.ts +30 -0
  84. package/dist/lib/snapshot-store.js +133 -0
  85. package/dist/lib/validate-local.js +7 -1
  86. package/dist/types/project-config.generated.d.ts +12 -0
  87. package/dist/utils/acf-format.d.ts +1 -0
  88. package/dist/utils/acf-format.js +6 -0
  89. package/dist/utils/form-format.d.ts +4 -0
  90. package/dist/utils/form-format.js +3 -7
  91. package/dist/utils/menu-format.d.ts +24 -0
  92. package/dist/utils/menu-format.js +8 -0
  93. package/dist/utils/option-format.d.ts +1 -0
  94. package/dist/utils/page-format.d.ts +27 -0
  95. package/dist/utils/page-format.js +111 -0
  96. package/dist/utils/resource-dirs.d.ts +3 -0
  97. package/dist/utils/resource-dirs.js +6 -0
  98. package/dist/utils/seo-format.d.ts +8 -0
  99. package/dist/utils/seo-format.js +3 -0
  100. package/dist/utils/snippet-format.d.ts +1 -0
  101. package/dist/utils/snippet-format.js +1 -0
  102. package/dist/utils/theme-styles-format.d.ts +26 -0
  103. package/dist/utils/theme-styles-format.js +55 -0
  104. package/oclif.manifest.json +1633 -168
  105. package/package.json +4 -2
  106. package/schemas/project-config.schema.json +15 -0
@@ -11,6 +11,7 @@ export default class Push extends PushCommand {
11
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
12
  };
13
13
  run(): Promise<void>;
14
+ private currentRevision;
14
15
  private loadObjects;
15
16
  private pushObject;
16
17
  private pushType;
@@ -2,7 +2,9 @@ import { Args, Flags } from '@oclif/core';
2
2
  import { join } from 'node:path';
3
3
  import { loadFiles } from '../../lib/load-files.js';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
- import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
5
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
6
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
+ import { ACF_OBJECT_TYPES, acfEndpoint, acfObjectEndpoint, getAcfKey } from '../../utils/acf-format.js';
6
8
  import { pluralize } from '../../utils/pluralize.js';
7
9
  export default class Push extends PushCommand {
8
10
  static args = {
@@ -22,9 +24,12 @@ export default class Push extends PushCommand {
22
24
  const types = (flags.type && flags.type.length > 0 ? flags.type : ACF_OBJECT_TYPES);
23
25
  this.log(`Pushing ACF configuration to ${url}`);
24
26
  this.log(`ACF path: ${path}`);
27
+ const provider = getResourceStateProvider('acf');
28
+ const beforeState = await this.captureBeforePushState(provider, path);
25
29
  for (const type of types) {
26
30
  await this.pushType(type, path);
27
31
  }
32
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
28
33
  if (this.failedCount > 0) {
29
34
  this.error(`${pluralize(this.failedCount, 'ACF object')} failed to push.`);
30
35
  }
@@ -33,6 +38,17 @@ export default class Push extends PushCommand {
33
38
  await this.recordSuccess();
34
39
  this.log('All ACF objects pushed.');
35
40
  }
41
+ async currentRevision(type, key) {
42
+ try {
43
+ const current = await this.wp.get(acfObjectEndpoint(type, key));
44
+ return current.revision;
45
+ }
46
+ catch (error) {
47
+ if (isNotFoundError(error))
48
+ return undefined;
49
+ throw error;
50
+ }
51
+ }
36
52
  async loadObjects(dir) {
37
53
  return loadFiles(dir, {
38
54
  extension: '.json',
@@ -57,7 +73,14 @@ export default class Push extends PushCommand {
57
73
  return;
58
74
  }
59
75
  try {
60
- await this.wp.post(acfEndpoint(type), object);
76
+ // Read the object's current revision right before writing it, and send it back as
77
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
78
+ // object in between, instead of this push silently overwriting it (#234). No revision to
79
+ // condition on for an object that doesn't exist remotely yet (a first push, an upsert
80
+ // create): falls back to today's unconditional write, same as before this existed.
81
+ const expectedRevision = await this.currentRevision(type, key);
82
+ const body = expectedRevision === undefined ? object : { ...object, expectedRevision };
83
+ await this.wp.post(acfEndpoint(type), body);
61
84
  if (task)
62
85
  task.output = `Pushed: ${key}`;
63
86
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('acf', {
3
+ description: 'Restore ACF field groups, post types, taxonomies, and options pages to a snapshot taken automatically before an earlier `lps acf push`',
4
+ pathNoun: 'ACF directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('api', {
3
+ description: 'Restore API route files to a snapshot taken automatically before an earlier `lps api push`',
4
+ pathNoun: 'api directory',
5
+ });
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
7
7
  export default class Diff extends DiffCommand {
8
8
  static description = [
9
9
  'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
10
- 'Covers snippets, forms, ACF, API routes, Hooks, SEO, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
10
+ 'Covers snippets, forms, ACF, API routes, Hooks, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
11
11
  'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
12
12
  ].join(' ');
13
13
  static enableJsonFlag = true;
@@ -1,8 +1,24 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ export type DoctorCheck = {
3
+ message?: string;
4
+ ok: boolean;
5
+ title: string;
6
+ };
7
+ export type DoctorResult = {
8
+ checks: DoctorCheck[];
9
+ environment: string;
10
+ ok: boolean;
11
+ pluginVersion?: string;
12
+ project: string;
13
+ url: string;
14
+ };
2
15
  export default class Doctor extends LoopressCommand {
3
16
  static description: string;
17
+ static enableJsonFlag: boolean;
4
18
  static examples: string[];
5
- run(): Promise<void>;
19
+ private readonly checks;
20
+ run(): Promise<DoctorResult>;
6
21
  private check;
22
+ private out;
7
23
  private reportPluginVersion;
8
24
  }
@@ -5,76 +5,93 @@ import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
5
5
  import { pluralize } from '../utils/pluralize.js';
6
6
  export default class Doctor extends LoopressCommand {
7
7
  static description = 'Diagnose connectivity, plugin and credential problems for the targeted environment';
8
+ static enableJsonFlag = true;
8
9
  static examples = ['$ lps doctor', '$ lps doctor --env production'];
10
+ checks = [];
9
11
  async run() {
10
12
  const { name, token, url } = this.siteConfig;
11
13
  const project = this.localConfig.projectId
12
14
  ? configManager.getProject(this.localConfig.projectId)
13
15
  : configManager.getCurrentProject();
14
- this.log(`Project: ${project?.name ?? '(unknown)'}`);
15
- this.log(`Environment: ${name}`);
16
- this.log(`URL: ${url}`);
17
- this.log('');
18
- let failed = 0;
16
+ const projectName = project?.name ?? '(unknown)';
17
+ this.out(`Project: ${projectName}`);
18
+ this.out(`Environment: ${name}`);
19
+ this.out(`URL: ${url}`);
20
+ this.out('');
21
+ let pluginVersion;
19
22
  const isReachable = await this.check('WordPress REST API reachable', async () => {
20
23
  const diagnostic = await diagnoseWpSite(url);
21
24
  if (!diagnostic.ok)
22
25
  throw new Error(diagnostic.reason);
23
26
  });
24
- if (!isReachable) {
25
- failed++;
26
- this.log('- Remaining checks skipped while the site is unreachable.');
27
- this.error('1 check failed.', { exit: 1 });
28
- }
29
- if (!token) {
30
- this.log('✗ Credentials configured');
31
- this.log(` No credentials stored for ${url}. Run \`lps project config\` to add them.`);
32
- this.log('- Remaining checks skipped without credentials.');
33
- this.error('1 check failed.', { exit: 1 });
34
- }
35
- // The namespace index answers regardless of which Loopress features are active, so a 404
36
- // here can only mean the plugin itself is missing or outdated (WpClient's 404 message
37
- // already says exactly that).
38
- if (!(await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1')))) {
39
- failed++;
40
- }
41
- // wp/v2/users/me is WordPress core and requires authentication, so it validates the
42
- // application password without depending on any Loopress feature.
43
- if (!(await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me')))) {
44
- failed++;
27
+ if (isReachable) {
28
+ if (token) {
29
+ // The namespace index answers regardless of which Loopress features are active, so a
30
+ // 404 here can only mean the plugin itself is missing or outdated (WpClient's 404
31
+ // message already says exactly that).
32
+ await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
33
+ // wp/v2/users/me is WordPress core and requires authentication, so it validates the
34
+ // application password without depending on any Loopress feature.
35
+ await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
36
+ pluginVersion = await this.reportPluginVersion();
37
+ }
38
+ else {
39
+ this.checks.push({
40
+ message: `No credentials stored for ${url}. Run \`lps project config\` to add them.`,
41
+ ok: false,
42
+ title: 'Credentials configured',
43
+ });
44
+ this.out('✗ Credentials configured');
45
+ this.out(` No credentials stored for ${url}. Run \`lps project config\` to add them.`);
46
+ this.out('- Remaining checks skipped without credentials.');
47
+ }
45
48
  }
46
- await this.reportPluginVersion();
47
- this.log('');
48
- if (failed > 0) {
49
- this.error(`${pluralize(failed, 'check')} failed.`, { exit: 1 });
49
+ else {
50
+ this.out('- Remaining checks skipped while the site is unreachable.');
50
51
  }
51
- this.log('All checks passed.');
52
+ const failed = this.checks.filter((check) => !check.ok).length;
53
+ const ok = failed === 0;
54
+ this.out('');
55
+ this.out(ok ? 'All checks passed.' : `${pluralize(failed, 'check')} failed.`);
56
+ if (!ok)
57
+ process.exitCode = 1;
58
+ return { checks: this.checks, environment: name, ok, pluginVersion, project: projectName, url };
52
59
  }
53
60
  async check(title, run) {
54
61
  try {
55
62
  await run();
56
- this.log(`✓ ${title}`);
63
+ this.checks.push({ ok: true, title });
64
+ this.out(`✓ ${title}`);
57
65
  return true;
58
66
  }
59
67
  catch (error) {
60
- this.log(`✗ ${title}`);
61
- this.log(` ${error.message}`);
68
+ const { message } = error;
69
+ this.checks.push({ message, ok: false, title });
70
+ this.out(`✗ ${title}`);
71
+ this.out(` ${message}`);
62
72
  return false;
63
73
  }
64
74
  }
75
+ out(message) {
76
+ if (!this.jsonEnabled())
77
+ this.log(message);
78
+ }
65
79
  // Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
66
80
  // predates it, neither should flip the doctor to a failure when everything else works.
67
81
  async reportPluginVersion() {
68
82
  try {
69
83
  const status = await this.wp.get('loopress/v1/update');
70
- this.log(`✓ Plugin version: ${status.current_version ?? '(unknown)'}`);
84
+ const version = status.current_version ?? '(unknown)';
85
+ this.out(`✓ Plugin version: ${version}`);
86
+ return version;
71
87
  }
72
88
  catch (error) {
73
89
  if (isNotFoundError(error)) {
74
- this.log('- Plugin version: not exposed by this plugin edition.');
75
- return;
90
+ this.out('- Plugin version: not exposed by this plugin edition.');
91
+ return undefined;
76
92
  }
77
- this.log(`- Plugin version: could not be read. ${error.message}`);
93
+ this.out(`- Plugin version: could not be read. ${error.message}`);
94
+ return undefined;
78
95
  }
79
96
  }
80
97
  }
@@ -39,7 +39,10 @@ export default class Pull extends LoopressCommand {
39
39
  async write(form, writeDir) {
40
40
  const id = getFormId(form);
41
41
  const title = getFormTitle(form);
42
- await writeFile(join(writeDir, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(form, null, 2) + '\n');
42
+ // `revision` is server bookkeeping (see RemoteForm in form-format.ts), never part of
43
+ // the tracked configuration: never persisted locally.
44
+ const local = Object.fromEntries(Object.entries(form).filter(([key]) => key !== 'revision'));
45
+ await writeFile(join(writeDir, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(local, null, 2) + '\n');
43
46
  },
44
47
  });
45
48
  if (this.dryRun)
@@ -12,6 +12,7 @@ export default class Push extends PushCommand {
12
12
  };
13
13
  private allowNotifications;
14
14
  run(): Promise<void>;
15
+ private currentRevision;
15
16
  private ensureCanonicalFilename;
16
17
  private loadFiles;
17
18
  private pushForm;
@@ -4,7 +4,9 @@ import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
5
  import { putOrCreate } from '../../lib/put-or-create.js';
6
6
  import { readdirTolerant } from '../../lib/readdir-tolerant.js';
7
- import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
7
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
9
+ import { FORM_ENDPOINT, formEndpoint, getFormId, getFormTitle } from '../../utils/form-format.js';
8
10
  import { pluralize } from '../../utils/pluralize.js';
9
11
  import { toSlug } from '../../utils/to-slug.js';
10
12
  export default class Push extends PushCommand {
@@ -29,9 +31,12 @@ export default class Push extends PushCommand {
29
31
  this.allowNotifications = flags['allow-notifications'];
30
32
  this.log(`Pushing forms to ${url}`);
31
33
  this.log(`Forms path: ${path}`);
34
+ const provider = getResourceStateProvider('form');
35
+ const beforeState = await this.captureBeforePushState(provider, path);
32
36
  const files = await this.loadFiles(path);
33
37
  this.log(`Found ${pluralize(files.length, 'form')} to push`);
34
38
  await this.runPushTasks(files, ({ data }) => getFormTitle(data), async ({ data, filePath }, task) => this.pushForm(filePath, data, task));
39
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
35
40
  if (this.failedCount > 0) {
36
41
  this.error(`${pluralize(this.failedCount, 'form')} failed to push.`);
37
42
  }
@@ -40,6 +45,17 @@ export default class Push extends PushCommand {
40
45
  await this.recordSuccess();
41
46
  this.log('All forms pushed.');
42
47
  }
48
+ async currentRevision(id) {
49
+ try {
50
+ const current = await this.wp.get(formEndpoint(id));
51
+ return current.revision;
52
+ }
53
+ catch (error) {
54
+ if (isNotFoundError(error))
55
+ return undefined;
56
+ throw error;
57
+ }
58
+ }
43
59
  // Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
44
60
  // whenever it doesn't already match (a hand-created file with no id, or a stale slug after
45
61
  // a title change in the WordPress admin), same principle as ensureCanonicalFilename in
@@ -85,11 +101,21 @@ export default class Push extends PushCommand {
85
101
  try {
86
102
  const id = getFormId(data);
87
103
  const payload = this.allowNotifications ? { ...data, allowNotifications: true } : data;
104
+ // Read the form's current revision right before writing it, and send it back as
105
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
106
+ // form in between, instead of this push silently overwriting it (#234). No revision to
107
+ // condition on for a form that doesn't exist remotely yet (a first push, or one whose id
108
+ // 404s below and falls back to create): that PUT attempt just 404s too, same as before
109
+ // this existed. Kept out of the POST fallback body (`postPayload`): it is a precondition
110
+ // on an existing resource, meaningless (and never persisted) on a freshly created one.
111
+ const expectedRevision = id === null ? undefined : await this.currentRevision(id);
112
+ const putPayload = expectedRevision === undefined ? payload : { ...payload, expectedRevision };
88
113
  const { body, created } = await putOrCreate(this.wp, {
89
114
  id,
90
- payload,
115
+ payload: putPayload,
91
116
  postEndpoint: FORM_ENDPOINT,
92
- putEndpoint: (formId) => `${FORM_ENDPOINT}/${formId}`,
117
+ postPayload: payload,
118
+ putEndpoint: formEndpoint,
93
119
  });
94
120
  const canonicalId = created ? getFormId(body) : id;
95
121
  if (canonicalId !== null)
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('form', {
3
+ description: 'Restore forms to a snapshot taken automatically before an earlier `lps form push`',
4
+ pathNoun: 'forms directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('hook', {
3
+ description: 'Restore hook files to a snapshot taken automatically before an earlier `lps hook push`',
4
+ pathNoun: 'hooks directory',
5
+ });
@@ -15,10 +15,12 @@ const SNIPPET_PROVIDERS = [
15
15
  const FEATURES = [
16
16
  { dir: 'acf', key: 'acfDir', label: 'ACF' },
17
17
  { dir: 'seo', key: 'seoDir', label: 'SEO' },
18
+ { dir: 'menus', key: 'menuDir', label: 'Menus' },
18
19
  { dir: 'forms', key: 'formDir', label: 'Forms' },
19
20
  { dir: 'api', key: 'apiDir', label: 'Custom API routes' },
20
21
  { dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
21
22
  { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
23
+ { dir: 'pages', key: 'pageDir', label: 'Static pages' },
22
24
  ];
23
25
  export default class Init extends Command {
24
26
  static description = 'Initialize a loopress.json config file in the current directory';
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('menu', {
3
+ description: 'Show what differs in nav menus and the active theme menu locations between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'menus directory',
5
+ });
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type Menu, type MenuLocations } from '../../utils/menu-format.js';
3
+ type ListResult = {
4
+ locations: MenuLocations;
5
+ menus: Menu[];
6
+ };
7
+ export default class List extends LoopressCommand {
8
+ static description: string;
9
+ static enableJsonFlag: boolean;
10
+ static examples: string[];
11
+ run(): Promise<ListResult>;
12
+ }
13
+ export {};
@@ -0,0 +1,32 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../../utils/menu-format.js';
3
+ function countItems(items) {
4
+ return items.reduce((total, item) => total + 1 + countItems(item.children), 0);
5
+ }
6
+ export default class List extends LoopressCommand {
7
+ static description = 'List nav menus and the active theme menu locations on WordPress';
8
+ static enableJsonFlag = true;
9
+ static examples = ['$ lps menu list'];
10
+ async run() {
11
+ const menus = await this.wp.get(MENU_ENDPOINT);
12
+ const locations = await this.wp.get(MENU_LOCATIONS_ENDPOINT);
13
+ if (menus.length === 0) {
14
+ this.log('(no menus)');
15
+ }
16
+ for (const menu of menus) {
17
+ this.log(`${menu.slug} (${menu.name}): ${countItems(menu.items)} item(s)`);
18
+ for (const warning of menu.warnings)
19
+ this.warn(`${menu.slug}: ${warning}`);
20
+ }
21
+ this.log('');
22
+ this.log('locations:');
23
+ const entries = Object.entries(locations);
24
+ if (entries.length === 0) {
25
+ this.log(' (none registered by the active theme)');
26
+ }
27
+ for (const [location, slug] of entries) {
28
+ this.log(` ${location}: ${slug ?? '(unassigned)'}`);
29
+ }
30
+ return { locations, menus };
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ private pullLocations;
14
+ private pullMenus;
15
+ }
@@ -0,0 +1,72 @@
1
+ import { Args } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
6
+ import { MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../../utils/menu-format.js';
7
+ // The reserved local filename for menu locations (see pullLocations()): a menu genuinely slugged
8
+ // "menu-locations" would collide with it, so it's skipped rather than silently overwriting that file.
9
+ const LOCATIONS_FILE_BASENAME = 'menu-locations';
10
+ export default class Pull extends LoopressCommand {
11
+ static args = {
12
+ path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
13
+ };
14
+ static description = 'Pull nav menus and the active theme menu locations from WordPress';
15
+ static examples = ['$ lps menu pull'];
16
+ static flags = {
17
+ ...LoopressCommand.dryRunFlag,
18
+ ...LoopressCommand.yesFlag,
19
+ };
20
+ async run() {
21
+ const { args } = await this.parse(Pull);
22
+ const { url } = this.siteConfig;
23
+ const path = this.resolveMenuPath(args.path);
24
+ this.log(`Pulling nav menus from ${url}`);
25
+ this.log(`Menus path: ${path}`);
26
+ await this.pullMenus(path);
27
+ await this.pullLocations(path);
28
+ }
29
+ async pullLocations(basePath) {
30
+ const file = join(basePath, `${LOCATIONS_FILE_BASENAME}.json`);
31
+ const locations = await this.wp.get(MENU_LOCATIONS_ENDPOINT);
32
+ if (this.dryRun) {
33
+ this.log(`[dry-run] Would pull locations to ${file}`);
34
+ return;
35
+ }
36
+ await mkdir(basePath, { recursive: true });
37
+ await writeFile(file, JSON.stringify(locations, null, 2) + '\n');
38
+ this.log(`Pulled locations to ${file}`);
39
+ }
40
+ async pullMenus(dir) {
41
+ const fetched = await this.wp.get(MENU_ENDPOINT);
42
+ const remote = fetched.filter((menu) => {
43
+ if (menu.slug === LOCATIONS_FILE_BASENAME) {
44
+ this.warn(`Skipping menu "${menu.slug}": this filename is reserved for ${LOCATIONS_FILE_BASENAME}.json`);
45
+ return false;
46
+ }
47
+ return true;
48
+ });
49
+ const orphans = await findOrphanedFiles(dir, new Set(remote.map((menu) => menu.slug)), {
50
+ extensions: ['.json'],
51
+ key: (base) => (base === LOCATIONS_FILE_BASENAME ? null : basenameKey(base)),
52
+ });
53
+ await this.pullDirectory(dir, remote, orphans, {
54
+ dryRunMessage: `Would pull ${remote.length} menu(s) to ${dir}`,
55
+ orphanReason: `in ${dir} no longer present on WordPress`,
56
+ pulledMessage: `Pulled ${remote.length} menu(s) to ${dir}`,
57
+ title: (menu) => menu.slug,
58
+ // revision/warnings are server bookkeeping, never part of the tracked configuration (see
59
+ // their doc comments on Menu in menu-format.ts): never persisted locally.
60
+ async write(menu, writeDir) {
61
+ const local = { items: menu.items, name: menu.name, slug: menu.slug };
62
+ await writeFile(join(writeDir, `${menu.slug}.json`), JSON.stringify(local, null, 2) + '\n');
63
+ },
64
+ });
65
+ if (this.dryRun)
66
+ return;
67
+ for (const menu of remote) {
68
+ for (const warning of menu.warnings)
69
+ this.warn(`${menu.slug}: ${warning}`);
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,17 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ private currentRevision;
14
+ private pushLocations;
15
+ private pushMenuFile;
16
+ private pushMenus;
17
+ }