@loopress/cli 0.29.0 → 0.31.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 (154) hide show
  1. package/README.md +1369 -360
  2. package/dist/commands/acf/push.js +2 -1
  3. package/dist/commands/composer/push.d.ts +1 -0
  4. package/dist/commands/composer/push.js +13 -0
  5. package/dist/commands/content/copy.d.ts +28 -0
  6. package/dist/commands/content/copy.js +97 -0
  7. package/dist/commands/cpt/diff.js +5 -0
  8. package/dist/commands/cpt/list.d.ts +2 -0
  9. package/dist/commands/cpt/list.js +4 -0
  10. package/dist/commands/cpt/pull.d.ts +2 -0
  11. package/dist/commands/cpt/pull.js +4 -0
  12. package/dist/commands/cpt/push.d.ts +2 -0
  13. package/dist/commands/cpt/push.js +4 -0
  14. package/dist/commands/cpt/rm.d.ts +2 -0
  15. package/dist/commands/cpt/rm.js +3 -0
  16. package/dist/commands/cpt/rollback.js +5 -0
  17. package/dist/commands/dev.js +14 -4
  18. package/dist/commands/diff.js +1 -1
  19. package/dist/commands/doctor.d.ts +2 -0
  20. package/dist/commands/doctor.js +57 -2
  21. package/dist/commands/form/push.js +4 -1
  22. package/dist/commands/meta/diff.d.ts +2 -0
  23. package/dist/commands/meta/diff.js +5 -0
  24. package/dist/commands/meta/list.d.ts +19 -0
  25. package/dist/commands/meta/list.js +21 -0
  26. package/dist/commands/meta/pull.d.ts +2 -0
  27. package/dist/commands/meta/pull.js +4 -0
  28. package/dist/commands/meta/push.d.ts +2 -0
  29. package/dist/commands/meta/push.js +4 -0
  30. package/dist/commands/meta/rm.d.ts +2 -0
  31. package/dist/commands/meta/rm.js +3 -0
  32. package/dist/commands/meta/rollback.d.ts +2 -0
  33. package/dist/commands/meta/rollback.js +5 -0
  34. package/dist/commands/option/list.js +2 -8
  35. package/dist/commands/option/push.js +4 -2
  36. package/dist/commands/page/push.js +3 -2
  37. package/dist/commands/pattern/diff.d.ts +2 -0
  38. package/dist/commands/pattern/diff.js +5 -0
  39. package/dist/commands/pattern/list.d.ts +2 -0
  40. package/dist/commands/pattern/list.js +3 -0
  41. package/dist/commands/pattern/pull.d.ts +2 -0
  42. package/dist/commands/pattern/pull.js +4 -0
  43. package/dist/commands/pattern/push.d.ts +2 -0
  44. package/dist/commands/pattern/push.js +4 -0
  45. package/dist/commands/pattern/rm.d.ts +2 -0
  46. package/dist/commands/pattern/rm.js +3 -0
  47. package/dist/commands/pattern/rollback.d.ts +2 -0
  48. package/dist/commands/pattern/rollback.js +5 -0
  49. package/dist/commands/plugin/audit.js +43 -25
  50. package/dist/commands/project/config.d.ts +1 -0
  51. package/dist/commands/project/config.js +20 -1
  52. package/dist/commands/project/list.d.ts +1 -1
  53. package/dist/commands/project/list.js +6 -6
  54. package/dist/commands/project/pull.js +11 -8
  55. package/dist/commands/project/push.js +23 -18
  56. package/dist/commands/project/remove.js +1 -1
  57. package/dist/commands/project/switch.d.ts +0 -1
  58. package/dist/commands/project/switch.js +16 -36
  59. package/dist/commands/promote.d.ts +1 -0
  60. package/dist/commands/promote.js +7 -3
  61. package/dist/commands/pull.d.ts +2 -1
  62. package/dist/commands/pull.js +31 -6
  63. package/dist/commands/push.d.ts +4 -2
  64. package/dist/commands/push.js +51 -19
  65. package/dist/commands/role/diff.d.ts +2 -0
  66. package/dist/commands/role/diff.js +5 -0
  67. package/dist/commands/role/list.d.ts +2 -0
  68. package/dist/commands/role/list.js +4 -0
  69. package/dist/commands/role/pull.d.ts +2 -0
  70. package/dist/commands/role/pull.js +4 -0
  71. package/dist/commands/role/push.d.ts +2 -0
  72. package/dist/commands/role/push.js +4 -0
  73. package/dist/commands/role/rm.d.ts +2 -0
  74. package/dist/commands/role/rm.js +3 -0
  75. package/dist/commands/role/rollback.d.ts +2 -0
  76. package/dist/commands/role/rollback.js +5 -0
  77. package/dist/commands/snippet/push.d.ts +1 -0
  78. package/dist/commands/snippet/push.js +24 -3
  79. package/dist/commands/status.d.ts +2 -3
  80. package/dist/commands/status.js +21 -53
  81. package/dist/commands/taxonomy/diff.d.ts +2 -0
  82. package/dist/commands/taxonomy/diff.js +5 -0
  83. package/dist/commands/taxonomy/list.d.ts +2 -0
  84. package/dist/commands/taxonomy/list.js +5 -0
  85. package/dist/commands/taxonomy/pull.d.ts +2 -0
  86. package/dist/commands/taxonomy/pull.js +4 -0
  87. package/dist/commands/taxonomy/push.d.ts +2 -0
  88. package/dist/commands/taxonomy/push.js +4 -0
  89. package/dist/commands/taxonomy/rm.d.ts +2 -0
  90. package/dist/commands/taxonomy/rm.js +3 -0
  91. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  92. package/dist/commands/taxonomy/rollback.js +5 -0
  93. package/dist/commands/theme/template/push.d.ts +2 -0
  94. package/dist/commands/theme/template/push.js +26 -5
  95. package/dist/commands/theme/version/pull.js +6 -1
  96. package/dist/config/project-config.manager.d.ts +3 -3
  97. package/dist/config/project-config.manager.js +22 -31
  98. package/dist/hooks/finally.js +11 -3
  99. package/dist/lib/api-client.js +2 -1
  100. package/dist/lib/app-build.js +3 -1
  101. package/dist/lib/base.d.ts +2 -2
  102. package/dist/lib/base.js +33 -38
  103. package/dist/lib/declared-type-command.d.ts +35 -0
  104. package/dist/lib/declared-type-command.js +192 -0
  105. package/dist/lib/dev-watch.d.ts +1 -1
  106. package/dist/lib/dev-watch.js +7 -3
  107. package/dist/lib/diff-command.d.ts +2 -0
  108. package/dist/lib/diff-command.js +24 -3
  109. package/dist/lib/github-release.js +4 -0
  110. package/dist/lib/load-snippets.js +4 -0
  111. package/dist/lib/local-callback-server.js +3 -1
  112. package/dist/lib/php-files-command.d.ts +8 -0
  113. package/dist/lib/php-files-command.js +5 -0
  114. package/dist/lib/push-command.d.ts +5 -2
  115. package/dist/lib/push-command.js +96 -15
  116. package/dist/lib/put-or-create.js +3 -4
  117. package/dist/lib/resource-state.js +38 -85
  118. package/dist/lib/rollback-command.js +9 -2
  119. package/dist/lib/rollback-materialize.js +13 -24
  120. package/dist/lib/spinner.d.ts +2 -0
  121. package/dist/lib/spinner.js +72 -0
  122. package/dist/lib/temp-admin.d.ts +10 -1
  123. package/dist/lib/temp-admin.js +27 -2
  124. package/dist/lib/validate-local.js +40 -12
  125. package/dist/lib/wp-client.d.ts +1 -0
  126. package/dist/lib/wp-client.js +21 -2
  127. package/dist/lib/wp-site-diagnostic.js +3 -2
  128. package/dist/types/global-config.generated.d.ts +1 -5
  129. package/dist/types/project-config.generated.d.ts +28 -4
  130. package/dist/types/snippet.d.ts +1 -0
  131. package/dist/utils/composer.js +2 -1
  132. package/dist/utils/declared-type-format.d.ts +52 -0
  133. package/dist/utils/declared-type-format.js +233 -0
  134. package/dist/utils/resource-dirs.d.ts +9 -3
  135. package/dist/utils/resource-dirs.js +16 -4
  136. package/dist/utils/template-format.d.ts +1 -0
  137. package/dist/utils/theme-css.d.ts +19 -0
  138. package/dist/utils/theme-css.js +100 -0
  139. package/oclif.manifest.json +3294 -1213
  140. package/package.json +32 -16
  141. package/schemas/global-config.schema.json +2 -6
  142. package/schemas/project-config.schema.json +37 -7
  143. package/dist/commands/seo/diff.js +0 -5
  144. package/dist/commands/seo/list.d.ts +0 -18
  145. package/dist/commands/seo/list.js +0 -58
  146. package/dist/commands/seo/pull.d.ts +0 -24
  147. package/dist/commands/seo/pull.js +0 -89
  148. package/dist/commands/seo/push.d.ts +0 -29
  149. package/dist/commands/seo/push.js +0 -178
  150. package/dist/commands/seo/rollback.js +0 -5
  151. package/dist/utils/seo-format.d.ts +0 -29
  152. package/dist/utils/seo-format.js +0 -18
  153. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  154. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import got from 'got';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { withSpinner } from '../../lib/spinner.js';
