@loopress/cli 0.32.2 → 0.34.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 (166) hide show
  1. package/README.md +549 -179
  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 +7 -5
  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 +53 -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.d.ts +2 -2
  30. package/dist/commands/dev.js +13 -18
  31. package/dist/commands/diff.d.ts +6 -3
  32. package/dist/commands/diff.js +63 -13
  33. package/dist/commands/doctor.d.ts +4 -2
  34. package/dist/commands/doctor.js +49 -25
  35. package/dist/commands/form/pull.js +7 -5
  36. package/dist/commands/form/push.js +4 -3
  37. package/dist/commands/init.js +4 -3
  38. package/dist/commands/menu/push.js +8 -8
  39. package/dist/commands/option/list.js +5 -2
  40. package/dist/commands/option/push.js +3 -1
  41. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  42. package/dist/commands/option/{remove.js → rm.js} +2 -1
  43. package/dist/commands/page/push.js +3 -1
  44. package/dist/commands/plugin/add.d.ts +1 -0
  45. package/dist/commands/plugin/add.js +50 -9
  46. package/dist/commands/plugin/audit.d.ts +4 -4
  47. package/dist/commands/plugin/audit.js +35 -15
  48. package/dist/commands/plugin/pull.js +3 -2
  49. package/dist/commands/plugin/push.js +41 -6
  50. package/dist/commands/plugin/status.js +3 -1
  51. package/dist/commands/project/config.js +7 -7
  52. package/dist/commands/project/pull.js +3 -1
  53. package/dist/commands/project/push.js +6 -4
  54. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  55. package/dist/commands/project/{remove.js → rm.js} +3 -2
  56. package/dist/commands/project/switch.js +6 -4
  57. package/dist/commands/promote.d.ts +1 -0
  58. package/dist/commands/promote.js +15 -6
  59. package/dist/commands/pull.d.ts +7 -0
  60. package/dist/commands/pull.js +67 -18
  61. package/dist/commands/push.d.ts +11 -1
  62. package/dist/commands/push.js +157 -48
  63. package/dist/commands/snippet/publish.js +9 -5
  64. package/dist/commands/snippet/push.d.ts +1 -1
  65. package/dist/commands/snippet/push.js +15 -12
  66. package/dist/commands/status.js +1 -0
  67. package/dist/commands/theme/activate.d.ts +21 -0
  68. package/dist/commands/theme/activate.js +44 -0
  69. package/dist/commands/theme/pull.js +2 -1
  70. package/dist/commands/theme/push.js +7 -3
  71. package/dist/commands/theme/style/diff.js +1 -1
  72. package/dist/commands/theme/style/push.js +8 -3
  73. package/dist/commands/theme/style/rollback.js +1 -1
  74. package/dist/commands/theme/template/push.d.ts +2 -0
  75. package/dist/commands/theme/template/push.js +66 -18
  76. package/dist/commands/validate.js +3 -0
  77. package/dist/config/auth.manager.d.ts +10 -7
  78. package/dist/config/auth.manager.js +5 -1
  79. package/dist/config/json-file.d.ts +1 -1
  80. package/dist/config/project-config.manager.d.ts +2 -2
  81. package/dist/config/project-config.manager.js +13 -21
  82. package/dist/lib/api-client.js +16 -21
  83. package/dist/lib/app-build.js +6 -2
  84. package/dist/lib/app-manifest.js +6 -6
  85. package/dist/lib/base.d.ts +8 -1
  86. package/dist/lib/base.js +43 -9
  87. package/dist/lib/bootstrap-full-install.js +8 -6
  88. package/dist/lib/capabilities.d.ts +44 -0
  89. package/dist/lib/capabilities.js +85 -0
  90. package/dist/lib/declared-plugins.d.ts +9 -0
  91. package/dist/lib/declared-plugins.js +21 -0
  92. package/dist/lib/declared-type-command.js +7 -2
  93. package/dist/lib/dev-watch.d.ts +1 -1
  94. package/dist/lib/dev-watch.js +13 -15
  95. package/dist/lib/diff-command.d.ts +19 -3
  96. package/dist/lib/diff-command.js +132 -14
  97. package/dist/lib/diff-state.js +2 -3
  98. package/dist/lib/environment-urls.d.ts +6 -0
  99. package/dist/lib/environment-urls.js +59 -0
  100. package/dist/lib/exclude.d.ts +16 -0
  101. package/dist/lib/exclude.js +102 -0
  102. package/dist/lib/load-files.js +2 -1
  103. package/dist/lib/load-snippets.js +39 -43
  104. package/dist/lib/local-callback-server.js +7 -1
  105. package/dist/lib/narrow.d.ts +5 -0
  106. package/dist/lib/narrow.js +26 -0
  107. package/dist/lib/php-files-command.js +13 -8
  108. package/dist/lib/plugin-detection.d.ts +0 -1
  109. package/dist/lib/plugin-detection.js +2 -4
  110. package/dist/lib/push-command.d.ts +5 -1
  111. package/dist/lib/push-command.js +48 -7
  112. package/dist/lib/put-or-create.d.ts +2 -2
  113. package/dist/lib/readdir-tolerant.js +9 -4
  114. package/dist/lib/resource-selection.d.ts +9 -0
  115. package/dist/lib/resource-selection.js +49 -0
  116. package/dist/lib/resource-state.d.ts +2 -0
  117. package/dist/lib/resource-state.js +60 -14
  118. package/dist/lib/rollback-command.d.ts +1 -1
  119. package/dist/lib/rollback-command.js +4 -3
  120. package/dist/lib/rollback-materialize.js +29 -9
  121. package/dist/lib/rotate-app-password.js +1 -0
  122. package/dist/lib/sentry.js +2 -2
  123. package/dist/lib/snapshot-store.d.ts +10 -7
  124. package/dist/lib/snapshot-store.js +21 -5
  125. package/dist/lib/spinner.js +4 -0
  126. package/dist/lib/validate-local.d.ts +1 -0
  127. package/dist/lib/validate-local.js +102 -28
  128. package/dist/lib/wp-client.d.ts +7 -1
  129. package/dist/lib/wp-client.js +60 -69
  130. package/dist/lib/wp-site-diagnostic.js +3 -5
  131. package/dist/types/global-config.generated.d.ts +1 -1
  132. package/dist/types/plugin.d.ts +7 -5
  133. package/dist/types/plugin.js +6 -1
  134. package/dist/types/project-config.generated.d.ts +24 -5
  135. package/dist/types/snippet.d.ts +2 -2
  136. package/dist/types/snippet.js +1 -1
  137. package/dist/utils/acf-format.js +6 -3
  138. package/dist/utils/block-format.d.ts +29 -0
  139. package/dist/utils/block-format.js +109 -0
  140. package/dist/utils/composer.d.ts +7 -15
  141. package/dist/utils/composer.js +43 -16
  142. package/dist/utils/declared-type-format.js +26 -19
  143. package/dist/utils/form-format.js +2 -2
  144. package/dist/utils/loopress-config.js +9 -1
  145. package/dist/utils/menu-format.d.ts +3 -1
  146. package/dist/utils/menu-format.js +5 -0
  147. package/dist/utils/option-format.d.ts +2 -2
  148. package/dist/utils/option-format.js +15 -25
  149. package/dist/utils/page-format.js +9 -7
  150. package/dist/utils/php-top-level.js +15 -4
  151. package/dist/utils/plugin-sync.d.ts +7 -7
  152. package/dist/utils/plugin-sync.js +11 -11
  153. package/dist/utils/plugins.js +9 -6
  154. package/dist/utils/resource-dirs.d.ts +2 -0
  155. package/dist/utils/resource-dirs.js +13 -5
  156. package/dist/utils/snippet-format.d.ts +1 -1
  157. package/dist/utils/snippet-format.js +5 -3
  158. package/dist/utils/template-format.js +2 -2
  159. package/dist/utils/theme-css.js +14 -6
  160. package/dist/utils/theme-styles-format.d.ts +3 -0
  161. package/dist/utils/theme-styles-format.js +15 -5
  162. package/dist/utils/version.js +1 -1
  163. package/oclif.manifest.json +2661 -1235
  164. package/package.json +45 -20
  165. package/schemas/global-config.schema.json +1 -1
  166. package/schemas/project-config.schema.json +32 -5
