@loopress/cli 0.22.0 → 0.24.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 (117) hide show
  1. package/README.md +794 -85
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/app/list.d.ts +17 -0
  13. package/dist/commands/app/list.js +35 -0
  14. package/dist/commands/{page → app}/pull.d.ts +7 -5
  15. package/dist/commands/app/pull.js +94 -0
  16. package/dist/commands/app/push.d.ts +28 -0
  17. package/dist/commands/app/push.js +130 -0
  18. package/dist/commands/app/remove.d.ts +20 -0
  19. package/dist/commands/app/remove.js +42 -0
  20. package/dist/commands/composer/pull.js +6 -16
  21. package/dist/commands/composer/push.d.ts +1 -0
  22. package/dist/commands/composer/push.js +43 -19
  23. package/dist/commands/dev.js +1 -1
  24. package/dist/commands/diff.d.ts +13 -0
  25. package/dist/commands/diff.js +42 -0
  26. package/dist/commands/form/diff.d.ts +2 -0
  27. package/dist/commands/form/diff.js +5 -0
  28. package/dist/commands/hook/diff.d.ts +2 -0
  29. package/dist/commands/hook/diff.js +5 -0
  30. package/dist/commands/hook/list.d.ts +2 -0
  31. package/dist/commands/hook/list.js +2 -0
  32. package/dist/commands/hook/pull.d.ts +2 -0
  33. package/dist/commands/hook/pull.js +2 -0
  34. package/dist/commands/hook/push.d.ts +2 -0
  35. package/dist/commands/hook/push.js +2 -0
  36. package/dist/commands/init.js +2 -1
  37. package/dist/commands/option/add.d.ts +15 -0
  38. package/dist/commands/option/add.js +47 -0
  39. package/dist/commands/option/diff.d.ts +2 -0
  40. package/dist/commands/option/diff.js +5 -0
  41. package/dist/commands/{page → option}/list.d.ts +5 -1
  42. package/dist/commands/option/list.js +151 -0
  43. package/dist/commands/option/pull.d.ts +16 -0
  44. package/dist/commands/option/pull.js +75 -0
  45. package/dist/commands/{page → option}/push.d.ts +3 -8
  46. package/dist/commands/option/push.js +66 -0
  47. package/dist/commands/option/remove.d.ts +22 -0
  48. package/dist/commands/option/remove.js +69 -0
  49. package/dist/commands/plugin/add.d.ts +1 -0
  50. package/dist/commands/plugin/add.js +18 -8
  51. package/dist/commands/plugin/audit.d.ts +30 -0
  52. package/dist/commands/plugin/audit.js +138 -0
  53. package/dist/commands/plugin/pull.d.ts +1 -2
  54. package/dist/commands/plugin/pull.js +11 -11
  55. package/dist/commands/plugin/push.d.ts +13 -8
  56. package/dist/commands/plugin/push.js +161 -67
  57. package/dist/commands/plugin/status.d.ts +22 -0
  58. package/dist/commands/plugin/status.js +65 -0
  59. package/dist/commands/pull.js +3 -2
  60. package/dist/commands/push.js +4 -3
  61. package/dist/commands/seo/diff.d.ts +2 -0
  62. package/dist/commands/seo/diff.js +5 -0
  63. package/dist/commands/snippet/diff.d.ts +2 -0
  64. package/dist/commands/snippet/diff.js +5 -0
  65. package/dist/commands/theme/add.d.ts +13 -0
  66. package/dist/commands/theme/add.js +35 -0
  67. package/dist/commands/theme/pull.d.ts +21 -0
  68. package/dist/commands/theme/pull.js +37 -0
  69. package/dist/commands/theme/push.d.ts +22 -0
  70. package/dist/commands/theme/push.js +123 -0
  71. package/dist/commands/theme/status.d.ts +20 -0
  72. package/dist/commands/theme/status.js +51 -0
  73. package/dist/lib/app-manifest.d.ts +36 -0
  74. package/dist/lib/app-manifest.js +162 -0
  75. package/dist/lib/base.d.ts +3 -1
  76. package/dist/lib/base.js +14 -19
  77. package/dist/lib/dev-watch.d.ts +1 -1
  78. package/dist/lib/dev-watch.js +1 -2
  79. package/dist/lib/diff-command.d.ts +47 -0
  80. package/dist/lib/diff-command.js +180 -0
  81. package/dist/lib/diff-state.d.ts +22 -0
  82. package/dist/lib/diff-state.js +88 -0
  83. package/dist/lib/find-orphaned-files.js +1 -1
  84. package/dist/lib/interactive.d.ts +1 -0
  85. package/dist/lib/interactive.js +10 -0
  86. package/dist/lib/php-files-command.d.ts +54 -0
  87. package/dist/lib/php-files-command.js +216 -0
  88. package/dist/lib/put-or-create.js +1 -1
  89. package/dist/lib/readdir-tolerant.d.ts +1 -0
  90. package/dist/lib/readdir-tolerant.js +3 -1
  91. package/dist/lib/resource-state.d.ts +14 -0
  92. package/dist/lib/resource-state.js +361 -0
  93. package/dist/lib/validate-local.js +15 -7
  94. package/dist/lib/wp-client.d.ts +5 -0
  95. package/dist/lib/wp-client.js +56 -0
  96. package/dist/types/plugin.d.ts +1 -0
  97. package/dist/types/project-config.generated.d.ts +19 -5
  98. package/dist/utils/option-format.d.ts +31 -0
  99. package/dist/utils/option-format.js +51 -0
  100. package/dist/utils/plugin-sync.d.ts +26 -0
  101. package/dist/utils/plugin-sync.js +31 -0
  102. package/dist/utils/plugins.d.ts +15 -2
  103. package/dist/utils/plugins.js +57 -12
  104. package/dist/utils/resource-dirs.d.ts +13 -0
  105. package/dist/utils/resource-dirs.js +34 -0
  106. package/dist/utils/themes.d.ts +30 -0
  107. package/dist/utils/themes.js +29 -0
  108. package/dist/utils/version.d.ts +2 -0
  109. package/dist/utils/version.js +21 -0
  110. package/oclif.manifest.json +1897 -244
  111. package/package.json +26 -19
  112. package/schemas/project-config.schema.json +32 -9
  113. package/dist/commands/page/list.js +0 -19
  114. package/dist/commands/page/pull.js +0 -57
  115. package/dist/commands/page/push.js +0 -132
  116. package/dist/utils/page-format.d.ts +0 -7
  117. package/dist/utils/page-format.js +0 -78