3
4
  import { pinVersion } from '../../types/plugin.js';
4
5
  import { compareVersions } from '../../utils/version.js';
5
6
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
@@ -19,7 +20,7 @@ export default class Audit extends LoopressCommand {
19
20
  const advisories = [];
20
21
  const health = [];
21
22
  for (const slug of slugs) {
22
- const found = await this.auditOne(slug, pinVersion(manifest[slug]));
23
+ const found = await withSpinner(`Auditing ${slug}`, async () => this.auditOne(slug, pinVersion(manifest[slug])));
23
24
  advisories.push(...found.advisories);
24
25
  health.push(...found.health);
25
26
  }
@@ -27,7 +28,12 @@ export default class Audit extends LoopressCommand {
27
28
  }
28
29
  async auditOne(slug, pinned) {
29
30
  const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
30
- const advisories = vulns.filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn)).map((v) => ({ ...v, slug }));
31
+ // "latest" is the newest WordPress.org release, the one a push installs: audit that version.
32
+ // Unknown (WordPress.org unreachable), every advisory is reported, safer than none.
33
+ const version = pinned === 'latest' && typeof info?.version === 'string' ? info.version : pinned;
34
+ const advisories = vulns
35
+ .filter((v) => version === 'latest' || affects(version, v.range))
36
+ .map(({ range: _range, ...v }) => ({ ...v, slug }));
31
37
  const health = info ? describeHealth(slug, pinned, info) : [];
