@loopress/cli 0.33.0 → 0.35.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 (92) hide show
  1. package/README.md +236 -207
  2. package/dist/commands/app/push.js +2 -1
  3. package/dist/commands/block/push.js +9 -4
  4. package/dist/commands/dev.d.ts +2 -2
  5. package/dist/commands/dev.js +10 -16
  6. package/dist/commands/diff.d.ts +6 -4
  7. package/dist/commands/diff.js +49 -29
  8. package/dist/commands/doctor.js +13 -3
  9. package/dist/commands/form/push.js +3 -2
  10. package/dist/commands/option/list.js +6 -1
  11. package/dist/commands/option/push.js +1 -0
  12. package/dist/commands/page/push.js +1 -0
  13. package/dist/commands/plugin/add.d.ts +1 -0
  14. package/dist/commands/plugin/add.js +50 -9
  15. package/dist/commands/plugin/audit.d.ts +1 -1
  16. package/dist/commands/plugin/audit.js +45 -23
  17. package/dist/commands/plugin/outdated.d.ts +22 -0
  18. package/dist/commands/plugin/outdated.js +67 -0
  19. package/dist/commands/plugin/push.d.ts +2 -1
  20. package/dist/commands/plugin/push.js +109 -23
  21. package/dist/commands/plugin/update.d.ts +36 -0
  22. package/dist/commands/plugin/update.js +121 -0
  23. package/dist/commands/project/config.js +0 -1
  24. package/dist/commands/promote.d.ts +1 -0
  25. package/dist/commands/promote.js +8 -4
  26. package/dist/commands/pull.d.ts +5 -0
  27. package/dist/commands/pull.js +26 -1
  28. package/dist/commands/push.d.ts +9 -0
  29. package/dist/commands/push.js +116 -40
  30. package/dist/commands/snippet/push.js +3 -2
  31. package/dist/commands/theme/style/diff.js +1 -1
  32. package/dist/commands/theme/style/push.js +1 -1
  33. package/dist/commands/theme/style/rollback.js +1 -1
  34. package/dist/commands/theme/template/push.d.ts +2 -0
  35. package/dist/commands/theme/template/push.js +42 -13
  36. package/dist/commands/validate.js +3 -0
  37. package/dist/lib/base.d.ts +7 -0
  38. package/dist/lib/base.js +55 -2
  39. package/dist/lib/capabilities.d.ts +5 -0
  40. package/dist/lib/capabilities.js +16 -0
  41. package/dist/lib/declared-plugins.d.ts +9 -0
  42. package/dist/lib/declared-plugins.js +21 -0
  43. package/dist/lib/declared-type-command.js +3 -0
  44. package/dist/lib/dev-watch.d.ts +1 -1
  45. package/dist/lib/dev-watch.js +11 -13
  46. package/dist/lib/diff-command.d.ts +17 -0
  47. package/dist/lib/diff-command.js +125 -9
  48. package/dist/lib/environment-urls.d.ts +6 -0
  49. package/dist/lib/environment-urls.js +59 -0
  50. package/dist/lib/exclude.d.ts +16 -0
  51. package/dist/lib/exclude.js +102 -0
  52. package/dist/lib/load-snippets.js +3 -5
  53. package/dist/lib/php-files-command.js +5 -3
  54. package/dist/lib/push-command.d.ts +26 -0
  55. package/dist/lib/push-command.js +78 -12
  56. package/dist/lib/readdir-tolerant.js +6 -3
  57. package/dist/lib/resource-selection.d.ts +9 -0
  58. package/dist/lib/resource-selection.js +49 -0
  59. package/dist/lib/resource-state.d.ts +2 -0
  60. package/dist/lib/resource-state.js +25 -4
  61. package/dist/lib/rollback-command.js +1 -1
  62. package/dist/lib/rollback-materialize.js +1 -1
  63. package/dist/lib/validate-local.d.ts +1 -0
  64. package/dist/lib/validate-local.js +71 -12
  65. package/dist/lib/wp-client.d.ts +2 -0
  66. package/dist/lib/wp-client.js +36 -10
  67. package/dist/types/plugin.d.ts +2 -1
  68. package/dist/types/plugin.js +32 -1
  69. package/dist/types/project-config.generated.d.ts +15 -4
  70. package/dist/utils/block-format.d.ts +2 -0
  71. package/dist/utils/block-format.js +18 -2
  72. package/dist/utils/option-format.d.ts +1 -0
  73. package/dist/utils/option-format.js +14 -1
  74. package/dist/utils/page-format.js +2 -1
  75. package/dist/utils/plugins.d.ts +3 -0
  76. package/dist/utils/plugins.js +6 -1
  77. package/dist/utils/rename-retry.d.ts +1 -0
  78. package/dist/utils/rename-retry.js +20 -0
  79. package/dist/utils/resource-dirs.js +4 -4
  80. package/dist/utils/version.d.ts +2 -0
  81. package/dist/utils/version.js +21 -0
  82. package/dist/utils/wporg.d.ts +5 -0
  83. package/dist/utils/wporg.js +41 -0
  84. package/oclif.manifest.json +2131 -1150
  85. package/package.json +14 -14
  86. package/schemas/project-config.schema.json +21 -4
  87. package/dist/commands/api/publish.d.ts +0 -9
  88. package/dist/commands/api/publish.js +0 -45
  89. package/dist/commands/snippet/publish.d.ts +0 -10
  90. package/dist/commands/snippet/publish.js +0 -62
  91. package/dist/lib/resolve-linked-project.d.ts +0 -8
  92. package/dist/lib/resolve-linked-project.js +0 -27