@@ -0,0 +1,75 @@
1
+ import { Args } from '@oclif/core';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { loadFiles } from '../../lib/load-files.js';
6
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
+ import { optionEndpoint, optionFileName, parseLocalOption } from '../../utils/option-format.js';
8
+ import { pluralize } from '../../utils/pluralize.js';
9
+ export default class Pull extends LoopressCommand {
10
+ static args = {
11
+ path: Args.string({ description: 'Path to options directory (overrides project config)' }),
12
+ };
13
+ static description = 'Refresh locally tracked options from WordPress. Options not yet tracked are never pulled, run `lps option add <name>` for those first.';
14
+ static enableJsonFlag = true;
15
+ static examples = ['$ lps option pull'];
16
+ static flags = {
17
+ ...LoopressCommand.dryRunFlag,
18
+ ...LoopressCommand.yesFlag,
19
+ };
20
+ async run() {
21
+ const { args } = await this.parse(Pull);
22
+ const dir = this.resolveOptionsPath(args.path);
23
+ const { url } = this.siteConfig;
24
+ this.log(`Pulling tracked options from ${url}`);
25
+ this.log(`Options path: ${dir}`);
26
+ const tracked = await loadFiles(dir, {
27
+ extension: '.json',
28
+ onSkip: (message) => {
29
+ this.warn(message);
30
+ },
31
+ parse: parseLocalOption,
32
+ });
33
+ if (tracked.length === 0) {
34
+ this.log('Nothing tracked locally. Run `lps option add <name>` first.');
35
+ return [];
36
+ }
37
+ if (this.dryRun) {
38
+ this.log(`[dry-run] Would refresh ${pluralize(tracked.length, 'option')}: ${tracked.map((option) => option.name).join(', ')}`);
39
+ return tracked;
40
+ }
41
+ const refreshed = [];
42
+ const orphans = [];
43
+ for (const option of tracked) {
44
+ const result = await this.pullOption(dir, option);
45
+ if (result === null) {
46
+ orphans.push(optionFileName(option.name));
47
+ }
48
+ else {
49
+ refreshed.push(result);
50
+ this.log(`Pulled: ${option.name}`);
51
+ }
52
+ }
53
+ await this.removeOrphanedFiles(dir, orphans, 'no longer exist on WordPress');
54
+ this.log(`Pulled ${pluralize(refreshed.length, 'option')}`);
55
+ return refreshed;
56
+ }
57
+ // Returns the refreshed option (and writes it to disk), or null when the tracked name is a
58
+ // 404 on this environment: absent, not a failure (see the orphan reconciliation in run()).
59
+ // readonly is a local policy flag with no WordPress counterpart, always preserved from the
60
+ // existing file rather than overwritten by the pulled value.
61
+ async pullOption(dir, option) {
62
+ let remote;
63
+ try {
64
+ remote = await this.wp.get(optionEndpoint(option.name));
65
+ }
66
+ catch (error) {
67
+ if (isNotFoundError(error))
68
+ return null;
69
+ throw error;
70
+ }
71
+ const local = { ...remote, readonly: option.readonly };
72
+ await writeFile(join(dir, optionFileName(option.name)), JSON.stringify(local, null, 2) + '\n');
73
+ return local;
74
+ }
75
+ }
@@ -1,10 +1,7 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
- type PushedPage = {
3
- id: null | number;
4
- title: string;
5
- };
6
2
  type PushResult = {
7
- pushed: PushedPage[];
3
+ pushed: string[];
4
+ skipped: string[];
8
5
  status: 'dry-run' | 'success';
9
6
  };