32
38
  return { advisories, health };
33
39
  }
@@ -71,8 +77,13 @@ export default class Audit extends LoopressCommand {
71
77
  const noun = advisories.length === 1 ? 'advisory' : 'advisories';
72
78
  this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
73
79
  'Vulnerability data: wpvulnerability.net.');
74
- if (advisories.length > 0)
75
- this.exit(1);
80
+ // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
81
+ if (advisories.length > 0) {
82
+ if (this.jsonEnabled())
83
+ process.exitCode = 1;
84
+ else
85
+ this.exit(1);
86
+ }
76
87
  return { advisories, health, status: 'issues' };
77
88
  }
78
89
  }
@@ -85,34 +96,41 @@ function normalizeAdvisory(raw) {
85
96
  const cvss = (raw.cvss ?? {});
86
97
  const source = Array.isArray(raw.source) ? raw.source : [];
87
98
  const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
99
+ const range = extractRange(raw);
88
100
  return {
89
101
  cve,
90
- fixedIn: extractFixedIn(raw),
102
+ fixedIn: range.max?.op === 'lt' ? range.max.version : undefined,
103
+ range,
91
104
  score: cvss.score,
92
105
  severity: cvss.severity,
93
- title: typeof raw.name === 'string' ? raw.name : 'Unnamed advisory',
106
+ title: typeof raw.name === 'string' ? decodeEntities(raw.name) : 'Unnamed advisory',
94
107
  };
95
108
  }
