@loopress/cli 0.32.1 → 0.33.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 (149) hide show
  1. package/README.md +511 -154
  2. package/dist/commands/acf/list.d.ts +1 -1
  3. package/dist/commands/acf/list.js +3 -4
  4. package/dist/commands/acf/pull.d.ts +1 -1
  5. package/dist/commands/acf/pull.js +9 -6
  6. package/dist/commands/acf/push.d.ts +1 -1
  7. package/dist/commands/acf/push.js +5 -4
  8. package/dist/commands/api/publish.js +2 -1
  9. package/dist/commands/app/pull.js +2 -1
  10. package/dist/commands/app/push.js +5 -4
  11. package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
  12. package/dist/commands/app/{remove.js → rm.js} +2 -1
  13. package/dist/commands/block/diff.d.ts +2 -0
  14. package/dist/commands/block/diff.js +5 -0
  15. package/dist/commands/block/list.d.ts +8 -0
  16. package/dist/commands/block/list.js +23 -0
  17. package/dist/commands/block/pull.d.ts +21 -0
  18. package/dist/commands/block/pull.js +67 -0
  19. package/dist/commands/block/push.d.ts +20 -0
  20. package/dist/commands/block/push.js +48 -0
  21. package/dist/commands/block/rm.d.ts +23 -0
  22. package/dist/commands/block/rm.js +69 -0
  23. package/dist/commands/block/rollback.d.ts +2 -0
  24. package/dist/commands/block/rollback.js +5 -0
  25. package/dist/commands/composer/init.js +1 -0
  26. package/dist/commands/composer/push.js +5 -4
  27. package/dist/commands/content/copy.d.ts +3 -0
  28. package/dist/commands/content/copy.js +79 -2
  29. package/dist/commands/dev.js +4 -3
  30. package/dist/commands/diff.d.ts +1 -0
  31. package/dist/commands/diff.js +33 -3
  32. package/dist/commands/doctor.d.ts +4 -2
  33. package/dist/commands/doctor.js +37 -23
  34. package/dist/commands/form/pull.js +7 -5
  35. package/dist/commands/form/push.js +4 -3
  36. package/dist/commands/init.js +4 -3
  37. package/dist/commands/menu/push.js +8 -8
  38. package/dist/commands/option/list.js +5 -2
  39. package/dist/commands/option/push.js +2 -1
  40. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  41. package/dist/commands/option/{remove.js → rm.js} +2 -1
  42. package/dist/commands/page/push.js +2 -1
  43. package/dist/commands/plugin/audit.d.ts +4 -4
  44. package/dist/commands/plugin/audit.js +35 -15
  45. package/dist/commands/plugin/pull.js +3 -2
  46. package/dist/commands/plugin/push.js +2 -2
  47. package/dist/commands/plugin/status.js +3 -1
  48. package/dist/commands/project/config.js +7 -6
  49. package/dist/commands/project/pull.js +3 -1
  50. package/dist/commands/project/push.js +6 -4
  51. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  52. package/dist/commands/project/{remove.js → rm.js} +3 -2
  53. package/dist/commands/project/switch.js +6 -4
  54. package/dist/commands/promote.js +7 -2
  55. package/dist/commands/pull.d.ts +2 -0
  56. package/dist/commands/pull.js +42 -18
  57. package/dist/commands/push.d.ts +2 -1
  58. package/dist/commands/push.js +69 -25
  59. package/dist/commands/snippet/publish.js +9 -5
  60. package/dist/commands/snippet/push.d.ts +1 -1
  61. package/dist/commands/snippet/push.js +15 -12
  62. package/dist/commands/status.js +1 -0
  63. package/dist/commands/theme/activate.d.ts +21 -0
  64. package/dist/commands/theme/activate.js +44 -0
  65. package/dist/commands/theme/pull.js +2 -1
  66. package/dist/commands/theme/push.js +7 -3
  67. package/dist/commands/theme/style/push.js +7 -2
  68. package/dist/commands/theme/template/push.js +25 -6
  69. package/dist/config/auth.manager.d.ts +10 -7
  70. package/dist/config/auth.manager.js +5 -1
  71. package/dist/config/json-file.d.ts +1 -1
  72. package/dist/config/project-config.manager.d.ts +2 -2
  73. package/dist/config/project-config.manager.js +13 -21
  74. package/dist/lib/api-client.js +16 -21
  75. package/dist/lib/app-build.js +6 -2
  76. package/dist/lib/app-manifest.js +6 -6
  77. package/dist/lib/base.d.ts +2 -1
  78. package/dist/lib/base.js +10 -8
  79. package/dist/lib/bootstrap-full-install.js +8 -6
  80. package/dist/lib/capabilities.d.ts +44 -0
  81. package/dist/lib/capabilities.js +85 -0
  82. package/dist/lib/declared-type-command.js +4 -2
  83. package/dist/lib/dev-watch.js +5 -5
  84. package/dist/lib/diff-command.d.ts +2 -3
  85. package/dist/lib/diff-command.js +9 -7
  86. package/dist/lib/diff-state.js +2 -3
  87. package/dist/lib/load-files.js +2 -1
  88. package/dist/lib/load-snippets.js +37 -42
  89. package/dist/lib/local-callback-server.js +7 -1
  90. package/dist/lib/narrow.d.ts +5 -0
  91. package/dist/lib/narrow.js +26 -0
  92. package/dist/lib/php-files-command.js +8 -5
  93. package/dist/lib/plugin-detection.d.ts +0 -1
  94. package/dist/lib/plugin-detection.js +2 -4
  95. package/dist/lib/push-command.d.ts +1 -1
  96. package/dist/lib/push-command.js +19 -7
  97. package/dist/lib/put-or-create.d.ts +2 -2
  98. package/dist/lib/readdir-tolerant.js +3 -1
  99. package/dist/lib/resource-state.d.ts +1 -1
  100. package/dist/lib/resource-state.js +58 -19
  101. package/dist/lib/rollback-command.d.ts +1 -1
  102. package/dist/lib/rollback-command.js +3 -2
  103. package/dist/lib/rollback-materialize.js +28 -8
  104. package/dist/lib/rotate-app-password.js +1 -0
  105. package/dist/lib/sentry.js +2 -2
  106. package/dist/lib/snapshot-store.d.ts +10 -7
  107. package/dist/lib/snapshot-store.js +21 -5
  108. package/dist/lib/spinner.js +4 -0
  109. package/dist/lib/validate-local.js +35 -20
  110. package/dist/lib/wp-client.d.ts +6 -1
  111. package/dist/lib/wp-client.js +54 -69
  112. package/dist/lib/wp-site-diagnostic.js +3 -5
  113. package/dist/types/global-config.generated.d.ts +1 -1
  114. package/dist/types/plugin.d.ts +7 -5
  115. package/dist/types/plugin.js +6 -1
  116. package/dist/types/project-config.generated.d.ts +9 -1
  117. package/dist/types/snippet.d.ts +2 -2
  118. package/dist/types/snippet.js +1 -1
  119. package/dist/utils/acf-format.d.ts +1 -0
  120. package/dist/utils/acf-format.js +11 -3
  121. package/dist/utils/block-format.d.ts +27 -0
  122. package/dist/utils/block-format.js +93 -0
  123. package/dist/utils/composer.d.ts +7 -15
  124. package/dist/utils/composer.js +43 -16
  125. package/dist/utils/declared-type-format.js +26 -19
  126. package/dist/utils/form-format.js +2 -2
  127. package/dist/utils/loopress-config.js +9 -1
  128. package/dist/utils/menu-format.d.ts +3 -1
  129. package/dist/utils/menu-format.js +5 -0
  130. package/dist/utils/option-format.d.ts +2 -2
  131. package/dist/utils/option-format.js +15 -25
  132. package/dist/utils/page-format.js +7 -6
  133. package/dist/utils/php-top-level.js +15 -4
  134. package/dist/utils/plugin-sync.d.ts +7 -7
  135. package/dist/utils/plugin-sync.js +11 -11
  136. package/dist/utils/plugins.js +9 -6
  137. package/dist/utils/resource-dirs.d.ts +2 -0
  138. package/dist/utils/resource-dirs.js +9 -1
  139. package/dist/utils/snippet-format.d.ts +1 -1
  140. package/dist/utils/snippet-format.js +5 -3
  141. package/dist/utils/template-format.js +2 -2
  142. package/dist/utils/theme-css.js +14 -6
  143. package/dist/utils/theme-styles-format.d.ts +3 -0
  144. package/dist/utils/theme-styles-format.js +44 -6
  145. package/dist/utils/version.js +1 -1
  146. package/oclif.manifest.json +1588 -1043
  147. package/package.json +42 -17
  148. package/schemas/global-config.schema.json +1 -1
  149. package/schemas/project-config.schema.json +11 -1
