@loopress/cli 0.26.0 → 0.28.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 (87) hide show
  1. package/README.md +275 -177
  2. package/dist/commands/app/pull.js +17 -4
  3. package/dist/commands/app/push.d.ts +3 -0
  4. package/dist/commands/app/push.js +29 -5
  5. package/dist/commands/composer/push.js +1 -19
  6. package/dist/commands/diff.js +1 -1
  7. package/dist/commands/init.js +7 -1
  8. package/dist/commands/page/push.js +2 -2
  9. package/dist/commands/plugin/add.d.ts +8 -1
  10. package/dist/commands/plugin/add.js +10 -5
  11. package/dist/commands/plugin/audit.js +4 -5
  12. package/dist/commands/plugin/pull.d.ts +3 -1
  13. package/dist/commands/plugin/pull.js +17 -9
  14. package/dist/commands/plugin/push.d.ts +5 -1
  15. package/dist/commands/plugin/push.js +90 -37
  16. package/dist/commands/plugin/status.d.ts +1 -0
  17. package/dist/commands/plugin/status.js +21 -2
  18. package/dist/commands/project/config.js +12 -8
  19. package/dist/commands/promote.d.ts +2 -0
  20. package/dist/commands/promote.js +70 -8
  21. package/dist/commands/pull.js +8 -4
  22. package/dist/commands/push.d.ts +1 -0
  23. package/dist/commands/push.js +42 -4
  24. package/dist/commands/template/diff.d.ts +10 -0
  25. package/dist/commands/template/diff.js +13 -0
  26. package/dist/commands/template/list.d.ts +13 -0
  27. package/dist/commands/template/list.js +23 -0
  28. package/dist/commands/template/push.d.ts +20 -0
  29. package/dist/commands/template/push.js +42 -0
  30. package/dist/commands/theme/add.d.ts +8 -1
  31. package/dist/commands/theme/add.js +4 -2
  32. package/dist/commands/theme/pull.d.ts +3 -1
  33. package/dist/commands/theme/pull.js +10 -4
  34. package/dist/commands/theme/style/diff.d.ts +2 -0
  35. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  36. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  37. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  38. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  39. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  40. package/dist/commands/theme/style/rollback.d.ts +2 -0
  41. package/dist/commands/theme/style/rollback.js +6 -0
  42. package/dist/lib/api-client.js +26 -1
  43. package/dist/lib/app-build.d.ts +5 -0
  44. package/dist/lib/app-build.js +69 -0
  45. package/dist/lib/app-manifest.d.ts +4 -0
  46. package/dist/lib/app-manifest.js +8 -0
  47. package/dist/lib/base.js +11 -2
  48. package/dist/lib/bootstrap-full-install.js +45 -14
  49. package/dist/lib/dev-watch.d.ts +1 -1
  50. package/dist/lib/dev-watch.js +4 -1
  51. package/dist/lib/diff-command.d.ts +1 -0
  52. package/dist/lib/diff-command.js +6 -4
  53. package/dist/lib/json-delegation.d.ts +1 -0
  54. package/dist/lib/json-delegation.js +18 -0
  55. package/dist/lib/load-snippets.js +1 -1
  56. package/dist/lib/php-files-command.js +4 -3
  57. package/dist/lib/readdir-tolerant.js +15 -5
  58. package/dist/lib/resource-state.js +43 -1
  59. package/dist/lib/rollback-command.d.ts +1 -0
  60. package/dist/lib/rollback-command.js +8 -6
  61. package/dist/lib/rotate-app-password.js +2 -2
  62. package/dist/lib/snapshot-store.js +14 -0
  63. package/dist/lib/validate-local.js +34 -7
  64. package/dist/lib/wp-client.d.ts +4 -1
  65. package/dist/lib/wp-client.js +56 -3
  66. package/dist/types/plugin.d.ts +8 -1
  67. package/dist/types/plugin.js +4 -1
  68. package/dist/types/project-config.generated.d.ts +19 -2
  69. package/dist/utils/composer.d.ts +14 -0
  70. package/dist/utils/composer.js +136 -0
  71. package/dist/utils/loopress-config.d.ts +1 -0
  72. package/dist/utils/loopress-config.js +14 -0
  73. package/dist/utils/page-format.d.ts +12 -0
  74. package/dist/utils/page-format.js +41 -26
  75. package/dist/utils/plugins.d.ts +9 -4
  76. package/dist/utils/plugins.js +16 -7
  77. package/dist/utils/resource-dirs.d.ts +2 -0
  78. package/dist/utils/resource-dirs.js +4 -0
  79. package/dist/utils/seo-format.d.ts +2 -2
  80. package/dist/utils/template-format.d.ts +30 -0
  81. package/dist/utils/template-format.js +36 -0
  82. package/oclif.manifest.json +536 -320
  83. package/package.json +7 -4
  84. package/schemas/project-config.schema.json +25 -4
  85. package/dist/commands/theme-styles/diff.d.ts +0 -2
  86. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  87. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -1,86 +1,104 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { confirmUninstall } from '../../lib/interactive.js';