96
- // wpvulnerability's `impact.software[].versions[]` carries `to_version` / `to_compare`; the
97
- // first "< X" bound is the version the fix landed in. Best-effort: the shape varies, so a
98
- // missing bound just means we can't tell and the advisory is reported anyway.
99
- function extractFixedIn(raw) {
100
- const impact = raw.impact;
101
- for (const sw of impact?.software ?? []) {
102
- for (const v of sw.versions ?? []) {
103
- if (v.to_compare === '<' && typeof v.to_version === 'string')
104
- return v.to_version;
105
- }
106
- }
107
- return undefined;
109
+ // wpvulnerability's `operator`: `max_version` with `max_operator` "lt" (fixed in that version),
110
+ // "le" (affected up to it, no fix yet) or "eq" (that version only), and an optional lower bound `min_version` with
111
+ // `min_operator` "gt" or "ge". No upper bound means every version from the lower one on.
112
+ 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) };
116
+ }
117
+ // A bound we can't evaluate (odd version strings, unknown operator) counts as affected: a false
118
+ // positive is safer than hiding an advisory.
119
+ function affects(version, range) {
120
+ const within = (bound, ok) => {
121
+ if (!bound)
122
+ return true;
123
+ const order = compareVersions(version, bound.version);
124
+ return order === null || !Object.hasOwn(ok, bound.op) || ok[bound.op](order);
125
+ };
126
+ 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 }));
108
127
  }
