@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
@@ -0,0 +1,21 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { parseComposerJson, wpackagistRequire } from '../utils/composer.js';
5
+ import {} from '../utils/loopress-config.js';
6
+ import { isLocalEnvironment } from './wp-client.js';
7
+ // The wordpress.org plugins the project declares: composer.json's wpackagist plugins when it
8
+ // exists, loopress.json's otherwise. Unreadable or invalid composer.json: none, the commands
9
+ // report it.
10
+ export async function declaredPlugins(options) {
11
+ const path = resolve(process.cwd(), options.rootDir, 'composer.json');
12
+ if (!existsSync(path))
13
+ return { fromComposer: false, plugins: Object.keys(options.localConfig.plugins ?? {}) };
14
+ try {
15
+ const composerJson = parseComposerJson(await readFile(path, 'utf8'));
16
+ return { fromComposer: true, plugins: Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(options.siteName))) };
17
+ }
18
+ catch {
19
+ return { fromComposer: true, plugins: [] };
20
+ }
21
+ }
@@ -46,6 +46,9 @@ export function declaredPushCommand(spec, description) {
46
46
  parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
47
47
  });
48
48
  this.log(`Found ${pluralize(items.length, spec.noun, spec.plural)} to push`);
49
+ // Patterns carry block markup, the other declared types only registration arguments.
50
+ if (spec.cliName === 'pattern')
51
+ this.warnEnvironmentUrls(items.map((item) => ({ id: `${spec.plural}/${item.slug}`, value: item.args })));
49
52
  const pushed = [];
50
53
  await this.runPushTasks(items, (item) => item.slug, async (item, task) => {
51
54
  await this.pushItem(item, task);
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'blocks' | 'css' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
2
+ export type ResourceType = 'api' | 'block' | 'css' | 'hook' | 'page' | 'part' | 'plugin' | 'snippet' | 'template';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -2,12 +2,10 @@ import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
3
  import {} from '../utils/loopress-config.js';
4
4
  import { resolveResourceDir } from '../utils/resource-dirs.js';
5
- export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', 'css', 'blocks', 'pages', 'api', 'hooks', 'plugins'];
6
- // `only` narrows the default set, `skip` then removes from whatever that set is, same
7
- // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
5
+ import { selectResources } from './resource-selection.js';
6
+ export const RESOURCE_TYPES = ['snippet', 'template', 'part', 'css', 'block', 'page', 'api', 'hook', 'plugin'];
8
7
  export function resolveResourceTypes(only, skip = []) {
9
- const base = only && only.length > 0 ? only : RESOURCE_TYPES;
10
- return base.filter((type) => !skip.includes(type));
8
+ return selectResources(RESOURCE_TYPES, only, skip);
11
9
  }
12
10
  // One target per requested type that actually has something to watch: a missing directory (no
13
11
  // `api/` folder yet) or an empty plugin manifest is left out silently, matching `push`'s own
@@ -17,19 +15,19 @@ export function buildWatchTargets(types, localConfig, cwd) {
17
15
  const candidates = {
18
16
  api: { commandId: 'api:push', path: join(cwd, resolveResourceDir('api', localConfig)), type: 'api' },
19
17
  // The theme CSS is bundled into the child theme with the templates, by the same push.
20
- blocks: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('blockCss', localConfig)), type: 'blocks' },
18
+ block: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('blockCss', localConfig)), type: 'block' },
21
19
  css: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('css', localConfig)), type: 'css' },
22
- hooks: { commandId: 'hook:push', path: join(cwd, resolveResourceDir('hooks', localConfig)), type: 'hooks' },
23
- pages: { commandId: 'page:push', path: join(cwd, resolveResourceDir('page', localConfig)), type: 'pages' },
24
- plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
25
- parts: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('part', localConfig)), type: 'parts' },
26
- templates: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('template', localConfig)), type: 'templates' },
27
- snippets: { commandId: 'snippet:push', path: join(cwd, resolveResourceDir('snippets', localConfig)), type: 'snippets' },
20
+ hook: { commandId: 'hook:push', path: join(cwd, resolveResourceDir('hooks', localConfig)), type: 'hook' },
21
+ page: { commandId: 'page:push', path: join(cwd, resolveResourceDir('page', localConfig)), type: 'page' },
22
+ plugin: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugin' },
23
+ part: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('part', localConfig)), type: 'part' },
24
+ template: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('template', localConfig)), type: 'template' },
25
+ snippet: { commandId: 'snippet:push', path: join(cwd, resolveResourceDir('snippets', localConfig)), type: 'snippet' },
28
26
  };
29
27
  return types
30
28
  .map((type) => candidates[type])
31
29
  .filter((target) => {
32
- if (target.type === 'plugins')
30
+ if (target.type === 'plugin')
33
31
  return Object.keys(localConfig.plugins ?? {}).length > 0;
34
32
  return existsSync(target.path);
35
33
  });
@@ -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',
@@ -68,14 +69,11 @@ async function readSidecar(metaPath) {
68
69
  throw new Error('not a JSON object');
69
70
  return meta;
70
71
  }
71
- // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
72
- // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
73
- // directory the same way. `onSkip` is optional so callers that don't care about per-file
74
- // warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
72
+ // Reads the local `snippets/` directory. `onSkip` is optional: skipped files are simply left out either way.
75
73
  export async function loadSnippets(path, onSkip) {
76
74
  let files;
77
75
  try {
78
- files = await readdir(path);
76
+ files = withoutExcluded(path, await readdir(path), (file) => file);
79
77
  }
80
78
  catch (error) {
81
79
  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(', ')}`;