@loopress/cli 0.33.0 → 0.34.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 (62) hide show
  1. package/README.md +174 -161
  2. package/dist/commands/app/push.js +2 -1
  3. package/dist/commands/block/push.js +9 -4
  4. package/dist/commands/dev.d.ts +2 -2
  5. package/dist/commands/dev.js +10 -16
  6. package/dist/commands/diff.d.ts +6 -4
  7. package/dist/commands/diff.js +49 -29
  8. package/dist/commands/doctor.js +12 -2
  9. package/dist/commands/option/push.js +1 -0
  10. package/dist/commands/page/push.js +1 -0
  11. package/dist/commands/plugin/add.d.ts +1 -0
  12. package/dist/commands/plugin/add.js +50 -9
  13. package/dist/commands/plugin/push.js +39 -4
  14. package/dist/commands/project/config.js +0 -1
  15. package/dist/commands/promote.d.ts +1 -0
  16. package/dist/commands/promote.js +8 -4
  17. package/dist/commands/pull.d.ts +5 -0
  18. package/dist/commands/pull.js +26 -1
  19. package/dist/commands/push.d.ts +9 -0
  20. package/dist/commands/push.js +104 -39
  21. package/dist/commands/theme/style/diff.js +1 -1
  22. package/dist/commands/theme/style/push.js +1 -1
  23. package/dist/commands/theme/style/rollback.js +1 -1
  24. package/dist/commands/theme/template/push.d.ts +2 -0
  25. package/dist/commands/theme/template/push.js +42 -13
  26. package/dist/commands/validate.js +3 -0
  27. package/dist/lib/base.d.ts +6 -0
  28. package/dist/lib/base.js +33 -1
  29. package/dist/lib/declared-plugins.d.ts +9 -0
  30. package/dist/lib/declared-plugins.js +21 -0
  31. package/dist/lib/declared-type-command.js +3 -0
  32. package/dist/lib/dev-watch.d.ts +1 -1
  33. package/dist/lib/dev-watch.js +11 -13
  34. package/dist/lib/diff-command.d.ts +17 -0
  35. package/dist/lib/diff-command.js +125 -9
  36. package/dist/lib/environment-urls.d.ts +6 -0
  37. package/dist/lib/environment-urls.js +59 -0
  38. package/dist/lib/exclude.d.ts +16 -0
  39. package/dist/lib/exclude.js +102 -0
  40. package/dist/lib/load-snippets.js +2 -1
  41. package/dist/lib/php-files-command.js +5 -3
  42. package/dist/lib/push-command.d.ts +4 -0
  43. package/dist/lib/push-command.js +38 -2
  44. package/dist/lib/readdir-tolerant.js +6 -3
  45. package/dist/lib/resource-selection.d.ts +9 -0
  46. package/dist/lib/resource-selection.js +49 -0
  47. package/dist/lib/resource-state.d.ts +2 -0
  48. package/dist/lib/resource-state.js +25 -4
  49. package/dist/lib/rollback-command.js +1 -1
  50. package/dist/lib/rollback-materialize.js +1 -1
  51. package/dist/lib/validate-local.d.ts +1 -0
  52. package/dist/lib/validate-local.js +67 -8
  53. package/dist/lib/wp-client.d.ts +1 -0
  54. package/dist/lib/wp-client.js +6 -0
  55. package/dist/types/project-config.generated.d.ts +15 -4
  56. package/dist/utils/block-format.d.ts +2 -0
  57. package/dist/utils/block-format.js +18 -2
  58. package/dist/utils/page-format.js +2 -1
  59. package/dist/utils/resource-dirs.js +4 -4
  60. package/oclif.manifest.json +2275 -1394
  61. package/package.json +7 -7
  62. package/schemas/project-config.schema.json +21 -4
@@ -4,6 +4,7 @@ import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { buildApp } from '../../lib/app-build.js';
6
6
  import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
7
+ import { withoutExcluded } from '../../lib/exclude.js';
7
8
  import { errorCode, errorMessage } from '../../lib/narrow.js';
8
9
  import { PushCommand } from '../../lib/push-command.js';
9
10
  import { isNotFoundError } from '../../lib/wp-client.js';
