@loopress/cli 0.32.2 → 0.34.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 (166) hide show
  1. package/README.md +549 -179
  2. package/dist/commands/acf/list.d.ts +1 -1
  3. package/dist/commands/acf/list.js +3 -4
  4. package/dist/commands/acf/pull.d.ts +1 -1
  5. package/dist/commands/acf/pull.js +9 -6
  6. package/dist/commands/acf/push.d.ts +1 -1
  7. package/dist/commands/acf/push.js +5 -4
  8. package/dist/commands/api/publish.js +2 -1
  9. package/dist/commands/app/pull.js +2 -1
  10. package/dist/commands/app/push.js +7 -5
  11. package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
  12. package/dist/commands/app/{remove.js → rm.js} +2 -1
  13. package/dist/commands/block/diff.d.ts +2 -0
  14. package/dist/commands/block/diff.js +5 -0
  15. package/dist/commands/block/list.d.ts +8 -0
  16. package/dist/commands/block/list.js +23 -0
  17. package/dist/commands/block/pull.d.ts +21 -0
  18. package/dist/commands/block/pull.js +67 -0
  19. package/dist/commands/block/push.d.ts +20 -0
  20. package/dist/commands/block/push.js +53 -0
  21. package/dist/commands/block/rm.d.ts +23 -0
  22. package/dist/commands/block/rm.js +69 -0
  23. package/dist/commands/block/rollback.d.ts +2 -0
  24. package/dist/commands/block/rollback.js +5 -0
  25. package/dist/commands/composer/init.js +1 -0
  26. package/dist/commands/composer/push.js +5 -4
  27. package/dist/commands/content/copy.d.ts +3 -0
  28. package/dist/commands/content/copy.js +79 -2
  29. package/dist/commands/dev.d.ts +2 -2
  30. package/dist/commands/dev.js +13 -18
  31. package/dist/commands/diff.d.ts +6 -3
  32. package/dist/commands/diff.js +63 -13
  33. package/dist/commands/doctor.d.ts +4 -2
  34. package/dist/commands/doctor.js +49 -25
  35. package/dist/commands/form/pull.js +7 -5
  36. package/dist/commands/form/push.js +4 -3
  37. package/dist/commands/init.js +4 -3
  38. package/dist/commands/menu/push.js +8 -8
  39. package/dist/commands/option/list.js +5 -2
  40. package/dist/commands/option/push.js +3 -1
  41. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  42. package/dist/commands/option/{remove.js → rm.js} +2 -1
  43. package/dist/commands/page/push.js +3 -1
  44. package/dist/commands/plugin/add.d.ts +1 -0
  45. package/dist/commands/plugin/add.js +50 -9
  46. package/dist/commands/plugin/audit.d.ts +4 -4
  47. package/dist/commands/plugin/audit.js +35 -15
  48. package/dist/commands/plugin/pull.js +3 -2
  49. package/dist/commands/plugin/push.js +41 -6
  50. package/dist/commands/plugin/status.js +3 -1
  51. package/dist/commands/project/config.js +7 -7
  52. package/dist/commands/project/pull.js +3 -1
  53. package/dist/commands/project/push.js +6 -4
  54. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  55. package/dist/commands/project/{remove.js → rm.js} +3 -2
  56. package/dist/commands/project/switch.js +6 -4
  57. package/dist/commands/promote.d.ts +1 -0
  58. package/dist/commands/promote.js +15 -6
  59. package/dist/commands/pull.d.ts +7 -0
  60. package/dist/commands/pull.js +67 -18
  61. package/dist/commands/push.d.ts +11 -1
  62. package/dist/commands/push.js +157 -48
  63. package/dist/commands/snippet/publish.js +9 -5
  64. package/dist/commands/snippet/push.d.ts +1 -1
  65. package/dist/commands/snippet/push.js +15 -12
  66. package/dist/commands/status.js +1 -0
  67. package/dist/commands/theme/activate.d.ts +21 -0
  68. package/dist/commands/theme/activate.js +44 -0
  69. package/dist/commands/theme/pull.js +2 -1
  70. package/dist/commands/theme/push.js +7 -3
  71. package/dist/commands/theme/style/diff.js +1 -1
  72. package/dist/commands/theme/style/push.js +8 -3
  73. package/dist/commands/theme/style/rollback.js +1 -1
  74. package/dist/commands/theme/template/push.d.ts +2 -0
  75. package/dist/commands/theme/template/push.js +66 -18
  76. package/dist/commands/validate.js +3 -0
  77. package/dist/config/auth.manager.d.ts +10 -7
  78. package/dist/config/auth.manager.js +5 -1
  79. package/dist/config/json-file.d.ts +1 -1
  80. package/dist/config/project-config.manager.d.ts +2 -2
  81. package/dist/config/project-config.manager.js +13 -21
  82. package/dist/lib/api-client.js +16 -21
  83. package/dist/lib/app-build.js +6 -2
  84. package/dist/lib/app-manifest.js +6 -6
  85. package/dist/lib/base.d.ts +8 -1
  86. package/dist/lib/base.js +43 -9
  87. package/dist/lib/bootstrap-full-install.js +8 -6
  88. package/dist/lib/capabilities.d.ts +44 -0
  89. package/dist/lib/capabilities.js +85 -0
  90. package/dist/lib/declared-plugins.d.ts +9 -0
  91. package/dist/lib/declared-plugins.js +21 -0
  92. package/dist/lib/declared-type-command.js +7 -2
  93. package/dist/lib/dev-watch.d.ts +1 -1
  94. package/dist/lib/dev-watch.js +13 -15
  95. package/dist/lib/diff-command.d.ts +19 -3
  96. package/dist/lib/diff-command.js +132 -14
  97. package/dist/lib/diff-state.js +2 -3
  98. package/dist/lib/environment-urls.d.ts +6 -0
  99. package/dist/lib/environment-urls.js +59 -0
  100. package/dist/lib/exclude.d.ts +16 -0
  101. package/dist/lib/exclude.js +102 -0
  102. package/dist/lib/load-files.js +2 -1
  103. package/dist/lib/load-snippets.js +39 -43
  104. package/dist/lib/local-callback-server.js +7 -1
  105. package/dist/lib/narrow.d.ts +5 -0
  106. package/dist/lib/narrow.js +26 -0
  107. package/dist/lib/php-files-command.js +13 -8
  108. package/dist/lib/plugin-detection.d.ts +0 -1
  109. package/dist/lib/plugin-detection.js +2 -4
  110. package/dist/lib/push-command.d.ts +5 -1
  111. package/dist/lib/push-command.js +48 -7
  112. package/dist/lib/put-or-create.d.ts +2 -2
  113. package/dist/lib/readdir-tolerant.js +9 -4
  114. package/dist/lib/resource-selection.d.ts +9 -0
  115. package/dist/lib/resource-selection.js +49 -0
  116. package/dist/lib/resource-state.d.ts +2 -0
  117. package/dist/lib/resource-state.js +60 -14
  118. package/dist/lib/rollback-command.d.ts +1 -1
  119. package/dist/lib/rollback-command.js +4 -3
  120. package/dist/lib/rollback-materialize.js +29 -9
  121. package/dist/lib/rotate-app-password.js +1 -0
  122. package/dist/lib/sentry.js +2 -2
  123. package/dist/lib/snapshot-store.d.ts +10 -7
  124. package/dist/lib/snapshot-store.js +21 -5
  125. package/dist/lib/spinner.js +4 -0
  126. package/dist/lib/validate-local.d.ts +1 -0
  127. package/dist/lib/validate-local.js +102 -28
  128. package/dist/lib/wp-client.d.ts +7 -1
  129. package/dist/lib/wp-client.js +60 -69
  130. package/dist/lib/wp-site-diagnostic.js +3 -5
  131. package/dist/types/global-config.generated.d.ts +1 -1
  132. package/dist/types/plugin.d.ts +7 -5
  133. package/dist/types/plugin.js +6 -1
  134. package/dist/types/project-config.generated.d.ts +24 -5
  135. package/dist/types/snippet.d.ts +2 -2
  136. package/dist/types/snippet.js +1 -1
  137. package/dist/utils/acf-format.js +6 -3
  138. package/dist/utils/block-format.d.ts +29 -0
  139. package/dist/utils/block-format.js +109 -0
  140. package/dist/utils/composer.d.ts +7 -15
  141. package/dist/utils/composer.js +43 -16
  142. package/dist/utils/declared-type-format.js +26 -19
  143. package/dist/utils/form-format.js +2 -2
  144. package/dist/utils/loopress-config.js +9 -1
  145. package/dist/utils/menu-format.d.ts +3 -1
  146. package/dist/utils/menu-format.js +5 -0
  147. package/dist/utils/option-format.d.ts +2 -2
  148. package/dist/utils/option-format.js +15 -25
  149. package/dist/utils/page-format.js +9 -7
  150. package/dist/utils/php-top-level.js +15 -4
  151. package/dist/utils/plugin-sync.d.ts +7 -7
  152. package/dist/utils/plugin-sync.js +11 -11
  153. package/dist/utils/plugins.js +9 -6
  154. package/dist/utils/resource-dirs.d.ts +2 -0
  155. package/dist/utils/resource-dirs.js +13 -5
  156. package/dist/utils/snippet-format.d.ts +1 -1
  157. package/dist/utils/snippet-format.js +5 -3
  158. package/dist/utils/template-format.js +2 -2
  159. package/dist/utils/theme-css.js +14 -6
  160. package/dist/utils/theme-styles-format.d.ts +3 -0
  161. package/dist/utils/theme-styles-format.js +15 -5
  162. package/dist/utils/version.js +1 -1
  163. package/oclif.manifest.json +2661 -1235
  164. package/package.json +45 -20
  165. package/schemas/global-config.schema.json +1 -1
  166. package/schemas/project-config.schema.json +32 -5
