@loopress/cli 0.34.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 (44) hide show
  1. package/README.md +190 -174
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/form/push.js +3 -2
  4. package/dist/commands/option/list.js +6 -1
  5. package/dist/commands/plugin/audit.d.ts +1 -1
  6. package/dist/commands/plugin/audit.js +45 -23
  7. package/dist/commands/plugin/outdated.d.ts +22 -0
  8. package/dist/commands/plugin/outdated.js +67 -0
  9. package/dist/commands/plugin/push.d.ts +2 -1
  10. package/dist/commands/plugin/push.js +70 -19
  11. package/dist/commands/plugin/update.d.ts +36 -0
  12. package/dist/commands/plugin/update.js +121 -0
  13. package/dist/commands/push.js +12 -1
  14. package/dist/commands/snippet/push.js +3 -2
  15. package/dist/lib/base.d.ts +1 -0
  16. package/dist/lib/base.js +22 -1
  17. package/dist/lib/capabilities.d.ts +5 -0
  18. package/dist/lib/capabilities.js +16 -0
  19. package/dist/lib/load-snippets.js +1 -4
  20. package/dist/lib/push-command.d.ts +22 -0
  21. package/dist/lib/push-command.js +41 -11
  22. package/dist/lib/validate-local.js +4 -4
  23. package/dist/lib/wp-client.d.ts +1 -0
  24. package/dist/lib/wp-client.js +30 -10
  25. package/dist/types/plugin.d.ts +2 -1
  26. package/dist/types/plugin.js +32 -1
  27. package/dist/utils/option-format.d.ts +1 -0
  28. package/dist/utils/option-format.js +14 -1
  29. package/dist/utils/plugins.d.ts +3 -0
  30. package/dist/utils/plugins.js +6 -1
  31. package/dist/utils/rename-retry.d.ts +1 -0
  32. package/dist/utils/rename-retry.js +20 -0
  33. package/dist/utils/version.d.ts +2 -0
  34. package/dist/utils/version.js +21 -0
  35. package/dist/utils/wporg.d.ts +5 -0
  36. package/dist/utils/wporg.js +41 -0
  37. package/oclif.manifest.json +1401 -1301
  38. package/package.json +10 -10
  39. package/dist/commands/api/publish.d.ts +0 -9
  40. package/dist/commands/api/publish.js +0 -45
  41. package/dist/commands/snippet/publish.d.ts +0 -10
  42. package/dist/commands/snippet/publish.js +0 -62
  43. package/dist/lib/resolve-linked-project.d.ts +0 -8
  44. package/dist/lib/resolve-linked-project.js +0 -27
@@ -1,5 +1,5 @@
1
1
  import { Args } from '@oclif/core';
2
- import { readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { basename, dirname, extname, join } from 'node:path';
4
4
  import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
5
5
  import { errorCode, errorMessage, isRecord } from '../../lib/narrow.js';
@@ -10,6 +10,7 @@ import { isNotFoundError } from '../../lib/wp-client.js';
10
10
  import {} from '../../types/snippet.generated.js';
11
11
  import {} from '../../types/snippet.js';
12
12
  import { pluralize } from '../../utils/pluralize.js';
13
+ import { renameWithRetry } from '../../utils/rename-retry.js';
13
14
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
14
15
  import { toSlug } from '../../utils/to-slug.js';
15
16
  export default class Push extends PushCommand {
@@ -89,7 +90,7 @@ export default class Push extends PushCommand {
89
90
  return;
90
91
  const newPath = join(dir, `${canonicalBase}${ext}`);
91
92
  const newMetaPath = join(dir, `${canonicalBase}.json`);
92
- await rename(snippet.path, newPath);
93
+ await renameWithRetry(snippet.path, newPath);
93
94
  await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
94
95
  await rm(oldMetaPath, { force: true });
95
96
  }
@@ -50,6 +50,7 @@ export declare abstract class LoopressCommand extends Command {
50
50
  protected resolveOptionsPath(override?: string): string;
51
51
  protected resolveSnippetsPath(override?: string): string;
52
52
  protected resolveThemeStylesPath(override?: string): string;
53
+ private assertSiteSupportsResource;
53
54
  private pickEnvironment;
54
55
  private resolveEnvironment;
55
56
  }
package/dist/lib/base.js CHANGED
@@ -8,7 +8,8 @@ import {} from '../types/config.js';
8
8
  import { readLocalConfig } from '../utils/loopress-config.js';
9
9
  import { pluralize } from '../utils/pluralize.js';
10
10
  import { resolveResourceDir } from '../utils/resource-dirs.js';
11
- import { getCapabilities } from './capabilities.js';
11
+ import { commandResourceKind, getCapabilities, missingIntegration, unsupportedMessage } from './capabilities.js';
12
+ import { declaredPlugins } from './declared-plugins.js';
12
13
  import { activateExclusions } from './exclude.js';
13
14
  import { isInteractive } from './interactive.js';
14
15
  import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
@@ -104,6 +105,7 @@ export class LoopressCommand extends Command {
104
105
  honors: this.ctor.honorsExclude,
105
106
  });
106
107
  await this.maybeAutoRotate();
108
+ await this.assertSiteSupportsResource();
107
109
  }