@@ -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,
@@ -95,7 +115,7 @@ const MATERIALIZERS = {
95
115
  role: declaredType(ROLE_SPEC),
96
116
  snippet,
97
117
  taxonomy: declaredType(TAXONOMY_SPEC),
98
- 'theme-styles': themeStyles,
118
+ 'theme-style': themeStyles,
99
119
  };
100
120
  // Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
101
121
  // have produced, so the existing `<resource> push` command can push it unmodified: a rollback
@@ -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(() => {
@@ -6,5 +6,6 @@ export type ValidateResult = {
6
6
  checked: number;
7
7
  problems: Problem[];
8
8
  valid: boolean;
9
+ warnings: Problem[];
9
10
  };
10
11
  export declare function validateLocal(cwd: string): Promise<ValidateResult>;
@@ -1,27 +1,34 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readdir, 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';
7
11
  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';
14
+ import { globToRegExp, matchesAny } from './exclude.js';
10
15
  import { loadSnippets } from './load-snippets.js';
16
+ import { errorCode, errorMessage, isRecord } from './narrow.js';
11
17
  import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
12
18
  import { readdirTolerant } from './readdir-tolerant.js';
13
19
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
14
20
  // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
15
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStylesDir'];
21
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStyleDir'];
16
22
  const DEFAULT_DIR = {
17
23
  acfDir: 'acf',
18
24
  apiDir: 'api',
25
+ blockDir: RESOURCE_DIR_DEFAULTS.block,
19
26
  cptDir: 'cpt',
20
27
  formDir: 'forms',
21
- hooksDir: 'hooks',
28
+ hookDir: 'hooks',
22
29
  menuDir: 'menus',
23
30
  metaDir: 'meta',
24
- optionsDir: 'options',
31
+ optionDir: 'options',
25
32
  pageDir: 'pages',
26
33
  partDir: RESOURCE_DIR_DEFAULTS.part,
27
34
  patternDir: RESOURCE_DIR_DEFAULTS.pattern,
@@ -29,7 +36,7 @@ const DEFAULT_DIR = {
29
36
  snippetsDir: 'snippets',
30
37
  taxonomyDir: 'taxonomies',
31
38
  templateDir: RESOURCE_DIR_DEFAULTS.template,
32
- themeStylesDir: 'theme',
39
+ themeStyleDir: 'theme',
33
40
  };
34
41
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
35
42
  // and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
@@ -52,7 +59,7 @@ export async function validateLocal(cwd) {
52
59
  }
53
60
  // Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
54
61
  // there with a warning, so it's a problem here.
55
- checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
62
+ checked += await checkJsonDir(resolve('optionDir'), problems, parseLocalOption);
56
63
  checked += await checkJsonDir(resolve('cptDir'), problems, declaredFileChecker(CPT_SPEC));
57
64
  checked += await checkJsonDir(resolve('taxonomyDir'), problems, declaredFileChecker(TAXONOMY_SPEC));
58
65
  checked += await checkJsonDir(resolve('metaDir'), problems, declaredFileChecker(META_SPEC));
@@ -60,14 +67,75 @@ export async function validateLocal(cwd) {
60
67
  checked += await checkPatterns(resolve('patternDir'), problems);
61
68
  checked += await checkSnippets(resolve('snippetsDir'), problems);
62
69
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
63
- checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
70
+ checked += await checkPhpDir(resolve('hookDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
71
+ const blocks = await readLocalBlocks(resolve('blockDir'));
72
+ checked += blocks.blocks.length;
73
+ problems.push(...blocks.problems);
64
74
  const pages = await readLocalPages(resolve('pageDir'));
65
75
  checked += pages.pages.length + pages.problems.length;
66
76
  problems.push(...pages.problems);
67
77
  const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
68
78
  checked += templates.templates.length + templates.parts.length + templates.problems.length;
69
79
  problems.push(...templates.problems);
70
- return { checked, problems, valid: problems.length === 0 };
80
+ const warnings = await checkExcludes(localConfig, join(cwd, rootDir), problems);
81
+ return { checked, problems, valid: problems.length === 0, warnings };
82
+ }
83
+ // `environments.<name>.exclude` (lib/exclude.ts): a list of globs per environment. A glob that
84
+ // matches no file is a warning, not a problem: likely a typo or a renamed file, so the feature it
85
+ // was meant to hold back would reach that environment.
86
+ async function checkExcludes(localConfig, root, problems) {
87
+ const { environments } = localConfig;
88
+ if (environments === undefined)
89
+ return [];
90
+ if (!isRecord(environments) || Array.isArray(environments)) {
91
+ problems.push({ file: 'loopress.json', message: '"environments" must be an object of environment name -> settings' });
92
+ return [];
93
+ }
94
+ const warnings = [];
95
+ let files;
96
+ for (const [name, settings] of Object.entries(environments)) {
97
+ const parsed = ENVIRONMENT_SETTINGS.safeParse(settings);
98
+ if (!parsed.success) {
99
+ problems.push({ file: 'loopress.json', message: `environments.${name} must be an object with an optional "exclude" list of glob strings` });
100
+ continue;
101
+ }
102
+ for (const pattern of parsed.data.exclude ?? []) {
103
+ files ??= await projectFiles(root);
104
+ const matcher = globToRegExp(pattern);
105
+ if (files.every((segments) => !matchesAny([matcher], segments))) {
106
+ warnings.push({ file: 'loopress.json', message: `environments.${name}.exclude: "${pattern}" matches no file` });
107
+ }
108
+ }
109
+ }
110
+ return warnings;
111
+ }
112
+ const ENVIRONMENT_SETTINGS = z.strictObject({ exclude: z.array(z.string()).optional() });
113
+ // Every file under `root` as '/'-split segments, without node_modules and .git.
114
+ async function projectFiles(root) {
115
+ const files = [];
116
+ const walk = async (dir) => {
117
+ let entries;
118
+ try {
119
+ entries = await readdir(dir, { withFileTypes: true });
120
+ }
121
+ catch (error) {
122
+ if (errorCode(error) === 'ENOENT')
123
+ return;
124
+ throw error;
125
+ }
126
+ for (const entry of entries) {
127
+ const path = join(dir, entry.name);
128
+ if (entry.isDirectory()) {
129
+ if (entry.name !== 'node_modules' && entry.name !== '.git')
130
+ await walk(path);
131
+ }
132
+ else {
133
+ files.push(relative(root, path).split(sep));
134
+ }
135
+ }
136
+ };
137
+ await walk(root);
138
+ return files;
71
139
  }
72
140
  // Same directory rules as pages (flat, .html only), then the same checks as `pattern push`.
73
141
  async function checkPatterns(dir, problems) {
@@ -108,6 +176,7 @@ async function checkPhpDir(dir, problems, emptyMessage, spec) {
108
176
  }
109
177
  return checked;
110
178
  }
179
+ const JSON_OBJECT = z.record(z.string(), z.unknown());
111
180
  async function checkComposerJson(cwd, problems) {
112
181
  let raw;
113
182
  try {
@@ -122,14 +191,13 @@ async function checkComposerJson(cwd, problems) {
122
191
  parsed = JSON.parse(raw);
123
192
  }
124
193
  catch (error) {
125
- problems.push({ file: 'composer.json', message: `not valid JSON: ${error.message}` });
194
+ problems.push({ file: 'composer.json', message: `not valid JSON: ${errorMessage(error)}` });
126
195
  return 1;
127
196
  }
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
- }
197
+ const result = COMPOSER_JSON.safeParse(parsed);
198
+ if (!result.success) {
199
+ for (const { message } of result.error.issues)
200
+ problems.push({ file: 'composer.json', message });
133
201
  }
134
202
  return 1;
135
203
  }
@@ -151,10 +219,10 @@ async function checkJsonDir(dir, problems, shape) {
151
219
  parsed = JSON.parse(raw);
152
220
  }
153
221
  catch (error) {
154
- problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
222
+ problems.push({ file: filePath, message: `not valid JSON: ${errorMessage(error)}` });
155
223
  continue;
156
224
  }
157
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
225
+ if (!JSON_OBJECT.safeParse(parsed).success) {
158
226
  problems.push({ file: filePath, message: 'expected a JSON object' });
159
227
  continue;
160
228
  }
@@ -162,7 +230,7 @@ async function checkJsonDir(dir, problems, shape) {
162
230
  shape?.(raw, filePath);
163
231
  }
164
232
  catch (error) {
165
- problems.push({ file: filePath, message: error.message });
233
+ problems.push({ file: filePath, message: errorMessage(error) });
166
234
  }
167
235
  }
168
236
  return checked;
@@ -184,19 +252,16 @@ function checkLoopressJson(localConfig, problems) {
184
252
  const { plugins } = localConfig;
185
253
  if (plugins === undefined)
186
254
  return;
187
- if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
255
+ const manifest = JSON_OBJECT.safeParse(plugins);
256
+ if (!manifest.success) {
188
257
  problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
189
258
  return;
190
259
  }
191
- for (const [slug, version] of Object.entries(plugins)) {
260
+ for (const [slug, version] of Object.entries(manifest.data)) {
192
261
  // 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)
262
+ if (!PLUGIN_PIN.safeParse(version).success) {
199
263
  problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
264
+ }
200
265
  }
201
266
  }
202
267
  async function checkSnippets(dir, problems) {
@@ -211,7 +276,7 @@ async function checkSnippets(dir, problems) {
211
276
  });
212
277
  }
213
278
  catch (error) {
214
- problems.push({ file: dir, message: error.message });
279
+ problems.push({ file: dir, message: errorMessage(error) });
215
280
  return 0;
216
281
  }
217
282
  // loadSnippets coerces an invalid `type` to the extension default rather than failing, so
@@ -227,6 +292,10 @@ async function checkSnippets(dir, problems) {
227
292
  problems.push({ file: filePath, message: 'not valid JSON' });
228
293
  continue;
229
294
  }
295
+ if (!isRecord(meta)) {
296
+ problems.push({ file: filePath, message: 'expected a JSON object' });
297
+ continue;
298
+ }
230
299
  if (meta.type !== undefined && parseType(meta.type) === null) {
231
300
  problems.push({ file: filePath, message: `"type": ${JSON.stringify(meta.type)} is not one of css, html, js, php, text` });
232
301
  }
@@ -245,15 +314,20 @@ async function readConfig(cwd, problems) {
245
314
  raw = await readFile(join(cwd, 'loopress.json'), 'utf8');
246
315
  }
247
316
  catch (error) {
248
- if (error.code === 'ENOENT')
317
+ if (errorCode(error) === 'ENOENT')
249
318
  return {};
250
319
  throw error;
251
320
  }
321
+ let parsed;
252
322
  try {
253
- return JSON.parse(raw);
323
+ parsed = JSON.parse(raw);
254
324
  }
255
325
  catch {
256
326
  problems.push({ file: 'loopress.json', message: 'loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.' });
257
327
  return {};
258
328
  }
329
+ if (isRecord(parsed) && !Array.isArray(parsed))
330
+ return parsed;
331
+ problems.push({ file: 'loopress.json', message: 'expected a JSON object' });
332
+ return {};
259
333
  }
@@ -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>;
@@ -24,9 +24,15 @@ type RequestOptions = {
24
24
  };
25
25
  export declare function isNotFoundError(error: unknown): boolean;
26
26
  export declare function isForbiddenError(error: unknown): boolean;
27
+ export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
27
28
  export declare function isApplicative404(error: unknown, marker: string): boolean;
28
29
  export declare function isConflictCode(error: unknown, code: string): boolean;
29
30
  export declare function isTimeoutError(error: unknown): boolean;
30
31
  export declare function isInvalidPageNumber(error: unknown): boolean;
32
+ export declare function responseOf(error: unknown): {
33
+ body?: string | undefined;
34
+ statusCode?: number | undefined;
35
+ };
36
+ export declare function parseBody(body: string | undefined): Record<string, unknown>;
31
37
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
32
38
  export {};