5
6
  import { PushCommand } from '../../lib/push-command.js';
6
7
  import { isNotFoundError } from '../../lib/wp-client.js';
7
- import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
8
+ import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
+ import { toIntent, wpackagistRequire } from '../../utils/composer.js';
10
+ import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
11
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
9
- const IN_SYNC = { activated: [], installed: [], pinned: [], pruned: [], removed: [], status: 'in-sync' };
12
+ import { isExactVersion } from '../../utils/version.js';
13
+ const IN_SYNC = {
14
+ activated: [],
15
+ deactivated: [],
16
+ installed: [],
17
+ pinned: [],
18
+ pruned: [],
19
+ removed: [],
20
+ status: 'in-sync',
21
+ };
10
22
  export default class Push extends PushCommand {
11
- static description = 'Install plugins on WordPress to match loopress.json, pinned via Composer + WPackagist';
23
+ static description = 'Install plugins on WordPress to match loopress.json (or composer.json), via Composer + WPackagist';
12
24
  static enableJsonFlag = true;
13
25
  static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
14
26
  static flags = {
15
27
  ...PushCommand.dryRunFlag,
16
28
  ...PushCommand.yesFlag,
29
+ activate: Flags.boolean({
30
+ default: false,
31
+ description: 'composer.json projects only: activate every plugin it declares. Without it, plugins keep their current state and new ones stay inactive',
32
+ }),
17
33
  force: Flags.boolean({
18
34
  default: false,
19
35
  description: 'Allow downgrades and let Loopress take over plugins installed outside it (replaces their files)',
20
36
  }),
21
37
  prune: Flags.boolean({
22
38
  default: false,
23
- description: 'Deactivate plugins that are active on the site but absent from loopress.json',
39
+ description: 'Deactivate plugins that are active on the site but absent from loopress.json (or composer.json)',
24
40
  }),
25
41
  };
26
42
  async run() {
27
43
  const { flags } = await this.parse(Push);
28
- const { force, prune } = flags;
29
- if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
30
- this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer push` instead.');
31
- return { ...IN_SYNC, status: 'composer-managed' };
32
- }
33
- const manifest = this.localConfig.plugins ?? {};
34
- if (Object.keys(manifest).length === 0) {
35
- this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
36
- }
44
+ const { activate, force, prune } = flags;
45
+ const source = await this.loadSource(activate);
46
+ const fromComposer = source.file === 'composer.json';
37
47
  this.log(`Pushing plugins to ${this.siteConfig.url}`);
48
+ if (fromComposer) {
49
+ this.log(`From composer.json, pushed whole (${source.packageCount} packages): themes and libraries it declares are synced too.`);
50
+ }
38
51
  const raw = await this.wp.get('wp/v2/plugins');
39
52
  const installed = parseInstalledPlugins(raw);
40
53
  const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'plugin');
41
- const diff = diffPlugins(manifest, installed, managed);
42
- this.guardForce(diff, force);
54
+ const diff = diffPlugins(source.manifest, installed, managed);
55
+ // composer.json records no active state: leave inactive plugins alone unless --activate.
56
+ const skippedActivations = fromComposer && !activate ? diff.toActivate : [];
57
+ if (fromComposer && !activate)
58
+ diff.toActivate = [];
59
+ this.guardForce(diff, force, source.file);
43
60
  const toPrune = prune ? diff.untrackedActive : [];
44
- // A "latest" pin never shows as drift, but its newest upstream release may have moved
45
- // since the last push, so a push must still run `composer update` to pick it up.
46
- const hasLatestPin = Object.values(manifest).includes('latest');
47
- if (!hasLatestPin && isNoop(diff, toPrune)) {
61
+ // A "latest" pin (or a Composer constraint) never shows as drift, but its newest upstream
62
+ // release may have moved since the last push, so a push must still run `composer update`.
63
+ const hasFloatingPin = Object.values(source.manifest).some((pin) => !isExactVersion(pinVersion(pin)));
64
+ if (!hasFloatingPin && isNoop(diff, toPrune)) {
48
65
  this.log('Everything is already in sync.');
66
+ this.logStillInactive(skippedActivations);
49
67
  return { ...IN_SYNC };
50
68
  }
51
69
  if (isNoop(diff, toPrune))
52
- this.log('Refreshing plugins pinned to "latest" to their newest releases.');
70
+ this.log('Refreshing plugins not pinned to an exact version to their newest releases.');
53
71
  logPlan(this, diff, toPrune, force);
54
72
  if (this.dryRun) {
55
73
  return this.result(diff, { includeCollisions: true, pruned: toPrune, removed: diff.toRemove, status: 'dry-run' });
56
74
  }
57
75
  if (!(await confirmUninstall(diff.toRemove, this.yes)))
58
76
  this.error('Aborted.');
59
- const deactivated = await this.deactivateEndangered(installed, diff, force);
60
- // Plugins that must end up active: the ones we deactivated for the file swap (minus any
61
- // being uninstalled) plus the ones the manifest wants active but that are inactive today.
62
- const toReactivate = [...deactivated.filter((p) => !diff.toRemove.includes(p.slug)), ...diff.toActivate];
77
+ // Plugins switched off for the file swap come back on, unless loopress.json wants them inactive.
78
+ const deactivated = (await this.deactivateEndangered(installed, diff, force)).filter((p) => !diff.toRemove.includes(p.slug) && (fromComposer || pinActive(source.manifest[p.slug])));
63
79
  let response;
64
80
  try {
65
- response = await this.sync(manifest, force);
81
+ response = await this.sync(source.intent, force);
66
82
  }
67
83
  catch (error) {
68
84
  // The file swap never happened, so restore the plugins we defensively deactivated
69
85
  // instead of leaving the site with them switched off.
70
- await this.activate(deactivated.filter((p) => !diff.toRemove.includes(p.slug)));
86
+ await this.activate(deactivated);
71
87
  throw error;
72
88
  }
73
- toReactivate.push(...(await this.filesForNewlyInstalled(diff.toInstall)));
74
- if (toPrune.length > 0) {
75
- await this.deactivate(installed.filter((p) => toPrune.includes(p.slug)));
76
- }
77
- await this.activate(toReactivate);
89
+ const activateNew = (slug) => (fromComposer ? activate : pinActive(source.manifest[slug]));
90
+ const newlyInstalled = await this.filesForNewlyInstalled(diff.toInstall);
91
+ const toActivate = [...deactivated, ...diff.toActivate, ...newlyInstalled.filter((p) => activateNew(p.slug))];
92
+ const toDeactivate = [...diff.toDeactivate, ...installed.filter((p) => toPrune.includes(p.slug))];
93
+ await this.deactivate(toDeactivate);
94
+ await this.activate(toActivate);
78
95
  if (response.output.trim())
79
96
  this.log(response.output.trim());
80
97
  this.log('Plugins synced.');
98
+ this.logStillInactive([...skippedActivations, ...newlyInstalled.filter((p) => !activateNew(p.slug))], fromComposer);
81
99
  await this.recordSuccess();
82
100
  return this.result(diff, {
83
- activated: toReactivate.map((p) => p.slug),
101
+ activated: toActivate.map((p) => p.slug),
84
102
  includeCollisions: force,
85
103
  pruned: toPrune,
86
104
  removed: response.removed ?? diff.toRemove,
@@ -140,26 +158,59 @@ export default class Push extends PushCommand {
140
158
  return file ? [{ file, slug }] : [];
141
159
  });
142
160
  }
143
- guardForce(diff, force) {
161
+ guardForce(diff, force, file) {
144
162
  if (force)
145
163
  return;
146
164
  if (diff.collisions.length > 0) {
147
165
  const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
148
166
  this.error(`${diff.collisions.length} plugin(s) are already installed outside Loopress: ${list}. ` +
149
167
  'Re-run with --force to let Loopress manage them (this replaces their files with the WPackagist build, ' +
150
- 'local modifications are lost), or remove them from loopress.json.');
168
+ `local modifications are lost), or remove them from ${file}.`);
151
169
  }
152
170
  const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
153
171
  if (downgrades.length > 0) {
154
172
  const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
155
173
  this.error(`Refusing to downgrade: ${list}. A downgrade only replaces plugin files, it does not undo database ` +
156
- 'migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in loopress.json.');
174
+ `migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in ${file}.`);
157
175
  }
158
176
  }
177
+ async loadSource(activate) {
178
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
179
+ if (existsSync(composerJsonPath)) {
180
+ const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
181
+ const manifest = wpackagistRequire(composerJson, 'plugin');
182
+ if (Object.keys(manifest).length === 0) {
183
+ this.error('No wpackagist-plugin/* package in composer.json. Run `lps plugin pull` first, or `lps composer push` to push it as is.');
184
+ }
185
+ const require = composerJson.require ?? {};
186
+ return { file: 'composer.json', intent: toIntent(require), manifest, packageCount: Object.keys(require).length };
187
+ }
188
+ if (activate) {
189
+ this.error('--activate only applies to projects with a composer.json: loopress.json already records whether each plugin is active.');
190
+ }
191
+ const manifest = this.localConfig.plugins ?? {};
192
+ if (Object.keys(manifest).length === 0) {
193
+ this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
194
+ }
195
+ return {
196
+ file: 'loopress.json',
197
+ intent: { plugins: pinVersions(manifest) },
198
+ manifest,
199
+ packageCount: Object.keys(manifest).length,
200
+ };
201
+ }
202
+ // composer.json projects: plugins this push left inactive, and how to change that.
203
+ logStillInactive(plugins, fromComposer = true) {
204
+ if (!fromComposer || plugins.length === 0)
205
+ return;
206
+ this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
207
+ this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
208
+ }
159
209
  result(diff, opts) {
160
210
  const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
161
211
  return {
162
212
  activated: opts.activated ?? diff.toActivate.map((a) => a.slug),
213
+ deactivated: diff.toDeactivate.map((a) => a.slug),
163
214
  installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
164
215
  pinned: diff.toPin.map((p) => p.slug),
165
216
  pruned: opts.pruned,
@@ -167,9 +218,9 @@ export default class Push extends PushCommand {
167
218
  status: opts.status,
168
219
  };
169
220
  }
170
- async sync(manifest, force) {
221
+ async sync(intent, force) {
171
222
  try {
172
- return await this.wp.post('loopress/v1/composer/sync', { force, intent: { plugins: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
223
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
173
224
  }
174
225
  catch (error) {
175
226
  const collisions = parseCollisions(error);
@@ -184,6 +235,7 @@ function isNoop(diff, toPrune) {
184
235
  return (diff.toInstall.length === 0 &&
185
236
  diff.toPin.length === 0 &&
186
237
  diff.toActivate.length === 0 &&
238
+ diff.toDeactivate.length === 0 &&
187
239
  diff.toRemove.length === 0 &&
188
240
  diff.collisions.length === 0 &&
189
241
  toPrune.length === 0);
@@ -201,6 +253,7 @@ function logPlan(cmd, diff, prune, force) {
201
253
  section('To take over', diff.collisions.map((c) => `! ${c.slug} (installed outside Loopress)`));
202
254
  section('To re-pin', diff.toPin.map((p) => `~ ${p.slug} ${p.from} to ${p.to}`));
203
255
  section('To activate', diff.toActivate.map((a) => `↑ ${a.slug}`));
256
+ section('To deactivate', diff.toDeactivate.map((a) => `↓ ${a.slug}`));
204
257
  section('To uninstall', diff.toRemove.map((s) => `- ${s}`));
205
258
  section('To deactivate (--prune)', prune.map((s) => `⊘ ${s}`));
206
259
  }
@@ -4,6 +4,7 @@ type StatusResult = {
4
4
  inactive: string[];
5
5
  missing: string[];
6
6
  status: 'composer-managed' | 'drift' | 'in-sync';
7
+ unexpectedlyActive?: string[];
7
8
  untrackedActive: string[];
8
9
  wrongVersion: Array<{
9
10
  live: string;
@@ -12,7 +12,14 @@ export default class Status extends LoopressCommand {
12
12
  const { url } = this.siteConfig;
13
13
  if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
14
14
  this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer` commands instead.');
15
- return { drift: false, inactive: [], missing: [], status: 'composer-managed', untrackedActive: [], wrongVersion: [] };
15
+ return {
16
+ drift: false,
17
+ inactive: [],
18
+ missing: [],
19
+ status: 'composer-managed',
20
+ untrackedActive: [],
21
+ wrongVersion: [],
22
+ };
16
23
  }
