@loopress/cli 0.33.0 → 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 (62) hide show
  1. package/README.md +174 -161
  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 +12 -2
  9. package/dist/commands/option/push.js +1 -0
  10. package/dist/commands/page/push.js +1 -0
  11. package/dist/commands/plugin/add.d.ts +1 -0
  12. package/dist/commands/plugin/add.js +50 -9
  13. package/dist/commands/plugin/push.js +39 -4
  14. package/dist/commands/project/config.js +0 -1
  15. package/dist/commands/promote.d.ts +1 -0
  16. package/dist/commands/promote.js +8 -4
  17. package/dist/commands/pull.d.ts +5 -0
  18. package/dist/commands/pull.js +26 -1
  19. package/dist/commands/push.d.ts +9 -0
  20. package/dist/commands/push.js +104 -39
  21. package/dist/commands/theme/style/diff.js +1 -1
  22. package/dist/commands/theme/style/push.js +1 -1
  23. package/dist/commands/theme/style/rollback.js +1 -1
  24. package/dist/commands/theme/template/push.d.ts +2 -0
  25. package/dist/commands/theme/template/push.js +42 -13
  26. package/dist/commands/validate.js +3 -0
  27. package/dist/lib/base.d.ts +6 -0
  28. package/dist/lib/base.js +33 -1
  29. package/dist/lib/declared-plugins.d.ts +9 -0
  30. package/dist/lib/declared-plugins.js +21 -0
  31. package/dist/lib/declared-type-command.js +3 -0
  32. package/dist/lib/dev-watch.d.ts +1 -1
  33. package/dist/lib/dev-watch.js +11 -13
  34. package/dist/lib/diff-command.d.ts +17 -0
  35. package/dist/lib/diff-command.js +125 -9
  36. package/dist/lib/environment-urls.d.ts +6 -0
  37. package/dist/lib/environment-urls.js +59 -0
  38. package/dist/lib/exclude.d.ts +16 -0
  39. package/dist/lib/exclude.js +102 -0
  40. package/dist/lib/load-snippets.js +2 -1
  41. package/dist/lib/php-files-command.js +5 -3
  42. package/dist/lib/push-command.d.ts +4 -0
  43. package/dist/lib/push-command.js +38 -2
  44. package/dist/lib/readdir-tolerant.js +6 -3
  45. package/dist/lib/resource-selection.d.ts +9 -0
  46. package/dist/lib/resource-selection.js +49 -0
  47. package/dist/lib/resource-state.d.ts +2 -0
  48. package/dist/lib/resource-state.js +25 -4
  49. package/dist/lib/rollback-command.js +1 -1
  50. package/dist/lib/rollback-materialize.js +1 -1
  51. package/dist/lib/validate-local.d.ts +1 -0
  52. package/dist/lib/validate-local.js +67 -8
  53. package/dist/lib/wp-client.d.ts +1 -0
  54. package/dist/lib/wp-client.js +6 -0
  55. package/dist/types/project-config.generated.d.ts +15 -4
  56. package/dist/utils/block-format.d.ts +2 -0
  57. package/dist/utils/block-format.js +18 -2
  58. package/dist/utils/page-format.js +2 -1
  59. package/dist/utils/resource-dirs.js +4 -4
  60. package/oclif.manifest.json +2275 -1394
  61. package/package.json +7 -7
  62. package/schemas/project-config.schema.json +21 -4
@@ -1,4 +1,5 @@
1
1
  import { LoopressCommand } from './base.js';
2
+ import { type MissingIntegration } from './capabilities.js';
2
3
  import { type ResourceState, type StateChange } from './diff-state.js';
3
4
  import { type ResourceStateProvider } from './resource-state.js';
4
5
  import { WpClient } from './wp-client.js';
@@ -6,7 +7,10 @@ export type ResourceDiffJson = {
6
7
  added: string[];
7
8
  changed: StateChange[];
8
9
  error?: string;
10
+ heldBack?: string[];
11
+ reason?: string;
9
12
  removed: string[];
13
+ status?: 'skipped';
10
14
  };
