@loopress/cli 0.27.0 → 0.28.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 (75) hide show
  1. package/README.md +267 -175
  2. package/dist/commands/app/pull.js +17 -4
  3. package/dist/commands/app/push.d.ts +3 -0
  4. package/dist/commands/app/push.js +29 -5
  5. package/dist/commands/composer/push.js +1 -19
  6. package/dist/commands/diff.js +1 -1
  7. package/dist/commands/init.js +2 -0
  8. package/dist/commands/plugin/add.js +6 -3
  9. package/dist/commands/plugin/audit.js +4 -5
  10. package/dist/commands/plugin/pull.d.ts +3 -1
  11. package/dist/commands/plugin/pull.js +17 -9
  12. package/dist/commands/plugin/push.d.ts +5 -1
  13. package/dist/commands/plugin/push.js +90 -37
  14. package/dist/commands/plugin/status.d.ts +1 -0
  15. package/dist/commands/plugin/status.js +21 -2
  16. package/dist/commands/project/config.js +12 -8
  17. package/dist/commands/promote.d.ts +2 -0
  18. package/dist/commands/promote.js +69 -8
  19. package/dist/commands/pull.js +2 -2
  20. package/dist/commands/push.d.ts +1 -0
  21. package/dist/commands/push.js +37 -3
  22. package/dist/commands/template/diff.d.ts +10 -0
  23. package/dist/commands/template/diff.js +13 -0
  24. package/dist/commands/template/list.d.ts +13 -0
  25. package/dist/commands/template/list.js +23 -0
  26. package/dist/commands/template/push.d.ts +20 -0
  27. package/dist/commands/template/push.js +42 -0
  28. package/dist/commands/theme/pull.d.ts +3 -1
  29. package/dist/commands/theme/pull.js +10 -4
  30. package/dist/commands/theme/style/diff.d.ts +2 -0
  31. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  32. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  33. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  34. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  35. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  36. package/dist/commands/theme/style/rollback.d.ts +2 -0
  37. package/dist/commands/theme/style/rollback.js +6 -0
  38. package/dist/lib/api-client.js +3 -1
  39. package/dist/lib/app-build.d.ts +5 -0
  40. package/dist/lib/app-build.js +69 -0
  41. package/dist/lib/app-manifest.d.ts +4 -0
  42. package/dist/lib/app-manifest.js +8 -0
  43. package/dist/lib/base.js +11 -2
  44. package/dist/lib/bootstrap-full-install.js +45 -14
  45. package/dist/lib/dev-watch.d.ts +1 -1
  46. package/dist/lib/dev-watch.js +3 -1
  47. package/dist/lib/diff-command.d.ts +1 -0
  48. package/dist/lib/diff-command.js +6 -4
  49. package/dist/lib/resource-state.js +43 -1
  50. package/dist/lib/rollback-command.d.ts +1 -0
  51. package/dist/lib/rollback-command.js +8 -6
  52. package/dist/lib/rotate-app-password.js +2 -2
  53. package/dist/lib/validate-local.js +6 -0
  54. package/dist/lib/wp-client.d.ts +4 -1
  55. package/dist/lib/wp-client.js +25 -1
  56. package/dist/types/plugin.d.ts +8 -1
  57. package/dist/types/plugin.js +4 -1
  58. package/dist/types/project-config.generated.d.ts +19 -2
  59. package/dist/utils/composer.d.ts +14 -0
  60. package/dist/utils/composer.js +136 -0
  61. package/dist/utils/page-format.d.ts +8 -0
  62. package/dist/utils/page-format.js +34 -26
  63. package/dist/utils/plugins.d.ts +9 -4
  64. package/dist/utils/plugins.js +16 -7
  65. package/dist/utils/resource-dirs.d.ts +2 -0
  66. package/dist/utils/resource-dirs.js +4 -0
  67. package/dist/utils/seo-format.d.ts +2 -2
  68. package/dist/utils/template-format.d.ts +30 -0
  69. package/dist/utils/template-format.js +36 -0
  70. package/oclif.manifest.json +526 -324
  71. package/package.json +3 -2
  72. package/schemas/project-config.schema.json +25 -4
  73. package/dist/commands/theme-styles/diff.d.ts +0 -2
  74. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  75. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -2,9 +2,9 @@ import { confirm } from '@inquirer/prompts';
2
2
  import { Args } from '@oclif/core';
3
3
  import { Buffer } from 'node:buffer';
4
4
  import { existsSync } from 'node:fs';
5
- import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
5
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import { dirname, join } from 'node:path';
7
- import { APP_CONFIG_FILENAME } from '../../lib/app-manifest.js';
7
+ import { APP_CONFIG_FILENAME, parseAppConfig } from '../../lib/app-manifest.js';
8
8
  import { LoopressCommand } from '../../lib/base.js';
