@loopress/cli 0.27.0 → 0.29.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 (124) hide show
  1. package/README.md +448 -189
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/pull.js +17 -4
  9. package/dist/commands/app/push.d.ts +3 -0
  10. package/dist/commands/app/push.js +30 -6
  11. package/dist/commands/composer/push.js +3 -21
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/form/pull.d.ts +9 -1
  14. package/dist/commands/form/pull.js +3 -1
  15. package/dist/commands/form/push.d.ts +7 -1
  16. package/dist/commands/form/push.js +3 -1
  17. package/dist/commands/init.js +6 -36
  18. package/dist/commands/menu/pull.d.ts +7 -1
  19. package/dist/commands/menu/pull.js +2 -0
  20. package/dist/commands/menu/push.d.ts +7 -1
  21. package/dist/commands/menu/push.js +3 -1
  22. package/dist/commands/option/pull.js +4 -4
  23. package/dist/commands/option/push.js +2 -0
  24. package/dist/commands/page/push.js +3 -0
  25. package/dist/commands/plugin/add.js +6 -3
  26. package/dist/commands/plugin/audit.js +4 -5
  27. package/dist/commands/plugin/pull.d.ts +7 -1
  28. package/dist/commands/plugin/pull.js +68 -14
  29. package/dist/commands/plugin/push.d.ts +5 -1
  30. package/dist/commands/plugin/push.js +92 -38
  31. package/dist/commands/plugin/status.d.ts +1 -0
  32. package/dist/commands/plugin/status.js +21 -2
  33. package/dist/commands/project/config.js +12 -8
  34. package/dist/commands/promote.d.ts +2 -0
  35. package/dist/commands/promote.js +69 -8
  36. package/dist/commands/pull.js +2 -2
  37. package/dist/commands/push.d.ts +1 -0
  38. package/dist/commands/push.js +40 -5
  39. package/dist/commands/seo/pull.d.ts +8 -1
  40. package/dist/commands/seo/pull.js +2 -0
  41. package/dist/commands/seo/push.d.ts +7 -1
  42. package/dist/commands/seo/push.js +3 -1
  43. package/dist/commands/snippet/publish.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.js +1 -1
  46. package/dist/commands/status.d.ts +1 -0
  47. package/dist/commands/status.js +4 -4
  48. package/dist/commands/theme/pull.d.ts +6 -8
  49. package/dist/commands/theme/pull.js +30 -26
  50. package/dist/commands/theme/push.d.ts +8 -9
  51. package/dist/commands/theme/push.js +54 -111
  52. package/dist/commands/theme/style/diff.d.ts +2 -0
  53. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  54. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
  55. package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
  56. package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
  57. package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
  58. package/dist/commands/theme/style/rollback.d.ts +2 -0
  59. package/dist/commands/theme/style/rollback.js +6 -0
  60. package/dist/commands/theme/template/diff.d.ts +10 -0
  61. package/dist/commands/theme/template/diff.js +13 -0
  62. package/dist/commands/theme/template/list.d.ts +13 -0
  63. package/dist/commands/theme/template/list.js +23 -0
  64. package/dist/commands/theme/template/push.d.ts +20 -0
  65. package/dist/commands/theme/template/push.js +44 -0
  66. package/dist/commands/theme/version/pull.d.ts +23 -0
  67. package/dist/commands/theme/version/pull.js +43 -0
  68. package/dist/commands/theme/version/push.d.ts +22 -0
  69. package/dist/commands/theme/version/push.js +123 -0
  70. package/dist/hooks/finally.d.ts +2 -1
  71. package/dist/hooks/finally.js +27 -4
  72. package/dist/lib/api-client.js +3 -1
  73. package/dist/lib/app-build.d.ts +5 -0
  74. package/dist/lib/app-build.js +69 -0
  75. package/dist/lib/app-manifest.d.ts +4 -0
  76. package/dist/lib/app-manifest.js +10 -2
  77. package/dist/lib/base.d.ts +1 -0
  78. package/dist/lib/base.js +14 -2
  79. package/dist/lib/bootstrap-full-install.js +45 -14
  80. package/dist/lib/dev-watch.d.ts +1 -1
  81. package/dist/lib/dev-watch.js +3 -1
  82. package/dist/lib/diff-command.d.ts +1 -0
  83. package/dist/lib/diff-command.js +6 -4
  84. package/dist/lib/diff-state.js +1 -0
  85. package/dist/lib/github-release.js +0 -1
  86. package/dist/lib/local-callback-server.d.ts +12 -6
  87. package/dist/lib/local-callback-server.js +21 -22
  88. package/dist/lib/php-files-command.js +3 -1
  89. package/dist/lib/push-command.d.ts +5 -0
  90. package/dist/lib/push-command.js +30 -8
  91. package/dist/lib/resource-state.js +47 -4
  92. package/dist/lib/rollback-command.d.ts +1 -0
  93. package/dist/lib/rollback-command.js +8 -6
  94. package/dist/lib/rotate-app-password.js +2 -2
  95. package/dist/lib/snapshot-store.js +2 -2
  96. package/dist/lib/validate-local.js +6 -0
  97. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  98. package/dist/lib/wp-authorize-flow.js +24 -28
  99. package/dist/lib/wp-client.d.ts +4 -1
  100. package/dist/lib/wp-client.js +25 -1
  101. package/dist/lib/wp-site-diagnostic.js +37 -0
  102. package/dist/types/plugin.d.ts +8 -1
  103. package/dist/types/plugin.js +4 -1
  104. package/dist/types/project-config.generated.d.ts +19 -2
  105. package/dist/utils/composer.d.ts +16 -0
  106. package/dist/utils/composer.js +144 -0
  107. package/dist/utils/option-format.d.ts +3 -1
  108. package/dist/utils/option-format.js +14 -3
  109. package/dist/utils/page-format.d.ts +12 -0
  110. package/dist/utils/page-format.js +40 -28
  111. package/dist/utils/plugin-sync.js +1 -1
  112. package/dist/utils/plugins.d.ts +9 -4
  113. package/dist/utils/plugins.js +16 -7
  114. package/dist/utils/resource-dirs.d.ts +2 -0
  115. package/dist/utils/resource-dirs.js +6 -2
  116. package/dist/utils/seo-format.d.ts +2 -2
  117. package/dist/utils/template-format.d.ts +31 -0
  118. package/dist/utils/template-format.js +43 -0
  119. package/oclif.manifest.json +841 -63
  120. package/package.json +24 -17
  121. package/schemas/project-config.schema.json +25 -4
  122. package/dist/commands/theme-styles/diff.d.ts +0 -2
  123. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  124. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -1,16 +1,21 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Args, Command } from '@oclif/core';