@@ -8,7 +8,9 @@ import { configManager } from '../config/project-config.manager.js';
8
8
  import { LoopressCommand } from '../lib/base.js';
9
9
  import { isInteractive } from '../lib/interactive.js';
10
10
  import { stdoutToStderr } from '../lib/json-delegation.js';
11
+ import { errorMessage } from '../lib/narrow.js';
11
12
  import { PushCommand } from '../lib/push-command.js';
13
+ import {} from '../types/config.js';
12
14
  import { readLocalConfig } from '../utils/loopress-config.js';
13
15
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
14
16
  export default class Promote extends Command {
@@ -18,15 +20,17 @@ export default class Promote extends Command {
18
20
  };
19
21
  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.';
20
22
  static enableJsonFlag = true;
21
- static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
23
+ static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run', '$ lps promote staging production --skip-unsupported'];
22
24
  static flags = {
23
25
  ...LoopressCommand.dryRunFlag,
24
26
  ...LoopressCommand.yesFlag,
27
+ ...LoopressCommand.skipUnsupportedFlag,
25
28
  overwrite: PushCommand.baseFlags.overwrite,
26
29
  };
27
30
  async run() {
28
31
  const { args, flags } = await this.parse(Promote);
29
32
  const { overwrite, yes } = flags;
33
+ const skipUnsupported = flags['skip-unsupported'];
30
34
  const dryRun = flags['dry-run'];
31
35
  const { environments, projectName } = await this.resolveProject();
32
36
  const from = this.requireEnvironment(environments, projectName, args.from);
@@ -50,16 +54,16 @@ export default class Promote extends Command {
50
54
  try {
51
55
  this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
52
56
  try {
53
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
57
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false, skipUnsupported)));
54
58
  }
55
59
  catch (error) {
56
60
  // A partial pull must never be pushed onward: stop before touching <to>.
57
61
  // "untouched" is only true of <to>: a real (non dry-run) pull has already rewritten the files.
58
62
  const filesNote = dryRun ? '' : ` Your local files may already hold part of ${from.name}, review them before pushing.`;
59
- this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${error.message}`);
63
+ this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${errorMessage(error)}`);
60
64
  }
