@loopress/cli 0.24.1 → 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 (96) hide show
  1. package/README.md +629 -88
  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 +1 -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/plugin/push.d.ts +1 -0
  35. package/dist/commands/plugin/push.js +14 -0
  36. package/dist/commands/promote.d.ts +8 -1
  37. package/dist/commands/promote.js +2 -0
  38. package/dist/commands/pull.d.ts +11 -1
  39. package/dist/commands/pull.js +9 -2
  40. package/dist/commands/push.d.ts +11 -1
  41. package/dist/commands/push.js +9 -2
  42. package/dist/commands/seo/pull.js +6 -2
  43. package/dist/commands/seo/push.d.ts +1 -0
  44. package/dist/commands/seo/push.js +33 -3
  45. package/dist/commands/seo/rollback.d.ts +2 -0
  46. package/dist/commands/seo/rollback.js +5 -0
  47. package/dist/commands/snippet/push.d.ts +1 -0
  48. package/dist/commands/snippet/push.js +31 -1
  49. package/dist/commands/snippet/rollback.d.ts +2 -0
  50. package/dist/commands/snippet/rollback.js +5 -0
  51. package/dist/commands/theme-styles/diff.d.ts +2 -0
  52. package/dist/commands/theme-styles/diff.js +5 -0
  53. package/dist/commands/theme-styles/pull.d.ts +12 -0
  54. package/dist/commands/theme-styles/pull.js +31 -0
  55. package/dist/commands/theme-styles/push.d.ts +12 -0
  56. package/dist/commands/theme-styles/push.js +45 -0
  57. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  58. package/dist/commands/theme-styles/rollback.js +5 -0
  59. package/dist/lib/app-manifest.js +4 -27
  60. package/dist/lib/base.d.ts +2 -0
  61. package/dist/lib/base.js +6 -0
  62. package/dist/lib/find-orphaned-files.js +5 -47
  63. package/dist/lib/load-files.js +4 -42
  64. package/dist/lib/php-files-command.d.ts +1 -0
  65. package/dist/lib/php-files-command.js +84 -47
  66. package/dist/lib/push-command.d.ts +4 -0
  67. package/dist/lib/push-command.js +49 -0
  68. package/dist/lib/readdir-tolerant.d.ts +1 -0
  69. package/dist/lib/readdir-tolerant.js +12 -0
  70. package/dist/lib/resource-state.js +92 -3
  71. package/dist/lib/rollback-command.d.ts +21 -0
  72. package/dist/lib/rollback-command.js +180 -0
  73. package/dist/lib/rollback-materialize.d.ts +1 -0
  74. package/dist/lib/rollback-materialize.js +121 -0
  75. package/dist/lib/snapshot-store.d.ts +30 -0
  76. package/dist/lib/snapshot-store.js +133 -0
  77. package/dist/lib/validate-local.js +2 -1
  78. package/dist/types/project-config.generated.d.ts +8 -0
  79. package/dist/utils/acf-format.d.ts +1 -0
  80. package/dist/utils/acf-format.js +6 -0
  81. package/dist/utils/form-format.d.ts +4 -0
  82. package/dist/utils/form-format.js +3 -7
  83. package/dist/utils/menu-format.d.ts +24 -0
  84. package/dist/utils/menu-format.js +8 -0
  85. package/dist/utils/option-format.d.ts +1 -0
  86. package/dist/utils/resource-dirs.d.ts +2 -0
  87. package/dist/utils/resource-dirs.js +4 -0
  88. package/dist/utils/seo-format.d.ts +8 -0
  89. package/dist/utils/seo-format.js +3 -0
  90. package/dist/utils/snippet-format.d.ts +1 -0
  91. package/dist/utils/snippet-format.js +1 -0
  92. package/dist/utils/theme-styles-format.d.ts +26 -0
  93. package/dist/utils/theme-styles-format.js +55 -0
  94. package/oclif.manifest.json +1736 -465
  95. package/package.json +4 -2
  96. package/schemas/project-config.schema.json +10 -0
