@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,18 +1,36 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  import { type ResourceState } from './diff-state.js';
3
3
  import { type ResourceStateProvider } from './resource-state.js';
4
+ export type PushChange = {
5
+ id: string;
6
+ status: 'changed' | 'created' | 'removed' | 'unchanged';
7
+ };
8
+ export declare function pushChanges(local: ResourceState, remote: ResourceState, options?: {
9
+ mirror?: boolean;
10
+ writes?: (id: string) => boolean;
11
+ }): PushChange[];
12
+ export declare function logChanges(log: (message: string) => void, changes: Array<{
13
+ id: string;
14
+ status: string;
15
+ }>, prefix?: string): void;
4
16
  export declare abstract class PushCommand extends LoopressCommand {
5
17
  static baseFlags: {
18
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
19
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
20
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
21
  };
9
22
  protected failedCount: number;
23
+ protected previewChanges: PushChange[];
24
+ protected previewStates?: {
25
+ local: ResourceState;
26
+ remote: ResourceState;
27
+ };
10
28
  private refusedByGuard;
11
- protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
29
+ protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
12
30
  catch(err: Error): Promise<void>;
13
31
  protected guardProductionPush(): Promise<void>;
14
32
  init(): Promise<void>;
15
- protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
33
+ protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
16
34
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
17
35
  protected recordSuccess(): Promise<void>;
18
36
  protected reportPushToSite(): Promise<void>;
@@ -23,4 +41,5 @@ export declare abstract class PushCommand extends LoopressCommand {
23
41
  output: string;
24
42
  }) => Promise<void>): Promise<void>;
25
43
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
44
+ private guardOutsideEdits;
26
45
  }
@@ -1,21 +1,51 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  import { Flags } from '@oclif/core';
2
3
  import got from 'got';
3
4
  import { Listr } from 'listr2';
4
5
  import { resolve } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
5
7
  import { authManager } from '../config/auth.manager.js';
6
8
  import { API_URL } from './api-client.js';
7
9
  import { LoopressCommand } from './base.js';
8
10
  import { guardProductionPush } from './guard-production-push.js';
9
- import { writeSnapshot } from './snapshot-store.js';
11
+ import { isInteractive } from './interactive.js';
12
+ import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
10
13
  import { withoutSpinner, withSpinner } from './spinner.js';
