@loopress/cli 0.32.2 → 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 (147) 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 +10 -5
  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.js +38 -13
  100. package/dist/lib/rollback-command.d.ts +1 -1
  101. package/dist/lib/rollback-command.js +3 -2
  102. package/dist/lib/rollback-materialize.js +28 -8
  103. package/dist/lib/rotate-app-password.js +1 -0
  104. package/dist/lib/sentry.js +2 -2
  105. package/dist/lib/snapshot-store.d.ts +10 -7
  106. package/dist/lib/snapshot-store.js +21 -5
  107. package/dist/lib/spinner.js +4 -0
  108. package/dist/lib/validate-local.js +35 -20
  109. package/dist/lib/wp-client.d.ts +6 -1
  110. package/dist/lib/wp-client.js +54 -69
  111. package/dist/lib/wp-site-diagnostic.js +3 -5
  112. package/dist/types/global-config.generated.d.ts +1 -1
  113. package/dist/types/plugin.d.ts +7 -5
  114. package/dist/types/plugin.js +6 -1
  115. package/dist/types/project-config.generated.d.ts +9 -1
  116. package/dist/types/snippet.d.ts +2 -2
  117. package/dist/types/snippet.js +1 -1
  118. package/dist/utils/acf-format.js +6 -3
  119. package/dist/utils/block-format.d.ts +27 -0
  120. package/dist/utils/block-format.js +93 -0
  121. package/dist/utils/composer.d.ts +7 -15
  122. package/dist/utils/composer.js +43 -16
  123. package/dist/utils/declared-type-format.js +26 -19
  124. package/dist/utils/form-format.js +2 -2
  125. package/dist/utils/loopress-config.js +9 -1
  126. package/dist/utils/menu-format.d.ts +3 -1
  127. package/dist/utils/menu-format.js +5 -0
  128. package/dist/utils/option-format.d.ts +2 -2
  129. package/dist/utils/option-format.js +15 -25
  130. package/dist/utils/page-format.js +7 -6
  131. package/dist/utils/php-top-level.js +15 -4
  132. package/dist/utils/plugin-sync.d.ts +7 -7
  133. package/dist/utils/plugin-sync.js +11 -11
  134. package/dist/utils/plugins.js +9 -6
  135. package/dist/utils/resource-dirs.d.ts +2 -0
  136. package/dist/utils/resource-dirs.js +9 -1
  137. package/dist/utils/snippet-format.d.ts +1 -1
  138. package/dist/utils/snippet-format.js +5 -3
  139. package/dist/utils/template-format.js +2 -2
  140. package/dist/utils/theme-css.js +14 -6
  141. package/dist/utils/theme-styles-format.d.ts +3 -0
  142. package/dist/utils/theme-styles-format.js +15 -5
  143. package/dist/utils/version.js +1 -1
  144. package/oclif.manifest.json +1938 -1393
  145. package/package.json +42 -17
  146. package/schemas/global-config.schema.json +1 -1
  147. 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 = {}) {
@@ -72,7 +76,7 @@ export class PushCommand extends LoopressCommand {
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
@@ -247,7 +252,7 @@ export class PushCommand extends LoopressCommand {
247
252
  local = await provider.local(dir, () => { }, this.wp);
248
253
  }
249
254
  catch (error) {
250
- this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${error.message}. Retry, or pass --overwrite.`);
255
+ this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${errorMessage(error)}. Retry, or pass --overwrite.`);
251
256
  }
252
257
  const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
253
258
  remote.get(id) !== undefined &&
@@ -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
  }
@@ -1,20 +1,24 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
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
  }
@@ -166,20 +170,20 @@ const acfProvider = {
166
170
  title: 'ACF',
167
171
  };
168
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
+ }
169
179
  // api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
170
180
  // verbatim (line diff on change), no server-side normalization to undo. Only the directory
171
181
  // kind, the endpoint and the labels differ.