@@ -0,0 +1,120 @@
1
+ import { Args } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { PushCommand } from '../../lib/push-command.js';
5
+ import { readdirTolerant } from '../../lib/readdir-tolerant.js';
6
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
7
+ import { isNotFoundError } from '../../lib/wp-client.js';
8
+ import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT, menuEndpoint } from '../../utils/menu-format.js';
9
+ import { pluralize } from '../../utils/pluralize.js';
10
+ // A menu literally slugged "menu-locations" would still collide, but that's far less likely
11
+ // than the plain "locations" this used to be (WordPress's own menu-locations feature).
12
+ const LOCATIONS_FILENAME = 'menu-locations.json';
13
+ export default class Push extends PushCommand {
14
+ static args = {
15
+ path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
16
+ };
17
+ static description = 'Push local nav menus and the active theme menu locations to WordPress. Each menu\'s post_type/taxonomy items are resolved by slug on the target environment, never by a raw id; an item whose target does not exist there fails the whole menu rather than guessing.';
18
+ static examples = ['$ lps menu push'];
19
+ static flags = {
20
+ ...PushCommand.dryRunFlag,
21
+ ...PushCommand.yesFlag,
22
+ };
23
+ async run() {
24
+ const { args } = await this.parse(Push);
25
+ const { url } = this.siteConfig;
26
+ const path = this.resolveMenuPath(args.path);
27
+ this.log(`Pushing nav menus to ${url}`);
28
+ this.log(`Menus path: ${path}`);
29
+ const provider = getResourceStateProvider('menu');
30
+ const beforeState = await this.captureBeforePushState(provider, path);
31
+ await this.pushMenus(path);
32
+ await this.pushLocations(path);
33
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
34
+ if (this.failedCount > 0) {
35
+ this.error(`${pluralize(this.failedCount, 'menu')} failed to push.`);
36
+ }
37
+ if (this.dryRun)
38
+ return;
39
+ await this.recordSuccess();
40
+ this.log('All nav menus pushed.');
41
+ }
42
+ async currentRevision(slug) {
43
+ try {
44
+ const current = await this.wp.get(menuEndpoint(slug));
45
+ return current.revision;
46
+ }
47
+ catch (error) {
48
+ if (isNotFoundError(error))
49
+ return undefined;
50
+ throw error;
51
+ }
52
+ }
53
+ async pushLocations(basePath) {
54
+ const file = join(basePath, LOCATIONS_FILENAME);
55
+ let raw;
56
+ try {
57
+ raw = await readFile(file, 'utf8');
58
+ }
59
+ catch (error) {
60
+ if (error.code === 'ENOENT')
61
+ return;
62
+ throw error;
63
+ }
64
+ if (this.dryRun) {
65
+ this.log(`[dry-run] Would push: ${file}`);
66
+ return;
67
+ }
68
+ try {
69
+ await this.wp.put(MENU_LOCATIONS_ENDPOINT, JSON.parse(raw));
70
+ this.log(`Pushed: ${file}`);
71
+ }
72
+ catch (error) {
73
+ this.failedCount++;
74
+ this.warn(`Failed to push ${file}: ${error.message}`);
75
+ }
76
+ }
77
+ async pushMenuFile(filePath, task) {
78
+ if (this.dryRun) {
79
+ if (task)
80
+ task.output = `[dry-run] Would push: ${filePath}`;
81
+ return;
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(await readFile(filePath, 'utf8'));
85
+ if (typeof parsed !== 'object' || parsed === null)
86
+ throw new Error('not a JSON object');
87
+ const record = parsed;
88
+ const slug = getMenuSlug(record);
89
+ if (slug === null)
90
+ throw new Error('missing or invalid "slug"');
91
+ const name = typeof record.name === 'string' ? record.name : '';
92
+ const items = record.items ?? [];
93
+ if (!Array.isArray(items))
94
+ throw new Error('"items" must be an array');
95
+ // Read the menu's current revision right before writing it, and send it back as
96
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the menu
97
+ // in between, instead of this push silently overwriting it (#234). No revision to condition
98
+ // on for a menu that doesn't exist remotely yet (a first push, an upsert create): falls back
99
+ // to today's unconditional write, same as before this existed.
100
+ const expectedRevision = await this.currentRevision(slug);
101
+ const body = { items, name, slug };
102
+ if (expectedRevision !== undefined)
103
+ body.expectedRevision = expectedRevision;
104
+ const result = await this.wp.post(MENU_ENDPOINT, body);
105
+ if (task) {
106
+ task.output = result.warnings.length > 0 ? `Pushed: ${slug} (warning: ${result.warnings.join('; ')})` : `Pushed: ${slug}`;
107
+ }
108
+ }
109
+ catch (error) {
110
+ this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
111
+ }
112
+ }
113
+ async pushMenus(basePath) {
114
+ const files = (await readdirTolerant(basePath)).filter((file) => extname(file) === '.json' && file !== LOCATIONS_FILENAME);
115
+ if (files.length === 0)
116
+ return;
117
+ this.log(`Found ${pluralize(files.length, 'menu')} to push`);
118
+ await this.runPushTasks(files, (file) => file, async (file, task) => this.pushMenuFile(join(basePath, file), task));
119
+ }
120
+ }
@@ -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('menu', {
3
+ description: 'Restore nav menus and menu locations to a snapshot taken automatically before an earlier `lps menu push`',
4
+ pathNoun: 'menus directory',
5
+ });
@@ -33,7 +33,7 @@ export default class Add extends LoopressCommand {
33
33
  }