109
- // If we know the fix version, a pin below it is affected. If we can't compare (odd version
110
- // strings), report it anyway: a false positive is safer than hiding an advisory.
111
- function affects(pinned, fixedIn) {
112
- if (!fixedIn)
113
- return true;
114
- const order = compareVersions(pinned, fixedIn);
115
- return order === null || order < 0;
128
+ // Advisory names come HTML-encoded ("WPForms &#8211; AI Form Builder").
129
+ function decodeEntities(text) {
130
+ return text
131
+ .replaceAll(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
132
+ .replaceAll('&quot;', '"')
133
+ .replaceAll('&amp;', '&');
116
134
  }
117
135
  function describeHealth(slug, pinned, info) {
118
136
  const notes = [];
@@ -7,4 +7,5 @@ export default class Config extends Command {
7
7
  private promptManualCredentials;
8
8
  private resolveCredentials;
9
9
  private resolveProject;
10
+ private sweepLeftoverTempAdmins;
10
11
  }
@@ -4,9 +4,12 @@ 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
6
  import { isLoopressFullActive } from '../../lib/plugin-detection.js';
7
+ import { withSpinner } from '../../lib/spinner.js';
8
+ import { sweepTempAdmins } from '../../lib/temp-admin.js';
7
9
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
10
  import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
9
11
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
12
+ import { pluralize } from '../../utils/pluralize.js';
10
13
  const NEW_PROJECT = '__new__';
11
14
  const AUTH_BROWSER = 'browser';
12
15
  const AUTH_MANUAL = 'manual';
@@ -96,6 +99,10 @@ export default class Config extends Command {
96
99
  if (!token)
97
100
  return; // env was just built with one a few lines up; guard only narrows the type
98
101
  const wp = new WpClient(url, token, name);
102
+ // Before the "already active" early return: an install killed mid-way (SIGKILL, power loss)
103
+ // may have activated Full and still left its temp admin behind, and re-running this command
104
+ // is the documented fix for both.
105
+ await this.sweepLeftoverTempAdmins(wp);
99
106
  // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
100
107
  // there" rather than surprising the user with an account-creating prompt they didn't ask
101
108
  // for; `lps project config` can simply be re-run if it genuinely isn't installed.
@@ -109,7 +116,9 @@ export default class Config extends Command {
109
116
  if (!isProceed)
110
117
  return;
111
118
  try {
112
- await bootstrapLoopressFull(wp, url, (message) => { this.log(message); });
119
+ await withSpinner('Installing Loopress Full', async () => bootstrapLoopressFull(wp, url, (message) => {
120
+ this.log(message);
121
+ }));
113
122
  }
114
123
  catch (error) {
115
124
  this.warn(error.message);
@@ -181,4 +190,14 @@ export default class Config extends Command {
181
190
  });
182
191
  return { projectId: configManager.createProjectId(projectName.trim()), projectName: projectName.trim() };
183
192
  }
193
+ async sweepLeftoverTempAdmins(wp) {
194
+ try {
195
+ const removed = await sweepTempAdmins(wp);
196
+ if (removed.length > 0)
197
+ this.log(`✓ Removed ${pluralize(removed.length, 'leftover temporary admin account')}: ${removed.join(', ')}`);
198
+ }
199
+ catch (error) {
200
+ this.warn(error.message);
201
+ }
202
+ }
184
203
  }
@@ -1,6 +1,6 @@
1
1
  import { Command } from '@oclif/core';
2
2
  type EnvironmentInfo = {
3
- isCurrent: boolean;
3
+ isDefault: boolean;
4
4
  name: string;
5
5
  url: string;
6
6
  };
@@ -14,15 +14,15 @@ export default class List extends Command {
14
14
  }
15
15
  return projects.map((project) => this.logProject(project));
16
16
  }
17
- // Only name/url/isCurrent are surfaced, deliberately: EnvironmentConfig also carries the
17
+ // Only name/url/isDefault are surfaced, deliberately: EnvironmentConfig also carries the
18
18
  // WordPress application password token, which must never end up in --json output.
19
19
  logEnvironment(env) {
20
- const envMarker = c(env.isCurrent ? 'cyan' : 'dim', '·');
21
- const envName = c(env.isCurrent ? 'cyan' : 'dim', env.name.padEnd(15));
20
+ const envMarker = c(env.isDefault ? 'cyan' : 'dim', '·');
21
+ const envName = c(env.isDefault ? 'cyan' : 'dim', env.name.padEnd(15));
22
22
  const envUrl = c('dim', env.url);
23
- const activeTag = env.isCurrent ? ` ${c('cyan', '←')}` : '';
24
- this.log(` ${envMarker} ${envName} ${envUrl}${activeTag}`);
25
- return { isCurrent: env.isCurrent, name: env.name, url: env.url };
23
+ const defaultTag = env.isDefault ? ` ${c('cyan', '[default]')}` : '';
24
+ this.log(` ${envMarker} ${envName} ${envUrl}${defaultTag}`);
25
+ return { isDefault: env.isDefault, name: env.name, url: env.url };
26
26
  }
27
27
  logProject(project) {
28
28
  const marker = project.isCurrent ? c('green', '●') : c('dim', '○');
@@ -3,6 +3,7 @@ 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 { withoutSpinner } from '../../lib/spinner.js';
6
7
  import { pluralize } from '../../utils/pluralize.js';
7
8
  export default class Pull extends Command {
8
9
  static description = 'Pull projects and environments from your Loopress account that are not configured locally yet';
@@ -23,14 +24,16 @@ export default class Pull extends Command {
23
24
  let projectCount = 0;
24
25
  let environmentCount = 0;
25
26
  if (newApiProjects.length > 0) {
26
- await new Listr(newApiProjects.map((apiProject) => ({
27
- task: (_ctx, task) => {
28
- this.pullProject(apiProject, task);
29
- projectCount++;
30
- environmentCount += apiProject.environments.length;
31
- },
32
- title: `Pull project "${apiProject.name}" from the API`,
33
- })), { concurrent: false, exitOnError: false }).run();
27
+ await withoutSpinner(async () => {
28
+ await new Listr(newApiProjects.map((apiProject) => ({
29
+ task: (_ctx, task) => {
30
+ this.pullProject(apiProject, task);
31
+ projectCount++;
32
+ environmentCount += apiProject.environments.length;
33
+ },
34
+ title: `Pull project "${apiProject.name}" from the API`,
35
+ })), { concurrent: false, exitOnError: false }).run();
36
+ });
34
37
  }
35
38
  this.log(`\n✓ Pulled ${pluralize(projectCount, 'project')}, ${pluralize(environmentCount, 'environment')} from your Loopress account`);
36
39
  }
@@ -6,6 +6,7 @@ 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 { withoutSpinner } from '../../lib/spinner.js';
9
10
  import { pluralize } from '../../utils/pluralize.js';
10
11
  import { toSlug } from '../../utils/to-slug.js';
11
12
  export default class Push extends Command {
@@ -66,15 +67,17 @@ export default class Push extends Command {
66
67
  if (envsNeedingWork.length === 0)
67
68
  return 0;
68
69
  let count = 0;
69
- await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
70
- task: async (_ctx, task) => {
71
- await this.applyEnvironment(api, apiProjectId, envPlan, task);
72
- count++;
73
- },
74
- title: envPlan.action === 'create'
75
- ? `Create environment "${envPlan.env.name}" on "${projectName}"`
76
- : `Link environment "${envPlan.env.name}" on "${projectName}"`,
77
- })), { concurrent: false, exitOnError: false }).run();
70
+ await withoutSpinner(async () => {
71
+ await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
72
+ task: async (_ctx, task) => {
73
+ await this.applyEnvironment(api, apiProjectId, envPlan, task);
74
+ count++;
75
+ },
76
+ title: envPlan.action === 'create'
77
+ ? `Create environment "${envPlan.env.name}" on "${projectName}"`
78
+ : `Link environment "${envPlan.env.name}" on "${projectName}"`,
79
+ })), { concurrent: false, exitOnError: false }).run();
80
+ });
78
81
  return count;