172
182
  function phpFilesProvider(spec) {
173
- const toState = (files) => {
174
- const state = new Map();
175
- for (const file of files)
176
- state.set(file.filename, file.content);
177
- return state;
178
- };
179
183
  return {
180
184
  dirKind: spec.dirKind,
181
185
  async local(dir, onWarn) {
182
- return toState(await loadFiles(dir, {
186
+ return filesToState(await loadFiles(dir, {
183
187
  extension: '.php',
184
188
  onSkip: onWarn,
185
189
  parse: (raw, filePath) => ({
@@ -190,7 +194,7 @@ function phpFilesProvider(spec) {
190
194
  }));
191
195
  },
192
196
  async remote(wp) {
193
- return toState(await wp.get(spec.endpoint));
197
+ return filesToState(await wp.get(spec.endpoint));
194
198
  },
195
199
  resource: spec.resource,
196
200
  title: spec.title,
@@ -208,6 +212,24 @@ const hookProvider = phpFilesProvider({
208
212
  resource: 'hook',
209
213
  title: 'Hooks',
210
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
+ };
211
233
  // ---- Pages ------------------------------------------------------------------------------
212
234
  // Keyed by slug (the page's identity on both sides). `link` is server-derived and never
213
235
  // compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
@@ -240,8 +262,10 @@ async function childThemeState(wp, kind) {
240
262
  const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
241
263
  for (const entry of child.customized) {
242
264
  const [dir, slug] = entry.split('/', 2);
243
- if (dir === kind)
244
- 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
+ }
245
269
  }
246
270
  return state;
247
271
  }
@@ -430,6 +454,7 @@ export const RESOURCE_STATE_PROVIDERS = [
430
454
  acfProvider,
431
455
  apiProvider,
432
456
  hookProvider,
457
+ blockProvider,
433
458
  templateProvider,
434
459
  partProvider,
435
460
  pageProvider,
@@ -476,7 +501,7 @@ export async function composerLocalState(rootDir) {
476
501
  state.set(file, await readFile(join(rootDir, file), 'utf8'));
477
502
  }
478
503
  catch (error) {
479
- if (error.code !== 'ENOENT')
504
+ if (errorCode(error) !== 'ENOENT')
480
505
  throw error;
481
506
  }
482
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}.`);
@@ -1,13 +1,16 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { getAcfKey } from '../utils/acf-format.js';
5
+ import { writeBlock } from '../utils/block-format.js';
4
6
  import { CPT_SPEC, fileExtension, formatDeclaredFile, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
5
7
  import { getFormId, getFormTitle } from '../utils/form-format.js';
6
8
  import { getMenuSlug } from '../utils/menu-format.js';
7
9
  import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
8
- import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
10
+ import { buildMetaFile, buildSnippetFile, normalizeSnippet } from '../utils/snippet-format.js';
9
11
  import { themeStylesFileName } from '../utils/theme-styles-format.js';
10
12
  import { toSlug } from '../utils/to-slug.js';
13
+ import { isRecord } from './narrow.js';
11
14
  const SNIPPET_EXTENSIONS = { css: 'css', html: 'html', js: 'js', php: 'php', text: 'txt' };
12
15
  async function writeJson(path, value) {
13
16
  await mkdir(dirname(path), { recursive: true });
@@ -19,9 +22,20 @@ async function writeJson(path, value) {
19
22
  // compares (no `id`, no `description`, `code` with its `<?php` tag already stripped), exactly
20
23
  // what `normalizeSnippet` + `stripPhpOpeningTag` produce, so it plugs straight into the same
21
24
  // `buildSnippetFile`/`buildMetaFile` pair `snippet pull` uses to write files.
25
+ // A snapshot entry that should be an object: anything else is a corrupted snapshot file.
26
+ function snapshotRecord(id, value) {
27
+ if (!isRecord(value))
28
+ throw new Error(`Snapshot entry "${id}" is not an object.`);
29
+ return value;
30
+ }
31
+ const SNAPSHOT_OPTION = z.object({ autoload: z.string(), value: z.unknown() });
22
32
  async function snippet(id, value, dir) {
23
- const fields = value;
24
- const normalized = { ...fields, description: '', id: Number(id) };
33
+ const data = snapshotRecord(id, value);
34
+ // normalizeSnippet would coerce it, writing a corrupted entry's code over the live snippet.
35
+ if (typeof data.code !== 'string')
36
+ throw new Error(`Snapshot entry "${id}" has a non-string "code".`);
37
+ // normalizeSnippet is idempotent on its own output, so it narrows `data` without changing it.
38
+ const normalized = { ...normalizeSnippet(data), description: '', id: Number(id) };
25
39
  const ext = SNIPPET_EXTENSIONS[normalized.type];
26
40
  const base = `${normalized.id}-${toSlug(normalized.name)}`;
27
41
  await mkdir(dir, { recursive: true });
@@ -31,7 +45,7 @@ async function snippet(id, value, dir) {
31
45
  // value is the whole form JSON (minus `modified`/`modified_gmt`), the same shape `form pull`
32
46
  // writes verbatim; `form push` re-sends it as-is.
33
47
  async function form(id, value, dir) {
34
- const data = value;
48
+ const data = snapshotRecord(id, value);
35
49
  const formId = getFormId(data) ?? Number(id);
36
50
  const slug = toSlug(getFormTitle(data), 'untitled');
37
51
  await writeJson(join(dir, `${formId}-${slug}.json`), data);
@@ -41,7 +55,7 @@ async function form(id, value, dir) {
41
55
  async function acf(id, value, dir) {
42
56
  const separator = id.indexOf('/');
43
57
  const type = id.slice(0, separator);
44
- const data = value;
58
+ const data = snapshotRecord(id, value);
45
59
  const key = getAcfKey(data) ?? id.slice(separator + 1);
46
60
  await writeJson(join(dir, type, `${key}.json`), data);
47
61
  }
@@ -51,13 +65,18 @@ async function phpFile(id, value, dir) {
51
65
  await mkdir(dirname(filePath), { recursive: true });
52
66
  await writeFile(filePath, String(value));
53
67
  }
68
+ // block: id is the slug, value the folder's files (file name => text).
69
+ async function block(id, value, dir) {
70
+ const files = snapshotRecord(id, value);
71
+ await writeBlock(dir, { files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, String(content)])), slug: id });
72
+ }
54
73
  // ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
55
74
  async function menu(id, value, dir) {
56
75
  if (id === 'menu-locations') {
57
76
  await writeJson(join(dir, 'menu-locations.json'), value);
58
77
  return;
59
78
  }
60
- const data = value;
79
+ const data = snapshotRecord(id, value);
61
80
  const slug = getMenuSlug(data) ?? id.slice('menu/'.length);
62
81
  await writeJson(join(dir, `${slug}.json`), data);
63
82
  }
@@ -67,7 +86,7 @@ async function menu(id, value, dir) {
67
86
  // dangerous option (siteurl, home, ...); defaulting to the same policy `option add` itself uses
68
87
  // keeps a rolled-back option exactly as protected as a freshly tracked one, never less.
69
88
  async function option(id, value, dir) {
70
- const { autoload, value: optionValue } = value;
89
+ const { autoload, value: optionValue } = SNAPSHOT_OPTION.parse(value);
71
90
  const local = { autoload, name: id, readonly: defaultReadonlyFor(id), value: optionValue };
72
91
  await writeJson(join(dir, optionFileName(id)), local);
73
92
  }
@@ -79,12 +98,13 @@ async function themeStyles(id, value, dir) {
79
98
  // written in the spec's file format like `<resource> pull` does.
80
99
  function declaredType(spec) {
81
100
  return async (id, value, dir) => {
82
- await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, value));
101
+ await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, snapshotRecord(id, value)));
83
102
  };
84
103
  }
85
104
  const MATERIALIZERS = {
86
105
  acf,
87
106
  api: phpFile,
107
+ block,
88
108
  cpt: declaredType(CPT_SPEC),
89
109
  form,
90
110
  hook: phpFile,
@@ -1,3 +1,4 @@
1
+ import {} from '../types/config.js';
1
2
  import { WpClient } from './wp-client.js';
2
3
  const APPLICATION_PASSWORDS_PATH = 'wp/v2/users/me/application-passwords';
3
4
  export const APP_PASSWORD_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
@@ -20,8 +20,8 @@ export function runtimeContext() {
20
20
  os: `${platform()} ${release()}`,
21
21
  };
22
22
  }
23
- // Positional args and flag values can carry WordPress URLs, usernames, application passwords,
24
- // or tokens (e.g. `lps login --token xxx`, `lps project config <url>`). Only flag names are
23
+ // Positional args and flag values are user input: environment names, local paths, slugs, and
24
+ // anything else typed after a command, possibly a URL or a credential. Only flag names are
25
25
  // safe to report, they help tell which code path crashed without leaking what was passed to it.
26
26
  export function redactArgv(argv) {
27
27
  return argv.map((arg) => {