@loopress/cli 0.27.0 → 0.29.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 (124) hide show
  1. package/README.md +448 -189
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/pull.js +17 -4
  9. package/dist/commands/app/push.d.ts +3 -0
  10. package/dist/commands/app/push.js +30 -6
  11. package/dist/commands/composer/push.js +3 -21
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/form/pull.d.ts +9 -1
  14. package/dist/commands/form/pull.js +3 -1
  15. package/dist/commands/form/push.d.ts +7 -1
  16. package/dist/commands/form/push.js +3 -1
  17. package/dist/commands/init.js +6 -36
  18. package/dist/commands/menu/pull.d.ts +7 -1
  19. package/dist/commands/menu/pull.js +2 -0
  20. package/dist/commands/menu/push.d.ts +7 -1
  21. package/dist/commands/menu/push.js +3 -1
  22. package/dist/commands/option/pull.js +4 -4
  23. package/dist/commands/option/push.js +2 -0
  24. package/dist/commands/page/push.js +3 -0
  25. package/dist/commands/plugin/add.js +6 -3
  26. package/dist/commands/plugin/audit.js +4 -5
  27. package/dist/commands/plugin/pull.d.ts +7 -1
  28. package/dist/commands/plugin/pull.js +68 -14
  29. package/dist/commands/plugin/push.d.ts +5 -1
  30. package/dist/commands/plugin/push.js +92 -38
  31. package/dist/commands/plugin/status.d.ts +1 -0
  32. package/dist/commands/plugin/status.js +21 -2
  33. package/dist/commands/project/config.js +12 -8
  34. package/dist/commands/promote.d.ts +2 -0
  35. package/dist/commands/promote.js +69 -8
  36. package/dist/commands/pull.js +2 -2
  37. package/dist/commands/push.d.ts +1 -0
  38. package/dist/commands/push.js +40 -5
  39. package/dist/commands/seo/pull.d.ts +8 -1
  40. package/dist/commands/seo/pull.js +2 -0
  41. package/dist/commands/seo/push.d.ts +7 -1
  42. package/dist/commands/seo/push.js +3 -1
  43. package/dist/commands/snippet/publish.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.js +1 -1
  46. package/dist/commands/status.d.ts +1 -0
  47. package/dist/commands/status.js +4 -4
  48. package/dist/commands/theme/pull.d.ts +6 -8
  49. package/dist/commands/theme/pull.js +30 -26
  50. package/dist/commands/theme/push.d.ts +8 -9
  51. package/dist/commands/theme/push.js +54 -111
  52. package/dist/commands/theme/style/diff.d.ts +2 -0
  53. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  54. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
  55. package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
  56. package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
  57. package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
  58. package/dist/commands/theme/style/rollback.d.ts +2 -0
  59. package/dist/commands/theme/style/rollback.js +6 -0
  60. package/dist/commands/theme/template/diff.d.ts +10 -0
  61. package/dist/commands/theme/template/diff.js +13 -0
  62. package/dist/commands/theme/template/list.d.ts +13 -0
  63. package/dist/commands/theme/template/list.js +23 -0
  64. package/dist/commands/theme/template/push.d.ts +20 -0
  65. package/dist/commands/theme/template/push.js +44 -0
  66. package/dist/commands/theme/version/pull.d.ts +23 -0
  67. package/dist/commands/theme/version/pull.js +43 -0
  68. package/dist/commands/theme/version/push.d.ts +22 -0
  69. package/dist/commands/theme/version/push.js +123 -0
  70. package/dist/hooks/finally.d.ts +2 -1
  71. package/dist/hooks/finally.js +27 -4
  72. package/dist/lib/api-client.js +3 -1
  73. package/dist/lib/app-build.d.ts +5 -0
  74. package/dist/lib/app-build.js +69 -0
  75. package/dist/lib/app-manifest.d.ts +4 -0
  76. package/dist/lib/app-manifest.js +10 -2
  77. package/dist/lib/base.d.ts +1 -0
  78. package/dist/lib/base.js +14 -2
  79. package/dist/lib/bootstrap-full-install.js +45 -14
  80. package/dist/lib/dev-watch.d.ts +1 -1
  81. package/dist/lib/dev-watch.js +3 -1
  82. package/dist/lib/diff-command.d.ts +1 -0
  83. package/dist/lib/diff-command.js +6 -4
  84. package/dist/lib/diff-state.js +1 -0
  85. package/dist/lib/github-release.js +0 -1
  86. package/dist/lib/local-callback-server.d.ts +12 -6
  87. package/dist/lib/local-callback-server.js +21 -22
  88. package/dist/lib/php-files-command.js +3 -1
  89. package/dist/lib/push-command.d.ts +5 -0
  90. package/dist/lib/push-command.js +30 -8
  91. package/dist/lib/resource-state.js +47 -4
  92. package/dist/lib/rollback-command.d.ts +1 -0
  93. package/dist/lib/rollback-command.js +8 -6
  94. package/dist/lib/rotate-app-password.js +2 -2
  95. package/dist/lib/snapshot-store.js +2 -2
  96. package/dist/lib/validate-local.js +6 -0
  97. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  98. package/dist/lib/wp-authorize-flow.js +24 -28
  99. package/dist/lib/wp-client.d.ts +4 -1
  100. package/dist/lib/wp-client.js +25 -1
  101. package/dist/lib/wp-site-diagnostic.js +37 -0
  102. package/dist/types/plugin.d.ts +8 -1
  103. package/dist/types/plugin.js +4 -1
  104. package/dist/types/project-config.generated.d.ts +19 -2
  105. package/dist/utils/composer.d.ts +16 -0
  106. package/dist/utils/composer.js +144 -0
  107. package/dist/utils/option-format.d.ts +3 -1
  108. package/dist/utils/option-format.js +14 -3
  109. package/dist/utils/page-format.d.ts +12 -0
  110. package/dist/utils/page-format.js +40 -28
  111. package/dist/utils/plugin-sync.js +1 -1
  112. package/dist/utils/plugins.d.ts +9 -4
  113. package/dist/utils/plugins.js +16 -7
  114. package/dist/utils/resource-dirs.d.ts +2 -0
  115. package/dist/utils/resource-dirs.js +6 -2
  116. package/dist/utils/seo-format.d.ts +2 -2
  117. package/dist/utils/template-format.d.ts +31 -0
  118. package/dist/utils/template-format.js +43 -0
  119. package/oclif.manifest.json +841 -63
  120. package/package.json +24 -17
  121. package/schemas/project-config.schema.json +25 -4
  122. package/dist/commands/theme-styles/diff.d.ts +0 -2
  123. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  124. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -1,5 +1,6 @@
