@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
@@ -0,0 +1,72 @@
1
+ import { Args } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
6
+ import { MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../../utils/menu-format.js';
7
+ // The reserved local filename for menu locations (see pullLocations()): a menu genuinely slugged
8
+ // "menu-locations" would collide with it, so it's skipped rather than silently overwriting that file.
9
+ const LOCATIONS_FILE_BASENAME = 'menu-locations';
10
+ export default class Pull extends LoopressCommand {
11
+ static args = {
12
+ path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
13
+ };
14
+ static description = 'Pull nav menus and the active theme menu locations from WordPress';
15
+ static examples = ['$ lps menu pull'];
16
+ static flags = {
17
+ ...LoopressCommand.dryRunFlag,
18
+ ...LoopressCommand.yesFlag,
19
+ };
20
+ async run() {
21
+ const { args } = await this.parse(Pull);
22
+ const { url } = this.siteConfig;
23
+ const path = this.resolveMenuPath(args.path);
24
+ this.log(`Pulling nav menus from ${url}`);
25
+ this.log(`Menus path: ${path}`);
26
+ await this.pullMenus(path);
27
+ await this.pullLocations(path);
28
+ }
29
+ async pullLocations(basePath) {
30
+ const file = join(basePath, `${LOCATIONS_FILE_BASENAME}.json`);
31
+ const locations = await this.wp.get(MENU_LOCATIONS_ENDPOINT);
32
+ if (this.dryRun) {
33
+ this.log(`[dry-run] Would pull locations to ${file}`);
34
+ return;
35
+ }
36
+ await mkdir(basePath, { recursive: true });
37
+ await writeFile(file, JSON.stringify(locations, null, 2) + '\n');
38
+ this.log(`Pulled locations to ${file}`);
39
+ }
40
+ async pullMenus(dir) {
41
+ const fetched = await this.wp.get(MENU_ENDPOINT);
42
+ const remote = fetched.filter((menu) => {
43
+ if (menu.slug === LOCATIONS_FILE_BASENAME) {
44
+ this.warn(`Skipping menu "${menu.slug}": this filename is reserved for ${LOCATIONS_FILE_BASENAME}.json`);
45
+ return false;
46
+ }
47
+ return true;
48
+ });
49
+ const orphans = await findOrphanedFiles(dir, new Set(remote.map((menu) => menu.slug)), {
50
+ extensions: ['.json'],
51
+ key: (base) => (base === LOCATIONS_FILE_BASENAME ? null : basenameKey(base)),
52
+ });
53
+ await this.pullDirectory(dir, remote, orphans, {
54
+ dryRunMessage: `Would pull ${remote.length} menu(s) to ${dir}`,
55
+ orphanReason: `in ${dir} no longer present on WordPress`,
56
+ pulledMessage: `Pulled ${remote.length} menu(s) to ${dir}`,
57
+ title: (menu) => menu.slug,
58
+ // revision/warnings are server bookkeeping, never part of the tracked configuration (see
59
+ // their doc comments on Menu in menu-format.ts): never persisted locally.
60
+ async write(menu, writeDir) {
61
+ const local = { items: menu.items, name: menu.name, slug: menu.slug };
62
+ await writeFile(join(writeDir, `${menu.slug}.json`), JSON.stringify(local, null, 2) + '\n');
63
+ },
64
+ });
65
+ if (this.dryRun)
66
+ return;
67
+ for (const menu of remote) {
68
+ for (const warning of menu.warnings)
69
+ this.warn(`${menu.slug}: ${warning}`);
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,17 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ private currentRevision;
14
+ private pushLocations;
15
+ private pushMenuFile;
16
+ private pushMenus;
17
+ }
@@ -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;
@@ -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-external-redirects': 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 allowExternalRedirects;
12
14
  run(): Promise<void>;
15
+ private currentPostMetaRevision;
13
16
  private jsonFilesIn;
14
17
  private pushPostMeta;
15
18
  private pushPostMetaFile;