@loopress/cli 0.32.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +549 -179
  2. package/dist/commands/acf/list.d.ts +1 -1
  3. package/dist/commands/acf/list.js +3 -4
  4. package/dist/commands/acf/pull.d.ts +1 -1
  5. package/dist/commands/acf/pull.js +9 -6
  6. package/dist/commands/acf/push.d.ts +1 -1
  7. package/dist/commands/acf/push.js +5 -4
  8. package/dist/commands/api/publish.js +2 -1
  9. package/dist/commands/app/pull.js +2 -1
  10. package/dist/commands/app/push.js +7 -5
  11. package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
  12. package/dist/commands/app/{remove.js → rm.js} +2 -1
  13. package/dist/commands/block/diff.d.ts +2 -0
  14. package/dist/commands/block/diff.js +5 -0
  15. package/dist/commands/block/list.d.ts +8 -0
  16. package/dist/commands/block/list.js +23 -0
  17. package/dist/commands/block/pull.d.ts +21 -0
  18. package/dist/commands/block/pull.js +67 -0
  19. package/dist/commands/block/push.d.ts +20 -0
  20. package/dist/commands/block/push.js +53 -0
  21. package/dist/commands/block/rm.d.ts +23 -0
  22. package/dist/commands/block/rm.js +69 -0
  23. package/dist/commands/block/rollback.d.ts +2 -0
  24. package/dist/commands/block/rollback.js +5 -0
  25. package/dist/commands/composer/init.js +1 -0
  26. package/dist/commands/composer/push.js +5 -4
  27. package/dist/commands/content/copy.d.ts +3 -0
  28. package/dist/commands/content/copy.js +79 -2
  29. package/dist/commands/dev.d.ts +2 -2
  30. package/dist/commands/dev.js +13 -18
  31. package/dist/commands/diff.d.ts +6 -3
  32. package/dist/commands/diff.js +63 -13
  33. package/dist/commands/doctor.d.ts +4 -2
  34. package/dist/commands/doctor.js +49 -25
  35. package/dist/commands/form/pull.js +7 -5
  36. package/dist/commands/form/push.js +4 -3
  37. package/dist/commands/init.js +4 -3
  38. package/dist/commands/menu/push.js +8 -8
  39. package/dist/commands/option/list.js +5 -2
  40. package/dist/commands/option/push.js +3 -1
  41. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  42. package/dist/commands/option/{remove.js → rm.js} +2 -1
  43. package/dist/commands/page/push.js +3 -1
  44. package/dist/commands/plugin/add.d.ts +1 -0
  45. package/dist/commands/plugin/add.js +50 -9
  46. package/dist/commands/plugin/audit.d.ts +4 -4
  47. package/dist/commands/plugin/audit.js +35 -15
  48. package/dist/commands/plugin/pull.js +3 -2
  49. package/dist/commands/plugin/push.js +41 -6
  50. package/dist/commands/plugin/status.js +3 -1
  51. package/dist/commands/project/config.js +7 -7
  52. package/dist/commands/project/pull.js +3 -1
  53. package/dist/commands/project/push.js +6 -4
  54. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  55. package/dist/commands/project/{remove.js → rm.js} +3 -2
  56. package/dist/commands/project/switch.js +6 -4
  57. package/dist/commands/promote.d.ts +1 -0
  58. package/dist/commands/promote.js +15 -6
  59. package/dist/commands/pull.d.ts +7 -0
  60. package/dist/commands/pull.js +67 -18
  61. package/dist/commands/push.d.ts +11 -1
  62. package/dist/commands/push.js +157 -48
  63. package/dist/commands/snippet/publish.js +9 -5
  64. package/dist/commands/snippet/push.d.ts +1 -1
  65. package/dist/commands/snippet/push.js +15 -12
  66. package/dist/commands/status.js +1 -0
  67. package/dist/commands/theme/activate.d.ts +21 -0
  68. package/dist/commands/theme/activate.js +44 -0
  69. package/dist/commands/theme/pull.js +2 -1
  70. package/dist/commands/theme/push.js +7 -3
  71. package/dist/commands/theme/style/diff.js +1 -1
  72. package/dist/commands/theme/style/push.js +8 -3
  73. package/dist/commands/theme/style/rollback.js +1 -1
  74. package/dist/commands/theme/template/push.d.ts +2 -0
  75. package/dist/commands/theme/template/push.js +66 -18
  76. package/dist/commands/validate.js +3 -0
  77. package/dist/config/auth.manager.d.ts +10 -7
  78. package/dist/config/auth.manager.js +5 -1
  79. package/dist/config/json-file.d.ts +1 -1
  80. package/dist/config/project-config.manager.d.ts +2 -2
  81. package/dist/config/project-config.manager.js +13 -21
  82. package/dist/lib/api-client.js +16 -21
  83. package/dist/lib/app-build.js +6 -2
  84. package/dist/lib/app-manifest.js +6 -6
  85. package/dist/lib/base.d.ts +8 -1
  86. package/dist/lib/base.js +43 -9
  87. package/dist/lib/bootstrap-full-install.js +8 -6
  88. package/dist/lib/capabilities.d.ts +44 -0
  89. package/dist/lib/capabilities.js +85 -0
  90. package/dist/lib/declared-plugins.d.ts +9 -0
  91. package/dist/lib/declared-plugins.js +21 -0
  92. package/dist/lib/declared-type-command.js +7 -2
  93. package/dist/lib/dev-watch.d.ts +1 -1
  94. package/dist/lib/dev-watch.js +13 -15
  95. package/dist/lib/diff-command.d.ts +19 -3
  96. package/dist/lib/diff-command.js +132 -14
  97. package/dist/lib/diff-state.js +2 -3
  98. package/dist/lib/environment-urls.d.ts +6 -0
  99. package/dist/lib/environment-urls.js +59 -0
  100. package/dist/lib/exclude.d.ts +16 -0
  101. package/dist/lib/exclude.js +102 -0
  102. package/dist/lib/load-files.js +2 -1
  103. package/dist/lib/load-snippets.js +39 -43
  104. package/dist/lib/local-callback-server.js +7 -1
  105. package/dist/lib/narrow.d.ts +5 -0
  106. package/dist/lib/narrow.js +26 -0
  107. package/dist/lib/php-files-command.js +13 -8
  108. package/dist/lib/plugin-detection.d.ts +0 -1
  109. package/dist/lib/plugin-detection.js +2 -4
  110. package/dist/lib/push-command.d.ts +5 -1
  111. package/dist/lib/push-command.js +48 -7
  112. package/dist/lib/put-or-create.d.ts +2 -2
  113. package/dist/lib/readdir-tolerant.js +9 -4
  114. package/dist/lib/resource-selection.d.ts +9 -0
  115. package/dist/lib/resource-selection.js +49 -0
  116. package/dist/lib/resource-state.d.ts +2 -0
  117. package/dist/lib/resource-state.js +60 -14
  118. package/dist/lib/rollback-command.d.ts +1 -1
  119. package/dist/lib/rollback-command.js +4 -3
  120. package/dist/lib/rollback-materialize.js +29 -9
  121. package/dist/lib/rotate-app-password.js +1 -0
  122. package/dist/lib/sentry.js +2 -2
  123. package/dist/lib/snapshot-store.d.ts +10 -7
  124. package/dist/lib/snapshot-store.js +21 -5
  125. package/dist/lib/spinner.js +4 -0
  126. package/dist/lib/validate-local.d.ts +1 -0
  127. package/dist/lib/validate-local.js +102 -28
  128. package/dist/lib/wp-client.d.ts +7 -1
  129. package/dist/lib/wp-client.js +60 -69
  130. package/dist/lib/wp-site-diagnostic.js +3 -5
  131. package/dist/types/global-config.generated.d.ts +1 -1
  132. package/dist/types/plugin.d.ts +7 -5
  133. package/dist/types/plugin.js +6 -1
  134. package/dist/types/project-config.generated.d.ts +24 -5
  135. package/dist/types/snippet.d.ts +2 -2
  136. package/dist/types/snippet.js +1 -1
  137. package/dist/utils/acf-format.js +6 -3
  138. package/dist/utils/block-format.d.ts +29 -0
  139. package/dist/utils/block-format.js +109 -0
  140. package/dist/utils/composer.d.ts +7 -15
  141. package/dist/utils/composer.js +43 -16
  142. package/dist/utils/declared-type-format.js +26 -19
  143. package/dist/utils/form-format.js +2 -2
  144. package/dist/utils/loopress-config.js +9 -1
  145. package/dist/utils/menu-format.d.ts +3 -1
  146. package/dist/utils/menu-format.js +5 -0
  147. package/dist/utils/option-format.d.ts +2 -2
  148. package/dist/utils/option-format.js +15 -25
  149. package/dist/utils/page-format.js +9 -7
  150. package/dist/utils/php-top-level.js +15 -4
  151. package/dist/utils/plugin-sync.d.ts +7 -7
  152. package/dist/utils/plugin-sync.js +11 -11
  153. package/dist/utils/plugins.js +9 -6
  154. package/dist/utils/resource-dirs.d.ts +2 -0
  155. package/dist/utils/resource-dirs.js +13 -5
  156. package/dist/utils/snippet-format.d.ts +1 -1
  157. package/dist/utils/snippet-format.js +5 -3
  158. package/dist/utils/template-format.js +2 -2
  159. package/dist/utils/theme-css.js +14 -6
  160. package/dist/utils/theme-styles-format.d.ts +3 -0
  161. package/dist/utils/theme-styles-format.js +15 -5
  162. package/dist/utils/version.js +1 -1
  163. package/oclif.manifest.json +2661 -1235
  164. package/package.json +45 -20
  165. package/schemas/global-config.schema.json +1 -1
  166. package/schemas/project-config.schema.json +32 -5
