@loopress/cli 0.21.0 → 0.23.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 (103) hide show
  1. package/README.md +457 -67
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/app/list.d.ts +17 -0
  11. package/dist/commands/app/list.js +35 -0
  12. package/dist/commands/app/pull.d.ts +27 -0
  13. package/dist/commands/app/pull.js +94 -0
  14. package/dist/commands/app/push.d.ts +28 -0
  15. package/dist/commands/app/push.js +130 -0
  16. package/dist/commands/app/remove.d.ts +20 -0
  17. package/dist/commands/app/remove.js +42 -0
  18. package/dist/commands/composer/push.d.ts +1 -0
  19. package/dist/commands/composer/push.js +43 -19
  20. package/dist/commands/dev.js +1 -1
  21. package/dist/commands/doctor.js +2 -1
  22. package/dist/commands/form/list.d.ts +2 -4
  23. package/dist/commands/form/list.js +3 -10
  24. package/dist/commands/form/pull.js +15 -24
  25. package/dist/commands/form/push.js +17 -42
  26. package/dist/commands/init.js +1 -0
  27. package/dist/commands/login.js +6 -2
  28. package/dist/commands/logout.js +2 -1
  29. package/dist/commands/page/pull.js +17 -28
  30. package/dist/commands/page/push.js +27 -52
  31. package/dist/commands/plugin/add.d.ts +1 -0
  32. package/dist/commands/plugin/add.js +18 -8
  33. package/dist/commands/plugin/audit.d.ts +30 -0
  34. package/dist/commands/plugin/audit.js +138 -0
  35. package/dist/commands/plugin/pull.d.ts +1 -1
  36. package/dist/commands/plugin/pull.js +13 -15
  37. package/dist/commands/plugin/push.d.ts +14 -5
  38. package/dist/commands/plugin/push.js +171 -60
  39. package/dist/commands/plugin/status.d.ts +22 -0
  40. package/dist/commands/plugin/status.js +65 -0
  41. package/dist/commands/project/config.js +4 -1
  42. package/dist/commands/project/list.d.ts +16 -1
  43. package/dist/commands/project/list.js +22 -16
  44. package/dist/commands/project/pull.js +3 -2
  45. package/dist/commands/project/push.d.ts +5 -0
  46. package/dist/commands/project/push.js +79 -58
  47. package/dist/commands/promote.d.ts +18 -0
  48. package/dist/commands/promote.js +90 -0
  49. package/dist/commands/pull.d.ts +12 -0
  50. package/dist/commands/pull.js +51 -0
  51. package/dist/commands/push.d.ts +13 -0
  52. package/dist/commands/push.js +57 -0
  53. package/dist/commands/seo/list.d.ts +9 -2
  54. package/dist/commands/seo/list.js +9 -11
  55. package/dist/commands/seo/pull.js +14 -37
  56. package/dist/commands/seo/push.js +24 -48
  57. package/dist/commands/snippet/list.js +2 -1
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.js +15 -20
  60. package/dist/commands/snippet/push.js +21 -30
  61. package/dist/commands/theme/add.d.ts +13 -0
  62. package/dist/commands/theme/add.js +35 -0
  63. package/dist/commands/theme/pull.d.ts +21 -0
  64. package/dist/commands/theme/pull.js +37 -0
  65. package/dist/commands/theme/push.d.ts +23 -0
  66. package/dist/commands/theme/push.js +130 -0
  67. package/dist/commands/theme/status.d.ts +20 -0
  68. package/dist/commands/theme/status.js +51 -0
  69. package/dist/commands/validate.d.ts +8 -0
  70. package/dist/commands/validate.js +24 -0
  71. package/dist/lib/app-manifest.d.ts +36 -0
  72. package/dist/lib/app-manifest.js +162 -0
  73. package/dist/lib/base.d.ts +10 -0
  74. package/dist/lib/base.js +56 -22
  75. package/dist/lib/guard-production-push.d.ts +12 -0
  76. package/dist/lib/guard-production-push.js +20 -0
  77. package/dist/lib/load-snippets.js +65 -60
  78. package/dist/lib/local-callback-server.js +9 -4
  79. package/dist/lib/push-command.d.ts +3 -0
  80. package/dist/lib/push-command.js +22 -15
  81. package/dist/lib/put-or-create.d.ts +13 -0
  82. package/dist/lib/put-or-create.js +19 -0
  83. package/dist/lib/readdir-tolerant.d.ts +5 -0
  84. package/dist/lib/readdir-tolerant.js +11 -0
  85. package/dist/lib/resolve-linked-project.d.ts +8 -0
  86. package/dist/lib/resolve-linked-project.js +27 -0
  87. package/dist/lib/validate-local.d.ts +10 -0
  88. package/dist/lib/validate-local.js +183 -0
  89. package/dist/types/plugin.d.ts +1 -0
  90. package/dist/types/project-config.generated.d.ts +11 -1
  91. package/dist/utils/plugin-sync.d.ts +26 -0
  92. package/dist/utils/plugin-sync.js +31 -0
  93. package/dist/utils/plugins.d.ts +15 -2
  94. package/dist/utils/plugins.js +57 -12
  95. package/dist/utils/pluralize.d.ts +1 -0
  96. package/dist/utils/pluralize.js +5 -0
  97. package/dist/utils/themes.d.ts +30 -0
  98. package/dist/utils/themes.js +29 -0
  99. package/dist/utils/version.d.ts +2 -0
  100. package/dist/utils/version.js +21 -0
  101. package/oclif.manifest.json +986 -125
  102. package/package.json +25 -20
  103. package/schemas/project-config.schema.json +22 -4