108
110
  // Silent, best-effort: a stale app password still works, so a failed rotation attempt
109
111
  // (offline, site down) just tries again on the next command instead of blocking this one.
@@ -218,6 +220,25 @@ export class LoopressCommand extends Command {
218
220
  resolveThemeStylesPath(override) {
219
221
  return resolveResourceDir('themeStyles', this.localConfig, override);
220
222
  }
223
+ // A command on a single kind of resource (`lps hook push`) says what `lps push` says when the
224
+ // site can't hold it, instead of failing on the 404. A site whose capabilities can't be read
225
+ // (too old a plugin, unreachable) lets the command run and report its own error.
226
+ async assertSiteSupportsResource() {
227
+ const kind = commandResourceKind(this.id);
228
+ if (!kind)
229
+ return;
230
+ let missing;
231
+ try {
232
+ missing = missingIntegration(kind, await this.capabilities());
233
+ }
234
+ catch {
235
+ return;
236
+ }
237
+ if (!missing)
238
+ return;
239
+ const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
240
+ this.error(unsupportedMessage(missing, this.siteConfig.name, declared));
241
+ }
221
242
  pickEnvironment(project, envName) {
222
243
  const env = project.environments[envName];
223
244
  if (!env) {
@@ -36,6 +36,11 @@ export declare function integrationFix(missing: MissingIntegration, declared: {
36
36
  fromComposer: boolean;
37
37
  plugins: string[];
38
38
  }): string | undefined;
39
+ export declare function commandResourceKind(commandId: string | undefined): ResourceDirKind | undefined;
40
+ export declare function unsupportedMessage(missing: MissingIntegration, site: string, declared: {
41
+ fromComposer: boolean;
42
+ plugins: string[];
43
+ }): string;
39
44
  export type ThemeActivation = {
40
45
  active: string;
41
46
  changed: boolean;
@@ -78,6 +78,22 @@ export function integrationFix(missing, declared) {
78
78
  const where = declared.fromComposer ? `wpackagist-plugin/${first} in composer.json` : `"${first}" in loopress.json plugins`;
79
79
  return `Declare ${first}${or} (${where}), then activate it: ${activate(first)}`;
80
80
  }
81
+ // The command topics that act on one kind of resource the site may be unable to hold
82
+ // (`lps hook push`, `lps acf list`...), so they can say so instead of surfacing the 404. `publish`
83
+ // goes through the console, not the site. `scf` is the alias of `acf`.
84
+ const TOPIC_KINDS = { acf: 'acf', api: 'api', block: 'block', form: 'form', hook: 'hooks', page: 'page', pattern: 'pattern', scf: 'acf', snippet: 'snippets' };
85
+ export function commandResourceKind(commandId) {
86
+ const [topic, action] = (commandId ?? '').split(':', 2);
87
+ if (!topic || !action || action === 'publish')
88
+ return undefined;
89
+ return TOPIC_KINDS[topic];
90
+ }
91
+ // "Not supported by <site>: <reason>. <fix>.", for the single-resource commands.
92
+ export function unsupportedMessage(missing, site, declared) {
93
+ const fix = integrationFix(missing, declared);
94
+ const fixSentence = fix ? ` ${fix}.` : '';
95
+ return `Not supported by ${site}: ${missing.reason}.${fixSentence}`;
96
+ }
81
97
  export async function activateTheme(wp, stylesheet) {
82
98
  const activation = await wp.post(THEME_ACTIVATE_ENDPOINT, { stylesheet });
83
99
  cache.delete(wp);
@@ -69,10 +69,7 @@ async function readSidecar(metaPath) {
69
69
  throw new Error('not a JSON object');
70
70
  return meta;
71
71
  }
72
- // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
73
- // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
74
- // directory the same way. `onSkip` is optional so callers that don't care about per-file
75
- // 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.
76
73
  export async function loadSnippets(path, onSkip) {
77
74
  let files;
78
75
  try {
@@ -2,6 +2,26 @@ import { LoopressCommand } from './base.js';
2
2
  import { type ResourceState } from './diff-state.js';
3
3
  import { type ScannedItem } from './environment-urls.js';
4
4
  import { type ResourceStateProvider } from './resource-state.js';
5
+ export type DeploymentStep = {
6
+ command: string;
7
+ status: 'failed' | 'pushed' | 'skipped';
8
+ };
9
+ type DeploymentPlugins = Array<{
10
+ active: boolean;
11
+ slug: string;
12
+ version: string;
13
+ }>;
14
+ export declare function startDeploymentCollector(): {
15
+ plugins?: DeploymentPlugins;
16
+ };
17
+ export declare function stopDeploymentCollector(): void;
18
+ export declare function postDeployment(body: {
19
+ command: string | undefined;
20
+ plugins?: DeploymentPlugins;
21
+ status: 'failure' | 'success';
22
+ steps?: DeploymentStep[];
23
+ url: string;
24
+ }): Promise<void>;
5
25
  export type PushChange = {
6
26
  id: string;
7
27
  status: 'changed' | 'created' | 'removed' | 'unchanged';
@@ -46,4 +66,6 @@ export declare abstract class PushCommand extends LoopressCommand {
46
66
  protected warnEnvironmentUrls(items: ScannedItem[]): void;
47
67
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
48
68
  private guardOutsideEdits;
69
+ private pushedPlugins;
49
70
  }
71
+ export {};
@@ -7,6 +7,7 @@ import { resolve } from 'node:path';
7
7
  import { isDeepStrictEqual } from 'node:util';
8
8
  import { authManager } from '../config/auth.manager.js';
9
9
  import {} from '../types/config.js';
10
+ import { pinActive, pinVersion } from '../types/plugin.js';
10
11
  import { API_URL } from './api-client.js';
11
12
  import { LoopressCommand } from './base.js';
12
13
  import {} from './diff-state.js';
@@ -33,6 +34,32 @@ function gitState() {
33
34
  return {};
34
35
  }
35
36
  }
37
+ // Set while `lps push` runs its steps in process: they hand over what they would have posted
38
+ // instead of posting, so the run sends one record (and waits at most one timeout).
39
+ let deploymentCollector;
40
+ export function startDeploymentCollector() {
41
+ deploymentCollector = {};
42
+ return deploymentCollector;
43
+ }
44
+ export function stopDeploymentCollector() {
45
+ deploymentCollector = undefined;
46
+ }
47
+ // Best-effort: recording must never interrupt the push flow.
48
+ export async function postDeployment(body) {
49
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
50
+ if (!token)
51
+ return;
52
+ try {
53
+ await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
54
+ headers: { Authorization: `Bearer ${token}` },
55
+ json: { ...body, ...gitState() },
56
+ timeout: { request: 3000 },
57
+ }));
58
+ }
59
+ catch {
60
+ // non-blocking
61
+ }
62
+ }
36
63
  export function pushChanges(local, remote, options = {}) {
37
64
  const changes = [...local.keys()]
38
65
  .filter((id) => options.writes?.(id) ?? true)
@@ -158,19 +185,14 @@ export class PushCommand extends LoopressCommand {
158
185
  }
159
186
  }
160
187
  async recordDeployment(status) {
161
- const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
162
- if (!token)
188
+ // Inside `lps push`, the steps only report the plugin inventory: the run posts one record.
189
+ if (deploymentCollector) {
190
+ const { plugins } = this.pushedPlugins(status);
191
+ if (plugins)
192
+ deploymentCollector.plugins = plugins;
163
193
  return;
164
- try {
165
- await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
166
- headers: { Authorization: `Bearer ${token}` },
167
- json: { status, url: this.siteConfig.url, command: this.id, ...gitState() },
168
- timeout: { request: 3000 },
169
- }));
170
- }
171
- catch {
172
- // non-blocking: recording must never interrupt the push flow
173
194
  }
195
+ await postDeployment({ command: this.id, status, url: this.siteConfig.url, ...this.pushedPlugins(status) });
174
196
  }
175
197
  async recordSuccess() {
176
198
  if (this.dryRun)
@@ -309,6 +331,14 @@ export class PushCommand extends LoopressCommand {
309
331
  if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
310
332
  this.error('Aborted.');
311
333
  }
334
+ // The plugin inventory of a landed `plugin:push` (a full `lps push` runs it as a step), read from
335
+ // loopress.json: slug, pinned version and whether it must be active. Other commands send none.
336
+ pushedPlugins(status) {
337
+ if (status !== 'success' || this.id !== 'plugin:push')
338
+ return {};
339
+ const plugins = Object.entries(this.localConfig.plugins ?? {}).map(([slug, pin]) => ({ active: pinActive(pin), slug, version: pinVersion(pin) }));
340
+ return { plugins };
341
+ }
312
342
  }
313
343
  function sameEntry(left, right, id) {
314
344
  return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
@@ -2,7 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { configManager } from '../config/project-config.manager.js';
5
- import { PLUGIN_PIN } from '../types/plugin.js';
5
+ import { pinProblem } from '../types/plugin.js';
6
6
  import { readLocalBlocks } from '../utils/block-format.js';
7
7
  import { COMPOSER_JSON } from '../utils/composer.js';
8
8
  import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
@@ -259,9 +259,9 @@ function checkLoopressJson(localConfig, problems) {
259
259
  }
260
260
  for (const [slug, version] of Object.entries(manifest.data)) {
261
261
  // The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
262
- if (!PLUGIN_PIN.safeParse(version).success) {
263
- problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
264
- }
262
+ const message = pinProblem(slug, version);
263
+ if (message)
264
+ problems.push({ file: 'loopress.json', message });
265
265
  }
266
266
  }
267
267
  async function checkSnippets(dir, problems) {
@@ -24,6 +24,7 @@ type RequestOptions = {
24
24
  };
25
25
  export declare function isNotFoundError(error: unknown): boolean;
26
26
  export declare function isForbiddenError(error: unknown): boolean;
27
+ export declare function isFirewallBlock(error: unknown): boolean;
27
28
  export declare function isApplicationPasswordsDisabled(error: unknown): boolean;
28
29
  export declare function isApplicative404(error: unknown, marker: string): boolean;
29
30
  export declare function isConflictCode(error: unknown, code: string): boolean;
@@ -100,6 +100,17 @@ export function isNotFoundError(error) {
100
100
  export function isForbiddenError(error) {
101
101
  return responseOf(causeOf(error)).statusCode === 403;
102
102
  }
103
+ // A 403 whose body is not a WordPress JSON error (`code`/`message`) nor a Loopress `error`
104
+ // refusal: the answer of a firewall (ModSecurity, Imunify360, Wordfence...) in front of PHP.
105
+ export function isFirewallBlock(error) {
106
+ return isFirewallResponse(responseOf(causeOf(error)));
107
+ }
108
+ function isFirewallResponse({ body, statusCode }) {
109
+ if (statusCode !== 403)
110
+ return false;
111
+ const parsed = parseBody(body);
112
+ return typeof parsed.code !== 'string' && typeof parsed.message !== 'string' && typeof parsed.error !== 'string';
113
+ }
103
114
  // WordPress core answers the authenticated request with this `code` when Application
104
115
  // Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
105
116
  // filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
@@ -152,16 +163,8 @@ export function parseBody(body) {
152
163
  }
153
164
  export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
154
165
  const { body, statusCode: status } = responseOf(error);
155
- if (status === 401 || status === 403) {
156
- // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
157
- // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
158
- // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
159
- // auth failures use `{code, message}` instead and keep the credentials hint.
160
- const refusal = extractLoopressError(body);
161
- if (refusal)
162
- return `Request refused (${status}) on ${url}: ${refusal}`;
163
- return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
164
- }
166
+ if (status === 401 || status === 403)
167
+ return formatAuthError(status, body, url);
165
168
  if (status === 404) {
166
169
  const reason = extractServerErrorMessage(body);
167
170
  return reason
@@ -186,6 +189,23 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
186
189
  const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
187
190
  return `Request to ${url} failed: ${message || (code ?? String(error))}`;
188
191
  }
192
+ function formatAuthError(status, body, url) {
193
+ if (isFirewallResponse({ body, statusCode: status }))
194
+ return firewallMessage(url);
195
+ // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
196
+ // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
197
+ // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
198
+ // auth failures use `{code, message}` instead and keep the credentials hint.
199
+ const refusal = extractLoopressError(body);
200
+ if (refusal)
201
+ return `Request refused (${status}) on ${url}: ${refusal}`;
202
+ return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
203
+ }
204
+ // A 403 that is not a WordPress JSON error never came from WordPress: a web application
205
+ // firewall answered before PHP ran, and no credential change will fix it.
206
+ function firewallMessage(url) {
207
+ return `Request blocked (403) on ${url}: the response is not a WordPress error, so a firewall or security plugin (ModSecurity, Imunify360, Wordfence) probably blocked the request. Allow-list the request or your IP in the firewall, then retry.`;
208
+ }
189
209
  const NETWORK_ERROR_HINTS = {
190
210
  ECONNREFUSED: 'connection refused, the site may be down',
191
211
  ECONNRESET: 'connection reset by the server',
@@ -16,7 +16,8 @@ export type WpNativePlugin = {
16
16
  export declare const PLUGIN_PIN: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
17
17
  active: z.ZodExactOptional<z.ZodBoolean>;
18
18
  version: z.ZodString;
19
- }, z.core.$strip>]>;
19
+ }, z.core.$strict>]>;
20
+ export declare function pinProblem(slug: string, pin: unknown): string | undefined;
20
21
  export type PluginPin = z.infer<typeof PLUGIN_PIN>;
21
22
  export type PluginManifest = Record<string, PluginPin>;
22
23
  export declare const pinVersion: (pin: PluginPin) => string;
@@ -1,7 +1,38 @@
1
1
  import { z } from 'zod';
2
2
  // A bare version string means "installed and active"; the object form records a plugin that
3
3
  // must stay installed but inactive.
4
- export const PLUGIN_PIN = z.union([z.string(), z.object({ active: z.boolean().exactOptional(), version: z.string() })]);
4
+ // Strict: an unknown key is a typo (`activate` for `active`) that would otherwise be dropped.
5
+ export const PLUGIN_PIN = z.union([z.string(), z.strictObject({ active: z.boolean().exactOptional(), version: z.string() })]);
6
+ const PIN_KEYS = ['active', 'version'];
7
+ // Levenshtein distance, enough to tell a typo of a pin key ("activate", "verison") from another key.
8
+ function editDistance(a, b) {
9
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
10
+ for (let i = 1; i <= a.length; i++) {
11
+ const current = [i];
12
+ for (let j = 1; j <= b.length; j++) {
13
+ const substitution = (previous[j - 1] ?? 0) + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1);
14
+ current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution));
15
+ }
16
+ previous = current;
17
+ }
18
+ return previous[b.length] ?? 0;
19
+ }
20
+ // Why a loopress.json plugin pin is invalid, or undefined when it is fine.
21
+ export function pinProblem(slug, pin) {
22
+ if (PLUGIN_PIN.safeParse(pin).success)
23
+ return undefined;
24
+ if (pin !== null && typeof pin === 'object' && !Array.isArray(pin)) {
25
+ const unknown = Object.keys(pin).filter((key) => !PIN_KEYS.includes(key));
26
+ if (unknown.length > 0) {
27
+ const hints = unknown.map((key) => {
28
+ const near = PIN_KEYS.find((known) => editDistance(key.toLowerCase(), known) <= 2);
29
+ return near ? `"${key}" (did you mean "${near}"?)` : `"${key}"`;
30
+ });
31
+ return `plugin "${slug}" has unknown key(s) ${hints.join(', ')}: a pin is a version string or {version, active}`;
32
+ }
33
+ }
34
+ return `plugin "${slug}" must map to a version string or {version, active}`;
35
+ }
5
36
  export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