34
34
  throw error;
35
35
  }
36
- const local = { ...remote, readonly: defaultReadonlyFor(name) };
36
+ const local = { autoload: remote.autoload, name: remote.name, readonly: defaultReadonlyFor(name), value: remote.value };
37
37
  const file = join(dir, optionFileName(name));
38
38
  if (this.dryRun) {
39
39
  this.log(`[dry-run] Would track "${name}" at ${file}`);
@@ -16,6 +16,7 @@ export default class Push extends PushCommand {
16
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  };
18
18
  run(): Promise<PushResult>;
19
+ private currentRevision;
19
20
  private pushOption;
20
21
  }
21
22
  export {};
@@ -1,6 +1,8 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { loadFiles } from '../../lib/load-files.js';
3
3
  import { PushCommand } from '../../lib/push-command.js';
4
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
5
+ import { isNotFoundError } from '../../lib/wp-client.js';
4
6
  import { optionEndpoint, parseLocalOption, partitionByReadonly } from '../../utils/option-format.js';
5
7
  import { pluralize } from '../../utils/pluralize.js';
6
8
  export default class Push extends PushCommand {
@@ -21,6 +23,8 @@ export default class Push extends PushCommand {
21
23
  const { url } = this.siteConfig;
22
24
  this.log(`Pushing tracked options to ${url}`);
23
25
  this.log(`Options path: ${dir}`);
26
+ const provider = getResourceStateProvider('option');
27
+ const beforeState = await this.captureBeforePushState(provider, dir);
24
28
  const tracked = await loadFiles(dir, {
25
29
  extension: '.json',
26
30
  onSkip: (message) => {
@@ -38,6 +42,7 @@ export default class Push extends PushCommand {
38
42
  await this.pushOption(option, task);
39
43
  pushed.push(option.name);
40
44
  });
45
+ await this.writeAfterPushSnapshot(provider, dir, beforeState);
41
46
  if (this.failedCount > 0) {
42
47
  this.error(`${pluralize(this.failedCount, 'option')} failed to push.`);
43
48
  }
@@ -48,6 +53,17 @@ export default class Push extends PushCommand {
48
53
  this.log('All options pushed.');
49
54
  return { pushed, skipped: skippedNames, status: 'success' };
50
55
  }
56
+ async currentRevision(name) {
57
+ try {
58
+ const current = await this.wp.get(optionEndpoint(name));
59
+ return current.revision;
60
+ }
61
+ catch (error) {
62
+ if (isNotFoundError(error))
63
+ return undefined;
64
+ throw error;
65
+ }
66
+ }
51
67
  async pushOption(option, task) {
52
68
  if (this.dryRun) {
53
69
  if (task)
@@ -55,7 +71,16 @@ export default class Push extends PushCommand {
55
71
  return;
56
72
  }
57
73
  try {
58
- await this.wp.put(optionEndpoint(option.name), { autoload: option.autoload, value: option.value });
74
+ // Read the option's current revision right before writing it, and send it back as
75
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
76
+ // option in between, instead of this push silently overwriting it (#234). No revision to
77
+ // condition on for an option that doesn't exist remotely yet (a first push, an upsert
78
+ // create): falls back to today's unconditional write, same as before this existed.
79
+ const expectedRevision = await this.currentRevision(option.name);
80
+ const body = { autoload: option.autoload, value: option.value };
81
+ if (expectedRevision !== undefined)
82
+ body.expectedRevision = expectedRevision;
83
+ await this.wp.put(optionEndpoint(option.name), body);
59
84
  if (task)
60
85
  task.output = `Pushed: ${option.name}`;
61
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('option', {
3
+ description: 'Restore tracked options to a snapshot taken automatically before an earlier `lps option push`',
4
+ pathNoun: 'options directory',
5
+ });
@@ -22,6 +22,7 @@ export default class Push extends PushCommand {
22
22
  private deactivate;
23
23
  private deactivateEndangered;
24
24
  private fetchInstanceLock;
25
+ private filesForNewlyInstalled;
25
26
  private guardForce;
26
27
  private result;
27
28
  private sync;
@@ -70,6 +70,7 @@ export default class Push extends PushCommand {
70
70
  await this.activate(deactivated.filter((p) => !diff.toRemove.includes(p.slug)));
71
71
  throw error;
72
72
  }
73
+ toReactivate.push(...(await this.filesForNewlyInstalled(diff.toInstall)));
73
74
  if (toPrune.length > 0) {
74
75
  await this.deactivate(installed.filter((p) => toPrune.includes(p.slug)));
75
76
  }
@@ -126,6 +127,19 @@ export default class Push extends PushCommand {
126
127
  throw error;
127
128
  }
128
129
  }
130
+ // toInstall entries carry no `file`: the plugin didn't exist on the site at diff time, so its
131
+ // WordPress core plugin id (folder/main-file.php) is only knowable after sync() has actually
132
+ // installed it. Refetch to get it, then activate like everything else.
133
+ async filesForNewlyInstalled(toInstall) {
134
+ if (toInstall.length === 0)
135
+ return [];
136
+ const installedAfterSync = parseInstalledPlugins(await this.wp.get('wp/v2/plugins'));
137
+ const fileBySlug = new Map(installedAfterSync.map((p) => [p.slug, p.file]));
138
+ return toInstall.flatMap(({ slug }) => {
139
+ const file = fileBySlug.get(slug);
140
+ return file ? [{ file, slug }] : [];
141
+ });
142
+ }
129
143
  guardForce(diff, force) {
130
144
  if (force)
131
145
  return;
@@ -1,18 +1,25 @@
1
1
  import { Command } from '@oclif/core';
2
+ type PromoteResult = {
3
+ from: string;
4
+ status: 'dry-run' | 'promoted';
5
+ to: string;
6
+ };
2
7
  export default class Promote extends Command {
3
8
  static args: {
4
9
  from: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
10
  to: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
11
  };
7
12
  static description: string;
13
+ static enableJsonFlag: boolean;
8
14
  static examples: string[];
9
15
  static flags: {
10
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
18
  };
13
- run(): Promise<void>;
19
+ run(): Promise<PromoteResult>;
14
20
  private confirmPromotion;
15
21
  private delegateArgv;
16
22
  private requireEnvironment;
17
23
  private resolveProject;
18
24
  }
25
+ export {};
@@ -10,6 +10,7 @@ export default class Promote extends Command {
10
10
  to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
11
11
  };
12
12
  static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process.';
13
+ static enableJsonFlag = true;
13
14
  static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
14
15
  static flags = {
15
16
  ...LoopressCommand.dryRunFlag,
@@ -38,6 +39,7 @@ export default class Promote extends Command {
38
39
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
39
40
  await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
40
41
  this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
42
+ return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
41
43
  }
42
44
  // Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
43
45
  // target, not just production, so it needs its own confirmation regardless of the name. That
@@ -1,12 +1,22 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ type PullTargetResult = {
3
+ error?: string;
4
+ label: string;
5
+ status: 'failed' | 'pulled';
6
+ };
7
+ type PullResult = {
8
+ results: PullTargetResult[];
9
+ };
2
10
  export default class Pull extends LoopressCommand {
3
11
  static description: string;
12
+ static enableJsonFlag: boolean;
4
13
  static examples: string[];
5
14
  static flags: {
6
15
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
17
  };
9
18
  private failedCount;
10
- run(): Promise<void>;
19
+ run(): Promise<PullResult>;
11
20
  private buildArgv;
12
21
  }
22
+ export {};
@@ -11,11 +11,13 @@ const PULL_TARGETS = [
11
11
  { commandId: 'hook:pull', label: 'hooks', supportsYes: true },
12
12
  { commandId: 'form:pull', label: 'forms', supportsYes: true },
13
13
  { commandId: 'seo:pull', label: 'SEO', supportsYes: true },
14
+ { commandId: 'menu:pull', label: 'menus', supportsYes: true },
14
15
  { commandId: 'option:pull', label: 'options', supportsYes: true },
15
16
  { commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
16
17
  ];
17
18
  export default class Pull extends LoopressCommand {
18
- static description = 'Pull all content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, options, and snippets, from WordPress';
19
+ static description = 'Pull all content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, from WordPress';
20
+ static enableJsonFlag = true;
19
21
  static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
20
22
  static flags = {
21
23
  ...LoopressCommand.dryRunFlag,
@@ -23,21 +25,26 @@ export default class Pull extends LoopressCommand {
23
25
  };
24
26
  failedCount = 0;
25
27
  async run() {
28
+ const results = [];
26
29
  for (const target of PULL_TARGETS) {
27
30
  this.log(`\n→ Pulling ${target.label}...`);
28
31
  try {
29
32
  await this.config.runCommand(target.commandId, this.buildArgv(target));
30
33
  this.log(`✓ ${target.label} pulled`);
34
+ results.push({ label: target.label, status: 'pulled' });
31
35
  }
32
36
  catch (error) {
33
37
  this.failedCount++;
34
- this.log(`✗ ${target.label} failed: ${error.message}`);
38
+ const { message } = error;
39
+ this.log(`✗ ${target.label} failed: ${message}`);
40
+ results.push({ error: message, label: target.label, status: 'failed' });
35
41
  }
36
42
  }
37
43
  if (this.failedCount > 0) {
38
44
  this.error(`${pluralize(this.failedCount, 'resource')} failed to pull.`);
39
45
  }
40
46
  this.log('\nAll resources pulled.');
47
+ return { results };
41
48
  }
42
49
  // `--yes` is forwarded only when the user passed it and the target accepts it: unlike
43
50
  // `lps push`, a bare `lps pull` should still prompt before removing local files.
@@ -1,13 +1,23 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ type PushTargetResult = {
3
+ error?: string;
4
+ label: string;
5
+ status: 'failed' | 'pushed';
6
+ };
7
+ type PushResult = {
8
+ results: PushTargetResult[];
9
+ };
2
10
  export default class Push extends LoopressCommand {
3
11
  static description: string;
12
+ static enableJsonFlag: boolean;
4
13
  static examples: string[];
5
14
  static flags: {
6
15
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
17
  };
9
18
  private failedCount;
10
- run(): Promise<void>;
19
+ run(): Promise<PushResult>;
11
20
  private buildArgv;
12
21
  private guardProductionPush;
13
22
  }
23
+ export {};
@@ -11,11 +11,13 @@ const PUSH_TARGETS = [
11
11
  { commandId: 'hook:push', label: 'hooks' },
12
12
  { commandId: 'form:push', label: 'forms' },
13
13
  { commandId: 'seo:push', label: 'SEO' },
14
+ { commandId: 'menu:push', label: 'menus' },
14
15
  { commandId: 'option:push', label: 'options' },
15
16
  { commandId: 'snippet:push', label: 'snippets' },
16
17
  ];
17
18
  export default class Push extends LoopressCommand {
18
- static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, options, and snippets, to WordPress';
19
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, to WordPress';
20
+ static enableJsonFlag = true;
19
21
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
20
22
  static flags = {
21
23
  ...LoopressCommand.dryRunFlag,
@@ -25,21 +27,26 @@ export default class Push extends LoopressCommand {
25
27
  async run() {
26
28
  await this.guardProductionPush();
27
29
  const argv = this.buildArgv();
30
+ const results = [];
28
31
  for (const target of PUSH_TARGETS) {
29
32
  this.log(`\n→ Pushing ${target.label}...`);
30
33
  try {
31
34
  await this.config.runCommand(target.commandId, argv);
32
35
  this.log(`✓ ${target.label} pushed`);
36
+ results.push({ label: target.label, status: 'pushed' });
33
37
  }
34
38
  catch (error) {
35
39
  this.failedCount++;
36
- this.log(`✗ ${target.label} failed: ${error.message}`);
40
+ const { message } = error;
41
+ this.log(`✗ ${target.label} failed: ${message}`);
42
+ results.push({ error: message, label: target.label, status: 'failed' });
37
43
  }
38
44
  }
39
45
  if (this.failedCount > 0) {
40
46
  this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
41
47
  }
42
48
  this.log('\nAll resources pushed.');
49
+ return { results };
43
50
  }
44
51
  // Every delegated push always gets --yes: either this command's own guard already confirmed
45
52
  // production once above, or the target isn't production and there's nothing to confirm.
@@ -40,7 +40,9 @@ export default class Pull extends LoopressCommand {
40
40
  orphanReason: `in ${dir} no longer present on WordPress`,
41
41
  pulledMessage: `Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`,
42
42
  title: (post) => post.slug,
43
- write: async (post, writeDir) => writeFile(join(writeDir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n'),
43
+ // `revision` (#234) is never persisted locally, same as RemoteOption.revision: it's only
44
+ // ever read fresh, right before a push, never trusted from a stale local file.
45
+ write: async ({ meta, slug, title }, writeDir) => writeFile(join(writeDir, `${slug}.json`), JSON.stringify({ meta, slug, title }, null, 2) + '\n'),
44
46
  });
45
47
  }
46
48
  // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
@@ -71,7 +73,9 @@ export default class Pull extends LoopressCommand {
71
73
  }
72
74
  async pullSettings(basePath) {
73
75
  const file = join(basePath, 'settings.json');
74
- const settings = await this.wp.get(SEO_SETTINGS_ENDPOINT);
76
+ // `revision` (#234) is never persisted locally, same as RemoteOption.revision: settings.json
77
+ // holds only the settings themselves, exactly as it did before the wrapped response shape.
78
+ const { settings } = await this.wp.get(SEO_SETTINGS_ENDPOINT);
75
79
  if (this.dryRun) {
76
80
  this.log(`[dry-run] Would pull settings to ${file}`);
77
81
  return;
@@ -12,6 +12,7 @@ export default class Push extends PushCommand {
12
12
  };
13
13
  private allowExternalRedirects;
14
14
  run(): Promise<void>;
15
+ private currentPostMetaRevision;
15
16
  private jsonFilesIn;
16
17
  private pushPostMeta;
17
18
  private pushPostMetaFile;
@@ -4,8 +4,10 @@ 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 { getResourceStateProvider } from '../../lib/resource-state.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
9
  import { pluralize } from '../../utils/pluralize.js';
8
- import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
10
+ import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoPostMetaItemEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
9
11
  export default class Push extends PushCommand {
10
12
  static args = {
11
13
  path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
@@ -28,9 +30,12 @@ export default class Push extends PushCommand {
28
30
  this.allowExternalRedirects = flags['allow-external-redirects'];
29
31
  this.log(`Pushing SEO configuration to ${url}`);
30
32
  this.log(`SEO path: ${path}`);
33
+ const provider = getResourceStateProvider('seo');
34
+ const beforeState = await this.captureBeforePushState(provider, path);
31
35
  await this.pushSettings(path);
32
36
  await this.pushPostMeta(path);
33
37
  await this.pushRedirects(path);
38
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
34
39
  if (this.failedCount > 0) {
35
40
  this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
36
41
  }
@@ -39,6 +44,17 @@ export default class Push extends PushCommand {
39
44
  await this.recordSuccess();
40
45
  this.log('All SEO configuration pushed.');
41
46
  }
47
+ async currentPostMetaRevision(postType, slug) {
48
+ try {
49
+ const current = await this.wp.get(seoPostMetaItemEndpoint(postType, slug));
50
+ return current.revision;
51
+ }
52
+ catch (error) {
53
+ if (isNotFoundError(error))
54
+ return undefined;
55
+ throw error;
56
+ }
57
+ }
42
58
  async jsonFilesIn(dir) {
43
59
  return (await readdirTolerant(dir)).filter((file) => extname(file) === '.json');
44
60
  }
@@ -64,7 +80,16 @@ export default class Push extends PushCommand {
64
80
  }
65
81
  try {
66
82
  const post = JSON.parse(await readFile(filePath, 'utf8'));
67
- await this.wp.post(seoPostMetaEndpoint(postType), { meta: post.meta, slug: post.slug });
83
+ // Read the post's current SEO-meta revision right before writing it, and send it back as
84
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed that
85
+ // post's meta in between, instead of this push silently overwriting it (#234). No revision
86
+ // to condition on for a post whose meta doesn't exist remotely yet (an upsert that will
87
+ // itself fail with a clearer "post not found" error): falls back to an unconditional write.
88
+ const expectedRevision = await this.currentPostMetaRevision(postType, post.slug);
89
+ const body = { meta: post.meta, slug: post.slug };
90
+ if (expectedRevision !== undefined)
91
+ body.expectedRevision = expectedRevision;
92
+ await this.wp.post(seoPostMetaEndpoint(postType), body);
68
93
  if (task)
69
94
  task.output = `Pushed: ${post.slug}`;
70
95
  }
@@ -128,7 +153,12 @@ export default class Push extends PushCommand {
128
153
  return;
129
154
  }
130
155
  try {
131
- await this.wp.put(SEO_SETTINGS_ENDPOINT, JSON.parse(raw));
156
+ const settings = JSON.parse(raw);
157
+ // Read the settings' current revision right before writing them, and send it back as
158
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
159
+ // settings in between, instead of this push silently overwriting it (#234).
160
+ const { revision } = await this.wp.get(SEO_SETTINGS_ENDPOINT);
161
+ await this.wp.put(SEO_SETTINGS_ENDPOINT, { expectedRevision: revision, settings });
132
162
  this.log(`Pushed: ${file}`);
133
163
  }
134
164
  catch (error) {
@@ -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('seo', {
3
+ description: 'Restore SEO settings, post meta, and redirects to a snapshot taken automatically before an earlier `lps seo push`',
4
+ pathNoun: 'SEO directory',
5
+ });
@@ -19,6 +19,7 @@ export default class Push extends PushCommand {
19
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  };
21
21
  run(): Promise<PushResult>;
22
+ private currentRevision;
22
23
  private ensureCanonicalFilename;
23
24
  private loadSnippets;
24
25
  private pushSnippet;
@@ -4,6 +4,8 @@ import { basename, dirname, extname, join } from 'node:path';
4
4
  import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { putOrCreate } from '../../lib/put-or-create.js';
7
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
9
  import { pluralize } from '../../utils/pluralize.js';
8
10
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
9
11
  import { toSlug } from '../../utils/to-slug.js';
@@ -24,6 +26,8 @@ export default class Push extends PushCommand {
24
26
  const path = this.resolveSnippetsPath(args.path);
25
27
  this.log(`Pushing snippets to ${url}`);
26
28
  this.log(`Snippets path: ${path}`);
29
+ const provider = getResourceStateProvider('snippet');
30
+ const beforeState = await this.captureBeforePushState(provider, path);
27
31
  const snippets = await this.loadSnippets(path);
28
32
  this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
29
33
  const pushed = [];
@@ -31,6 +35,7 @@ export default class Push extends PushCommand {
31
35
  const id = await this.pushSnippet(snippet, task);
32
36
  pushed.push({ id, name: snippet.name });
33
37
  });
38
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
34
39
  if (this.failedCount > 0) {
35
40
  this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
36
41
  }
@@ -40,6 +45,17 @@ export default class Push extends PushCommand {
40
45
  this.log('All snippets pushed.');
41
46
  return { pushed, status: 'success' };
42
47
  }
48
+ async currentRevision(id) {
49
+ try {
50
+ const current = await this.wp.get(`${SNIPPETS_ENDPOINT}/${id}`);
51
+ return normalizeSnippet(current).revision;
52
+ }
53
+ catch (error) {
54
+ if (isNotFoundError(error))
55
+ return undefined;
56
+ throw error;
57
+ }
58
+ }
43
59
  // Renames the local file pair to the `<id>-<slug>` convention used by `snippet pull` whenever
44
60
  // it doesn't already match (e.g. a hand-created `demo.php` with no id, or a stale slug after a rename).
45
61
  // This is a side effect of `push`: local files on disk are renamed, not just the remote snippet.
@@ -90,12 +106,26 @@ export default class Push extends PushCommand {
90
106
  }
91
107
  try {
92
108
  const payload = this.toPayload(snippet);
109
+ // Read the snippet's current revision right before writing it, and send it back as
110
+ // `expectedRevision`: WordPress refuses the write (412) if something else changed the
111
+ // snippet in between, instead of this push silently overwriting it (#234). No revision to
112
+ // condition on when there's no known remote id yet (a first push): putPayload then equals
113
+ // payload, and putOrCreate falls back to POST below exactly as before this existed. Kept
114
+ // out of the POST body specifically (see postPayload): a spurious expectedRevision on a
115
+ // create would be meaningless there.
116
+ let putPayload = payload;
117
+ if (snippet.id !== undefined) {
118
+ const expectedRevision = await this.currentRevision(snippet.id);
119
+ if (expectedRevision !== undefined)
120
+ putPayload = { ...payload, expectedRevision };
121
+ }
93
122
  // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
94
123
  // falls back to POST instead of failing, adopting whatever id the site assigns.
95
124
  const { body, created } = await putOrCreate(this.wp, {
96
125
  id: snippet.id ?? null,
97
- payload,
126
+ payload: putPayload,
98
127
  postEndpoint: SNIPPETS_ENDPOINT,
128
+ postPayload: payload,
99
129
  putEndpoint: (id) => `${SNIPPETS_ENDPOINT}/${id}`,
100
130
  });
101
131
  let { id } = snippet;
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;