@@ -52,7 +53,7 @@ export default class Push extends PushCommand {
52
53
  async appDirNames(path, only) {
53
54
  let entries;
54
55
  try {
55
- entries = await readdir(path, { withFileTypes: true });
56
+ entries = withoutExcluded(path, await readdir(path, { withFileTypes: true }), (entry) => entry.name);
56
57
  }
57
58
  catch (error) {
58
59
  if (errorCode(error) === 'ENOENT') {
@@ -3,7 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
3
3
  import { errorMessage } from '../../lib/narrow.js';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
5
  import { getResourceStateProvider } from '../../lib/resource-state.js';
6
- import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../../utils/block-format.js';
6
+ import { blockFilesToPush, BLOCKS_ENDPOINT, excludedBlockFiles, formatBlockProblems, readLocalBlocks } from '../../utils/block-format.js';
7
7
  import { pluralize } from '../../utils/pluralize.js';
8
8
  import { resolveResourceDir } from '../../utils/resource-dirs.js';
9
9
  export default class Push extends PushCommand {
@@ -20,9 +20,14 @@ export default class Push extends PushCommand {
20
20
  const path = resolveResourceDir('block', this.localConfig, args.path);
21
21
  this.log(`Pushing dynamic blocks to ${this.siteConfig.url}`);
22
22
  this.log(`Blocks path: ${path}`);
23
- const { blocks, problems } = await readLocalBlocks(path);
24
- if (problems.length > 0)
25
- this.error(formatBlockProblems(problems));
23
+ const read = await readLocalBlocks(path);
24
+ if (read.problems.length > 0)
25
+ this.error(formatBlockProblems(read.problems));
26
+ // An excluded file inside a block folder: the site's copy is sent back, read here rather than
27
+ // from the best-effort snapshot so a failed read stops the push instead of deleting it.
28
+ const withExcluded = read.blocks.some((block) => excludedBlockFiles(path, block.slug).length > 0);
29
+ const site = withExcluded && !this.dryRun ? new Map((await this.wp.get(BLOCKS_ENDPOINT)).map((block) => [block.slug, block.files])) : undefined;
30
+ const blocks = site ? read.blocks.map((block) => ({ ...block, files: blockFilesToPush(path, block, site.get(block.slug)) })) : read.blocks;
26
31
  this.log(`Found ${pluralize(blocks.length, 'block')} to push`);
27
32
  const provider = getResourceStateProvider('block');
28
33
  const beforeState = await this.captureBeforePushState(provider, path);
@@ -3,8 +3,8 @@ export default class Dev extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
- skip: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ only: ReturnType<typeof import("@oclif/core/flags").string>;
7
+ skip: ReturnType<typeof import("@oclif/core/flags").string>;
8
8
  };
9
9
  run(): Promise<void>;
10
10
  private parseTypeFlag;
@@ -1,24 +1,27 @@
1
- import { Command, Flags } from '@oclif/core';
1
+ import { Command } from '@oclif/core';
2
2
  import { watch as watchFiles } from 'chokidar';
3
3
  import { basename } from 'node:path';
4
4
  import { configManager } from '../config/project-config.manager.js';
5
5
  import { buildWatchTargets, createDebouncedBatcher, resolveResourceTypes, RESOURCE_TYPES, resourceTypeForPath } from '../lib/dev-watch.js';
6
+ import { disableExclusions } from '../lib/exclude.js';
6
7
  import { errorMessage, isOneOf } from '../lib/narrow.js';
8
+ import { parseSelectionFlag, selectionFlags } from '../lib/resource-selection.js';
7
9
  import { readLocalConfig } from '../utils/loopress-config.js';
8
10
  const DEBOUNCE_MS = 400;
9
11
  // Noise no one wants pushed: VCS internals, deps, and editor/OS droppings.
10
12
  const IGNORED = /(?:^|[/\\])(?:(?:\.git|node_modules|\.DS_Store)(?:[/\\]|$)|[^/\\]*\.swp$)/;
11
13
  export default class Dev extends Command {
12
14
  static description = 'Watch project files and push changes to the local WordPress instance as they happen. Always targets the "local" environment, run `lps snippet push` etc. directly for any other environment.';
13
- static examples = ['$ lps dev', '$ lps dev --only=snippets,api', '$ lps dev --skip=plugins'];
15
+ static examples = ['$ lps dev', '$ lps dev --only=snippet,api', '$ lps dev --skip=plugin'];
14
16
  static flags = {
15
- only: Flags.string({ description: `Only watch these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
16
- skip: Flags.string({ description: `Skip these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
17
+ ...selectionFlags('watch', RESOURCE_TYPES),
17
18
  };
18
19
  async run() {
19
20
  const { flags } = await this.parse(Dev);
20
- const only = flags.only ? this.parseTypeFlag(flags.only, 'only') : undefined;
21
- const skip = flags.skip ? this.parseTypeFlag(flags.skip, 'skip') : [];
21
+ // Dev only targets local, where an excluded feature is being built: it pushes everything.
22
+ disableExclusions();
23
+ const only = this.parseTypeFlag(flags.only, 'only');
24
+ const skip = this.parseTypeFlag(flags.skip, 'skip');
22
25
  const types = resolveResourceTypes(only, skip);
23
26
  const localConfig = await readLocalConfig();
24
27
  const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
@@ -36,16 +39,7 @@ export default class Dev extends Command {
36
39
  await this.watch(targets);
37
40
  }
38
41
  parseTypeFlag(raw, flagName) {
39
- return raw
40
- .split(',')
41
- .map((value) => value.trim())
42
- .filter(Boolean)
43
- .map((value) => {
44
- if (!isOneOf(RESOURCE_TYPES, value)) {
45
- this.error(`Unknown resource type "${value}" in --${flagName}. Valid types: ${RESOURCE_TYPES.join(', ')}`);
46
- }
47
- return value;
48
- });
42
+ return parseSelectionFlag(raw, flagName, RESOURCE_TYPES, (message) => this.error(message)).filter((value) => isOneOf(RESOURCE_TYPES, value));
49
43
  }
50
44
  // Sequential on purpose (see notes technique: parallelizing push is out of scope). A failed
51
45
  // push is logged and swallowed here, never thrown, so one bad batch can't take the watcher down.
@@ -4,12 +4,14 @@ export default class Diff extends DiffCommand {
4
4
  static enableJsonFlag: boolean;
5
5
  static examples: string[];
6
6
  static flags: {
7
- only: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
- skip: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ only: ReturnType<typeof import("@oclif/core/flags").string>;
8
+ skip: ReturnType<typeof import("@oclif/core/flags").string>;
9
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  };
11
12
  run(): Promise<DiffJson>;
13
+ private composerSiteTarget;
12
14
  private projectUses;
13
- private selectResources;
14
- private siteLacks;
15
+ private siteMissing;
16
+ private withSiteSkip;
15
17
  }
@@ -1,8 +1,9 @@
1
- import { Flags } from '@oclif/core';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { resolve } from 'node:path';
4
- import { getCapabilities, missingIntegration, missingModule } from '../lib/capabilities.js';
3
+ import { getCapabilities, integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
4
+ import { declaredPlugins } from '../lib/declared-plugins.js';
5
5
  import { DiffCommand } from '../lib/diff-command.js';
6
+ import { parseSelectionFlag, selectionFlags, selectResources } from '../lib/resource-selection.js';
6
7
  import { RESOURCE_STATE_PROVIDERS } from '../lib/resource-state.js';
7
8
  import {} from '../lib/wp-client.js';
8
9
  import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
@@ -26,30 +27,39 @@ export default class Diff extends DiffCommand {
26
27
  ];
27
28
  static flags = {
28
29
  ...DiffCommand.againstFlag,
29
- only: Flags.string({ description: 'Only compare these resources', multiple: true, options: [...RESOURCES] }),
30
- skip: Flags.string({ description: 'Compare every resource except these', multiple: true, options: [...RESOURCES] }),
30
+ ...DiffCommand.skipUnsupportedFlag,
31
+ ...selectionFlags('compare', RESOURCES),
31
32
  };
32
33
  async run() {
33
34
  const { flags } = await this.parse(Diff);
34
35
  const sides = this.resolveSides(flags.against);
35
- const selected = this.selectResources(flags.only, flags.skip);
36
+ const selected = new Set(selectResources(RESOURCES, parseSelectionFlag(flags.only, 'only', RESOURCES, (message) => this.error(message)), parseSelectionFlag(flags.skip, 'skip', RESOURCES, (message) => this.error(message))));
36
37
  const providers = RESOURCE_STATE_PROVIDERS.filter((provider) => selected.has(provider.resource));
38
+ const skipUnsupported = flags['skip-unsupported'];
37
39
  const targets = [
38
- ...(await Promise.all(providers.map(async (provider) => ({
39
- ...this.providerTarget(provider, sides),
40
- skip: !(await this.projectUses(provider)) && (await this.siteLacks((capabilities) => missingIntegration(provider.dirKind, capabilities), sides)),
41
- })))),
42
- ...(selected.has('composer')
43
- ? [
44
- {
45
- ...this.composerTarget(sides),
46
- skip: !existsSync(resolve(process.cwd(), this.rootDir, 'composer.json')) &&
47
- (await this.siteLacks((capabilities) => missingModule('dependencies', 'Composer files', capabilities), sides)),
48
- },
49
- ]
50
- : []),
40
+ ...(await Promise.all(providers.map(async (provider) => {
41
+ const lacking = await this.siteMissing((capabilities) => missingIntegration(provider.dirKind, capabilities), sides);
42
+ return this.withSiteSkip(this.providerTarget(provider, sides), lacking, await this.projectUses(provider), skipUnsupported);
43
+ }))),
44
+ ...(selected.has('composer') ? [await this.composerSiteTarget(sides, skipUnsupported)] : []),
51
45
  ];
52
- return this.report(targets, sides);
46
+ if (skipUnsupported) {
47
+ const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
48
+ for (const { unsupported } of targets) {
49
+ if (unsupported)
50
+ unsupported.fix = integrationFix(unsupported.missing, declared);
51
+ }
52
+ }
53
+ const result = await this.report(targets, sides);
54
+ // Only the environment against its local files is drift; two environments differing is not.
55
+ if (!sides.againstWp)
56
+ await this.reportDrift(result);
57
+ return result;
58
+ }
59
+ async composerSiteTarget(sides, skipUnsupported) {
60
+ const lacking = await this.siteMissing((capabilities) => missingModule('dependencies', 'Composer files', capabilities), sides);
61
+ const used = existsSync(resolve(process.cwd(), this.rootDir, 'composer.json'));
62
+ return this.withSiteSkip(this.composerTarget(sides), lacking, used, skipUnsupported);
53
63
  }
54
64
  // Tells apart a resource the project doesn't use from one it does, for the ones a site can lack:
55
65
  // a plugin-backed resource without its directory, Global Styles without a tracked file.
@@ -59,25 +69,35 @@ export default class Diff extends DiffCommand {
59
69
  }
60
70
  return projectUses(provider.dirKind, this.localConfig);
61
71
  }
62
- // `--only` narrows the default set, `--skip` then removes from whatever that set is, same
63
- // precedence a combined `--only=a,b --skip=b` reads as (mirrors dev-watch's resolveResourceTypes).
64
- selectResources(only, skip) {
65
- const base = only && only.length > 0 ? only : RESOURCES;
66
- return new Set(base.filter((resource) => !(skip ?? []).includes(resource)));
67
- }
68
72
  // Either side can't hold the resource. Capabilities that can't be read (site down, plugin too
69
73
  // old) skip nothing: the comparison then reports the failure itself, as inconclusive.
70
- async siteLacks(missing, sides) {
74
+ async siteMissing(missing, sides) {
71
75
  try {
72
76
  // Inside the try: `this.wp` errors out without credentials, the comparison reports that.
73
77
  const sites = [[this.wp, sides.leftLabel]];
74
78
  if (sides.againstWp)
75
79
  sites.push([sides.againstWp, sides.rightLabel]);
76
- const all = await Promise.all(sites.map(async ([wp, name]) => getCapabilities(wp, name)));
77
- return all.some((capabilities) => missing(capabilities) !== undefined);
80
+ const all = await Promise.all(sites.map(async ([wp, site]) => ({ capabilities: await getCapabilities(wp, site), site })));
81
+ for (const { capabilities, site } of all) {
82
+ const found = missing(capabilities);
83
+ if (found)
84
+ return { missing: found, site };
85
+ }
86
+ return undefined;
78
87
  }
79
88
  catch {
80
- return false;
89
+ return undefined;
81
90
  }
82
91
  }
92
+ // A resource the site can't hold is skipped when the project doesn't use it, and also when it
93
+ // does with --skip-unsupported, which then reports why instead of "not configured".
94
+ withSiteSkip(target, lacking, used, skipUnsupported) {
95
+ if (!lacking)
96
+ return target;
97
+ if (!used)
98
+ return { ...target, skip: true };
99
+ if (!skipUnsupported)
100
+ return target;
101
+ return { ...target, skip: true, unsupported: { missing: lacking.missing, reason: `${lacking.missing.reason} on ${lacking.site}` } };
102
+ }
83
103
  }
@@ -4,7 +4,7 @@ import {} from '../lib/capabilities.js';
4
4
  import { errorMessage } from '../lib/narrow.js';
5
5
  import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
6
6
  import { findTempAdmins } from '../lib/temp-admin.js';
7
- import { isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
7
+ import { isApplicationPasswordsDisabled, isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
8
8
  import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
9
9
  import { pluralize } from '../utils/pluralize.js';
10
10
  export default class Doctor extends LoopressCommand {
@@ -36,7 +36,17 @@ export default class Doctor extends LoopressCommand {
36
36
  const isInstalled = await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
37
37
  // wp/v2/users/me is WordPress core and requires authentication, so it validates the
38
38
  // application password without depending on any Loopress feature.
39
- const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
39
+ const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => {
40
+ try {
41
+ await this.wp.get('wp/v2/users/me');
42
+ }
43
+ catch (error) {
44
+ if (isApplicationPasswordsDisabled(error)) {
45
+ throw new Error('Application Passwords are disabled for this account. Enable them for this user (a security plugin or the `wp_is_application_passwords_available` filter may turn them off), or use another account.', { cause: error });
46
+ }
47
+ throw error;
48
+ }
49
+ });
40
50
  // Skipped when the credentials were just rejected: it would only repeat that same 401.
41
51
  if (isAuthenticated) {
42
52
  // Without the plugin it would only repeat that same 404.
@@ -40,6 +40,7 @@ export default class Push extends PushCommand {
40
40
  this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
41
41
  }
42
42
  this.log(`Found ${pluralize(writable.length, 'option')} to push`);
43
+ this.warnEnvironmentUrls(writable.map((option) => ({ id: `options/${option.name}`, value: option.value })));
43
44
  const pushed = [];
44
45
  await this.runPushTasks(writable, (option) => option.name, async (option, task) => {
45
46
  await this.pushOption(option, task);
@@ -35,6 +35,7 @@ export default class Push extends PushCommand {
35
35
  this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
36
36
  }
37
37
  this.log(`Found ${pluralize(selected.length, 'page')} to push`);
38
+ this.warnEnvironmentUrls(selected.map((page) => ({ id: `pages/${page.slug}`, value: page.html })));
38
39
  const provider = getResourceStateProvider('page');
39
40
  const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
40
41
  const pushed = [];
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
2
  type AddResult = {
3
+ dependencies: string[];
3
4
  slug: string;
4
5
  status: 'added' | 'dry-run' | 'unchanged' | 'updated';
5
6
  version: string;
@@ -1,6 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
+ import got from 'got';
2
3
  import { LoopressCommand } from '../../lib/base.js';
3
- import { pinVersion } from '../../types/plugin.js';
4
+ import { pinActive, pinVersion } from '../../types/plugin.js';
4
5
  import { writeLocalConfig } from '../../utils/loopress-config.js';
5
6
  import { isExactVersion } from '../../utils/version.js';
6
7
  export default class Add extends LoopressCommand {
@@ -26,22 +27,62 @@ export default class Add extends LoopressCommand {
26
27
  this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
27
28
  }
28
29
  const existing = this.localConfig.plugins ?? {};
30
+ const required = await requiredPlugins(slug);
31
+ const dependencies = required.filter((dep) => !Object.hasOwn(existing, dep));
32
+ // An inactive pin keeps its dependency off, so push could never activate `slug`.
33
+ const inactive = required.filter((dep) => Object.hasOwn(existing, dep) && !pinActive(existing[dep]));
34
+ if (inactive.length > 0) {
35
+ this.warn(`${slug} requires ${inactive.join(', ')}, pinned inactive in loopress.json. Remove \`"active": false\` there, or push cannot activate ${slug}.`);
36
+ }
29
37
  const current = existing[slug];
30
- if (current !== undefined && pinVersion(current) === version) {
38
+ if (current !== undefined && pinVersion(current) === version && dependencies.length === 0) {
31
39
  this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
32
- return { slug, status: 'unchanged', version };
40
+ return { dependencies, slug, status: 'unchanged', version };
33
41
  }
34
42
  const isUpdated = current !== undefined;
43
+ const requires = dependencies.length > 0 ? `, with required ${dependencies.join(', ')} (latest)` : '';
35
44
  if (this.dryRun) {
36
- this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
37
- return { slug, status: 'dry-run', version };
45
+ this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version})${requires} in loopress.json`);
46
+ return { dependencies, slug, status: 'dry-run', version };
38
47
  }
39
48
  await writeLocalConfig({
40
49
  ...this.localConfig,
41
- // Re-pinning a plugin recorded as inactive keeps it inactive.
42
- plugins: { ...existing, [slug]: typeof current === 'object' ? { ...current, version } : version },
50
+ plugins: {
51
+ ...existing,
52
+ ...Object.fromEntries(dependencies.map((dep) => [dep, 'latest'])),
53
+ // Re-pinning a plugin recorded as inactive keeps it inactive.
54
+ [slug]: typeof current === 'object' ? { ...current, version } : version,
55
+ },
43
56
  });
44
- this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
45
- return { slug, status: isUpdated ? 'updated' : 'added', version };
57
+ this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version})${requires}. Run \`lps plugin push\` to apply.`);
58
+ return { dependencies, slug, status: isUpdated ? 'updated' : 'added', version };
59
+ }
60
+ }
61
+ // Every plugin `slug` needs active first, transitively, from the `Requires Plugins` header
62
+ // WordPress.org exposes as `requires_plugins`. Not on WordPress.org or unreachable: none known,
63
+ // and `lps plugin push` still names a missing one when WordPress refuses the activation.
64
+ async function requiredPlugins(slug) {
65
+ const found = [];
66
+ const queue = [slug];
67
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
68
+ let info;
69
+ try {
70
+ info = await got('https://api.wordpress.org/plugins/info/1.2/', {
71
+ searchParams: { action: 'plugin_information', 'request[slug]': next },
72
+ timeout: { request: 10_000 },
73
+ }).json();
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const requires = Array.isArray(info.requires_plugins) ? info.requires_plugins : [];
79
+ for (const dep of requires) {
80
+ if (typeof dep !== 'string' || dep === slug || found.includes(dep)) {
81
+ continue;
82
+ }
83
+ found.push(dep);
84
+ queue.push(dep);
85
+ }
46
86
  }
87
+ return found;
47
88
  }
@@ -3,8 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { confirmUninstall } from '../../lib/interactive.js';
6
+ import { causeOf, isRecord } from '../../lib/narrow.js';
6
7
  import { PushCommand } from '../../lib/push-command.js';
7
- import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
8
+ import { isLocalEnvironment, isNotFoundError, parseBody, responseOf } from '../../lib/wp-client.js';
8
9
  import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
10
  import { parseComposerJson, requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
11
  import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
@@ -105,10 +106,33 @@ export default class Push extends PushCommand {
105
106
  status: 'success',
106
107
  });
107
108
  }
109
+ // WordPress refuses to activate a plugin whose `Requires Plugins` are inactive, and loopress.json
110
+ // has no dependency order (dokan-lite sorts before woocommerce). A refused plugin is retried
111
+ // after the others, until a round activates nothing.
108
112
  async activate(plugins) {
109
- for (const plugin of plugins) {
110
- this.log(` ⊙ activating ${plugin.slug}`);
111
- await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
113
+ let pending = plugins;
114
+ while (pending.length > 0) {
115
+ const blocked = [];
116
+ let missing = [];
117
+ for (const plugin of pending) {
118
+ this.log(` ⊙ activating ${plugin.slug}`);
119
+ try {
120
+ await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
121
+ }
122
+ catch (error) {
123
+ const notInstalled = missingDependencies(error);
124
+ if (!notInstalled)
125
+ throw error;
126
+ blocked.push(plugin);
127
+ missing = [...missing, ...notInstalled];
128
+ }
129
+ }
130
+ if (blocked.length === pending.length) {
131
+ const hint = missing.length > 0 ? ` Not installed: ${[...new Set(missing)].join(', ')}.` : '';
132
+ this.error(`Could not activate ${blocked.map((p) => p.slug).join(', ')}: WordPress reports required plugins missing or inactive.${hint} ` +
133
+ 'Add them with `lps plugin add <slug>`, then push again.');
134
+ }
135
+ pending = blocked;
112
136
  }
113
137
  }
114
138
  async deactivate(plugins) {
@@ -232,6 +256,17 @@ export default class Push extends PushCommand {
232
256
  }
233
257
  }
234
258
  }
259
+ // The dependency slugs not installed at all when WordPress refused an activation over unmet
260
+ // `Requires Plugins` (empty when they are only inactive), or null for any other error. The REST
261
+ // controller adds `{status: 500}` as the error data, which pushes activate_plugin()'s own data
262
+ // (`{not_installed, inactive}`) into `additional_data`.
263
+ function missingDependencies(error) {
264
+ const body = parseBody(responseOf(causeOf(error)).body);
265
+ if (body.code !== 'plugin_missing_dependencies')
266
+ return null;
267
+ const data = [body.data, ...(Array.isArray(body.additional_data) ? body.additional_data : [])];
268
+ return data.flatMap((d) => (isRecord(d) && isRecord(d.not_installed) ? Object.keys(d.not_installed) : []));
269
+ }
235
270
  function isNoop(diff, toPrune) {
236
271
  return (diff.toInstall.length === 0 &&
237
272
  diff.toPin.length === 0 &&
@@ -94,7 +94,6 @@ export default class Config extends Command {
94
94
  }
95
95
  this.log(`✓ "${projectName}/${envName}" configured`);
96
96
  await this.maybeBootstrapLoopressFull(env);
97
- this.log('→ Run `lps project switch` to change the active project or environment');
98
97
  }
99
98
  async maybeBootstrapLoopressFull(env) {
100
99
  const { name, token, url } = env;
@@ -14,6 +14,7 @@ export default class Promote extends Command {
14
14
  static examples: string[];
15
15
  static flags: {
16
16
  overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  };
@@ -20,15 +20,17 @@ export default class Promote extends Command {
20
20
  };
21
21
  static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
22
22
  static enableJsonFlag = true;
23
- static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
23
+ static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run', '$ lps promote staging production --skip-unsupported'];
24
24
  static flags = {
25
25
  ...LoopressCommand.dryRunFlag,
26
26
  ...LoopressCommand.yesFlag,
27
+ ...LoopressCommand.skipUnsupportedFlag,
27
28
  overwrite: PushCommand.baseFlags.overwrite,
28
29
  };
29
30
  async run() {
30
31
  const { args, flags } = await this.parse(Promote);
31
32
  const { overwrite, yes } = flags;
33
+ const skipUnsupported = flags['skip-unsupported'];
32
34
  const dryRun = flags['dry-run'];
33
35
  const { environments, projectName } = await this.resolveProject();
34
36
  const from = this.requireEnvironment(environments, projectName, args.from);
@@ -52,7 +54,7 @@ export default class Promote extends Command {
52
54
  try {
53
55
  this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
54
56
  try {
55
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
57
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false, skipUnsupported)));
56
58
  }
57
59
  catch (error) {
58
60
  // A partial pull must never be pushed onward: stop before touching <to>.
@@ -61,7 +63,7 @@ export default class Promote extends Command {
61
63
  this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${errorMessage(error)}`);
62
64
  }
63
65
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
64
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
66
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun, skipUnsupported), ...(overwrite ? ['--overwrite'] : [])]));
65
67
  }
66
68
  finally {
67
69
  if (scratch) {
@@ -114,8 +116,10 @@ export default class Promote extends Command {
114
116
  // `--yes` is always forwarded: confirmPromotion above already gathered intent once, so the
115
117
  // delegated `pull` and `push` must not prompt again (push would otherwise re-run its own
116
118
  // production guard, and pull its orphan-deletion prompt).
117
- delegateArgv(env, dryRun) {
119
+ delegateArgv(env, dryRun, skipUnsupported) {
118
120
  const argv = ['--env', env, '--yes'];
121
+ if (skipUnsupported)
122
+ argv.push('--skip-unsupported');
119
123
  if (dryRun)
120
124
  argv.push('--dry-run');
121
125
  return argv;
@@ -2,6 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
2
2
  type PullTargetResult = {
3
3
  error?: string;
4
4
  label: string;
5
+ reason?: string;
5
6
  status: 'failed' | 'pulled' | 'skipped';
6
7
  };
7
8
  type PullResult = {
@@ -12,14 +13,18 @@ export default class Pull extends LoopressCommand {
12
13
  static enableJsonFlag: boolean;
13
14
  static examples: string[];
14
15
  static flags: {
16
+ 'skip-unsupported': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  };
18
20
  private failedCount;
21
+ private skipUnsupported;
22
+ private readonly unsupported;
19
23
  run(): Promise<PullResult>;
20
24
  private buildArgv;
21
25
  private globalStylesSkipNote;
22
26
  private projectUses;
23
27
  private unavailable;
28
+ private warnUnsupported;
24
29
  }
25
30
  export {};
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
- import { missingIntegration, missingModule } from '../lib/capabilities.js';
2
+ import { integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
3
+ import { declaredPlugins } from '../lib/declared-plugins.js';
3
4
  import { stdoutToStderr } from '../lib/json-delegation.js';
4
5
  import { errorMessage } from '../lib/narrow.js';
5
6
  import { pluralize } from '../utils/pluralize.js';
@@ -42,9 +43,15 @@ export default class Pull extends LoopressCommand {
42
43
  static flags = {
43
44
  ...LoopressCommand.dryRunFlag,
44
45
  ...LoopressCommand.yesFlag,
46
+ ...LoopressCommand.skipUnsupportedFlag,
45
47
  };
46
48
  failedCount = 0;
49
+ skipUnsupported = false;
50
+ // What --skip-unsupported held back: the target and why, for the final warning.
51
+ unsupported = [];
47
52
  async run() {
53
+ const { flags } = await this.parse(Pull);
54
+ this.skipUnsupported = flags['skip-unsupported'];
48
55
  const results = [];
49
56
  const capabilities = await this.capabilities();
50
57
  for (const target of PULL_TARGETS) {
@@ -73,12 +80,14 @@ export default class Pull extends LoopressCommand {
73
80
  }
74
81
  }
75
82
  if (this.failedCount > 0) {
83
+ await this.warnUnsupported();
76
84
  // Each failure's own reason, not just the count: under --json the per-resource lines above
77
85
  // never reach stdout, and this message is all an MCP caller or a script gets to see.
78
86
  const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
79
87
  this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
80
88
  }
81
89
  this.log('\nAll resources pulled.');
90
+ await this.warnUnsupported();
82
91
  return { results };
83
92
  }
84
93
  // `--yes` is forwarded only when the user passed it and the target accepts it: unlike
@@ -116,9 +125,25 @@ export default class Pull extends LoopressCommand {
116
125
  this.log(`\n→ ${target.label}: ${missing.needsFull ? missing.reason : 'not configured'}, skipped`);
117
126
  return { label: target.label, status: 'skipped' };
118
127
  }
128
+ if (this.skipUnsupported) {
129
+ this.unsupported.push({ label: target.label, missing });
130
+ this.log(`\n→ ${target.label}: ${missing.reason}, skipped (--skip-unsupported)`);
131
+ return { label: target.label, reason: `${missing.reason} on ${this.siteConfig.name}`, status: 'skipped' };
132
+ }
119
133
  this.failedCount++;
120
134
  const error = `${missing.reason} on ${this.siteConfig.name}`;
121
135
  this.log(`\n✗ ${target.label} failed: ${error}`);
122
136
  return { error, label: target.label, status: 'failed' };
123
137
  }
138
+ async warnUnsupported() {
139
+ if (this.unsupported.length === 0)
140
+ return;
141
+ const declared = await declaredPlugins({ localConfig: this.localConfig, rootDir: this.rootDir, siteName: this.siteConfig.name });
142
+ const lines = this.unsupported.map(({ label, missing }) => {
143
+ const fix = integrationFix(missing, declared);
144
+ const fixSentence = fix ? ` ${fix}.` : '';
145
+ return `${label}: ${missing.reason} on ${this.siteConfig.name}.${fixSentence}`;
146
+ });
147
+ this.warn(`Skipped with --skip-unsupported, this site can't hold them:\n ${lines.join('\n ')}`);
148
+ }
124
149
  }