@loopress/cli 0.32.2 → 0.33.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 (147) hide show
  1. package/README.md +511 -154
  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 +5 -4
  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 +48 -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.js +4 -3
  30. package/dist/commands/diff.d.ts +1 -0
  31. package/dist/commands/diff.js +33 -3
  32. package/dist/commands/doctor.d.ts +4 -2
  33. package/dist/commands/doctor.js +37 -23
  34. package/dist/commands/form/pull.js +7 -5
  35. package/dist/commands/form/push.js +4 -3
  36. package/dist/commands/init.js +4 -3
  37. package/dist/commands/menu/push.js +8 -8
  38. package/dist/commands/option/list.js +5 -2
  39. package/dist/commands/option/push.js +2 -1
  40. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  41. package/dist/commands/option/{remove.js → rm.js} +2 -1
  42. package/dist/commands/page/push.js +2 -1
  43. package/dist/commands/plugin/audit.d.ts +4 -4
  44. package/dist/commands/plugin/audit.js +35 -15
  45. package/dist/commands/plugin/pull.js +3 -2
  46. package/dist/commands/plugin/push.js +2 -2
  47. package/dist/commands/plugin/status.js +3 -1
  48. package/dist/commands/project/config.js +7 -6
  49. package/dist/commands/project/pull.js +3 -1
  50. package/dist/commands/project/push.js +6 -4
  51. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  52. package/dist/commands/project/{remove.js → rm.js} +3 -2
  53. package/dist/commands/project/switch.js +6 -4
  54. package/dist/commands/promote.js +7 -2
  55. package/dist/commands/pull.d.ts +2 -0
  56. package/dist/commands/pull.js +42 -18
  57. package/dist/commands/push.d.ts +2 -1
  58. package/dist/commands/push.js +69 -25
  59. package/dist/commands/snippet/publish.js +9 -5
  60. package/dist/commands/snippet/push.d.ts +1 -1
  61. package/dist/commands/snippet/push.js +15 -12
  62. package/dist/commands/status.js +1 -0
  63. package/dist/commands/theme/activate.d.ts +21 -0
  64. package/dist/commands/theme/activate.js +44 -0
  65. package/dist/commands/theme/pull.js +2 -1
  66. package/dist/commands/theme/push.js +7 -3
  67. package/dist/commands/theme/style/push.js +7 -2
  68. package/dist/commands/theme/template/push.js +25 -6
  69. package/dist/config/auth.manager.d.ts +10 -7
  70. package/dist/config/auth.manager.js +5 -1
  71. package/dist/config/json-file.d.ts +1 -1
  72. package/dist/config/project-config.manager.d.ts +2 -2
  73. package/dist/config/project-config.manager.js +13 -21
  74. package/dist/lib/api-client.js +16 -21
  75. package/dist/lib/app-build.js +6 -2
  76. package/dist/lib/app-manifest.js +6 -6
  77. package/dist/lib/base.d.ts +2 -1
  78. package/dist/lib/base.js +10 -8
  79. package/dist/lib/bootstrap-full-install.js +8 -6
  80. package/dist/lib/capabilities.d.ts +44 -0
  81. package/dist/lib/capabilities.js +85 -0
  82. package/dist/lib/declared-type-command.js +4 -2
  83. package/dist/lib/dev-watch.js +5 -5
  84. package/dist/lib/diff-command.d.ts +2 -3
  85. package/dist/lib/diff-command.js +9 -7
  86. package/dist/lib/diff-state.js +2 -3
  87. package/dist/lib/load-files.js +2 -1
  88. package/dist/lib/load-snippets.js +37 -42
  89. package/dist/lib/local-callback-server.js +7 -1
  90. package/dist/lib/narrow.d.ts +5 -0
  91. package/dist/lib/narrow.js +26 -0
  92. package/dist/lib/php-files-command.js +8 -5
  93. package/dist/lib/plugin-detection.d.ts +0 -1
  94. package/dist/lib/plugin-detection.js +2 -4
  95. package/dist/lib/push-command.d.ts +1 -1
  96. package/dist/lib/push-command.js +10 -5
  97. package/dist/lib/put-or-create.d.ts +2 -2
  98. package/dist/lib/readdir-tolerant.js +3 -1
  99. package/dist/lib/resource-state.js +38 -13
  100. package/dist/lib/rollback-command.d.ts +1 -1
  101. package/dist/lib/rollback-command.js +3 -2
  102. package/dist/lib/rollback-materialize.js +28 -8
  103. package/dist/lib/rotate-app-password.js +1 -0
  104. package/dist/lib/sentry.js +2 -2
  105. package/dist/lib/snapshot-store.d.ts +10 -7
  106. package/dist/lib/snapshot-store.js +21 -5
  107. package/dist/lib/spinner.js +4 -0
  108. package/dist/lib/validate-local.js +35 -20
  109. package/dist/lib/wp-client.d.ts +6 -1
  110. package/dist/lib/wp-client.js +54 -69
  111. package/dist/lib/wp-site-diagnostic.js +3 -5
  112. package/dist/types/global-config.generated.d.ts +1 -1
  113. package/dist/types/plugin.d.ts +7 -5
  114. package/dist/types/plugin.js +6 -1
  115. package/dist/types/project-config.generated.d.ts +9 -1
  116. package/dist/types/snippet.d.ts +2 -2
  117. package/dist/types/snippet.js +1 -1
  118. package/dist/utils/acf-format.js +6 -3
  119. package/dist/utils/block-format.d.ts +27 -0
  120. package/dist/utils/block-format.js +93 -0
  121. package/dist/utils/composer.d.ts +7 -15
  122. package/dist/utils/composer.js +43 -16
  123. package/dist/utils/declared-type-format.js +26 -19
  124. package/dist/utils/form-format.js +2 -2
  125. package/dist/utils/loopress-config.js +9 -1
  126. package/dist/utils/menu-format.d.ts +3 -1
  127. package/dist/utils/menu-format.js +5 -0
  128. package/dist/utils/option-format.d.ts +2 -2
  129. package/dist/utils/option-format.js +15 -25
  130. package/dist/utils/page-format.js +7 -6
  131. package/dist/utils/php-top-level.js +15 -4
  132. package/dist/utils/plugin-sync.d.ts +7 -7
  133. package/dist/utils/plugin-sync.js +11 -11
  134. package/dist/utils/plugins.js +9 -6
  135. package/dist/utils/resource-dirs.d.ts +2 -0
  136. package/dist/utils/resource-dirs.js +9 -1
  137. package/dist/utils/snippet-format.d.ts +1 -1
  138. package/dist/utils/snippet-format.js +5 -3
  139. package/dist/utils/template-format.js +2 -2
  140. package/dist/utils/theme-css.js +14 -6
  141. package/dist/utils/theme-styles-format.d.ts +3 -0
  142. package/dist/utils/theme-styles-format.js +15 -5
  143. package/dist/utils/version.js +1 -1
  144. package/oclif.manifest.json +1938 -1393
  145. package/package.json +42 -17
  146. package/schemas/global-config.schema.json +1 -1
  147. package/schemas/project-config.schema.json +11 -1