9
9
  import { isInteractive } from '../../lib/interactive.js';
10
10
  import { pluralize } from '../../utils/pluralize.js';
@@ -60,14 +60,27 @@ export default class Pull extends LoopressCommand {
60
60
  }
61
61
  async pullApp(path, name) {
62
62
  const manifest = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
63
- const distDir = join(path, name, 'dist');
63
+ const appDir = join(path, name);
64
+ const configPath = join(appDir, APP_CONFIG_FILENAME);
65
+ // WordPress doesn't know where the app builds to (assetsDir) or whether its entry is explicit:
66
+ // keep what the local config says, only refresh what the server is the source of truth for.
67
+ const local = existsSync(configPath) ? parseAppConfig(await readFile(configPath, 'utf8'), appDir) : {};
68
+ const assetsDir = local.assetsDir ?? 'dist';
69
+ const distDir = join(appDir, assetsDir);
64
70
  for (const file of manifest.files) {
65
71
  const { content } = await this.wp.get(`loopress/v1/apps/${name}/asset?path=${encodeURIComponent(file.path)}`);
66
72
  const target = join(distDir, file.path);
67
73
  await mkdir(dirname(target), { recursive: true });
68
74
  await writeFile(target, Buffer.from(content, 'base64'));
69
75
  }
70
- await writeFile(join(path, name, APP_CONFIG_FILENAME), JSON.stringify({ assetsDir: 'dist', mountSelector: manifest.mountSelector, name: manifest.name, routing: manifest.routing }, null, 2) + '\n');
76
+ await writeFile(configPath, JSON.stringify({
77
+ ...local,
78
+ assetsDir,
79
+ crossOrigin: manifest.crossOrigin ?? undefined, // dropped by JSON.stringify when unset
80
+ mountSelector: manifest.mountSelector,
81
+ name: manifest.name,
82
+ routing: manifest.routing,
83
+ }, null, 2) + '\n');
71
84
  this.log(` ${name}: ${pluralize(manifest.files.length, 'file')} (build ${manifest.buildId})`);
72
85
  return { files: manifest.files.length, name };
73
86
  }
@@ -18,11 +18,14 @@ export default class Push extends PushCommand {
18
18
  static flags: {
19
19
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ build: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
22
  };
23
+ private build;
22
24
  run(): Promise<PushResult>;
23
25
  private appDirNames;
24
26
  private pushApp;
25
27
  private remoteFiles;
26
28
  private uploadAsset;
29
+ private warnIfCrossOriginIgnored;
27
30
  }
28
31
  export {};
@@ -1,7 +1,8 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { buildApp } from '../../lib/app-build.js';
5
6
  import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
6
7
  import { PushCommand } from '../../lib/push-command.js';
7
8
  import { isNotFoundError } from '../../lib/wp-client.js';