@@ -1,6 +1,27 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  import { type ResourceState } from './diff-state.js';
3
+ import { type ScannedItem } from './environment-urls.js';
3
4
  import { type ResourceStateProvider } from './resource-state.js';
5
+ export type DeploymentStep = {
6
+ command: string;
7
+ status: 'failed' | 'pushed' | 'skipped';
8
+ };
9
+ type DeploymentPlugins = Array<{
10
+ active: boolean;
11
+ slug: string;
12
+ version: string;
13
+ }>;
14
+ export declare function startDeploymentCollector(): {
15
+ plugins?: DeploymentPlugins;
16
+ };
17
+ export declare function stopDeploymentCollector(): void;
18
+ export declare function postDeployment(body: {
19
+ command: string | undefined;
20
+ plugins?: DeploymentPlugins;
21
+ status: 'failure' | 'success';
22
+ steps?: DeploymentStep[];
23
+ url: string;
24
+ }): Promise<void>;
4
25
  export type PushChange = {
5
26
  id: string;
6
27
  status: 'changed' | 'created' | 'removed' | 'unchanged';
@@ -19,6 +40,7 @@ export declare abstract class PushCommand extends LoopressCommand {
19
40
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
41
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
42
  };
43
+ static honorsExclude: boolean;
22
44
  protected failedCount: number;
23
45
  protected previewChanges: PushChange[];
24
46
  protected previewStates?: {
@@ -28,6 +50,7 @@ export declare abstract class PushCommand extends LoopressCommand {
28
50
  private refusedByGuard;
29
51
  protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
30
52
  catch(err: Error): Promise<void>;
53
+ finally(error: Error | undefined): Promise<void>;
31
54
  protected guardProductionPush(): Promise<void>;
32
55
  init(): Promise<void>;
33
56
  protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
@@ -40,6 +63,9 @@ export declare abstract class PushCommand extends LoopressCommand {
40
63
  protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
41
64
  output: string;
42
65
  }) => Promise<void>): Promise<void>;
66
+ protected warnEnvironmentUrls(items: ScannedItem[]): void;
43
67
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
44
68
  private guardOutsideEdits;
69
+ private pushedPlugins;
45
70
  }
71
+ export {};
@@ -2,19 +2,64 @@ import { confirm } from '@inquirer/prompts';
2
2
  import { Flags } from '@oclif/core';
3
3
  import got from 'got';
4
4
  import { Listr } from 'listr2';
5
+ import { execFileSync } from 'node:child_process';
5
6
  import { resolve } from 'node:path';
6
7
  import { isDeepStrictEqual } from 'node:util';
7
8
  import { authManager } from '../config/auth.manager.js';
8
9
  import {} from '../types/config.js';
