@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
@@ -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
+ });
@@ -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('page', {
3
+ description: 'Show what differs in static pages between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'pages directory',
5
+ });
@@ -0,0 +1,8 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type RemotePage } from '../../utils/page-format.js';
3
+ export default class List extends LoopressCommand {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ run(): Promise<Array<Omit<RemotePage, 'html'>>>;
8
+ }
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { PAGES_ENDPOINT } from '../../utils/page-format.js';
3
+ import { pluralize } from '../../utils/pluralize.js';
4
+ export default class List extends LoopressCommand {
5
+ static description = 'List the static pages managed by Loopress on WordPress';
6
+ static enableJsonFlag = true;
7
+ static examples = ['$ lps page list'];
8
+ async run() {
9
+ const pages = await this.wp.get(PAGES_ENDPOINT);
10
+ // The HTML is only needed by `page diff`, it would drown the JSON output here.
11
+ const listed = pages.map(({ html: _html, ...page }) => page);
12
+ if (listed.length === 0) {
13
+ this.log('No pages managed by Loopress');
14
+ return listed;
15
+ }
16
+ this.log(`Found ${pluralize(listed.length, 'page')}:`);
17
+ this.log('');
18
+ for (const page of listed)
19
+ this.log(` ${page.slug} ${page.status} ${page.link}`);
20
+ return listed;
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ pushed: string[];
4
+ status: 'dry-run' | 'success';
5
+ };
6
+ export default class Push extends PushCommand {
7
+ static args: {
8
+ slug: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
9
+ };
10
+ static description: string;
11
+ static enableJsonFlag: boolean;
12
+ static examples: string[];
13
+ static flags: {
14
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<PushResult>;
18
+ private pushPage;
19
+ }
20
+ export {};
@@ -0,0 +1,60 @@
1
+ import { Args } from '@oclif/core';
2
+ import { PushCommand } from '../../lib/push-command.js';
3
+ import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
4
+ import { pluralize } from '../../utils/pluralize.js';
5
+ import { resolveResourceDir } from '../../utils/resource-dirs.js';
6
+ export default class Push extends PushCommand {
7
+ static args = {
8
+ slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
9
+ };
10
+ static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
11
+ static enableJsonFlag = true;
12
+ static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
13
+ static flags = {
14
+ ...PushCommand.dryRunFlag,
15
+ ...PushCommand.yesFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Push);
19
+ const path = resolveResourceDir('page', this.localConfig);
20
+ this.log(`Pushing pages to ${this.siteConfig.url}`);
21
+ this.log(`Pages path: ${path}`);
22
+ const { pages, problems } = await readLocalPages(path);
23
+ if (problems.length > 0) {
24
+ this.error(`Nothing was pushed, fix these pages first:\n ${formatPageProblems(problems)}`);
25
+ }
26
+ const selected = args.slug === undefined ? pages : pages.filter((page) => page.slug === args.slug);
27
+ if (args.slug !== undefined && selected.length === 0) {
28
+ this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
29
+ }
30
+ this.log(`Found ${pluralize(selected.length, 'page')} to push`);
31
+ const pushed = [];
32
+ await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
33
+ await this.pushPage(page, task);
34
+ pushed.push(page.slug);
35
+ });
36
+ if (this.failedCount > 0) {
37
+ this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
38
+ }
39
+ if (this.dryRun)
40
+ return { pushed, status: 'dry-run' };
41
+ await this.recordSuccess();
42
+ this.log('All pages pushed.');
43
+ return { pushed, status: 'success' };
44
+ }
45
+ async pushPage(page, task) {
46
+ if (this.dryRun) {
47
+ if (task)
48
+ task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
49
+ return;
50
+ }
51
+ try {
52
+ const result = await this.wp.put(PAGES_ENDPOINT, page);
53
+ if (task)
54
+ task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}`;
55
+ }
56
+ catch (error) {
57
+ this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
58
+ }
59
+ }
60
+ }
@@ -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 {};
@@ -10,12 +10,15 @@ const PUSH_TARGETS = [
10
10
  { commandId: 'api:push', label: 'API routes' },
11
11
  { commandId: 'hook:push', label: 'hooks' },
12
12
  { commandId: 'form:push', label: 'forms' },
13
+ { commandId: 'page:push', label: 'pages' },
13
14
  { commandId: 'seo:push', label: 'SEO' },
15
+ { commandId: 'menu:push', label: 'menus' },
14
16
  { commandId: 'option:push', label: 'options' },
15
17
  { commandId: 'snippet:push', label: 'snippets' },
16
18
  ];
17
19
  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';
20
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
21
+ static enableJsonFlag = true;
19
22
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
20
23
  static flags = {
21
24
  ...LoopressCommand.dryRunFlag,
@@ -25,21 +28,26 @@ export default class Push extends LoopressCommand {
25
28
  async run() {
26
29
  await this.guardProductionPush();
27
30
  const argv = this.buildArgv();
31
+ const results = [];
28
32
  for (const target of PUSH_TARGETS) {
29
33
  this.log(`\n→ Pushing ${target.label}...`);
30
34
  try {
31
35
  await this.config.runCommand(target.commandId, argv);
32
36
  this.log(`✓ ${target.label} pushed`);
37
+ results.push({ label: target.label, status: 'pushed' });
33
38
  }
34
39
  catch (error) {
35
40
  this.failedCount++;
36
- this.log(`✗ ${target.label} failed: ${error.message}`);
41
+ const { message } = error;
42
+ this.log(`✗ ${target.label} failed: ${message}`);
43
+ results.push({ error: message, label: target.label, status: 'failed' });
37
44
  }
38
45
  }
39
46
  if (this.failedCount > 0) {
40
47
  this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
41
48
  }
42
49
  this.log('\nAll resources pushed.');
50
+ return { results };
43
51
  }
44
52
  // Every delegated push always gets --yes: either this command's own guard already confirmed
45
53
  // 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;