17
24
  const manifest = this.localConfig.plugins ?? {};
18
25
  const raw = await this.wp.get('wp/v2/plugins');
@@ -22,6 +29,7 @@ export default class Status extends LoopressCommand {
22
29
  const missing = diff.toInstall.map((a) => a.slug);
23
30
  const wrongVersion = diff.toPin.map((p) => ({ live: p.from, pinned: p.to, slug: p.slug }));
24
31
  const inactive = diff.toActivate.map((a) => a.slug);
32
+ const unexpectedlyActive = diff.toDeactivate.map((a) => a.slug);
25
33
  const { untrackedActive } = diff;
26
34
  // `collisions` are also drift (Loopress doesn't own them yet); surface them under missing's
27
35
  // banner so `plugin push --force` is the obvious next step.
@@ -29,6 +37,7 @@ export default class Status extends LoopressCommand {
29
37
  const drift = missing.length > 0 ||
30
38
  wrongVersion.length > 0 ||
31
39
  inactive.length > 0 ||
40
+ unexpectedlyActive.length > 0 ||
32
41
  diff.toRemove.length > 0 ||
33
42
  collisions.length > 0;
34
43
  if (!drift && untrackedActive.length === 0) {
@@ -43,13 +52,23 @@ export default class Status extends LoopressCommand {
43
52
  this.log(`Version drift: ${w.slug} is ${w.live}, loopress.json pins ${w.pinned}`);
44
53
  if (inactive.length > 0)
45
54
  this.log(`Pinned but inactive: ${inactive.join(', ')}`);
55
+ if (unexpectedlyActive.length > 0)
56
+ this.log(`Pinned inactive but active: ${unexpectedlyActive.join(', ')}`);
46
57
  if (diff.toRemove.length > 0)
47
58
  this.log(`Managed by Loopress but dropped from loopress.json: ${diff.toRemove.join(', ')}`);
48
59
  if (untrackedActive.length > 0)
49
60
  this.log(`Active but untracked: ${untrackedActive.join(', ')}`);
50
61
  if (drift)
51
62
  this.exit(1);
52
- return { drift, inactive, missing, status: drift ? 'drift' : 'in-sync', untrackedActive, wrongVersion };
63
+ return {
64
+ drift,
65
+ inactive,
66
+ missing,
67
+ status: drift ? 'drift' : 'in-sync',
68
+ unexpectedlyActive,
69
+ untrackedActive,
70
+ wrongVersion,
71
+ };
53
72
  }
54
73
  async fetchInstanceLock() {
55
74
  try {
@@ -5,7 +5,7 @@ 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
7
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
- import { WpClient } from '../../lib/wp-client.js';
8
+ import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
9
9
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
10
10
  const NEW_PROJECT = '__new__';
11
11
  const AUTH_BROWSER = 'browser';
@@ -48,16 +48,20 @@ export default class Config extends Command {
48
48
  const rawUrl = await input({
49
49
  message: 'WordPress URL',
50
50
  validate(value) {
51
+ let parsed;
51
52
  try {
52
- const parsed = new URL(value);
53
- if (!['http:', 'https:'].includes(parsed.protocol)) {
54
- return 'URL must start with http:// or https://';
55
- }
56
- return true;
53
+ parsed = new URL(value);
57
54
  }
58
55
  catch {
59
56
  return 'Invalid URL';
60
57
  }
58
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
59
+ return 'URL must start with http:// or https://';
60
+ }
61
+ if (parsed.protocol === 'http:' && !isLocalEnvironment(envName)) {
62
+ return `Plain HTTP would send the Application Password in cleartext. Use HTTPS. Plain HTTP is allowed only for the "local" environment (this one is "${envName}").`;
63
+ }
64
+ return true;
61
65
  },
62
66
  });
63
67
  let url = rawUrl;
@@ -88,10 +92,10 @@ export default class Config extends Command {
88
92
  this.log('→ Run `lps project switch` to change the active project or environment');
89
93
  }
90
94
  async maybeBootstrapLoopressFull(env) {
91
- const { token, url } = env;
95
+ const { name, token, url } = env;
92
96
  if (!token)
93
97
  return; // env was just built with one a few lines up; guard only narrows the type
94
- const wp = new WpClient(url, token);
98
+ const wp = new WpClient(url, token, name);
95
99
  // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
96
100
  // there" rather than surprising the user with an account-creating prompt they didn't ask
97
101
  // for; `lps project config` can simply be re-run if it genuinely isn't installed.
@@ -17,8 +17,10 @@ export default class Promote extends Command {
17
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  };
19
19
  run(): Promise<PromoteResult>;
20
+ private assertDirsInsideProject;
20
21
  private confirmPromotion;
21
22
  private delegateArgv;
23
+ private enterScratchCopy;
22
24
  private requireEnvironment;
23
25
  private resolveProject;
24
26
  }
@@ -1,15 +1,21 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Args, Command } from '@oclif/core';
3
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins -- stable since 22.3, works unflagged on 22.0+
4
+ import { cp, mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
7
  import { configManager } from '../config/project-config.manager.js';
4
8
  import { LoopressCommand } from '../lib/base.js';
5
9
  import { isInteractive } from '../lib/interactive.js';
10
+ import { stdoutToStderr } from '../lib/json-delegation.js';
6
11
  import { readLocalConfig } from '../utils/loopress-config.js';
12
+ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
7
13
  export default class Promote extends Command {
8
14
  static args = {
9
15
  from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
10
16
  to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
11
17
  };
12
- static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process.';
18
+ static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
13
19
  static enableJsonFlag = true;
14
20
  static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
15
21
  static flags = {
@@ -28,19 +34,55 @@ export default class Promote extends Command {
28
34
  }
29
35
  if (!dryRun)
30
36
  await this.confirmPromotion(from.name, to.name, to.url, yes);
31
- this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
37
+ // A dry-run pull writes nothing, so a dry-run push right after would preview the current
38
+ // local files instead of <from>. Instead, really pull into a throwaway copy of the project
39
+ // and dry-run the push from there: the preview is what a real promotion would push, and the
40
+ // user's own files stay untouched.
41
+ const originalCwd = process.cwd();
42
+ const scratch = dryRun ? await this.enterScratchCopy(originalCwd) : undefined;
43
+ // The delegated pull is real, so without this its auto-rotation could revoke a stale app
44
+ // password during what the user asked to be a preview (see LoopressCommand.maybeAutoRotate).
45
+ const previousNoRotate = process.env.LOOPRESS_NO_AUTO_ROTATE;
46
+ if (scratch)
47
+ process.env.LOOPRESS_NO_AUTO_ROTATE = '1';
32
48
  try {
33
- await this.config.runCommand('pull', this.delegateArgv(from.name, dryRun));
49
+ this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
50
+ try {
51
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
52
+ }
53
+ catch (error) {
54
+ // 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}`);
56
+ }
57
+ this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
58
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
34
59
  }
35
- catch (error) {
36
- // A partial pull must never be pushed onward: stop before touching <to>.
37
- this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
60
+ finally {
61
+ if (scratch) {
62
+ if (previousNoRotate === undefined)
63
+ delete process.env.LOOPRESS_NO_AUTO_ROTATE;
64
+ else
65
+ process.env.LOOPRESS_NO_AUTO_ROTATE = previousNoRotate;
66
+ process.chdir(originalCwd);
67
+ await rm(scratch, { force: true, recursive: true });
68
+ }
38
69
  }
39
- this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
40
- await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
41
70
  this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
42
71
  return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
43
72
  }
73
+ // The scratch pull writes wherever loopress.json points. An absolute or `../` rootDir or
74
+ // resource dir would land outside the copy, in the user's real files, during a dry run.
75
+ async assertDirsInsideProject(projectDir) {
76
+ const localConfig = await readLocalConfig();
77
+ const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
78
+ const outside = dirs.find((dir) => {
79
+ const rel = relative(projectDir, resolve(projectDir, dir));
80
+ return rel.startsWith('..') || isAbsolute(rel);
81
+ });
82
+ if (outside !== undefined) {
83
+ this.error(`--dry-run can't preview safely: "${outside}" in loopress.json is outside the project. Run \`lps diff --env <from> --against <to>\` instead.`);
84
+ }
85
+ }
44
86
  // Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
45
87
  // target, not just production, so it needs its own confirmation regardless of the name. That
46
88
  // prompt also calls out production when relevant, making a second production-only guard
@@ -69,6 +111,26 @@ export default class Promote extends Command {
69
111
  argv.push('--dry-run');
70
112
  return argv;
71
113
  }
114
+ // Copies the whole project (not just what `pull` rewrites): push also reads files pull never
115
+ // touches, and a real promotion pushes those as they are locally. node_modules and .git are
116
+ // never read by push. Returns the scratch root, cwd is left inside the copy.
117
+ async enterScratchCopy(projectDir) {
118
+ await this.assertDirsInsideProject(projectDir);
119
+ const scratch = await mkdtemp(join(tmpdir(), 'lps-promote-'));
120
+ try {
121
+ const copy = join(scratch, basename(projectDir));
122
+ await cp(projectDir, copy, {
123
+ filter: (source) => !['.git', 'node_modules'].includes(basename(source)),
124
+ recursive: true,
125
+ });
126
+ process.chdir(copy);
127
+ }
128
+ catch (error) {
129
+ await rm(scratch, { force: true, recursive: true });
130
+ throw error;
131
+ }
132
+ return scratch;
133
+ }
72
134
  requireEnvironment(environments, projectName, name) {
73
135
  const env = environments[name];
74
136
  if (!env) {
@@ -1,7 +1,8 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ import { stdoutToStderr } from '../lib/json-delegation.js';
2
3
  import { pluralize } from '../utils/pluralize.js';
3
- // composer before plugins: `plugin:pull` reads the local composer.json to skip
4
- // Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
4
+ // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
+ // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
5
6
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
6
7
  const PULL_TARGETS = [
7
8
  { commandId: 'composer:pull', label: 'composer' },
@@ -29,7 +30,7 @@ export default class Pull extends LoopressCommand {
29
30
  for (const target of PULL_TARGETS) {
30
31
  this.log(`\n→ Pulling ${target.label}...`);
31
32
  try {
32
- await this.config.runCommand(target.commandId, this.buildArgv(target));
33
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
33
34
  this.log(`✓ ${target.label} pulled`);
34
35
  results.push({ label: target.label, status: 'pulled' });
35
36
  }
@@ -41,7 +42,10 @@ export default class Pull extends LoopressCommand {
41
42
  }
42
43
  }
43
44
  if (this.failedCount > 0) {
44
- this.error(`${pluralize(this.failedCount, 'resource')} failed to pull.`);
45
+ // Each failure's own reason, not just the count: under --json the per-resource lines above
46
+ // never reach stdout, and this message is all an MCP caller or a script gets to see.
47
+ const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
48
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
45
49
  }
46
50
  this.log('\nAll resources pulled.');
47
51
  return { results };
@@ -18,6 +18,7 @@ export default class Push extends LoopressCommand {
18
18
  private failedCount;
19
19
  run(): Promise<PushResult>;
20
20
  private buildArgv;
21
+ private composerDeclaresPlugins;
21
22
  private guardProductionPush;
22
23
  }
23
24
  export {};
@@ -1,5 +1,10 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { LoopressCommand } from '../lib/base.js';
2
5
  import { guardProductionPush } from '../lib/guard-production-push.js';
6
+ import { stdoutToStderr } from '../lib/json-delegation.js';
7
+ import { wpackagistRequire } from '../utils/composer.js';
3
8
  import { pluralize } from '../utils/pluralize.js';
4
9
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
5
10
  // relies on, then content itself.
@@ -10,6 +15,8 @@ const PUSH_TARGETS = [
10
15
  { commandId: 'api:push', label: 'API routes' },
11
16
  { commandId: 'hook:push', label: 'hooks' },
12
17
  { commandId: 'form:push', label: 'forms' },
18
+ // Before pages: WordPress refuses a page whose `template` it doesn't know yet.
19
+ { commandId: 'template:push', label: 'templates and parts' },
13
20
  { commandId: 'page:push', label: 'pages' },
14
21
  { commandId: 'seo:push', label: 'SEO' },
15
22
  { commandId: 'menu:push', label: 'menus' },
@@ -17,7 +24,7 @@ const PUSH_TARGETS = [
17
24
  { commandId: 'snippet:push', label: 'snippets' },
18
25
  ];
19
26
  export default class Push extends LoopressCommand {
20
- static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
27
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, templates, pages, SEO, menus, options, and snippets, to WordPress';
21
28
  static enableJsonFlag = true;
22
29
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
23
30
  static flags = {
@@ -29,10 +36,16 @@ export default class Push extends LoopressCommand {
29
36
  await this.guardProductionPush();
30
37
  const argv = this.buildArgv();
31
38
  const results = [];
39
+ const composerPushedByPlugins = await this.composerDeclaresPlugins();
32
40
  for (const target of PUSH_TARGETS) {
41
+ if (composerPushedByPlugins && target.commandId === 'composer:push') {
42
+ this.log('\n→ composer: already pushed whole by plugins');
43
+ results.push({ label: target.label, status: 'pushed' });
44
+ continue;
45
+ }
33
46
  this.log(`\n→ Pushing ${target.label}...`);
34
47
  try {
35
- await this.config.runCommand(target.commandId, argv);
48
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
36
49
  this.log(`✓ ${target.label} pushed`);
37
50
  results.push({ label: target.label, status: 'pushed' });
38
51
  }
@@ -44,7 +57,12 @@ export default class Push extends LoopressCommand {
44
57
  }
45
58
  }
46
59
  if (this.failedCount > 0) {
47
- this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
60
+ // Each failure's own reason, not just the count: under --json the per-resource lines above
61
+ // never reach stdout, and this message is all an MCP caller or a script gets to see.
62
+ const failures = results
63
+ .filter((result) => result.status === 'failed')
64
+ .map((result) => `${result.label}: ${result.error}`);
65
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
48
66
  }
49
67
  this.log('\nAll resources pushed.');
50
68
  return { results };
@@ -58,9 +76,29 @@ export default class Push extends LoopressCommand {
58
76
  argv.push('--dry-run');
59
77
  return argv;
60
78
  }
79
+ // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
80
+ // safety), so `composer:push` would only run Composer on the server a second time.
81
+ async composerDeclaresPlugins() {
82
+ const path = join(process.cwd(), this.rootDir, 'composer.json');
83
+ if (!existsSync(path))
84
+ return false;
85
+ try {
86
+ const composerJson = JSON.parse(await readFile(path, 'utf8'));
87
+ return Object.keys(wpackagistRequire(composerJson, 'plugin')).length > 0;
88
+ }
89
+ catch {
90
+ // Unreadable: let both commands run and report the parse error themselves.
91
+ return false;
92
+ }
93
+ }
61
94
  // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
62
95
  // delegated command.
63
96
  async guardProductionPush() {
64
- await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
97
+ await guardProductionPush({
98
+ dryRun: this.dryRun,
99
+ error: (message) => this.error(message),
100
+ siteConfig: this.siteConfig,
101
+ yes: this.yes,
102
+ });
65
103
  }
66
104
  }
@@ -0,0 +1,10 @@
1
+ import { DiffCommand, type DiffJson } from '../../lib/diff-command.js';
2
+ export default class Diff extends DiffCommand {
3
+ static description: string;
4
+ static enableJsonFlag: boolean;
5
+ static examples: string[];
6
+ static flags: {
7
+ against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ run(): Promise<DiffJson>;
10
+ }
@@ -0,0 +1,13 @@
1
+ import { DiffCommand } from '../../lib/diff-command.js';
2
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
3
+ export default class Diff extends DiffCommand {
4
+ static description = 'Show what differs in block templates and parts between your local files and a WordPress environment, or between two environments. Templates and parts edited in the Site Editor show up too.';
5
+ static enableJsonFlag = true;
6
+ static examples = ['$ lps template diff', '$ lps template diff --env staging', '$ lps template diff --env staging --against production'];
7
+ static flags = { ...DiffCommand.againstFlag };
8
+ async run() {
9
+ const { flags } = await this.parse(Diff);
10
+ const sides = this.resolveSides(flags.against);
11
+ return this.report(['template', 'part'].map((resource) => this.providerTarget(getResourceStateProvider(resource), sides)), sides);
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type ChildTheme } from '../../utils/template-format.js';
3
+ type ListResult = Omit<ChildTheme, 'parts' | 'templates'> & {
4
+ parts: string[];
5
+ templates: string[];
6
+ };
7
+ export default class List extends LoopressCommand {
8
+ static description: string;
9
+ static enableJsonFlag: boolean;
10
+ static examples: string[];
11
+ run(): Promise<ListResult>;
12
+ }
13
+ export {};