1
1
  import got from 'got';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { pinVersion } from '../../types/plugin.js';
3
4
  import { compareVersions } from '../../utils/version.js';
4
5
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
5
6
  const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
@@ -18,7 +19,7 @@ export default class Audit extends LoopressCommand {
18
19
  const advisories = [];
19
20
  const health = [];
20
21
  for (const slug of slugs) {
21
- const found = await this.auditOne(slug, manifest[slug]);
22
+ const found = await this.auditOne(slug, pinVersion(manifest[slug]));
22
23
  advisories.push(...found.advisories);
23
24
  health.push(...found.health);
24
25
  }
@@ -26,16 +27,14 @@ export default class Audit extends LoopressCommand {
26
27
  }
27
28
  async auditOne(slug, pinned) {
28
29
  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 }));
30
+ const advisories = vulns.filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn)).map((v) => ({ ...v, slug }));
32
31
  const health = info ? describeHealth(slug, pinned, info) : [];
33
32
  return { advisories, health };
34
33
  }
35
34
  async fetchHealth(slug) {
36
35
  try {
37
36
  const body = await got(WPORG_INFO, {
38
- searchParams: { 'action': 'plugin_information', 'request[slug]': slug },
37
+ searchParams: { action: 'plugin_information', 'request[slug]': slug },
39
38
  timeout: { request: 10_000 },
40
39
  }).json();
41
40
  return typeof body === 'object' && !('error' in body) ? body : null;
@@ -1,15 +1,21 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type ComposerPullResult } from '../../utils/composer.js';
2
3
  import { type MergeResult } from '../../utils/plugins.js';
3
4
  type PullResult = MergeResult & {
4
- status: 'composer-managed' | 'dry-run' | 'success';
5
+ skipped?: ComposerPullResult['skipped'];
6
+ status: 'dry-run' | 'success';
7
+ untracked: string[];
5
8
  };
6
9
  export default class Pull extends LoopressCommand {
7
10
  static description: string;
8
11
  static enableJsonFlag: boolean;
9
12
  static examples: string[];
10
13
  static flags: {
14
+ plugin: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
17
  };
13
18
  run(): Promise<PullResult>;
19
+ private selectPlugins;
14
20
  }
15
21
  export {};
@@ -1,36 +1,68 @@
1
+ import { checkbox } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
1
3
  import { existsSync } from 'node:fs';
4
+ import { readFile } from 'node:fs/promises';
2
5
  import { join } from 'node:path';
3
6
  import { LoopressCommand } from '../../lib/base.js';
7
+ import { isInteractive } from '../../lib/interactive.js';
8
+ import { getComposerManagedSlugs, NOT_ON_WORDPRESS_ORG, pullIntoComposerJson, } from '../../utils/composer.js';
4
9
  import { writeLocalConfig } from '../../utils/loopress-config.js';
5
10
  import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
6
11
  export default class Pull extends LoopressCommand {
7
- static description = 'Pull installed plugins from WordPress into loopress.json, pinned to their live versions';
12
+ static description = 'Pull installed plugins from WordPress into loopress.json (or composer.json), pinned to their live versions. ' +
13
+ 'Plugins already tracked are refreshed; new ones are offered as a list to tick, so plugins that only belong ' +
14
+ 'on this environment (cache, security, backups) stay out of the project.';
8
15
  static enableJsonFlag = true;
9
- static examples = ['$ lps plugin pull', '$ lps plugin pull --dry-run'];
16
+ static examples = ['$ lps plugin pull', '$ lps plugin pull --env production --plugin query-monitor'];
10
17
  static flags = {
11
18
  ...LoopressCommand.dryRunFlag,
19
+ ...LoopressCommand.yesFlag,
20
+ plugin: Flags.string({
21
+ description: 'Start tracking this installed plugin too, without asking (repeatable)',
22
+ multiple: true,
23
+ }),
12
24
  };
13
25
  async run() {
26
+ const { flags } = await this.parse(Pull);
14
27
  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
- }
19
28
  this.log(`Pulling plugins from ${url}`);
20
29
  const raw = await this.wp.get('wp/v2/plugins');
21
- const installed = parseInstalledPlugins(raw);
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
- const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, incoming);
30
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
31
+ const composerJson = existsSync(composerJsonPath)
32
+ ? JSON.parse(await readFile(composerJsonPath, 'utf8'))
33
+ : undefined;
34
+ const declared = new Set(composerJson ? getComposerManagedSlugs(composerJson) : Object.keys(this.localConfig.plugins ?? {}));
35
+ const all = parseInstalledPlugins(raw);
36
+ const selected = await this.selectPlugins(all, declared, flags.plugin ?? [], flags.yes);
37
+ const installed = all.filter((p) => selected.has(p.slug));
38
+ const untracked = all.filter((p) => !selected.has(p.slug)).map((p) => p.slug);
39
+ if (untracked.length > 0) {
40
+ this.log(` - Not tracked: ${untracked.join(', ')} (add one with \`lps plugin pull --plugin <slug>\`)`);
41
+ }
42
+ // Pin every plugin to the version actually running on the site, and record the inactive ones
43
+ // as such so a later `plugin push` doesn't switch them on. A later `plugin push` installs
44
+ // exactly this set via Composer + WPackagist; drift only surfaces when the pinned version and
45
+ // the live version disagree.
46
+ const versions = Object.fromEntries(installed.map((p) => [p.slug, p.version]));
47
+ // A composer.json is authoritative over loopress.json: pin the live versions there instead.
48
+ if (composerJson) {
49
+ const result = await pullIntoComposerJson(composerJsonPath, 'plugin', versions, {
50
+ dryRun: this.dryRun,
51
+ log: this.log.bind(this),
52
+ });
53
+ // Ticked, but Composer can't install it (premium or custom): still not tracked by the project.
54
+ const notInstallable = result.skipped.filter((s) => s.reason === NOT_ON_WORDPRESS_ORG).map((s) => s.slug);
55
+ return { ...result, status: this.dryRun ? 'dry-run' : 'success', untracked: [...untracked, ...notInstallable] };
56
+ }
57
+ const pins = Object.fromEntries(installed.map((p) => [p.slug, p.active ? p.version : { active: false, version: p.version }]));
58
+ const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, pins);
27
59
  if (this.dryRun) {
28
60
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
29
61
  if (added.length > 0)
30
62
  this.log(` + ${added.join(', ')}`);
31
63
  for (const u of updated)
32
64
  this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
33
- return { added, merged, status: 'dry-run', updated };
65
+ return { added, merged, status: 'dry-run', untracked, updated };
34
66
  }
