@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
@@ -2,10 +2,14 @@ import { confirm } from '@inquirer/prompts';
2
2
  import { Args, Flags } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
4
  import { LoopressCommand } from '../../lib/base.js';
5
+ import { activateTheme, getCapabilities } from '../../lib/capabilities.js';
5
6
  import { isInteractive } from '../../lib/interactive.js';
6
7
  import { stdoutToStderr } from '../../lib/json-delegation.js';
7
8
  import { isLocalEnvironment, LOCAL_ENVIRONMENT_NAME, WpClient } from '../../lib/wp-client.js';
9
+ import {} from '../../types/plugin.js';
10
+ import { parseInstalledPlugins } from '../../utils/plugins.js';
8
11
  import { pluralize } from '../../utils/pluralize.js';
12
+ import { CHILD_THEME_SUFFIX } from '../../utils/theme-styles-format.js';
9
13
  const BATCH_SIZE = 100;
10
14
  // Code goes up, content comes down: the content of <from> replaces the local site's, IDs
11
15
  // unchanged, never the reverse and never into the repo. Not a `pull`: nothing is written to
@@ -22,7 +26,7 @@ export default class Copy extends LoopressCommand {
22
26
  static baseFlags = {
23
27
  env: Flags.string({ hidden: true }),
24
28
  };
25
- static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
29
+ static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, activate the theme and plugins active there (when installed locally), then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
26
30
  static enableJsonFlag = true;
27
31
  static examples = ['$ lps content copy production', '$ lps content copy staging --dry-run'];
28
32
  static flags = {
@@ -41,6 +45,8 @@ export default class Copy extends LoopressCommand {
41
45
  const source = this.sourceWp(from);
42
46
  if (!this.dryRun)
43
47
  await this.confirmReplace(from);
48
+ // Read before the reset: a failing read must not leave the local site emptied and unpushed.
49
+ const alignment = await this.readAlignment(source, from);
44
50
  const info = await source.get('loopress/v1/content');
45
51
  if (!this.dryRun) {
46
52
  await this.wp.post('loopress/v1/content/reset', { home: info.home, uploadsUrl: info.uploadsUrl });
@@ -49,6 +55,10 @@ export default class Copy extends LoopressCommand {
49
55
  const terms = await this.copy('terms', source);
50
56
  this.log(`→ Copying posts from ${from}...`);
51
57
  const posts = await this.copy('posts', source);
58
+ // Before the push: it must meet the source's plugins (a snippet push needs its plugin active)
59
+ // and its theme (templates and Global Styles go to the active one).
60
+ await this.alignPlugins(alignment, from);
61
+ const pendingChild = await this.alignTheme(alignment, from);
52
62
  const summary = `${pluralize(posts, 'post')} and ${pluralize(terms, 'term')}`;
53
63
  if (this.dryRun) {
54
64
  this.log(`\n[dry-run] Would replace the local content with ${summary} from ${from}, then push the project to ${LOCAL_ENVIRONMENT_NAME}.`);
@@ -56,6 +66,9 @@ export default class Copy extends LoopressCommand {
56
66
  }
57
67
  this.log(`\n→ Pushing the project to ${LOCAL_ENVIRONMENT_NAME}...`);
58
68
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', ['--env', LOCAL_ENVIRONMENT_NAME, '--yes']));
69
+ // The push may have activated it already, through the project's activateChildTheme.
70
+ if (pendingChild && (await activateTheme(this.wp, pendingChild)).changed)
71
+ this.log(`Activated ${pendingChild}, like on ${from}.`);
59
72
  this.log(`\nCopied ${summary} from ${from}.`);
60
73
  return { from, posts, status: 'copied', terms };
61
74
  }
@@ -70,6 +83,54 @@ export default class Copy extends LoopressCommand {
70
83
  this.error(`No credentials configured for ${env.url}. Run \`lps project config\` to add them.`);
71
84
  return new WpClient(env.url, env.token, env.name);
72
85
  }
86
+ // Activates locally the plugins active on <from> but inactive here. Never installs: a plugin
87
+ // missing here is reported, composer.json is where it gets declared.
88
+ async alignPlugins({ localPlugins, sourcePlugins }, from) {
89
+ const local = new Map(localPlugins.map((plugin) => [plugin.slug, plugin]));
90
+ const activeThere = sourcePlugins.filter((plugin) => plugin.active);
91
+ const missing = activeThere.filter((plugin) => !local.has(plugin.slug)).map((plugin) => plugin.slug);
92
+ if (missing.length > 0)
93
+ this.warn(`Active on ${from} but not installed locally: ${missing.join(', ')}. Declare them in composer.json.`);
94
+ const toActivate = activeThere.flatMap((plugin) => {
95
+ const here = local.get(plugin.slug);
96
+ return here && !here.active ? [here] : [];
97
+ });
98
+ for (const plugin of toActivate) {
99
+ if (this.dryRun) {
100
+ this.log(`[dry-run] Would activate ${plugin.slug}, active on ${from}.`);
101
+ continue;
102
+ }
103
+ await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
104
+ this.log(`Activated ${plugin.slug}, like on ${from}.`);
105
+ }
106
+ }
107
+ // Activates locally the theme active on <from>. When that is the Loopress child, not written
108
+ // here yet, its parent is activated now and the child returned, to activate once the push has
109
+ // written it.
110
+ async alignTheme({ here, installedThemes: installed, there }, from) {
111
+ if (here.active === there.active)
112
+ return undefined;
113
+ const isChild = there.active === `${there.template}${CHILD_THEME_SUFFIX}`;
114
+ let now;
115
+ if (installed.has(there.active))
116
+ now = there.active;
117
+ else if (isChild && installed.has(there.template))
118
+ now = there.template;
119
+ if (!now) {
120
+ this.warn(`${there.active} is active on ${from} but not installed locally, the local site keeps ${here.active}.`);
121
+ return undefined;
122
+ }
123
+ const later = now === there.active ? undefined : there.active;
124
+ if (this.dryRun) {
125
+ this.log(`[dry-run] Would activate ${later ?? now} instead of ${here.active}, like on ${from}.`);
126
+ return undefined;
127
+ }
128
+ if (now !== here.active) {
129
+ await activateTheme(this.wp, now);
130
+ this.log(`Activated ${now}, like on ${from}.`);
131
+ }
132
+ return later;
133
+ }
73
134
  async confirmReplace(from) {
74
135
  if (this.yes)
75
136
  return;
@@ -87,11 +148,27 @@ export default class Copy extends LoopressCommand {
87
148
  while (after !== null) {
88
149
  const page = await source.get(`loopress/v1/content/${kind}?after=${after}&limit=${BATCH_SIZE}`);
89
150
  const { next, ...rows } = page;
90
- count += rows[kind].length;
151
+ const batch = rows[kind];
152
+ // The local site is already emptied: a page without its list must stop the copy, not count as 0.
153
+ if (!Array.isArray(batch))
154
+ this.error(`${kind} page after ${after} from the source has no "${kind}" list.`);
155
+ count += batch.length;
91
156
  if (!this.dryRun)
92
157
  await this.wp.post(`loopress/v1/content/${kind}`, rows);
93
158
  after = next;
94
159
  }
95
160
  return count;
96
161
  }
162
+ // Everything alignPlugins and alignTheme read. The local theme list only matters when the
163
+ // active themes differ.
164
+ async readAlignment(source, from) {
165
+ const [sourcePlugins, localPlugins, { theme: there }, { theme: here }] = await Promise.all([
166
+ source.get('wp/v2/plugins').then(parseInstalledPlugins),
167
+ this.wp.get('wp/v2/plugins').then(parseInstalledPlugins),
168
+ getCapabilities(source, from),
169
+ this.capabilities(),
170
+ ]);
171
+ const installedThemes = here.active === there.active ? new Set() : new Set((await this.wp.get('wp/v2/themes')).map((theme) => theme.stylesheet));
172
+ return { here, installedThemes, localPlugins, sourcePlugins, there };
173
+ }
97
174
  }
@@ -3,8 +3,8 @@ export default class Dev extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
- skip: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ only: ReturnType<typeof import("@oclif/core/flags").string>;
7
+ skip: ReturnType<typeof import("@oclif/core/flags").string>;
8
8
  };
9
9
  run(): Promise<void>;
10
10
  private parseTypeFlag;
@@ -1,23 +1,27 @@
1
- import { Command, Flags } from '@oclif/core';
1
+ import { Command } from '@oclif/core';
2
2
  import { watch as watchFiles } from 'chokidar';
3
3
  import { basename } from 'node:path';
4
4
  import { configManager } from '../config/project-config.manager.js';
5
5
  import { buildWatchTargets, createDebouncedBatcher, resolveResourceTypes, RESOURCE_TYPES, resourceTypeForPath } from '../lib/dev-watch.js';
6
+ import { disableExclusions } from '../lib/exclude.js';
7
+ import { errorMessage, isOneOf } from '../lib/narrow.js';
8
+ import { parseSelectionFlag, selectionFlags } from '../lib/resource-selection.js';
6
9
  import { readLocalConfig } from '../utils/loopress-config.js';
7
10
  const DEBOUNCE_MS = 400;
8
11
  // Noise no one wants pushed: VCS internals, deps, and editor/OS droppings.
9
- const IGNORED = /(?:(^|[/\\])(\.git|node_modules|\.DS_Store)(?:[/\\]|$))|(?:\.swp$)/;
12
+ const IGNORED = /(?:^|[/\\])(?:(?:\.git|node_modules|\.DS_Store)(?:[/\\]|$)|[^/\\]*\.swp$)/;
10
13
  export default class Dev extends Command {
11
14
  static description = 'Watch project files and push changes to the local WordPress instance as they happen. Always targets the "local" environment, run `lps snippet push` etc. directly for any other environment.';
12
- static examples = ['$ lps dev', '$ lps dev --only=snippets,api', '$ lps dev --skip=plugins'];
15
+ static examples = ['$ lps dev', '$ lps dev --only=snippet,api', '$ lps dev --skip=plugin'];
13
16
  static flags = {
14
- only: Flags.string({ description: `Only watch these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
15
- skip: Flags.string({ description: `Skip these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
17
+ ...selectionFlags('watch', RESOURCE_TYPES),
16
18
  };
17
19
  async run() {
18
20
  const { flags } = await this.parse(Dev);
19
- const only = flags.only ? this.parseTypeFlag(flags.only, 'only') : undefined;
20
- const skip = flags.skip ? this.parseTypeFlag(flags.skip, 'skip') : [];
21
+ // Dev only targets local, where an excluded feature is being built: it pushes everything.
22
+ disableExclusions();
23
+ const only = this.parseTypeFlag(flags.only, 'only');
24
+ const skip = this.parseTypeFlag(flags.skip, 'skip');
21
25
  const types = resolveResourceTypes(only, skip);
22
26
  const localConfig = await readLocalConfig();
23
27
  const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
@@ -35,16 +39,7 @@ export default class Dev extends Command {
35
39
  await this.watch(targets);
36
40
  }
37
41
  parseTypeFlag(raw, flagName) {
38
- return raw
39
- .split(',')
40
- .map((value) => value.trim())
41
- .filter(Boolean)
42
- .map((value) => {
43
- if (!RESOURCE_TYPES.includes(value)) {
44
- this.error(`Unknown resource type "${value}" in --${flagName}. Valid types: ${RESOURCE_TYPES.join(', ')}`);
45
- }
46
- return value;
47
- });
42
+ return parseSelectionFlag(raw, flagName, RESOURCE_TYPES, (message) => this.error(message)).filter((value) => isOneOf(RESOURCE_TYPES, value));
48
43
  }
49
44
  // Sequential on purpose (see notes technique: parallelizing push is out of scope). A failed
50
45
  // push is logged and swallowed here, never thrown, so one bad batch can't take the watcher down.
@@ -69,7 +64,7 @@ export default class Dev extends Command {
69
64
  this.log(`✓ ${label} synced`);
70
65
  }
71
66
  catch (error) {
72
- this.log(`✗ ${label} failed: ${error.message}`);
67
+ this.log(`✗ ${label} failed: ${errorMessage(error)}`);
73
68
  }
74
69
  }
75
70
  }
@@ -4,11 +4,14 @@ export default class Diff extends DiffCommand {
4
4
  static enableJsonFlag: boolean;
5
5
  static examples: string[];
6
6
  static flags: {
7
- only: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
- skip: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ only: ReturnType<typeof import("@oclif/core/flags").string>;
8
+ skip: ReturnType<typeof import("@oclif/core/flags").string>;
9
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  };
11
12
  run(): Promise<DiffJson>;
13
+ private composerSiteTarget;
12
14
  private projectUses;
13
- private selectResources;
15
+ private siteMissing;
16
+ private withSiteSkip;
14
17
  }
@@ -1,6 +1,11 @@
1
- import { Flags } from '@oclif/core';
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { getCapabilities, integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
4
+ import { declaredPlugins } from '../lib/declared-plugins.js';
2
5
  import { DiffCommand } from '../lib/diff-command.js';
6
+ import { parseSelectionFlag, selectionFlags, selectResources } from '../lib/resource-selection.js';
3
7
  import { RESOURCE_STATE_PROVIDERS } from '../lib/resource-state.js';
8
+ import {} from '../lib/wp-client.js';
4
9
  import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
5
10
  import { globalStylesFiles } from '../utils/theme-styles-format.js';
6
11
  // Everything `lps diff` can compare, in report order. Composer isn't a directory-backed
@@ -9,7 +14,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
9
14
  export default class Diff extends DiffCommand {
10
15
  static description = [
11
16
  'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
12
- 'Covers snippets, forms, ACF, API routes, hooks, templates, template parts, pages, menus, custom post types, taxonomies, post meta, roles, block patterns, options, theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
17
+ 'Covers snippets, forms, ACF, API routes, hooks, dynamic blocks, templates, template parts, pages, menus, custom post types, taxonomies, post meta, roles, block patterns, options, theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
13
18
  'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
14
19
  ].join(' ');
15
20
  static enableJsonFlag = true;
@@ -22,19 +27,39 @@ export default class Diff extends DiffCommand {
22
27
  ];
23
28
  static flags = {
24
29
  ...DiffCommand.againstFlag,
25
- only: Flags.string({ description: 'Only compare these resources', multiple: true, options: [...RESOURCES] }),
26
- skip: Flags.string({ description: 'Compare every resource except these', multiple: true, options: [...RESOURCES] }),
30
+ ...DiffCommand.skipUnsupportedFlag,
31
+ ...selectionFlags('compare', RESOURCES),
27
32
  };
28
33
  async run() {
29
34
  const { flags } = await this.parse(Diff);
30
35
  const sides = this.resolveSides(flags.against);
31
- const selected = this.selectResources(flags.only, flags.skip);
36
+ const selected = new Set(selectResources(RESOURCES, parseSelectionFlag(flags.only, 'only', RESOURCES, (message) => this.error(message)), parseSelectionFlag(flags.skip, 'skip', RESOURCES, (message) => this.error(message))));
32
37
  const providers = RESOURCE_STATE_PROVIDERS.filter((provider) => selected.has(provider.resource));
38
+ const skipUnsupported = flags['skip-unsupported'];
33
39
  const targets = [
34
- ...(await Promise.all(providers.map(async (provider) => ({ ...this.providerTarget(provider, sides), optional: !(await this.projectUses(provider)) })))),
35
- ...(selected.has('composer') ? [this.composerTarget(sides)] : []),
40
+ ...(await Promise.all(providers.map(async (provider) => {
41
+ const lacking = await this.siteMissing((capabilities) => missingIntegration(provider.dirKind, capabilities), sides);
42
+ return this.withSiteSkip(this.providerTarget(provider, sides), lacking, await this.projectUses(provider), skipUnsupported);
43
+ }))),
44
+ ...(selected.has('composer') ? [await this.composerSiteTarget(sides, skipUnsupported)] : []),
36
45
  ];
37
- return this.report(targets, sides);
46
+ if (skipUnsupported) {
47
+ const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
48
+ for (const { unsupported } of targets) {
49
+ if (unsupported)
50
+ unsupported.fix = integrationFix(unsupported.missing, declared);
51
+ }
52
+ }
53
+ const result = await this.report(targets, sides);
54
+ // Only the environment against its local files is drift; two environments differing is not.
55
+ if (!sides.againstWp)
56
+ await this.reportDrift(result);
57
+ return result;
58
+ }
59
+ async composerSiteTarget(sides, skipUnsupported) {
60
+ const lacking = await this.siteMissing((capabilities) => missingModule('dependencies', 'Composer files', capabilities), sides);
61
+ const used = existsSync(resolve(process.cwd(), this.rootDir, 'composer.json'));
62
+ return this.withSiteSkip(this.composerTarget(sides), lacking, used, skipUnsupported);
38
63
  }
39
64
  // Tells apart a resource the project doesn't use from one it does, for the ones a site can lack:
40
65
  // a plugin-backed resource without its directory, Global Styles without a tracked file.
@@ -44,10 +69,35 @@ export default class Diff extends DiffCommand {
44
69
  }
45
70
  return projectUses(provider.dirKind, this.localConfig);
46
71
  }
47
- // `--only` narrows the default set, `--skip` then removes from whatever that set is, same
48
- // precedence a combined `--only=a,b --skip=b` reads as (mirrors dev-watch's resolveResourceTypes).
49
- selectResources(only, skip) {
50
- const base = only && only.length > 0 ? only : RESOURCES;
51
- return new Set(base.filter((resource) => !(skip ?? []).includes(resource)));
72
+ // Either side can't hold the resource. Capabilities that can't be read (site down, plugin too
73
+ // old) skip nothing: the comparison then reports the failure itself, as inconclusive.
74
+ async siteMissing(missing, sides) {
75
+ try {
76
+ // Inside the try: `this.wp` errors out without credentials, the comparison reports that.
77
+ const sites = [[this.wp, sides.leftLabel]];
78
+ if (sides.againstWp)
79
+ sites.push([sides.againstWp, sides.rightLabel]);
80
+ const all = await Promise.all(sites.map(async ([wp, site]) => ({ capabilities: await getCapabilities(wp, site), site })));
81
+ for (const { capabilities, site } of all) {
82
+ const found = missing(capabilities);
83
+ if (found)
84
+ return { missing: found, site };
85
+ }
86
+ return undefined;
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ // A resource the site can't hold is skipped when the project doesn't use it, and also when it
93
+ // does with --skip-unsupported, which then reports why instead of "not configured".
94
+ withSiteSkip(target, lacking, used, skipUnsupported) {
95
+ if (!lacking)
96
+ return target;
97
+ if (!used)
98
+ return { ...target, skip: true };
99
+ if (!skipUnsupported)
100
+ return target;
101
+ return { ...target, skip: true, unsupported: { missing: lacking.missing, reason: `${lacking.missing.reason} on ${lacking.site}` } };
52
102
  }
53
103
  }
@@ -1,14 +1,16 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ import { type SiteCapabilities } from '../lib/capabilities.js';
2
3
  export type DoctorCheck = {
3
4
  message?: string;
4
5
  ok: boolean;
5
6
  title: string;
6
7
  };
7
8
  export type DoctorResult = {
9
+ capabilities?: SiteCapabilities | undefined;
8
10
  checks: DoctorCheck[];
9
11
  environment: string;
10
12
  ok: boolean;
11
- pluginVersion?: string;
13
+ pluginVersion?: string | undefined;
12
14
  project: string;
13
15
  url: string;
14
16
  };
@@ -19,8 +21,8 @@ export default class Doctor extends LoopressCommand {
19
21
  private readonly checks;
20
22
  run(): Promise<DoctorResult>;
21
23
  private check;
24
+ private checkCapabilities;
22
25
  private checkHookErrors;
23
26
  private checkLeftoverTempAdmins;
24
27
  private out;
25
- private reportPluginVersion;
26
28
  }
@@ -1,8 +1,10 @@
1
1
  import { configManager } from '../config/project-config.manager.js';
2
2
  import { LoopressCommand } from '../lib/base.js';
3
+ import {} from '../lib/capabilities.js';
4
+ import { errorMessage } from '../lib/narrow.js';
3
5
  import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
4
6
  import { findTempAdmins } from '../lib/temp-admin.js';
5
- import { isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
7
+ import { isApplicationPasswordsDisabled, isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
6
8
  import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
7
9
  import { pluralize } from '../utils/pluralize.js';
8
10
  export default class Doctor extends LoopressCommand {
@@ -20,7 +22,7 @@ export default class Doctor extends LoopressCommand {
20
22
  this.out(`Environment: ${name}`);
21
23
  this.out(`URL: ${url}`);
22
24
  this.out('');
23
- let pluginVersion;
25
+ let capabilities;
24
26
  const isReachable = await this.check('WordPress REST API reachable', async () => {
25
27
  const diagnostic = await diagnoseWpSite(url);
26
28
  if (!diagnostic.ok)
@@ -31,16 +33,28 @@ export default class Doctor extends LoopressCommand {
31
33
  // The namespace index answers regardless of which Loopress features are active, so a
32
34
  // 404 here can only mean the plugin itself is missing or outdated (WpClient's 404
33
35
  // message already says exactly that).
34
- await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
36
+ const isInstalled = await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
35
37
  // wp/v2/users/me is WordPress core and requires authentication, so it validates the
36
38
  // application password without depending on any Loopress feature.
37
- const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
39
+ const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => {
40
+ try {
41
+ await this.wp.get('wp/v2/users/me');
42
+ }
43
+ catch (error) {
44
+ if (isApplicationPasswordsDisabled(error)) {
45
+ throw new Error('Application Passwords are disabled for this account. Enable them for this user (a security plugin or the `wp_is_application_passwords_available` filter may turn them off), or use another account.', { cause: error });
46
+ }
47
+ throw error;
48
+ }
49
+ });
38
50
  // Skipped when the credentials were just rejected: it would only repeat that same 401.
39
51
  if (isAuthenticated) {
52
+ // Without the plugin it would only repeat that same 404.
53
+ if (isInstalled)
54
+ capabilities = await this.checkCapabilities();
40
55
  await this.checkLeftoverTempAdmins();
41
56
  await this.checkHookErrors();
42
57
  }
43
- pluginVersion = await this.reportPluginVersion();
44
58
  }
45
59
  else {
46
60
  this.checks.push({
@@ -62,7 +76,7 @@ export default class Doctor extends LoopressCommand {
62
76
  this.out(ok ? 'All checks passed.' : `${pluralize(failed, 'check')} failed.`);
63
77
  if (!ok)
64
78
  process.exitCode = 1;
65
- return { checks: this.checks, environment: name, ok, pluginVersion, project: projectName, url };
79
+ return { capabilities, checks: this.checks, environment: name, ok, pluginVersion: capabilities?.version, project: projectName, url };
66
80
  }
67
81
  async check(title, run) {
68
82
  try {
@@ -72,13 +86,41 @@ export default class Doctor extends LoopressCommand {
72
86
  return true;
73
87
  }
74
88
  catch (error) {
75
- const { message } = error;
89
+ const message = errorMessage(error);
76
90
  this.checks.push({ message, ok: false, title });
77
91
  this.out(`✗ ${title}`);
78
92
  this.out(` ${message}`);
79
93
  return false;
80
94
  }
81
95
  }
96
+ // The CLI decides what to run from these, so an older plugin without them fails the doctor.
97
+ // The rest is informational.
98
+ async checkCapabilities() {
99
+ let capabilities;
100
+ await this.check('Plugin recent enough for this CLI (loopress/v1/capabilities)', async () => {
101
+ capabilities = await this.capabilities();
102
+ });
103
+ if (!capabilities)
104
+ return undefined;
105
+ const { edition, environmentType, integrations, theme, version } = capabilities;
106
+ const state = (active) => {
107
+ if (active === undefined)
108
+ return 'needs Loopress Full';
109
+ return active ? 'active' : 'none';
110
+ };
111
+ this.out(`- Plugin: Loopress ${edition === 'full' ? 'Full' : 'Light'} ${version}, environment type "${environmentType}"`);
112
+ this.out(`- Integrations: ACF ${state(integrations.acf)}, forms ${state(integrations.form)}, snippets ${state(integrations.snippets)}`);
113
+ this.out(`- Theme: ${theme.active} (${theme.isBlock ? 'block theme' : 'classic theme'})`);
114
+ if (theme.child) {
115
+ let childState = 'not pushed yet';
116
+ if (theme.child.active)
117
+ childState = 'active';
118
+ else if (theme.child.exists)
119
+ childState = 'not active, `lps theme activate`';
120
+ this.out(`- Loopress child theme: ${theme.child.stylesheet}, ${childState}`);
121
+ }
122
+ return capabilities;
123
+ }
82
124
  // A hook that fails to load or throws while running doesn't break the site, it just quietly
83
125
  // does nothing, and its only trace is an error_log nobody reads on managed hosting.
84
126
  async checkHookErrors() {
@@ -131,22 +173,4 @@ export default class Doctor extends LoopressCommand {
131
173
  if (!this.jsonEnabled())
132
174
  this.log(message);
133
175
  }
134
- // Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
135
- // predates it, neither should flip the doctor to a failure when everything else works.
136
- async reportPluginVersion() {
137
- try {
138
- const status = await this.wp.get('loopress/v1/update');
139
- const version = status.current_version ?? '(unknown)';
140
- this.out(`✓ Plugin version: ${version}`);
141
- return version;
142
- }
143
- catch (error) {
144
- if (isNotFoundError(error)) {
145
- this.out('- Plugin version: not exposed by this plugin edition.');
146
- return undefined;
147
- }
148
- this.out(`- Plugin version: could not be read. ${error.message}`);
149
- return undefined;
150
- }
151
- }
152
176
  }
@@ -24,11 +24,14 @@ export default class Pull extends LoopressCommand {
24
24
  this.log(`Pulling forms from ${url}`);
25
25
  this.log(`Forms path: ${path}`);
26
26
  const remoteList = await this.wp.get(FORM_ENDPOINT);
27
- const withId = remoteList.filter((form) => getFormId(form) !== null);
27
+ const withId = remoteList.flatMap((form) => {
28
+ const id = getFormId(form);
29
+ return id === null ? [] : [{ form, id }];
30
+ });
28
31
  const skipped = remoteList.length - withId.length;
29
32
  // Only matches files following the `<id>-<slug>.json` convention pull/push themselves
30
33
  // produce, same principle as `snippet pull`.
31
- const orphans = await findOrphanedFiles(path, new Set(withId.map((form) => String(getFormId(form)))), {
34
+ const orphans = await findOrphanedFiles(path, new Set(withId.map(({ id }) => String(id))), {
32
35
  extensions: ['.json'],
33
36
  key: numericPrefixKey,
34
37
  });
@@ -36,9 +39,8 @@ export default class Pull extends LoopressCommand {
36
39
  dryRunMessage: `Would pull ${pluralize(withId.length, 'form')} to ${path}`,
37
40
  orphanReason: `in ${path} no longer present on WordPress`,
38
41
  pulledMessage: `Pulled ${pluralize(withId.length, 'form')} to ${path}`,
39
- title: (form) => getFormTitle(form),
40
- async write(form, writeDir) {
41
- const id = getFormId(form);
42
+ title: ({ form }) => getFormTitle(form),
43
+ async write({ form, id }, writeDir) {
42
44
  const title = getFormTitle(form);
43
45
  // `revision` is server bookkeeping (see RemoteForm in form-format.ts), never part of
44
46
  // the tracked configuration: never persisted locally.
@@ -1,6 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
+ import { errorMessage, isRecord } from '../../lib/narrow.js';
4
5
  import { PushCommand } from '../../lib/push-command.js';
5
6
  import { putOrCreate } from '../../lib/put-or-create.js';
6
7
  import { readdirTolerant } from '../../lib/readdir-tolerant.js';
@@ -82,10 +83,10 @@ export default class Push extends PushCommand {
82
83
  parsed = JSON.parse(await readFile(filePath, 'utf8'));
83
84
  }
84
85
  catch (error) {
85
- this.warn(`Skipping "${filePath}": ${error.message}`);
86
+ this.warn(`Skipping "${filePath}": ${errorMessage(error)}`);
86
87
  continue;
87
88
  }
88
- if (typeof parsed !== 'object' || parsed === null) {
89
+ if (!isRecord(parsed)) {
89
90
  this.warn(`Skipping "${filePath}": not a JSON object`);
90
91
  continue;
91
92
  }
@@ -129,7 +130,7 @@ export default class Push extends PushCommand {
129
130
  task.output = `Pushed: ${title}`;
130
131
  }
131
132
  catch (error) {
132
- this.reportTaskFailure(`Failed to push ${title}: ${error.message}`, error, task);
133
+ this.reportTaskFailure(`Failed to push ${title}: ${errorMessage(error)}`, error, task);
133
134
  }
134
135
  }
135
136
  }
@@ -66,8 +66,9 @@ export default class Init extends Command {
66
66
  this.log('Still no project configured, falling back to manual entry.');
67
67
  return this.promptManualProjectId();
68
68
  }
69
- if (projects.length === 1) {
70
- return { projectId: projects[0].id, projectLabel: projects[0].name };
69
+ const [only] = projects;
70
+ if (only && projects.length === 1) {
71
+ return { projectId: only.id, projectLabel: only.name };
71
72
  }
72
73
  }
73
74
  const choice = await select({
@@ -77,6 +78,6 @@ export default class Init extends Command {
77
78
  if (choice === '__manual__') {
78
79
  return this.promptManualProjectId();
79
80
  }
80
- return { projectId: choice, projectLabel: projects.find((p) => p.id === choice).name };
81
+ return { projectId: choice, projectLabel: projects.find((p) => p.id === choice)?.name ?? choice };
81
82
  }
82
83
  }
@@ -1,11 +1,12 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
+ import { errorCode, errorMessage, isRecord } from '../../lib/narrow.js';
4
5
  import { PushCommand } from '../../lib/push-command.js';
5
6
  import { readdirTolerant } from '../../lib/readdir-tolerant.js';
6
7
  import { getResourceStateProvider } from '../../lib/resource-state.js';
7
8
  import { isNotFoundError } from '../../lib/wp-client.js';
8
- import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT, menuEndpoint } from '../../utils/menu-format.js';
9
+ import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS, MENU_LOCATIONS_ENDPOINT, menuEndpoint } from '../../utils/menu-format.js';
9
10
  import { pluralize } from '../../utils/pluralize.js';
10
11
  // A menu literally slugged "menu-locations" would still collide, but that's far less likely
11
12
  // than the plain "locations" this used to be (WordPress's own menu-locations feature).
@@ -59,7 +60,7 @@ export default class Push extends PushCommand {
59
60
  raw = await readFile(file, 'utf8');
60
61
  }
61
62
  catch (error) {
62
- if (error.code === 'ENOENT')
63
+ if (errorCode(error) === 'ENOENT')
63
64
  return;
64
65
  throw error;
65
66
  }
@@ -68,12 +69,12 @@ export default class Push extends PushCommand {
68
69
  return;
69
70
  }
70
71
  try {
71
- await this.wp.put(MENU_LOCATIONS_ENDPOINT, JSON.parse(raw));
72
+ await this.wp.put(MENU_LOCATIONS_ENDPOINT, MENU_LOCATIONS.parse(JSON.parse(raw)));
72
73
  this.log(`Pushed: ${file}`);
73
74
  }
74
75
  catch (error) {
75
76
  this.failedCount++;
76
- this.warn(`Failed to push ${file}: ${error.message}`);
77
+ this.warn(`Failed to push ${file}: ${errorMessage(error)}`);
77
78
  }
78
79
  }
79
80
  async pushMenuFile(filePath, task) {
@@ -83,10 +84,9 @@ export default class Push extends PushCommand {
83
84
  return;
84
85
  }
85
86
  try {
86
- const parsed = JSON.parse(await readFile(filePath, 'utf8'));
87
- if (typeof parsed !== 'object' || parsed === null)
87
+ const record = JSON.parse(await readFile(filePath, 'utf8'));
88
+ if (!isRecord(record))
88
89
  throw new Error('not a JSON object');
89
- const record = parsed;
90
90
  const slug = getMenuSlug(record);
91
91
  if (slug === null)
92
92
  throw new Error('missing or invalid "slug"');
@@ -109,7 +109,7 @@ export default class Push extends PushCommand {
109
109
  }
110
110
  }
111
111
  catch (error) {
112
- this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
112
+ this.reportTaskFailure(`Failed to push ${filePath}: ${errorMessage(error)}`, error, task);
113
113
  }
114
114
  }
115
115
  async pushMenus(basePath) {