@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,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) => {
@@ -1,12 +1,14 @@
1
+ import { z } from 'zod';
1
2
  import { type ResourceState } from './diff-state.js';
2
3
  export declare const SNAPSHOT_RETENTION = 10;
3
- export type Snapshot = {
4
- afterState: Record<string, unknown>;
5
- beforeState: Record<string, unknown>;
6
- createdAt: string;
7
- environment: string;
8
- resource: string;
9
- };
4
+ declare const SNAPSHOT: z.ZodObject<{
5
+ afterState: z.ZodRecord<z.ZodString, z.ZodUnknown>;
6
+ beforeState: z.ZodRecord<z.ZodString, z.ZodUnknown>;
7
+ createdAt: z.ZodString;
8
+ environment: z.ZodString;
9
+ resource: z.ZodString;
10
+ }, z.core.$strip>;
11
+ export type Snapshot = z.infer<typeof SNAPSHOT>;
10
12
  export type SnapshotSummary = {
11
13
  createdAt: string;
12
14
  environment: string;
@@ -28,3 +30,4 @@ export declare function readSnapshot(rootDir: string, resource: string, environm
28
30
  id: string;
29
31
  }>;
30
32
  export declare function pruneSnapshots(rootDir: string, resource: string, environment: string, retain: number): Promise<void>;
33
+ export {};
@@ -1,8 +1,23 @@
1
1
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import {} from './diff-state.js';
5
+ import { errorCode } from './narrow.js';
3
6
  // How many snapshots `writeSnapshot` keeps per resource before pruning the oldest, so the
4
7
  // cache directory doesn't grow without bound across a project's lifetime.
5
8
  export const SNAPSHOT_RETENTION = 10;
9
+ // One `lps <resource> push` recorded as a restore point. `beforeState` is the remote state
10
+ // read immediately before that push (the "left" side `lps diff` would have shown); `afterState`
11
+ // is the local state that was about to be pushed (the "right" side). Rolling back restores
12
+ // `beforeState`; comparing the environment's *current* state against `afterState` at rollback
13
+ // time is what detects whether anything else touched the environment since.
14
+ const SNAPSHOT = z.object({
15
+ afterState: z.record(z.string(), z.unknown()),
16
+ beforeState: z.record(z.string(), z.unknown()),
17
+ createdAt: z.string(),
18
+ environment: z.string(),
19
+ resource: z.string(),
20
+ });
6
21
  // `ResourceState` (a Map, for O(1) lookups during comparison) has no direct JSON
7
22
  // representation; snapshots round-trip through a plain object instead. Keys are sorted so two
8
23
  // snapshots of the same state serialize identically (stable diffs, deterministic tests).
@@ -27,10 +42,10 @@ function snapshotPath(rootDir, resource, environment, id) {
27
42
  return join(snapshotsDir(rootDir, resource, environment), `${id}.json`);
28
43
  }
29
44
  function isEnoent(error) {
30
- return error.code === 'ENOENT';
45
+ return errorCode(error) === 'ENOENT';
31
46
  }
32
47
  function isEexist(error) {
33
- return error.code === 'EEXIST';
48
+ return errorCode(error) === 'EEXIST';
34
49
  }
35
50
  // Snapshot ids are the millisecond timestamp they were written at: sortable, unique enough for
36
51
  // a single-machine CLI run, and directly usable as `--to <id>`.
@@ -105,7 +120,8 @@ export async function listSnapshots(rootDir, resource, environment) {
105
120
  for (const entry of snapshotFiles) {
106
121
  const id = entry.slice(0, -'.json'.length);
107
122
  try {
108
- const parsed = JSON.parse(await readFile(join(dir, entry), 'utf8'));
123
+ const raw = JSON.parse(await readFile(join(dir, entry), 'utf8'));
124
+ const parsed = SNAPSHOT.parse(raw);
109
125
  summaries.push({ createdAt: parsed.createdAt, environment: parsed.environment, id, resource });
110
126
  }
111
127
  catch {
@@ -122,7 +138,7 @@ export async function listSnapshots(rootDir, resource, environment) {
122
138
  export async function readSnapshot(rootDir, resource, environment, id) {
123
139
  let targetId = id;
124
140
  if (targetId === undefined) {
125
- const [latest] = await listSnapshots(rootDir, resource, environment);
141
+ const latest = (await listSnapshots(rootDir, resource, environment)).at(0);
126
142
  if (!latest) {
127
143
  throw new Error(`No snapshots found for "${resource}" on "${environment}". A snapshot is written automatically by \`lps ${resource} push\`.`);
128
144
  }
@@ -138,7 +154,7 @@ export async function readSnapshot(rootDir, resource, environment, id) {
138
154
  }
139
155
  throw error;
140
156
  }
141
- return { ...JSON.parse(raw), id: targetId };
157
+ return { ...SNAPSHOT.parse(JSON.parse(raw)), id: targetId };
142
158
  }
143
159
  export async function pruneSnapshots(rootDir, resource, environment, retain) {
144
160
  const summaries = await listSnapshots(rootDir, resource, environment);
@@ -16,6 +16,7 @@ function start() {
16
16
  const rawOut = process.stdout.write;
17
17
  const rawErr = process.stderr.write;
18
18
  const draw = () => {
19
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typed as number, but undefined when stderr is not a TTY
19
20
  const text = `${FRAMES[frame]} ${label}`.slice(0, (process.stderr.columns ?? 80) - 1);
20
21
  rawErr.call(process.stderr, `\r\u{1B}[2K${text}`);
21
22
  };
@@ -26,7 +27,10 @@ function start() {
26
27
  draw();
27
28
  return result;
28
29
  };
30
+ // write() is overloaded, and a single rest-args wrapper can't be typed as every overload.
31
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
29
32
  process.stdout.write = intercept(process.stdout, rawOut);
33
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
30
34
  process.stderr.write = intercept(process.stderr, rawErr);
31
35
  draw();
32
36
  const timer = setInterval(() => {
@@ -1,6 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { configManager } from '../config/project-config.manager.js';
5
+ import { PLUGIN_PIN } from '../types/plugin.js';
6
+ import { readLocalBlocks } from '../utils/block-format.js';
7
+ import { COMPOSER_JSON } from '../utils/composer.js';
4
8
  import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
5
9
  import { parseLocalOption } from '../utils/option-format.js';
6
10
  import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
@@ -8,6 +12,7 @@ import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
12
  import { parseType } from '../utils/snippet-format.js';
9
13
  import { readLocalTemplates } from '../utils/template-format.js';
10
14
  import { loadSnippets } from './load-snippets.js';
15
+ import { errorCode, errorMessage, isRecord } from './narrow.js';
11
16
  import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
12
17
  import { readdirTolerant } from './readdir-tolerant.js';
13
18
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
@@ -16,6 +21,7 @@ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStylesDir'];
16
21
  const DEFAULT_DIR = {
17
22
  acfDir: 'acf',
18
23
  apiDir: 'api',
24
+ blockDir: RESOURCE_DIR_DEFAULTS.block,
19
25
  cptDir: 'cpt',
20
26
  formDir: 'forms',
21
27
  hooksDir: 'hooks',
@@ -61,6 +67,9 @@ export async function validateLocal(cwd) {
61
67
  checked += await checkSnippets(resolve('snippetsDir'), problems);
62
68
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
63
69
  checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
70
+ const blocks = await readLocalBlocks(resolve('blockDir'));
71
+ checked += blocks.blocks.length;
72
+ problems.push(...blocks.problems);
64
73
  const pages = await readLocalPages(resolve('pageDir'));
65
74
  checked += pages.pages.length + pages.problems.length;
66
75
  problems.push(...pages.problems);
@@ -108,6 +117,7 @@ async function checkPhpDir(dir, problems, emptyMessage, spec) {
108
117
  }
109
118
  return checked;
110
119
  }
120
+ const JSON_OBJECT = z.record(z.string(), z.unknown());
111
121
  async function checkComposerJson(cwd, problems) {
112
122
  let raw;
113
123
  try {
@@ -122,14 +132,13 @@ async function checkComposerJson(cwd, problems) {
122
132
  parsed = JSON.parse(raw);
123
133
  }
124
134
  catch (error) {
125
- problems.push({ file: 'composer.json', message: `not valid JSON: ${error.message}` });
135
+ problems.push({ file: 'composer.json', message: `not valid JSON: ${errorMessage(error)}` });
126
136
  return 1;
127
137
  }
128
- for (const key of ['require', 'require-dev']) {
129
- const section = parsed[key];
130
- if (section !== undefined && (typeof section !== 'object' || section === null || Array.isArray(section))) {
131
- problems.push({ file: 'composer.json', message: `"${key}" must be an object` });
132
- }
138
+ const result = COMPOSER_JSON.safeParse(parsed);
139
+ if (!result.success) {
140
+ for (const { message } of result.error.issues)
141
+ problems.push({ file: 'composer.json', message });
133
142
  }
134
143
  return 1;
135
144
  }
@@ -151,10 +160,10 @@ async function checkJsonDir(dir, problems, shape) {
151
160
  parsed = JSON.parse(raw);
152
161
  }
153
162
  catch (error) {
154
- problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
163
+ problems.push({ file: filePath, message: `not valid JSON: ${errorMessage(error)}` });
155
164
  continue;
156
165
  }
157
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
166
+ if (!JSON_OBJECT.safeParse(parsed).success) {
158
167
  problems.push({ file: filePath, message: 'expected a JSON object' });
159
168
  continue;
160
169
  }
@@ -162,7 +171,7 @@ async function checkJsonDir(dir, problems, shape) {
162
171
  shape?.(raw, filePath);
163
172
  }
164
173
  catch (error) {
165
- problems.push({ file: filePath, message: error.message });
174
+ problems.push({ file: filePath, message: errorMessage(error) });
166
175
  }
167
176
  }
168
177
  return checked;
@@ -184,19 +193,16 @@ function checkLoopressJson(localConfig, problems) {
184
193
  const { plugins } = localConfig;
185
194
  if (plugins === undefined)
186
195
  return;
187
- if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
196
+ const manifest = JSON_OBJECT.safeParse(plugins);
197
+ if (!manifest.success) {
188
198
  problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
189
199
  return;
190
200
  }
191
- for (const [slug, version] of Object.entries(plugins)) {
201
+ for (const [slug, version] of Object.entries(manifest.data)) {
192
202
  // 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)
203
+ if (!PLUGIN_PIN.safeParse(version).success) {
199
204
  problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
205
+ }
200
206
  }
201
207
  }
202
208
  async function checkSnippets(dir, problems) {
@@ -211,7 +217,7 @@ async function checkSnippets(dir, problems) {
211
217
  });
212
218
  }
213
219
  catch (error) {
214
- problems.push({ file: dir, message: error.message });
220
+ problems.push({ file: dir, message: errorMessage(error) });
215
221
  return 0;
216
222
  }
217
223
  // loadSnippets coerces an invalid `type` to the extension default rather than failing, so
@@ -227,6 +233,10 @@ async function checkSnippets(dir, problems) {
227
233
  problems.push({ file: filePath, message: 'not valid JSON' });
228
234
  continue;
229
235
  }
236
+ if (!isRecord(meta)) {
237
+ problems.push({ file: filePath, message: 'expected a JSON object' });
238
+ continue;
239
+ }
230
240
  if (meta.type !== undefined && parseType(meta.type) === null) {
231
241
  problems.push({ file: filePath, message: `"type": ${JSON.stringify(meta.type)} is not one of css, html, js, php, text` });
232
242
  }
@@ -245,15 +255,20 @@ async function readConfig(cwd, problems) {
245
255
  raw = await readFile(join(cwd, 'loopress.json'), 'utf8');
246
256
  }
247
257
  catch (error) {
248
- if (error.code === 'ENOENT')
258
+ if (errorCode(error) === 'ENOENT')
249
259
  return {};
250
260
  throw error;
251
261
  }
262
+ let parsed;
252
263
  try {
253
- return JSON.parse(raw);
264
+ parsed = JSON.parse(raw);
254
265
  }
255
266
  catch {
256
267
  problems.push({ file: 'loopress.json', message: 'loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.' });
257
268
  return {};
258
269
  }
270
+ if (isRecord(parsed) && !Array.isArray(parsed))
271
+ return parsed;
272
+ problems.push({ file: 'loopress.json', message: 'expected a JSON object' });
273
+ return {};
259
274
  }
@@ -7,8 +7,8 @@ export declare function assertSecureSiteUrl(siteUrl: string, environmentName: st
7
7
  * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
8
8
  */
9
9
  export declare class WpClient {
10
- private readonly siteUrl;
11
10
  private readonly client;
11
+ private readonly siteUrl;
12
12
  constructor(siteUrl: string, token: string, environmentName?: string);
13
13
  delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
14
14
  get<T>(path: string, options?: RequestOptions): Promise<T>;
@@ -28,5 +28,10 @@ export declare function isApplicative404(error: unknown, marker: string): boolea
28
28
  export declare function isConflictCode(error: unknown, code: string): boolean;
29
29
  export declare function isTimeoutError(error: unknown): boolean;
30
30
  export declare function isInvalidPageNumber(error: unknown): boolean;
31
+ export declare function responseOf(error: unknown): {
32
+ body?: string | undefined;
33
+ statusCode?: number | undefined;
34
+ };
35
+ export declare function parseBody(body: string | undefined): Record<string, unknown>;
31
36
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
32
37
  export {};
@@ -1,5 +1,6 @@
1
- import got from 'got';
1
+ import got, {} from 'got';
2
2
  import { Buffer } from 'node:buffer';
3
+ import { causeOf, errorCode, isRecord } from './narrow.js';
3
4
  import { withSpinner } from './spinner.js';
4
5
  export const REQUEST_TIMEOUT_MS = 30_000;
5
6
  // The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
@@ -30,11 +31,11 @@ export function assertSecureSiteUrl(siteUrl, environmentName) {
30
31
  * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
31
32
  */
32
33
  export class WpClient {
33
- siteUrl;
34
34
  client;
35
+ siteUrl;
35
36
  constructor(siteUrl, token, environmentName) {
36
- this.siteUrl = siteUrl;
37
37
  assertSecureSiteUrl(siteUrl, environmentName);
38
+ this.siteUrl = siteUrl;
38
39
  this.client = got.extend({
39
40
  headers: { Authorization: `Basic ${Buffer.from(token).toString('base64')}` },
40
41
  prefixUrl: `${siteUrl}/wp-json`,
@@ -83,6 +84,8 @@ export class WpClient {
83
84
  const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
84
85
  try {
85
86
  const response = await withSpinner(`Waiting for ${new URL(this.siteUrl).host}`, async () => this.client(path, { json, method, timeout: { request: timeoutMs } }));
87
+ // The REST contract boundary: each endpoint's shape is the caller's `T`, not checked here.
88
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
86
89
  return (response.body ? JSON.parse(response.body) : undefined);
87
90
  }
88
91
  catch (error) {
@@ -91,95 +94,91 @@ export class WpClient {
91
94
  }
92
95
  }
93
96
  export function isNotFoundError(error) {
94
- const cause = error?.cause;
95
- return cause?.response?.statusCode === 404;
97
+ return responseOf(causeOf(error)).statusCode === 404;
96
98
  }
97
99
  // Authenticated but missing the capability the endpoint needs (e.g. `list_users`).
98
100
  export function isForbiddenError(error) {
99
- const cause = error?.cause;
100
- return cause?.response?.statusCode === 403;
101
+ return responseOf(causeOf(error)).statusCode === 403;
101
102
  }
102
103
  // A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
103
104
  // (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
104
105
  // isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
105
106
  // installed or predates the feature, which callers must still surface normally.
106
107
  export function isApplicative404(error, marker) {
107
- if (!isNotFoundError(error))
108
- return false;
109
- const body = error.cause?.response?.body;
110
- if (!body)
111
- return false;
112
- try {
113
- return JSON.parse(body).error === marker;
114
- }
115
- catch {
116
- return false;
117
- }
108
+ return isNotFoundError(error) && parseBody(responseOf(causeOf(error)).body).error === marker;
118
109
  }
119
110
  // A 409 a Loopress controller tags with a `code`, for a refusal the CLI can act on (e.g.
120
111
  // `loopress_page_not_managed`, which `lps page push` offers to take over).
121
112
  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
- }
113
+ const { body, statusCode } = responseOf(causeOf(error));
114
+ return statusCode === 409 && parseBody(body).code === code;
131
115
  }
132
116
  export function isTimeoutError(error) {
133
- const cause = error?.cause;
134
- return cause?.name === 'TimeoutError';
117
+ const cause = causeOf(error);
118
+ return isRecord(cause) && cause.name === 'TimeoutError';
135
119
  }
136
120
  // WordPress core answers 400 with this `code` when `?page=N` is past the last page of a
137
121
  // collection, which is how `WpClient.getAll` knows it has walked them all.
138
122
  export function isInvalidPageNumber(error) {
139
- const body = error.cause?.response?.body;
123
+ return parseBody(responseOf(causeOf(error)).body).code === 'rest_post_invalid_page_number';
124
+ }
125
+ // The HTTP response a got error carries, read structurally so a mocked error reads the same.
126
+ export function responseOf(error) {
127
+ const response = isRecord(error) ? error.response : undefined;
128
+ if (!isRecord(response))
129
+ return {};
130
+ return {
131
+ body: typeof response.body === 'string' ? response.body : undefined,
132
+ statusCode: typeof response.statusCode === 'number' ? response.statusCode : undefined,
133
+ };
134
+ }
135
+ // A response body's top-level JSON fields, or none when it isn't JSON.
136
+ export function parseBody(body) {
140
137
  if (!body)
141
- return false;
138
+ return {};
142
139
  try {
143
- return JSON.parse(body).code === 'rest_post_invalid_page_number';
140
+ const parsed = JSON.parse(body);
141
+ return isRecord(parsed) ? parsed : {};
144
142
  }
145
143
  catch {
146
- return false;
144
+ return {};
147
145
  }
148
146
  }
149
147
  export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
150
- const err = error;
151
- const status = err.response?.statusCode;
148
+ const { body, statusCode: status } = responseOf(error);
152
149
  if (status === 401 || status === 403) {
153
150
  // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
154
151
  // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
155
152
  // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
156
153
  // auth failures use `{code, message}` instead and keep the credentials hint.
157
- const refusal = extractLoopressError(err.response?.body);
154
+ const refusal = extractLoopressError(body);
158
155
  if (refusal)
159
156
  return `Request refused (${status}) on ${url}: ${refusal}`;
160
157
  return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
161
158
  }
162
159
  if (status === 404) {
163
- const reason = extractServerErrorMessage(err.response?.body);
160
+ const reason = extractServerErrorMessage(body);
164
161
  return reason
165
162
  ? `Request failed (404) on ${url}: ${reason}`
166
163
  : `Endpoint not found (404) on ${url}. Is the required plugin installed and up to date on the site?`;
167
164
  }
168
165
  if (status !== undefined) {
169
- const reason = extractServerErrorMessage(err.response?.body);
166
+ const reason = extractServerErrorMessage(body);
170
167
  return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
171
168
  }
172
- if (err.name === 'TimeoutError') {
169
+ if (isRecord(error) && error.name === 'TimeoutError') {
173
170
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
174
171
  }
175
- const reason = err.code ? NETWORK_ERROR_HINTS[err.code] : undefined;
172
+ const code = errorCode(error);
173
+ const reason = code ? NETWORK_ERROR_HINTS[code] : undefined;
176
174
  // The site, not the endpoint: every resource fails the same way when the site is down, so
177
175
  // `lps diff` can print it once (see DiffCommand.report).
178
176
  if (reason)
179
177
  return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
180
178
  // Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
181
179
  // 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))}`;
180
+ const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
181
+ return `Request to ${url} failed: ${message || (code ?? String(error))}`;
183
182
  }
184
183
  const NETWORK_ERROR_HINTS = {
185
184
  ECONNREFUSED: 'connection refused, the site may be down',
@@ -188,15 +187,8 @@ const NETWORK_ERROR_HINTS = {
188
187
  ETIMEDOUT: 'connection timed out',
189
188
  };
190
189
  function extractLoopressError(body) {
191
- if (!body)
192
- return undefined;
193
- try {
194
- const { error } = JSON.parse(body);
195
- return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
196
- }
197
- catch {
198
- return undefined;
199
- }
190
+ const { error } = parseBody(body);
191
+ return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
200
192
  }
201
193
  // Plugin exception messages go through esc_html() (WordPress coding standards require it for
202
194
  // anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
@@ -219,24 +211,17 @@ function decodeEntities(text) {
219
211
  // specifically so a caller that only reads `error` doesn't see it, which is exactly what this
220
212
  // function used to do, hiding the one piece of text that actually explains the failure.
221
213
  function extractServerErrorMessage(body) {
222
- if (!body)
214
+ const parsed = parseBody(body);
215
+ // WP core's own generic body for a route it never registered (e.g. a plugin too old to
216
+ // expose this endpoint yet) is never more useful than the "is the plugin up to date?"
217
+ // fallback below; surfacing it verbatim buries a version-skew mismatch behind text that
218
+ // reads like the endpoint doesn't exist at all, pointing the user in the wrong direction.
219
+ if (parsed.code === 'rest_no_route')
223
220
  return undefined;
224
- try {
225
- const parsed = JSON.parse(body);
226
- // WP core's own generic body for a route it never registered (e.g. a plugin too old to
227
- // expose this endpoint yet) is never more useful than the "is the plugin up to date?"
228
- // fallback below; surfacing it verbatim buries a version-skew mismatch behind text that
229
- // reads like the endpoint doesn't exist at all, pointing the user in the wrong direction.
230
- if (parsed.code === 'rest_no_route')
231
- return undefined;
232
- const reason = parsed.error ?? parsed.message;
233
- const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
234
- const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
235
- if (summary && detail)
236
- return `${summary}\n${detail}`;
237
- return summary ?? detail;
238
- }
239
- catch {
240
- return undefined;
241
- }
221
+ const reason = parsed.error ?? parsed.message;
222
+ const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
223
+ const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
224
+ if (summary && detail)
225
+ return `${summary}\n${detail}`;
226
+ return summary ?? detail;
242
227
  }
@@ -1,4 +1,5 @@
1
1
  import got from 'got';
2
+ import { errorMessage, isRecord } from './narrow.js';
2
3
  import { withSpinner } from './spinner.js';
3
4
  export const REQUEST_TIMEOUT_MS = 10_000;
4
5
  /**
@@ -28,10 +29,10 @@ export async function diagnoseWpSite(siteUrl) {
28
29
  catch (error) {
29
30
  return {
30
31
  ok: false,
31
- reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${describe(error)})`,
32
+ reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${errorMessage(error)})`,
32
33
  };
33
34
  }
34
- if (!index?.authentication?.['application-passwords']) {
35
+ if (!isRecord(index?.authentication?.['application-passwords'])) {
35
36
  return {
36
37
  ok: false,
37
38
  reason: `Application Passwords are not available on ${siteUrl}. The site may be older than WordPress 5.6, require HTTPS, or have the feature disabled by a plugin or filter.`,
@@ -69,6 +70,3 @@ async function detectWpVersion(siteUrl) {
69
70
  }
70
71
  return undefined;
71
72
  }
72
- function describe(error) {
73
- return error instanceof Error ? error.message : String(error);
74
- }