3
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins -- stable since 22.3, works unflagged on 22.0+
4
+ import { cp, mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
7
  import { configManager } from '../config/project-config.manager.js';
4
8
  import { LoopressCommand } from '../lib/base.js';
5
9
  import { isInteractive } from '../lib/interactive.js';
6
10
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
11
  import { readLocalConfig } from '../utils/loopress-config.js';
12
+ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
13
  export default class Promote extends Command {
9
14
  static args = {
10
15
  from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
11
16
  to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
12
17
  };
13
- 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.';
18
+ 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. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
14
19
  static enableJsonFlag = true;
15
20
  static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
16
21
  static flags = {
@@ -29,19 +34,55 @@ export default class Promote extends Command {
29
34
  }
30
35
  if (!dryRun)
31
36
  await this.confirmPromotion(from.name, to.name, to.url, yes);
32
- this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
37
+ // A dry-run pull writes nothing, so a dry-run push right after would preview the current
38
+ // local files instead of <from>. Instead, really pull into a throwaway copy of the project
39
+ // and dry-run the push from there: the preview is what a real promotion would push, and the
40
+ // user's own files stay untouched.
41
+ const originalCwd = process.cwd();
42
+ const scratch = dryRun ? await this.enterScratchCopy(originalCwd) : undefined;
43
+ // The delegated pull is real, so without this its auto-rotation could revoke a stale app
44
+ // password during what the user asked to be a preview (see LoopressCommand.maybeAutoRotate).
45
+ const previousNoRotate = process.env.LOOPRESS_NO_AUTO_ROTATE;
46
+ if (scratch)
47
+ process.env.LOOPRESS_NO_AUTO_ROTATE = '1';
33
48
  try {
34
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, dryRun)));
49
+ this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
50
+ try {
51
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
52
+ }
53
+ catch (error) {
54
+ // A partial pull must never be pushed onward: stop before touching <to>.
55
+ this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
56
+ }
57
+ this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
58
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
35
59
  }