@@ -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;
@@ -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,27 +12,24 @@ 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
- if (composerSlugs.length > 0) {
21
- const found = installed.filter((p) => composerSlugs.includes(p.slug)).map((p) => p.slug);
22
- if (found.length > 0) {
23
- this.log(`Skipping ${found.length} Composer-managed ${found.length === 1 ? 'plugin' : 'plugins'}: ${found.join(', ')}`);
24
- }
25
- }
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]));
26
26
  const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, incoming);
27
27
  if (this.dryRun) {
28
28
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
29
29
  if (added.length > 0)
30
30
  this.log(` + ${added.join(', ')}`);
31
- if (updated.length > 0) {
32
- for (const u of updated)
33
- this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
34
- }
31
+ for (const u of updated)
32
+ this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
35
33
  return { added, merged, status: 'dry-run', updated };
36
34
  }
37
35
  await writeLocalConfig({ ...this.localConfig, plugins: merged });
@@ -2,20 +2,29 @@ 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 installPlugin;
19
- private performPluginAction;
21
+ private activate;
22
+ private confirmRemovals;
23
+ private deactivate;
24
+ private deactivateEndangered;
25
+ private fetchInstanceLock;
26
+ private guardForce;
27
+ private result;
28
+ private sync;
20
29
  }
21
30
  export {};
@@ -1,88 +1,199 @@
1
- import { Listr } from 'listr2';
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { isInteractive } from '../../lib/interactive.js';
2
6
  import { PushCommand } from '../../lib/push-command.js';