79
82
  }
80
83
  async applyProject(api, plan, task) {
@@ -101,15 +104,17 @@ export default class Push extends Command {
101
104
  if (projectsNeedingWork.length === 0)
102
105
  return 0;
103
106
  let count = 0;
104
- await new Listr(projectsNeedingWork.map((plan) => ({
105
- task: async (_ctx, task) => {
106
- await this.applyProject(api, plan, task);
107
- count++;
108
- },
109
- title: plan.action === 'create'
110
- ? `Create project "${plan.project.name}" on the API`
111
- : `Link project "${plan.project.name}" to the API`,
112
- })), { concurrent: false, exitOnError: false }).run();
107
+ await withoutSpinner(async () => {
108
+ await new Listr(projectsNeedingWork.map((plan) => ({
109
+ task: async (_ctx, task) => {
110
+ await this.applyProject(api, plan, task);
111
+ count++;
112
+ },
113
+ title: plan.action === 'create'
114
+ ? `Create project "${plan.project.name}" on the API`
115
+ : `Link project "${plan.project.name}" to the API`,
116
+ })), { concurrent: false, exitOnError: false }).run();
117
+ });
113
118
  return count;
114
119
  }
115
120
  collectEnvsNeedingWork(projectPlans, envPlansByProject) {
@@ -24,7 +24,7 @@ export default class Remove extends Command {
24
24
  const envChoices = configManager.listEnvironments(project.id).map((env) => {
25
25
  targets.push({ env: env.name, kind: 'env', projectId: project.id, projectName: project.name });
26
26
  return {
27
- name: ` ${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
27
+ name: ` ${env.name.padEnd(20)} ${env.url}`,
28
28
  value: String(targets.length - 1),
29
29
  };
30
30
  });
@@ -3,5 +3,4 @@ export default class Switch extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
- private resolveSelection;
7
6
  }
@@ -1,9 +1,8 @@
1
- import { select, Separator } from '@inquirer/prompts';
2
- import { Command, ux } from '@oclif/core';
1
+ import { select } from '@inquirer/prompts';
2
+ import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
- const c = ux.colorize;
5
4
  export default class Switch extends Command {
6
- static description = 'Switch the active project and environment';
5
+ static description = 'Switch the active project (pass --env to target a non-local environment)';
7
6
  static examples = ['$ lps project switch'];
8
7
  async run() {
9
8
  await this.parse(Switch);
@@ -11,37 +10,18 @@ export default class Switch extends Command {
11
10
  if (projects.length === 0) {
12
11
  this.error('No projects configured. Run `lps project config` first.');
13
12
  }
14
- const { envName, projectId, projectName } = await this.resolveSelection(projects);
15
- configManager.setCurrent(projectId, envName);
16
- this.log(`✓ Switched to "${projectName}/${envName}"`);
17
- }
18
- async resolveSelection(projects) {
19
- const groups = projects
20
- .map((project) => ({ envs: configManager.listEnvironments(project.id), project }))
21
- .filter(({ envs }) => envs.length > 0);
22
- if (groups.length === 0) {
23
- this.error('No environments configured. Run `lps project config` first.');
24
- }
25
- const entries = groups.flatMap(({ envs, project }) => envs.map((env) => ({ env, project })));
26
- if (entries.length === 1) {
27
- const { env, project } = entries[0];
28
- return { envName: env.name, projectId: project.id, projectName: project.name };
29
- }
30
- const choices = groups.flatMap(({ envs, project }) => [
31
- new Separator(c(project.isCurrent ? 'green' : 'dim', `─── ${project.name} ───`)),
32
- ...envs.map((env) => ({
33
- name: `${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
34
- value: `${project.id}::${env.name}`,
35
- })),
36
- ]);
37
- const current = entries.find(({ env }) => env.isCurrent);
38
- const chosen = await select({
39
- choices,
40
- default: current && `${current.project.id}::${current.env.name}`,
41
- message: 'Select project / environment',
42
- });
43
- const [projectId, envName] = chosen.split('::', 2);
44
- const { project } = groups.find((group) => group.project.id === projectId);
45
- return { envName, projectId, projectName: project.name };
13
+ const projectId = projects.length === 1
14
+ ? projects[0].id
15
+ : await select({
16
+ choices: projects.map((project) => ({
17
+ name: `${project.name}${project.isCurrent ? ' [current]' : ''}`,
18
+ value: project.id,
19
+ })),
20
+ default: projects.find((project) => project.isCurrent)?.id,
21
+ message: 'Select project',
22
+ });
23
+ configManager.setCurrent(projectId);
24
+ const project = projects.find((candidate) => candidate.id === projectId);
25
+ this.log(`✓ Switched to "${project.name}"`);
46
26
  }
47
27
  }
