@loopress/cli 0.30.0 → 0.32.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 (86) hide show
  1. package/README.md +494 -222
  2. package/dist/commands/acf/diff.d.ts +2 -2
  3. package/dist/commands/acf/diff.js +3 -1
  4. package/dist/commands/acf/list.d.ts +1 -0
  5. package/dist/commands/acf/list.js +2 -1
  6. package/dist/commands/acf/pull.d.ts +1 -0
  7. package/dist/commands/acf/pull.js +2 -1
  8. package/dist/commands/acf/push.d.ts +7 -2
  9. package/dist/commands/acf/push.js +14 -5
  10. package/dist/commands/acf/rollback.d.ts +2 -2
  11. package/dist/commands/acf/rollback.js +3 -1
  12. package/dist/commands/composer/pull.js +1 -1
  13. package/dist/commands/composer/push.d.ts +1 -0
  14. package/dist/commands/composer/push.js +13 -0
  15. package/dist/commands/content/copy.d.ts +28 -0
  16. package/dist/commands/content/copy.js +97 -0
  17. package/dist/commands/diff.d.ts +1 -0
  18. package/dist/commands/diff.js +13 -2
  19. package/dist/commands/doctor.d.ts +1 -0
  20. package/dist/commands/doctor.js +29 -2
  21. package/dist/commands/form/list.js +1 -1
  22. package/dist/commands/form/pull.js +1 -1
  23. package/dist/commands/form/push.d.ts +2 -1
  24. package/dist/commands/form/push.js +6 -3
  25. package/dist/commands/init.js +0 -23
  26. package/dist/commands/menu/push.d.ts +2 -1
  27. package/dist/commands/menu/push.js +1 -1
  28. package/dist/commands/option/push.d.ts +2 -1
  29. package/dist/commands/option/push.js +5 -3
  30. package/dist/commands/page/push.d.ts +4 -1
  31. package/dist/commands/page/push.js +43 -10
  32. package/dist/commands/pattern/push.js +1 -1
  33. package/dist/commands/pattern/rm.js +1 -1
  34. package/dist/commands/plugin/audit.js +42 -25
  35. package/dist/commands/plugin/push.js +1 -1
  36. package/dist/commands/plugin/status.js +1 -1
  37. package/dist/commands/promote.d.ts +1 -0
  38. package/dist/commands/promote.js +7 -3
  39. package/dist/commands/pull.d.ts +2 -1
  40. package/dist/commands/pull.js +37 -5
  41. package/dist/commands/push.d.ts +6 -2
  42. package/dist/commands/push.js +64 -19
  43. package/dist/commands/snippet/list.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.d.ts +3 -1
  46. package/dist/commands/snippet/push.js +26 -5
  47. package/dist/commands/theme/pull.js +1 -1
  48. package/dist/commands/theme/push.d.ts +1 -0
  49. package/dist/commands/theme/push.js +3 -3
  50. package/dist/commands/theme/style/push.d.ts +2 -1
  51. package/dist/commands/theme/style/push.js +4 -5
  52. package/dist/commands/theme/template/list.js +1 -1
  53. package/dist/commands/theme/template/push.d.ts +11 -1
  54. package/dist/commands/theme/template/push.js +34 -5
  55. package/dist/commands/theme/version/pull.js +6 -1
  56. package/dist/commands/theme/version/push.js +1 -1
  57. package/dist/lib/base.d.ts +1 -0
  58. package/dist/lib/base.js +3 -0
  59. package/dist/lib/declared-type-command.d.ts +2 -1
  60. package/dist/lib/declared-type-command.js +1 -1
  61. package/dist/lib/diff-command.d.ts +3 -0
  62. package/dist/lib/diff-command.js +34 -5
  63. package/dist/lib/load-snippets.js +4 -0
  64. package/dist/lib/php-files-command.d.ts +11 -1
  65. package/dist/lib/php-files-command.js +39 -19
  66. package/dist/lib/plugin-detection.d.ts +1 -0
  67. package/dist/lib/plugin-detection.js +4 -0
  68. package/dist/lib/push-command.d.ts +21 -2
  69. package/dist/lib/push-command.js +105 -6
  70. package/dist/lib/resource-state.js +5 -5
  71. package/dist/lib/rollback-command.js +9 -2
  72. package/dist/lib/validate-local.js +21 -8
  73. package/dist/lib/wp-client.d.ts +1 -0
  74. package/dist/lib/wp-client.js +27 -1
  75. package/dist/types/snippet.d.ts +1 -0
  76. package/dist/utils/acf-format.d.ts +5 -0
  77. package/dist/utils/acf-format.js +29 -0
  78. package/dist/utils/php-top-level.d.ts +2 -0
  79. package/dist/utils/php-top-level.js +148 -0
  80. package/dist/utils/resource-dirs.d.ts +1 -0
  81. package/dist/utils/resource-dirs.js +7 -1
  82. package/dist/utils/template-format.d.ts +1 -0
  83. package/dist/utils/theme-styles-format.d.ts +2 -0
  84. package/dist/utils/theme-styles-format.js +32 -4
  85. package/oclif.manifest.json +1132 -919
  86. package/package.json +6 -3