10
+ import { pinActive, pinVersion } from '../types/plugin.js';
9
11
  import { API_URL } from './api-client.js';
10
12
  import { LoopressCommand } from './base.js';
11
13
  import {} from './diff-state.js';
14
+ import { environmentUrlWarnings } from './environment-urls.js';
15
+ import { heldBackPaths } from './exclude.js';
12
16
  import { guardProductionPush } from './guard-production-push.js';
13
17
  import { isInteractive } from './interactive.js';
14
18
  import { errorMessage } from './narrow.js';
15
19
  import {} from './resource-state.js';
16
20
  import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
17
21
  import { withoutSpinner, withSpinner } from './spinner.js';
22
+ // The Git state the deployment record carries: the HEAD commit and whether the working tree has
23
+ // uncommitted changes. Empty outside a Git repository (or without git), so nothing is sent then.
24
+ function gitState() {
25
+ try {
26
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- git is resolved from PATH like in every dev tool
27
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
28
+ const commit = git(['rev-parse', 'HEAD']);
29
+ if (!commit)
30
+ return {};
31
+ return { commit, dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '' };
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+ // Set while `lps push` runs its steps in process: they hand over what they would have posted
38
+ // instead of posting, so the run sends one record (and waits at most one timeout).
39
+ let deploymentCollector;
40
+ export function startDeploymentCollector() {
41
+ deploymentCollector = {};
42
+ return deploymentCollector;
43
+ }
44
+ export function stopDeploymentCollector() {
45
+ deploymentCollector = undefined;
46
+ }
47
+ // Best-effort: recording must never interrupt the push flow.
48
+ export async function postDeployment(body) {
49
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
50
+ if (!token)
51
+ return;
52
+ try {
53
+ await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
54
+ headers: { Authorization: `Bearer ${token}` },
55
+ json: { ...body, ...gitState() },
56
+ timeout: { request: 3000 },
57
+ }));
58
+ }
59
+ catch {
60
+ // non-blocking
61
+ }
62
+ }
18
63
  export function pushChanges(local, remote, options = {}) {
19
64
  const changes = [...local.keys()]
20
65
  .filter((id) => options.writes?.(id) ?? true)
@@ -44,6 +89,7 @@ export class PushCommand extends LoopressCommand {
44
89
  }),
45
90
  'production-confirmed': Flags.boolean({ hidden: true }),
46
91
  };
92
+ static honorsExclude = true;
47
93
  failedCount = 0;
48
94
  // A dry run's per-item preview, set by captureBeforePushState: what the push would create,
49
95
  // change or leave as it is on the site.
@@ -101,6 +147,16 @@ export class PushCommand extends LoopressCommand {
101
147
  }
102
148
  await super.catch(err);
103
149
  }
150
+ // Lists what the environment's `exclude` globs (loopress.json) kept out of this push, so a
151
+ // feature held back from it is never forgotten there.
152
+ async finally(error) {
153
+ const paths = error ? [] : heldBackPaths();
154
+ if (paths.length > 0) {
155
+ const prefix = this.dryRun ? '[dry-run] ' : '';
156
+ this.log(`${prefix}Held back from ${this.siteConfig.name} (exclude in loopress.json): ${paths.join(', ')}`);
157
+ }
158
+ await super.finally(error);
159
+ }
104
160
  async guardProductionPush() {
105
161
  await guardProductionPush({
106
162
  dryRun: this.dryRun,
@@ -129,19 +185,14 @@ export class PushCommand extends LoopressCommand {
129
185
  }
130
186
  }
131
187
  async recordDeployment(status) {
132
- const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
133
- if (!token)
188
+ // Inside `lps push`, the steps only report the plugin inventory: the run posts one record.
189
+ if (deploymentCollector) {
190
+ const { plugins } = this.pushedPlugins(status);
191
+ if (plugins)
192
+ deploymentCollector.plugins = plugins;
134
193
  return;
135
- try {
136
- await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
137
- headers: { Authorization: `Bearer ${token}` },
138
- json: { status, url: this.siteConfig.url },
139
- timeout: { request: 3000 },
140
- }));
141
- }
142
- catch {
143
- // non-blocking: recording must never interrupt the push flow
144
194
  }
195
+ await postDeployment({ command: this.id, status, url: this.siteConfig.url, ...this.pushedPlugins(status) });
145
196
  }