6
37
  // No pin at all (a slug loopress.json doesn't list) keeps the default: active.
7
38
  export const pinActive = (pin) => typeof pin === 'string' || pin?.active !== false;
@@ -26,6 +26,7 @@ export type ListedOption = {
26
26
  };
27
27
  export declare function isReservedOptionName(name: string): boolean;
28
28
  export declare function defaultReadonlyFor(name: string): boolean;
29
+ export declare function isWindowsReservedName(name: string): boolean;
29
30
  export declare function optionFileName(name: string): string;
30
31
  export declare function partitionByReadonly(tracked: LocalOption[]): {
31
32
  skipped: LocalOption[];
@@ -38,7 +38,17 @@ export function isReservedOptionName(name) {
38
38
  export function defaultReadonlyFor(name) {
39
39
  return READONLY_BY_DEFAULT_OPTION_NAMES.includes(name);
40
40
  }
41
+ // Windows refuses these device names as a file name, with or without an extension (`NUL.json`).
42
+ const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
43
+ export function isWindowsReservedName(name) {
44
+ return WINDOWS_RESERVED_NAME.test(name.split('.', 1)[0]?.trimEnd() ?? '');
45
+ }
46
+ function reservedNameMessage(name) {
47
+ return `Option name "${name}" is a reserved Windows file name (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9) and cannot be tracked as ${name}.json`;
48
+ }
41
49
  export function optionFileName(name) {
50
+ if (isWindowsReservedName(name))
51
+ throw new Error(reservedNameMessage(name));
42
52
  return `${name}.json`;
43
53
  }
44
54
  // Splits tracked options into what `push` may write and what it must skip: readonly is a local
@@ -54,7 +64,10 @@ const REFS_ERROR = '"refs" must map a value path to a post type, e.g. {"purchase
54
64
  // Each check's message is the error thrown for it, the first failing one wins. Loose: unknown
55
65
  // keys (`readonly`, future ones) pass through untouched.
56
66
  const LOCAL_OPTION = z.looseObject({
57
- name: z.string('missing a "name" string').min(1, 'missing a "name" string'),
67
+ name: z
68
+ .string('missing a "name" string')
69
+ .min(1, 'missing a "name" string')
70
+ .refine((name) => !isWindowsReservedName(name), { error: (issue) => reservedNameMessage(String(issue.input)) }),
58
71
  autoload: z.string('missing an "autoload" string'),
59
72
  // `value` itself is deliberately untyped (a WP option can hold any JSON-safe value, including
60
73
  // null); only the key's presence is checked, so a hand-edited file that dropped it entirely is
@@ -37,5 +37,8 @@ export type MergeResult<P extends PluginPin = PluginPin> = {
37
37
  export declare function describePin(pin: PluginPin): string;
38
38
  export declare function mergePluginManifest<P extends PluginPin>(existing: Record<string, P>, incoming: Record<string, P>): MergeResult<P>;
39
39
  export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
40
+ export declare function preferredCopy<P extends {
41
+ file: string;
42
+ }>(copies: P[], slug: string): P | undefined;
40
43
  export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
41
44
  export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;
@@ -46,12 +46,17 @@ export function parseInstalledPlugins(raw) {
46
46
  }))
47
47
  .filter((plugin) => !LOOPRESS_PLUGIN_SLUGS.has(plugin.slug));
48
48
  }
49
+ // Composer installs a plugin into `<slug>/`; a site can also hold another copy under the same
50
+ // slug (core's `hello.php` is Hello Dolly). Prefer the one in the slug's folder.
51
+ export function preferredCopy(copies, slug) {
52
+ return copies.find((p) => p.file.startsWith(`${slug}/`)) ?? copies[0];
53
+ }
49
54
  export function diffPlugins(manifest, installed,
50
55
  // Slugs Loopress currently manages, from the local composer.lock's wpackagist-plugin/*
51
56
  // entries. Empty for a loopress.json-only project that doesn't keep a lockfile in the repo:
52
57
  // `toRemove` then stays empty (removals still happen server-side, they just aren't previewed).
53
58
  managedSlugs = new Set()) {
54
- const installedMap = new Map(installed.map((p) => [p.slug, p]));
59
+ const installedMap = new Map(installed.map((p) => [p.slug, preferredCopy(installed.filter((q) => q.slug === p.slug), p.slug) ?? p]));
55
60
  const toInstall = [];
56
61
  const toPin = [];
57
62
  const toActivate = [];
@@ -0,0 +1 @@
1
+ export declare function renameWithRetry(from: string, to: string, attempts?: number, baseDelayMs?: number): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { rename } from 'node:fs/promises';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ const RETRYABLE_CODES = new Set(['EBUSY', 'EPERM']);
4
+ // On Windows, an editor, an antivirus or the search indexer can briefly hold a file open and make
5
+ // rename() fail with EPERM/EBUSY. Those clear within milliseconds, so retry a few times with a
6
+ // growing delay before giving up. Other errors (ENOENT, EEXIST...) are thrown right away.
7
+ export async function renameWithRetry(from, to, attempts = 5, baseDelayMs = 50) {
8
+ for (let attempt = 1;; attempt++) {
9
+ try {
10
+ await rename(from, to);
11
+ return;
12
+ }
13
+ catch (error) {
14
+ const code = error instanceof Error && 'code' in error ? String(error.code) : '';
15
+ if (!RETRYABLE_CODES.has(code) || attempt >= attempts)
16
+ throw error;
17
+ await sleep(baseDelayMs * attempt);
18
+ }
19
+ }
20
+ }
@@ -1,2 +1,4 @@
1
1
  export declare function isExactVersion(value: string): boolean;
2
2
  export declare function compareVersions(a: string, b: string): null | number;
3
+ export type VersionJump = 'major' | 'minor' | 'patch';
4
+ export declare function versionJump(from: string, to: string): null | VersionJump;
@@ -19,3 +19,24 @@ export function isExactVersion(value) {
19
19
  export function compareVersions(a, b) {
20
20
  return validate(a) && validate(b) ? cmp(a, b) : null;
21
21
  }
22
+ // How far `from` is behind `to`: the first numeric segment that differs, major, minor, then
23
+ // anything finer is a patch. Null when `from` is not behind `to` or either isn't comparable.
24
+ export function versionJump(from, to) {
25
+ const order = compareVersions(from, to);
26
+ if (order === null || order >= 0)
27
+ return null;
28
+ const a = numericSegments(from);
29
+ const b = numericSegments(to);
30
+ if (a[0] !== b[0])
31
+ return 'major';
32
+ if ((a[1] ?? 0) !== (b[1] ?? 0))
33
+ return 'minor';
34
+ return 'patch';
35
+ }
36
+ function numericSegments(version) {
37
+ const core = (version.split('-', 1)[0] ?? version).replace(/^v/i, '');
38
+ return core.split('.').map((part) => {
39
+ const n = Number(part);
40
+ return Number.isNaN(n) ? 0 : Math.trunc(n);
41
+ });
42
+ }
@@ -0,0 +1,5 @@
1
+ export declare const WPORG_INFO = "https://api.wordpress.org/plugins/info/1.2/";
2
+ export declare function requestPluginInfo(slug: string, { versions }?: {
3
+ versions?: boolean;
4
+ }): Promise<null | Record<string, unknown>>;
5
+ export declare function fetchPluginInfo(slug: string): Promise<null | Record<string, unknown>>;
@@ -0,0 +1,41 @@
1
+ import got from 'got';
2
+ export const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
3
+ // The WordPress.org plugin_information answer for one slug, or null when the plugin isn't
4
+ // listed there (zip, premium), or {error: 'closed'} for a closed one. Throws when the API can't be reached or answers with an error.
5
+ // `versions` adds every released version (`versions`: version -> download URL) to the answer.
6
+ export async function requestPluginInfo(slug, { versions = false } = {}) {
7
+ try {
8
+ const body = await got(WPORG_INFO, {
9
+ searchParams: {
10
+ action: 'plugin_information',
11
+ 'request[slug]': slug,
12
+ ...(versions && { 'request[fields][versions]': '1' }),
13
+ },
14
+ timeout: { request: 10_000 },
15
+ }).json();
16
+ // A removed plugin answers {error: 'closed'}: kept so callers can tell it from an unlisted one.
17
+ if (typeof body === 'object' && 'error' in body && body.error === 'closed')
18
+ return body;
19
+ return typeof body === 'object' && !('error' in body) ? body : null;
20
+ }
21
+ catch (error) {
22
+ if (isNotFound(error))
23
+ return null;
24
+ throw error;
25
+ }
26
+ }
27
+ // Same lookup, but an unreachable API is reported as null too.
28
+ export async function fetchPluginInfo(slug) {
29
+ try {
30
+ return await requestPluginInfo(slug);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function isNotFound(error) {
37
+ if (typeof error !== 'object' || error === null || !('response' in error))
38
+ return false;
39
+ const { response } = error;
40
+ return typeof response === 'object' && response !== null && 'statusCode' in response && response.statusCode === 404;
41
+ }