@@ -1,6 +1,8 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { basename, extname, join } from 'node:path';
3
+ import {} from '../types/snippet.js';
3
4
  import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
5
+ import { errorCode, errorMessage, isRecord } from './narrow.js';
4
6
  const TYPE_BY_EXTENSION = {
5
7
  '.css': 'css',
6
8
  '.html': 'html',
@@ -21,58 +23,51 @@ async function loadSnippet(path, file, onSkip) {
21
23
  content = await readFile(filePath, 'utf8');
22
24
  }
23
25
  catch (error) {
24
- onSkip?.(`Skipping "${filePath}": ${error.message}`);
26
+ onSkip?.(`Skipping "${filePath}": ${errorMessage(error)}`);
25
27
  return undefined;
26
28
  }
27
- let id;
28
- let name;
29
- let description;
30
- let type;
31
- let isActive = false;
32
- let tags = [];
33
- let location = null;
34
- let insertMethod = null;
35
- let priority = 10;
36
- let shortcodeAttributes = [];
29
+ let meta;
37
30
  try {
38
- const metaContent = await readFile(metaPath, 'utf8');
39
- const meta = JSON.parse(metaContent);
40
- id = meta.id;
41
- // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
42
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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;
46
- type = parseType(meta.type) ?? undefined;
47
- isActive = Boolean(meta.active);
48
- tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
49
- location = parseLocation(meta.location);
50
- insertMethod = parseInsertMethod(meta.insertMethod);
51
- priority = meta.priority ?? 10;
52
- shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
31
+ meta = await readSidecar(metaPath);
53
32
  }
54
33
  catch (error) {
55
- if (error.code !== 'ENOENT') {
56
- onSkip?.(`Skipping "${metaPath}": ${error.message}`);
57
- return undefined;
58
- }
34
+ onSkip?.(`Skipping "${metaPath}": ${errorMessage(error)}`);
35
+ return undefined;
59
36
  }
60
- const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
37
+ const type = parseType(meta.type) ?? TYPE_BY_EXTENSION[ext] ?? 'php';
61
38
  return {
62
- active: isActive,
39
+ active: Boolean(meta.active),
63
40
  code: content,
64
- description,
65
- id,
66
- insertMethod: insertMethod ?? 'auto',
67
- location: location ?? defaultLocationForType(resolvedType),
68
- name: name ?? basename(file, ext),
41
+ // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
42
+ description: typeof meta.description === 'string' ? meta.description : undefined,
43
+ id: typeof meta.id === 'number' ? meta.id : undefined,
44
+ insertMethod: parseInsertMethod(meta.insertMethod) ?? 'auto',
45
+ location: parseLocation(meta.location) ?? defaultLocationForType(type),
46
+ // An empty-string name in the sidecar JSON counts as absent too.
47
+ name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : basename(file, ext),
69
48
  path: filePath,
70
- priority,
71
- shortcodeAttributes,
72
- tags,
73
- type: resolvedType,
49
+ priority: typeof meta.priority === 'number' ? meta.priority : 10,
50
+ shortcodeAttributes: Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [],
51
+ tags: Array.isArray(meta.tags) ? meta.tags.map(String) : [],
52
+ type,
74
53
  };
75
54
  }
