@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
@@ -13,7 +13,7 @@ export default class Push extends PushCommand {
13
13
  static args = {
14
14
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
15
15
  };
16
- static description = 'Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
16
+ static description = 'Push local snippet files to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
17
17
  static enableJsonFlag = true;
18
18
  static examples = ['$ lps snippet push', '$ lps snippet push ./snippets'];
19
19
  static flags = {
@@ -28,7 +28,7 @@ export default class Push extends PushCommand {
28
28
  this.log(`Snippets path: ${path}`);
29
29
  const provider = getResourceStateProvider('snippet');
30
30
  const beforeState = await this.captureBeforePushState(provider, path);
31
- const snippets = await this.loadSnippets(path);
31
+ const snippets = this.withoutDuplicateIds(await this.loadSnippets(path));
32
32
  this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
33
33
  const pushed = [];
34
34
  await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
@@ -40,7 +40,7 @@ export default class Push extends PushCommand {
40
40
  this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
41
41
  }
42
42
  if (this.dryRun)
43
- return { pushed, status: 'dry-run' };
43
+ return { changes: this.previewChanges, pushed, status: 'dry-run' };
44
44
  await this.recordSuccess();
45
45
  this.log('All snippets pushed.');
46
46
  return { pushed, status: 'success' };
@@ -148,8 +148,11 @@ export default class Push extends PushCommand {
148
148
  toPayload(snippet) {
149
149
  return {
150
150
  active: snippet.active,
151
- code: stripPhpOpeningTag(snippet.code),
152
- description: `Imported from ${snippet.path}`,
151
+ // Only PHP files carry an editor-only `<?php` tag: an HTML snippet starting with an inline
152
+ // `<?php ... ?>` block is its real content and must reach WordPress untouched.
153
+ code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
154
+ // The sidecar's description wins, so a pull then push never erases one written in wp-admin.
155
+ description: snippet.description ?? `Imported from ${snippet.path}`,
153
156
  insertMethod: snippet.insertMethod,
154
157
  location: snippet.location,
155
158
  name: snippet.name,
@@ -159,4 +162,22 @@ export default class Push extends PushCommand {
159
162
  type: snippet.type,
160
163
  };
161
164
  }
165
+ // Two files claiming the same id would each PUT over that id, the last one silently replacing
166
+ // the other on the site (seen after switching snippet provider, whose ids restart). Neither is
167
+ // pushed, and the push fails, until the files are fixed by hand.
168
+ withoutDuplicateIds(snippets) {
169
+ const pathsById = new Map();
170
+ for (const { id, path } of snippets) {
171
+ if (id !== undefined)
172
+ pathsById.set(id, [...(pathsById.get(id) ?? []), path]);
173
+ }
174
+ return snippets.filter((snippet) => {
175
+ const paths = snippet.id === undefined ? [] : pathsById.get(snippet.id);
176
+ if (paths.length < 2)
177
+ return true;
178
+ this.failedCount++;
179
+ this.warn(`Skipping "${snippet.path}": id ${snippet.id} is also used by ${paths.filter((p) => p !== snippet.path).join(', ')}.`);
180
+ return false;
181
+ });
182
+ }
162
183
  }
@@ -7,7 +7,7 @@ const PULL_TARGETS = [
7
7
  { commandId: 'theme:style:pull', label: 'Global Styles' },
8
8
  ];
9
9
  export default class Pull extends LoopressCommand {
10
- static description = 'Pull everything theme related from WordPress: theme versions and Global Styles';
10
+ static description = 'Pull everything theme related from WordPress: theme versions into loopress.json, then Global Styles into a local file';
11
11
  static enableJsonFlag = true;
12
12
  static examples = ['$ lps theme pull', '$ lps theme pull --env staging', '$ lps theme pull --dry-run'];
13
13
  static flags = {
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../../lib/base.js';
2
2
  type PushTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
+ result?: unknown;
5
6
  status: 'failed' | 'pushed';
6
7
  };
7
8
  type PushResult = {
@@ -13,7 +13,7 @@ const PUSH_TARGETS = [
13
13
  { commandId: 'theme:style:push', label: 'Global Styles' },
14
14
  ];
15
15
  export default class Push extends LoopressCommand {
16
- static description = 'Push everything theme related to WordPress: theme versions, templates and parts, then Global Styles';
16
+ static description = 'Push everything theme related to WordPress: theme versions from loopress.json, then block templates and parts, then Global Styles. Never switches the active theme.';
17
17
  static enableJsonFlag = true;
18
18
  static examples = ['$ lps theme push', '$ lps theme push --env staging', '$ lps theme push --dry-run'];
19
19
  static flags = {
@@ -46,9 +46,9 @@ export default class Push extends LoopressCommand {
46
46
  argv.push('--force');
47
47
  this.log(`\n→ Pushing ${target.label}...`);
48
48
  try {
49
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
49
+ const result = await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
50
50
  this.log(`✓ ${target.label} pushed`);
51
- results.push({ label: target.label, status: 'pushed' });
51
+ results.push({ label: target.label, result, status: 'pushed' });
52
52
  }
53
53
  catch (error) {
54
54
  const { message } = error;
@@ -1,5 +1,6 @@
1
- import { PushCommand } from '../../../lib/push-command.js';
1
+ import { type PushChange, PushCommand } from '../../../lib/push-command.js';
2
2
  type ThemeStylePushResult = {
3
+ changes?: PushChange[];
3
4
  file: string;
4
5
  status: 'dry-run' | 'success';
5
6
  };
@@ -32,12 +32,11 @@ export default class Push extends PushCommand {
32
32
  }
33
33
  throw error;
34
34
  }
35
- if (this.dryRun) {
36
- this.log(`[dry-run] Would push: ${file}`);
37
- return { file, status: 'dry-run' };
38
- }
35
+ // Only the active theme's file is pushed, whatever else the directory holds.
39
36
  const provider = getResourceStateProvider('theme-styles');
40
- const beforeState = await this.captureBeforePushState(provider, dir);
37
+ const beforeState = await this.captureBeforePushState(provider, dir, (name) => name === stylesheet);
38
+ if (this.dryRun)
39
+ return { changes: this.previewChanges, file, status: 'dry-run' };
41
40
  const local = JSON.parse(raw);
42
41
  await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
43
42
  this.log(`Pushed: ${file}`);
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../../../lib/base.js';
2
2
  import { pluralize } from '../../../utils/pluralize.js';
3
3
  import { CHILD_THEME_ENDPOINT } from '../../../utils/template-format.js';
4
4
  export default class List extends LoopressCommand {
5
- static description = 'Show the Loopress child theme of the active block theme: its templates, parts, and those edited in the Site Editor';
5
+ static description = 'Show the Loopress child theme of the active block theme: whether it is active, its templates and parts, and those edited in the Site Editor';
6
6
  static enableJsonFlag = true;
7
7
  static examples = ['$ lps theme template list'];
8
8
  async run() {
@@ -1,7 +1,16 @@
1
- import { PushCommand } from '../../../lib/push-command.js';
1
+ import { type PushChange, PushCommand } from '../../../lib/push-command.js';
2
+ type CssChange = {
3
+ id: string;
4
+ status: 'unknown' | PushChange['status'];
5
+ };
2
6
  type PushResult = {
3
7
  active?: boolean;
4
8
  blockStyles: string[];
9
+ changes?: {
10
+ css: CssChange[];
11
+ parts: PushChange[];
12
+ templates: PushChange[];
13
+ };
5
14
  customized?: string[];
6
15
  parts: string[];
7
16
  status: 'dry-run' | 'success';
@@ -18,5 +27,6 @@ export default class Push extends PushCommand {
18
27
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
28
  };
20
29
  run(): Promise<PushResult>;
30
+ private previewChildTheme;
21
31
  }
22
32
  export {};
@@ -1,5 +1,5 @@
1
- import { PushCommand } from '../../../lib/push-command.js';
2
- import { getResourceStateProvider } from '../../../lib/resource-state.js';
1
+ import { createHash } from 'node:crypto';
2
+ import { logChanges, pushChanges, PushCommand } from '../../../lib/push-command.js';
3
3
  import { formatPageProblems } from '../../../utils/page-format.js';
4
4
  import { pluralize } from '../../../utils/pluralize.js';
5
5
  import { resolveResourceDir } from '../../../utils/resource-dirs.js';
@@ -33,9 +33,11 @@ export default class Push extends PushCommand {
33
33
  this.log(`Found ${pluralize(templates.length, 'template')}, ${pluralize(parts.length, 'part')}, ` +
34
34
  `${css.style ? 'a global stylesheet' : 'no global stylesheet'} and ${pluralize(css.blocks.length, 'block stylesheet')}`);
35
35
  if (this.dryRun) {
36
- await this.previewRemote(getResourceStateProvider('template'), templatesPath);
37
- this.log('[dry-run] Would rewrite the child theme with them.');
38
- return { ...names, status: 'dry-run' };
36
+ const changes = await this.previewChildTheme(templates, parts, css);
37
+ logChanges((message) => { this.log(message); }, changes.templates, 'templates/');
38
+ logChanges((message) => { this.log(message); }, changes.parts, 'parts/');
39
+ logChanges((message) => { this.log(message); }, changes.css, 'css/');
40
+ return { ...names, changes, status: 'dry-run' };
39
41
  }
40
42
  const child = await this.wp.put(CHILD_THEME_ENDPOINT, { ...css, parts, templates });
41
43
  await this.recordSuccess();
@@ -62,4 +64,31 @@ export default class Push extends PushCommand {
62
64
  unknownBlocks,
63
65
  };
64
66
  }
67
+ // Same read and same refusal as previewRemote, one GET for templates, parts and CSS.
68
+ async previewChildTheme(templates, parts, css) {
69
+ let child;
70
+ try {
71
+ child = await this.wp.get(CHILD_THEME_ENDPOINT);
72
+ }
73
+ catch (error) {
74
+ this.error(`The site would refuse this push: ${error.message}`);
75
+ }
76
+ const localCss = new Map(css.blocks.map((block) => [`blocks/${block.name}.css`, sha256(block.css)]));
77
+ if (css.style !== '')
78
+ localCss.set('style.css', sha256(css.style));
79
+ const cssChanges = child.css
80
+ ? pushChanges(localCss, new Map(Object.entries(child.css)), { mirror: true })
81
+ : [...localCss.keys()].toSorted((a, b) => a.localeCompare(b)).map((id) => ({ id, status: 'unknown' }));
82
+ return {
83
+ css: cssChanges,
84
+ parts: pushChanges(bySlug(parts), bySlug(child.parts), { mirror: true }),
85
+ templates: pushChanges(bySlug(templates), bySlug(child.templates), { mirror: true }),
86
+ };
87
+ }
88
+ }
89
+ function bySlug(files) {
90
+ return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
91
+ }
92
+ function sha256(text) {
93
+ return createHash('sha256').update(text, 'utf8').digest('hex');
65
94
  }
@@ -16,8 +16,13 @@ export default class Pull extends LoopressCommand {
16
16
  const { url } = this.siteConfig;
17
17
  this.log(`Pulling themes from ${url}`);
18
18
  const raw = await this.wp.get('wp/v2/themes');
19
+ // A theme with no Version header can't be pinned, and `theme version push` would then fail on
20
+ // it: the Loopress child theme (`<parent>-loopress`, written by `theme template push`) is one.
19
21
  const installed = parseInstalledThemes(raw);
20
- const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
22
+ const unversioned = installed.filter((t) => t.version === '').map((t) => t.slug);
23
+ const incoming = Object.fromEntries(installed.filter((t) => t.version !== '').map((t) => [t.slug, t.version]));
24
+ if (unversioned.length > 0)
25
+ this.log(` - Skipped: ${unversioned.join(', ')} (no version to pin)`);
21
26
  // A composer.json is authoritative over loopress.json: pin the live versions there instead.
22
27
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
23
28
  if (existsSync(composerJsonPath)) {
@@ -9,7 +9,7 @@ import { lockedWpackagistSlugs } from '../../../utils/plugins.js';
9
9
  import { diffThemes, parseInstalledThemes } from '../../../utils/themes.js';
10
10
  const IN_SYNC = { installed: [], pinned: [], removed: [], status: 'in-sync' };
11
11
  export default class Push extends PushCommand {
12
- static description = 'Install themes on WordPress to match loopress.json (versions only, never switches the active theme)';
12
+ static description = 'Install WordPress.org themes on WordPress to match loopress.json, via Composer + WPackagist (versions only, never switches the active theme)';
13
13
  static enableJsonFlag = true;
14
14
  static examples = ['$ lps theme version push', '$ lps theme version push --force'];
15
15
  static flags = {
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
14
14
  };
15
15
  protected dryRun: boolean;
16
16
  protected localConfig: LoopressLocalConfig;
17
+ protected overwrite: boolean;
17
18
  protected productionConfirmed: boolean;
18
19
  protected projectId: string;
19
20
  protected siteConfig: EnvironmentConfig;
package/dist/lib/base.js CHANGED
@@ -28,6 +28,8 @@ export class LoopressCommand extends Command {
28
28
  };
29
29
  dryRun = false;
30
30
  localConfig = {};
31
+ // Set by PushCommand's --overwrite, see PushCommand.guardOutsideEdits.
32
+ overwrite = false;
31
33
  // Set by PushCommand's hidden --production-confirmed, see there.
32
34
  productionConfirmed = false;
33
35
  projectId;
@@ -58,6 +60,7 @@ export class LoopressCommand extends Command {
58
60
  }));
59
61
  this.dryRun = Boolean(flags['dry-run']);
60
62
  this.yes = Boolean(flags.yes);
63
+ this.overwrite = Boolean(flags.overwrite);
61
64
  this.productionConfirmed = Boolean(flags['production-confirmed']);
62
65
  this.localConfig = await readLocalConfig();
63
66
  const resolved = this.resolveEnvironment(flags.env);
@@ -1,8 +1,9 @@
1
1
  import { type DeclaredTypeSpec, type RegisteredItem } from '../utils/declared-type-format.js';
2
2
  import { LoopressCommand } from './base.js';
3
3
  import { type CommandClass } from './php-files-command.js';
4
- import { PushCommand } from './push-command.js';
4
+ import { type PushChange, PushCommand } from './push-command.js';
5
5
  type PushResult = {
6
+ changes?: PushChange[];
6
7
  path: string;
7
8
  pushed: string[];
8
9
  status: 'dry-run' | 'success';
@@ -54,7 +54,7 @@ export function declaredPushCommand(spec, description) {
54
54
  this.error(`${pluralize(this.failedCount, spec.noun, spec.plural)} failed to push.`);
55
55
  }
56
56
  if (this.dryRun)
57
- return { path, pushed, status: 'dry-run' };
57
+ return { changes: this.previewChanges, path, pushed, status: 'dry-run' };
58
58
  await this.recordSuccess();
59
59
  this.log(`All ${spec.plural} pushed.`);
60
60
  return { path, pushed, status: 'success' };
@@ -16,6 +16,7 @@ export type DiffJson = {
16
16
  };
17
17
  export type DiffTarget = {
18
18
  left: () => Promise<ResourceState>;
19
+ optional?: boolean;
19
20
  resource: string;
20
21
  right: () => Promise<ResourceState>;
21
22
  title: string;
@@ -23,6 +24,7 @@ export type DiffTarget = {
23
24
  type DiffSides = {
24
25
  againstWp: undefined | WpClient;
25
26
  leftLabel: string;
27
+ rightHeading: string;
26
28
  rightLabel: string;
27
29
  warn: (message: string) => void;
28
30
  };
@@ -37,6 +39,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
37
39
  abstract run(): Promise<DiffJson>;
38
40
  private computeResource;
39
41
  private out;
42
+ private renderFailure;
40
43
  private renderSection;
41
44
  private wpForEnvironment;
42
45
  }
@@ -4,6 +4,7 @@ import { configManager } from '../config/project-config.manager.js';
4
4
  import { resolveResourceDir } from '../utils/resource-dirs.js';
5
5
  import { LoopressCommand } from './base.js';
6
6
  import { compareStates, isEmptyDiff } from './diff-state.js';
7
+ import { INTEGRATION_MISSING } from './plugin-detection.js';
7
8
  import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
8
9
  import { WpClient } from './wp-client.js';
9
10
  const c = ux.colorize;
@@ -47,8 +48,16 @@ export class DiffCommand extends LoopressCommand {
47
48
  // a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
48
49
  // when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
49
50
  async report(targets, sides) {
50
- this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightLabel)}\n`);
51
+ this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
51
52
  const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
53
+ // Every resource failing the same way (site down, bad credentials) is one problem: say it
54
+ // once instead of once per resource.
55
+ const sharedError = sharedFailure(results.map((result) => result.json.error));
56
+ if (sharedError !== undefined) {
57
+ // Named, since some resources may have compared fine (one that needs no request).
58
+ const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
59
+ this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
60
+ }
52
61
  const resources = {};
53
62
  let drift = false;
54
63
  let failed = false;
@@ -56,12 +65,12 @@ export class DiffCommand extends LoopressCommand {
56
65
  let added = 0;
57
66
  let removed = 0;
58
67
  let changed = 0;
59
- for (const result of results) {
68
+ const checked = results.filter((result) => !result.skipped);
69
+ for (const result of checked) {
60
70
  resources[result.resource] = result.json;
61
71
  if (result.json.error !== undefined) {
62
72
  failed = true;
63
- const errorLabel = c('red', `error: ${result.json.error}`);
64
- this.out(`${c('bold', result.title)} ${errorLabel}`);
73
+ this.renderFailure(result.title, result.json.error, sharedError);
65
74
  continue;
66
75
  }
67
76
  compared += 1;
@@ -73,6 +82,9 @@ export class DiffCommand extends LoopressCommand {
73
82
  if (result.drift)
74
83
  drift = true;
75
84
  }
85
+ const skipped = results.filter((result) => result.skipped);
86
+ for (const result of skipped)
87
+ this.out(`${c('bold', result.title)} ${c('dim', 'not configured, skipped')}`);
76
88
  this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
77
89
  if (failed)
78
90
  this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
@@ -91,6 +103,7 @@ export class DiffCommand extends LoopressCommand {
91
103
  return {
92
104
  againstWp: against ? this.wpForEnvironment(against) : undefined,
93
105
  leftLabel: this.siteConfig.name,
106
+ rightHeading: against ?? 'local files',
94
107
  rightLabel: against ?? 'local',
95
108
  warn: (message) => {
96
109
  this.warn(message);
@@ -113,13 +126,23 @@ export class DiffCommand extends LoopressCommand {
113
126
  }
114
127
  catch (error) {
115
128
  const { message } = error;
116
- return { drift: false, json: { added: [], changed: [], error: message, removed: [] }, resource: target.resource, title: target.title };
129
+ const empty = { added: [], changed: [], removed: [] };
130
+ if (target.optional && INTEGRATION_MISSING.test(message))
131
+ return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title };
132
+ return { drift: false, json: { ...empty, error: message }, resource: target.resource, title: target.title };
117
133
  }
118
134
  }
119
135
  out(message) {
120
136
  if (!this.jsonEnabled())
121
137
  this.log(message);
122
138
  }
139
+ renderFailure(title, error, sharedError) {
140
+ // Already printed once by `report` when every failure is the same.
141
+ if (sharedError !== undefined)
142
+ return;
143
+ const errorLabel = c('red', `error: ${error}`);
144
+ this.out(`${c('bold', title)} ${errorLabel}`);
145
+ }
123
146
  renderSection(title, stateDiff) {
124
147
  if (isEmptyDiff(stateDiff)) {
125
148
  this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
@@ -180,3 +203,9 @@ function indentPatch(patch) {
180
203
  .join('\n')
181
204
  .trimEnd();
182
205
  }
206
+ // The one error every failed resource shares, when more than one failed (see `report`).
207
+ function sharedFailure(errors) {
208
+ const failures = errors.filter((error) => error !== undefined);
209
+ const distinct = new Set(failures);
210
+ return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
211
+ }
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
26
26
  }
27
27
  let id;
28
28
  let name;
29
+ let description;
29
30
  let type;
30
31
  let isActive = false;
31
32
  let tags = [];
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
40
41
  // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
41
42
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
42
43
  name = meta.name || undefined;
44
+ // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
45
+ description = meta.description;
43
46
  type = parseType(meta.type) ?? undefined;
44
47
  isActive = Boolean(meta.active);
45
48
  tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
58
61
  return {
59
62
  active: isActive,
60
63
  code: content,
64
+ description,
61
65
  id,
62
66
  insertMethod: insertMethod ?? 'auto',
63
67
  location: location ?? defaultLocationForType(resolvedType),
@@ -2,7 +2,7 @@ import { type Config } from '@oclif/core';
2
2
  import { type EnvironmentConfig } from '../types/config.js';
3
3
  import { type ResourceDirKind } from '../utils/resource-dirs.js';
4
4
  import { LoopressCommand } from './base.js';
5
- import { PushCommand } from './push-command.js';
5
+ import { type PushChange, PushCommand } from './push-command.js';
6
6
  export type PhpFilesResource = {
7
7
  afterPush?: (context: {
8
8
  filenames: string[];
@@ -30,8 +30,17 @@ type RemotePhpFile = {
30
30
  filename: string;
31
31
  public?: boolean;
32
32
  revision?: string;
33
+ runtimeError?: RuntimeError;
33
34
  };
35
+ export type RuntimeError = {
36
+ at: string;
37
+ message: string;
38
+ method: string;
39
+ type: 'action' | 'cron' | 'filter';
40
+ };
41
+ export declare function formatRuntimeError(error: RuntimeError): string;
34
42
  type PushResult = {
43
+ changes?: PushChange[];
35
44
  pruned: string[];
36
45
  pushed: string[];
37
46
  status: 'dry-run' | 'success';
@@ -59,6 +68,7 @@ export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
59
68
  export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
60
69
  run(): Promise<RmResult>;
61
70
  };
71
+ export declare function phpFileProblems(spec: Pick<PhpFilesResource, 'filenamePattern' | 'invalidFilenameHint'>, filename: string, content: string): string[];
62
72
  export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
63
73
  export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
64
74
  export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import { mkdir, writeFile } from 'node:fs/promises';
5
5
  import { dirname, join, relative, sep } from 'node:path';
6
6
  import { authManager } from '../config/auth.manager.js';
7
+ import { firstTopLevelSideEffect, startsWithStrictTypes } from '../utils/php-top-level.js';
7
8
  import { pluralize } from '../utils/pluralize.js';
8
9
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
9
10
  import { ApiClient } from './api-client.js';
@@ -14,11 +15,37 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
14
15
  import { PushCommand } from './push-command.js';
15
16
  import { getResourceStateProvider } from './resource-state.js';
16
17
  import { isApplicative404 } from './wp-client.js';
18
+ export function formatRuntimeError(error) {
19
+ return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
20
+ }
17
21
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
18
22
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
19
23
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
20
24
  // presence, so a file that would still fail server-side doesn't falsely pass this check.
21
25
  const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
26
+ // What push checks on a route or hook file before sending it (the server rejects the same):
27
+ // shared with `lps validate`. The server still runs what needs WordPress (PHP syntax, class-name
28
+ // collisions), so passing these doesn't guarantee the push.
29
+ // `filename`: the path under the resource dir, without `.php`.
30
+ export function phpFileProblems(spec, filename, content) {
31
+ const problems = [];
32
+ if (!spec.filenamePattern.test(filename))
33
+ problems.push(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
34
+ const declareCount = content.match(DECLARE_PATTERN)?.length ?? 0;
35
+ if (declareCount !== 1) {
36
+ const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
37
+ problems.push(`${filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`);
38
+ }
39
+ else if (!startsWithStrictTypes(content)) {
40
+ problems.push(`${filename}.php: "declare(strict_types=1);" must be the first statement, right after <?php (one in a comment doesn't count)`);
41
+ }
42
+ const sideEffect = firstTopLevelSideEffect(content);
43
+ if (sideEffect !== null) {
44
+ problems.push(`${filename}.php: code outside the class (${sideEffect}) would run at load time, move it into the class. ` +
45
+ "Don't add an ABSPATH guard: the plugin adds one on push.");
46
+ }
47
+ return problems;
48
+ }
22
49
  // A file's expected revision (#234), computed from `beforeState`: the remote listing already
23
50
  // read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
24
51
  // for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
@@ -91,10 +118,8 @@ export function resourcePushCommand(spec) {
91
118
  // files without also landing the new ones.
92
119
  const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
93
120
  if (this.dryRun) {
94
- for (const file of files)
95
- this.log(`[dry-run] Would push: ${file.filename}`);
96
121
  await this.writeAfterPushSnapshot(provider, path, beforeState);
97
- return { pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
122
+ return { changes: this.previewChanges, pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
98
123
  }
99
124
  const { pruned, pushed } = files.length === 0 && pruneList.length === 0
100
125
  ? { pruned: [], pushed: [] }
@@ -177,14 +202,7 @@ export function resourcePushCommand(spec) {
177
202
  return orphans;
178
203
  }
179
204
  validateFileLocally(file, task) {
180
- if (!spec.filenamePattern.test(file.filename)) {
181
- const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
182
- this.reportTaskFailure(message, new Error(message), task);
183
- }
184
- const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
185
- if (declareCount !== 1) {
186
- const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
187
- const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
205
+ for (const message of phpFileProblems(spec, file.filename, file.content)) {
188
206
  this.reportTaskFailure(message, new Error(message), task);
189
207
  }
190
208
  if (task)
@@ -262,6 +280,8 @@ export function resourceListCommand(spec) {
262
280
  for (const file of files) {
263
281
  const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
264
282
  this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
283
+ if (file.runtimeError)
284
+ this.log(` ${formatRuntimeError(file.runtimeError)}`);
265
285
  }
266
286
  if (files.some((file) => file.public)) {
267
287
  this.log('');
@@ -348,14 +368,14 @@ export const API_FILES_RESOURCE = {
348
368
  filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
349
369
  invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
350
370
  label: 'API routes',
351
- listDescription: 'List custom API route files from WordPress',
371
+ listDescription: 'List custom API route files currently on WordPress',
352
372
  noun: 'route file',
353
373
  orphanReason: 'whose route no longer exists on WordPress',
354
374
  pathLabel: 'API',
355
375
  pathNoun: 'api directory',
356
- pullDescription: 'Pull custom API route files from WordPress',
357
- pushDescription: 'Push custom API route files to WordPress',
358
- rmDescription: 'Remove a custom API route file from WordPress',
376
+ pullDescription: 'Pull custom API route files from WordPress into local files',
377
+ pushDescription: 'Push local custom API route files to WordPress',
378
+ rmDescription: 'Remove one custom API route file from WordPress',
359
379
  };
360
380
  export const HOOK_FILES_RESOURCE = {
361
381
  cliName: 'hook',
@@ -367,12 +387,12 @@ export const HOOK_FILES_RESOURCE = {
367
387
  filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
368
388
  invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
369
389
  label: 'hooks',
370
- listDescription: 'List hook files (actions, filters, cron) from WordPress',
390
+ listDescription: 'List hook files (actions, filters, cron) currently on WordPress',
371
391
  noun: 'hook file',
372
392
  orphanReason: 'no longer present on WordPress',
373
393
  pathLabel: 'Hooks',
374
394
  pathNoun: 'hooks directory',
375
- pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
376
- pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
377
- rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
395
+ pullDescription: 'Pull hook files (actions, filters, cron) from WordPress into local files',
396
+ pushDescription: 'Push local hook files (actions, filters, cron) to WordPress',
397
+ rmDescription: 'Remove one hook file (action, filter, cron) from WordPress',
378
398
  };
@@ -6,3 +6,4 @@ import { type WpClient } from './wp-client.js';
6
6
  * see Loopress Full itself.
7
7
  */
8
8
  export declare function isLoopressFullActive(wp: WpClient): Promise<boolean>;
9
+ export declare const INTEGRATION_MISSING: RegExp;
@@ -9,3 +9,7 @@ export async function isLoopressFullActive(wp) {
9
9
  const plugins = await wp.get('wp/v2/plugins');
10
10
  return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
11
11
  }
12
+ // How the Loopress plugin reports a resource's integration missing: "ACF is not active", "No
13
+ // supported form plugin is active", "No supported snippet plugin is active". Global Styles say a
14
+ // classic theme is active. The aggregate commands skip such a resource the project doesn't use.
15
+ export const INTEGRATION_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b|\bis a classic theme\b/;