@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
@@ -79,11 +79,14 @@ const NON_TTY_FALLBACK_COLUMNS = 300;
79
79
  // column. See confidenceColor() above for SOURCE?/PLUGIN's shared sûr/pas sûr color coding.
80
80
  function renderTable(rows, { includeCore }) {
81
81
  const previousColumns = process.env.COLUMNS;
82
- if (!process.stdout.columns)
82
+ // Undefined on a non-TTY stdout, despite its `number` type.
83
+ const ttyColumns = process.stdout.columns > 0 ? process.stdout.columns : undefined;
84
+ if (!previousColumns && ttyColumns === undefined)
83
85
  process.env.COLUMNS = String(NON_TTY_FALLBACK_COLUMNS);
84
86
  // Mirrors tty-table's own getAvailableWidth(): its column-width percentages are computed against
85
87
  // this same number, so fillPercent() above needs it too to reserve the right amount for AUTOLOAD/CORE.
86
- const width = (process.stdout.columns || Number(process.env.COLUMNS) || NON_TTY_FALLBACK_COLUMNS) - 2;
88
+ const envColumns = Number(process.env.COLUMNS);
89
+ const width = (ttyColumns ?? (envColumns > 0 ? envColumns : NON_TTY_FALLBACK_COLUMNS)) - 2;
87
90
  const header = [
88
91
  { align: 'left', headerAlign: 'left', value: 'NAME', width: columnWidth(COLUMN_WEIGHT.name, width, includeCore) },
89
92
  { align: 'left', headerAlign: 'left', value: 'AUTOLOAD' },
@@ -1,5 +1,6 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { loadFiles } from '../../lib/load-files.js';
3
+ import { errorMessage } from '../../lib/narrow.js';
3
4
  import { PushCommand } from '../../lib/push-command.js';
4
5
  import { getResourceStateProvider } from '../../lib/resource-state.js';
5
6
  import { isNotFoundError } from '../../lib/wp-client.js';
@@ -39,6 +40,7 @@ export default class Push extends PushCommand {
39
40
  this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
40
41
  }
41
42
  this.log(`Found ${pluralize(writable.length, 'option')} to push`);
43
+ this.warnEnvironmentUrls(writable.map((option) => ({ id: `options/${option.name}`, value: option.value })));
42
44
  const pushed = [];
43
45
  await this.runPushTasks(writable, (option) => option.name, async (option, task) => {
44
46
  await this.pushOption(option, task);
@@ -89,7 +91,7 @@ export default class Push extends PushCommand {
89
91
  task.output = `Pushed: ${option.name}`;
90
92
  }
91
93
  catch (error) {
92
- this.reportTaskFailure(`Failed to push ${option.name}: ${error.message}`, error, task);
94
+ this.reportTaskFailure(`Failed to push ${option.name}: ${errorMessage(error)}`, error, task);
93
95
  }
94
96
  }
95
97
  }
@@ -5,6 +5,7 @@ type RemoveResult = {
5
5
  status: 'aborted' | 'dry-run' | 'success';
6
6
  };
7
7
  export default class Remove extends LoopressCommand {
8
+ static aliases: string[];
8
9
  static args: {
9
10
  name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
11
  };
@@ -8,13 +8,14 @@ import { isInteractive } from '../../lib/interactive.js';
8
8
  import { isNotFoundError } from '../../lib/wp-client.js';
9
9
  import { optionEndpoint, optionFileName } from '../../utils/option-format.js';
10
10
  export default class Remove extends LoopressCommand {
11
+ static aliases = ['option:remove'];
11
12
  static args = {
12
13
  name: Args.string({ description: 'Option name to stop tracking', required: true }),
13
14
  };
14
15
  static description = 'Stop tracking an option locally and delete it from WordPress. Local untracking always happens (git-reversible); ' +
15
16
  'pass --local-only to skip the WordPress delete and only stop tracking.';
16
17
  static enableJsonFlag = true;
17
- static examples = ['$ lps option remove wpseo_titles', '$ lps option remove wpseo_titles --local-only'];
18
+ static examples = ['$ lps option rm wpseo_titles', '$ lps option rm wpseo_titles --local-only'];
18
19
  static flags = {
19
20
  ...LoopressCommand.dryRunFlag,
20
21
  ...LoopressCommand.yesFlag,
@@ -1,6 +1,7 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Args } from '@oclif/core';
3
3
  import { isInteractive } from '../../lib/interactive.js';
4
+ import { errorMessage } from '../../lib/narrow.js';
4
5
  import { PushCommand } from '../../lib/push-command.js';
5
6
  import { getResourceStateProvider } from '../../lib/resource-state.js';
6
7
  import { isConflictCode } from '../../lib/wp-client.js';
@@ -34,6 +35,7 @@ export default class Push extends PushCommand {
34
35
  this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
35
36
  }
36
37
  this.log(`Found ${pluralize(selected.length, 'page')} to push`);
38
+ this.warnEnvironmentUrls(selected.map((page) => ({ id: `pages/${page.slug}`, value: page.html })));
37
39
  const provider = getResourceStateProvider('page');
38
40
  const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
39
41
  const pushed = [];
@@ -90,7 +92,7 @@ export default class Push extends PushCommand {
90
92
  task?.skip?.(`${page.slug} already exists on the site, not created by Loopress`);
91
93
  return false;
92
94
  }
93
- this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
95
+ this.reportTaskFailure(`Failed to push ${page.slug}: ${errorMessage(error)}`, error, task);
94
96
  }
95
97
  }
96
98
  }
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
2
  type AddResult = {
3
+ dependencies: string[];
3
4
  slug: string;
4
5
  status: 'added' | 'dry-run' | 'unchanged' | 'updated';
5
6
  version: string;
@@ -1,6 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
+ import got from 'got';
2
3
  import { LoopressCommand } from '../../lib/base.js';
3
- import { pinVersion } from '../../types/plugin.js';
4
+ import { pinActive, pinVersion } from '../../types/plugin.js';
4
5
  import { writeLocalConfig } from '../../utils/loopress-config.js';
5
6
  import { isExactVersion } from '../../utils/version.js';
6
7
  export default class Add extends LoopressCommand {
@@ -26,22 +27,62 @@ export default class Add extends LoopressCommand {
26
27
  this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
27
28
  }
28
29
  const existing = this.localConfig.plugins ?? {};
30
+ const required = await requiredPlugins(slug);
31
+ const dependencies = required.filter((dep) => !Object.hasOwn(existing, dep));
32
+ // An inactive pin keeps its dependency off, so push could never activate `slug`.
33
+ const inactive = required.filter((dep) => Object.hasOwn(existing, dep) && !pinActive(existing[dep]));
34
+ if (inactive.length > 0) {
35
+ this.warn(`${slug} requires ${inactive.join(', ')}, pinned inactive in loopress.json. Remove \`"active": false\` there, or push cannot activate ${slug}.`);
36
+ }
29
37
  const current = existing[slug];
30
- if (current !== undefined && pinVersion(current) === version) {
38
+ if (current !== undefined && pinVersion(current) === version && dependencies.length === 0) {
31
39
  this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
32
- return { slug, status: 'unchanged', version };
40
+ return { dependencies, slug, status: 'unchanged', version };
33
41
  }
34
42
  const isUpdated = current !== undefined;
43
+ const requires = dependencies.length > 0 ? `, with required ${dependencies.join(', ')} (latest)` : '';
35
44
  if (this.dryRun) {
36
- this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
37
- return { slug, status: 'dry-run', version };
45
+ this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version})${requires} in loopress.json`);
46
+ return { dependencies, slug, status: 'dry-run', version };
38
47
  }
39
48
  await writeLocalConfig({
40
49
  ...this.localConfig,
41
- // Re-pinning a plugin recorded as inactive keeps it inactive.
42
- plugins: { ...existing, [slug]: typeof current === 'object' ? { ...current, version } : version },
50
+ plugins: {
51
+ ...existing,
52
+ ...Object.fromEntries(dependencies.map((dep) => [dep, 'latest'])),
53
+ // Re-pinning a plugin recorded as inactive keeps it inactive.
54
+ [slug]: typeof current === 'object' ? { ...current, version } : version,
55
+ },
43
56
  });
44
- this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
45
- return { slug, status: isUpdated ? 'updated' : 'added', version };
57
+ this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version})${requires}. Run \`lps plugin push\` to apply.`);
58
+ return { dependencies, slug, status: isUpdated ? 'updated' : 'added', version };
59
+ }
60
+ }
61
+ // Every plugin `slug` needs active first, transitively, from the `Requires Plugins` header
62
+ // WordPress.org exposes as `requires_plugins`. Not on WordPress.org or unreachable: none known,
63
+ // and `lps plugin push` still names a missing one when WordPress refuses the activation.
64
+ async function requiredPlugins(slug) {
65
+ const found = [];
66
+ const queue = [slug];
67
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
68
+ let info;
69
+ try {
70
+ info = await got('https://api.wordpress.org/plugins/info/1.2/', {
71
+ searchParams: { action: 'plugin_information', 'request[slug]': next },
72
+ timeout: { request: 10_000 },
73
+ }).json();
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const requires = Array.isArray(info.requires_plugins) ? info.requires_plugins : [];
79
+ for (const dep of requires) {
80
+ if (typeof dep !== 'string' || dep === slug || found.includes(dep)) {
81
+ continue;
82
+ }
83
+ found.push(dep);
84
+ queue.push(dep);
85
+ }
46
86
  }
87
+ return found;
47
88
  }