55
+ // The sidecar's JSON object, or no fields when the snippet has no sidecar (yet).
56
+ async function readSidecar(metaPath) {
57
+ let raw;
58
+ try {
59
+ raw = await readFile(metaPath, 'utf8');
60
+ }
61
+ catch (error) {
62
+ if (errorCode(error) === 'ENOENT')
63
+ return {};
64
+ throw error;
65
+ }
66
+ const meta = JSON.parse(raw);
67
+ if (!isRecord(meta))
68
+ throw new Error('not a JSON object');
69
+ return meta;
70
+ }
76
71
  // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
77
72
  // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
78
73
  // directory the same way. `onSkip` is optional so callers that don't care about per-file
@@ -83,7 +78,7 @@ export async function loadSnippets(path, onSkip) {
83
78
  files = await readdir(path);
84
79
  }
85
80
  catch (error) {
86
- throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
81
+ throw new Error(`Error loading snippets: ${errorMessage(error)}`, { cause: error });
87
82
  }
88
83
  const snippets = [];
89
84
  for (const file of files) {
@@ -94,7 +94,13 @@ export async function waitForLocalCallback(options) {
94
94
  reject(new Error(options.timeoutMessage));
95
95
  }, timeoutMs);
96
96
  server.listen(0, '127.0.0.1', () => {
97
- const { port } = server.address();
97
+ const address = server.address();
98
+ // Only a pipe or a closed server has no AddressInfo, never a TCP listen that just succeeded.
99
+ if (address === null || typeof address === 'string') {
100
+ reject(new Error('The local callback server has no TCP port'));
101
+ return;
102
+ }
103
+ const { port } = address;
98
104
  const targetUrl = options.buildUrl(`http://${options.callbackHost ?? 'localhost'}:${port}`, state);
99
105
  options.log(options.openingMessage);
100
106
  options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
@@ -0,0 +1,5 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function isOneOf<T extends string>(values: Iterable<T>, value: unknown): value is T;
3
+ export declare function errorMessage(error: unknown): string;
4
+ export declare function causeOf(error: unknown): unknown;
5
+ export declare function errorCode(error: unknown): string | undefined;
@@ -0,0 +1,26 @@
1
+ // Type guards for values that arrive as `unknown`: a caught error, a parsed JSON document.
2
+ // Arrays pass isRecord, same as a bare `typeof value === 'object' && value !== null` check.
3
+ export function isRecord(value) {
4
+ return typeof value === 'object' && value !== null;
5
+ }
6
+ // Membership in a closed list of literals, narrowing `value` to that union.
7
+ export function isOneOf(values, value) {
8
+ for (const candidate of values)
9
+ if (candidate === value)
10
+ return true;
11
+ return false;
12
+ }
13
+ // `.message` of anything thrown: an Error, or an object shaped like one (got and oclif errors
14
+ // are both). Anything else is stringified rather than read as `undefined`.
15
+ export function errorMessage(error) {
16
+ return isRecord(error) && typeof error.message === 'string' ? error.message : String(error);
17
+ }
18
+ // The error a rethrown one wraps (`new Error(message, {cause})`), e.g. the got error behind
19
+ // WpClient's formatted one.
20
+ export function causeOf(error) {
21
+ return isRecord(error) ? error.cause : undefined;
22
+ }
23
+ // Node's errno code ('ENOENT', 'EEXIST', ...) when the thrown value carries one.
24
+ export function errorCode(error) {
25
+ return isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
26
+ }
@@ -4,14 +4,17 @@ 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 {} from '../types/config.js';
7
8
  import { firstTopLevelSideEffect, startsWithStrictTypes } from '../utils/php-top-level.js';
8
9
  import { pluralize } from '../utils/pluralize.js';
9
10
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
10
11
  import { ApiClient } from './api-client.js';
11
12
  import { LoopressCommand } from './base.js';
13
+ import {} from './diff-state.js';
12
14
  import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
13
15
  import { isInteractive } from './interactive.js';
14
16
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
17
+ import { errorMessage } from './narrow.js';
15
18
  import { PushCommand } from './push-command.js';
16
19
  import { getResourceStateProvider } from './resource-state.js';
17
20
  import { isApplicative404 } from './wp-client.js';
@@ -82,7 +85,7 @@ export function resourcePushCommand(spec) {
82
85
  static enableJsonFlag = true;
83
86
  static examples = [
84
87
  `$ lps ${spec.cliName} push`,
85
- `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
88
+ `$ lps ${spec.cliName} push ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
86
89
  `$ lps ${spec.cliName} push --prune`,
87
90
  ];
88
91
  static flags = {
@@ -158,14 +161,14 @@ export function resourcePushCommand(spec) {
158
161
  result = await this.wp.post(`${spec.endpoint}/batch`, body);
159
162
  }
160
163
  catch (error) {
161
- this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error.message}`);
164
+ this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${errorMessage(error)}`);
162
165
  }
163
166
  for (const file of result.files) {
164
167
  this.log(file.syntax_check === 'skipped'
165
168
  ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
166
169
  : `Pushed: ${file.filename}`);
167
170
  if (file.public) {
168
- this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
171
+ this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://docs.loopress.dev/api/routes/#authentication-and-permissions`);
169
172
  }
170
173
  }
171
174
  for (const filename of result.pruned)
@@ -216,7 +219,7 @@ export function resourcePullCommand(spec) {
216
219
  static args = pathArg(spec.pathNoun);
217
220
  static description = spec.pullDescription;
218
221
  static enableJsonFlag = true;
219
- static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
222
+ static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
220
223
  static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
221
224
  async run() {
222
225
  const { args } = await this.parse(ResourcePull);
@@ -294,7 +297,7 @@ export function resourceListCommand(spec) {
294
297
  }
295
298
  export function resourceRmCommand(spec) {
296
299
  class ResourceRm extends LoopressCommand {
297
- // `remove` matches `lps app remove` and `lps option remove`, `rm` stays for existing scripts.
300
+ // `rm` is canonical everywhere, `remove` stays an alias for existing scripts.
298
301
  static aliases = [`${spec.cliName}:remove`];
299
302
  static args = {
300
303
  filename: Args.string({
@@ -6,4 +6,3 @@ 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;
@@ -1,3 +1,5 @@
1
+ import {} from '../types/plugin.js';
2
+ import {} from './wp-client.js';
1
3
  const FULL_PLUGIN_PREFIX = 'loopress-full/';
2
4
  /**
3
5
  * Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
@@ -9,7 +11,3 @@ export async function isLoopressFullActive(wp) {
9
11
  const plugins = await wp.get('wp/v2/plugins');
10
12
  return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
11
13
  }
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/;
@@ -7,7 +7,7 @@ export type PushChange = {
7
7
  };
8
8
  export declare function pushChanges(local: ResourceState, remote: ResourceState, options?: {
9
9
  mirror?: boolean;
10
- writes?: (id: string) => boolean;
10
+ writes?: ((id: string) => boolean) | undefined;
11
11
  }): PushChange[];
12
12
  export declare function logChanges(log: (message: string) => void, changes: Array<{
13
13
  id: string;
@@ -5,10 +5,14 @@ import { Listr } from 'listr2';
5
5
  import { resolve } from 'node:path';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
7
  import { authManager } from '../config/auth.manager.js';
8
+ import {} from '../types/config.js';
8
9
  import { API_URL } from './api-client.js';
9
10
  import { LoopressCommand } from './base.js';
11
+ import {} from './diff-state.js';
10
12
  import { guardProductionPush } from './guard-production-push.js';
11
13
  import { isInteractive } from './interactive.js';
14
+ import { errorMessage } from './narrow.js';
15
+ import {} from './resource-state.js';
12
16
  import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
13
17
  import { withoutSpinner, withSpinner } from './spinner.js';
14
18
  export function pushChanges(local, remote, options = {}) {
@@ -65,14 +69,14 @@ export class PushCommand extends LoopressCommand {
65
69
  const remote = await this.previewRemote(provider, dir);
66
70
  await this.guardOutsideEdits(provider, dir, remote, writes);
67
71
  try {
68
- const local = await provider.local(dir, () => { });
72
+ const local = await provider.local(dir, () => { }, this.wp);
69
73
  this.previewStates = { local, remote };
70
74
  this.previewChanges = pushChanges(local, remote, { writes });
71
75
  }
72
76
  catch (error) {
73
77
  // The push reports the unreadable files itself, but an empty `changes` must not read as
74
78
  // "nothing changes".
75
- this.warn(`Could not list what this push would change: ${error.message}`);
79
+ this.warn(`Could not list what this push would change: ${errorMessage(error)}`);
76
80
  }
77
81
  logChanges((message) => { this.log(message); }, this.previewChanges);
78
82
  return undefined;
@@ -84,13 +88,14 @@ export class PushCommand extends LoopressCommand {
84
88
  }, dir);
85
89
  }
86
90
  catch (error) {
87
- this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
91
+ this.warn(`Could not read remote state for a rollback snapshot: ${errorMessage(error)}`);
88
92
  return undefined;
89
93
  }
90
94
  await this.guardOutsideEdits(provider, dir, remote, writes);
91
95
  return remote;
92
96
  }
93
97
  async catch(err) {
98
+ // Unset when init() failed before resolving the environment, despite its declared type.
94
99
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
95
100
  await this.recordDeployment('failure');
96
101
  }
@@ -120,7 +125,7 @@ export class PushCommand extends LoopressCommand {
120
125
  return await provider.remote(this.wp, () => { }, dir);
121
126
  }
122
127
  catch (error) {
123
- this.error(`The site would refuse this push: ${error.message}`);
128
+ this.error(`The site would refuse this push: ${errorMessage(error)}`);
124
129
  }
125
130
  }
126
131
  async recordDeployment(status) {
@@ -211,7 +216,7 @@ export class PushCommand extends LoopressCommand {
211
216
  });
212
217
  }
213
218
  catch (error) {
214
- this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
219
+ this.warn(`Could not save a rollback snapshot after this push: ${errorMessage(error)}`);
215
220
  }
216
221
  }
217
222
  // Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
@@ -236,12 +241,19 @@ export class PushCommand extends LoopressCommand {
236
241
  const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
237
242
  expected = recordToState(snapshot.afterState);
238
243
  pushedAt = snapshot.createdAt;
239
- local = await provider.local(dir, () => { });
240
244
  }
241
245
  catch {
242
- // No earlier push from this machine, or unreadable local files the push reports itself.
246
+ // No earlier push from this machine.
243
247
  return;
244
248
  }
249
+ // Unreadable files are skipped with a warning, so a failure here is the site's (ACF's
250
+ // normalize route): the check can't run, and a push must not go on unchecked.
251
+ try {
252
+ local = await provider.local(dir, () => { }, this.wp);
253
+ }
254
+ catch (error) {
255
+ this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${errorMessage(error)}. Retry, or pass --overwrite.`);
256
+ }
245
257
  const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
246
258
  remote.get(id) !== undefined &&
247
259
  !sameEntry(remote, expected, id) &&
@@ -6,8 +6,8 @@ type PutOrCreateOptions = {
6
6
  postPayload?: Record<string, unknown>;
7
7
  putEndpoint: (id: number) => string;
8
8
  };
9
- export declare function putOrCreate<T>(wp: WpClient, options: PutOrCreateOptions): Promise<{
10
- body: T;
9
+ export declare function putOrCreate(wp: WpClient, options: PutOrCreateOptions): Promise<{
10
+ body: Record<string, unknown>;
11
11
  created: boolean;
12
12
  }>;
13
13
  export {};
@@ -1,5 +1,7 @@
1
+ import {} from 'node:fs';
1
2
  import { readdir, stat } from 'node:fs/promises';
2
3
  import { join, relative, sep } from 'node:path';
4
+ import { errorCode } from './narrow.js';
3
5
  export async function readdirTolerant(dir, options) {
4
6
  try {
5
7
  return options?.withFileTypes
@@ -7,7 +9,7 @@ export async function readdirTolerant(dir, options) {
7
9
  : await readdir(dir, { recursive: options?.recursive });
8
10
  }
9
11
  catch (error) {
10
- if (error.code === 'ENOENT')
12
+ if (errorCode(error) === 'ENOENT')
11
13
  return [];
12
14
  throw error;
13
15
  }
@@ -3,7 +3,7 @@ import { type ResourceState } from './diff-state.js';
3
3
  import { type WpClient } from './wp-client.js';
4
4
  export type ResourceStateProvider = {
5
5
  dirKind: ResourceDirKind;
6
- local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
6
+ local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
7
7
  remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
8
  resource: string;
9
9
  title: string;
@@ -1,20 +1,24 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
- import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
3
+ import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
4
+ import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
4
5
  import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
5
6
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
6
7
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
7
8
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
8
9
  import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
10
+ import {} from '../utils/resource-dirs.js';
9
11
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
10
12
  import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
11
13
  import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
14
+ import {} from './diff-state.js';
12
15
  import { loadFiles } from './load-files.js';
13
16
  import { loadSnippets } from './load-snippets.js';
17
+ import { causeOf, errorCode, isRecord } from './narrow.js';
14
18
  import { isApplicative404, isNotFoundError } from './wp-client.js';
15
19
  function readJson(raw) {
16
20
  const parsed = JSON.parse(raw);
17
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
18
22
  throw new Error('not a JSON object');
19
23
  }
20
24
  return parsed;
@@ -59,7 +63,7 @@ const snippetProvider = {
59
63
  catch (error) {
60
64
  // A directory that was never created is "nothing tracked locally", the same tolerance
61
65
  // loadFiles/readdirTolerant already have; loadSnippets wraps the ENOENT in its own Error.
62
- if (error.cause?.code === 'ENOENT')
66
+ if (errorCode(causeOf(error)) === 'ENOENT')
63
67
  return state;
64
68
  throw error;
65
69
  }
@@ -115,19 +119,33 @@ const formProvider = {
115
119
  };
116
120
  // ---- ACF --------------------------------------------------------------------------------
117
121
  // ACF's export JSON is large, deeply nested, and versioned by ACF itself; `key` is the stable
118
- // identity and the whole object (minus the `modified` timestamp) round-trips untouched, so it
119
- // is all compared. Ids are namespaced by object type (`field-groups/group_x`) since keys are
120
- // only unique within a type.
122
+ // identity and the whole object (minus the `modified` timestamp) is compared. Ids are
123
+ // namespaced by object type (`field-groups/group_x`) since keys are only unique within a type.
124
+ // The site stores every setting, defaults included, while a hand- or agent-written file holds
125
+ // only what its author wrote: given the site, the local side is what the site would store
126
+ // (ACF's own normalization, nothing saved), or every such file would read as drift forever.
127
+ async function normalizeAcf(wp, type, objects) {
128
+ try {
129
+ return await wp.post(acfNormalizeEndpoint(type), { objects });
130
+ }
131
+ catch (error) {
132
+ // A plugin older than the route: compared as written, as before it existed.
133
+ if (isNotFoundError(error))
134
+ return objects;
135
+ throw error;
136
+ }
137
+ }
121
138
  const acfProvider = {
122
139
  dirKind: 'acf',
123
- async local(dir, onWarn) {
140
+ async local(dir, onWarn, wp) {
124
141
  const state = new Map();
125
142
  for (const type of ACF_OBJECT_TYPES) {
126
- const objects = await loadFiles(join(dir, type), {
143
+ const files = await loadFiles(join(dir, type), {
127
144
  extension: '.json',
128
145
  onSkip: onWarn,
129
146
  parse: readJson,
130
147
  });
148
+ const objects = wp && files.length > 0 ? await normalizeAcf(wp, type, files) : files;
131
149
  for (const object of objects) {
132
150
  const key = getAcfKey(object);
133
151
  if (key !== null)
@@ -152,20 +170,20 @@ const acfProvider = {
152
170
  title: 'ACF',
153
171
  };
154
172
  // ---- PHP file directories (API routes, hooks) -----------------------------------------
173
+ function filesToState(files) {
174
+ const state = new Map();
175
+ for (const file of files)
176
+ state.set(file.filename, file.content);
177
+ return state;
178
+ }
155
179
  // api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
156
180
  // verbatim (line diff on change), no server-side normalization to undo. Only the directory
157
181
  // kind, the endpoint and the labels differ.
158
182
  function phpFilesProvider(spec) {
159
- const toState = (files) => {
160
- const state = new Map();
161
- for (const file of files)
162
- state.set(file.filename, file.content);
163
- return state;
164
- };
165
183
  return {
166
184
  dirKind: spec.dirKind,
167
185
  async local(dir, onWarn) {
168
- return toState(await loadFiles(dir, {
186
+ return filesToState(await loadFiles(dir, {
169
187
  extension: '.php',
170
188
  onSkip: onWarn,
171
189
  parse: (raw, filePath) => ({
@@ -176,7 +194,7 @@ function phpFilesProvider(spec) {
176
194
  }));
177
195
  },
178
196
  async remote(wp) {
179
- return toState(await wp.get(spec.endpoint));
197
+ return filesToState(await wp.get(spec.endpoint));
180
198
  },
181
199
  resource: spec.resource,
182
200
  title: spec.title,
@@ -194,6 +212,24 @@ const hookProvider = phpFilesProvider({
194
212
  resource: 'hook',
195
213
  title: 'Hooks',
196
214
  });
215
+ // ---- Dynamic blocks -------------------------------------------------------------------
216
+ // Keyed by slug, compared on the whole folder (file name => text). A local problem fails the
217
+ // resource, the same stance as pages: pushing would stop on it anyway.
218
+ const blockProvider = {
219
+ dirKind: 'block',
220
+ async local(dir) {
221
+ const { blocks, problems } = await readLocalBlocks(dir);
222
+ if (problems.length > 0)
223
+ throw new Error(formatBlockProblems(problems));
224
+ return new Map(blocks.map((block) => [block.slug, block.files]));
225
+ },
226
+ async remote(wp) {
227
+ const blocks = await wp.get(BLOCKS_ENDPOINT);
228
+ return new Map(blocks.map((block) => [block.slug, block.files]));
229
+ },
230
+ resource: 'block',
231
+ title: 'Dynamic blocks',
232
+ };
197
233
  // ---- Pages ------------------------------------------------------------------------------
198
234
  // Keyed by slug (the page's identity on both sides). `link` is server-derived and never
199
235
  // compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
@@ -226,8 +262,10 @@ async function childThemeState(wp, kind) {
226
262
  const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
227
263
  for (const entry of child.customized) {
228
264
  const [dir, slug] = entry.split('/', 2);
229
- if (dir === kind)
230
- state.set(slug, { ...state.get(slug), siteEditor: 'customized' });
265
+ if (dir === kind && slug !== undefined) {
266
+ const existing = state.get(slug);
267
+ state.set(slug, { ...(isRecord(existing) && existing), siteEditor: 'customized' });
268
+ }
231
269
  }
232
270
  return state;
233
271
  }
@@ -416,6 +454,7 @@ export const RESOURCE_STATE_PROVIDERS = [
416
454
  acfProvider,
417
455
  apiProvider,
418
456
  hookProvider,
457
+ blockProvider,
419
458
  templateProvider,
420
459
  partProvider,
421
460
  pageProvider,
@@ -462,7 +501,7 @@ export async function composerLocalState(rootDir) {
462
501
  state.set(file, await readFile(join(rootDir, file), 'utf8'));
463
502
  }
464
503
  catch (error) {
465
- if (error.code !== 'ENOENT')
504
+ if (errorCode(error) !== 'ENOENT')
466
505
  throw error;
467
506
  }
468
507
  }
@@ -1,7 +1,7 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  import { type SnapshotSummary } from './snapshot-store.js';
3
3
  export type RollbackResult = {
4
- drift?: {
4
+ drift?: undefined | {
5
5
  added: string[];
6
6
  changed: string[];
7
7
  removed: string[];
@@ -9,6 +9,7 @@ import { LoopressCommand } from './base.js';
9
9
  import { compareStates, isEmptyDiff } from './diff-state.js';
10
10
  import { guardProductionPush } from './guard-production-push.js';
11
11
  import { isInteractive } from './interactive.js';
12
+ import { errorMessage } from './narrow.js';
12
13
  import { getResourceStateProvider } from './resource-state.js';
13
14
  import { materializeSnapshot } from './rollback-materialize.js';
14
15
  import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
@@ -69,7 +70,7 @@ export function resourceRollbackCommand(resource, options) {
69
70
  snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
70
71
  }
71
72
  catch (error) {
72
- this.error(error.message);
73
+ this.error(errorMessage(error));
73
74
  }
74
75
  await guardProductionPush({
75
76
  dryRun: this.dryRun,
@@ -140,7 +141,7 @@ export function resourceRollbackCommand(resource, options) {
140
141
  // A cleanup failure here must never hide a real error from the push above: warn about
141
142
  // it and let the original error (if any) keep propagating instead of being replaced.
142
143
  await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
143
- this.warn(`Could not remove the temporary directory "${tmpDir}": ${error.message}`);
144
+ this.warn(`Could not remove the temporary directory "${tmpDir}": ${errorMessage(error)}`);
144
145
  });
145
146
  }
146
147
  this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);