146
197
  async recordSuccess() {
147
198
  if (this.dryRun)
@@ -191,12 +242,19 @@ export class PushCommand extends LoopressCommand {
191
242
  })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
192
243
  });
193
244
  }
245
+ // Absolute URLs to another environment of the project in what this push sends: warned about,
246
+ // never rewritten, on a dry run too.
247
+ warnEnvironmentUrls(items) {
248
+ const prefix = this.dryRun ? '[dry-run] ' : '';
249
+ for (const warning of environmentUrlWarnings(items, this.siteConfig, this.projectEnvironments))
250
+ this.warn(`${prefix}${warning}`);
251
+ }
194
252
  // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
195
253
  // with a fresh remote read taken right after the push, and writes both as one rollback
196
254
  // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
197
255
  // the local files that were pushed, is what keeps this correct for every resource's own
198
256
  // partial-push semantics: a readonly option skipped by `option push`, a snippet or form
199
- // that gets a server-assigned id on create, a theme-styles push that only touches the
257
+ // that gets a server-assigned id on create, a theme-style push that only touches the
200
258
  // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
201
259
  // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
202
260
  // against exactly this after-state, so it has to be what actually landed, not what was sent.
@@ -273,6 +331,14 @@ export class PushCommand extends LoopressCommand {
273
331
  if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
274
332
  this.error('Aborted.');
275
333
  }
334
+ // The plugin inventory of a landed `plugin:push` (a full `lps push` runs it as a step), read from
335
+ // loopress.json: slug, pinned version and whether it must be active. Other commands send none.
336
+ pushedPlugins(status) {
337
+ if (status !== 'success' || this.id !== 'plugin:push')
338
+ return {};
339
+ const plugins = Object.entries(this.localConfig.plugins ?? {}).map(([slug, pin]) => ({ active: pinActive(pin), slug, version: pinVersion(pin) }));
340
+ return { plugins };
341
+ }
276
342
  }