3
- import { getComposerManagedSlugs, readComposerJson } from '../../utils/composer.js';
4
- import { diffPlugins, parseInstalledPlugins } from '../../utils/plugins.js';
7
+ import { isNotFoundError } from '../../lib/wp-client.js';
8
+ import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
9
+ import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
10
+ const IN_SYNC = { activated: [], installed: [], pinned: [], pruned: [], removed: [], status: 'in-sync' };
5
11
  export default class Push extends PushCommand {
6
- static description = 'Push plugins to WordPress to match loopress.json';
12
+ static description = 'Install plugins on WordPress to match loopress.json, pinned via Composer + WPackagist';
7
13
  static enableJsonFlag = true;
8
- static examples = ['$ lps plugin push', '$ lps plugin push --dry-run'];
14
+ static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
9
15
  static flags = {
10
16
  ...PushCommand.dryRunFlag,
11
17
  ...PushCommand.yesFlag,
18
+ force: Flags.boolean({
19
+ default: false,
20
+ description: 'Allow downgrades and let Loopress take over plugins installed outside it (replaces their files)',
21
+ }),
22
+ prune: Flags.boolean({
23
+ default: false,
24
+ description: 'Deactivate plugins that are active on the site but absent from loopress.json',
25
+ }),
12
26
  };
13
27
  async run() {
14
- const { url } = this.siteConfig;
15
- const manifest = this.localConfig.plugins;
16
- if (!manifest || Object.keys(manifest).length === 0) {
17
- this.error('No plugins found in loopress.json. Run `lps plugin pull` first.');
28
+ const { flags } = await this.parse(Push);
29
+ const { force, prune } = flags;
30
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
31
+ this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer push` instead.');
32
+ return { ...IN_SYNC, status: 'composer-managed' };
18
33
  }
19
- const composerJson = await readComposerJson();
20
- const composerSlugs = composerJson ? getComposerManagedSlugs(composerJson) : [];
21
- const filteredManifest = Object.fromEntries(Object.entries(manifest).filter(([slug]) => !composerSlugs.includes(slug)));
22
- const skippedComposerManaged = composerSlugs.filter((slug) => Object.hasOwn(manifest, slug));
23
- if (skippedComposerManaged.length > 0) {
24
- this.log(`Skipping ${skippedComposerManaged.length} Composer-managed ${skippedComposerManaged.length === 1 ? 'plugin' : 'plugins'}: ${skippedComposerManaged.join(', ')}`);
25
- this.log('Run `lps composer push` to deploy them.');
34
+ const manifest = this.localConfig.plugins ?? {};
35
+ if (Object.keys(manifest).length === 0) {
36
+ this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
26
37
  }
27
- this.log(`Pushing plugins to ${url}`);
38
+ this.log(`Pushing plugins to ${this.siteConfig.url}`);
28
39
  const raw = await this.wp.get('wp/v2/plugins');
29
40
  const installed = parseInstalledPlugins(raw);
30
- const { toActivate, toInstall } = diffPlugins(filteredManifest, installed);
31
- if (toInstall.length === 0 && toActivate.length === 0) {
41
+ const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'plugin');
42
+ const diff = diffPlugins(manifest, installed, managed);
43
+ this.guardForce(diff, force);
44
+ const toPrune = prune ? diff.untrackedActive : [];
45
+ // A "latest" pin never shows as drift, but its newest upstream release may have moved
46
+ // since the last push, so a push must still run `composer update` to pick it up.
47
+ const hasLatestPin = Object.values(manifest).includes('latest');
48
+ if (!hasLatestPin && isNoop(diff, toPrune)) {
32
49
  this.log('Everything is already in sync.');
33
- return { activated: [], installed: [], skippedComposerManaged, status: 'in-sync' };
50
+ return { ...IN_SYNC };
34
51
  }
35
- if (toInstall.length > 0) {
36
- this.log(`\nTo install (${toInstall.length}):`);
37
- for (const a of toInstall)
38
- this.log(` + ${a.slug}`);
52
+ if (isNoop(diff, toPrune))
53
+ this.log('Refreshing plugins pinned to "latest" to their newest releases.');
54
+ logPlan(this, diff, toPrune, force);
55
+ if (this.dryRun) {
56
+ return this.result(diff, { includeCollisions: true, pruned: toPrune, removed: diff.toRemove, status: 'dry-run' });
39
57
  }
40
- if (toActivate.length > 0) {
41
- this.log(`\nTo activate (${toActivate.length}):`);
42
- for (const a of toActivate)
43
- this.log(` ↑ ${a.slug}`);
44
- }
45
- const installedSlugs = toInstall.map((a) => a.slug);
46
- const activatedSlugs = toActivate.map((a) => a.slug);
47
- if (this.dryRun)
48
- return { activated: activatedSlugs, installed: installedSlugs, skippedComposerManaged, status: 'dry-run' };
49
- // Installing with `status: active` activates in the same call, so installs never need a
50
- // separate activation step the way the old custom endpoint's two-step flow did.
51
- if (toInstall.length > 0) {
52
- await new Listr(toInstall.map((action) => ({
53
- task: async (_ctx, task) => this.installPlugin(action.slug, task),
54
- title: `Install ${action.slug}`,
55
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
58
+ await this.confirmRemovals(diff.toRemove);
59
+ const deactivated = await this.deactivateEndangered(installed, diff, force);
60
+ // Plugins that must end up active: the ones we deactivated for the file swap (minus any
61
+ // being uninstalled) plus the ones the manifest wants active but that are inactive today.
62
+ const toReactivate = [...deactivated.filter((p) => !diff.toRemove.includes(p.slug)), ...diff.toActivate];
63
+ let response;
64
+ try {
65
+ response = await this.sync(manifest, force);
56
66
  }
57
- if (toActivate.length > 0) {
58
- await new Listr(toActivate.map((action) => ({
59
- task: async (_ctx, task) => this.activatePlugin(action.file, action.slug, task),
60
- title: `Activate ${action.slug}`,
61
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
67
+ catch (error) {
68
+ // The file swap never happened, so restore the plugins we defensively deactivated
69
+ // instead of leaving the site with them switched off.
70
+ await this.activate(deactivated.filter((p) => !diff.toRemove.includes(p.slug)));
71
+ throw error;
62
72
  }
63
- if (this.failedCount > 0) {
64
- this.error(`${this.failedCount} plugin${this.failedCount === 1 ? '' : 's'} failed to install or activate.`);
73
+ if (toPrune.length > 0) {
74
+ await this.deactivate(installed.filter((p) => toPrune.includes(p.slug)));
65
75
  }
76
+ await this.activate(toReactivate);
77
+ if (response.output.trim())
78
+ this.log(response.output.trim());
79
+ this.log('Plugins synced.');
66
80
  await this.recordSuccess();
67
- return { activated: activatedSlugs, installed: installedSlugs, skippedComposerManaged, status: 'success' };
81
+ return this.result(diff, {
82
+ activated: toReactivate.map((p) => p.slug),
83
+ includeCollisions: force,
84
+ pruned: toPrune,
85
+ removed: response.removed ?? diff.toRemove,
86
+ status: 'success',
87
+ });
88
+ }
89
+ async activate(plugins) {
90
+ for (const plugin of plugins) {
91
+ this.log(` ⊙ activating ${plugin.slug}`);
92
+ await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
93
+ }
68
94
  }
69
- async activatePlugin(file, slug, task) {
70
- await this.performPluginAction('activate', slug, async () => this.wp.put(`wp/v2/plugins/${file}`, { status: 'active' }), task);
95
+ async confirmRemovals(toRemove) {
96
+ if (toRemove.length === 0 || this.yes || !isInteractive())
97
+ return;
98
+ const ok = await confirm({ default: false, message: `Uninstall ${toRemove.join(', ')} from the site?` });
99
+ if (!ok)
100
+ this.error('Aborted.');
71
101
  }
72
- async installPlugin(slug, task) {
73
- await this.performPluginAction('install', slug, async () => this.wp.post('wp/v2/plugins', { slug, status: 'active' }), task);
102
+ async deactivate(plugins) {
103
+ for (const plugin of plugins) {
104
+ this.log(` ⊘ deactivating ${plugin.slug}`);
105
+ await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'inactive' });
106
+ }
107
+ }
108
+ // Deactivate anything whose folder is about to be deleted or replaced (removed, re-pinned to
109
+ // a new version, or taken over with --force), so the site does not fatal in the window
110
+ // between removal and Composer finishing the install. Returns the plugins it switched off so
111
+ // the caller can switch the still-wanted ones back on.
112
+ async deactivateEndangered(installed, diff, force) {
113
+ const endangered = new Set([
114
+ ...diff.toRemove,
115
+ ...diff.toPin.map((p) => p.slug),
116
+ ...(force ? diff.collisions.map((c) => c.slug) : []),
117
+ ]);
118
+ const victims = installed.filter((p) => p.active && endangered.has(p.slug));
119
+ await this.deactivate(victims);
120
+ return victims;
74
121
  }
75
- async performPluginAction(verb, slug, request, task) {
122
+ // The site's live composer.lock tells us which plugins Loopress already manages, so the plan
123
+ // can preview removals and tell an unmanaged folder (collision) apart from a re-pin. 404 =
124
+ // nothing pushed yet.
125
+ async fetchInstanceLock() {
76
126
  try {
77
- await request();
78
- const message = `✓ ${slug} ${verb === 'install' ? 'installed and activated' : 'activated'}`;
79
- if (task)
80
- task.output = message;
81
- else
82
- this.log(` ${message}`);
127
+ const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
128
+ return composerLock;
83
129
  }
84
130
  catch (error) {
85
- this.reportTaskFailure(`Failed to ${verb} ${slug}: ${error.message}`, error, task);
131
+ if (isNotFoundError(error))
132
+ return null;
133
+ throw error;
134
+ }
135
+ }
136
+ guardForce(diff, force) {
137
+ if (force)
138
+ return;
139
+ if (diff.collisions.length > 0) {
140
+ const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
141
+ this.error(`${diff.collisions.length} plugin(s) are already installed outside Loopress: ${list}. ` +
142
+ 'Re-run with --force to let Loopress manage them (this replaces their files with the WPackagist build, ' +
143
+ 'local modifications are lost), or remove them from loopress.json.');
144
+ }
145
+ const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
146
+ if (downgrades.length > 0) {
147
+ const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
148
+ this.error(`Refusing to downgrade: ${list}. A downgrade only replaces plugin files, it does not undo database ` +
149
+ 'migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in loopress.json.');
86
150
  }
87
151
  }
152
+ result(diff, opts) {
153
+ const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
154
+ return {
155
+ activated: opts.activated ?? diff.toActivate.map((a) => a.slug),
156
+ installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
157
+ pinned: diff.toPin.map((p) => p.slug),
158
+ pruned: opts.pruned,
159
+ removed: opts.removed,
160
+ status: opts.status,
161
+ };
162
+ }
163
+ async sync(manifest, force) {
164
+ try {
165
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent: { plugins: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
166
+ }
167
+ catch (error) {
168
+ const collisions = parseCollisions(error);
169
+ if (collisions) {
170
+ this.error(`The site rejected the push: ${collisions.map((c) => c.slug).join(', ')} installed outside Loopress. Re-run with --force.`);
171
+ }
172
+ throw error;
173
+ }
174
+ }
175
+ }
176
+ function isNoop(diff, toPrune) {
177
+ return (diff.toInstall.length === 0 &&
178
+ diff.toPin.length === 0 &&
179
+ diff.toActivate.length === 0 &&
180
+ diff.toRemove.length === 0 &&
181
+ diff.collisions.length === 0 &&
182
+ toPrune.length === 0);
183
+ }
184
+ function logPlan(cmd, diff, prune, force) {
185
+ const section = (label, lines) => {
186
+ if (lines.length === 0)
187
+ return;
188
+ cmd.log(`\n${label} (${lines.length}):`);
189
+ for (const line of lines)
190
+ cmd.log(` ${line}`);
191
+ };
192
+ section('To install', diff.toInstall.map((a) => `+ ${a.slug} ${a.version}`));
193
+ if (force)
194
+ section('To take over', diff.collisions.map((c) => `! ${c.slug} (installed outside Loopress)`));
195
+ section('To re-pin', diff.toPin.map((p) => `~ ${p.slug} ${p.from} to ${p.to}`));
196
+ section('To activate', diff.toActivate.map((a) => `↑ ${a.slug}`));
197
+ section('To uninstall', diff.toRemove.map((s) => `- ${s}`));
198
+ section('To deactivate (--prune)', prune.map((s) => `⊘ ${s}`));
88
199
  }
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type StatusResult = {
3
+ drift: boolean;
4
+ inactive: string[];
5
+ missing: string[];
6
+ status: 'composer-managed' | 'drift' | 'in-sync';
7
+ untrackedActive: string[];
8
+ wrongVersion: Array<{
9
+ live: string;
10
+ pinned: string;
11
+ slug: string;
12
+ }>;
13
+ };
14
+ export default class Status extends LoopressCommand {
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {};
19
+ run(): Promise<StatusResult>;
20
+ private fetchInstanceLock;
21
+ }
22
+ export {};