11
15
  export type DiffJson = {
12
16
  drift: boolean;
@@ -15,11 +19,18 @@ export type DiffJson = {
15
19
  right: string;
16
20
  };
17
21
  export type DiffTarget = {
22
+ heldBackFrom?: (() => Promise<ResourceState>) | undefined;
23
+ isHeldBack?: ((id: string) => boolean) | undefined;
18
24
  left: () => Promise<ResourceState>;
19
25
  resource: string;
20
26
  right: () => Promise<ResourceState>;
21
27
  skip?: boolean;
22
28
  title: string;
29
+ unsupported?: {
30
+ fix?: string | undefined;
31
+ missing: MissingIntegration;
32
+ reason: string;
33
+ };
23
34
  };
24
35
  export type DiffSides = {
25
36
  againstWp: undefined | WpClient;
@@ -32,15 +43,19 @@ export declare abstract class DiffCommand extends LoopressCommand {
32
43
  static againstFlag: {
33
44
  against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
34
45
  };
46
+ static honorsExclude: boolean;
35
47
  protected composerTarget(sides: DiffSides): DiffTarget;
36
48
  protected providerTarget(provider: ResourceStateProvider, sides: DiffSides, dirOverride?: string): DiffTarget;
37
49
  protected report(targets: DiffTarget[], sides: DiffSides): Promise<DiffJson>;
50
+ protected reportDrift(result: DiffJson): Promise<void>;
38
51
  protected resolveSides(against: string | undefined): DiffSides;
39
52
  abstract run(): Promise<DiffJson>;
40
53
  private computeResource;
41
54
  private out;
42
55
  private renderFailure;
56
+ private renderHeldBack;
43
57
  private renderSection;
58
+ private renderSkipped;
44
59
  private wpForEnvironment;
45
60
  }
46
61
  export declare function resourceDiffCommand(resource: string, options: {
@@ -48,3 +63,5 @@ export declare function resourceDiffCommand(resource: string, options: {
48
63
  description: string;
49
64
  pathNoun: string;
50
65
  }): typeof DiffCommand;
66
+ export declare function excludedFileFor(dir: string, id: string, provider: Pick<ResourceStateProvider, 'fileExtension' | 'fileName'>): boolean;
67
+ export declare function sharedReads(wp: WpClient): WpClient;
@@ -1,9 +1,15 @@
1
1
  import { Args, Flags, ux } from '@oclif/core';
2
- import { resolve } from 'node:path';
2
+ import got from 'got';
3
+ import { join, resolve } from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { authManager } from '../config/auth.manager.js';
3
6
  import { configManager } from '../config/project-config.manager.js';
4
7
  import { resolveResourceDir } from '../utils/resource-dirs.js';
8
+ import { API_URL } from './api-client.js';
5
9
  import { LoopressCommand } from './base.js';
10
+ import {} from './capabilities.js';
6
11
  import { compareStates, isEmptyDiff } from './diff-state.js';
12
+ import { hasExclusions, isExcluded, withoutExclusions } from './exclude.js';
7
13
  import { errorMessage } from './narrow.js';
8
14
  import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
9
15
  import { WpClient } from './wp-client.js';
@@ -18,6 +24,7 @@ export class DiffCommand extends LoopressCommand {
18
24
  description: 'Compare the primary environment against this second environment instead of against local files',
19
25
  }),
20
26
  };
27
+ static honorsExclude = true;
21
28
  composerTarget(sides) {
22
29
  // resolve(), not join(): an absolute `rootDir` in loopress.json must stay absolute rather
23
30
  // than being prefixed with the cwd, so Composer is read from the same place as the
@@ -39,10 +46,18 @@ export class DiffCommand extends LoopressCommand {
39
46
  }
40
47
  const dir = resolveResourceDir(provider.dirKind, this.localConfig, dirOverride);
41
48
  const { againstWp } = sides;
49
+ const holdsBack = againstWp === undefined && hasExclusions();
50
+ // With exclusions, the local side may read the site too (an excluded file keeps the site's
51
+ // copy): both sides then share one response, so a site change between two reads can't show
52
+ // up as drift.
53
+ const wp = holdsBack ? sharedReads(this.wp) : this.wp;
42
54
  return {
43
- left: async () => provider.remote(this.wp, sides.warn, dir),
55
+ // Only against local files: they are what an exclude glob names.
56
+ heldBackFrom: holdsBack ? async () => withoutExclusions(async () => provider.local(dir, () => { }, wp)) : undefined,
57
+ isHeldBack: holdsBack ? (id) => excludedFileFor(dir, id, provider) : undefined,
58
+ left: async () => provider.remote(wp, sides.warn, dir),
44
59
  resource: provider.resource,
45
- right: againstWp ? async () => provider.remote(againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn, this.wp),
60
+ right: againstWp ? async () => provider.remote(againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn, wp),
46
61
  title: provider.title,
47
62
  };
48
63
  }
@@ -84,20 +99,49 @@ export class DiffCommand extends LoopressCommand {
84
99
  if (result.drift)
85
100
  drift = true;
86
101
  }
102
+ this.renderHeldBack(checked, sides.leftLabel);
87
103
  const skipped = results.filter((result) => result.skipped);
88
- for (const result of skipped)
89
- this.out(`${c('bold', result.title)} ${c('dim', 'not configured, skipped')}`);
104
+ const unsupported = this.renderSkipped(skipped, resources);
90
105
  this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
91
106
  if (failed)
92
107
  this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
93
108
  else
94
109
  this.out(drift ? c('yellow', 'Drift detected.') : c('green', 'Everything is in sync.'));
110
+ if (unsupported.length > 0)
111
+ this.warn(`Skipped with --skip-unsupported, not compared:\n ${unsupported.join('\n ')}`);
95
112
  if (failed)
96
113
  process.exitCode = 2;
97
114
  else if (drift)
98
115
  process.exitCode = 1;
99
116
  return { drift, left: sides.leftLabel, resources, right: sides.rightLabel };
100
117
  }
118
+ // Best-effort, like `recordDeployment`: when a token is present, sends the console the verdict
119
+ // and the changed items (resource, id, change kind, never contents). Silent on any failure, and
120
+ // it never touches the exit code or the `--json` output.
121
+ async reportDrift(result) {
122
+ const items = Object.entries(result.resources).flatMap(([resource, diff]) => [
123
+ ...diff.added.map((id) => ({ change: 'added', id, resource })),
124
+ ...diff.removed.map((id) => ({ change: 'removed', id, resource })),
125
+ ...diff.changed.map(({ id }) => ({ change: 'changed', id, resource })),
126
+ ]);
127
+ const inconclusive = Object.values(result.resources).some((diff) => diff.error !== undefined);
128
+ let status = result.drift ? 'drift' : 'clean';
129
+ if (inconclusive)
130
+ status = 'inconclusive';
131
+ try {
132
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
133
+ if (!token)
134
+ return;
135
+ await got.post(`${API_URL}/drift-reports`, {
136
+ headers: { Authorization: `Bearer ${token}` },
137
+ json: { exitCode: process.exitCode ?? 0, items, status, url: this.siteConfig.url },
138
+ timeout: { request: 3000 },
139
+ });
140
+ }
141
+ catch {
142
+ // non-blocking: reporting must never change the diff's outcome
143
+ }
144
+ }
101
145
  resolveSides(against) {
102
146
  if (against === this.siteConfig.name) {
103
147
  this.error('--against must be a different environment from the one being compared.');
@@ -117,13 +161,24 @@ export class DiffCommand extends LoopressCommand {
117
161
  async computeResource(target, sides) {
118
162
  const empty = { added: [], changed: [], removed: [] };
119
163
  if (target.skip)
120
- return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title };
164
+ return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title, unsupported: target.unsupported };
121
165
  try {
122
- const [leftState, rightState] = await Promise.all([target.left(), target.right()]);
166
+ const [leftState, rightState, fullState] = await Promise.all([target.left(), target.right(), target.heldBackFrom?.()]);
167
+ // Whole items: only in the unfiltered local files, or only on the site under an excluded path.
168
+ const heldBackItems = [
169
+ ...(fullState ? [...fullState.keys()].filter((id) => !rightState.has(id)) : []),
170
+ ...[...leftState.keys()].filter((id) => !rightState.has(id) && !fullState?.has(id) && target.isHeldBack?.(id) === true),
171
+ ];
172
+ for (const id of heldBackItems)
173
+ leftState.delete(id);
174
+ // An item an excluded file is part of (a block's view.js): compared with the site's copy of
175
+ // that file, held back when the local one differs.
176
+ const heldBackFiles = fullState ? [...rightState.keys()].filter((id) => fullState.has(id) && !isDeepStrictEqual(fullState.get(id), rightState.get(id))) : [];
177
+ const heldBack = [...heldBackItems, ...heldBackFiles].toSorted((a, b) => a.localeCompare(b));
123
178
  const stateDiff = compareStates(leftState, rightState, { left: sides.leftLabel, right: sides.rightLabel });
124
179
  return {
125
180
  drift: !isEmptyDiff(stateDiff),
126
- json: { added: stateDiff.added, changed: stateDiff.changed, removed: stateDiff.removed },
181
+ json: { added: stateDiff.added, changed: stateDiff.changed, removed: stateDiff.removed, ...(heldBack.length > 0 && { heldBack }) },
127
182
  resource: target.resource,
128
183
  stateDiff,
129
184
  title: target.title,
@@ -145,6 +200,16 @@ export class DiffCommand extends LoopressCommand {
145
200
  const errorLabel = c('red', `error: ${error}`);
146
201
  this.out(`${c('bold', title)} ${errorLabel}`);
147
202
  }
203
+ // Its own group, after the comparison: excluded items are never drift, but a feature held back
204
+ // from an environment must stay visible there.
205
+ renderHeldBack(results, envName) {
206
+ const held = results.filter((result) => (result.json.heldBack ?? []).length > 0);
207
+ if (held.length === 0)
208
+ return;
209
+ this.out(c('bold', `\nHeld back from ${envName} (exclude in loopress.json), not compared`));
210
+ for (const result of held)
211
+ this.out(c('dim', ` ${result.title}: ${(result.json.heldBack ?? []).join(', ')}`));
212
+ }
148
213
  renderSection(title, stateDiff) {
149
214
  if (isEmptyDiff(stateDiff)) {
150
215
  this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
@@ -160,6 +225,24 @@ export class DiffCommand extends LoopressCommand {
160
225
  this.out(indentPatch(change.patch));
161
226
  }
162
227
  }
228
+ // The skipped resources' lines. Returns the --skip-unsupported ones for the final warning, and
229
+ // adds them to the JSON result as skipped, with their reason.
230
+ renderSkipped(skipped, resources) {
231
+ const warnings = [];
232
+ for (const { resource, title, unsupported } of skipped) {
233
+ if (!unsupported) {
234
+ this.out(`${c('bold', title)} ${c('dim', 'not configured, skipped')}`);
235
+ continue;
236
+ }
237
+ const { fix, reason } = unsupported;
238
+ resources[resource] = { added: [], changed: [], reason, removed: [], status: 'skipped' };
239
+ const note = `${reason}, skipped (--skip-unsupported)`;
240
+ this.out(`${c('bold', title)} ${c('dim', note)}`);
241
+ const fixSentence = fix ? ` ${fix}.` : '';
242
+ warnings.push(`${title}: ${reason}.${fixSentence}`);
243
+ }
244
+ return warnings;
245
+ }
163
246
  wpForEnvironment(envName) {
164
247
  const env = configManager.getEnvironment(this.projectId, envName);
165
248
  if (!env) {
@@ -173,7 +256,7 @@ export class DiffCommand extends LoopressCommand {
173
256
  }
174
257
  // Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
175
258
  // only in wording and which provider they target, so the whole body lives here.
176
- // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
259
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
177
260
  export function resourceDiffCommand(resource, options) {
178
261
  const command = options.command ?? resource;
179
262
  class ResourceDiff extends DiffCommand {
@@ -196,6 +279,19 @@ export function resourceDiffCommand(resource, options) {
196
279
  }
197
280
  return ResourceDiff;
198
281
  }
282
+ // The id is the file's path under the resource directory for most resources (a slug, an API
283
+ // route), without its extension: a provider whose ids don't follow that names the file itself
284
+ // (`fileName`), one with an extension is tried with it only, and one without (a block folder)
285
+ // with the bare path and every extension resource files use.
286
+ const RESOURCE_FILE_EXTENSIONS = ['.json', '.php', '.html'];
287
+ export function excludedFileFor(dir, id, provider) {
288
+ if (provider.fileName) {
289
+ const fileName = provider.fileName(id);
290
+ return fileName !== undefined && isExcluded(join(dir, fileName));
291
+ }
292
+ const extensions = provider.fileExtension === undefined ? ['', ...RESOURCE_FILE_EXTENSIONS] : [provider.fileExtension];
293
+ return extensions.some((extension) => isExcluded(join(dir, `${id}${extension}`)));
294
+ }
199
295
  // Indents an already-formatted change block (a per-field list or a header-less unified diff)
200
296
  // so it sits visually under the `~ id` line.
201
297
  function indentPatch(patch) {
@@ -211,3 +307,23 @@ function sharedFailure(errors) {
211
307
  const distinct = new Set(failures);
212
308
  return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
213
309
  }
310
+ // A client whose plain GETs are read once per path and shared, for the lifetime of one
311
+ // resource comparison. Every other call goes to `wp` unchanged.
312
+ export function sharedReads(wp) {
313
+ const reads = new Map();
314
+ // `Object.create` keeps every other WpClient method, bound to `wp`'s own state.
315
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
316
+ const shared = Object.create(wp);
317
+ shared.get = async (path, options) => {
318
+ if (options !== undefined)
319
+ return wp.get(path, options);
320
+ let read = reads.get(path);
321
+ if (read === undefined) {
322
+ read = wp.get(path);
323
+ reads.set(path, read);
324
+ }
325
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the same path yields the same T
326
+ return read;
327
+ };
328
+ return shared;
329
+ }
@@ -0,0 +1,6 @@
1
+ import { type EnvironmentConfig } from '../types/config.js';
2
+ export type ScannedItem = {
3
+ id: string;
4
+ value: unknown;
5
+ };
6
+ export declare function environmentUrlWarnings(items: ScannedItem[], target: Pick<EnvironmentConfig, 'name' | 'url'>, environments: Array<Pick<EnvironmentConfig, 'name' | 'url'>>): string[];
@@ -0,0 +1,59 @@
1
+ import {} from '../types/config.js';
2
+ // `http(s)://` then the host, plain or JSON-escaped (`https:\/\/`), as block attributes and option
3
+ // values can hold either.
4
+ const ABSOLUTE_URL = /https?:(?:\/\/|\\\/\\\/)[^\s"'<>()\\]+/gi;
5
+ // Warns, never rewrites: an absolute URL whose host is another environment of the project keeps
6
+ // pointing there once pushed (a staging link on production). A relative path, a link to the
7
+ // target environment itself and an external link are left alone.
8
+ export function environmentUrlWarnings(items, target, environments) {
9
+ const others = otherEnvironmentHosts(target, environments);
10
+ if (others.size === 0)
11
+ return [];
12
+ return items.flatMap((item) => [...urlsByHost(item.value, others)].map(([host, urls]) => `${item.id} links to the "${others.get(host)}" environment (${host}), it will still point there on ${target.name}: ${[...urls].join(', ')}. ` +
13
+ 'Use a relative path (/about) instead.'));
14
+ }
15
+ // Host (with its port, so two local sites on one machine stay apart) to environment name.
16
+ function otherEnvironmentHosts(target, environments) {
17
+ const targetHost = hostOf(target.url);
18
+ const others = new Map();
19
+ for (const env of environments) {
20
+ const host = hostOf(env.url);
21
+ if (host !== undefined && host !== targetHost && !others.has(host))
22
+ others.set(host, env.name);
23
+ }
24
+ return others;
25
+ }
26
+ function urlsByHost(value, hosts) {
27
+ const found = new Map();
28
+ for (const text of strings(value)) {
29
+ for (const match of text.matchAll(ABSOLUTE_URL)) {
30
+ const url = match[0].replaceAll(String.raw `\/`, '/');
31
+ const host = hostOf(url);
32
+ if (host === undefined || !hosts.has(host))
33
+ continue;
34
+ found.set(host, (found.get(host) ?? new Set()).add(url));
35
+ }
36
+ }
37
+ return found;
38
+ }
39
+ function hostOf(url) {
40
+ try {
41
+ return new URL(url).host.toLowerCase() || undefined;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ function* strings(value) {
48
+ if (typeof value === 'string') {
49
+ yield value;
50
+ }
51
+ else if (Array.isArray(value)) {
52
+ for (const entry of value)
53
+ yield* strings(entry);
54
+ }
55
+ else if (value !== null && typeof value === 'object') {
56
+ for (const entry of Object.values(value))
57
+ yield* strings(entry);
58
+ }
59
+ }
@@ -0,0 +1,16 @@
1
+ import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
+ export declare function environmentExcludes(config: LoopressLocalConfig, envName: string): string[];
3
+ export declare function activateExclusions(options: {
4
+ config: LoopressLocalConfig;
5
+ cwd: string;
6
+ envName: string;
7
+ honors: boolean;
8
+ }): void;
9
+ export declare function disableExclusions(): void;
10
+ export declare function hasExclusions(): boolean;
11
+ export declare function isExcluded(path: string): boolean;
12
+ export declare function withoutExcluded<T>(dir: string, entries: T[], name: (entry: T) => string): T[];
13
+ export declare function heldBackPaths(): string[];
14
+ export declare function withoutExclusions<T>(fn: () => Promise<T>): Promise<T>;
15
+ export declare function matchesAny(matchers: RegExp[], segments: string[]): boolean;
16
+ export declare function globToRegExp(glob: string): RegExp;
@@ -0,0 +1,102 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import {} from '../utils/loopress-config.js';
4
+ let active;
5
+ // `lps dev` runs the per-resource pushes itself: it disables exclusions for its whole process.
6
+ let disabled = false;
7
+ // Paths (relative to rootDir) the listing layer hid since the active set was installed, for a
8
+ // dry run to list them.
9
+ const heldBack = new Set();
10
+ // Inside `withoutExclusions`: every file is listed, to tell which items a filter held back.
11
+ const suspended = new AsyncLocalStorage();
12
+ export function environmentExcludes(config, envName) {
13
+ const excludes = config.environments?.[envName]?.exclude;
14
+ return Array.isArray(excludes) ? excludes.filter((pattern) => typeof pattern === 'string' && pattern.trim() !== '') : [];
15
+ }
16
+ // Called by every LoopressCommand's init: installs the environment's exclusions when the command
17
+ // honors them, clears whatever a previous command of the same process installed otherwise.
18
+ export function activateExclusions(options) {
19
+ heldBack.clear();
20
+ const patterns = !disabled && options.honors ? environmentExcludes(options.config, options.envName) : [];
21
+ active =
22
+ patterns.length === 0
23
+ ? undefined
24
+ : { matchers: patterns.map((pattern) => globToRegExp(pattern)), root: resolve(options.cwd, options.config.rootDir ?? '.') };
25
+ }
26
+ export function disableExclusions() {
27
+ disabled = true;
28
+ active = undefined;
29
+ }
30
+ export function hasExclusions() {
31
+ return active !== undefined;
32
+ }
33
+ // `path` is a file or a directory, absolute or relative to the cwd. A glob matching one of its
34
+ // parent directories covers it too (`hooks/feature` excludes everything under it).
35
+ export function isExcluded(path) {
36
+ if (!active || suspended.getStore())
37
+ return false;
38
+ const rel = relative(active.root, resolve(path));
39
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
40
+ return false;
41
+ return matchesAny(active.matchers, rel.split(sep));
42
+ }
43
+ // Drops the excluded entries of a listing of `dir` (entries relative to it) and records them.
44
+ export function withoutExcluded(dir, entries, name) {
45
+ if (!active || suspended.getStore())
46
+ return entries;
47
+ const { root } = active;
48
+ return entries.filter((entry) => {
49
+ const path = resolve(dir, name(entry));
50
+ if (!isExcluded(path))
51
+ return true;
52
+ heldBack.add(relative(root, path).split(sep).join('/'));
53
+ return false;
54
+ });
55
+ }
56
+ export function heldBackPaths() {
57
+ return [...heldBack].toSorted((a, b) => a.localeCompare(b));
58
+ }
59
+ // Runs `fn` with every file listed, excluded ones included.
60
+ export async function withoutExclusions(fn) {
61
+ return suspended.run(true, fn);
62
+ }
63
+ // Every pattern matched against a '/'-split relative path, or any of its parent directories.
64
+ export function matchesAny(matchers, segments) {
65
+ for (let { length } = segments; length > 0; length--) {
66
+ const candidate = segments.slice(0, length).join('/');
67
+ if (matchers.some((matcher) => matcher.test(candidate)))
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ // `**` any number of directories, `*` anything but '/', `?` one character but '/'. A leading
73
+ // `./` is ignored, the rest is literal.
74
+ export function globToRegExp(glob) {
75
+ let source = glob.trim().replace(/^\.\//, '');
76
+ while (source.endsWith('/'))
77
+ source = source.slice(0, -1);
78
+ let pattern = '';
79
+ for (let i = 0; i < source.length; i++) {
80
+ const char = source.charAt(i);
81
+ if (char === '*' && source.charAt(i + 1) === '*') {
82
+ i += 1;
83
+ if (source.charAt(i + 1) === '/') {
84
+ i += 1;
85
+ pattern += '(?:.*/)?';
86
+ }
87
+ else {
88
+ pattern += '.*';
89
+ }
90
+ }
91
+ else if (char === '*') {
92
+ pattern += '[^/]*';
93
+ }
94
+ else if (char === '?') {
95
+ pattern += '[^/]';
96
+ }
97
+ else {
98
+ pattern += char.replaceAll(/[$()+.[\\\]^{|}]/g, String.raw `\$&`);
99
+ }
100
+ }
101
+ return new RegExp(`^${pattern}$`);
102
+ }
@@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
2
2
  import { basename, extname, join } from 'node:path';
3
3
  import {} from '../types/snippet.js';
4
4
  import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
5
+ import { withoutExcluded } from './exclude.js';
5
6
  import { errorCode, errorMessage, isRecord } from './narrow.js';
6
7
  const TYPE_BY_EXTENSION = {
7
8
  '.css': 'css',
@@ -75,7 +76,7 @@ async function readSidecar(metaPath) {
75
76
  export async function loadSnippets(path, onSkip) {
76
77
  let files;
77
78
  try {
78
- files = await readdir(path);
79
+ files = withoutExcluded(path, await readdir(path), (file) => file);
79
80
  }
80
81
  catch (error) {
81
82
  throw new Error(`Error loading snippets: ${errorMessage(error)}`, { cause: error });
@@ -11,6 +11,7 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
11
11
  import { ApiClient } from './api-client.js';
12
12
  import { LoopressCommand } from './base.js';
13
13
  import {} from './diff-state.js';
14
+ import { isExcluded } from './exclude.js';
14
15
  import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
15
16
  import { isInteractive } from './interactive.js';
16
17
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
@@ -119,7 +120,7 @@ export function resourcePushCommand(spec) {
119
120
  // confirmation), but their removal itself happens inside the same atomic swap as the push
120
121
  // below, not as separate DELETE calls, so a mid-push failure can't remove server-side
121
122
  // files without also landing the new ones.
122
- const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
123
+ const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename)), path) : [];
123
124
  if (this.dryRun) {
124
125
  await this.writeAfterPushSnapshot(provider, path, beforeState);
125
126
  return { changes: this.previewChanges, pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
@@ -181,9 +182,10 @@ export function resourcePushCommand(spec) {
181
182
  // local path in CI must not silently wipe production routes, and unlike `pull`'s local
182
183
  // deletions these are not recoverable from the repo. Only decides *which* filenames to
183
184
  // prune; pushBatch() is what actually removes them, atomically alongside the push.
184
- async resolvePruneList(localFilenames) {
185
+ // A file excluded from this environment (loopress.json) is left alone, never pruned.
186
+ async resolvePruneList(localFilenames, path) {
185
187
  const remote = await this.wp.get(spec.endpoint);
186
- const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
188
+ const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name) && !isExcluded(join(path, `${name}.php`)));
187
189
  if (orphans.length === 0)
188
190
  return [];
189
191
  const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
@@ -1,5 +1,6 @@
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';
4
5
  export type PushChange = {
5
6
  id: string;
@@ -19,6 +20,7 @@ export declare abstract class PushCommand extends LoopressCommand {
19
20
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
21
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
22
  };
23
+ static honorsExclude: boolean;
22
24
  protected failedCount: number;
23
25
  protected previewChanges: PushChange[];
24
26
  protected previewStates?: {
@@ -28,6 +30,7 @@ export declare abstract class PushCommand extends LoopressCommand {
28
30
  private refusedByGuard;
29
31
  protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
30
32
  catch(err: Error): Promise<void>;
33
+ finally(error: Error | undefined): Promise<void>;
31
34
  protected guardProductionPush(): Promise<void>;
32
35
  init(): Promise<void>;
33
36
  protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
@@ -40,6 +43,7 @@ export declare abstract class PushCommand extends LoopressCommand {
40
43
  protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
41
44
  output: string;
42
45
  }) => Promise<void>): Promise<void>;
46
+ protected warnEnvironmentUrls(items: ScannedItem[]): void;
43
47
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
44
48
  private guardOutsideEdits;
45
49
  }
@@ -2,6 +2,7 @@ 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';
@@ -9,12 +10,29 @@ import {} from '../types/config.js';
9
10
  import { API_URL } from './api-client.js';
10
11
  import { LoopressCommand } from './base.js';
11
12
  import {} from './diff-state.js';
13
+ import { environmentUrlWarnings } from './environment-urls.js';
14
+ import { heldBackPaths } from './exclude.js';
12
15
  import { guardProductionPush } from './guard-production-push.js';
13
16
  import { isInteractive } from './interactive.js';
14
17
  import { errorMessage } from './narrow.js';
15
18
  import {} from './resource-state.js';
16
19
  import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
17
20
  import { withoutSpinner, withSpinner } from './spinner.js';
21
+ // The Git state the deployment record carries: the HEAD commit and whether the working tree has
22
+ // uncommitted changes. Empty outside a Git repository (or without git), so nothing is sent then.
23
+ function gitState() {
24
+ try {
25
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- git is resolved from PATH like in every dev tool
26
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
27
+ const commit = git(['rev-parse', 'HEAD']);
28
+ if (!commit)
29
+ return {};
30
+ return { commit, dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '' };
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
18
36
  export function pushChanges(local, remote, options = {}) {
19
37
  const changes = [...local.keys()]
20
38
  .filter((id) => options.writes?.(id) ?? true)
@@ -44,6 +62,7 @@ export class PushCommand extends LoopressCommand {
44
62
  }),
45
63
  'production-confirmed': Flags.boolean({ hidden: true }),
46
64
  };
65
+ static honorsExclude = true;
47
66
  failedCount = 0;
48
67
  // A dry run's per-item preview, set by captureBeforePushState: what the push would create,
49
68
  // change or leave as it is on the site.
@@ -101,6 +120,16 @@ export class PushCommand extends LoopressCommand {
101
120
  }
102
121
  await super.catch(err);
103
122
  }
123
+ // Lists what the environment's `exclude` globs (loopress.json) kept out of this push, so a
124
+ // feature held back from it is never forgotten there.
125
+ async finally(error) {
126
+ const paths = error ? [] : heldBackPaths();
127
+ if (paths.length > 0) {
128
+ const prefix = this.dryRun ? '[dry-run] ' : '';
129
+ this.log(`${prefix}Held back from ${this.siteConfig.name} (exclude in loopress.json): ${paths.join(', ')}`);
130
+ }
131
+ await super.finally(error);
132
+ }
104
133
  async guardProductionPush() {
105
134
  await guardProductionPush({
106
135
  dryRun: this.dryRun,
@@ -135,7 +164,7 @@ export class PushCommand extends LoopressCommand {
135
164
  try {
136
165
  await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
137
166
  headers: { Authorization: `Bearer ${token}` },
138
- json: { status, url: this.siteConfig.url },
167
+ json: { status, url: this.siteConfig.url, command: this.id, ...gitState() },
139
168
  timeout: { request: 3000 },
140
169
  }));
141
170
  }
@@ -191,12 +220,19 @@ export class PushCommand extends LoopressCommand {
191
220
  })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
192
221
  });
193
222
  }
223
+ // Absolute URLs to another environment of the project in what this push sends: warned about,
224
+ // never rewritten, on a dry run too.
225
+ warnEnvironmentUrls(items) {
226
+ const prefix = this.dryRun ? '[dry-run] ' : '';
227
+ for (const warning of environmentUrlWarnings(items, this.siteConfig, this.projectEnvironments))
228
+ this.warn(`${prefix}${warning}`);
229
+ }
194
230
  // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
195
231
  // with a fresh remote read taken right after the push, and writes both as one rollback
196
232
  // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
197
233
  // the local files that were pushed, is what keeps this correct for every resource's own
198
234
  // 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
235
+ // that gets a server-assigned id on create, a theme-style push that only touches the
200
236
  // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
201
237
  // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
202
238
  // against exactly this after-state, so it has to be what actually landed, not what was sent.
@@ -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')