36
- catch (error) {
37
- // A partial pull must never be pushed onward: stop before touching <to>.
38
- this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
60
+ finally {
61
+ if (scratch) {
62
+ if (previousNoRotate === undefined)
63
+ delete process.env.LOOPRESS_NO_AUTO_ROTATE;
64
+ else
65
+ process.env.LOOPRESS_NO_AUTO_ROTATE = previousNoRotate;
66
+ process.chdir(originalCwd);
67
+ await rm(scratch, { force: true, recursive: true });
68
+ }
39
69
  }
40
- this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
41
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
42
70
  this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
43
71
  return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
44
72
  }
73
+ // The scratch pull writes wherever loopress.json points. An absolute or `../` rootDir or
74
+ // resource dir would land outside the copy, in the user's real files, during a dry run.
75
+ async assertDirsInsideProject(projectDir) {
76
+ const localConfig = await readLocalConfig();
77
+ const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
78
+ const outside = dirs.find((dir) => {
79
+ const rel = relative(projectDir, resolve(projectDir, dir));
80
+ return rel.startsWith('..') || isAbsolute(rel);
81
+ });
82
+ if (outside !== undefined) {
83
+ this.error(`--dry-run can't preview safely: "${outside}" in loopress.json is outside the project. Run \`lps diff --env <from> --against <to>\` instead.`);
84
+ }
85
+ }
45
86
  // Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
46
87
  // target, not just production, so it needs its own confirmation regardless of the name. That
47
88
  // prompt also calls out production when relevant, making a second production-only guard
@@ -70,6 +111,26 @@ export default class Promote extends Command {
70
111
  argv.push('--dry-run');
71
112
  return argv;
72
113
  }