@@ -10,15 +11,22 @@ export default class Push extends PushCommand {
10
11
  static args = {
11
12
  name: Args.string({ description: 'Push only this app (defaults to every app in the directory)' }),
12
13
  };
13
- static description = 'Push built single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a built dist/ folder. Only files whose content changed are uploaded.';
14
+ static description = 'Build and push single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a dist/ folder. When the app has a package.json with a `build` script, it runs first (use --no-build to skip it). Only files whose content changed are uploaded.';
14
15
  static enableJsonFlag = true;
15
- static examples = ['$ lps app push', '$ lps app push search'];
16
+ static examples = ['$ lps app push', '$ lps app push search', '$ lps app push --no-build'];
16
17
  static flags = {
18
+ build: Flags.boolean({
19
+ allowNo: true,
20
+ default: true,
21
+ description: "Run the app's package.json build script before pushing",
22
+ }),
17
23
  ...PushCommand.dryRunFlag,
18
24
  ...PushCommand.yesFlag,
19
25
  };
26
+ build = true;
20
27
  async run() {
21
- const { args } = await this.parse(Push);
28
+ const { args, flags } = await this.parse(Push);
29
+ this.build = flags.build;
22
30
  const { url } = this.siteConfig;
23
31
  const path = this.resolveAppsPath();
24
32
  this.log(`Pushing apps to ${url}`);
@@ -71,6 +79,13 @@ export default class Push extends PushCommand {
71
79
  let manifest;
72
80
  let distDir;
73
81
  try {
82
+ // Built on dry runs too: the preview diffs dist/, so a stale one would make it lie.
83
+ if (this.build) {
84
+ await buildApp(appDir, (command) => {
85
+ if (task)
86
+ task.output = `${dirName}: building (${command})`;
87
+ });
88
+ }
74
89
  ;
75
90
  ({ distDir, manifest } = await loadAppManifest(appDir, dirName));
76
91
  }
@@ -93,12 +108,14 @@ export default class Push extends PushCommand {
93
108
  task.output = `${manifest.name}: uploading ${file.path} (${index + 1}/${changed.length})`;
94
109
  await this.uploadAsset(manifest.name, distDir, file);
95
110
  }
111
+ let committed;
96
112
  try {
97
- await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
113
+ committed = await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
98
114
  }
99
115
  catch (error) {
100
116
  this.reportTaskFailure(`${manifest.name}: commit failed: ${error.message}`, error, task);
101
117
  }
118
+ this.warnIfCrossOriginIgnored(manifest, committed);
102
119
  if (task) {
103
120
  task.output =
104
121
  changed.length === 0
@@ -127,4 +144,11 @@ export default class Push extends PushCommand {
127
144
  this.reportTaskFailure(`${name}: failed to upload ${file.path}: ${error.message}`, error);
128
145
  }
129
146
  }
147
+ // An older plugin, or an app .htaccess the site owner manages, commits the build without the
148
+ // policy: say so rather than let the app look protected when it is not.
149
+ warnIfCrossOriginIgnored(manifest, committed) {
150
+ if (manifest.crossOrigin && committed?.crossOrigin !== manifest.crossOrigin) {
151
+ this.warn(`${manifest.name}: crossOrigin "${manifest.crossOrigin}" was not applied: either the Loopress plugin on this site is too old (update it, then push again) or wp-content/loopress/apps/${manifest.name}/.htaccess is managed by hand and was left untouched.`);
152
+ }
153
+ }
130
154
  }
@@ -4,26 +4,8 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { isTimeoutError } from '../../lib/wp-client.js';
7
+ import { toIntent } from '../../utils/composer.js';
7
8
  import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
- // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
- // understands. `composer/installers` is owned by the server scaffold, never sent.
10
- function toIntent(require) {
11
- const intent = { libraries: {}, plugins: {}, themes: {} };
12
- for (const [name, constraint] of Object.entries(require)) {
13
- if (name === 'composer/installers')
14
- continue;
15
- if (name.startsWith('wpackagist-plugin/')) {
16
- intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
17
- }
18
- else if (name.startsWith('wpackagist-theme/')) {
19
- intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
20
- }
21
- else {
22
- intent.libraries[name] = constraint;
23
- }
24
- }
25
- return intent;
26
- }
27
9
  export default class ComposerPush extends PushCommand {
28
10
  static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
29
11
  static enableJsonFlag = true;
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
7
7
  export default class Diff extends DiffCommand {
8
8
  static description = [
9
9
  'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
10
- 'Covers snippets, forms, ACF, API routes, Hooks, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
10
+ 'Covers snippets, forms, ACF, API routes, Hooks, templates, template parts, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
11
11
  'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
12
12
  ].join(' ');
13
13
  static enableJsonFlag = true;
@@ -21,6 +21,8 @@ const FEATURES = [
21
21
  { dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
22
22
  { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
23
23
  { dir: 'pages', key: 'pageDir', label: 'Static pages' },
24
+ { dir: 'templates', key: 'templateDir', label: 'Block templates' },
25
+ { dir: 'parts', key: 'partDir', label: 'Block template parts' },
24
26
  ];
25
27
  export default class Init extends Command {
26
28
  static description = 'Initialize a loopress.json config file in the current directory';
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { pinVersion } from '../../types/plugin.js';
3
4
  import { writeLocalConfig } from '../../utils/loopress-config.js';
4
5
  import { isExactVersion } from '../../utils/version.js';
5
6
  export default class Add extends LoopressCommand {
@@ -25,18 +26,20 @@ export default class Add extends LoopressCommand {
25
26
  this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
26
27
  }
27
28
  const existing = this.localConfig.plugins ?? {};
28
- if (existing[slug] === version) {
29
+ const current = existing[slug];
30
+ if (current !== undefined && pinVersion(current) === version) {
29
31
  this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
30
32
  return { slug, status: 'unchanged', version };
31
33
  }
32
- const isUpdated = existing[slug] !== undefined;
34
+ const isUpdated = current !== undefined;
33
35
  if (this.dryRun) {
34
36
  this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
35
37
  return { slug, status: 'dry-run', version };
36
38
  }
37
39
  await writeLocalConfig({
38
40
  ...this.localConfig,
39
- plugins: { ...existing, [slug]: version },
41
+ // Re-pinning a plugin recorded as inactive keeps it inactive.
42
+ plugins: { ...existing, [slug]: typeof current === 'object' ? { ...current, version } : version },
40
43
  });
41
44
  this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
42
45
  return { slug, status: isUpdated ? 'updated' : 'added', version };
@@ -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,7 +1,9 @@
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';
5
7
  };
6
8
  export default class Pull extends LoopressCommand {
7
9
  static description: string;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
+ import { pullIntoComposerJson } from '../../utils/composer.js';
4
5
  import { writeLocalConfig } from '../../utils/loopress-config.js';
5
6
  import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
6
7
  export default class Pull extends LoopressCommand {
@@ -12,18 +13,25 @@ export default class Pull extends LoopressCommand {
12
13
  };
13
14
  async run() {
14
15
  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
16
  this.log(`Pulling plugins from ${url}`);
20
17
  const raw = await this.wp.get('wp/v2/plugins');
21
18
  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);
19
+ // Pin every plugin to the version actually running on the site, and record the inactive ones
20
+ // as such so a later `plugin push` doesn't switch them on. A later `plugin push` installs
21
+ // exactly this set via Composer + WPackagist; drift only surfaces when the pinned version and
22
+ // the live version disagree.
23
+ const versions = Object.fromEntries(installed.map((p) => [p.slug, p.version]));
24
+ const pins = Object.fromEntries(installed.map((p) => [p.slug, p.active ? p.version : { active: false, version: p.version }]));
25
+ // A composer.json is authoritative over loopress.json: pin the live versions there instead.
26
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
27
+ if (existsSync(composerJsonPath)) {
28
+ const result = await pullIntoComposerJson(composerJsonPath, 'plugin', versions, {
29
+ dryRun: this.dryRun,
30
+ log: this.log.bind(this),
31
+ });
32
+ return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
33
+ }
34
+ const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, pins);
27
35
  if (this.dryRun) {
28
36
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
29
37
  if (added.length > 0)
@@ -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
7
  import { isNotFoundError } from '../../lib/wp-client.js';
7
- import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
8
+ import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
+ import { 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,59 @@ 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 manifest = wpackagistRequire(composerJson, 'plugin');
182
+ if (Object.keys(manifest).length === 0) {
183
+ this.error('No wpackagist-plugin/* package in composer.json. Run `lps plugin pull` first, or `lps composer push` to push it as is.');
184
+ }
185
+ const require = composerJson.require ?? {};
186
+ return { file: 'composer.json', intent: toIntent(require), manifest, packageCount: Object.keys(require).length };
187
+ }
188
+ if (activate) {
189
+ this.error('--activate only applies to projects with a composer.json: loopress.json already records whether each plugin is active.');
190
+ }
191
+ const manifest = this.localConfig.plugins ?? {};
192
+ if (Object.keys(manifest).length === 0) {
193
+ this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
194
+ }
195
+ return {
196
+ file: 'loopress.json',
197
+ intent: { plugins: pinVersions(manifest) },
198
+ manifest,
199
+ packageCount: Object.keys(manifest).length,
200
+ };
201
+ }
202
+ // composer.json projects: plugins this push left inactive, and how to change that.
203
+ logStillInactive(plugins, fromComposer = true) {
204
+ if (!fromComposer || plugins.length === 0)
205
+ return;
206
+ this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
207
+ this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
208
+ }
159
209
  result(diff, opts) {
160
210
  const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
161
211
  return {
162
212
  activated: opts.activated ?? diff.toActivate.map((a) => a.slug),
213
+ deactivated: diff.toDeactivate.map((a) => a.slug),
163
214
  installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
164
215
  pinned: diff.toPin.map((p) => p.slug),
165
216
  pruned: opts.pruned,
@@ -167,9 +218,9 @@ export default class Push extends PushCommand {
167
218
  status: opts.status,
168
219
  };
169
220
  }
170
- async sync(manifest, force) {
221
+ async sync(intent, force) {
171
222
  try {
172
- return await this.wp.post('loopress/v1/composer/sync', { force, intent: { plugins: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
223
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
173
224
  }
174
225
  catch (error) {
175
226
  const collisions = parseCollisions(error);
@@ -184,6 +235,7 @@ function isNoop(diff, toPrune) {
184
235
  return (diff.toInstall.length === 0 &&
185
236
  diff.toPin.length === 0 &&
186
237
  diff.toActivate.length === 0 &&
238
+ diff.toDeactivate.length === 0 &&
187
239
  diff.toRemove.length === 0 &&
188
240
  diff.collisions.length === 0 &&
189
241
  toPrune.length === 0);
@@ -201,6 +253,7 @@ function logPlan(cmd, diff, prune, force) {
201
253
  section('To take over', diff.collisions.map((c) => `! ${c.slug} (installed outside Loopress)`));
202
254
  section('To re-pin', diff.toPin.map((p) => `~ ${p.slug} ${p.from} to ${p.to}`));
203
255
  section('To activate', diff.toActivate.map((a) => `↑ ${a.slug}`));
256
+ section('To deactivate', diff.toDeactivate.map((a) => `↓ ${a.slug}`));
204
257
  section('To uninstall', diff.toRemove.map((s) => `- ${s}`));
205
258
  section('To deactivate (--prune)', prune.map((s) => `⊘ ${s}`));
206
259
  }
@@ -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 {