@@ -13,6 +13,7 @@ export default class Promote extends Command {
13
13
  static enableJsonFlag: boolean;
14
14
  static examples: string[];
15
15
  static flags: {
16
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  };
@@ -8,6 +8,7 @@ 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 { PushCommand } from '../lib/push-command.js';
11
12
  import { readLocalConfig } from '../utils/loopress-config.js';
12
13
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
13
14
  export default class Promote extends Command {
@@ -21,10 +22,11 @@ export default class Promote extends Command {
21
22
  static flags = {
22
23
  ...LoopressCommand.dryRunFlag,
23
24
  ...LoopressCommand.yesFlag,
25
+ overwrite: PushCommand.baseFlags.overwrite,
24
26
  };
25
27
  async run() {
26
28
  const { args, flags } = await this.parse(Promote);
27
- const { yes } = flags;
29
+ const { overwrite, yes } = flags;
28
30
  const dryRun = flags['dry-run'];
29
31
  const { environments, projectName } = await this.resolveProject();
30
32
  const from = this.requireEnvironment(environments, projectName, args.from);
@@ -52,10 +54,12 @@ export default class Promote extends Command {
52
54
  }
53
55
  catch (error) {
54
56
  // A partial pull must never be pushed onward: stop before touching <to>.
55
- this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
57
+ // "untouched" is only true of <to>: a real (non dry-run) pull has already rewritten the files.
58
+ 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}`);
56
60
  }
57
61
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
58
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
62
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
59
63
  }
60
64
  finally {
61
65
  if (scratch) {
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PullTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
- status: 'failed' | 'pulled';
5
+ status: 'failed' | 'pulled' | 'skipped';
6
6
  };
7
7
  type PullResult = {
8
8
  results: PullTargetResult[];
@@ -18,5 +18,6 @@ export default class Pull extends LoopressCommand {
18
18
  private failedCount;
19
19
  run(): Promise<PullResult>;
20
20
  private buildArgv;
21
+ private usesResource;
21
22
  }
22
23
  export {};
@@ -1,23 +1,33 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { LoopressCommand } from '../lib/base.js';
2
4
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
5
  import { pluralize } from '../utils/pluralize.js';
6
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
7
+ // How the plugin reports its integration missing: "ACF is not active", "No supported form
8
+ // plugin is active", "No supported snippet plugin is active".
9
+ const PLUGIN_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b/;
4
10
  // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
11
  // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
6
12
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
7
13
  const PULL_TARGETS = [
8
14
  { commandId: 'composer:pull', label: 'composer' },
9
15
  { commandId: 'plugin:pull', label: 'plugins' },
10
- { commandId: 'acf:pull', label: 'ACF', supportsYes: true },
16
+ { commandId: 'cpt:pull', label: 'post types', supportsYes: true },
17
+ { commandId: 'taxonomy:pull', label: 'taxonomies', supportsYes: true },
18
+ { commandId: 'meta:pull', label: 'post meta', supportsYes: true },
19
+ { commandId: 'role:pull', label: 'roles', supportsYes: true },
20
+ { commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
11
21
  { commandId: 'api:pull', label: 'API routes', supportsYes: true },
12
22
  { commandId: 'hook:pull', label: 'hooks', supportsYes: true },
13
- { commandId: 'form:pull', label: 'forms', supportsYes: true },
14
- { commandId: 'seo:pull', label: 'SEO', supportsYes: true },
23
+ { commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
24
+ { commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
15
25
  { commandId: 'menu:pull', label: 'menus', supportsYes: true },
16
26
  { commandId: 'option:pull', label: 'options', supportsYes: true },
17
- { commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
27
+ { commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
18
28
  ];
19
29
  export default class Pull extends LoopressCommand {
20
- static description = 'Pull all content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, from WordPress';
30
+ static description = 'Pull all content, plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, menus, options, and snippets, from WordPress';
21
31
  static enableJsonFlag = true;
22
32
  static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
23
33
  static flags = {
@@ -27,6 +37,7 @@ export default class Pull extends LoopressCommand {
27
37
  failedCount = 0;
28
38
  async run() {
29
39
  const results = [];
40
+ this.warnRemovedSeoModule();
30
41
  for (const target of PULL_TARGETS) {
31
42
  this.log(`\n→ Pulling ${target.label}...`);
32
43
  try {
@@ -35,8 +46,19 @@ export default class Pull extends LoopressCommand {
35
46
  results.push({ label: target.label, status: 'pulled' });
36
47
  }
37
48
  catch (error) {
38
- this.failedCount++;
39
49
  const { message } = error;
50
+ // A site without ACF (or a form or snippet plugin) is the common case, not a failure,
51
+ // as long as the project doesn't use that resource: a vanilla site must pull, and a
52
+ // promote must not stop on it. A project that does use it still fails.
53
+ if (target.needsPlugin && PLUGIN_MISSING.test(message) && !this.usesResource(target.needsPlugin)) {
54
+ // oclif's catch already set exitCode to 1 for the sub-command; a real failure still
55
+ // exits non-zero through the this.error() below.
56
+ process.exitCode = undefined;
57
+ this.log(`→ ${target.label}: not configured, skipped`);
58
+ results.push({ label: target.label, status: 'skipped' });
59
+ continue;
60
+ }
61
+ this.failedCount++;
40
62
  this.log(`✗ ${target.label} failed: ${message}`);
41
63
  results.push({ error: message, label: target.label, status: 'failed' });
42
64
  }
@@ -60,4 +82,7 @@ export default class Pull extends LoopressCommand {
60
82
  argv.push('--yes');
61
83
  return argv;
62
84
  }
85
+ usesResource(kind) {
86
+ return existsSync(resolve(resolveResourceDir(kind, this.localConfig)));
87
+ }
63
88
  }
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PushTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
- status: 'failed' | 'pushed';
5
+ status: 'failed' | 'pushed' | 'skipped';
6
6
  };
7
7
  type PushResult = {
8
8
  results: PushTargetResult[];
@@ -12,13 +12,15 @@ export default class Push extends LoopressCommand {
12
12
  static enableJsonFlag: boolean;
13
13
  static examples: string[];
14
14
  static flags: {
15
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  };
18
- private failedCount;
19
19
  run(): Promise<PushResult>;
20
20
  private buildArgv;
21
21
  private composerDeclaresPlugins;
22
+ private composerJsonPath;
22
23
  private guardProductionPush;
24
+ private skippedTargets;
23
25
  }
24
26
  export {};