10
7
  export default class Push extends PushCommand {
@@ -19,8 +16,6 @@ export default class Push extends PushCommand {
19
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
17
  };
21
18
  run(): Promise<PushResult>;
22
- private ensureCanonicalFilename;
23
- private loadFiles;
24
- private pushPage;
19
+ private pushOption;
25
20
  }
26
21
  export {};
@@ -0,0 +1,66 @@
1
+ import { Args } from '@oclif/core';
2
+ import { loadFiles } from '../../lib/load-files.js';
3
+ import { PushCommand } from '../../lib/push-command.js';
4
+ import { optionEndpoint, parseLocalOption, partitionByReadonly } from '../../utils/option-format.js';
5
+ import { pluralize } from '../../utils/pluralize.js';
6
+ export default class Push extends PushCommand {
7
+ static args = {
8
+ path: Args.string({ description: 'Path to options directory (overrides project config)' }),
9
+ };
10
+ static description = 'Push locally tracked, non-readonly options to WordPress (upsert only, never deletes an untracked option). ' +
11
+ 'Options marked "readonly": true in their local file are skipped, edit the file to override.';
12
+ static enableJsonFlag = true;
13
+ static examples = ['$ lps option push'];
14
+ static flags = {
15
+ ...PushCommand.dryRunFlag,
16
+ ...PushCommand.yesFlag,
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(Push);
20
+ const dir = this.resolveOptionsPath(args.path);
21
+ const { url } = this.siteConfig;
22
+ this.log(`Pushing tracked options to ${url}`);
23
+ this.log(`Options path: ${dir}`);
24
+ const tracked = await loadFiles(dir, {
25
+ extension: '.json',
26
+ onSkip: (message) => {
27
+ this.warn(message);
28
+ },
29
+ parse: parseLocalOption,
30
+ });
31
+ const { skipped, writable } = partitionByReadonly(tracked);
32
+ if (skipped.length > 0) {
33
+ this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
34
+ }
35
+ this.log(`Found ${pluralize(writable.length, 'option')} to push`);
36
+ const pushed = [];
37
+ await this.runPushTasks(writable, (option) => option.name, async (option, task) => {
38
+ await this.pushOption(option, task);
39
+ pushed.push(option.name);
40
+ });
41
+ if (this.failedCount > 0) {
42
+ this.error(`${pluralize(this.failedCount, 'option')} failed to push.`);
43
+ }
44
+ const skippedNames = skipped.map((option) => option.name);
45
+ if (this.dryRun)
46
+ return { pushed, skipped: skippedNames, status: 'dry-run' };
47
+ await this.recordSuccess();
48
+ this.log('All options pushed.');
49
+ return { pushed, skipped: skippedNames, status: 'success' };
50
+ }
51
+ async pushOption(option, task) {
52
+ if (this.dryRun) {
53
+ if (task)
54
+ task.output = `[dry-run] Would push: ${option.name}`;
55
+ return;
56
+ }
57
+ try {
58
+ await this.wp.put(optionEndpoint(option.name), { autoload: option.autoload, value: option.value });
59
+ if (task)
60
+ task.output = `Pushed: ${option.name}`;
61
+ }
62
+ catch (error) {
63
+ this.reportTaskFailure(`Failed to push ${option.name}: ${error.message}`, error, task);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type RemoveResult = {
3
+ deletedRemote: boolean;
4
+ name: string;
5
+ status: 'aborted' | 'dry-run' | 'success';
6
+ };
7
+ export default class Remove extends LoopressCommand {
8
+ static args: {
9
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static description: string;
12
+ static enableJsonFlag: boolean;
13
+ static examples: string[];
14
+ static flags: {
15
+ 'local-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ };
20
+ run(): Promise<RemoveResult>;
21
+ }
22
+ export {};
@@ -0,0 +1,69 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LoopressCommand } from '../../lib/base.js';
6
+ import { guardProductionPush } from '../../lib/guard-production-push.js';
7
+ import { isInteractive } from '../../lib/interactive.js';
8
+ import { isNotFoundError } from '../../lib/wp-client.js';
9
+ import { optionEndpoint, optionFileName } from '../../utils/option-format.js';
10
+ export default class Remove extends LoopressCommand {
11
+ static args = {
12
+ name: Args.string({ description: 'Option name to stop tracking', required: true }),
13
+ };
14
+ static description = 'Stop tracking an option locally and delete it from WordPress. Local untracking always happens (git-reversible); ' +
15
+ 'pass --local-only to skip the WordPress delete and only stop tracking.';
16
+ static enableJsonFlag = true;
17
+ static examples = ['$ lps option remove wpseo_titles', '$ lps option remove wpseo_titles --local-only'];
18
+ static flags = {
19
+ ...LoopressCommand.dryRunFlag,
20
+ ...LoopressCommand.yesFlag,
21
+ 'local-only': Flags.boolean({ description: 'Untrack locally without deleting the option on WordPress' }),
22
+ path: Flags.string({ description: 'Path to options directory (overrides project config)' }),
23
+ };
24
+ async run() {
25
+ const { args, flags } = await this.parse(Remove);
26
+ const { name } = args;
27
+ const dir = this.resolveOptionsPath(flags.path);
28
+ const { url } = this.siteConfig;
29
+ const file = join(dir, optionFileName(name));
30
+ const localOnly = flags['local-only'];
31
+ if (this.dryRun) {
32
+ const andDelete = localOnly ? '' : ` and delete it from ${url}`;
33
+ this.log(`[dry-run] Would untrack "${name}" locally${andDelete}`);
34
+ return { deletedRemote: false, name, status: 'dry-run' };
35
+ }
36
+ if (localOnly) {
37
+ await rm(file, { force: true });
38
+ this.log(`Untracked "${name}" locally (left untouched on ${url})`);
39
+ return { deletedRemote: false, name, status: 'success' };
40
+ }
41
+ await guardProductionPush({
42
+ dryRun: this.dryRun,
43
+ error: (message) => this.error(message),
44
+ siteConfig: this.siteConfig,
45
+ yes: this.yes,
46
+ });
47
+ if (!this.yes && isInteractive()) {
48
+ const isProceed = await confirm({ default: false, message: `Delete option "${name}" from ${url}? (local tracking also stops)` });
49
+ if (!isProceed) {
50
+ this.log('Aborted.');
51
+ return { deletedRemote: false, name, status: 'aborted' };
52
+ }
53
+ }
54
+ let deletedRemote = true;
55
+ try {
56
+ await this.wp.delete(optionEndpoint(name));
57
+ }
58
+ catch (error) {
59
+ if (!isNotFoundError(error))
60
+ throw error;
61
+ deletedRemote = false;
62
+ }
63
+ await rm(file, { force: true });
64
+ this.log(deletedRemote
65
+ ? `Removed "${name}" from ${url} and untracked it locally`
66
+ : `"${name}" was already absent on ${url}; untracked it locally`);
67
+ return { deletedRemote, name, status: 'success' };
68
+ }
69
+ }
@@ -6,6 +6,7 @@ export default class Add extends LoopressCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  run(): Promise<void>;
@@ -1,32 +1,42 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
3
  import { writeLocalConfig } from '../../utils/loopress-config.js';
4
+ import { isExactVersion } from '../../utils/version.js';
4
5
  export default class Add extends LoopressCommand {
5
6
  static args = {
6
7
  slug: Args.string({ description: 'Plugin slug on WordPress.org', required: true }),
7
8
  };
8
9
  static description = 'Add a WordPress.org plugin to loopress.json';
9
- static examples = ['$ lps plugin add woocommerce', '$ lps plugin add contact-form-7 --dry-run'];
10
+ static examples = [
11
+ '$ lps plugin add woocommerce',
12
+ '$ lps plugin add woocommerce --version 9.4.2',
13
+ '$ lps plugin add contact-form-7 --dry-run',
14
+ ];
10
15
  static flags = {
11
16
  ...LoopressCommand.dryRunFlag,
17
+ version: Flags.string({ description: 'Exact version to pin (default: "latest", tracked on every push)' }),
12
18
  };
13
19
  async run() {
14
- const { args } = await this.parse(Add);
20
+ const { args, flags } = await this.parse(Add);
15
21
  const { slug } = args;
22
+ const version = flags.version ?? 'latest';
23
+ if (version !== 'latest' && !isExactVersion(version)) {
24
+ this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
25
+ }
16
26
  const existing = this.localConfig.plugins ?? {};
17
- if (existing[slug] === 'latest') {
18
- this.log(`${slug} is already in loopress.json, nothing to do.`);
27
+ if (existing[slug] === version) {
28
+ this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
19
29
  return;
20
30
  }
21
31
  const isUpdated = existing[slug] !== undefined;
22
32
  if (this.dryRun) {
23
- this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} in loopress.json`);
33
+ this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
24
34
  return;
25
35
  }
26
36
  await writeLocalConfig({
27
37
  ...this.localConfig,
28
- plugins: { ...existing, [slug]: 'latest' },
38
+ plugins: { ...existing, [slug]: version },
29
39
  });
30
- this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug}`);
40
+ this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
31
41
  }
32
42
  }
@@ -0,0 +1,30 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type Advisory = {
3
+ cve?: string;
4
+ fixedIn?: string;
5
+ score?: number;
6
+ severity?: string;
7
+ slug: string;
8
+ title: string;
9
+ };
10
+ type HealthNote = {
11
+ message: string;
12
+ slug: string;
13
+ };
14
+ type AuditResult = {
15
+ advisories: Advisory[];
16
+ health: HealthNote[];
17
+ status: 'clean' | 'issues';
18
+ };
19
+ export default class Audit extends LoopressCommand {
20
+ static description: string;
21
+ static enableJsonFlag: boolean;
22
+ static examples: string[];
23
+ static flags: {};
24
+ run(): Promise<AuditResult>;
25
+ private auditOne;
26
+ private fetchHealth;
27
+ private fetchVulnerabilities;
28
+ private report;
29
+ }
30
+ export {};
@@ -0,0 +1,138 @@
1
+ import got from 'got';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { compareVersions } from '../../utils/version.js';
4
+ const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
5
+ const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
6
+ export default class Audit extends LoopressCommand {
7
+ static description = 'Check the plugins in loopress.json for known vulnerabilities and health issues';
8
+ static enableJsonFlag = true;
9
+ static examples = ['$ lps plugin audit'];
10
+ static flags = {};
11
+ async run() {
12
+ const manifest = { ...this.localConfig.plugins };
13
+ const slugs = Object.keys(manifest);
14
+ if (slugs.length === 0) {
15
+ this.log('No plugins in loopress.json to audit.');
16
+ return { advisories: [], health: [], status: 'clean' };
17
+ }
18
+ const advisories = [];
19
+ const health = [];
20
+ for (const slug of slugs) {
21
+ const found = await this.auditOne(slug, manifest[slug]);
22
+ advisories.push(...found.advisories);
23
+ health.push(...found.health);
24
+ }
25
+ return this.report(advisories, health, slugs.length);
26
+ }
27
+ async auditOne(slug, pinned) {
28
+ const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
29
+ const advisories = vulns
30
+ .filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn))
31
+ .map((v) => ({ ...v, slug }));
32
+ const health = info ? describeHealth(slug, pinned, info) : [];
33
+ return { advisories, health };
34
+ }
35
+ async fetchHealth(slug) {
36
+ try {
37
+ const body = await got(WPORG_INFO, {
38
+ searchParams: { 'action': 'plugin_information', 'request[slug]': slug },
39
+ timeout: { request: 10_000 },
40
+ }).json();
41
+ return typeof body === 'object' && !('error' in body) ? body : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ async fetchVulnerabilities(slug) {
48
+ let body;
49
+ try {
50
+ body = await got(`${WPVULN_BASE}/${encodeURIComponent(slug)}/`, {
51
+ timeout: { request: 10_000 },
52
+ }).json();
53
+ }
54
+ catch (error) {
55
+ // Never swallow this into an empty list: a clean audit must mean "no advisories found",
56
+ // not "the advisory service was unreachable". Fail the command instead.
57
+ throw new Error(`Could not reach the vulnerability database (wpvulnerability.net) for "${slug}": ${error.message}. ` +
58
+ 'Audit aborted so a service outage is not reported as a clean result.', { cause: error });
59
+ }
60
+ const list = Array.isArray(body?.data?.vulnerability) ? body.data.vulnerability : [];
61
+ return list.map((raw) => normalizeAdvisory(raw));
62
+ }
63
+ report(advisories, health, count) {
64
+ if (advisories.length === 0 && health.length === 0) {
65
+ this.log(`No known vulnerabilities or health issues for ${count} plugin(s).`);
66
+ return { advisories, health, status: 'clean' };
67
+ }
68
+ for (const a of advisories)
69
+ this.log(` ⚠ ${a.slug}: ${a.title}${advisoryMeta(a)}`);
70
+ for (const h of health)
71
+ this.log(` · ${h.slug}: ${h.message}`);
72
+ const noun = advisories.length === 1 ? 'advisory' : 'advisories';
73
+ this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
74
+ 'Vulnerability data: wpvulnerability.net.');
75
+ if (advisories.length > 0)
76
+ this.exit(1);
77
+ return { advisories, health, status: 'issues' };
78
+ }
79
+ }
80
+ function advisoryMeta(a) {
81
+ const fixed = a.fixedIn ? `fixed in ${a.fixedIn}` : undefined;
82
+ const parts = [a.severity, a.cve, fixed].filter(Boolean);
83
+ return parts.length > 0 ? ` (${parts.join(', ')})` : '';
84
+ }
85
+ function normalizeAdvisory(raw) {
86
+ const cvss = (raw.cvss ?? {});
87
+ const source = Array.isArray(raw.source) ? raw.source : [];
88
+ const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
89
+ return {
90
+ cve,
91
+ fixedIn: extractFixedIn(raw),
92
+ score: cvss.score,
93
+ severity: cvss.severity,
94
+ title: typeof raw.name === 'string' ? raw.name : 'Unnamed advisory',
95
+ };
96
+ }
97
+ // wpvulnerability's `impact.software[].versions[]` carries `to_version` / `to_compare`; the
98
+ // first "< X" bound is the version the fix landed in. Best-effort: the shape varies, so a
99
+ // missing bound just means we can't tell and the advisory is reported anyway.
100
+ function extractFixedIn(raw) {
101
+ const impact = raw.impact;
102
+ for (const sw of impact?.software ?? []) {
103
+ for (const v of sw.versions ?? []) {
104
+ if (v.to_compare === '<' && typeof v.to_version === 'string')
105
+ return v.to_version;
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+ // If we know the fix version, a pin below it is affected. If we can't compare (odd version
111
+ // strings), report it anyway: a false positive is safer than hiding an advisory.
112
+ function affects(pinned, fixedIn) {
113
+ if (!fixedIn)
114
+ return true;
115
+ const order = compareVersions(pinned, fixedIn);
116
+ return order === null || order < 0;
117
+ }
118
+ function describeHealth(slug, pinned, info) {
119
+ const notes = [];
120
+ if (info.closed === true) {
121
+ notes.push({ message: 'removed from the WordPress.org directory', slug });
122
+ }
123
+ const requiresPhp = typeof info.requires_php === 'string' ? info.requires_php : null;
124
+ if (requiresPhp)
125
+ notes.push({ message: `requires PHP ${requiresPhp}`, slug });
126
+ const latest = typeof info.version === 'string' ? info.version : null;
127
+ const behindLatest = latest ? compareVersions(pinned, latest) : null;
128
+ if (latest && pinned !== 'latest' && behindLatest !== null && behindLatest < 0) {
129
+ notes.push({ message: `pinned to ${pinned}, latest is ${latest}`, slug });
130
+ }
131
+ const lastUpdated = typeof info.last_updated === 'string' ? info.last_updated : null;
132
+ if (lastUpdated) {
133
+ const years = (Date.now() - Date.parse(lastUpdated)) / (365 * 24 * 3600 * 1000);
134
+ if (years > 2)
135
+ notes.push({ message: `last updated ${lastUpdated.slice(0, 10)} (possibly abandoned)`, slug });
136
+ }
137
+ return notes;
138
+ }
@@ -1,7 +1,7 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
2
  import { type MergeResult } from '../../utils/plugins.js';
3
3
  type PullResult = MergeResult & {
4
- status: 'dry-run' | 'success';
4
+ status: 'composer-managed' | 'dry-run' | 'success';
5
5
  };
6
6
  export default class Pull extends LoopressCommand {
7
7
  static description: string;
@@ -11,6 +11,5 @@ export default class Pull extends LoopressCommand {
11
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
12
  };
13
13
  run(): Promise<PullResult>;
14
- private logSkippedComposerManaged;
15
14
  }
16
15
  export {};
@@ -1,9 +1,10 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { LoopressCommand } from '../../lib/base.js';
2
- import { getComposerManagedSlugs, readComposerJson } from '../../utils/composer.js';
3
4
  import { writeLocalConfig } from '../../utils/loopress-config.js';
4
5
  import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
5
6
  export default class Pull extends LoopressCommand {
6
- static description = 'Pull installed plugins from WordPress into loopress.json';
7
+ static description = 'Pull installed plugins from WordPress into loopress.json, pinned to their live versions';
7
8
  static enableJsonFlag = true;
8
9
  static examples = ['$ lps plugin pull', '$ lps plugin pull --dry-run'];
9
10
  static flags = {
@@ -11,13 +12,17 @@ export default class Pull extends LoopressCommand {
11
12
  };
12
13
  async run() {
13
14
  const { url } = this.siteConfig;
15
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
16
+ this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer pull` instead.');
17
+ return { added: [], merged: this.localConfig.plugins ?? {}, status: 'composer-managed', updated: [] };
18
+ }
14
19
  this.log(`Pulling plugins from ${url}`);
15
20
  const raw = await this.wp.get('wp/v2/plugins');
16
21
  const installed = parseInstalledPlugins(raw);
17
- const composerJson = await readComposerJson();
18
- const composerSlugs = composerJson ? getComposerManagedSlugs(composerJson) : [];
19
- const incoming = Object.fromEntries(installed.filter((p) => !composerSlugs.includes(p.slug)).map((p) => [p.slug, 'latest']));
20
- this.logSkippedComposerManaged(installed.filter((p) => composerSlugs.includes(p.slug)).map((p) => p.slug));
22
+ // Pin every plugin to the version actually running on the site. A later `plugin push`
23
+ // installs exactly this set via Composer + WPackagist; drift only surfaces when the pinned
24
+ // version and the live version disagree.
25
+ const incoming = Object.fromEntries(installed.map((p) => [p.slug, p.version]));
21
26
  const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, incoming);
22
27
  if (this.dryRun) {
23
28
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
@@ -35,9 +40,4 @@ export default class Pull extends LoopressCommand {
35
40
  this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
36
41
  return { added, merged, status: 'success', updated };
37
42
  }
38
- logSkippedComposerManaged(found) {
39
- if (found.length > 0) {
40
- this.log(`Skipping ${found.length} Composer-managed ${found.length === 1 ? 'plugin' : 'plugins'}: ${found.join(', ')}`);
41
- }
42
- }
43
43
  }
@@ -2,23 +2,28 @@ import { PushCommand } from '../../lib/push-command.js';
2
2
  type PushResult = {
3
3
  activated: string[];
4
4
  installed: string[];
5
- skippedComposerManaged: string[];
6
- status: 'dry-run' | 'in-sync' | 'success';
5
+ pinned: string[];
6
+ pruned: string[];
7
+ removed: string[];
8
+ status: 'composer-managed' | 'dry-run' | 'in-sync' | 'success';
7
9
  };
8
10
  export default class Push extends PushCommand {
9
11
  static description: string;
10
12
  static enableJsonFlag: boolean;
11
13
  static examples: string[];
12
14
  static flags: {
15
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ prune: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
19
  };
16
20
  run(): Promise<PushResult>;
17
- private activatePlugin;
18
- private applyPluginChanges;
19
- private installPlugin;
20
- private logPlannedChanges;
21
- private logSkippedComposerManaged;
22
- private performPluginAction;
21
+ private activate;
22
+ private deactivate;
23
+ private deactivateEndangered;
24
+ private fetchInstanceLock;
25
+ private guardForce;
26
+ private result;
27
+ private sync;
23
28
  }
24
29
  export {};