35
67
  await writeLocalConfig({ ...this.localConfig, plugins: merged });
36
68
  this.log(`Wrote ${Object.keys(merged).length} plugins to loopress.json`);
@@ -38,6 +70,28 @@ export default class Pull extends LoopressCommand {
38
70
  this.log(` + Added: ${added.join(', ')}`);
39
71
  for (const u of updated)
40
72
  this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
41
- return { added, merged, status: 'success', updated };
73
+ return { added, merged, status: 'success', untracked, updated };
74
+ }
75
+ // Plugins already tracked are always refreshed, plus any --plugin. Every other installed plugin
76
+ // is offered unticked, so a second pull from production never brings Wordfence back on its
77
+ // own. With nobody to ask (--yes, --json, CI, the MCP server), only those two sets count:
78
+ // "track everything" would bring the same problem back silently.
79
+ async selectPlugins(installed, declared, requested, yes) {
80
+ const slugs = new Set(installed.map((p) => p.slug));
81
+ for (const slug of requested) {
82
+ if (!slugs.has(slug))
83
+ this.warn(`--plugin ${slug}: not installed on this environment, skipped.`);
84
+ }
85
+ const selected = new Set([...slugs].filter((slug) => declared.has(slug) || requested.includes(slug)));
86
+ const candidates = installed.filter((p) => !selected.has(p.slug));
87
+ if (yes || candidates.length === 0 || this.jsonEnabled() || !isInteractive())
88
+ return selected;
89
+ const picked = await checkbox({
90
+ choices: candidates.map((p) => ({ name: `${p.slug} ${p.version}${p.active ? '' : ' (inactive)'}`, value: p.slug })),
91
+ message: 'Which of these plugins should the project track? Leave the ones that only belong on this environment unticked.',
92
+ });
93
+ for (const slug of picked)
94
+ selected.add(slug);
95
+ return selected;
42
96
  }
