@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
@@ -2,17 +2,41 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import writeFileAtomic from 'write-file-atomic';
5
+ import { z } from 'zod';
6
+ import { errorMessage } from '../lib/narrow.js';
5
7
  import { withSpinner } from '../lib/spinner.js';
8
+ import {} from './plugin-sync.js';
9
+ import {} from './plugins.js';
6
10
  import { isExactVersion } from './version.js';
7
11
  // Why pullIntoComposerJson skips a plugin it can't install: premium or custom, not on WPackagist.
8
12
  export const NOT_ON_WORDPRESS_ORG = 'not on WordPress.org';
13
+ // Only the sections the CLI reads are checked: the rest is Composer's own business, and goes
14
+ // back to disk untouched.
15
+ export const COMPOSER_JSON = z.looseObject({
16
+ require: z.record(z.string(), z.string('"require" must map each package to a version constraint'), '"require" must be an object').optional(),
17
+ 'require-dev': z
18
+ .record(z.string(), z.string('"require-dev" must map each package to a version constraint'), '"require-dev" must be an object')
19
+ .optional(),
20
+ }, 'expected a JSON object');
21
+ // Returns the parsed object itself, not zod's copy: zod's output moves the checked keys first, and
22
+ // pullIntoComposerJson writes this object back to the user's composer.json.
23
+ export function parseComposerJson(raw) {
24
+ const parsed = JSON.parse(raw);
25
+ if (isComposerJson(parsed))
26
+ return parsed;
27
+ const issues = COMPOSER_JSON.safeParse(parsed).error?.issues ?? [];
28
+ throw new Error(`Invalid composer.json: ${issues.map(({ message }) => message).join(', ')}`);
29
+ }
30
+ function isComposerJson(value) {
31
+ return COMPOSER_JSON.safeParse(value).success;
32
+ }
9
33
  export async function readComposerJson() {
10
34
  const path = join(process.cwd(), 'composer.json');
11
35
  if (!existsSync(path))
12
36
  return null;
13
37
  try {
14
38
  const content = await readFile(path, 'utf8');
15
- return JSON.parse(content);
39
+ return parseComposerJson(content);
16
40
  }
17
41
  catch {
18
42
  return null;
@@ -48,7 +72,7 @@ async function isOnWordPressOrg(kind, slug) {
48
72
  response = await withSpinner(`Checking WordPress.org for ${slug}`, async () => fetch(url));
49
73
  }
50
74
  catch (error) {
51
- throw fail(`network error: ${error.message}`);
75
+ throw fail(`network error: ${errorMessage(error)}`);
52
76
  }
53
77
  if (response.status === 404)
54
78
  return false;
@@ -77,15 +101,16 @@ function planComposerPull(composerJson, prefix, incoming) {
77
101
  const require = composerJson.require ?? {};
78
102
  const allPackages = { ...require, ...composerJson['require-dev'] };
79
103
  const providedBy = new Map(Object.keys(allPackages)
80
- .filter((name) => !name.startsWith(prefix) && name.includes('/'))
104
+ .filter((name) => !name.startsWith('wpackagist-plugin/') && !name.startsWith('wpackagist-theme/') && name.includes('/'))
81
105
  .map((name) => [name.split('/', 2)[1], name]));
82
106
  const result = { added: [], merged: {}, skipped: [], updated: [] };
83
107
  const candidates = [];
84
108
  for (const [slug, version] of Object.entries(incoming)) {
85
109
  const name = prefix + slug;
86
110
  const provider = providedBy.get(slug);
87
- if (Object.hasOwn(allPackages, name)) {
88
- const outcome = classifyDeclared(allPackages[name], version, Object.hasOwn(require, name));
111
+ const declared = Object.hasOwn(allPackages, name) ? allPackages[name] : undefined;
112
+ if (declared !== undefined) {
113
+ const outcome = classifyDeclared(declared, version, Object.hasOwn(require, name));
89
114
  if ('skip' in outcome) {
90
115
  result.skipped.push({ reason: outcome.skip, slug });
91
116
  continue;
@@ -98,7 +123,7 @@ function planComposerPull(composerJson, prefix, incoming) {
98
123
  result.skipped.push({ reason: `provided by ${provider}`, slug });
99
124
  }
100
125
  else {
101
- candidates.push(slug);
126
+ candidates.push([slug, version]);
102
127
  }
103
128
  }
104
129
  return { candidates, result };
@@ -112,14 +137,14 @@ function planComposerPull(composerJson, prefix, incoming) {
112
137
  // - A slug provided by another package (acme/<slug>) or absent from WordPress.org is skipped.
113
138
  export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }) {
114
139
  const raw = await readFile(path, 'utf8');
115
- const composerJson = JSON.parse(raw);
140
+ const composerJson = parseComposerJson(raw);
116
141
  const prefix = `wpackagist-${kind}/`;
117
142
  const { candidates, result } = planComposerPull(composerJson, prefix, incoming);
118
- const found = await Promise.all(candidates.map(async (slug) => isOnWordPressOrg(kind, slug)));
119
- for (const [i, slug] of candidates.entries()) {
120
- if (found[i]) {
143
+ const checked = await Promise.all(candidates.map(async ([slug, version]) => ({ onWordPressOrg: await isOnWordPressOrg(kind, slug), slug, version })));
144
+ for (const { onWordPressOrg, slug, version } of checked) {
145
+ if (onWordPressOrg) {
121
146
  result.added.push(slug);
122
- result.merged[slug] = incoming[slug];
147
+ result.merged[slug] = version;
123
148
  }
124
149
  else {
125
150
  result.skipped.push({ reason: NOT_ON_WORDPRESS_ORG, slug });
@@ -146,21 +171,23 @@ export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }
146
171
  // Split a composer.json `require` map into the three intent namespaces the sync endpoint
147
172
  // understands. `composer/installers` is owned by the server scaffold, never sent.
148
173
  export function toIntent(require) {
149
- const intent = { libraries: {}, plugins: {}, themes: {} };
174
+ const plugins = {};
175
+ const themes = {};
176
+ const libraries = {};
150
177
  for (const [name, constraint] of Object.entries(require)) {
151
178
  if (name === 'composer/installers')
152
179
  continue;
153
180
  if (name.startsWith('wpackagist-plugin/')) {
154
- intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
181
+ plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
155
182
  }
156
183
  else if (name.startsWith('wpackagist-theme/')) {
157
- intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
184
+ themes[name.slice('wpackagist-theme/'.length)] = constraint;
158
185
  }
159
186
  else {
160
- intent.libraries[name] = constraint;
187
+ libraries[name] = constraint;
161
188
  }
162
189
  }
163
- return intent;
190
+ return { libraries, plugins, themes };
164
191
  }
165
192
  // What a push installs: `require`, plus `require-dev` on the local environment only (Query
166
193
  // Monitor, or a premium plugin's free edition to develop against). A remote site never gets
@@ -1,5 +1,7 @@
1
1
  import { basename } from 'node:path';
2
+ import { z } from 'zod';
2
3
  import { parseHtmlHeader, titleFromSlug } from './page-format.js';
4
+ import {} from './resource-dirs.js';
3
5
  export const CPT_SPEC = {
4
6
  arrayArgs: { capabilities: [], labels: [], rewrite: [true, false], supports: [false], taxonomies: [], template: [] },
5
7
  cliName: 'cpt',
@@ -31,15 +33,16 @@ export const TAXONOMY_SPEC = {
31
33
  // register_taxonomy() creates the default term when it doesn't exist yet.
32
34
  termArgs: ['default_term'],
33
35
  };
34
- const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
35
- // register_post_meta()'s `type` values, each with the check its `default` must pass.
36
+ const JSON_OBJECT = z.record(z.string(), z.unknown());
37
+ const isObject = (value) => JSON_OBJECT.safeParse(value).success;
38
+ // register_post_meta()'s `type` values, each with the schema its `default` must pass.
36
39
  const META_TYPES = {
37
- array: Array.isArray,
38
- boolean: (value) => typeof value === 'boolean',
39
- integer: Number.isSafeInteger,
40
- number: (value) => typeof value === 'number',
41
- object: isObject,
42
- string: (value) => typeof value === 'string',
40
+ array: z.array(z.unknown()),
41
+ boolean: z.boolean(),
42
+ integer: z.int(),
43
+ number: z.number(),
44
+ object: JSON_OBJECT,
45
+ string: z.string(),
43
46
  };
44
47
  function checkMetaRest(key, type, rest) {
45
48
  if (typeof rest !== 'boolean' && !isObject(rest))
@@ -54,7 +57,7 @@ function checkMetaRest(key, type, rest) {
54
57
  }
55
58
  }
56
59
  function checkMetaKey(key, meta) {
57
- if (!/^[a-zA-Z0-9_-]{1,255}$/.test(key))
60
+ if (!/^[\w-]{1,255}$/.test(key))
58
61
  throw new Error(`"${key}" is not a valid meta key: 1 to 255 letters, digits, "_" or "-"`);
59
62
  if (!isObject(meta))
60
63
  throw new Error(`"${key}" must be a JSON object of register_post_meta() arguments`);
@@ -62,13 +65,14 @@ function checkMetaKey(key, meta) {
62
65
  if (code)
63
66
  throw new Error(`"${key}": "${code}" runs PHP code, use a hook instead of a file for it`);
64
67
  const { type } = meta;
65
- if (typeof type !== 'string' || !Object.hasOwn(META_TYPES, type)) {
68
+ const schema = typeof type === 'string' && Object.hasOwn(META_TYPES, type) ? META_TYPES[type] : undefined;
69
+ if (typeof type !== 'string' || !schema) {
66
70
  throw new Error(`"${key}": "type" must be one of ${Object.keys(META_TYPES).join(', ')}`);
67
71
  }
68
72
  if (Object.hasOwn(meta, 'single') && typeof meta.single !== 'boolean')
69
73
  throw new Error(`"${key}": "single" must be true or false`);
70
- checkMetaRest(key, type, meta.show_in_rest ?? false);
71
- if (Object.hasOwn(meta, 'default') && !META_TYPES[type](meta.default))
74
+ checkMetaRest(key, type, Object.hasOwn(meta, 'show_in_rest') ? meta.show_in_rest : false);
75
+ if (Object.hasOwn(meta, 'default') && !schema.safeParse(meta.default).success)
72
76
  throw new Error(`"${key}": "default" must be a ${type}`);
73
77
  }
74
78
  // Same checks as the plugin's PostMetaService::assertArgs(): the file is `key => register_post_meta()
@@ -99,7 +103,7 @@ function checkRoleArgs(args) {
99
103
  if (typeof args.name !== 'string' || args.name.trim() === '')
100
104
  throw new Error('"name" must be a non-empty string');
101
105
  const capabilities = args.capabilities ?? {};
102
- if (!isObject(capabilities) || Object.values(capabilities).some((granted) => typeof granted !== 'boolean')) {
106
+ if (!z.record(z.string(), z.boolean()).safeParse(capabilities).success) {
103
107
  throw new Error('"capabilities" must map each capability name to true or false');
104
108
  }
105
109
  }
@@ -132,6 +136,8 @@ const PATTERN_HEADER = {
132
136
  title: { key: 'title', type: 'text' },
133
137
  'viewport-width': { key: 'viewportWidth', type: 'integer' },
134
138
  };
139
+ // Typed lookups by header name: a plain object index needs `keyof typeof` on a string.
140
+ const PATTERN_HEADER_BY_NAME = new Map(Object.entries(PATTERN_HEADER));
135
141
  function readPatternValue(header, type, value) {
136
142
  if (type === 'list')
137
143
  return value.split(',').map((item) => item.trim()).filter(Boolean);
@@ -152,8 +158,10 @@ export function parsePatternFile(slug, raw) {
152
158
  const { body, meta } = parseHtmlHeader(raw, Object.keys(PATTERN_HEADER));
153
159
  const args = { title: titleFromSlug(slug) };
154
160
  for (const [header, value] of Object.entries(meta)) {
155
- const { key, type } = PATTERN_HEADER[header];
156
- args[key] = readPatternValue(header, type, value);
161
+ // parseHtmlHeader already refuses any other header name.
162
+ const spec = PATTERN_HEADER_BY_NAME.get(header);
163
+ if (spec)
164
+ args[spec.key] = readPatternValue(header, spec.type, value);
157
165
  }
158
166
  args.content = body;
159
167
  return args;
@@ -199,11 +207,10 @@ export function itemEndpoint(spec, slug) {
199
207
  return `${spec.endpoint}/${encodeURIComponent(slug)}`;
200
208
  }
201
209
  export function parseDeclaredArgs(raw) {
202
- const parsed = JSON.parse(raw);
203
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
210
+ const result = JSON_OBJECT.safeParse(JSON.parse(raw));
211
+ if (!result.success)
204
212
  throw new Error('not a JSON object of arguments');
205
- }
206
- return parsed;
213
+ return result.data;
207
214
  }
208
215
  // `lps validate`'s check of one file: the file name is a valid slug, array arguments are arrays,
209
216
  // and no argument is code.
@@ -1,3 +1,4 @@
1
+ import { isRecord } from '../lib/narrow.js';
1
2
  export const FORM_ENDPOINT = 'loopress/v1/forms';
2
3
  export function formEndpoint(id) {
3
4
  return `${FORM_ENDPOINT}/${id}`;
@@ -7,7 +8,6 @@ export function getFormId(data) {
7
8
  return Number.isSafeInteger(id) && id > 0 ? id : null;
8
9
  }
9
10
  export function getFormTitle(data) {
10
- const settings = data.settings;
11
- const title = settings?.form_title;
11
+ const title = isRecord(data.settings) ? data.settings.form_title : undefined;
12
12
  return typeof title === 'string' && title.trim() !== '' ? title : '(untitled)';
13
13
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { isRecord } from '../lib/narrow.js';
5
+ import {} from '../types/project-config.generated.js';
4
6
  // Only a missing file is treated as "no config" (returns {}). A file that exists but
5
7
  // fails to read or parse throws, so callers don't silently fall back to the global
6
8
  // current environment when loopress.json is actually broken.
@@ -9,12 +11,18 @@ export async function readLocalConfig() {
9
11
  if (!existsSync(configPath))
10
12
  return {};
11
13
  const content = await readFile(configPath, 'utf8');
14
+ let parsed;
12
15
  try {
13
- return JSON.parse(content);
16
+ parsed = JSON.parse(content);
14
17
  }
15
18
  catch {
16
19
  throw new Error('loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.');
17
20
  }
21
+ if (!isRecord(parsed) || Array.isArray(parsed))
22
+ throw new Error('loopress.json must be a JSON object. Fix or delete it, then run `lps init` again.');
23
+ // Its keys are checked by `lps validate` (lib/validate-local.ts), not here: schemas/project-config.schema.json
24
+ // refuses unknown keys, which would stop every command over one stray key.
25
+ return parsed;
18
26
  }
19
27
  export async function writeLocalConfig(config) {
20
28
  const configPath = join(process.cwd(), 'loopress.json');
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  export declare const MENU_ENDPOINT = "loopress/v1/menus";
2
3
  export declare const MENU_LOCATIONS_ENDPOINT = "loopress/v1/menu-locations";
3
4
  export declare function menuEndpoint(slug: string): string;
@@ -20,5 +21,6 @@ export type Menu = {
20
21
  slug: string;
21
22
  warnings: string[];
22
23
  };
23
- export type MenuLocations = Record<string, null | string>;
24
+ export declare const MENU_LOCATIONS: z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>;
25
+ export type MenuLocations = z.infer<typeof MENU_LOCATIONS>;
24
26
  export declare function getMenuSlug(data: Record<string, unknown>): null | string;
@@ -1,8 +1,13 @@
1
+ import { z } from 'zod';
1
2
  export const MENU_ENDPOINT = 'loopress/v1/menus';
2
3
  export const MENU_LOCATIONS_ENDPOINT = 'loopress/v1/menu-locations';
3
4
  export function menuEndpoint(slug) {
4
5
  return `${MENU_ENDPOINT}/${encodeURIComponent(slug)}`;
5
6
  }
7
+ // location => menu slug, or null when unassigned. Scoped to whichever theme is active on
8
+ // whichever environment answered the request (a theme_mod, see MenuService::getLocations()).
9
+ // Location slug -> assigned menu slug, null for an unassigned location.
10
+ export const MENU_LOCATIONS = z.record(z.string(), z.string().nullable());
6
11
  export function getMenuSlug(data) {
7
12
  return typeof data.slug === 'string' && data.slug.trim() !== '' ? data.slug : null;
8
13
  }
@@ -6,8 +6,8 @@ export type OptionRefs = Record<string, string>;
6
6
  export type LocalOption = {
7
7
  autoload: string;
8
8
  name: string;
9
- readonly?: boolean;
10
- refs?: OptionRefs;
9
+ readonly?: boolean | undefined;
10
+ refs?: OptionRefs | undefined;
11
11
  value: unknown;
12
12
  };
13
13
  export type RemoteOption = {
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  export const OPTIONS_ENDPOINT = 'loopress/v1/options';
2
3
  // `refs` asks WordPress to return the declared post references as paths (see OptionRefs).
3
4
  export function optionEndpoint(name, refs) {
@@ -49,32 +50,21 @@ export function partitionByReadonly(tracked) {
49
50
  (option.readonly ? skipped : writable).push(option);
50
51
  return { skipped, writable };
51
52
  }
52
- export function parseLocalOption(raw) {
53
- const parsed = JSON.parse(raw);
54
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
55
- throw new Error('not a JSON object');
56
- }
57
- const { autoload, name, refs, value } = parsed;
58
- if (typeof name !== 'string' || name === '') {
59
- throw new Error('missing a "name" string');
60
- }
61
- if (typeof autoload !== 'string') {
62
- throw new TypeError('missing an "autoload" string');
63
- }
53
+ const REFS_ERROR = '"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}';
54
+ // Each check's message is the error thrown for it, the first failing one wins. Loose: unknown
55
+ // keys (`readonly`, future ones) pass through untouched.
56
+ const LOCAL_OPTION = z.looseObject({
57
+ name: z.string('missing a "name" string').min(1, 'missing a "name" string'),
58
+ autoload: z.string('missing an "autoload" string'),
64
59
  // `value` itself is deliberately untyped (a WP option can hold any JSON-safe value, including
65
60
  // null); only the key's presence is checked, so a hand-edited file that dropped it entirely is
66
61
  // rejected here rather than silently pushing an `undefined` value to WordPress.
67
- if (value === undefined) {
68
- throw new Error('missing a "value" field');
69
- }
70
- if (refs !== undefined && !isOptionRefs(refs)) {
71
- throw new TypeError('"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}');
72
- }
73
- return parsed;
74
- }
75
- function isOptionRefs(refs) {
76
- return (typeof refs === 'object' &&
77
- refs !== null &&
78
- !Array.isArray(refs) &&
79
- Object.entries(refs).every(([path, postType]) => path !== '' && typeof postType === 'string' && postType !== ''));
62
+ value: z.custom((value) => value !== undefined, 'missing a "value" field'),
63
+ refs: z.record(z.string().min(1, REFS_ERROR), z.string(REFS_ERROR).min(1, REFS_ERROR), REFS_ERROR).optional(),
64
+ }, 'not a JSON object');
65
+ export function parseLocalOption(raw) {
66
+ const result = LOCAL_OPTION.safeParse(JSON.parse(raw));
67
+ if (!result.success)
68
+ throw new TypeError(result.error.issues[0]?.message);
69
+ return result.data;
80
70
  }
@@ -1,5 +1,7 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { basename, extname, join } from 'node:path';
3
+ import { withoutExcluded } from '../lib/exclude.js';
4
+ import { errorCode, errorMessage, isOneOf } from '../lib/narrow.js';
3
5
  export const PAGES_ENDPOINT = 'loopress/v1/pages';
4
6
  // Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
5
7
  // sanitize_title() would rewrite those, and the page would no longer match its file name.
@@ -45,7 +47,7 @@ export function titleFromSlug(slug) {
45
47
  export function parsePageFile(slug, raw) {
46
48
  const { body, meta } = parseHtmlHeader(raw, HEADER_KEYS);
47
49
  const status = meta.status ?? 'draft';
48
- if (!PAGE_STATUSES.includes(status)) {
50
+ if (!isOneOf(PAGE_STATUSES, status)) {
49
51
  throw new Error(`status "${status}" is not one of ${PAGE_STATUSES.join(', ')}`);
50
52
  }
51
53
  return {
@@ -53,9 +55,9 @@ export function parsePageFile(slug, raw) {
53
55
  hideTitle: parseBoolean('hide-title', meta['hide-title']),
54
56
  html: body,
55
57
  slug,
56
- status: status,
58
+ status,
57
59
  template: meta.template ?? '',
58
- title: meta.title || titleFromSlug(slug),
60
+ title: (meta.title ?? '') || titleFromSlug(slug),
59
61
  };
60
62
  }
61
63
  // Splits the optional `key: value` HTML comment header off `raw`. Shared with templates/.
@@ -64,7 +66,7 @@ export function parseHtmlHeader(raw, keys) {
64
66
  const meta = {};
65
67
  if (!header)
66
68
  return { body: raw, meta };
67
- for (const line of header[1].split(/\r?\n/)) {
69
+ for (const line of (header[1] ?? '').split(/\r?\n/)) {
68
70
  if (line.trim() === '')
69
71
  continue;
70
72
  const colon = line.indexOf(':');
@@ -89,10 +91,10 @@ export async function readLocalPages(dir) {
89
91
  export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE) {
90
92
  let entries;
91
93
  try {
92
- entries = await readdir(dir, { withFileTypes: true });
94
+ entries = withoutExcluded(dir, await readdir(dir, { withFileTypes: true }), (entry) => entry.name);
93
95
  }
94
96
  catch (error) {
95
- if (error.code === 'ENOENT')
97
+ if (errorCode(error) === 'ENOENT')
96
98
  return { files: [], problems: [] };
97
99
  throw error;
98
100
  }
@@ -120,7 +122,7 @@ export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE)
120
122
  files.push(parse(slug, await readFile(filePath, 'utf8')));
121
123
  }
122
124
  catch (error) {
123
- problems.push({ file: filePath, message: error.message });
125
+ problems.push({ file: filePath, message: errorMessage(error) });
124
126
  }
125
127
  }
126
128
  return { files, problems };
@@ -10,7 +10,7 @@ const OPEN_TAG = /<\?php\b/i;
10
10
  export function firstTopLevelSideEffect(content) {
11
11
  if (content.search(OPEN_TAG) !== 0)
12
12
  return 'inline HTML before <?php';
13
- const state = { atStart: true, braces: [], depth: 0, namespaceHeader: false };
13
+ const state = { atStart: true, attribute: 0, braces: [], depth: 0, namespaceHeader: false };
14
14
  for (const token of lex(content.slice(5))) {
15
15
  const found = step(state, token);
16
16
  if (found !== null)
@@ -36,6 +36,8 @@ export function startsWithStrictTypes(content) {
36
36
  function step(state, token) {
37
37
  if (token.type === 'html')
38
38
  return inlineHtml(state, token);
39
+ if (state.attribute > 0 || token.type === 'attribute')
40
+ return skipAttribute(state, token);
39
41
  const statementStart = state.depth === 0 && state.atStart;
40
42
  switch (token.text) {
41
43
  case ';': {
@@ -64,6 +66,15 @@ function step(state, token) {
64
66
  }
65
67
  }
66
68
  }
69
+ // An attribute (`#[Attr(...)]`) only decorates what follows it, so the statement still starts
70
+ // after its closing `]`: `#[Attr] foo();` must report `foo`.
71
+ function skipAttribute(state, token) {
72
+ if (token.type === 'attribute' || token.text === '[')
73
+ state.attribute += 1;
74
+ else if (token.text === ']')
75
+ state.attribute -= 1;
76
+ return null;
77
+ }
67
78
  // A template inside a class or function body (`?> <p>...</p> <?php`) only runs when it is called.
68
79
  function inlineHtml(state, token) {
69
80
  if (state.depth > 0)
@@ -86,7 +97,7 @@ function openBrace(state, statementStart) {
86
97
  return null;
87
98
  }
88
99
  function isDeclaration(token) {
89
- return token.type === 'attribute' || (token.type === 'word' && ALLOWED.has(token.text.toLowerCase()));
100
+ return token.type === 'word' && ALLOWED.has(token.text.toLowerCase());
90
101
  }
91
102
  function* lex(source) {
92
103
  let i = skipTrivia(source, 0);
@@ -126,13 +137,13 @@ function readToken(source, i) {
126
137
  }
127
138
  if (source.startsWith('#[', i))
128
139
  return [{ text: '#[', type: 'attribute' }, i + 2];
129
- if (source.startsWith('<<<', i) || `'"\``.includes(source[i])) {
140
+ if (source.startsWith('<<<', i) || `'"\``.includes(source.charAt(i))) {
130
141
  return [{ text: source[i] === '`' ? '`' : 'a string', type: 'string' }, skipString(source, i)];
131
142
  }
132
143
  const word = /^[\w$\\]+/.exec(source.slice(i));
133
144
  if (word)
134
145
  return [{ text: word[0], type: 'word' }, i + word[0].length];
135
- return [{ text: source[i], type: 'punct' }, i + 1];
146
+ return [{ text: source.charAt(i), type: 'punct' }, i + 1];
136
147
  }
137
148
  // Index right after the string (quoted, backtick, heredoc or nowdoc) starting at `i`.
138
149
  function skipString(source, i) {
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  export declare const SYNC_TIMEOUT_MS = 600000;
2
3
  export type SyncIntent = {
3
4
  libraries?: Record<string, string>;
@@ -20,13 +21,12 @@ export type SyncResponse = {
20
21
  lockDrift?: LockDriftEntry[];
21
22
  message: string;
22
23
  output: string;
23
- removed: string[];
24
- };
25
- export type Collision = {
26
- installedVersion: string;
27
- path: string;
28
- slug: string;
29
- type: 'plugin' | 'theme';
24
+ removed?: string[];
30
25
  };
26
+ declare const COLLISIONS: z.ZodArray<z.ZodObject<{
27
+ slug: z.ZodString;
28
+ }, z.core.$loose>>;
29
+ export type Collision = z.infer<typeof COLLISIONS>[number];
31
30
  export declare function parseCollisions(error: unknown): Collision[] | null;
32
31
  export declare function isDowngrade(from: string, to: string): boolean;
32
+ export {};
@@ -2,26 +2,26 @@
2
2
  // `lps plugin push`, `lps theme version push` and `lps composer push`. The endpoint runs Composer +
3
3
  // WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
4
4
  // never a composer.json, so the plugin stays the single authority on that file's shape.
5
+ import { z } from 'zod';
6
+ import { causeOf } from '../lib/narrow.js';
7
+ import { parseBody, responseOf } from '../lib/wp-client.js';
5
8
  import { compareVersions } from './version.js';
6
9
  // A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
7
10
  export const SYNC_TIMEOUT_MS = 600_000;
11
+ // The server also sends installedVersion, path and type, only the slug is read here.
12
+ const COLLISIONS = z.array(z.looseObject({ slug: z.string() }));
8
13
  // 422 from the sync endpoint when the intent references a plugin/theme whose folder already
9
14
  // exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
10
15
  // Re-running with --force lets the server remove the folder so Composer can take it over.
11
16
  export function parseCollisions(error) {
12
- const body = error.cause?.response;
13
- if (body?.statusCode !== 422 || !body.body)
17
+ const { body, statusCode } = responseOf(causeOf(error));
18
+ if (statusCode !== 422)
14
19
  return null;
15
- try {
16
- const parsed = JSON.parse(body.body);
17
- if (parsed.error === 'unmanaged_plugins_present' && Array.isArray(parsed.collisions)) {
18
- return parsed.collisions;
19
- }
20
- }
21
- catch {
20
+ const parsed = parseBody(body);
21
+ if (parsed.error !== 'unmanaged_plugins_present')
22
22
  return null;
23
- }
24
- return null;
23
+ const collisions = COLLISIONS.safeParse(parsed.collisions);
24
+ return collisions.success ? collisions.data : null;
25
25
  }
26
26
  // Only gates whether a version change needs --force. Unorderable inputs ("latest", a
27
27
  // constraint) count as "not a downgrade" so they don't block the push.
@@ -1,3 +1,4 @@
1
+ import { isRecord } from '../lib/narrow.js';
1
2
  import { pinActive, pinVersion, } from '../types/plugin.js';
2
3
  import { isExactVersion } from './version.js';
3
4
  // "1.7.2", or "1.7.2 (inactive)" for a pin that keeps the plugin inactive.
@@ -13,9 +14,10 @@ const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-li
13
14
  export function mergePluginManifest(existing, incoming) {
14
15
  const merged = { ...existing, ...incoming };
15
16
  const added = Object.keys(incoming).filter((s) => !Object.hasOwn(existing, s));
16
- const updated = Object.keys(incoming)
17
- .filter((s) => Object.hasOwn(existing, s) && describePin(existing[s]) !== describePin(incoming[s]))
18
- .map((s) => ({ from: describePin(existing[s]), slug: s, to: describePin(incoming[s]) }));
17
+ const updated = Object.entries(incoming).flatMap(([slug, pin]) => {
18
+ const before = Object.hasOwn(existing, slug) ? existing[slug] : undefined;
19
+ return before !== undefined && describePin(before) !== describePin(pin) ? [{ from: describePin(before), slug, to: describePin(pin) }] : [];
20
+ });
19
21
  return { added, merged, updated };
20
22
  }
21
23
  // WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
@@ -30,7 +32,7 @@ export function mergePluginManifest(existing, incoming) {
30
32
  const WP_ORG_PLUGIN_URI = /^https?:\/\/wordpress\.org\/plugins\/([^/]+)\/?$/;
31
33
  function slugFromPluginFile(file, pluginUri) {
32
34
  if (file.includes('/'))
33
- return file.split('/', 1)[0];
35
+ return file.slice(0, file.indexOf('/'));
34
36
  return WP_ORG_PLUGIN_URI.exec(pluginUri)?.[1] ?? file;
35
37
  }
36
38
  export function parseInstalledPlugins(raw) {
@@ -93,8 +95,9 @@ export function lockedWpackagistSlugs(composerLock, kind) {
93
95
  const prefix = `wpackagist-${kind}/`;
94
96
  try {
95
97
  const parsed = JSON.parse(composerLock);
96
- return new Set((parsed.packages ?? [])
97
- .map((p) => p.name ?? '')
98
+ const packages = isRecord(parsed) && Array.isArray(parsed.packages) ? parsed.packages : [];
99
+ return new Set(packages
100
+ .map((p) => (isRecord(p) && typeof p.name === 'string' ? p.name : ''))
98
101
  .filter((name) => name.startsWith(prefix))
99
102
  .map((name) => name.slice(prefix.length)));
100
103
  }
@@ -3,6 +3,7 @@ export declare const RESOURCE_DIR_DEFAULTS: {
3
3
  readonly acf: "acf";
4
4
  readonly api: "api";
5
5
  readonly apps: "apps";
6
+ readonly block: "blocks";
6
7
  readonly blockCss: "theme/blocks";
7
8
  readonly cpt: "cpt";
8
9
  readonly css: "theme/css";
@@ -23,3 +24,4 @@ export declare const RESOURCE_DIR_DEFAULTS: {
23
24
  export type ResourceDirKind = keyof typeof RESOURCE_DIR_DEFAULTS;
24
25
  export declare function resolveResourceDir(kind: ResourceDirKind, localConfig: LoopressLocalConfig, override?: string): string;
25
26
  export declare function projectUses(kind: ResourceDirKind, localConfig: LoopressLocalConfig): boolean;
27
+ export declare function projectHasChildThemeFiles(localConfig: LoopressLocalConfig): boolean;