@@ -1,6 +1,9 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  import { Args } from '@oclif/core';
3
+ import { isInteractive } from '../../lib/interactive.js';
2
4
  import { PushCommand } from '../../lib/push-command.js';
3
5
  import { getResourceStateProvider } from '../../lib/resource-state.js';
6
+ import { isConflictCode } from '../../lib/wp-client.js';
4
7
  import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
5
8
  import { pluralize } from '../../utils/pluralize.js';
6
9
  import { resolveResourceDir } from '../../utils/resource-dirs.js';
@@ -8,13 +11,15 @@ export default class Push extends PushCommand {
8
11
  static args = {
9
12
  slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
10
13
  };
11
- static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
14
+ static description = 'Push local static HTML pages (pages/<slug>.html) to WordPress, or only one when a slug is given. The `status` header (draft or publish, draft by default) is applied on every push.';
12
15
  static enableJsonFlag = true;
13
- static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
16
+ static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run', '$ lps page push about --overwrite'];
14
17
  static flags = {
15
18
  ...PushCommand.dryRunFlag,
16
19
  ...PushCommand.yesFlag,
17
20
  };
21
+ // Pages the site refused because a same-slug page exists there that Loopress didn't create.
22
+ unmanaged = [];
18
23
  async run() {
19
24
  const { args } = await this.parse(Push);
20
25
  const path = resolveResourceDir('page', this.localConfig);
@@ -29,34 +34,62 @@ export default class Push extends PushCommand {
29
34
  this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
30
35
  }
31
36
  this.log(`Found ${pluralize(selected.length, 'page')} to push`);
32
- if (this.dryRun)
33
- await this.previewRemote(getResourceStateProvider('page'), path);
37
+ const provider = getResourceStateProvider('page');
38
+ const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
34
39
  const pushed = [];
35
40
  await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
36
- await this.pushPage(page, task);
37
- pushed.push(page.slug);
41
+ if (await this.pushPage(page, task))
42
+ pushed.push(page.slug);
38
43
  });
44
+ await this.adoptUnmanaged(pushed);
45
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
39
46
  if (this.failedCount > 0) {
40
47
  this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
41
48
  }
42
49
  if (this.dryRun)
43
- return { pushed, status: 'dry-run' };
50
+ return { changes: this.previewChanges, pushed, status: 'dry-run' };
44
51
  await this.recordSuccess();
45
52
  this.log('All pages pushed.');
46
53
  return { pushed, status: 'success' };
47
54
  }
