@loopress/cli 0.24.0 → 0.25.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 (112) hide show
  1. package/README.md +716 -98
  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/rm.d.ts +2 -0
  7. package/dist/commands/api/rm.js +2 -0
  8. package/dist/commands/api/rollback.d.ts +2 -0
  9. package/dist/commands/api/rollback.js +5 -0
  10. package/dist/commands/composer/push.d.ts +3 -0
  11. package/dist/commands/composer/push.js +24 -7
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/doctor.d.ts +17 -1
  14. package/dist/commands/doctor.js +55 -38
  15. package/dist/commands/form/pull.js +4 -1
  16. package/dist/commands/form/push.d.ts +3 -0
  17. package/dist/commands/form/push.js +39 -6
  18. package/dist/commands/form/rollback.d.ts +2 -0
  19. package/dist/commands/form/rollback.js +5 -0
  20. package/dist/commands/hook/rm.d.ts +2 -0
  21. package/dist/commands/hook/rm.js +2 -0
  22. package/dist/commands/hook/rollback.d.ts +2 -0
  23. package/dist/commands/hook/rollback.js +5 -0
  24. package/dist/commands/init.js +1 -0
  25. package/dist/commands/login.js +3 -2
  26. package/dist/commands/menu/diff.d.ts +2 -0
  27. package/dist/commands/menu/diff.js +5 -0
  28. package/dist/commands/menu/list.d.ts +13 -0
  29. package/dist/commands/menu/list.js +32 -0
  30. package/dist/commands/menu/pull.d.ts +15 -0
  31. package/dist/commands/menu/pull.js +72 -0
  32. package/dist/commands/menu/push.d.ts +17 -0
  33. package/dist/commands/menu/push.js +120 -0
  34. package/dist/commands/menu/rollback.d.ts +2 -0
  35. package/dist/commands/menu/rollback.js +5 -0
  36. package/dist/commands/option/add.js +1 -1
  37. package/dist/commands/option/push.d.ts +1 -0
  38. package/dist/commands/option/push.js +26 -1
  39. package/dist/commands/option/rollback.d.ts +2 -0
  40. package/dist/commands/option/rollback.js +5 -0
  41. package/dist/commands/plugin/push.d.ts +1 -0
  42. package/dist/commands/plugin/push.js +14 -0
  43. package/dist/commands/promote.d.ts +8 -1
  44. package/dist/commands/promote.js +2 -0
  45. package/dist/commands/pull.d.ts +11 -1
  46. package/dist/commands/pull.js +9 -2
  47. package/dist/commands/push.d.ts +11 -1
  48. package/dist/commands/push.js +9 -2
  49. package/dist/commands/seo/pull.js +6 -2
  50. package/dist/commands/seo/push.d.ts +3 -0
  51. package/dist/commands/seo/push.js +49 -7
  52. package/dist/commands/seo/rollback.d.ts +2 -0
  53. package/dist/commands/seo/rollback.js +5 -0
  54. package/dist/commands/snippet/push.d.ts +1 -0
  55. package/dist/commands/snippet/push.js +31 -1
  56. package/dist/commands/snippet/rollback.d.ts +2 -0
  57. package/dist/commands/snippet/rollback.js +5 -0
  58. package/dist/commands/theme-styles/diff.d.ts +2 -0
  59. package/dist/commands/theme-styles/diff.js +5 -0
  60. package/dist/commands/theme-styles/pull.d.ts +12 -0
  61. package/dist/commands/theme-styles/pull.js +31 -0
  62. package/dist/commands/theme-styles/push.d.ts +12 -0
  63. package/dist/commands/theme-styles/push.js +45 -0
  64. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  65. package/dist/commands/theme-styles/rollback.js +5 -0
  66. package/dist/hooks/finally.js +4 -1
  67. package/dist/lib/app-manifest.js +4 -27
  68. package/dist/lib/base.d.ts +2 -0
  69. package/dist/lib/base.js +6 -0
  70. package/dist/lib/find-orphaned-files.js +5 -47
  71. package/dist/lib/load-files.js +4 -42
  72. package/dist/lib/local-callback-server.d.ts +14 -1
  73. package/dist/lib/local-callback-server.js +40 -1
  74. package/dist/lib/php-files-command.d.ts +17 -3
  75. package/dist/lib/php-files-command.js +196 -37
  76. package/dist/lib/push-command.d.ts +4 -0
  77. package/dist/lib/push-command.js +49 -0
  78. package/dist/lib/readdir-tolerant.d.ts +1 -0
  79. package/dist/lib/readdir-tolerant.js +12 -0
  80. package/dist/lib/resource-state.js +92 -3
  81. package/dist/lib/rollback-command.d.ts +21 -0
  82. package/dist/lib/rollback-command.js +180 -0
  83. package/dist/lib/rollback-materialize.d.ts +1 -0
  84. package/dist/lib/rollback-materialize.js +121 -0
  85. package/dist/lib/sentry.d.ts +3 -0
  86. package/dist/lib/sentry.js +30 -0
  87. package/dist/lib/snapshot-store.d.ts +30 -0
  88. package/dist/lib/snapshot-store.js +133 -0
  89. package/dist/lib/validate-local.js +2 -1
  90. package/dist/lib/wp-authorize-flow.d.ts +4 -0
  91. package/dist/lib/wp-authorize-flow.js +10 -8
  92. package/dist/types/project-config.generated.d.ts +8 -0
  93. package/dist/utils/acf-format.d.ts +1 -0
  94. package/dist/utils/acf-format.js +6 -0
  95. package/dist/utils/form-format.d.ts +4 -0
  96. package/dist/utils/form-format.js +3 -7
  97. package/dist/utils/menu-format.d.ts +24 -0
  98. package/dist/utils/menu-format.js +8 -0
  99. package/dist/utils/option-format.d.ts +2 -1
  100. package/dist/utils/option-format.js +22 -4
  101. package/dist/utils/plugin-sync.d.ts +6 -0
  102. package/dist/utils/resource-dirs.d.ts +2 -0
  103. package/dist/utils/resource-dirs.js +4 -0
  104. package/dist/utils/seo-format.d.ts +8 -0
  105. package/dist/utils/seo-format.js +3 -0
  106. package/dist/utils/snippet-format.d.ts +1 -0
  107. package/dist/utils/snippet-format.js +1 -0
  108. package/dist/utils/theme-styles-format.d.ts +26 -0
  109. package/dist/utils/theme-styles-format.js +55 -0
  110. package/oclif.manifest.json +1837 -386
  111. package/package.json +5 -3
  112. package/schemas/project-config.schema.json +10 -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: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { API_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(API_FILES_RESOURCE);
@@ -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
+ });
@@ -1,6 +1,8 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ import { type LockDriftEntry } from '../../utils/plugin-sync.js';
2
3
  type PushResult = {
3
4
  hasLock: boolean;
5
+ lockDrift: LockDriftEntry[];
4
6
  packageCount: number;
5
7
  status: 'dry-run' | 'success';
6
8
  };