@@ -1,10 +1,16 @@
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';
7
- import { INTEGRATION_MISSING } from './plugin-detection.js';
12
+ import { hasExclusions, isExcluded, withoutExclusions } from './exclude.js';
13
+ import { errorMessage } from './narrow.js';
8
14
  import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
9
15
  import { WpClient } from './wp-client.js';
10
16
  const c = ux.colorize;
@@ -18,15 +24,17 @@ 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
24
31
  // file-backed resources.
25
32
  const localRoot = resolve(process.cwd(), this.rootDir);
33
+ const { againstWp } = sides;
26
34
  return {
27
35
  left: async () => composerRemoteState(this.wp),
28
36
  resource: 'composer',
29
- right: sides.againstWp ? async () => composerRemoteState(sides.againstWp) : async () => composerLocalState(localRoot),
37
+ right: againstWp ? async () => composerRemoteState(againstWp) : async () => composerLocalState(localRoot),
30
38
  title: 'Composer',
31
39
  };
32
40
  }
@@ -37,10 +45,19 @@ export class DiffCommand extends LoopressCommand {
37
45
  this.error('A [PATH] argument cannot be combined with --against: --against compares two environments, not local files.');
38
46
  }
39
47
  const dir = resolveResourceDir(provider.dirKind, this.localConfig, dirOverride);
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;
40
54
  return {
41
- 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),
42
59
  resource: provider.resource,