48
- async pushPage(page, task) {
55
+ // One prompt for every page the first pass set aside, then a second pass that takes them over.
56
+ // Without a terminal to ask in, they count as failed: CI must opt in with --overwrite.
57
+ async adoptUnmanaged(pushed) {
58
+ if (this.unmanaged.length === 0)
59
+ return;
60
+ const slugs = this.unmanaged.map((page) => page.slug);
61
+ this.warn(`Already on ${this.siteConfig.url} but not created by Loopress: ${slugs.join(', ')}. ` +
62
+ 'Taking them over keeps their URL and ID, and shows your file instead of their current content (saved as a WordPress revision first). ' +
63
+ 'Pass --overwrite to take them over.');
64
+ if (!isInteractive() || !(await confirm({ default: false, message: `Take over ${pluralize(slugs.length, 'page')}?` }))) {
65
+ this.failedCount += slugs.length;
66
+ return;
67
+ }
68
+ await this.runPushTasks(this.unmanaged, (page) => page.slug, async (page, task) => {
69
+ if (await this.pushPage(page, task, true))
70
+ pushed.push(page.slug);
71
+ });
72
+ }
73
+ // Resolves false when the page was set aside for adoptUnmanaged instead of pushed.
74
+ async pushPage(page, task, adopt = this.overwrite) {
49
75
  if (this.dryRun) {
50
76
  if (task)
51
77
  task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
52
- return;
78
+ return true;
53
79
  }
54
80
  try {
55
- const result = await this.wp.put(PAGES_ENDPOINT, page);
81
+ const result = await this.wp.put(PAGES_ENDPOINT, adopt ? { ...page, adopt } : page);
56
82
  if (task)
57
83
  task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}${frontPageNote(result)}`;
84
+ return true;
58
85
  }
59
86
  catch (error) {
87
+ if (!adopt && isConflictCode(error, 'loopress_page_not_managed')) {
88
+ this.unmanaged.push(page);
89
+ // skip(): Listr's own "skipped" mark, not the green check of a page that was pushed.
90
+ task?.skip?.(`${page.slug} already exists on the site, not created by Loopress`);
91
+ return false;
92
+ }
60
93
  this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
61
94
  }
62
95
  }
@@ -1,4 +1,4 @@
1
1
  import { declaredPushCommand } from '../../lib/declared-type-command.js';
2
2
  import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
3
  export default declaredPushCommand(PATTERN_SPEC, 'Push local block patterns (patterns/<slug>.html, block markup under a title/categories header) to WordPress, ' +
4
- 'registered as loopress/<slug>. Create or update only, never deletes a pattern (use `lps pattern rm`).');
4
+ 'registered as loopress/<slug> and referenced with <!-- wp:pattern {"slug":"loopress/<slug>"} /-->. Create or update only, never deletes a pattern (use `lps pattern rm`).');
@@ -1,3 +1,3 @@
1
1
  import { declaredRmCommand } from '../../lib/declared-type-command.js';
2
2
  import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
- export default declaredRmCommand(PATTERN_SPEC, 'Stop registering a Loopress block pattern on WordPress. A wp:pattern reference to it renders nothing, copies already inserted in posts stay.', 'A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.');
3
+ export default declaredRmCommand(PATTERN_SPEC, 'Stop registering a Loopress block pattern on WordPress. A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.', 'A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.');
@@ -6,7 +6,7 @@ import { compareVersions } from '../../utils/version.js';
6
6
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
7
7
  const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
8
8
  export default class Audit extends LoopressCommand {
9
- static description = 'Check the plugins in loopress.json for known vulnerabilities and health issues';
9
+ static description = 'Check the plugins in loopress.json for known vulnerabilities (wpvulnerability.net) and health issues';
10
10
  static enableJsonFlag = true;
11
11
  static examples = ['$ lps plugin audit'];
12
12
  static flags = {};
@@ -28,7 +28,12 @@ export default class Audit extends LoopressCommand {
28
28
  }
29
29
  async auditOne(slug, pinned) {
30
30
  const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
31
- 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 }));
32
37
  const health = info ? describeHealth(slug, pinned, info) : [];
33
38
  return { advisories, health };
34
39
  }
@@ -72,8 +77,13 @@ export default class Audit extends LoopressCommand {
72
77
  const noun = advisories.length === 1 ? 'advisory' : 'advisories';
73
78
  this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
74
79
  'Vulnerability data: wpvulnerability.net.');
75
- if (advisories.length > 0)
76
- 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
+ }
77
87
  return { advisories, health, status: 'issues' };
78
88
  }
79
89
  }
@@ -86,34 +96,41 @@ function normalizeAdvisory(raw) {
86
96
  const cvss = (raw.cvss ?? {});
87
97
  const source = Array.isArray(raw.source) ? raw.source : [];
88
98
  const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
99
+ const range = extractRange(raw);
89
100
  return {
90
101
  cve,
91
- fixedIn: extractFixedIn(raw),
102
+ fixedIn: range.max?.op === 'lt' ? range.max.version : undefined,
103
+ range,
92
104
  score: cvss.score,
93
105
  severity: cvss.severity,
94
- title: typeof raw.name === 'string' ? raw.name : 'Unnamed advisory',
106
+ title: typeof raw.name === 'string' ? decodeEntities(raw.name) : 'Unnamed advisory',
95
107
  };
96
108
  }
97
- // wpvulnerability's `impact.software[].versions[]` carries `to_version` / `to_compare`; the
98
- // first "< X" bound is the version the fix landed in. Best-effort: the shape varies, so a
99
- // missing bound just means we can't tell and the advisory is reported anyway.
100
- function extractFixedIn(raw) {
101
- const impact = raw.impact;
102
- for (const sw of impact?.software ?? []) {
103
- for (const v of sw.versions ?? []) {
104
- if (v.to_compare === '<' && typeof v.to_version === 'string')
105
- return v.to_version;
106
- }
107
- }
108
- 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 }));
109
127
  }
110
- // If we know the fix version, a pin below it is affected. If we can't compare (odd version
111
- // strings), report it anyway: a false positive is safer than hiding an advisory.
112
- function affects(pinned, fixedIn) {
113
- if (!fixedIn)
114
- return true;
115
- const order = compareVersions(pinned, fixedIn);
116
- 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;', '&');
117
134
  }
118
135
  function describeHealth(slug, pinned, info) {
119
136
  const notes = [];
@@ -20,7 +20,7 @@ const IN_SYNC = {
20
20
  status: 'in-sync',
21
21
  };
22
22
  export default class Push extends PushCommand {
23
- static description = 'Install plugins on WordPress to match loopress.json (or composer.json), via Composer + WPackagist';
23
+ static description = 'Install WordPress.org plugins on WordPress to match loopress.json (active state included) or composer.json, via Composer + WPackagist';
24
24
  static enableJsonFlag = true;
25
25
  static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
26
26
  static flags = {
@@ -4,7 +4,7 @@ import { LoopressCommand } from '../../lib/base.js';
4
4
  import { isNotFoundError } from '../../lib/wp-client.js';
5
5
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
6
6
  export default class Status extends LoopressCommand {
7
- static description = 'Compare the plugins on WordPress against loopress.json and report drift';
7
+ static description = 'Compare the plugins on WordPress against loopress.json and report drift (missing, wrong version, inactive, untracked)';
8
8
  static enableJsonFlag = true;
9
9
  static examples = ['$ lps plugin status'];
10
10
  static flags = {};
@@ -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 globalStylesSkipNote;
21
22
  }
22
23
  export {};
@@ -1,6 +1,9 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
2
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
+ import { INTEGRATION_MISSING } from '../lib/plugin-detection.js';
3
4
  import { pluralize } from '../utils/pluralize.js';
5
+ import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
6
+ import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
4
7
  // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
8
  // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
6
9
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
@@ -11,17 +14,20 @@ const PULL_TARGETS = [
11
14
  { commandId: 'taxonomy:pull', label: 'taxonomies', supportsYes: true },
12
15
  { commandId: 'meta:pull', label: 'post meta', supportsYes: true },
13
16
  { commandId: 'role:pull', label: 'roles', supportsYes: true },
14
- { commandId: 'acf:pull', label: 'ACF', supportsYes: true },
17
+ { commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
15
18
  { commandId: 'api:pull', label: 'API routes', supportsYes: true },
16
19
  { commandId: 'hook:pull', label: 'hooks', supportsYes: true },
17
- { commandId: 'form:pull', label: 'forms', supportsYes: true },
20
+ { commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
18
21
  { commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
22
+ // Templates and parts have no pull. Global Styles are pulled only once the project tracks them,
23
+ // see globalStylesSkipNote.
24
+ { commandId: 'theme:style:pull', label: 'Global Styles' },
19
25
  { commandId: 'menu:pull', label: 'menus', supportsYes: true },
20
26
  { commandId: 'option:pull', label: 'options', supportsYes: true },
21
- { commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
27
+ { commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
22
28
  ];
23
29
  export default class Pull extends LoopressCommand {
24
- 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';
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';
25
31
  static enableJsonFlag = true;
26
32
  static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
27
33
  static flags = {
@@ -33,6 +39,12 @@ export default class Pull extends LoopressCommand {
33
39
  const results = [];
34
40
  this.warnRemovedSeoModule();
35
41
  for (const target of PULL_TARGETS) {
42
+ const skipNote = target.commandId === 'theme:style:pull' ? await this.globalStylesSkipNote() : undefined;
43
+ if (skipNote) {
44
+ this.log(`\n→ ${target.label}: ${skipNote}`);
45
+ results.push({ label: target.label, status: 'skipped' });
46
+ continue;
47
+ }
36
48
  this.log(`\n→ Pulling ${target.label}...`);
37
49
  try {
38
50
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
@@ -40,8 +52,19 @@ export default class Pull extends LoopressCommand {
40
52
  results.push({ label: target.label, status: 'pulled' });
41
53
  }
42
54
  catch (error) {
43
- this.failedCount++;
44
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
+ }
67
+ this.failedCount++;
45
68
  this.log(`✗ ${target.label} failed: ${message}`);
46
69
  results.push({ error: message, label: target.label, status: 'failed' });
47
70
  }
@@ -65,4 +88,13 @@ export default class Pull extends LoopressCommand {
65
88
  argv.push('--yes');
66
89
  return argv;
67
90
  }
91
+ // The site unreachable or the theme list refused: let the step run and report it itself.
92
+ async globalStylesSkipNote() {
93
+ try {
94
+ return await globalStylesSkipNote(this.wp, resolveResourceDir('themeStyles', this.localConfig), 'pull');
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
68
100
  }
@@ -2,7 +2,8 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PushTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
- status: 'failed' | 'pushed';
5
+ result?: unknown;
6
+ status: 'failed' | 'pushed' | 'skipped';
6
7
  };
7
8
  type PushResult = {
8
9
  results: PushTargetResult[];
@@ -12,13 +13,16 @@ export default class Push extends LoopressCommand {
12
13
  static enableJsonFlag: boolean;
13
14
  static examples: string[];
14
15
  static flags: {
16
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  };
18
- private failedCount;
19
20
  run(): Promise<PushResult>;
20
21
  private buildArgv;
21
22
  private composerDeclaresPlugins;
23
+ private composerJsonPath;
24
+ private globalStylesSkipNote;
22
25
  private guardProductionPush;
26
+ private skippedTargets;
23
27
  }
24
28
  export {};
@@ -4,9 +4,15 @@ import { join } from 'node:path';
4
4
  import { LoopressCommand } from '../lib/base.js';
5
5
  import { guardProductionPush } from '../lib/guard-production-push.js';
6
6
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
+ import { PushCommand } from '../lib/push-command.js';
7
8
  import { isLocalEnvironment } from '../lib/wp-client.js';
8
9
  import { wpackagistRequire } from '../utils/composer.js';
9
- import { pluralize } from '../utils/pluralize.js';
10
+ import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
11
+ import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
12
+ // Backed by a plugin the site may not have (ACF, a form or a snippet plugin): a project without
13
+ // the resource's directory doesn't use it, and its push, dry run included, would only stop on the
14
+ // site's 409 or the missing directory.
15
+ const PLUGIN_BACKED = { 'acf:push': 'acf', 'form:push': 'form', 'snippet:push': 'snippets' };
10
16
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
11
17
  // relies on, then content itself.
12
18
  const PUSH_TARGETS = [
@@ -25,53 +31,53 @@ const PUSH_TARGETS = [
25
31
  { commandId: 'pattern:push', label: 'patterns' },
26
32
  // Before pages: WordPress refuses a page whose `template` it doesn't know yet.
27
33
  { commandId: 'theme:template:push', label: 'templates and parts' },
34
+ // Belong to whichever theme is active, so right after its templates. Theme versions stay out:
35
+ // installing or removing themes is `lps theme push`'s call.
36
+ { commandId: 'theme:style:push', label: 'Global Styles' },
28
37
  { commandId: 'page:push', label: 'pages' },
29
38
  { commandId: 'menu:push', label: 'menus' },
30
39
  { commandId: 'option:push', label: 'options' },
31
40
  { commandId: 'snippet:push', label: 'snippets' },
32
41
  ];
33
42
  export default class Push extends LoopressCommand {
34
- static description = 'Push all local content, plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, templates, pages, menus, options, and snippets, to WordPress';
43
+ static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
35
44
  static enableJsonFlag = true;
36
45
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
37
46
  static flags = {
38
47
  ...LoopressCommand.dryRunFlag,
39
48
  ...LoopressCommand.yesFlag,
49
+ overwrite: PushCommand.baseFlags.overwrite,
40
50
  };
41
- failedCount = 0;
42
51
  async run() {
43
52
  await this.guardProductionPush();
44
53
  this.warnRemovedSeoModule();
45
54
  const argv = this.buildArgv();
46
55
  const results = [];
47
- const composerPushedByPlugins = await this.composerDeclaresPlugins();
56
+ const skips = await this.skippedTargets();
48
57
  for (const target of PUSH_TARGETS) {
49
- if (composerPushedByPlugins && target.commandId === 'composer:push') {
50
- this.log('\n→ composer: already pushed whole by plugins');
51
- results.push({ label: target.label, status: 'pushed' });
58
+ const skip = skips.get(target.commandId);
59
+ if (skip) {
60
+ this.log(`\n→ ${target.label}: ${skip.note}`);
61
+ results.push({ label: target.label, status: skip.status });
52
62
  continue;
53
63
  }
54
64
  this.log(`\n→ Pushing ${target.label}...`);
55
65
  try {
56
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
66
+ const result = await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
57
67
  this.log(this.dryRun ? `✓ ${target.label} would push` : `✓ ${target.label} pushed`);
58
- results.push({ label: target.label, status: 'pushed' });
68
+ results.push({ label: target.label, result, status: 'pushed' });
59
69
  }
60
70
  catch (error) {
61
- this.failedCount++;
62
71
  const { message } = error;
63
72
  this.log(`✗ ${target.label} failed: ${message}`);
64
73
  results.push({ error: message, label: target.label, status: 'failed' });
74
+ // Stop here: later targets depend on earlier ones (hooks use what composer installs,
75
+ // pages need their templates), so pushing them anyway can ship a silently broken site.
76
+ const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1).map((next) => next.label);
77
+ const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
78
+ this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
65
79
  }
66
80
  }
67
- if (this.failedCount > 0) {
68
- // Each failure's own reason, not just the count: under --json the per-resource lines above
69
- // never reach stdout, and this message is all an MCP caller or a script gets to see.
70
- const failures = results
71
- .filter((result) => result.status === 'failed')
72
- .map((result) => `${result.label}: ${result.error}`);
73
- this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
74
- }
75
81
  this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
76
82
  return { results };
77
83
  }
@@ -82,12 +88,15 @@ export default class Push extends LoopressCommand {
82
88
  const argv = ['--env', this.siteConfig.name, '--yes'];
83
89
  if (this.dryRun)
84
90
  argv.push('--dry-run');
91
+ // Not implied by --yes: overwriting changes made outside Loopress is its own decision.
92
+ if (this.overwrite)
93
+ argv.push('--overwrite');
85
94
  return argv;
86
95
  }
87
96
  // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
88
97
  // safety), so `composer:push` would only run Composer on the server a second time.
89
98
  async composerDeclaresPlugins() {
90
- const path = join(process.cwd(), this.rootDir, 'composer.json');
99
+ const path = this.composerJsonPath();
91
100
  if (!existsSync(path))
92
101
  return false;
93
102
  try {
@@ -99,6 +108,18 @@ export default class Push extends LoopressCommand {
99
108
  return false;
100
109
  }
101
110
  }
111
+ composerJsonPath() {
112
+ return join(process.cwd(), this.rootDir, 'composer.json');
113
+ }
114
+ // The site unreachable or the theme list refused: let the step run and report it itself.
115
+ async globalStylesSkipNote() {
116
+ try {
117
+ return await globalStylesSkipNote(this.wp, resolveResourceDir('themeStyles', this.localConfig), 'push');
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
102
123
  // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
103
124
  // delegated command.
104
125
  async guardProductionPush() {
@@ -109,4 +130,28 @@ export default class Push extends LoopressCommand {
109
130
  yes: this.yes,
110
131
  });
111
132
  }
133
+ // Unconfigured is not a failure: a project that doesn't manage plugins or composer through
134
+ // Loopress must not stop the run before anything else gets pushed.
135
+ async skippedTargets() {
136
+ const skips = new Map();
137
+ const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
138
+ const hasComposerJson = existsSync(this.composerJsonPath());
139
+ const composerPushedByPlugins = await this.composerDeclaresPlugins();
140
+ if (composerPushedByPlugins)
141
+ skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
142
+ else if (!hasComposerJson)
143
+ skips.set('composer:push', notConfigured);
144
+ // With a composer.json, plugins come from it only; otherwise from loopress.json.
145
+ const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
146
+ if (!pluginsConfigured)
147
+ skips.set('plugin:push', notConfigured);
148
+ for (const [commandId, kind] of Object.entries(PLUGIN_BACKED)) {
149
+ if (!projectUses(kind, this.localConfig))
150
+ skips.set(commandId, notConfigured);
151
+ }
152
+ const stylesNote = await this.globalStylesSkipNote();
153
+ if (stylesNote)
154
+ skips.set('theme:style:push', { note: stylesNote, status: 'skipped' });
155
+ return skips;
156
+ }
112
157
  }
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../../lib/base.js';
2
2
  import { pluralize } from '../../utils/pluralize.js';
3
3
  import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
4
4
  export default class List extends LoopressCommand {
5
- static description = 'List snippets from WordPress';
5
+ static description = 'List snippets currently on WordPress';
6
6
  static enableJsonFlag = true;
7
7
  static examples = ['$ lps snippet list'];
8
8
  async run() {
@@ -17,7 +17,7 @@ export default class Pull extends LoopressCommand {
17
17
  static args = {
18
18
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
19
19
  };
20
- static description = 'Pull snippets from WordPress';
20
+ static description = 'Pull snippets from WordPress into local files';
21
21
  static enableJsonFlag = true;
22
22
  static examples = ['$ lps snippet pull', '$ lps snippet pull ./snippets'];
23
23
  static flags = {
@@ -1,9 +1,10 @@
1
- import { PushCommand } from '../../lib/push-command.js';
1
+ import { type PushChange, PushCommand } from '../../lib/push-command.js';
2
2
  type PushedSnippet = {
3
3
  id?: number;
4
4
  name: string;
5
5
  };
6
6
  type PushResult = {
7
+ changes?: PushChange[];
7
8
  pushed: PushedSnippet[];
8
9
  status: 'dry-run' | 'success';
9
10
  };
@@ -24,5 +25,6 @@ export default class Push extends PushCommand {
24
25
  private loadSnippets;
25
26
  private pushSnippet;
26
27
  private toPayload;
28
+ private withoutDuplicateIds;
27
29
  }
28
30
  export {};