@@ -14,5 +16,6 @@ export default class ComposerPush extends PushCommand {
14
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  };
16
18
  run(): Promise<PushResult>;
19
+ private reportLockDrift;
17
20
  }
18
21
  export {};
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { isTimeoutError } from '../../lib/wp-client.js';
7
- import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
7
+ import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
8
  // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
9
  // understands. `composer/installers` is owned by the server scaffold, never sent.
10
10
  function toIntent(require) {
@@ -25,7 +25,7 @@ function toIntent(require) {
25
25
  return intent;
26
26
  }
27
27
  export default class ComposerPush extends PushCommand {
28
- static description = 'Push composer.json (and composer.lock, if present) to WordPress and run Composer';
28
+ static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
29
29
  static enableJsonFlag = true;
30
30
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
31
31
  static flags = {
@@ -47,14 +47,16 @@ export default class ComposerPush extends PushCommand {
47
47
  const packageCount = Object.keys(require).length;
48
48
  const intent = toIntent(require);
49
49
  const hasLock = existsSync(composerLockPath);
50
+ // The server resolves composer.json itself; it never installs from this lock (a crafted
51
+ // lock could point package downloads at arbitrary hosts). It is sent only so the server
52
+ // can report which pinned versions its own resolution moved. `lps composer pull` brings
53
+ // the resolved lock back.
50
54
  const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
51
55
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
52
56
  if (lock)
53
- this.log(' + composer.lock included (reproducible install)');
54
- else
55
- this.warn('No composer.lock found. The server will resolve versions freely.');
57
+ this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
56
58
  if (this.dryRun)
57
- return { hasLock, packageCount, status: 'dry-run' };
59
+ return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
58
60
  this.log('Running Composer on the server, this can take a few minutes...');
59
61
  let response;
60
62
  try {
@@ -72,7 +74,22 @@ export default class ComposerPush extends PushCommand {
72
74
  if (response.output.trim())
73
75
  this.log(response.output.trim());
74
76
  this.log('Composer run completed on the server.');
77
+ const lockDrift = response.lockDrift ?? [];
78
+ this.reportLockDrift(lockDrift);
75
79
  await this.recordSuccess();
76
- return { hasLock, packageCount, status: 'success' };
80
+ return { hasLock, lockDrift, packageCount, status: 'success' };
81
+ }
82
+ // The server resolves composer.json from scratch and never installs from the uploaded lock,
83
+ // so a project that committed a lock can see versions move. Spell out exactly what changed
84
+ // and point at `lps composer pull` to bring the resolved lock back locally.
85
+ reportLockDrift(lockDrift) {
86
+ if (lockDrift.length === 0)
87
+ return;
88
+ this.log('');
89
+ this.log(`The server resolved ${lockDrift.length} ${lockDrift.length === 1 ? 'package' : 'packages'} to a different version than your local composer.lock:`);
90
+ for (const { from, name, to } of lockDrift) {
91
+ this.log(` ${name}: ${from ?? '(absent)'} -> ${to ?? '(removed)'}`);
92
+ }
93
+ this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
77
94
  }
78
95
  }
@@ -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, 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)
@@ -6,10 +6,13 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ 'allow-notifications': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
13
+ private allowNotifications;
12
14
  run(): Promise<void>;
15
+ private currentRevision;
13
16
  private ensureCanonicalFilename;
14
17
  private loadFiles;
15
18
  private pushForm;
@@ -1,10 +1,12 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { readFile, rename } from 'node:fs/promises';
3
3
  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 {
@@ -12,20 +14,29 @@ export default class Push extends PushCommand {
12
14
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
13
15
  };
14
16
  static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
15
- static examples = ['$ lps form push'];
17
+ static examples = ['$ lps form push', '$ lps form push --allow-notifications'];
16
18
  static flags = {
17
19
  ...PushCommand.dryRunFlag,
18
20
  ...PushCommand.yesFlag,
21
+ 'allow-notifications': Flags.boolean({
22
+ default: false,
23
+ description: "Also push each form's notification and confirmation settings (recipients, sender, messages). Off by default: the server keeps its own so a stray push can't redirect submissions.",
24
+ }),
19
25
  };
26
+ allowNotifications = false;
20
27
  async run() {
21
- const { args } = await this.parse(Push);
28
+ const { args, flags } = await this.parse(Push);
22
29
  const { url } = this.siteConfig;
23
30
  const path = this.resolveFormPath(args.path);
31
+ this.allowNotifications = flags['allow-notifications'];
24
32
  this.log(`Pushing forms to ${url}`);
25
33
  this.log(`Forms path: ${path}`);
34
+ const provider = getResourceStateProvider('form');
35
+ const beforeState = await this.captureBeforePushState(provider, path);
26
36
  const files = await this.loadFiles(path);
27
37
  this.log(`Found ${pluralize(files.length, 'form')} to push`);
28
38
  await this.runPushTasks(files, ({ data }) => getFormTitle(data), async ({ data, filePath }, task) => this.pushForm(filePath, data, task));
39
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
29
40
  if (this.failedCount > 0) {
30
41
  this.error(`${pluralize(this.failedCount, 'form')} failed to push.`);
31
42
  }
@@ -34,6 +45,17 @@ export default class Push extends PushCommand {
34
45
  await this.recordSuccess();
35
46
  this.log('All forms pushed.');
36
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
+ }
37
59
  // Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
38
60
  // whenever it doesn't already match (a hand-created file with no id, or a stale slug after
39
61
  // a title change in the WordPress admin), same principle as ensureCanonicalFilename in
@@ -78,11 +100,22 @@ export default class Push extends PushCommand {
78
100
  }
79
101
  try {
80
102
  const id = getFormId(data);
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 };
81
113
  const { body, created } = await putOrCreate(this.wp, {
82
114
  id,
83
- payload: data,
115
+ payload: putPayload,
84
116
  postEndpoint: FORM_ENDPOINT,
85
- putEndpoint: (formId) => `${FORM_ENDPOINT}/${formId}`,
117
+ postPayload: payload,
118
+ putEndpoint: formEndpoint,
86
119
  });
87
120
  const canonicalId = created ? getFormId(body) : id;
88
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: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(HOOK_FILES_RESOURCE);
@@ -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,6 +15,7 @@ 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)' },
@@ -13,8 +13,9 @@ export default class Login extends Command {
13
13
  }
14
14
  async waitForCallback() {
15
15
  return waitForLocalCallback({
16
- buildUrl(callbackBaseUrl) {
17
- const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback`);
16
+ allowedOrigins: [CONSOLE_URL],
17
+ buildUrl(callbackBaseUrl, state) {
18
+ const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback?state=${state}`);
18
19
  return `${CONSOLE_URL}/cli-auth?callbackUrl=${callbackUrl}`;
19
20
  },
20
21
  handleRequest(url, { resolveWithPage, respondBadRequest }) {
@@ -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
+ }