277
343
  function sameEntry(left, right, id) {
278
344
  return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
@@ -1,12 +1,15 @@
1
1
  import {} from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
- import { join, relative, sep } from 'node:path';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { withoutExcluded } from './exclude.js';
4
5
  import { errorCode } from './narrow.js';
5
6
  export async function readdirTolerant(dir, options) {
6
7
  try {
8
+ // Excluded paths (lib/exclude.ts) are hidden here, so every resource listing through this
9
+ // helper leaves them out of a push or a diff on the environment that excludes them.
7
10
  return options?.withFileTypes
8
- ? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
9
- : await readdir(dir, { recursive: options?.recursive });
11
+ ? withoutExcluded(dir, await readdir(dir, { recursive: options.recursive, withFileTypes: true }), (entry) => resolve(entry.parentPath, entry.name))
12
+ : withoutExcluded(dir, await readdir(dir, { recursive: options?.recursive }), (entry) => entry);
10
13
  }
11
14
  catch (error) {
12
15
  if (errorCode(error) === 'ENOENT')
@@ -0,0 +1,9 @@
1
+ import { Flags } from '@oclif/core';
2
+ export declare const RESOURCE_IDS: readonly ["plugin", "composer", "cpt", "taxonomy", "meta", "role", "acf", "api", "hook", "block", "form", "pattern", "template", "part", "css", "theme-style", "page", "menu", "option", "snippet"];
3
+ export type ResourceId = (typeof RESOURCE_IDS)[number];
4
+ export declare function selectionFlags(noun: string, valid: readonly string[]): {
5
+ only: ReturnType<typeof Flags.string>;
6
+ skip: ReturnType<typeof Flags.string>;
7
+ };
8
+ export declare function parseSelectionFlag(raw: string[] | undefined, flagName: 'only' | 'skip', valid: readonly string[], fail: (message: string) => never): string[];
9
+ export declare function selectResources<T extends string>(all: readonly T[], only?: readonly string[], skip?: readonly string[]): T[];
@@ -0,0 +1,49 @@
1
+ import { Flags } from '@oclif/core';
2
+ // One singular id per resource, shared by every command taking `--only` / `--skip`
3
+ // (`lps dev`, `lps diff`, `lps push`). Each command accepts the subset it can act on.
4
+ export const RESOURCE_IDS = [
5
+ 'plugin',
6
+ 'composer',
7
+ 'cpt',
8
+ 'taxonomy',
9
+ 'meta',
10
+ 'role',
11
+ 'acf',
12
+ 'api',
13
+ 'hook',
14
+ 'block',
15
+ 'form',
16
+ 'pattern',
17
+ 'template',
18
+ 'part',
19
+ 'css',
20
+ 'theme-style',
21
+ 'page',
22
+ 'menu',
23
+ 'option',
24
+ 'snippet',
25
+ ];
26
+ // Comma-separated or repeated: `--only a,b`, `--only a --only b`, or both.
27
+ export function selectionFlags(noun, valid) {
28
+ return {
29
+ only: Flags.string({ description: `Only ${noun} these resources (comma-separated or repeated): ${valid.join(', ')}`, multiple: true }),
30
+ skip: Flags.string({ description: `${noun.charAt(0).toUpperCase()}${noun.slice(1)} every resource except these (comma-separated or repeated)`, multiple: true }),
31
+ };
32
+ }
33
+ // Splits the raw flag values and rejects any id outside `valid`.
34
+ export function parseSelectionFlag(raw, flagName, valid, fail) {
35
+ return (raw ?? [])
36
+ .flatMap((value) => value.split(','))
37
+ .map((value) => value.trim())
38
+ .filter(Boolean)
39
+ .map((value) => {
40
+ if (!valid.includes(value))
41
+ fail(`Unknown resource "${value}" in --${flagName}. Valid resources: ${valid.join(', ')}`);
42
+ return value;
43
+ });
44
+ }
45
+ // `only` narrows `all`, `skip` then removes from whatever that is, so a combined
46
+ // `--only=a,b --skip=b` reads as: a, b, then drop b. The result always follows `all`'s order.
47
+ export function selectResources(all, only = [], skip = []) {
48
+ return all.filter((id) => (only.length === 0 || only.includes(id)) && !skip.includes(id));
49
+ }
@@ -3,6 +3,8 @@ import { type ResourceState } from './diff-state.js';
3
3
  import { type WpClient } from './wp-client.js';
4
4
  export type ResourceStateProvider = {
5
5
  dirKind: ResourceDirKind;
6
+ fileExtension?: string;
7
+ fileName?(id: string): string | undefined;
6
8
  local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
7
9
  remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
10
  resource: string;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
4
- import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
4
+ import { blockFilesToPush, BLOCKS_ENDPOINT, excludedBlockFiles, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
5
5
  import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
6
6
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
7
7
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
@@ -91,6 +91,7 @@ const snippetProvider = {
91
91
  // stance as form-format.ts.
92
92
  const formProvider = {
93
93
  dirKind: 'form',
94
+ fileExtension: '.json',
94
95
  async local(dir, onWarn) {
95
96
  const entries = await loadFiles(dir, {
96
97
  extension: '.json',
@@ -137,6 +138,7 @@ async function normalizeAcf(wp, type, objects) {
137
138
  }
138
139
  const acfProvider = {
139
140
  dirKind: 'acf',
141
+ fileExtension: '.json',
140
142
  async local(dir, onWarn, wp) {
141
143
  const state = new Map();
142
144
  for (const type of ACF_OBJECT_TYPES) {
@@ -182,6 +184,7 @@ function filesToState(files) {
182
184
  function phpFilesProvider(spec) {
183
185
  return {
184
186
  dirKind: spec.dirKind,
187
+ fileExtension: '.php',
185
188
  async local(dir, onWarn) {
186
189
  return filesToState(await loadFiles(dir, {
187
190
  extension: '.php',
@@ -217,11 +220,17 @@ const hookProvider = phpFilesProvider({
217
220
  // resource, the same stance as pages: pushing would stop on it anyway.
218
221
  const blockProvider = {
219
222
  dirKind: 'block',
220
- async local(dir) {
223
+ // A block is a folder: only the bare `blocks/<slug>` path names a site-only block.
224
+ fileExtension: '',
225
+ // A file the environment excludes inside a block folder keeps the site's copy, as the push
226
+ // sends it back: without `wp`, that file is just left out.
227
+ async local(dir, _onWarn, wp) {
221
228
  const { blocks, problems } = await readLocalBlocks(dir);
222
229
  if (problems.length > 0)
223
230
  throw new Error(formatBlockProblems(problems));
224
- return new Map(blocks.map((block) => [block.slug, block.files]));
231
+ const withExcluded = wp !== undefined && blocks.some((block) => excludedBlockFiles(dir, block.slug).length > 0);
232
+ const site = withExcluded ? new Map((await wp.get(BLOCKS_ENDPOINT)).map((block) => [block.slug, block.files])) : undefined;
233
+ return new Map(blocks.map((block) => [block.slug, site ? blockFilesToPush(dir, block, site.get(block.slug)) : block.files]));
225
234
  },
226
235
  async remote(wp) {
227
236
  const blocks = await wp.get(BLOCKS_ENDPOINT);
@@ -271,6 +280,7 @@ async function childThemeState(wp, kind) {
271
280
  }
272
281
  const templateProvider = {
273
282
  dirKind: 'template',
283
+ fileExtension: '.html',
274
284
  async local(dir) {
275
285
  const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
276
286
  if (problems.length > 0)
@@ -283,6 +293,7 @@ const templateProvider = {
283
293
  };
284
294
  const partProvider = {
285
295
  dirKind: 'part',
296
+ fileExtension: '.html',
286
297
  async local(dir) {
287
298
  const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
288
299
  if (problems.length > 0)
@@ -311,6 +322,12 @@ function canonicalMenu(menu) {
311
322
  }
312
323
  const menuProvider = {
313
324
  dirKind: 'menu',
325
+ fileExtension: '.json',
326
+ fileName(id) {
327
+ if (id === MENU_LOCATIONS_FILE)
328
+ return `${id}.json`;
329
+ return id.startsWith('menu/') ? `${id.slice('menu/'.length)}.json` : undefined;
330
+ },
314
331
  async local(dir, onWarn) {
315
332
  const state = new Map();
316
333
  const entries = await loadFiles(dir, {
@@ -348,6 +365,7 @@ const menuProvider = {
348
365
  function declaredTypeProvider(spec, title) {
349
366
  return {
350
367
  dirKind: spec.dirKind,
368
+ fileExtension: fileExtension(spec),
351
369
  async local(dir, onWarn) {
352
370
  const entries = await loadFiles(dir, {
353
371
  extension: fileExtension(spec),
@@ -377,6 +395,7 @@ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
377
395
  // sides so it can never itself show up as drift.
378
396
  const optionsProvider = {
379
397
  dirKind: 'options',
398
+ fileExtension: '.json',
380
399
  async local(dir, onWarn) {
381
400
  const entries = await loadFiles(dir, {
382
401
  extension: '.json',
@@ -422,6 +441,8 @@ const optionsProvider = {
422
441
  // individually rather than colliding on a single key.
423
442
  const themeStylesProvider = {
424
443
  dirKind: 'themeStyles',
444
+ fileExtension: '.json',
445
+ fileName: (stylesheet) => `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`,
425
446
  async local(dir, onWarn) {
426
447
  const entries = await loadFiles(dir, {
427
448
  extension: '.json',
@@ -445,7 +466,7 @@ const themeStylesProvider = {
445
466
  const item = await wp.get(globalStylesEndpoint(id));
446
467
  return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
447
468
  },
448
- resource: 'theme-styles',
469
+ resource: 'theme-style',
449
470
  title: 'Theme styles',
450
471
  };
451
472
  export const RESOURCE_STATE_PROVIDERS = [
@@ -34,7 +34,7 @@ function indentPatch(patch) {
34
34
  // anything, this runs the equivalent of `lps <resource> diff` between the environment's current
35
35
  // state and the state the original push expected to leave behind, and requires explicit
36
36
  // confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
37
- // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
37
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
38
38
  export function resourceRollbackCommand(resource, options) {
39
39
  const command = options.command ?? resource;
40
40
  class ResourceRollback extends LoopressCommand {
@@ -115,7 +115,7 @@ const MATERIALIZERS = {
115
115
  role: declaredType(ROLE_SPEC),
116
116
  snippet,
117
117
  taxonomy: declaredType(TAXONOMY_SPEC),
118
- 'theme-styles': themeStyles,
118
+ 'theme-style': themeStyles,
119
119
  };
120
120
  // Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
121
121
  // have produced, so the existing `<resource> push` command can push it unmodified: a rollback
@@ -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,8 +1,8 @@
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
3
  import { z } from 'zod';
4
4
  import { configManager } from '../config/project-config.manager.js';
5
- import { PLUGIN_PIN } from '../types/plugin.js';
5
+ import { pinProblem } from '../types/plugin.js';
6
6
  import { readLocalBlocks } from '../utils/block-format.js';
7
7
  import { COMPOSER_JSON } from '../utils/composer.js';
8
8
  import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
@@ -11,23 +11,24 @@ import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
11
11
  import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
12
12
  import { parseType } from '../utils/snippet-format.js';
13
13
  import { readLocalTemplates } from '../utils/template-format.js';
14
+ import { globToRegExp, matchesAny } from './exclude.js';
14
15
  import { loadSnippets } from './load-snippets.js';
15
16
  import { errorCode, errorMessage, isRecord } from './narrow.js';
16
17
  import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
17
18
  import { readdirTolerant } from './readdir-tolerant.js';
18
19
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
19
20
  // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
20
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStylesDir'];
21
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStyleDir'];
21
22
  const DEFAULT_DIR = {
22
23
  acfDir: 'acf',
23
24
  apiDir: 'api',
24
25
  blockDir: RESOURCE_DIR_DEFAULTS.block,
25
26
  cptDir: 'cpt',
26
27
  formDir: 'forms',
27
- hooksDir: 'hooks',
28
+ hookDir: 'hooks',
28
29
  menuDir: 'menus',
29
30
  metaDir: 'meta',
30
- optionsDir: 'options',
31
+ optionDir: 'options',
31
32
  pageDir: 'pages',
32
33
  partDir: RESOURCE_DIR_DEFAULTS.part,
33
34
  patternDir: RESOURCE_DIR_DEFAULTS.pattern,
@@ -35,7 +36,7 @@ const DEFAULT_DIR = {
35
36
  snippetsDir: 'snippets',
36
37
  taxonomyDir: 'taxonomies',
37
38
  templateDir: RESOURCE_DIR_DEFAULTS.template,
38
- themeStylesDir: 'theme',
39
+ themeStyleDir: 'theme',
39
40
  };
40
41
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
41
42
  // and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
@@ -58,7 +59,7 @@ export async function validateLocal(cwd) {
58
59
  }
59
60
  // Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
60
61
  // there with a warning, so it's a problem here.
61
- checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
62
+ checked += await checkJsonDir(resolve('optionDir'), problems, parseLocalOption);
62
63
  checked += await checkJsonDir(resolve('cptDir'), problems, declaredFileChecker(CPT_SPEC));
63
64
  checked += await checkJsonDir(resolve('taxonomyDir'), problems, declaredFileChecker(TAXONOMY_SPEC));
64
65
  checked += await checkJsonDir(resolve('metaDir'), problems, declaredFileChecker(META_SPEC));
@@ -66,7 +67,7 @@ export async function validateLocal(cwd) {
66
67
  checked += await checkPatterns(resolve('patternDir'), problems);
67
68
  checked += await checkSnippets(resolve('snippetsDir'), problems);
68
69
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
69
- 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);
70
71
  const blocks = await readLocalBlocks(resolve('blockDir'));
71
72
  checked += blocks.blocks.length;
72
73
  problems.push(...blocks.problems);
@@ -76,7 +77,65 @@ export async function validateLocal(cwd) {
76
77
  const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
77
78
  checked += templates.templates.length + templates.parts.length + templates.problems.length;
78
79
  problems.push(...templates.problems);
79
- 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;
80
139
  }
81
140
  // Same directory rules as pages (flat, .html only), then the same checks as `pattern push`.
82
141
  async function checkPatterns(dir, problems) {
@@ -200,9 +259,9 @@ function checkLoopressJson(localConfig, problems) {
200
259
  }
201
260
  for (const [slug, version] of Object.entries(manifest.data)) {
202
261
  // The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
203
- if (!PLUGIN_PIN.safeParse(version).success) {
204
- problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
205
- }
262
+ const message = pinProblem(slug, version);
263
+ if (message)
264
+ problems.push({ file: 'loopress.json', message });
206
265
  }
207
266
  }
208
267
  async function checkSnippets(dir, problems) {
@@ -24,6 +24,8 @@ 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 isFirewallBlock(error: unknown): boolean;
28
+ export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
27
29
  export declare function isApplicative404(error: unknown, marker: string): boolean;
28
30
  export declare function isConflictCode(error: unknown, code: string): boolean;
29
31
  export declare function isTimeoutError(error: unknown): boolean;
@@ -100,6 +100,23 @@ export function isNotFoundError(error) {
100
100
  export function isForbiddenError(error) {
101
101
  return responseOf(causeOf(error)).statusCode === 403;
102
102
  }
103
+ // A 403 whose body is not a WordPress JSON error (`code`/`message`) nor a Loopress `error`
104
+ // refusal: the answer of a firewall (ModSecurity, Imunify360, Wordfence...) in front of PHP.
105
+ export function isFirewallBlock(error) {
106
+ return isFirewallResponse(responseOf(causeOf(error)));
107
+ }
108
+ function isFirewallResponse({ body, statusCode }) {
109
+ if (statusCode !== 403)
110
+ return false;
111
+ const parsed = parseBody(body);
112
+ return typeof parsed.code !== 'string' && typeof parsed.message !== 'string' && typeof parsed.error !== 'string';
113
+ }
114
+ // WordPress core answers the authenticated request with this `code` when Application
115
+ // Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
116
+ // filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
117
+ export function isApplicationPasswordsDisabled(error) {
118
+ return parseBody(responseOf(causeOf(error)).body).code === 'application_passwords_disabled_for_user';
119
+ }
103
120
  // A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
104
121
  // (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
105
122
  // isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
@@ -146,16 +163,8 @@ export function parseBody(body) {
146
163
  }
147
164
  export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
148
165
  const { body, statusCode: status } = responseOf(error);
149
- if (status === 401 || status === 403) {
150
- // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
151
- // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
152
- // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
153
- // auth failures use `{code, message}` instead and keep the credentials hint.
154
- const refusal = extractLoopressError(body);
155
- if (refusal)
156
- return `Request refused (${status}) on ${url}: ${refusal}`;
157
- return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
158
- }
166
+ if (status === 401 || status === 403)
167
+ return formatAuthError(status, body, url);
159
168
  if (status === 404) {
160
169
  const reason = extractServerErrorMessage(body);
161
170
  return reason
@@ -180,6 +189,23 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
180
189
  const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
181
190
  return `Request to ${url} failed: ${message || (code ?? String(error))}`;
182
191
  }
192
+ function formatAuthError(status, body, url) {
193
+ if (isFirewallResponse({ body, statusCode: status }))
194
+ return firewallMessage(url);
195
+ // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
196
+ // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
197
+ // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
198
+ // auth failures use `{code, message}` instead and keep the credentials hint.
199
+ const refusal = extractLoopressError(body);
200
+ if (refusal)
201
+ return `Request refused (${status}) on ${url}: ${refusal}`;
202
+ return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
203
+ }
204
+ // A 403 that is not a WordPress JSON error never came from WordPress: a web application
205
+ // firewall answered before PHP ran, and no credential change will fix it.
206
+ function firewallMessage(url) {
207
+ return `Request blocked (403) on ${url}: the response is not a WordPress error, so a firewall or security plugin (ModSecurity, Imunify360, Wordfence) probably blocked the request. Allow-list the request or your IP in the firewall, then retry.`;
208
+ }
183
209
  const NETWORK_ERROR_HINTS = {
184
210
  ECONNREFUSED: 'connection refused, the site may be down',
185
211
  ECONNRESET: 'connection reset by the server',