14
+ export function pushChanges(local, remote, options = {}) {
15
+ const changes = [...local.keys()]
16
+ .filter((id) => options.writes?.(id) ?? true)
17
+ .map((id) => {
18
+ if (!remote.has(id))
19
+ return { id, status: 'created' };
20
+ return { id, status: isDeepStrictEqual(local.get(id), remote.get(id)) ? 'unchanged' : 'changed' };
21
+ });
22
+ if (options.mirror) {
23
+ for (const id of remote.keys())
24
+ if (!local.has(id))
25
+ changes.push({ id, status: 'removed' });
26
+ }
27
+ return changes.toSorted((a, b) => a.id.localeCompare(b.id));
28
+ }
29
+ export function logChanges(log, changes, prefix = '') {
30
+ for (const change of changes)
31
+ log(`[dry-run] ${change.status}: ${prefix}${change.id}`);
32
+ }
11
33
  export class PushCommand extends LoopressCommand {
12
34
  // Passed by an aggregate command (`lps theme push`) that already confirmed production once,
13
35
  // so its steps skip that prompt while still asking their own confirmations (uninstalls).
14
36
  static baseFlags = {
15
37
  ...LoopressCommand.baseFlags,
38
+ overwrite: Flags.boolean({
39
+ description: 'Push even over changes made on the site outside Loopress since your last push. Pages: also take over a same-slug page Loopress did not create',
40
+ }),
16
41
  'production-confirmed': Flags.boolean({ hidden: true }),
17
42
  };
18
43
  failedCount = 0;
44
+ // A dry run's per-item preview, set by captureBeforePushState: what the push would create,
45
+ // change or leave as it is on the site.
46
+ previewChanges = [];
47
+ // Both sides those statuses were computed from, for a push that details a changed item.
48
+ previewStates;
19
49
  // Refusals of the production guard never reach the site, so they must not pollute the
20
50
  // deployment history with failure records.
21
51
  refusedByGuard = false;
@@ -25,14 +55,31 @@ export class PushCommand extends LoopressCommand {
25
55
  // interrupt the push flow" stance recordDeployment takes; `undefined` tells
26
56
  // `writeAfterPushSnapshot` there is no restore point to complete.
27
57
  //
28
- // On a dry run, the same read is the preview's check against the site (see previewRemote).
29
- async captureBeforePushState(provider, dir) {
58
+ // On a dry run, the same read is the preview's check against the site (see previewRemote), and
59
+ // the reference for its per-item statuses (`previewChanges`).
60
+ //
61
+ // `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
62
+ // skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
63
+ async captureBeforePushState(provider, dir, writes) {
30
64
  if (this.dryRun) {
31
- await this.previewRemote(provider, dir);
65
+ const remote = await this.previewRemote(provider, dir);
66
+ await this.guardOutsideEdits(provider, dir, remote, writes);
67
+ try {
68
+ const local = await provider.local(dir, () => { });
69
+ this.previewStates = { local, remote };
70
+ this.previewChanges = pushChanges(local, remote, { writes });
71
+ }
72
+ catch (error) {
73
+ // The push reports the unreadable files itself, but an empty `changes` must not read as
74
+ // "nothing changes".
75
+ this.warn(`Could not list what this push would change: ${error.message}`);
76
+ }
77
+ logChanges((message) => { this.log(message); }, this.previewChanges);
32
78
  return undefined;
33
79
  }
80
+ let remote;
34
81
  try {
35
- return await provider.remote(this.wp, (message) => {
82
+ remote = await provider.remote(this.wp, (message) => {
36
83
  this.warn(message);
37
84
  }, dir);
38
85
  }
@@ -40,6 +87,8 @@ export class PushCommand extends LoopressCommand {
40
87
  this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
41
88
  return undefined;
42
89
  }
90
+ await this.guardOutsideEdits(provider, dir, remote, writes);
91
+ return remote;
43
92
  }
44
93
  async catch(err) {
45
94
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
@@ -68,7 +117,7 @@ export class PushCommand extends LoopressCommand {
68
117
  // pre-apply check built on it, see what the real push will hit.
69
118
  async previewRemote(provider, dir) {
70
119
  try {
71
- await provider.remote(this.wp, () => { }, dir);
120
+ return await provider.remote(this.wp, () => { }, dir);
72
121
  }
73
122
  catch (error) {
74
123
  this.error(`The site would refuse this push: ${error.message}`);
@@ -165,4 +214,54 @@ export class PushCommand extends LoopressCommand {
165
214
  this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
166
215
  }
167
216
  }
217
+ // Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
218
+ // tool) since this machine's last push. Flags an id only when both hold:
219
+ // - the site no longer matches what that push left (the latest snapshot's `afterState`, the
220
+ // same reference rollback's drift check uses), and
221
+ // - this push would write a different value over it (local differs from the site), so a
222
+ // `pull` that already brought the change into the local files is no reason to stop.
223
+ // Only ids present locally count: a push is additive, it never touches a site-only id. And
224
+ // only ids the site still has with readable content: pushing over a deletion (`lps <resource>
225
+ // rm`, or wp-admin) loses nothing, and a file the site lists without its content (too large
226
+ // to send) can't be compared, so it would otherwise block every push.
227
+ // Ceiling: snapshots live in git-ignored `.loopress/`, so only the machine that pushed last
228
+ // is protected; a record kept on the site would cover teammates and CI.
229
+ async guardOutsideEdits(provider, dir, remote, writes) {
230
+ if (this.overwrite)
231
+ return;
232
+ let expected;
233
+ let local;
234
+ let pushedAt;
235
+ try {
236
+ const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
237
+ expected = recordToState(snapshot.afterState);
238
+ pushedAt = snapshot.createdAt;
239
+ local = await provider.local(dir, () => { });
240
+ }
241
+ catch {
242
+ // No earlier push from this machine, or unreadable local files the push reports itself.
243
+ return;
244
+ }
245
+ const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
246
+ remote.get(id) !== undefined &&
247
+ !sameEntry(remote, expected, id) &&
248
+ !sameEntry(local, remote, id));
249
+ if (overwritten.length === 0)
250
+ return;
251
+ const message = `Changed on ${this.siteConfig.url} outside Loopress since your last push (${pushedAt}), this push would overwrite: ${overwritten.join(', ')}. ` +
252
+ `Review with \`lps diff --only ${provider.resource}\`, bring the site's version into your files, or pass --overwrite.`;
253
+ // A dry run only previews: say what the real push would stop on.
254
+ if (this.dryRun) {
255
+ this.warn(message);
256
+ return;
257
+ }
258
+ if (!isInteractive())
259
+ this.error(message);
260
+ this.warn(message);
261
+ if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
262
+ this.error('Aborted.');
263
+ }
264
+ }
265
+ function sameEntry(left, right, id) {
266
+ return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
168
267
  }
@@ -31,14 +31,14 @@ const ACF_VOLATILE_KEYS = ['modified'];
31
31
  // A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
32
32
  const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
33
33
  // ---- snippets -------------------------------------------------------------------------------
34
- // `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
35
- // push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
36
- // becomes the map key. `code` has its leading `<?php` stripped on both sides: `snippet pull`
37
- // re-adds it to PHP files for editor friendliness, WordPress stores only the body.
34
+ // `description` is dropped: a sidecar without one gets "Imported from <path>" on push, so
35
+ // comparing it would flag every such snippet as drifted. `id` becomes the map key. A PHP
36
+ // snippet's `code` has its leading `<?php` stripped on both sides: `snippet pull` re-adds it
37
+ // for editor friendliness, WordPress stores only the body. Other types keep it, it's content.
38
38
  function canonicalSnippet(snippet) {
39
39
  return {
40
40
  active: snippet.active,
41
- code: stripPhpOpeningTag(snippet.code),
41
+ code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
42
42
  insertMethod: snippet.insertMethod,
43
43
  location: snippet.location,
44
44
  name: snippet.name,
@@ -126,8 +126,15 @@ export function resourceRollbackCommand(resource, options) {
126
126
  // commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
127
127
  // production guard and the drift confirmation above already covered what the delegated
128
128
  // push's own guard would ask again, same reasoning as the top-level `lps push`
129
- // delegating to each resource's push command in commands/push.ts.
130
- await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
129
+ // delegating to each resource's push command in commands/push.ts. --overwrite: the drift
130
+ // check above is the same question as the push's outside-edit check, already answered.
131
+ await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [
132
+ tmpDir,
133
+ '--env',
134
+ this.siteConfig.name,
135
+ '--yes',
136
+ '--overwrite',
137
+ ]);
131
138
  }
132
139
  finally {
133
140
  // A cleanup failure here must never hide a real error from the push above: warn about
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, relative, sep } from 'node:path';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
4
  import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
5
5
  import { parseLocalOption } from '../utils/option-format.js';
@@ -8,6 +8,7 @@ import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
8
  import { parseType } from '../utils/snippet-format.js';
9
9
  import { readLocalTemplates } from '../utils/template-format.js';
10
10
  import { loadSnippets } from './load-snippets.js';
11
+ import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
11
12
  import { readdirTolerant } from './readdir-tolerant.js';
12
13
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
13
14
  // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
@@ -58,8 +59,8 @@ export async function validateLocal(cwd) {
58
59
  checked += await checkJsonDir(resolve('roleDir'), problems, declaredFileChecker(ROLE_SPEC));
59
60
  checked += await checkPatterns(resolve('patternDir'), problems);
60
61
  checked += await checkSnippets(resolve('snippetsDir'), problems);
61
- checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
62
- checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
62
+ checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
63
+ checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
63
64
  const pages = await readLocalPages(resolve('pageDir'));
64
65
  checked += pages.pages.length + pages.problems.length;
65
66
  problems.push(...pages.problems);
@@ -83,7 +84,7 @@ async function checkPatterns(dir, problems) {
83
84
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
84
85
  // (invoice-pdf/[order_id].php, content/filters.php), a non-recursive scan would silently
85
86
  // never check those.
86
- async function checkPhpDir(dir, problems, emptyMessage) {
87
+ async function checkPhpDir(dir, problems, emptyMessage, spec) {
87
88
  const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
88
89
  let checked = 0;
89
90
  for (const entry of entries) {
@@ -96,8 +97,14 @@ async function checkPhpDir(dir, problems, emptyMessage) {
96
97
  // one level deep or more.
97
98
  const filePath = join(entry.parentPath, entry.name);
98
99
  const content = await readFile(filePath, 'utf8');
99
- if (content.trim() === '')
100
+ if (content.trim() === '') {
100
101
  problems.push({ file: filePath, message: emptyMessage });
102
+ continue;
103
+ }
104
+ // The same checks push runs, so a file that passes here doesn't fail there.
105
+ const filename = relative(dir, filePath).replaceAll(sep, '/').replace(/\.php$/, '');
106
+ for (const message of phpFileProblems(spec, filename, content))
107
+ problems.push({ file: filePath, message });
101
108
  }
102
109
  return checked;
103
110
  }
@@ -178,12 +185,18 @@ function checkLoopressJson(localConfig, problems) {
178
185
  if (plugins === undefined)
179
186
  return;
180
187
  if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
181
- problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string' });
188
+ problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
182
189
  return;
183
190
  }
184
191
  for (const [slug, version] of Object.entries(plugins)) {
185
- if (typeof version !== 'string')
186
- problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a string` });
192
+ // The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
193
+ const isPin = typeof version === 'string' ||
194
+ (typeof version === 'object' &&
195
+ version !== null &&
196
+ typeof version.version === 'string' &&
197
+ ['boolean', 'undefined'].includes(typeof version.active));
198
+ if (!isPin)
199
+ problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
187
200
  }
188
201
  }
189
202
  async function checkSnippets(dir, problems) {
@@ -25,6 +25,7 @@ type RequestOptions = {
25
25
  export declare function isNotFoundError(error: unknown): boolean;
26
26
  export declare function isForbiddenError(error: unknown): boolean;
27
27
  export declare function isApplicative404(error: unknown, marker: string): boolean;
28
+ export declare function isConflictCode(error: unknown, code: string): boolean;
28
29
  export declare function isTimeoutError(error: unknown): boolean;
29
30
  export declare function isInvalidPageNumber(error: unknown): boolean;
30
31
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
@@ -116,6 +116,19 @@ export function isApplicative404(error, marker) {
116
116
  return false;
117
117
  }
118
118
  }
119
+ // A 409 a Loopress controller tags with a `code`, for a refusal the CLI can act on (e.g.
120
+ // `loopress_page_not_managed`, which `lps page push` offers to take over).
121
+ export function isConflictCode(error, code) {
122
+ const response = error?.cause?.response;
123
+ if (response?.statusCode !== 409 || !response.body)
124
+ return false;
125
+ try {
126
+ return JSON.parse(response.body).code === code;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
119
132
  export function isTimeoutError(error) {
120
133
  const cause = error?.cause;
121
134
  return cause?.name === 'TimeoutError';
@@ -159,8 +172,21 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
159
172
  if (err.name === 'TimeoutError') {
160
173
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
161
174
  }
162
- return `Request to ${url} failed: ${err.message ?? String(error)}`;
175
+ const reason = err.code ? NETWORK_ERROR_HINTS[err.code] : undefined;
176
+ // The site, not the endpoint: every resource fails the same way when the site is down, so
177
+ // `lps diff` can print it once (see DiffCommand.report).
178
+ if (reason)
179
+ return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
180
+ // Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
181
+ // fall back to the error code so the reason is never blank.
182
+ return `Request to ${url} failed: ${err.message?.length ? err.message : (err.code ?? String(error))}`;
163
183
  }
184
+ const NETWORK_ERROR_HINTS = {
185
+ ECONNREFUSED: 'connection refused, the site may be down',
186
+ ECONNRESET: 'connection reset by the server',
187
+ ENOTFOUND: 'host not found, check the URL',
188
+ ETIMEDOUT: 'connection timed out',
189
+ };
164
190
  function extractLoopressError(body) {
165
191
  if (!body)
166
192
  return undefined;
@@ -2,6 +2,7 @@ import { type SnippetInsertMethod, type SnippetLocation, type SnippetType } from
2
2
  export type Snippet = {
3
3
  active: boolean;
4
4
  code: string;
5
+ description?: string;
5
6
  id?: number;
6
7
  insertMethod: SnippetInsertMethod;
7
8
  location: SnippetLocation;
@@ -3,3 +3,8 @@ export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
3
3
  export declare function acfEndpoint(type: AcfObjectType): string;
4
4
  export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
5
5
  export declare function getAcfKey(data: Record<string, unknown>): null | string;
6
+ export declare function acfFieldChanges(before: unknown, after: unknown): {
7
+ added: string[];
8
+ changed: string[];
9
+ removed: string[];
10
+ };
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
2
3
  export function acfEndpoint(type) {
3
4
  return `loopress/v1/acf/${type}`;
@@ -16,3 +17,31 @@ export function acfObjectEndpoint(type, key) {
16
17
  export function getAcfKey(data) {
17
18
  return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
18
19
  }
20
+ function topLevelFields(group) {
21
+ const fields = group?.fields;
22
+ const byKey = new Map();
23
+ for (const field of Array.isArray(fields) ? fields : []) {
24
+ if (typeof field?.key === 'string')
25
+ byKey.set(field.key, field);
26
+ }
27
+ return byKey;
28
+ }
29
+ // A changed field group's top-level fields, matched by `key` and named by `name` (a repeater's
30
+ // sub-field change shows as its parent changing). Only for the dry-run preview: the push itself
31
+ // always sends the whole group.
32
+ export function acfFieldChanges(before, after) {
33
+ const old = topLevelFields(before);
34
+ const next = topLevelFields(after);
35
+ const label = (field) => (typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key));
36
+ const changes = { added: [], changed: [], removed: [] };
37
+ for (const [key, field] of next) {
38
+ if (!old.has(key))
39
+ changes.added.push(label(field));
40
+ else if (!isDeepStrictEqual(old.get(key), field))
41
+ changes.changed.push(label(field));
42
+ }
43
+ for (const [key, field] of old)
44
+ if (!next.has(key))
45
+ changes.removed.push(label(field));
46
+ return changes;
47
+ }
@@ -0,0 +1,2 @@
1
+ export declare function firstTopLevelSideEffect(content: string): null | string;
2
+ export declare function startsWithStrictTypes(content: string): boolean;
@@ -0,0 +1,148 @@
1
+ // Port of the plugin's ClassScanner::firstTopLevelSideEffect(): the first statement at the top
2
+ // level (brace depth 0) that would run when the file is loaded, or null when the file only
3
+ // declares things. The server rejects such a file at push ("code outside the class"); checking
4
+ // it here lets `lps validate` catch it first, a hand-written ABSPATH guard included (the plugin
5
+ // adds its own). PHP has no tokenizer in Node, so this is a minimal lexer: it only needs to skip
6
+ // comments, strings and heredocs correctly, and to see keywords, braces and ';'.
7
+ const ALLOWED = new Set(['abstract', 'class', 'const', 'declare', 'enum', 'final', 'function', 'interface', 'namespace', 'readonly', 'trait', 'use']);
8
+ const OPEN_TAG = /<\?php\b/i;
9
+ // Returns a short description of the offending token, for the error message.
10
+ export function firstTopLevelSideEffect(content) {
11
+ if (content.search(OPEN_TAG) !== 0)
12
+ return 'inline HTML before <?php';
13
+ const state = { atStart: true, braces: [], depth: 0, namespaceHeader: false };
14
+ for (const token of lex(content.slice(5))) {
15
+ const found = step(state, token);
16
+ if (found !== null)
17
+ return found;
18
+ }
19
+ return null;
20
+ }
21
+ // True when the file's first statement is `declare(strict_types=1);` (PHP refuses it anywhere
22
+ // else). A count of the text alone would also accept one that only sits in a comment.
23
+ export function startsWithStrictTypes(content) {
24
+ if (content.search(OPEN_TAG) !== 0)
25
+ return false;
26
+ const texts = [];
27
+ for (const token of lex(content.slice(5))) {
28
+ texts.push(token.text.toLowerCase());
29
+ if (texts.length === 7)
30
+ break;
31
+ }
32
+ return texts.join('') === 'declare(strict_types=1);';
33
+ }
34
+ // Advances the brace depth and "a statement starts here" flag past one token; returns the token
35
+ // when it starts a top-level statement that isn't a declaration.
36
+ function step(state, token) {
37
+ if (token.type === 'html') {
38
+ state.atStart = true;
39
+ return token.text === '' ? null : 'inline HTML after ?>';
40
+ }
41
+ const statementStart = state.depth === 0 && state.atStart;
42
+ switch (token.text) {
43
+ case ';': {
44
+ if (state.depth === 0) {
45
+ state.atStart = true;
46
+ state.namespaceHeader = false;
47
+ }
48
+ return null;
49
+ }
50
+ case '{': {
51
+ return openBrace(state, statementStart);
52
+ }
53
+ case '}': {
54
+ if (state.braces.pop() !== true)
55
+ state.depth = Math.max(0, state.depth - 1);
56
+ if (state.depth === 0)
57
+ state.atStart = true;
58
+ return null;
59
+ }
60
+ default: {
61
+ if (!statementStart)
62
+ return null;
63
+ state.atStart = false;
64
+ state.namespaceHeader = token.text.toLowerCase() === 'namespace';
65
+ return isDeclaration(token) ? null : token.text;
66
+ }
67
+ }
68
+ }
69
+ function openBrace(state, statementStart) {
70
+ if (state.namespaceHeader) {
71
+ state.namespaceHeader = false;
72
+ state.braces.push(true);
73
+ state.atStart = true;
74
+ return null;
75
+ }
76
+ // A '{' starting a statement is a bare block, its body runs at load time.
77
+ if (statementStart)
78
+ return '{';
79
+ state.braces.push(false);
80
+ state.depth += 1;
81
+ return null;
82
+ }
83
+ function isDeclaration(token) {
84
+ return token.type === 'attribute' || (token.type === 'word' && ALLOWED.has(token.text.toLowerCase()));
85
+ }
86
+ function* lex(source) {
87
+ let i = skipTrivia(source, 0);
88
+ while (i < source.length) {
89
+ const [token, end] = readToken(source, i);
90
+ yield token;
91
+ i = skipTrivia(source, end);
92
+ }
93
+ }
94
+ // Skips whitespace and comments (`#[` is an attribute, not a comment).
95
+ function skipTrivia(source, start) {
96
+ let i = start;
97
+ for (;;) {
98
+ if (/\s/.test(source[i] ?? '')) {
99
+ i += 1;
100
+ }
101
+ else if (source.startsWith('//', i) || (source[i] === '#' && source[i + 1] !== '[')) {
102
+ // A line comment also ends at a closing tag, PHP outputs what follows it.
103
+ const end = source.slice(i).search(/\n|\?>/);
104
+ i = end === -1 ? source.length : i + end;
105
+ }
106
+ else if (source.startsWith('/*', i)) {
107
+ const end = source.indexOf('*/', i + 2);
108
+ i = end === -1 ? source.length : end + 2;
109
+ }
110
+ else {
111
+ return i;
112
+ }
113
+ }
114
+ }
115
+ function readToken(source, i) {
116
+ if (source.startsWith('?>', i)) {
117
+ // Closing tag: everything up to the next <?php is output, but for one newline PHP eats.
118
+ const next = source.slice(i + 2).search(OPEN_TAG);
119
+ const html = next === -1 ? source.slice(i + 2) : source.slice(i + 2, i + 2 + next);
120
+ return [{ text: html.replace(/^\r?\n/, ''), type: 'html' }, next === -1 ? source.length : i + 2 + next + 5];
121
+ }
122
+ if (source.startsWith('#[', i))
123
+ return [{ text: '#[', type: 'attribute' }, i + 2];
124
+ if (source.startsWith('<<<', i) || `'"\``.includes(source[i])) {
125
+ return [{ text: source[i] === '`' ? '`' : 'a string', type: 'string' }, skipString(source, i)];
126
+ }
127
+ const word = /^[\w$\\]+/.exec(source.slice(i));
128
+ if (word)
129
+ return [{ text: word[0], type: 'word' }, i + word[0].length];
130
+ return [{ text: source[i], type: 'punct' }, i + 1];
131
+ }
132
+ // Index right after the string (quoted, backtick, heredoc or nowdoc) starting at `i`.
133
+ function skipString(source, i) {
134
+ if (source[i] === '<') {
135
+ const head = /^<<<[ \t]*(["']?)(\w+)\1\r?\n/.exec(source.slice(i));
136
+ if (!head)
137
+ return i + 3;
138
+ // PHP 7.3+: the closing identifier may be indented and followed by anything but a word char.
139
+ const bodyStart = i + head[0].length - 1;
140
+ const close = new RegExp(String.raw `\n[ \t]*${head[2]}\b`).exec(source.slice(bodyStart));
141
+ return close ? bodyStart + close.index + close[0].length : source.length;
142
+ }
143
+ const quote = source[i];
144
+ let j = i + 1;
145
+ while (j < source.length && source[j] !== quote)
146
+ j += source[j] === '\\' ? 2 : 1;
147
+ return j + 1;
148
+ }
@@ -22,3 +22,4 @@ export declare const RESOURCE_DIR_DEFAULTS: {
22
22
  };
23
23
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
24
24
  export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
25
+ export declare function projectUses(kind: ResourceDirKind, localConfig: LoopressLocalConfig): boolean;
@@ -1,4 +1,5 @@
1
- import { join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
2
3
  // The default subdirectory for each file-backed resource, relative to rootDir. Used whenever
3
4
  // loopress.json has no `<kind>Dir` key, which is the case for a fresh `lps init`.
4
5
  export const RESOURCE_DIR_DEFAULTS = {
@@ -54,3 +55,8 @@ export function resolveResourceDir(kind, localConfig, override) {
54
55
  const configured = localConfig[CONFIG_KEY[kind]];
55
56
  return join(rootDir, configured ?? RESOURCE_DIR_DEFAULTS[kind]);
56
57
  }
58
+ // Whether the project uses a resource at all: its directory exists. What the aggregate `lps push`,
59
+ // `lps pull` and `lps diff` go by for a resource backed by a plugin the site may not have.
60
+ export function projectUses(kind, localConfig) {
61
+ return existsSync(resolve(resolveResourceDir(kind, localConfig)));
62
+ }
@@ -15,6 +15,7 @@ export type Part = {
15
15
  };
16
16
  export type ChildTheme = {
17
17
  active: boolean;
18
+ css?: Record<string, string>;
18
19
  customized: string[];
19
20
  exists: boolean;
20
21
  parent: string;
@@ -12,6 +12,8 @@ export type WpThemeSupportInfo = {
12
12
  };
13
13
  export declare function isBlockTheme(theme: Pick<WpThemeSupportInfo, 'theme_supports'>): boolean;
14
14
  export declare function getUserGlobalStylesId(theme: Pick<WpThemeSupportInfo, '_links'>): null | number;
15
+ export declare function globalStylesFiles(dir: string): Promise<string[]>;
16
+ export declare function globalStylesSkipNote(wp: WpClient, dir: string, action: 'pull' | 'push'): Promise<string | undefined>;
15
17
  export declare function getActiveThemeGlobalStyles(wp: WpClient): Promise<{
16
18
  id: number;
17
19
  stylesheet: string;