@@ -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);
@@ -6,7 +6,7 @@ import { confirmUninstall } from '../../lib/interactive.js';
6
6
  import { PushCommand } from '../../lib/push-command.js';
7
7
  import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
8
8
  import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
- import { requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
9
+ import { parseComposerJson, requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
10
  import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
11
11
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
12
12
  import { isExactVersion } from '../../utils/version.js';
@@ -177,7 +177,7 @@ export default class Push extends PushCommand {
177
177
  async loadSource(activate) {
178
178
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
179
179
  if (existsSync(composerJsonPath)) {
180
- const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
180
+ const composerJson = parseComposerJson(await readFile(composerJsonPath, 'utf8'));
181
181
  const includeDev = isLocalEnvironment(this.siteConfig.name);
182
182
  const manifest = wpackagistRequire(composerJson, 'plugin', includeDev);
183
183
  if (Object.keys(manifest).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';
@@ -121,7 +123,7 @@ export default class Config extends Command {
121
123
  }));
122
124
  }
123
125
  catch (error) {
124
- this.warn(error.message);
126
+ this.warn(errorMessage(error));
125
127
  }
126
128
  }
127
129
  async promptManualCredentials() {
@@ -157,7 +159,7 @@ export default class Config extends Command {
157
159
  return { appPassword: password, user: userLogin };
158
160
  }
159
161
  catch (error) {
160
- this.warn(`${error.message}\nFalling back to manual credential entry.`);
162
+ this.warn(`${errorMessage(error)}\nFalling back to manual credential entry.`);
161
163
  return this.promptManualCredentials();
162
164
  }
163
165
  }
@@ -171,10 +173,9 @@ export default class Config extends Command {
171
173
  ],
172
174
  message: 'Project',
173
175
  });