43
- right: sides.againstWp ? async () => provider.remote(sides.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),
44
61
  title: provider.title,
45
62
  };
46
63
  }
@@ -82,20 +99,49 @@ export class DiffCommand extends LoopressCommand {
82
99
  if (result.drift)
83
100
  drift = true;
84
101
  }
102
+ this.renderHeldBack(checked, sides.leftLabel);
85
103
  const skipped = results.filter((result) => result.skipped);
86
- for (const result of skipped)
87
- this.out(`${c('bold', result.title)} ${c('dim', 'not configured, skipped')}`);
104
+ const unsupported = this.renderSkipped(skipped, resources);
88
105
  this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
89
106
  if (failed)
90
107
  this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
91
108
  else
92
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 ')}`);
93
112
  if (failed)
94
113
  process.exitCode = 2;
95
114
  else if (drift)
96
115
  process.exitCode = 1;
97
116
  return { drift, left: sides.leftLabel, resources, right: sides.rightLabel };
98
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
+ }
99
145
  resolveSides(against) {
100
146
  if (against === this.siteConfig.name) {
101
147
  this.error('--against must be a different environment from the one being compared.');
@@ -113,22 +159,33 @@ export class DiffCommand extends LoopressCommand {
113
159
  // Fetches and compares one target. No output (so targets can run concurrently); `report`
114
160
  // renders `stateDiff` afterwards in a fixed order.
115
161
  async computeResource(target, sides) {
162
+ const empty = { added: [], changed: [], removed: [] };
163
+ if (target.skip)
164
+ return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title, unsupported: target.unsupported };
116
165
  try {
117
- 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));
118
178
  const stateDiff = compareStates(leftState, rightState, { left: sides.leftLabel, right: sides.rightLabel });
119
179
  return {
120
180
  drift: !isEmptyDiff(stateDiff),
121
- 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 }) },
122
182
  resource: target.resource,