43
97
  }
@@ -1,17 +1,19 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
2
  type PushResult = {
3
3
  activated: string[];
4
+ deactivated: string[];
4
5
  installed: string[];
5
6
  pinned: string[];
6
7
  pruned: string[];
7
8
  removed: string[];
8
- status: 'composer-managed' | 'dry-run' | 'in-sync' | 'success';
9
+ status: 'dry-run' | 'in-sync' | 'success';
9
10
  };
10
11
  export default class Push extends PushCommand {
11
12
  static description: string;
12
13
  static enableJsonFlag: boolean;
13
14
  static examples: string[];
14
15
  static flags: {
16
+ activate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  prune: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -24,6 +26,8 @@ export default class Push extends PushCommand {
24
26
  private fetchInstanceLock;
25
27
  private filesForNewlyInstalled;
26
28
  private guardForce;
29
+ private loadSource;
30
+ private logStillInactive;
27
31
  private result;
28
32
  private sync;
29
33
  }
@@ -1,86 +1,104 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { confirmUninstall } from '../../lib/interactive.js';
5
6
  import { PushCommand } from '../../lib/push-command.js';
6
- import { isNotFoundError } from '../../lib/wp-client.js';
7
- import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
7
+ import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
8
+ import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
+ import { requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
+ import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
11
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
9
- const IN_SYNC = { activated: [], installed: [], pinned: [], pruned: [], removed: [], status: 'in-sync' };
12
+ import { isExactVersion } from '../../utils/version.js';
13
+ const IN_SYNC = {
14
+ activated: [],
15
+ deactivated: [],
16
+ installed: [],
17
+ pinned: [],
18
+ pruned: [],
19
+ removed: [],
20
+ status: 'in-sync',
21
+ };
10
22
  export default class Push extends PushCommand {
11
- static description = 'Install plugins on WordPress to match loopress.json, pinned via Composer + WPackagist';
23
+ static description = 'Install plugins on WordPress to match loopress.json (or composer.json), via Composer + WPackagist';
12
24
  static enableJsonFlag = true;
13
25
  static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
14
26
  static flags = {
15
27
  ...PushCommand.dryRunFlag,
16
28
  ...PushCommand.yesFlag,
29
+ activate: Flags.boolean({
30
+ default: false,
31
+ description: 'composer.json projects only: activate every plugin it declares. Without it, plugins keep their current state and new ones stay inactive',
32
+ }),
17
33
  force: Flags.boolean({
18
34
  default: false,
19
35
  description: 'Allow downgrades and let Loopress take over plugins installed outside it (replaces their files)',
20
36
  }),
21
37
  prune: Flags.boolean({
22
38
  default: false,
23
- description: 'Deactivate plugins that are active on the site but absent from loopress.json',
39
+ description: 'Deactivate plugins that are active on the site but absent from loopress.json (or composer.json)',
24
40
  }),
25
41
  };
26
42
  async run() {
27
43
  const { flags } = await this.parse(Push);
28
- const { force, prune } = flags;
29
- if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
30
- this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer push` instead.');
31
- return { ...IN_SYNC, status: 'composer-managed' };
32
- }
33
- const manifest = this.localConfig.plugins ?? {};
34
- if (Object.keys(manifest).length === 0) {
35
- this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
36
- }
44
+ const { activate, force, prune } = flags;
45
+ const source = await this.loadSource(activate);
46
+ const fromComposer = source.file === 'composer.json';
37
47
  this.log(`Pushing plugins to ${this.siteConfig.url}`);
48
+ if (fromComposer) {
49
+ this.log(`From composer.json, pushed whole (${source.packageCount} packages): themes and libraries it declares are synced too.`);
50
+ }
38
51
  const raw = await this.wp.get('wp/v2/plugins');
39
52
  const installed = parseInstalledPlugins(raw);
40
53
  const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'plugin');
41
- const diff = diffPlugins(manifest, installed, managed);
42
- this.guardForce(diff, force);
54
+ const diff = diffPlugins(source.manifest, installed, managed);
55
+ // composer.json records no active state: leave inactive plugins alone unless --activate.
56
+ const skippedActivations = fromComposer && !activate ? diff.toActivate : [];
57
+ if (fromComposer && !activate)
58
+ diff.toActivate = [];
59
+ this.guardForce(diff, force, source.file);
43
60
  const toPrune = prune ? diff.untrackedActive : [];
44
- // A "latest" pin never shows as drift, but its newest upstream release may have moved
45
- // since the last push, so a push must still run `composer update` to pick it up.
46
- const hasLatestPin = Object.values(manifest).includes('latest');
47
- if (!hasLatestPin && isNoop(diff, toPrune)) {
61
+ // A "latest" pin (or a Composer constraint) never shows as drift, but its newest upstream
62
+ // release may have moved since the last push, so a push must still run `composer update`.
63
+ const hasFloatingPin = Object.values(source.manifest).some((pin) => !isExactVersion(pinVersion(pin)));
64
+ if (!hasFloatingPin && isNoop(diff, toPrune)) {
48
65
  this.log('Everything is already in sync.');
66
+ this.logStillInactive(skippedActivations);
49
67
  return { ...IN_SYNC };
50
68
  }
51
69
  if (isNoop(diff, toPrune))
52
- this.log('Refreshing plugins pinned to "latest" to their newest releases.');
70
+ this.log('Refreshing plugins not pinned to an exact version to their newest releases.');
53
71
  logPlan(this, diff, toPrune, force);
54
72
  if (this.dryRun) {
55
73
  return this.result(diff, { includeCollisions: true, pruned: toPrune, removed: diff.toRemove, status: 'dry-run' });
56
74
  }
57
75
  if (!(await confirmUninstall(diff.toRemove, this.yes)))
58
76
  this.error('Aborted.');
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];
77
+ // Plugins switched off for the file swap come back on, unless loopress.json wants them inactive.
78
+ const deactivated = (await this.deactivateEndangered(installed, diff, force)).filter((p) => !diff.toRemove.includes(p.slug) && (fromComposer || pinActive(source.manifest[p.slug])));
63
79
  let response;
64
80
  try {
65
- response = await this.sync(manifest, force);
81
+ response = await this.sync(source.intent, force);
66
82
  }
67
83
  catch (error) {
68
84
  // The file swap never happened, so restore the plugins we defensively deactivated
69
85
  // instead of leaving the site with them switched off.
70
- await this.activate(deactivated.filter((p) => !diff.toRemove.includes(p.slug)));
86
+ await this.activate(deactivated);
71
87
  throw error;
72
88
  }
73
- toReactivate.push(...(await this.filesForNewlyInstalled(diff.toInstall)));
74
- if (toPrune.length > 0) {
75
- await this.deactivate(installed.filter((p) => toPrune.includes(p.slug)));
76
- }
77
- await this.activate(toReactivate);
89
+ const activateNew = (slug) => (fromComposer ? activate : pinActive(source.manifest[slug]));
90
+ const newlyInstalled = await this.filesForNewlyInstalled(diff.toInstall);
91
+ const toActivate = [...deactivated, ...diff.toActivate, ...newlyInstalled.filter((p) => activateNew(p.slug))];
92
+ const toDeactivate = [...diff.toDeactivate, ...installed.filter((p) => toPrune.includes(p.slug))];
93
+ await this.deactivate(toDeactivate);
94
+ await this.activate(toActivate);
78
95
  if (response.output.trim())
79
96
  this.log(response.output.trim());
80
97
  this.log('Plugins synced.');
98
+ this.logStillInactive([...skippedActivations, ...newlyInstalled.filter((p) => !activateNew(p.slug))], fromComposer);
81
99
  await this.recordSuccess();
82
100
  return this.result(diff, {
83
- activated: toReactivate.map((p) => p.slug),
101
+ activated: toActivate.map((p) => p.slug),
84
102
  includeCollisions: force,
85
103
  pruned: toPrune,
86
104
  removed: response.removed ?? diff.toRemove,
@@ -140,26 +158,60 @@ export default class Push extends PushCommand {
140
158
  return file ? [{ file, slug }] : [];
141
159
  });
142
160
  }
143
- guardForce(diff, force) {
161
+ guardForce(diff, force, file) {
144
162
  if (force)
145
163
  return;
146
164
  if (diff.collisions.length > 0) {
147
165
  const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
148
166
  this.error(`${diff.collisions.length} plugin(s) are already installed outside Loopress: ${list}. ` +
149
167
  'Re-run with --force to let Loopress manage them (this replaces their files with the WPackagist build, ' +
150
- 'local modifications are lost), or remove them from loopress.json.');
168
+ `local modifications are lost), or remove them from ${file}.`);
151
169
  }
152
170
  const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
153
171
  if (downgrades.length > 0) {
154
172
  const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
155
173
  this.error(`Refusing to downgrade: ${list}. A downgrade only replaces plugin files, it does not undo database ` +
156
- 'migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in loopress.json.');
174
+ `migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in ${file}.`);
157
175
  }
158
176
  }
177
+ async loadSource(activate) {
178
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
179
+ if (existsSync(composerJsonPath)) {
180
+ const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
181
+ const includeDev = isLocalEnvironment(this.siteConfig.name);
182
+ const manifest = wpackagistRequire(composerJson, 'plugin', includeDev);
183
+ if (Object.keys(manifest).length === 0) {
184
+ this.error('No wpackagist-plugin/* package in composer.json. Run `lps plugin pull` first, or `lps composer push` to push it as is.');
185
+ }
186
+ const require = requireToInstall(composerJson, includeDev);
187
+ return { file: 'composer.json', intent: toIntent(require), manifest, packageCount: Object.keys(require).length };
188
+ }
189
+ if (activate) {
190
+ this.error('--activate only applies to projects with a composer.json: loopress.json already records whether each plugin is active.');
191
+ }
192
+ const manifest = this.localConfig.plugins ?? {};
193
+ if (Object.keys(manifest).length === 0) {
194
+ this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
195
+ }
196
+ return {
197
+ file: 'loopress.json',
198
+ intent: { plugins: pinVersions(manifest) },
199
+ manifest,
200
+ packageCount: Object.keys(manifest).length,
201
+ };
202
+ }
203
+ // composer.json projects: plugins this push left inactive, and how to change that.
204
+ logStillInactive(plugins, fromComposer = true) {
205
+ if (!fromComposer || plugins.length === 0)
206
+ return;
207
+ this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
208
+ this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
209
+ }
159
210
  result(diff, opts) {
160
211
  const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
161
212
  return {
162
213
  activated: opts.activated ?? diff.toActivate.map((a) => a.slug),
214
+ deactivated: diff.toDeactivate.map((a) => a.slug),
163
215
  installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
164
216
  pinned: diff.toPin.map((p) => p.slug),
165
217
  pruned: opts.pruned,
@@ -167,9 +219,9 @@ export default class Push extends PushCommand {
167
219
  status: opts.status,
168
220
  };
169
221
  }
170
- async sync(manifest, force) {
222
+ async sync(intent, force) {
171
223
  try {
172
- return await this.wp.post('loopress/v1/composer/sync', { force, intent: { plugins: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
224
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
173
225
  }
174
226
  catch (error) {
175
227
  const collisions = parseCollisions(error);
@@ -184,6 +236,7 @@ function isNoop(diff, toPrune) {
184
236
  return (diff.toInstall.length === 0 &&
185
237
  diff.toPin.length === 0 &&
186
238
  diff.toActivate.length === 0 &&
239
+ diff.toDeactivate.length === 0 &&
187
240
  diff.toRemove.length === 0 &&
188
241
  diff.collisions.length === 0 &&
189
242
  toPrune.length === 0);
@@ -201,6 +254,7 @@ function logPlan(cmd, diff, prune, force) {
201
254
  section('To take over', diff.collisions.map((c) => `! ${c.slug} (installed outside Loopress)`));
202
255
  section('To re-pin', diff.toPin.map((p) => `~ ${p.slug} ${p.from} to ${p.to}`));
203
256
  section('To activate', diff.toActivate.map((a) => `↑ ${a.slug}`));
257
+ section('To deactivate', diff.toDeactivate.map((a) => `↓ ${a.slug}`));
204
258
  section('To uninstall', diff.toRemove.map((s) => `- ${s}`));
205
259
  section('To deactivate (--prune)', prune.map((s) => `⊘ ${s}`));
206
260
  }
@@ -4,6 +4,7 @@ type StatusResult = {
4
4
  inactive: string[];
5
5
  missing: string[];
6
6
  status: 'composer-managed' | 'drift' | 'in-sync';
7
+ unexpectedlyActive?: string[];
7
8
  untrackedActive: string[];
8
9
  wrongVersion: Array<{
9
10
  live: string;
@@ -12,7 +12,14 @@ export default class Status extends LoopressCommand {
12
12
  const { url } = this.siteConfig;
13
13
  if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
14
14
  this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer` commands instead.');
15
- return { drift: false, inactive: [], missing: [], status: 'composer-managed', untrackedActive: [], wrongVersion: [] };
15
+ return {
16
+ drift: false,
17
+ inactive: [],
18
+ missing: [],
19
+ status: 'composer-managed',
20
+ untrackedActive: [],
21
+ wrongVersion: [],
22
+ };
16
23
  }
17
24
  const manifest = this.localConfig.plugins ?? {};
18
25
  const raw = await this.wp.get('wp/v2/plugins');
@@ -22,6 +29,7 @@ export default class Status extends LoopressCommand {
22
29
  const missing = diff.toInstall.map((a) => a.slug);
23
30
  const wrongVersion = diff.toPin.map((p) => ({ live: p.from, pinned: p.to, slug: p.slug }));
24
31
  const inactive = diff.toActivate.map((a) => a.slug);
32
+ const unexpectedlyActive = diff.toDeactivate.map((a) => a.slug);
25
33
  const { untrackedActive } = diff;
26
34
  // `collisions` are also drift (Loopress doesn't own them yet); surface them under missing's
27
35
  // banner so `plugin push --force` is the obvious next step.
@@ -29,6 +37,7 @@ export default class Status extends LoopressCommand {
29
37
  const drift = missing.length > 0 ||
30
38
  wrongVersion.length > 0 ||
31
39
  inactive.length > 0 ||
40
+ unexpectedlyActive.length > 0 ||
32
41
  diff.toRemove.length > 0 ||
33
42
  collisions.length > 0;
34
43
  if (!drift && untrackedActive.length === 0) {
@@ -43,13 +52,23 @@ export default class Status extends LoopressCommand {
43
52
  this.log(`Version drift: ${w.slug} is ${w.live}, loopress.json pins ${w.pinned}`);
44
53
  if (inactive.length > 0)
45
54
  this.log(`Pinned but inactive: ${inactive.join(', ')}`);
55
+ if (unexpectedlyActive.length > 0)
56
+ this.log(`Pinned inactive but active: ${unexpectedlyActive.join(', ')}`);
46
57
  if (diff.toRemove.length > 0)
47
58
  this.log(`Managed by Loopress but dropped from loopress.json: ${diff.toRemove.join(', ')}`);
48
59
  if (untrackedActive.length > 0)
49
60
  this.log(`Active but untracked: ${untrackedActive.join(', ')}`);
50
61
  if (drift)
51
62
  this.exit(1);
52
- return { drift, inactive, missing, status: drift ? 'drift' : 'in-sync', untrackedActive, wrongVersion };
63
+ return {
64
+ drift,
65
+ inactive,
66
+ missing,
67
+ status: drift ? 'drift' : 'in-sync',
68
+ unexpectedlyActive,
69
+ untrackedActive,
70
+ wrongVersion,
71
+ };
53
72
  }
54
73
  async fetchInstanceLock() {
55
74
  try {
@@ -5,7 +5,7 @@ import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
5
5
  import { isInteractive } from '../../lib/interactive.js';
6
6
  import { isLoopressFullActive } from '../../lib/plugin-detection.js';
7
7
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
- import { WpClient } from '../../lib/wp-client.js';
8
+ import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
9
9
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
10
10
  const NEW_PROJECT = '__new__';
11
11
  const AUTH_BROWSER = 'browser';
@@ -48,16 +48,20 @@ export default class Config extends Command {
48
48
  const rawUrl = await input({
49
49
  message: 'WordPress URL',
50
50
  validate(value) {
51
+ let parsed;
51
52
  try {
52
- const parsed = new URL(value);
53
- if (!['http:', 'https:'].includes(parsed.protocol)) {
54
- return 'URL must start with http:// or https://';
55
- }
56
- return true;
53
+ parsed = new URL(value);
57
54
  }
58
55
  catch {
59
56
  return 'Invalid URL';
60
57
  }
58
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
59
+ return 'URL must start with http:// or https://';
60
+ }
61
+ if (parsed.protocol === 'http:' && !isLocalEnvironment(envName)) {
62
+ return `Plain HTTP would send the Application Password in cleartext. Use HTTPS. Plain HTTP is allowed only for the "local" environment (this one is "${envName}").`;
63
+ }
64
+ return true;
61
65
  },
62
66
  });
63
67
  let url = rawUrl;
@@ -88,10 +92,10 @@ export default class Config extends Command {
88
92
  this.log('→ Run `lps project switch` to change the active project or environment');
89
93
  }
90
94
  async maybeBootstrapLoopressFull(env) {
91
- const { token, url } = env;
95
+ const { name, token, url } = env;
92
96
  if (!token)
93
97
  return; // env was just built with one a few lines up; guard only narrows the type
94
- const wp = new WpClient(url, token);
98
+ const wp = new WpClient(url, token, name);
95
99
  // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
96
100
  // there" rather than surprising the user with an account-creating prompt they didn't ask
97
101
  // for; `lps project config` can simply be re-run if it genuinely isn't installed.
@@ -17,8 +17,10 @@ export default class Promote extends Command {
17
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  };
19
19
  run(): Promise<PromoteResult>;
20
+ private assertDirsInsideProject;
20
21
  private confirmPromotion;
21
22
  private delegateArgv;
23
+ private enterScratchCopy;
22
24
  private requireEnvironment;
23
25
  private resolveProject;
24
26
  }