114
+ // Copies the whole project (not just what `pull` rewrites): push also reads files pull never
115
+ // touches, and a real promotion pushes those as they are locally. node_modules and .git are
116
+ // never read by push. Returns the scratch root, cwd is left inside the copy.
117
+ async enterScratchCopy(projectDir) {
118
+ await this.assertDirsInsideProject(projectDir);
119
+ const scratch = await mkdtemp(join(tmpdir(), 'lps-promote-'));
120
+ try {
121
+ const copy = join(scratch, basename(projectDir));
122
+ await cp(projectDir, copy, {
123
+ filter: (source) => !['.git', 'node_modules'].includes(basename(source)),
124
+ recursive: true,
125
+ });
126
+ process.chdir(copy);
127
+ }
128
+ catch (error) {
129
+ await rm(scratch, { force: true, recursive: true });
130
+ throw error;
131
+ }
132
+ return scratch;
133
+ }
73
134
  requireEnvironment(environments, projectName, name) {
74
135
  const env = environments[name];
75
136
  if (!env) {
@@ -1,8 +1,8 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
2
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
3
  import { pluralize } from '../utils/pluralize.js';
4
- // composer before plugins: `plugin:pull` reads the local composer.json to skip
5
- // Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
4
+ // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
+ // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
6
6
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
7
7
  const PULL_TARGETS = [
8
8
  { commandId: 'composer:pull', label: 'composer' },
@@ -18,6 +18,7 @@ export default class Push extends LoopressCommand {
18
18
  private failedCount;
19
19
  run(): Promise<PushResult>;
20
20
  private buildArgv;
21
+ private composerDeclaresPlugins;
21
22
  private guardProductionPush;
22
23
  }
23
24
  export {};
@@ -1,6 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { LoopressCommand } from '../lib/base.js';
2
5
  import { guardProductionPush } from '../lib/guard-production-push.js';
3
6
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
+ import { isLocalEnvironment } from '../lib/wp-client.js';
8
+ import { wpackagistRequire } from '../utils/composer.js';
4
9
  import { pluralize } from '../utils/pluralize.js';
5
10
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
6
11
  // relies on, then content itself.
@@ -11,6 +16,8 @@ const PUSH_TARGETS = [
11
16
  { commandId: 'api:push', label: 'API routes' },
12
17
  { commandId: 'hook:push', label: 'hooks' },
13
18
  { commandId: 'form:push', label: 'forms' },
19
+ // Before pages: WordPress refuses a page whose `template` it doesn't know yet.
20
+ { commandId: 'theme:template:push', label: 'templates and parts' },
14
21
  { commandId: 'page:push', label: 'pages' },
15
22
  { commandId: 'seo:push', label: 'SEO' },
16
23
  { commandId: 'menu:push', label: 'menus' },
@@ -18,7 +25,7 @@ const PUSH_TARGETS = [
18
25
  { commandId: 'snippet:push', label: 'snippets' },
19
26
  ];
20
27
  export default class Push extends LoopressCommand {
21
- static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
28
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, templates, pages, SEO, menus, options, and snippets, to WordPress';
22
29
  static enableJsonFlag = true;
23
30
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
24
31
  static flags = {
@@ -30,11 +37,17 @@ export default class Push extends LoopressCommand {
30
37
  await this.guardProductionPush();
31
38
  const argv = this.buildArgv();
32
39
  const results = [];
40
+ const composerPushedByPlugins = await this.composerDeclaresPlugins();
33
41
  for (const target of PUSH_TARGETS) {
42
+ if (composerPushedByPlugins && target.commandId === 'composer:push') {
43
+ this.log('\n→ composer: already pushed whole by plugins');
44
+ results.push({ label: target.label, status: 'pushed' });
45
+ continue;
46
+ }
34
47
  this.log(`\n→ Pushing ${target.label}...`);
35
48
  try {
36
49
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
37
- this.log(`✓ ${target.label} pushed`);
50
+ this.log(this.dryRun ? `✓ ${target.label} would push` : `✓ ${target.label} pushed`);
38
51
  results.push({ label: target.label, status: 'pushed' });
39
52
  }
40
53
  catch (error) {
@@ -47,10 +60,12 @@ export default class Push extends LoopressCommand {
47
60
  if (this.failedCount > 0) {
48
61
  // Each failure's own reason, not just the count: under --json the per-resource lines above
49
62
  // never reach stdout, and this message is all an MCP caller or a script gets to see.
50
- const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
63
+ const failures = results
64
+ .filter((result) => result.status === 'failed')
65
+ .map((result) => `${result.label}: ${result.error}`);
51
66
  this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
52
67
  }
53
- this.log('\nAll resources pushed.');
68
+ this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
54
69
  return { results };
55
70
  }
56
71
  // Every delegated push always gets --yes: either this command's own guard already confirmed
@@ -62,9 +77,29 @@ export default class Push extends LoopressCommand {
62
77
  argv.push('--dry-run');
63
78
  return argv;
64
79
  }
80
+ // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
81
+ // safety), so `composer:push` would only run Composer on the server a second time.
82
+ async composerDeclaresPlugins() {
83
+ const path = join(process.cwd(), this.rootDir, 'composer.json');
84
+ if (!existsSync(path))
85
+ return false;
86
+ try {
87
+ const composerJson = JSON.parse(await readFile(path, 'utf8'));
88
+ return Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(this.siteConfig.name))).length > 0;
89
+ }
90
+ catch {
91
+ // Unreadable: let both commands run and report the parse error themselves.
92
+ return false;
93
+ }
94
+ }
65
95
  // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
66
96
  // delegated command.
67
97
  async guardProductionPush() {
68
- await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
98
+ await guardProductionPush({
99
+ dryRun: this.dryRun,
100
+ error: (message) => this.error(message),
101
+ siteConfig: this.siteConfig,
102
+ yes: this.yes,
103
+ });
69
104
  }
70
105
  }
@@ -1,17 +1,24 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type SeoPullResult = {
3
+ path: string;
4
+ postTypes: string[];
5
+ status: 'dry-run' | 'success';
6
+ };
2
7
  export default class Pull extends LoopressCommand {
3
8
  static args: {
4
9
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
10
  };
6
11
  static description: string;
12
+ static enableJsonFlag: boolean;
7
13
  static examples: string[];
8
14
  static flags: {
9
15
  'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
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<SeoPullResult>;
14
20
  private pullPostMeta;
15
21
  private pullRedirects;
16
22
  private pullSettings;
17
23
  }
24
+ export {};
@@ -9,6 +9,7 @@ export default class Pull extends LoopressCommand {
9
9
  path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
10
10
  };
11
11
  static description = 'Pull SEO settings, post meta, and (if supported) redirects from WordPress';
12
+ static enableJsonFlag = true;
12
13
  static examples = ['$ lps seo pull', '$ lps seo pull --post-type post --post-type page'];
13
14
  static flags = {
14
15
  ...LoopressCommand.dryRunFlag,
@@ -27,6 +28,7 @@ export default class Pull extends LoopressCommand {
27
28
  await this.pullPostMeta(postType, path);
28
29
  }
29
30
  await this.pullRedirects(path);
31
+ return { path, postTypes, status: this.dryRun ? 'dry-run' : 'success' };
30
32
  }
31
33
  async pullPostMeta(postType, basePath) {
32
34
  const dir = join(basePath, 'post-meta', postType);
@@ -1,9 +1,14 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ type SeoPushResult = {
3
+ path: string;
4
+ status: 'dry-run' | 'success';
5
+ };
2
6
  export default class Push extends PushCommand {
3
7
  static args: {
4
8
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
9
  };
6
10
  static description: string;
11
+ static enableJsonFlag: boolean;
7
12
  static examples: string[];
8
13
  static flags: {
9
14
  'allow-external-redirects': import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -11,7 +16,7 @@ export default class Push extends PushCommand {
11
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
17
  };
13
18
  private allowExternalRedirects;
14
- run(): Promise<void>;
19
+ run(): Promise<SeoPushResult>;
15
20
  private currentPostMetaRevision;
16
21
  private jsonFilesIn;
17
22
  private pushPostMeta;
@@ -21,3 +26,4 @@ export default class Push extends PushCommand {
21
26
  private pushSettings;
22
27
  private renameToCanonical;
23
28
  }
29
+ export {};
@@ -13,6 +13,7 @@ export default class Push extends PushCommand {
13
13
  path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
14
14
  };
15
15
  static description = 'Push SEO settings, post meta, and redirects to WordPress. Local redirect files created remotely are renamed on disk to the `<id>-<slug>` convention. Fails clearly per file if the active SEO plugin does not support redirects.';
16
+ static enableJsonFlag = true;
16
17
  static examples = ['$ lps seo push', '$ lps seo push --allow-external-redirects'];
17
18
  static flags = {
18
19
  ...PushCommand.dryRunFlag,
@@ -40,9 +41,10 @@ export default class Push extends PushCommand {
40
41
  this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
41
42
  }
42
43
  if (this.dryRun)
43
- return;
44
+ return { path, status: 'dry-run' };
44
45
  await this.recordSuccess();
45
46
  this.log('All SEO configuration pushed.');
47
+ return { path, status: 'success' };
46
48
  }
47
49
  async currentPostMetaRevision(postType, slug) {
48
50
  try {
@@ -14,7 +14,7 @@ export default class Publish extends Command {
14
14
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
15
15
  };
16
16
  static description = 'Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.';
17
- static examples = ['$ lps snippet publish', '$ lps snippet publish --path ./snippets'];
17
+ static examples = ['$ lps snippet publish', '$ lps snippet publish ./snippets'];
18
18
  async run() {
19
19
  const { args } = await this.parse(Publish);
20
20
  const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
@@ -19,7 +19,7 @@ export default class Pull extends LoopressCommand {
19
19
  };
20
20
  static description = 'Pull snippets from WordPress';
21
21
  static enableJsonFlag = true;
22
- static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets'];
22
+ static examples = ['$ lps snippet pull', '$ lps snippet pull ./snippets'];
23
23
  static flags = {
24
24
  ...LoopressCommand.dryRunFlag,
25
25
  ...LoopressCommand.yesFlag,
@@ -15,7 +15,7 @@ export default class Push extends PushCommand {
15
15
  };
16
16
  static description = 'Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
17
17
  static enableJsonFlag = true;
18
- static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'];
18
+ static examples = ['$ lps snippet push', '$ lps snippet push ./snippets'];
19
19
  static flags = {
20
20
  ...PushCommand.dryRunFlag,
21
21
  ...PushCommand.yesFlag,
@@ -1,5 +1,6 @@
1
1
  import { Command } from '@oclif/core';
2
2
  type StatusInfo = {
3
+ environment?: string;
3
4
  environments?: string[];
4
5
  note?: string;
5
6
  project?: string;
@@ -37,7 +37,7 @@ export default class Status extends Command {
37
37
  }
38
38
  this.log(`Project: ${project.name} (${env.name})`);
39
39
  this.log(`URL: ${env.url}`);
40
- return { project: `${project.name} (${env.name})`, url: env.url };
40
+ return { environment: env.name, project: `${project.name} (${env.name})`, url: env.url };
41
41
  }
42
42
  // Mirrors base.ts:resolveEnvironment with --env: the targeted project comes from
43
43
  // loopress.json when pinned, from the globally active project otherwise.
@@ -54,7 +54,7 @@ export default class Status extends Command {
54
54
  }
55
55
  this.log(`Project: ${project.name} (${env.name}, via --env)`);
56
56
  this.log(`URL: ${env.url}`);
57
- return { project: `${project.name} (${env.name}, via --env)`, url: env.url };
57
+ return { environment: env.name, project: `${project.name} (${env.name}, via --env)`, url: env.url };
58
58
  }
59
59
  reportPinnedProject(projectId) {
60
60
  const project = configManager.getProject(projectId);
@@ -75,7 +75,7 @@ export default class Status extends Command {
75
75
  const env = project.environments[envNames[0]];
76
76
  this.log(`Project: ${project.name} (${env.name})`);
77
77
  this.log(`URL: ${env.url}`);
78
- return { project: `${project.name} (${env.name})`, url: env.url };
78
+ return { environment: env.name, project: `${project.name} (${env.name})`, url: env.url };
79
79
  }
80
80
  const current = configManager.getCurrentProject();
81
81
  const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
@@ -93,6 +93,6 @@ export default class Status extends Command {
93
93
  }
94
94
  this.log(`Project: ${project.name} (${currentEnv.name})`);
95
95
  this.log(`URL: ${currentEnv.url}`);
96
- return { project: `${project.name} (${currentEnv.name})`, url: currentEnv.url };
96
+ return { environment: currentEnv.name, project: `${project.name} (${currentEnv.name})`, url: currentEnv.url };
97
97
  }
98
98
  }
@@ -1,13 +1,11 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type PullTargetResult = {
3
+ error?: string;
4
+ label: string;
5
+ status: 'failed' | 'pulled';
6
+ };
2
7
  type PullResult = {
3
- added: string[];
4
- merged: Record<string, string>;
5
- status: 'composer-managed' | 'dry-run' | 'success';
6
- updated: Array<{
7
- from: string;
8
- slug: string;
9
- to: string;
10
- }>;
8
+ results: PullTargetResult[];
11
9
  };
12
10
  export default class Pull extends LoopressCommand {
13
11
  static description: string;
@@ -1,37 +1,41 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { LoopressCommand } from '../../lib/base.js';
4
- import { writeLocalConfig } from '../../utils/loopress-config.js';
5
- import { mergePluginManifest } from '../../utils/plugins.js';
6
- import { parseInstalledThemes } from '../../utils/themes.js';
2
+ import { stdoutToStderr } from '../../lib/json-delegation.js';
3
+ import { pluralize } from '../../utils/pluralize.js';
4
+ // Templates and parts have no pull: the child theme is written from the project, never read back.
5
+ const PULL_TARGETS = [
6
+ { commandId: 'theme:version:pull', label: 'theme versions' },
7
+ { commandId: 'theme:style:pull', label: 'Global Styles' },
8
+ ];
7
9
  export default class Pull extends LoopressCommand {
8
- static description = 'Pull installed themes from WordPress into loopress.json, pinned to their live versions';
10
+ static description = 'Pull everything theme related from WordPress: theme versions and Global Styles';
9
11
  static enableJsonFlag = true;
10
- static examples = ['$ lps theme pull'];
12
+ static examples = ['$ lps theme pull', '$ lps theme pull --env staging', '$ lps theme pull --dry-run'];
11
13
  static flags = {
12
14
  ...LoopressCommand.dryRunFlag,
13
15
  };
14
16
  async run() {
15
- const { url } = this.siteConfig;
16
- if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
17
- this.warn('This project has a composer.json, which is authoritative. Run `lps composer pull` instead.');
18
- return { added: [], merged: this.localConfig.themes ?? {}, status: 'composer-managed', updated: [] };
17
+ const argv = ['--env', this.siteConfig.name];
18
+ if (this.dryRun)
19
+ argv.push('--dry-run');
20
+ const results = [];
21
+ for (const target of PULL_TARGETS) {
22
+ this.log(`\n→ Pulling ${target.label}...`);
23
+ try {
24
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
25
+ this.log(`✓ ${target.label} pulled`);
26
+ results.push({ label: target.label, status: 'pulled' });
27
+ }
28
+ catch (error) {
29
+ const { message } = error;
30
+ this.log(`✗ ${target.label} failed: ${message}`);
31
+ results.push({ error: message, label: target.label, status: 'failed' });
32
+ }
19
33
  }
20
- this.log(`Pulling themes from ${url}`);
21
- const raw = await this.wp.get('wp/v2/themes');
22
- const installed = parseInstalledThemes(raw);
23
- const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
24
- const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
25
- if (this.dryRun) {
26
- this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
27
- return { added, merged, status: 'dry-run', updated };
34
+ const failures = results.filter((result) => result.status === 'failed');
35
+ if (failures.length > 0) {
36
+ const reasons = failures.map((result) => `${result.label}: ${result.error}`);
37
+ this.error(`${pluralize(failures.length, 'theme resource')} failed to pull. ${reasons.join('; ')}`);
28
38
  }
29
- await writeLocalConfig({ ...this.localConfig, themes: merged });
30
- this.log(`Wrote ${Object.keys(merged).length} themes to loopress.json`);
31
- if (added.length > 0)
32
- this.log(` + Added: ${added.join(', ')}`);
33
- for (const u of updated)
34
- this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
35
- return { added, merged, status: 'success', updated };
39
+ return { results };
36
40
  }
37
41
  }
@@ -1,11 +1,13 @@
1
- import { PushCommand } from '../../lib/push-command.js';
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type PushTargetResult = {
3
+ error?: string;
4
+ label: string;
5
+ status: 'failed' | 'pushed';
6
+ };
2
7
  type PushResult = {
3
- installed: string[];
4
- pinned: string[];
5
- removed: string[];
6
- status: 'composer-managed' | 'dry-run' | 'in-sync' | 'success';
8
+ results: PushTargetResult[];
7
9
  };
8
- export default class Push extends PushCommand {
10
+ export default class Push extends LoopressCommand {
9
11
  static description: string;
10
12
  static enableJsonFlag: boolean;
11
13
  static examples: string[];
@@ -15,8 +17,5 @@ export default class Push extends PushCommand {
15
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  };
17
19
  run(): Promise<PushResult>;
18
- private fetchInstanceLock;
19
- private guardForce;
20
- private sync;
21
20
  }
22
21
  export {};