123
183
  stateDiff,
124
184
  title: target.title,
125
185
  };
126
186
  }
127
187
  catch (error) {
128
- const { message } = error;
129
- const empty = { added: [], changed: [], removed: [] };
130
- if (target.optional && INTEGRATION_MISSING.test(message))
131
- return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title };
188
+ const message = errorMessage(error);
132
189
  return { drift: false, json: { ...empty, error: message }, resource: target.resource, title: target.title };
133
190
  }
134
191
  }
@@ -143,6 +200,16 @@ export class DiffCommand extends LoopressCommand {
143
200
  const errorLabel = c('red', `error: ${error}`);
144
201
  this.out(`${c('bold', title)} ${errorLabel}`);
145
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
+ }
146
213
  renderSection(title, stateDiff) {
147
214
  if (isEmptyDiff(stateDiff)) {
148
215
  this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
@@ -158,6 +225,24 @@ export class DiffCommand extends LoopressCommand {
158
225
  this.out(indentPatch(change.patch));
159
226
  }
160
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
+ }
161
246
  wpForEnvironment(envName) {
162
247
  const env = configManager.getEnvironment(this.projectId, envName);
163
248
  if (!env) {
@@ -171,7 +256,7 @@ export class DiffCommand extends LoopressCommand {
171
256
  }
172
257
  // Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
173
258
  // only in wording and which provider they target, so the whole body lives here.
174
- // `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).
175
260
  export function resourceDiffCommand(resource, options) {
176
261
  const command = options.command ?? resource;
177
262
  class ResourceDiff extends DiffCommand {
@@ -194,6 +279,19 @@ export function resourceDiffCommand(resource, options) {
194
279
  }
195
280
  return ResourceDiff;
196
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
+ }
197
295
  // Indents an already-formatted change block (a per-field list or a header-less unified diff)
198
296
  // so it sits visually under the `~ id` line.
199
297
  function indentPatch(patch) {
@@ -209,3 +307,23 @@ function sharedFailure(errors) {
209
307
  const distinct = new Set(failures);
210
308
  return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
211
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
+ }
@@ -1,11 +1,10 @@
1
1
  import { createTwoFilesPatch } from 'diff';
2
2
  import microdiff from 'microdiff';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
+ import { isRecord } from './narrow.js';
4
5
  const MAX_VALUE_LENGTH = 200;
5
- function isRecord(value) {
6
- return typeof value === 'object' && value !== null;
7
- }
8
6
  function formatValue(value) {
7
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typed as string, but JSON.stringify returns undefined for `undefined` (a removed field)
9
8
  const text = JSON.stringify(value) ?? String(value);
10
9
  return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text;
11
10
  }
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { extname, join } from 'node:path';
3
+ import { errorMessage } from './narrow.js';
3
4
  import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
4
5
  // Loads every `<extension>` file of a directory through `parse`. One file is read in
5
6
  // isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
@@ -15,7 +16,7 @@ export async function loadFiles(dir, options) {
15
16
  items.push(options.parse(await readFile(filePath, 'utf8'), filePath));
16
17
  }
17
18
  catch (error) {
18
- options.onSkip(`Skipping "${filePath}": ${error.message}`);
19
+ options.onSkip(`Skipping "${filePath}": ${errorMessage(error)}`);
19
20
  }
20
21
  }
21
22
  return items;
@@ -1,6 +1,9 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { basename, extname, join } from 'node:path';
3
+ import {} from '../types/snippet.js';
3
4
  import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
5
+ import { withoutExcluded } from './exclude.js';
6
+ import { errorCode, errorMessage, isRecord } from './narrow.js';
4
7
  const TYPE_BY_EXTENSION = {
5
8
  '.css': 'css',
6
9
  '.html': 'html',
@@ -21,58 +24,51 @@ async function loadSnippet(path, file, onSkip) {
21
24
  content = await readFile(filePath, 'utf8');
22
25
  }
23
26
  catch (error) {
24
- onSkip?.(`Skipping "${filePath}": ${error.message}`);
27
+ onSkip?.(`Skipping "${filePath}": ${errorMessage(error)}`);
25
28
  return undefined;
26
29
  }
27
- let id;
28
- let name;
29
- let description;
30
- let type;
31
- let isActive = false;
32
- let tags = [];
33
- let location = null;
34
- let insertMethod = null;
35
- let priority = 10;
36
- let shortcodeAttributes = [];
30
+ let meta;
37
31
  try {
38
- const metaContent = await readFile(metaPath, 'utf8');
39
- const meta = JSON.parse(metaContent);
40
- id = meta.id;
41
- // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
42
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
43
- name = meta.name || undefined;
44
- // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
45
- description = meta.description;
46
- type = parseType(meta.type) ?? undefined;
47
- isActive = Boolean(meta.active);
48
- tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
49
- location = parseLocation(meta.location);
50
- insertMethod = parseInsertMethod(meta.insertMethod);
51
- priority = meta.priority ?? 10;
52
- shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
32
+ meta = await readSidecar(metaPath);
53
33
  }
54
34
  catch (error) {
55
- if (error.code !== 'ENOENT') {
56
- onSkip?.(`Skipping "${metaPath}": ${error.message}`);
57
- return undefined;
58
- }
35
+ onSkip?.(`Skipping "${metaPath}": ${errorMessage(error)}`);
36
+ return undefined;
59
37
  }
60
- const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
38
+ const type = parseType(meta.type) ?? TYPE_BY_EXTENSION[ext] ?? 'php';
61
39
  return {
62
- active: isActive,
40
+ active: Boolean(meta.active),
63
41
  code: content,
64
- description,
65
- id,
66
- insertMethod: insertMethod ?? 'auto',
67
- location: location ?? defaultLocationForType(resolvedType),
68
- name: name ?? basename(file, ext),
42
+ // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
43
+ description: typeof meta.description === 'string' ? meta.description : undefined,
44
+ id: typeof meta.id === 'number' ? meta.id : undefined,
45
+ insertMethod: parseInsertMethod(meta.insertMethod) ?? 'auto',
46
+ location: parseLocation(meta.location) ?? defaultLocationForType(type),
47
+ // An empty-string name in the sidecar JSON counts as absent too.
48
+ name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : basename(file, ext),
69
49
  path: filePath,
70
- priority,
71
- shortcodeAttributes,
72
- tags,
73
- type: resolvedType,
50
+ priority: typeof meta.priority === 'number' ? meta.priority : 10,
51
+ shortcodeAttributes: Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [],
52
+ tags: Array.isArray(meta.tags) ? meta.tags.map(String) : [],
53
+ type,
74
54
  };
75
55
  }
56
+ // The sidecar's JSON object, or no fields when the snippet has no sidecar (yet).
57
+ async function readSidecar(metaPath) {
58
+ let raw;
59
+ try {
60
+ raw = await readFile(metaPath, 'utf8');
61
+ }
62
+ catch (error) {
63
+ if (errorCode(error) === 'ENOENT')
64
+ return {};
65
+ throw error;
66
+ }
67
+ const meta = JSON.parse(raw);
68
+ if (!isRecord(meta))
69
+ throw new Error('not a JSON object');
70
+ return meta;
71
+ }
76
72
  // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
77
73
  // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
78
74
  // directory the same way. `onSkip` is optional so callers that don't care about per-file
@@ -80,10 +76,10 @@ async function loadSnippet(path, file, onSkip) {
80
76
  export async function loadSnippets(path, onSkip) {
81
77
  let files;
82
78
  try {
83
- files = await readdir(path);
79
+ files = withoutExcluded(path, await readdir(path), (file) => file);
84
80
  }
85
81
  catch (error) {
86
- throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
82
+ throw new Error(`Error loading snippets: ${errorMessage(error)}`, { cause: error });
87
83
  }
88
84
  const snippets = [];
89
85
  for (const file of files) {
@@ -94,7 +94,13 @@ export async function waitForLocalCallback(options) {
94
94
  reject(new Error(options.timeoutMessage));
95
95
  }, timeoutMs);
96
96
  server.listen(0, '127.0.0.1', () => {
97
- const { port } = server.address();
97
+ const address = server.address();
98
+ // Only a pipe or a closed server has no AddressInfo, never a TCP listen that just succeeded.
99
+ if (address === null || typeof address === 'string') {
100
+ reject(new Error('The local callback server has no TCP port'));
101
+ return;
102
+ }
103
+ const { port } = address;
98
104
  const targetUrl = options.buildUrl(`http://${options.callbackHost ?? 'localhost'}:${port}`, state);
99
105
  options.log(options.openingMessage);
100
106
  options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
@@ -0,0 +1,5 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function isOneOf<T extends string>(values: Iterable<T>, value: unknown): value is T;
3
+ export declare function errorMessage(error: unknown): string;
4
+ export declare function causeOf(error: unknown): unknown;
5
+ export declare function errorCode(error: unknown): string | undefined;
@@ -0,0 +1,26 @@
1
+ // Type guards for values that arrive as `unknown`: a caught error, a parsed JSON document.
2
+ // Arrays pass isRecord, same as a bare `typeof value === 'object' && value !== null` check.
3
+ export function isRecord(value) {
4
+ return typeof value === 'object' && value !== null;
5
+ }
6
+ // Membership in a closed list of literals, narrowing `value` to that union.
7
+ export function isOneOf(values, value) {
8
+ for (const candidate of values)
9
+ if (candidate === value)
10
+ return true;
11
+ return false;
12
+ }
13
+ // `.message` of anything thrown: an Error, or an object shaped like one (got and oclif errors
14
+ // are both). Anything else is stringified rather than read as `undefined`.
15
+ export function errorMessage(error) {
16
+ return isRecord(error) && typeof error.message === 'string' ? error.message : String(error);
17
+ }
18
+ // The error a rethrown one wraps (`new Error(message, {cause})`), e.g. the got error behind
19
+ // WpClient's formatted one.
20
+ export function causeOf(error) {
21
+ return isRecord(error) ? error.cause : undefined;
22
+ }
23
+ // Node's errno code ('ENOENT', 'EEXIST', ...) when the thrown value carries one.
24
+ export function errorCode(error) {
25
+ return isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
26
+ }