61
65
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
62
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
66
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun, skipUnsupported), ...(overwrite ? ['--overwrite'] : [])]));
63
67
  }
64
68
  finally {
65
69
  if (scratch) {
@@ -78,7 +82,10 @@ export default class Promote extends Command {
78
82
  // resource dir would land outside the copy, in the user's real files, during a dry run.
79
83
  async assertDirsInsideProject(projectDir) {
80
84
  const localConfig = await readLocalConfig();
81
- const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
85
+ // Object.keys() is typed string[] even on an `as const` object: these are its own keys.
86
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
87
+ const kinds = Object.keys(RESOURCE_DIR_DEFAULTS);
88
+ const dirs = [localConfig.rootDir ?? '.', ...kinds.map((kind) => resolveResourceDir(kind, localConfig))];
82
89
  const outside = dirs.find((dir) => {
83
90
  const rel = relative(projectDir, resolve(projectDir, dir));
84
91
  return rel.startsWith('..') || isAbsolute(rel);
@@ -109,8 +116,10 @@ export default class Promote extends Command {
109
116
  // `--yes` is always forwarded: confirmPromotion above already gathered intent once, so the
110
117
  // delegated `pull` and `push` must not prompt again (push would otherwise re-run its own
111
118
  // production guard, and pull its orphan-deletion prompt).
112
- delegateArgv(env, dryRun) {
119
+ delegateArgv(env, dryRun, skipUnsupported) {
113
120
  const argv = ['--env', env, '--yes'];
121
+ if (skipUnsupported)
122
+ argv.push('--skip-unsupported');
114
123
  if (dryRun)
115
124
  argv.push('--dry-run');
116
125
  return argv;
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PullTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
+ reason?: string;
5
6
  status: 'failed' | 'pulled' | 'skipped';
6
7
  };
7
8
  type PullResult = {
@@ -12,12 +13,18 @@ export default class Pull extends LoopressCommand {
12
13
  static enableJsonFlag: boolean;
13
14
  static examples: string[];
14
15
  static flags: {
16
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  };
18
20
  private failedCount;
21
+ private skipUnsupported;
22
+ private readonly unsupported;
19
23
  run(): Promise<PullResult>;
20
24
  private buildArgv;
21
25
  private globalStylesSkipNote;
26
+ private projectUses;
27
+ private unavailable;
28
+ private warnUnsupported;
22
29
  }
23
30
  export {};
@@ -1,6 +1,8 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ import { integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
3
+ import { declaredPlugins } from '../lib/declared-plugins.js';
2
4
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
- import { INTEGRATION_MISSING } from '../lib/plugin-detection.js';
5
+ import { errorMessage } from '../lib/narrow.js';
4
6
  import { pluralize } from '../utils/pluralize.js';
5
7
  import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
6
8
  import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
@@ -15,10 +17,11 @@ const PULL_TARGETS = [
15
17
  { commandId: 'meta:pull', label: 'post meta', supportsYes: true },
16
18
  { commandId: 'role:pull', label: 'roles', supportsYes: true },
17
19
  { commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
18
- { commandId: 'api:pull', label: 'API routes', supportsYes: true },
19
- { commandId: 'hook:pull', label: 'hooks', supportsYes: true },
20
+ { commandId: 'api:pull', label: 'API routes', needsPlugin: 'api', supportsYes: true },
21
+ { commandId: 'hook:pull', label: 'hooks', needsPlugin: 'hooks', supportsYes: true },
22
+ { commandId: 'block:pull', label: 'dynamic blocks', needsPlugin: 'block', supportsYes: true },
20
23
  { commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
21
- { commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
24
+ { commandId: 'pattern:pull', label: 'patterns', needsPlugin: 'pattern', supportsYes: true },
22
25
  // Templates and parts have no pull. Global Styles are pulled only once the project tracks them,
23
26
  // see globalStylesSkipNote.
24
27
  { commandId: 'theme:style:pull', label: 'Global Styles' },
@@ -26,18 +29,31 @@ const PULL_TARGETS = [
26
29
  { commandId: 'option:pull', label: 'options', supportsYes: true },
27
30
  { commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
28
31
  ];
32
+ // Why this site can't give the target. Composer has no directory to tell whether the project
33
+ // uses it: never pulled from Light, which has nothing to give.
34
+ function missingFor(target, capabilities) {
35
+ if (target.commandId === 'composer:pull')
36
+ return missingModule('dependencies', 'Composer files', capabilities);
37
+ return target.needsPlugin === undefined ? undefined : missingIntegration(target.needsPlugin, capabilities);
38
+ }
29
39
  export default class Pull extends LoopressCommand {
30
- static description = 'Pull every tracked resource from WordPress into local files in one run: composer dependencies, plugins, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, Global Styles, menus, options, and snippets';
40
+ static description = 'Pull every tracked resource from WordPress into local files in one run: composer dependencies, plugins, post types, taxonomies, post meta, roles, ACF, API routes, hooks, dynamic blocks, forms, patterns, Global Styles, menus, options, and snippets';
31
41
  static enableJsonFlag = true;
32
42
  static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
33
43
  static flags = {
34
44
  ...LoopressCommand.dryRunFlag,
35
45
  ...LoopressCommand.yesFlag,
46
+ ...LoopressCommand.skipUnsupportedFlag,
36
47
  };
37
48
  failedCount = 0;
49
+ skipUnsupported = false;
50
+ // What --skip-unsupported held back: the target and why, for the final warning.
51
+ unsupported = [];
38
52
  async run() {
53
+ const { flags } = await this.parse(Pull);
54
+ this.skipUnsupported = flags['skip-unsupported'];
39
55
  const results = [];
40
- this.warnRemovedSeoModule();
56
+ const capabilities = await this.capabilities();
41
57
  for (const target of PULL_TARGETS) {
42
58
  const skipNote = target.commandId === 'theme:style:pull' ? await this.globalStylesSkipNote() : undefined;
43
59
  if (skipNote) {
@@ -45,6 +61,11 @@ export default class Pull extends LoopressCommand {
45
61
  results.push({ label: target.label, status: 'skipped' });
46
62
  continue;
47
63
  }
64
+ const unavailable = this.unavailable(target, capabilities);
65
+ if (unavailable) {
66
+ results.push(unavailable);
67
+ continue;
68
+ }
48
69
  this.log(`\n→ Pulling ${target.label}...`);
49
70
  try {
50
71
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
@@ -52,30 +73,21 @@ export default class Pull extends LoopressCommand {
52
73
  results.push({ label: target.label, status: 'pulled' });
53
74
  }
54
75
  catch (error) {
55
- const { message } = error;
56
- // A site without ACF (or a form or snippet plugin) is the common case, not a failure,
57
- // as long as the project doesn't use that resource: a vanilla site must pull, and a
58
- // promote must not stop on it. A project that does use it still fails.
59
- if (target.needsPlugin && INTEGRATION_MISSING.test(message) && !projectUses(target.needsPlugin, this.localConfig)) {
60
- // oclif's catch already set exitCode to 1 for the sub-command; a real failure still
61
- // exits non-zero through the this.error() below.
62
- process.exitCode = undefined;
63
- this.log(`→ ${target.label}: not configured, skipped`);
64
- results.push({ label: target.label, status: 'skipped' });
65
- continue;
66
- }
76
+ const message = errorMessage(error);
67
77
  this.failedCount++;
68
78
  this.log(`✗ ${target.label} failed: ${message}`);
69
79
  results.push({ error: message, label: target.label, status: 'failed' });
70
80
  }
71
81
  }
72
82
  if (this.failedCount > 0) {
83
+ await this.warnUnsupported();
73
84
  // Each failure's own reason, not just the count: under --json the per-resource lines above
74
85
  // never reach stdout, and this message is all an MCP caller or a script gets to see.
75
86
  const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
76
87
  this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
77
88
  }
78
89
  this.log('\nAll resources pulled.');
90
+ await this.warnUnsupported();
79
91
  return { results };
80
92
  }
81
93
  // `--yes` is forwarded only when the user passed it and the target accepts it: unlike
@@ -97,4 +109,41 @@ export default class Pull extends LoopressCommand {
97
109
  return undefined;
98
110
  }
99
111
  }
112
+ // Composer has no directory: counted as unused, see missingFor.
113
+ projectUses(target) {
114
+ return target.needsPlugin !== undefined && projectUses(target.needsPlugin, this.localConfig);
115
+ }
116
+ // A site without ACF (or a form or snippet plugin) is the common case, not a failure, as
117
+ // long as the project doesn't use that resource: a vanilla site must pull, and a promote
118
+ // must not stop on it. A project that does use it still fails, without asking the site.
119
+ // Same for a Loopress Full feature on Light.
120
+ unavailable(target, capabilities) {
121
+ const missing = missingFor(target, capabilities);
122
+ if (!missing)
123
+ return undefined;
124
+ if (!this.projectUses(target)) {
125
+ this.log(`\n→ ${target.label}: ${missing.needsFull ? missing.reason : 'not configured'}, skipped`);
126
+ return { label: target.label, status: 'skipped' };
127
+ }
128
+ if (this.skipUnsupported) {
129
+ this.unsupported.push({ label: target.label, missing });
130
+ this.log(`\n→ ${target.label}: ${missing.reason}, skipped (--skip-unsupported)`);
131
+ return { label: target.label, reason: `${missing.reason} on ${this.siteConfig.name}`, status: 'skipped' };
132
+ }
133
+ this.failedCount++;
134
+ const error = `${missing.reason} on ${this.siteConfig.name}`;
135
+ this.log(`\n✗ ${target.label} failed: ${error}`);
136
+ return { error, label: target.label, status: 'failed' };
137
+ }
138
+ async warnUnsupported() {
139
+ if (this.unsupported.length === 0)
140
+ return;
141
+ const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
142
+ const lines = this.unsupported.map(({ label, missing }) => {
143
+ const fix = integrationFix(missing, declared);
144
+ const fixSentence = fix ? ` ${fix}.` : '';
145
+ return `${label}: ${missing.reason} on ${this.siteConfig.name}.${fixSentence}`;
146
+ });
147
+ this.warn(`Skipped with --skip-unsupported, this site can't hold them:\n ${lines.join('\n ')}`);
148
+ }
100
149
  }
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PushTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
+ reason?: string;
5
6
  result?: unknown;
6
7
  status: 'failed' | 'pushed' | 'skipped';
7
8
  };
@@ -14,15 +15,24 @@ export default class Push extends LoopressCommand {
14
15
  static examples: string[];
15
16
  static flags: {
16
17
  overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ only: ReturnType<typeof import("@oclif/core/flags").string>;
20
+ skip: ReturnType<typeof import("@oclif/core/flags").string>;
17
21
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
22
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
23
  };
24
+ private skipUnsupported;
25
+ private readonly unsupported;
20
26
  run(): Promise<PushResult>;
27
+ private applySiteSteps;
21
28
  private buildArgv;
22
- private composerDeclaresPlugins;
23
29
  private composerJsonPath;
30
+ private declaredPlugins;
31
+ private doneMessage;
24
32
  private globalStylesSkipNote;
25
33
  private guardProductionPush;
26
34
  private skippedTargets;
35
+ private unsupportedLine;
36
+ private warnUnsupported;
27
37
  }
28
38
  export {};
@@ -2,63 +2,84 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { LoopressCommand } from '../lib/base.js';
5
+ import { integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
5
6
  import { guardProductionPush } from '../lib/guard-production-push.js';
6
7
  import { stdoutToStderr } from '../lib/json-delegation.js';
8
+ import { errorMessage } from '../lib/narrow.js';
7
9
  import { PushCommand } from '../lib/push-command.js';
10
+ import { parseSelectionFlag, selectionFlags, selectResources } from '../lib/resource-selection.js';
8
11
  import { isLocalEnvironment } from '../lib/wp-client.js';
9
- import { wpackagistRequire } from '../utils/composer.js';
10
- import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
12
+ import { pinActive } from '../types/plugin.js';
13
+ import { parseComposerJson, wpackagistRequire } from '../utils/composer.js';
14
+ import { projectHasChildThemeFiles, projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
11
15
  import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
12
16
  // Backed by a plugin the site may not have (ACF, a form or a snippet plugin): a project without
13
17
  // the resource's directory doesn't use it, and its push, dry run included, would only stop on the
14
18
  // site's 409 or the missing directory.
15
19
  const PLUGIN_BACKED = { 'acf:push': 'acf', 'form:push': 'form', 'snippet:push': 'snippets' };
20
+ // Features of Loopress Full only: on Light, skipped while the project doesn't use them, refused
21
+ // upfront when it does. Like every other resource, skipped when the project doesn't use them.
22
+ const FULL_ONLY = { 'api:push': 'api', 'block:push': 'block', 'hook:push': 'hooks', 'page:push': 'page', 'pattern:push': 'pattern' };
23
+ // Always available on the site: nothing to refuse, but a project without the resource's
24
+ // directory has nothing to push, so the step is skipped instead of running on an empty directory.
25
+ const CORE_BACKED = { 'cpt:push': 'cpt', 'menu:push': 'menu', 'meta:push': 'meta', 'option:push': 'options', 'role:push': 'role', 'taxonomy:push': 'taxonomy' };
16
26
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
17
27
  // relies on, then content itself.
18
28
  const PUSH_TARGETS = [
19
- { commandId: 'plugin:push', label: 'plugins' },
20
- { commandId: 'composer:push', label: 'composer' },
29
+ { commandId: 'plugin:push', id: 'plugin', label: 'plugins' },
30
+ { commandId: 'composer:push', id: 'composer', label: 'composer' },
21
31
  // Before ACF: a field group's location rule can target a Loopress post type or taxonomy.
22
- { commandId: 'cpt:push', label: 'post types' },
23
- { commandId: 'taxonomy:push', label: 'taxonomies' },
24
- { commandId: 'meta:push', label: 'post meta' },
25
- { commandId: 'role:push', label: 'roles' },
26
- { commandId: 'acf:push', label: 'ACF' },
27
- { commandId: 'api:push', label: 'API routes' },
28
- { commandId: 'hook:push', label: 'hooks' },
29
- { commandId: 'form:push', label: 'forms' },
32
+ { commandId: 'cpt:push', id: 'cpt', label: 'post types' },
33
+ { commandId: 'taxonomy:push', id: 'taxonomy', label: 'taxonomies' },
34
+ { commandId: 'meta:push', id: 'meta', label: 'post meta' },
35
+ { commandId: 'role:push', id: 'role', label: 'roles' },
36
+ { commandId: 'acf:push', id: 'acf', label: 'ACF' },
37
+ { commandId: 'api:push', id: 'api', label: 'API routes' },
38
+ { commandId: 'hook:push', id: 'hook', label: 'hooks' },
39
+ // Before templates and pages, which place them (`wp:loopress/<slug>`).
40
+ { commandId: 'block:push', id: 'block', label: 'dynamic blocks' },
41
+ { commandId: 'form:push', id: 'form', label: 'forms' },
30
42
  // Before templates and pages, which can reference them (`wp:pattern`).
31
- { commandId: 'pattern:push', label: 'patterns' },
43
+ { commandId: 'pattern:push', id: 'pattern', label: 'patterns' },
32
44
  // Before pages: WordPress refuses a page whose `template` it doesn't know yet.
33
- { commandId: 'theme:template:push', label: 'templates and parts' },
45
+ { commandId: 'theme:template:push', id: 'template', label: 'templates and parts' },
34
46
  // Belong to whichever theme is active, so right after its templates. Theme versions stay out:
35
47
  // installing or removing themes is `lps theme push`'s call.
36
- { commandId: 'theme:style:push', label: 'Global Styles' },
37
- { commandId: 'page:push', label: 'pages' },
38
- { commandId: 'menu:push', label: 'menus' },
39
- { commandId: 'option:push', label: 'options' },
40
- { commandId: 'snippet:push', label: 'snippets' },
48
+ { commandId: 'theme:style:push', id: 'theme-style', label: 'Global Styles' },
49
+ { commandId: 'page:push', id: 'page', label: 'pages' },
50
+ { commandId: 'menu:push', id: 'menu', label: 'menus' },
51
+ { commandId: 'option:push', id: 'option', label: 'options' },
52
+ { commandId: 'snippet:push', id: 'snippet', label: 'snippets' },
41
53
  ];
54
+ const PUSH_IDS = PUSH_TARGETS.map((target) => target.id);
42
55
  export default class Push extends LoopressCommand {
43
- static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
56
+ static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, dynamic blocks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
44
57
  static enableJsonFlag = true;
45
- static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
58
+ static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run', '$ lps push --only acf,page', '$ lps push --skip plugin --skip composer'];
46
59
  static flags = {
47
60
  ...LoopressCommand.dryRunFlag,
48
61
  ...LoopressCommand.yesFlag,
62
+ ...selectionFlags('push', PUSH_IDS),
63
+ ...LoopressCommand.skipUnsupportedFlag,
49
64
  overwrite: PushCommand.baseFlags.overwrite,
50
65
  };
66
+ skipUnsupported = false;
67
+ // What --skip-unsupported held back, for the final warning.
68
+ unsupported = [];
51
69
  async run() {
52
70
  await this.guardProductionPush();
53
- this.warnRemovedSeoModule();
71
+ const { flags } = await this.parse(Push);
72
+ this.skipUnsupported = flags['skip-unsupported'];
73
+ const skipFlag = parseSelectionFlag(flags.skip, 'skip', PUSH_IDS, (message) => this.error(message));
74
+ const selected = new Set(selectResources(PUSH_IDS, parseSelectionFlag(flags.only, 'only', PUSH_IDS, (message) => this.error(message)), skipFlag));
54
75
  const argv = this.buildArgv();
55
76
  const results = [];
56
- const skips = await this.skippedTargets();
77
+ const skips = await this.skippedTargets(selected, skipFlag.includes('composer'));
57
78
  for (const target of PUSH_TARGETS) {
58
79
  const skip = skips.get(target.commandId);
59
80
  if (skip) {
60
81
  this.log(`\n→ ${target.label}: ${skip.note}`);
61
- results.push({ label: target.label, status: skip.status });
82
+ results.push({ label: target.label, ...(skip.reason && { reason: skip.reason }), status: skip.status });
62
83
  continue;
63
84
  }
64
85
  this.log(`\n→ Pushing ${target.label}...`);
@@ -68,19 +89,48 @@ export default class Push extends LoopressCommand {
68
89
  results.push({ label: target.label, result, status: 'pushed' });
69
90
  }
70
91
  catch (error) {
71
- const { message } = error;
92
+ const message = errorMessage(error);
72
93
  this.log(`✗ ${target.label} failed: ${message}`);
73
94
  results.push({ error: message, label: target.label, status: 'failed' });
74
95
  // Stop here: later targets depend on earlier ones (hooks use what composer installs,
75
96
  // pages need their templates), so pushing them anyway can ship a silently broken site.
76
- const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1).map((next) => next.label);
97
+ const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1)
98
+ .filter((next) => !skips.has(next.commandId))
99
+ .map((next) => next.label);
77
100
  const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
101
+ this.warnUnsupported();
78
102
  this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
79
103
  }
80
104
  }
81
- this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
105
+ this.log(this.doneMessage(selected.size < PUSH_IDS.length));
106
+ this.warnUnsupported();
82
107
  return { results };
83
108
  }
109
+ // A step the project uses on a site that can't hold it would only fail at its turn, after
110
+ // everything before it was pushed: refuse the whole run upfront, saying how to fix it. An unused
111
+ // one is skipped, saying why when the site lacks it.
112
+ applySiteSteps(steps, skips, declared) {
113
+ const blockers = [];
114
+ for (const { commandId, missing, skipUnused, used } of steps) {
115
+ if (used && missing) {
116
+ const line = this.unsupportedLine(commandId, missing, declared);
117
+ if (this.skipUnsupported) {
118
+ skips.set(commandId, { note: `${missing.reason}, skipped (--skip-unsupported)`, reason: `${missing.reason} on ${this.siteConfig.name}`, status: 'skipped' });
119
+ this.unsupported.push(line);
120
+ }
121
+ else
122
+ blockers.push(line);
123
+ }
124
+ else if (!used && missing?.needsFull) {
125
+ skips.set(commandId, { note: `${missing.reason}, skipped`, status: 'skipped' });
126
+ }
127
+ else if (!used && skipUnused) {
128
+ skips.set(commandId, { note: 'not configured, skipped', status: 'skipped' });
129
+ }
130
+ }
131
+ if (blockers.length > 0)
132
+ this.error(`Nothing was pushed, the project uses what this site can't hold:\n ${blockers.join('\n ')}`);
133
+ }
84
134
  // Every delegated push always gets --yes: either this command's own guard already confirmed
85
135
  // production once above, or the target isn't production and there's nothing to confirm.
86
136
  // Without this, each delegated command would re-run its own production guard.
@@ -93,23 +143,28 @@ export default class Push extends LoopressCommand {
93
143
  argv.push('--overwrite');
94
144
  return argv;
95
145
  }
96
- // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
97
- // safety), so `composer:push` would only run Composer on the server a second time.
98
- async composerDeclaresPlugins() {
99
- const path = this.composerJsonPath();
100
- if (!existsSync(path))
101
- return false;
146
+ composerJsonPath() {
147
+ return join(process.cwd(), this.rootDir, 'composer.json');
148
+ }
149
+ // The wordpress.org plugins the project declares: composer.json's wpackagist plugins when it
150
+ // exists, loopress.json's otherwise. Unreadable or invalid composer.json: none, the commands report it.
151
+ async declaredPlugins(hasComposerJson) {
152
+ if (!hasComposerJson)
153
+ return Object.keys(this.localConfig.plugins ?? {});
102
154
  try {
103
- const composerJson = JSON.parse(await readFile(path, 'utf8'));
104
- return Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(this.siteConfig.name))).length > 0;
155
+ const composerJson = parseComposerJson(await readFile(this.composerJsonPath(), 'utf8'));
156
+ return Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(this.siteConfig.name)));
105
157
  }
106
158
  catch {
107
- // Unreadable: let both commands run and report the parse error themselves.
108
- return false;
159
+ return [];
109
160
  }
110
161
  }
111
- composerJsonPath() {
112
- return join(process.cwd(), this.rootDir, 'composer.json');
162
+ // A run narrowed by --only / --skip must not read as a full push. Unused resources skipped
163
+ // on a plain run don't count: that run still pushes everything the project has.
164
+ doneMessage(narrowed) {
165
+ if (narrowed)
166
+ return this.dryRun ? '\nEvery selected resource would push, the others are skipped.' : '\nSelected resources pushed, the others are skipped.';
167
+ return this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.';
113
168
  }
114
169
  // The site unreachable or the theme list refused: let the step run and report it itself.
115
170
  async globalStylesSkipNote() {
@@ -132,26 +187,80 @@ export default class Push extends LoopressCommand {
132
187
  }
133
188
  // Unconfigured is not a failure: a project that doesn't manage plugins or composer through
134
189
  // Loopress must not stop the run before anything else gets pushed.
135
- async skippedTargets() {
190
+ // `composerSkipped`: `composer` was named in --skip, as opposed to just left out of --only.
191
+ async skippedTargets(selected, composerSkipped) {
136
192
  const skips = new Map();
193
+ const isSelected = (commandId) => selected.has(PUSH_TARGETS.find((target) => target.commandId === commandId)?.id ?? '');
137
194
  const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
138
195
  const hasComposerJson = existsSync(this.composerJsonPath());
139
- const composerPushedByPlugins = await this.composerDeclaresPlugins();
196
+ const declaredPlugins = await this.declaredPlugins(hasComposerJson);
197
+ // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
198
+ // safety), so `composer:push` would only run Composer on the server a second time.
199
+ const composerPushedByPlugins = hasComposerJson && declaredPlugins.length > 0 && isSelected('plugin:push');
200
+ // That push runs Composer on the server, so a run told to skip Composer can't push plugins.
201
+ if (composerPushedByPlugins && composerSkipped) {
202
+ this.error('--skip composer conflicts with pushing plugins: composer.json declares them, so plugin push runs Composer on the server. Skip plugin too, or drop --skip composer.');
203
+ }
140
204
  if (composerPushedByPlugins)
141
205
  skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
142
206
  else if (!hasComposerJson)
143
207
  skips.set('composer:push', notConfigured);
144
208
  // With a composer.json, plugins come from it only; otherwise from loopress.json.
145
- const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
146
- if (!pluginsConfigured)
209
+ if (declaredPlugins.length === 0)
147
210
  skips.set('plugin:push', notConfigured);
148
- for (const [commandId, kind] of Object.entries(PLUGIN_BACKED)) {
149
- if (!projectUses(kind, this.localConfig))
150
- skips.set(commandId, notConfigured);
151
- }
152
- const stylesNote = await this.globalStylesSkipNote();
211
+ const capabilities = await this.capabilities();
212
+ // Installed and activated by this run's first step, before the steps that need them: a fresh
213
+ // site (CI, a new staging) gets ACF from loopress.json and its field groups in one push.
214
+ // composer.json records no active state, so its plugins stay inactive until --activate.
215
+ const activatedByPush = hasComposerJson || !isSelected('plugin:push') ? [] : declaredPlugins.filter((slug) => pinActive(this.localConfig.plugins?.[slug]));
216
+ const missingDependencies = missingModule('dependencies', 'plugins and Composer', capabilities);
217
+ // A target left out by --only / --skip is reported skipped and never reaches the site checks,
218
+ // so it can't block the run.
219
+ const allSteps = [
220
+ ...['plugin:push', 'composer:push'].map((commandId) => ({ commandId, missing: missingDependencies, skipUnused: false, used: !skips.has(commandId) })),
221
+ ...Object.entries(PLUGIN_BACKED).map(([commandId, kind]) => {
222
+ const missing = missingIntegration(kind, capabilities);
223
+ // Not missing when one of the plugins that would provide it is activated earlier in the run.
224
+ const activated = missing?.plugins?.some((slug) => activatedByPush.includes(slug)) ?? false;
225
+ // A dry run activates nothing, so the site can't preview this step yet.
226
+ if (activated && this.dryRun)
227
+ skips.set(commandId, { note: `${missing?.reason} until this push activates it, skipped`, status: 'skipped' });
228
+ return { commandId, missing: activated ? undefined : missing, skipUnused: true, used: projectUses(kind, this.localConfig) };
229
+ }),
230
+ ...Object.entries(FULL_ONLY).map(([commandId, kind]) => ({
231
+ commandId,
232
+ missing: missingIntegration(kind, capabilities),
233
+ skipUnused: true,
234
+ used: projectUses(kind, this.localConfig),
235
+ })),
236
+ ...Object.entries(CORE_BACKED).map(([commandId, kind]) => ({ commandId, missing: undefined, skipUnused: true, used: projectUses(kind, this.localConfig) })),
237
+ // Without templates, parts or CSS, the step would only write an empty child theme.
238
+ { commandId: 'theme:template:push', missing: missingIntegration('template', capabilities), skipUnused: true, used: projectHasChildThemeFiles(this.localConfig) },
239
+ ];
240
+ const steps = allSteps.filter((step) => isSelected(step.commandId));
241
+ this.applySiteSteps(steps, skips, { fromComposer: hasComposerJson, plugins: declaredPlugins });
242
+ const stylesNote = isSelected('theme:style:push') ? await this.globalStylesSkipNote() : undefined;
153
243
  if (stylesNote)
154
244
  skips.set('theme:style:push', { note: stylesNote, status: 'skipped' });
245
+ for (const target of PUSH_TARGETS) {
246
+ // Composer left out of --only still runs when plugin push syncs composer.json: say so.
247
+ if (composerPushedByPlugins && target.commandId === 'composer:push')
248
+ continue;
249
+ if (!selected.has(target.id))
250
+ skips.set(target.commandId, { note: 'not selected, skipped', status: 'skipped' });
251
+ }
155
252
  return skips;
156
253
  }
254
+ // "<resources>: <why> on <site>. <fix>", for the refusal and for the --skip-unsupported warning.
255
+ unsupportedLine(commandId, missing, declared) {
256
+ const label = PUSH_TARGETS.find((target) => target.commandId === commandId)?.label;
257
+ const fix = integrationFix(missing, declared);
258
+ const fixSentence = fix ? ` ${fix}.` : '';
259
+ return `${label}: ${missing.reason} on ${this.siteConfig.name}.${fixSentence}`;
260
+ }
261
+ warnUnsupported() {
262
+ if (this.unsupported.length > 0) {
263
+ this.warn(`Skipped with --skip-unsupported, this site can't hold them:\n ${this.unsupported.join('\n ')}\nResources that depend on them (a page placing a block, code calling a hook) may not work until they are pushed.`);
264
+ }
265
+ }
157
266
  }
@@ -1,9 +1,11 @@
1
1
  import { Args, Command } from '@oclif/core';
2
- import { join } from 'node:path';
3
2
  import { ApiClient } from '../../lib/api-client.js';
4
3
  import { loadSnippets } from '../../lib/load-snippets.js';
4
+ import { errorMessage } from '../../lib/narrow.js';
5
5
  import { resolveLinkedProject } from '../../lib/resolve-linked-project.js';
6
+ import {} from '../../types/snippet.js';
6
7
  import { pluralize } from '../../utils/pluralize.js';
8
+ import { resolveResourceDir } from '../../utils/resource-dirs.js';
7
9
  import { toSlug } from '../../utils/to-slug.js';
8
10
  // Publishes to the Loopress api (not a WordPress site), so this does not extend
9
11
  // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
@@ -18,14 +20,16 @@ export default class Publish extends Command {
18
20
  async run() {
19
21
  const { args } = await this.parse(Publish);
20
22
  const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
21
- const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
23
+ const path = resolveResourceDir('snippets', localConfig, args.path);
22
24
  this.log(`Publishing snippets from ${path}`);
23
25
  let snippets;
24
26
  try {
25
- snippets = await loadSnippets(path);
27
+ snippets = await loadSnippets(path, (message) => {
28
+ this.warn(message);
29
+ });
26
30
  }
27
31
  catch (error) {
28
- this.error(error.message);
32
+ this.error(errorMessage(error));
29
33
  }
30
34
  const api = new ApiClient(token);
31
35
  try {
@@ -34,7 +38,7 @@ export default class Publish extends Command {
34
38
  });
35
39
  }
36
40
  catch (error) {
37
- this.error(error.message);
41
+ this.error(errorMessage(error));
38
42
  }
39
43
  this.log(`Published ${pluralize(snippets.length, 'snippet')} to your Loopress account.`);
40
44
  }
@@ -1,6 +1,6 @@
1
1
  import { type PushChange, PushCommand } from '../../lib/push-command.js';
2
2
  type PushedSnippet = {
3
- id?: number;
3
+ id?: number | undefined;
4
4
  name: string;
5
5
  };
6
6
  type PushResult = {