174
- if (choice !== NEW_PROJECT) {
175
- const project = projects.find((p) => p.id === choice);
176
+ const project = projects.find((p) => p.id === choice);
177
+ if (project)
176
178
  return { projectId: project.id, projectName: project.name };
177
- }
178
179
  }
179
180
  const existingNames = new Set(projects.map((project) => project.name.trim().toLowerCase()));
180
181
  const projectName = await input({
@@ -197,7 +198,7 @@ export default class Config extends Command {
197
198
  this.log(`✓ Removed ${pluralize(removed.length, 'leftover temporary admin account')}: ${removed.join(', ')}`);
198
199
  }
199
200
  catch (error) {
200
- this.warn(error.message);
201
+ this.warn(errorMessage(error));
201
202
  }
202
203
  }
203
204
  }
@@ -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
  }
@@ -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 {
@@ -56,7 +58,7 @@ export default class Promote extends Command {
56
58
  // A partial pull must never be pushed onward: stop before touching <to>.
57
59
  // "untouched" is only true of <to>: a real (non dry-run) pull has already rewritten the files.
58
60
  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}`);
61
+ this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${errorMessage(error)}`);
60
62
  }
61
63
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
62
64
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
@@ -78,7 +80,10 @@ export default class Promote extends Command {
78
80
  // resource dir would land outside the copy, in the user's real files, during a dry run.
79
81
  async assertDirsInsideProject(projectDir) {
80
82
  const localConfig = await readLocalConfig();
81
- const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
83
+ // Object.keys() is typed string[] even on an `as const` object: these are its own keys.
84
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
85
+ const kinds = Object.keys(RESOURCE_DIR_DEFAULTS);
86
+ const dirs = [localConfig.rootDir ?? '.', ...kinds.map((kind) => resolveResourceDir(kind, localConfig))];
82
87
  const outside = dirs.find((dir) => {
83
88
  const rel = relative(projectDir, resolve(projectDir, dir));
84
89
  return rel.startsWith('..') || isAbsolute(rel);
@@ -19,5 +19,7 @@ export default class Pull extends LoopressCommand {
19
19
  run(): Promise<PullResult>;
20
20
  private buildArgv;
21
21
  private globalStylesSkipNote;
22
+ private projectUses;
23
+ private unavailable;
22
24
  }
23
25
  export {};
@@ -1,6 +1,7 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ import { missingIntegration, missingModule } from '../lib/capabilities.js';
2
3
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
- import { INTEGRATION_MISSING } from '../lib/plugin-detection.js';
4
+ import { errorMessage } from '../lib/narrow.js';
4
5
  import { pluralize } from '../utils/pluralize.js';
5
6
  import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
6
7
  import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
@@ -15,10 +16,11 @@ const PULL_TARGETS = [
15
16
  { commandId: 'meta:pull', label: 'post meta', supportsYes: true },
16
17
  { commandId: 'role:pull', label: 'roles', supportsYes: true },
17
18
  { 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 },
19
+ { commandId: 'api:pull', label: 'API routes', needsPlugin: 'api', supportsYes: true },
20
+ { commandId: 'hook:pull', label: 'hooks', needsPlugin: 'hooks', supportsYes: true },
21
+ { commandId: 'block:pull', label: 'dynamic blocks', needsPlugin: 'block', supportsYes: true },
20
22
  { commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
21
- { commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
23
+ { commandId: 'pattern:pull', label: 'patterns', needsPlugin: 'pattern', supportsYes: true },
22
24
  // Templates and parts have no pull. Global Styles are pulled only once the project tracks them,
23
25
  // see globalStylesSkipNote.
24
26
  { commandId: 'theme:style:pull', label: 'Global Styles' },
@@ -26,8 +28,15 @@ const PULL_TARGETS = [
26
28
  { commandId: 'option:pull', label: 'options', supportsYes: true },
27
29
  { commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
28
30
  ];
31
+ // Why this site can't give the target. Composer has no directory to tell whether the project
32
+ // uses it: never pulled from Light, which has nothing to give.
33
+ function missingFor(target, capabilities) {
34
+ if (target.commandId === 'composer:pull')
35
+ return missingModule('dependencies', 'Composer files', capabilities);
36
+ return target.needsPlugin === undefined ? undefined : missingIntegration(target.needsPlugin, capabilities);
37
+ }
29
38
  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';
39
+ 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
40
  static enableJsonFlag = true;
32
41
  static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
33
42
  static flags = {
@@ -37,7 +46,7 @@ export default class Pull extends LoopressCommand {
37
46
  failedCount = 0;
38
47
  async run() {
39
48
  const results = [];
40
- this.warnRemovedSeoModule();
49
+ const capabilities = await this.capabilities();
41
50
  for (const target of PULL_TARGETS) {
42
51
  const skipNote = target.commandId === 'theme:style:pull' ? await this.globalStylesSkipNote() : undefined;
43
52
  if (skipNote) {
@@ -45,6 +54,11 @@ export default class Pull extends LoopressCommand {
45
54
  results.push({ label: target.label, status: 'skipped' });
46
55
  continue;
47
56
  }
57
+ const unavailable = this.unavailable(target, capabilities);
58
+ if (unavailable) {
59
+ results.push(unavailable);
60
+ continue;
61
+ }
48
62
  this.log(`\n→ Pulling ${target.label}...`);
49
63
  try {
50
64
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
@@ -52,18 +66,7 @@ export default class Pull extends LoopressCommand {
52
66
  results.push({ label: target.label, status: 'pulled' });
53
67
  }
54
68
  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
- }
69
+ const message = errorMessage(error);
67
70
  this.failedCount++;
68
71
  this.log(`✗ ${target.label} failed: ${message}`);
69
72
  results.push({ error: message, label: target.label, status: 'failed' });
@@ -97,4 +100,25 @@ export default class Pull extends LoopressCommand {
97
100
  return undefined;
98
101
  }
99
102
  }
103
+ // Composer has no directory: counted as unused, see missingFor.
104
+ projectUses(target) {
105
+ return target.needsPlugin !== undefined && projectUses(target.needsPlugin, this.localConfig);
106
+ }
107
+ // A site without ACF (or a form or snippet plugin) is the common case, not a failure, as
108
+ // long as the project doesn't use that resource: a vanilla site must pull, and a promote
109
+ // must not stop on it. A project that does use it still fails, without asking the site.
110
+ // Same for a Loopress Full feature on Light.
111
+ unavailable(target, capabilities) {
112
+ const missing = missingFor(target, capabilities);
113
+ if (!missing)
114
+ return undefined;
115
+ if (!this.projectUses(target)) {
116
+ this.log(`\n→ ${target.label}: ${missing.needsFull ? missing.reason : 'not configured'}, skipped`);
117
+ return { label: target.label, status: 'skipped' };
118
+ }
119
+ this.failedCount++;
120
+ const error = `${missing.reason} on ${this.siteConfig.name}`;
121
+ this.log(`\n✗ ${target.label} failed: ${error}`);
122
+ return { error, label: target.label, status: 'failed' };
123
+ }
100
124
  }
@@ -18,9 +18,10 @@ export default class Push extends LoopressCommand {
18
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
19
  };
20
20
  run(): Promise<PushResult>;
21
+ private applySiteSteps;
21
22
  private buildArgv;
22
- private composerDeclaresPlugins;
23
23
  private composerJsonPath;
24
+ private declaredPlugins;
24
25
  private globalStylesSkipNote;
25
26
  private guardProductionPush;
26
27
  private skippedTargets;