@@ -1,9 +1,9 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
2
  type Advisory = {
3
- cve?: string;
4
- fixedIn?: string;
5
- score?: number;
6
- severity?: string;
3
+ cve?: string | undefined;
4
+ fixedIn?: string | undefined;
5
+ score?: number | undefined;
6
+ severity?: string | undefined;
7
7
  slug: string;
8
8
  title: string;
9
9
  };
@@ -1,5 +1,6 @@
1
1
  import got from 'got';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { errorMessage, isRecord } from '../../lib/narrow.js';
3
4
  import { withSpinner } from '../../lib/spinner.js';
4
5
  import { pinVersion } from '../../types/plugin.js';
5
6
  import { compareVersions } from '../../utils/version.js';
@@ -19,8 +20,8 @@ export default class Audit extends LoopressCommand {
19
20
  }
20
21
  const advisories = [];
21
22
  const health = [];
22
- for (const slug of slugs) {
23
- const found = await withSpinner(`Auditing ${slug}`, async () => this.auditOne(slug, pinVersion(manifest[slug])));
23
+ for (const [slug, pin] of Object.entries(manifest)) {
24
+ const found = await withSpinner(`Auditing ${slug}`, async () => this.auditOne(slug, pinVersion(pin)));
24
25
  advisories.push(...found.advisories);
25
26
  health.push(...found.health);
26
27
  }
@@ -59,11 +60,17 @@ export default class Audit extends LoopressCommand {
59
60
  catch (error) {
60
61
  // Never swallow this into an empty list: a clean audit must mean "no advisories found",
61
62
  // not "the advisory service was unreachable". Fail the command instead.
62
- throw new Error(`Could not reach the vulnerability database (wpvulnerability.net) for "${slug}": ${error.message}. ` +
63
+ throw new Error(`Could not reach the vulnerability database (wpvulnerability.net) for "${slug}": ${errorMessage(error)}. ` +
63
64
  'Audit aborted so a service outage is not reported as a clean result.', { cause: error });
64
65
  }
65
- const list = Array.isArray(body?.data?.vulnerability) ? body.data.vulnerability : [];
66
- return list.map((raw) => normalizeAdvisory(raw));
66
+ // A 200 with the wrong shape (degraded service) must not read as "no advisories" either.
67
+ const list = body?.data?.vulnerability;
68
+ if (!Array.isArray(list) || list.some((raw) => !isRecord(raw))) {
69
+ throw new TypeError(`Unexpected answer from the vulnerability database (wpvulnerability.net) for "${slug}". ` +
70
+ 'Audit aborted so a malformed response is not reported as a clean result.');
71
+ }
72
+ // Already all objects (checked above), the filter only carries that to the type.
73
+ return list.filter((raw) => isRecord(raw)).map((raw) => normalizeAdvisory(raw));
67
74
  }
68
75
  report(advisories, health, count) {
69
76
  if (advisories.length === 0 && health.length === 0) {
@@ -93,26 +100,28 @@ function advisoryMeta(a) {
93
100
  return parts.length > 0 ? ` (${parts.join(', ')})` : '';
94
101
  }
95
102
  function normalizeAdvisory(raw) {
96
- const cvss = (raw.cvss ?? {});
97
- const source = Array.isArray(raw.source) ? raw.source : [];
98
- const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
103
+ const cvss = isRecord(raw.cvss) ? raw.cvss : {};
104
+ const sourceIds = Array.isArray(raw.source) ? raw.source.map((s) => (isRecord(s) && typeof s.id === 'string' ? s.id : '')) : [];
105
+ const cve = typeof raw.cve === 'string' ? raw.cve : sourceIds.find((id) => id.startsWith('CVE-'));
99
106
  const range = extractRange(raw);
100
107
  return {
101
108
  cve,
102
109
  fixedIn: range.max?.op === 'lt' ? range.max.version : undefined,
103
110
  range,
104
- score: cvss.score,
105
- severity: cvss.severity,
111
+ score: typeof cvss.score === 'number' ? cvss.score : undefined,
112
+ severity: typeof cvss.severity === 'string' ? cvss.severity : undefined,
106
113
  title: typeof raw.name === 'string' ? decodeEntities(raw.name) : 'Unnamed advisory',
107
114
  };
108
115
  }
109
116
  // wpvulnerability's `operator`: `max_version` with `max_operator` "lt" (fixed in that version),
110
117
  // "le" (affected up to it, no fix yet) or "eq" (that version only), and an optional lower bound `min_version` with
111
118
  // `min_operator` "gt" or "ge". No upper bound means every version from the lower one on.
119
+ function toBound(version, operator) {
120
+ return typeof version === 'string' && version !== '' && typeof operator === 'string' ? { op: operator, version } : undefined;
121
+ }
112
122
  function extractRange(raw) {
113
- const op = (raw.operator ?? {});
114
- const bound = (version, operator) => typeof version === 'string' && version !== '' && typeof operator === 'string' ? { op: operator, version } : undefined;
115
- return { max: bound(op.max_version, op.max_operator), min: bound(op.min_version, op.min_operator) };
123
+ const op = isRecord(raw.operator) ? raw.operator : {};
124
+ return { max: toBound(op.max_version, op.max_operator), min: toBound(op.min_version, op.min_operator) };
116
125
  }
117
126
  // A bound we can't evaluate (odd version strings, unknown operator) counts as affected: a false
118
127
  // positive is safer than hiding an advisory.
@@ -121,7 +130,8 @@ function affects(version, range) {
121
130
  if (!bound)
122
131
  return true;
123
132
  const order = compareVersions(version, bound.version);
124
- return order === null || !Object.hasOwn(ok, bound.op) || ok[bound.op](order);
133
+ const check = Object.hasOwn(ok, bound.op) ? ok[bound.op] : undefined;
134
+ return order === null || !check || check(order);
125
135
  };
126
136
  return (within(range.min, { ge: (o) => o >= 0, gt: (o) => o > 0 }) && within(range.max, { eq: (o) => o === 0, le: (o) => o <= 0, lt: (o) => o < 0 }));
127
137
  }
@@ -147,9 +157,19 @@ function describeHealth(slug, pinned, info) {
147
157
  }
148
158
  const lastUpdated = typeof info.last_updated === 'string' ? info.last_updated : null;
149
159
  if (lastUpdated) {
150
- const years = (Date.now() - Date.parse(lastUpdated)) / (365 * 24 * 3600 * 1000);
160
+ const years = (Date.now() - parseWordPressDate(lastUpdated)) / (365 * 24 * 3600 * 1000);
151
161
  if (years > 2)
152
162
  notes.push({ message: `last updated ${lastUpdated.slice(0, 10)} (possibly abandoned)`, slug });
153
163
  }
154
164
  return notes;
155
165
  }
166
+ // WordPress.org's plugins API returns `last_updated` as "2015-03-04 10:00am GMT", which
167
+ // Date.parse() reads as NaN. Rewrite that form to ISO, and leave anything else to Date.parse().
168
+ function parseWordPressDate(value) {
169
+ const match = /^(\d{4}-\d{2}-\d{2}) (\d{1,2}):(\d{2})(am|pm) GMT$/i.exec(value);
170
+ if (!match)
171
+ return Date.parse(value);
172
+ const [, day, hour, minute, half] = match;
173
+ const hours = (Number(hour) % 12) + (half?.toLowerCase() === 'pm' ? 12 : 0);
174
+ return Date.parse(`${day}T${String(hours).padStart(2, '0')}:${minute}:00Z`);
175
+ }
@@ -5,7 +5,8 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { LoopressCommand } from '../../lib/base.js';
7
7
  import { isInteractive } from '../../lib/interactive.js';
8
- import { getComposerManagedSlugs, NOT_ON_WORDPRESS_ORG, pullIntoComposerJson, } from '../../utils/composer.js';
8
+ import {} from '../../types/plugin.js';
9
+ import { getComposerManagedSlugs, NOT_ON_WORDPRESS_ORG, parseComposerJson, pullIntoComposerJson, } from '../../utils/composer.js';
9
10
  import { writeLocalConfig } from '../../utils/loopress-config.js';
10
11
  import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
11
12
  export default class Pull extends LoopressCommand {
@@ -29,7 +30,7 @@ export default class Pull extends LoopressCommand {
29
30
  const raw = await this.wp.get('wp/v2/plugins');
30
31
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
31
32
  const composerJson = existsSync(composerJsonPath)
32
- ? JSON.parse(await readFile(composerJsonPath, 'utf8'))
33
+ ? parseComposerJson(await readFile(composerJsonPath, 'utf8'))
33
34
  : undefined;
34
35
  const declared = new Set(composerJson ? getComposerManagedSlugs(composerJson) : Object.keys(this.localConfig.plugins ?? {}));
35
36
  const all = parseInstalledPlugins(raw);
@@ -3,10 +3,11 @@ import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { confirmUninstall } from '../../lib/interactive.js';
6
+ import { causeOf, isRecord } from '../../lib/narrow.js';
6
7
  import { PushCommand } from '../../lib/push-command.js';
7
- import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
8
+ import { isLocalEnvironment, isNotFoundError, parseBody, responseOf } from '../../lib/wp-client.js';
8
9
  import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
- import { requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
+ import { parseComposerJson, requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
11
  import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
11
12
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
12
13
  import { isExactVersion } from '../../utils/version.js';
@@ -105,10 +106,33 @@ export default class Push extends PushCommand {
105
106
  status: 'success',
106
107
  });
107
108
  }
109
+ // WordPress refuses to activate a plugin whose `Requires Plugins` are inactive, and loopress.json
110
+ // has no dependency order (dokan-lite sorts before woocommerce). A refused plugin is retried
111
+ // after the others, until a round activates nothing.
108
112
  async activate(plugins) {
109
- for (const plugin of plugins) {
110
- this.log(` ⊙ activating ${plugin.slug}`);
111
- await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
113
+ let pending = plugins;
114
+ while (pending.length > 0) {
115
+ const blocked = [];
116
+ let missing = [];
117
+ for (const plugin of pending) {
118
+ this.log(` ⊙ activating ${plugin.slug}`);
119
+ try {
120
+ await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
121
+ }
122
+ catch (error) {
123
+ const notInstalled = missingDependencies(error);
124
+ if (!notInstalled)
125
+ throw error;
126
+ blocked.push(plugin);
127
+ missing = [...missing, ...notInstalled];
128
+ }
129
+ }
130
+ if (blocked.length === pending.length) {
131
+ const hint = missing.length > 0 ? ` Not installed: ${[...new Set(missing)].join(', ')}.` : '';
132
+ this.error(`Could not activate ${blocked.map((p) => p.slug).join(', ')}: WordPress reports required plugins missing or inactive.${hint} ` +
133
+ 'Add them with `lps plugin add <slug>`, then push again.');
134
+ }
135
+ pending = blocked;
112
136
  }
113
137
  }
114
138
  async deactivate(plugins) {
@@ -177,7 +201,7 @@ export default class Push extends PushCommand {
177
201
  async loadSource(activate) {
178
202
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
179
203
  if (existsSync(composerJsonPath)) {
180
- const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
204
+ const composerJson = parseComposerJson(await readFile(composerJsonPath, 'utf8'));
181
205
  const includeDev = isLocalEnvironment(this.siteConfig.name);
182
206
  const manifest = wpackagistRequire(composerJson, 'plugin', includeDev);
183
207
  if (Object.keys(manifest).length === 0) {
@@ -232,6 +256,17 @@ export default class Push extends PushCommand {
232
256
  }
233
257
  }
234
258
  }
259
+ // The dependency slugs not installed at all when WordPress refused an activation over unmet
260
+ // `Requires Plugins` (empty when they are only inactive), or null for any other error. The REST
261
+ // controller adds `{status: 500}` as the error data, which pushes activate_plugin()'s own data
262
+ // (`{not_installed, inactive}`) into `additional_data`.
263
+ function missingDependencies(error) {
264
+ const body = parseBody(responseOf(causeOf(error)).body);
265
+ if (body.code !== 'plugin_missing_dependencies')
266
+ return null;
267
+ const data = [body.data, ...(Array.isArray(body.additional_data) ? body.additional_data : [])];
268
+ return data.flatMap((d) => (isRecord(d) && isRecord(d.not_installed) ? Object.keys(d.not_installed) : []));
269
+ }
235
270
  function isNoop(diff, toPrune) {
236
271
  return (diff.toInstall.length === 0 &&
237
272
  diff.toPin.length === 0 &&
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
4
  import { isNotFoundError } from '../../lib/wp-client.js';
5
+ import {} from '../../types/plugin.js';
5
6
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
6
7
  export default class Status extends LoopressCommand {
7
8
  static description = 'Compare the plugins on WordPress against loopress.json and report drift (missing, wrong version, inactive, untracked)';
@@ -64,7 +65,8 @@ export default class Status extends LoopressCommand {
64
65
  drift,
65
66
  inactive,
66
67
  missing,
67
- status: drift ? 'drift' : 'in-sync',
68
+ // Drift exits with code 1 above, so only the untracked-active report gets here.
69
+ status: 'in-sync',
68
70
  unexpectedlyActive,
69
71
  untrackedActive,
70
72
  wrongVersion,
@@ -3,12 +3,14 @@ import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
4
  import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
5
5
  import { isInteractive } from '../../lib/interactive.js';
6
+ import { errorMessage } from '../../lib/narrow.js';
6
7
  import { isLoopressFullActive } from '../../lib/plugin-detection.js';
7
8
  import { withSpinner } from '../../lib/spinner.js';
8
9
  import { sweepTempAdmins } from '../../lib/temp-admin.js';
9
10
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
10
11
  import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
11
12
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
13
+ import {} from '../../types/config.js';
12
14
  import { pluralize } from '../../utils/pluralize.js';
13
15
  const NEW_PROJECT = '__new__';
14
16
  const AUTH_BROWSER = 'browser';
@@ -92,7 +94,6 @@ export default class Config extends Command {
92
94
  }
93
95
  this.log(`✓ "${projectName}/${envName}" configured`);
94
96
  await this.maybeBootstrapLoopressFull(env);
95
- this.log('→ Run `lps project switch` to change the active project or environment');
96
97
  }
97
98
  async maybeBootstrapLoopressFull(env) {
98
99
  const { name, token, url } = env;
@@ -121,7 +122,7 @@ export default class Config extends Command {
121
122
  }));
122
123
  }
123
124
  catch (error) {
124
- this.warn(error.message);
125
+ this.warn(errorMessage(error));
125
126
  }
126
127
  }
127
128
  async promptManualCredentials() {
@@ -157,7 +158,7 @@ export default class Config extends Command {
157
158
  return { appPassword: password, user: userLogin };
158
159
  }
159
160
  catch (error) {
160
- this.warn(`${error.message}\nFalling back to manual credential entry.`);
161
+ this.warn(`${errorMessage(error)}\nFalling back to manual credential entry.`);
161
162
  return this.promptManualCredentials();
162
163
  }
163
164
  }
@@ -171,10 +172,9 @@ export default class Config extends Command {
171
172
  ],
172
173
  message: 'Project',
173
174
  });
174
- if (choice !== NEW_PROJECT) {
175
- const project = projects.find((p) => p.id === choice);
175
+ const project = projects.find((p) => p.id === choice);
176
+ if (project)
176
177
  return { projectId: project.id, projectName: project.name };
177
- }
178
178
  }
179
179
  const existingNames = new Set(projects.map((project) => project.name.trim().toLowerCase()));
180
180
  const projectName = await input({
@@ -197,7 +197,7 @@ export default class Config extends Command {
197
197
  this.log(`✓ Removed ${pluralize(removed.length, 'leftover temporary admin account')}: ${removed.join(', ')}`);
198
198
  }
199
199
  catch (error) {
200
- this.warn(error.message);
200
+ this.warn(errorMessage(error));
201
201
  }
202
202
  }
203
203
  }
@@ -3,7 +3,9 @@ import { Listr } from 'listr2';
3
3
  import { authManager } from '../../config/auth.manager.js';
4
4
  import { configManager } from '../../config/project-config.manager.js';
5
5
  import { ApiClient } from '../../lib/api-client.js';
6
+ import { errorMessage } from '../../lib/narrow.js';
6
7
  import { withoutSpinner } from '../../lib/spinner.js';
8
+ import {} from '../../types/config.js';
7
9
  import { pluralize } from '../../utils/pluralize.js';
8
10
  export default class Pull extends Command {
9
11
  static description = 'Pull projects and environments from your Loopress account that are not configured locally yet';
@@ -42,7 +44,7 @@ export default class Pull extends Command {
42
44
  return await api.get('projects');
43
45
  }
44
46
  catch (error) {
45
- this.error(`Could not fetch projects from the API: ${error.message}`);
47
+ this.error(`Could not fetch projects from the API: ${errorMessage(error)}`);
46
48
  }
47
49
  }
48
50
  // Reuse a local project already linked to this API project instead of always minting a new
@@ -6,7 +6,9 @@ import { configManager } from '../../config/project-config.manager.js';
6
6
  import { ApiClient } from '../../lib/api-client.js';
7
7
  import { LoopressCommand } from '../../lib/base.js';
8
8
  import { isInteractive } from '../../lib/interactive.js';
9
+ import { errorMessage } from '../../lib/narrow.js';
9
10
  import { withoutSpinner } from '../../lib/spinner.js';
11
+ import {} from '../../types/config.js';
10
12
  import { pluralize } from '../../utils/pluralize.js';
11
13
  import { toSlug } from '../../utils/to-slug.js';
12
14
  export default class Push extends Command {
@@ -57,7 +59,7 @@ export default class Push extends Command {
57
59
  task.output = envPlan.action === 'create' ? 'Created on the API' : 'Linked to the API';
58
60
  }
59
61
  catch (error) {
60
- const message = `Failed to push "${envPlan.env.name}": ${error.message}`;
62
+ const message = `Failed to push "${envPlan.env.name}": ${errorMessage(error)}`;
61
63
  if (task)
62
64
  task.output = message;
63
65
  throw error;
@@ -94,7 +96,7 @@ export default class Push extends Command {
94
96
  task.output = plan.action === 'create' ? 'Created on the API' : 'Linked to the API';
95
97
  }
96
98
  catch (error) {
97
- const message = `Failed to push project "${plan.project.name}": ${error.message}`;
99
+ const message = `Failed to push project "${plan.project.name}": ${errorMessage(error)}`;
98
100
  if (task)
99
101
  task.output = message;
100
102
  throw error;
@@ -138,7 +140,7 @@ export default class Push extends Command {
138
140
  return await api.get('projects');
139
141
  }
140
142
  catch (error) {
141
- this.warn(`Could not fetch existing projects from the API, will create everything as new: ${error.message}`);
143
+ this.warn(`Could not fetch existing projects from the API, will create everything as new: ${errorMessage(error)}`);
142
144
  return [];
143
145
  }
144
146
  }
@@ -203,7 +205,7 @@ export default class Push extends Command {
203
205
  await this.pushCredentials(api, plan.apiProjectId, envPlan.apiEnvironmentId, envPlan.env);
204
206
  }
205
207
  catch (error) {
206
- this.warn(`Failed to push "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
208
+ this.warn(`Failed to push "${plan.project.name}/${envPlan.env.name}": ${errorMessage(error)}`);
207
209
  }
208
210
  }
209
211
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from '@oclif/core';
2
2
  export default class Remove extends Command {
3
+ static aliases: string[];
3
4
  static description: string;
4
5
  static examples: string[];
5
6
  run(): Promise<void>;
@@ -2,8 +2,9 @@ import { checkbox } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
4
  export default class Remove extends Command {
5
+ static aliases = ['project:remove'];
5
6
  static description = 'Remove one or more WordPress projects or environments';
6
- static examples = ['$ lps project remove'];
7
+ static examples = ['$ lps project rm'];
7
8
  async run() {
8
9
  await this.parse(Remove);
9
10
  const projects = configManager.listProjects();
@@ -38,7 +39,7 @@ export default class Remove extends Command {
38
39
  this.log('Nothing removed.');
39
40
  return;
40
41
  }
41
- const selected = chosen.map((index) => targets[Number(index)]);
42
+ const selected = chosen.flatMap((index) => targets[Number(index)] ?? []);
42
43
  const projectsToRemove = new Map(selected.filter((target) => target.kind === 'project').map((target) => [target.projectId, target.projectName]));
43
44
  const envsToRemove = selected.filter((target) => target.kind === 'env' && !projectsToRemove.has(target.projectId));
44
45
  for (const projectId of projectsToRemove.keys())
@@ -10,18 +10,20 @@ export default class Switch extends Command {
10
10
  if (projects.length === 0) {
11
11
  this.error('No projects configured. Run `lps project config` first.');
12
12
  }
13
- const projectId = projects.length === 1
14
- ? projects[0].id
13
+ const [only] = projects;
14
+ const currentId = projects.find((project) => project.isCurrent)?.id;
15
+ const projectId = only && projects.length === 1
16
+ ? only.id
15
17
  : await select({
16
18
  choices: projects.map((project) => ({
17
19
  name: `${project.name}${project.isCurrent ? ' [current]' : ''}`,
18
20
  value: project.id,
19
21
  })),
20
- default: projects.find((project) => project.isCurrent)?.id,
22
+ ...(currentId !== undefined && { default: currentId }),
21
23
  message: 'Select project',
22
24
  });
23
25
  configManager.setCurrent(projectId);
24
26
  const project = projects.find((candidate) => candidate.id === projectId);
25
- this.log(`✓ Switched to "${project.name}"`);
27
+ this.log(`✓ Switched to "${project?.name ?? projectId}"`);
26
28
  }
27
29
  }
@@ -14,6 +14,7 @@ export default class Promote extends Command {
14
14
  static examples: string[];
15
15
  static flags: {
16
16
  overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  };