@loopress/cli 0.33.0 → 0.35.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 (92) hide show
  1. package/README.md +236 -207
  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 +13 -3
  9. package/dist/commands/form/push.js +3 -2
  10. package/dist/commands/option/list.js +6 -1
  11. package/dist/commands/option/push.js +1 -0
  12. package/dist/commands/page/push.js +1 -0
  13. package/dist/commands/plugin/add.d.ts +1 -0
  14. package/dist/commands/plugin/add.js +50 -9
  15. package/dist/commands/plugin/audit.d.ts +1 -1
  16. package/dist/commands/plugin/audit.js +45 -23
  17. package/dist/commands/plugin/outdated.d.ts +22 -0
  18. package/dist/commands/plugin/outdated.js +67 -0
  19. package/dist/commands/plugin/push.d.ts +2 -1
  20. package/dist/commands/plugin/push.js +109 -23
  21. package/dist/commands/plugin/update.d.ts +36 -0
  22. package/dist/commands/plugin/update.js +121 -0
  23. package/dist/commands/project/config.js +0 -1
  24. package/dist/commands/promote.d.ts +1 -0
  25. package/dist/commands/promote.js +8 -4
  26. package/dist/commands/pull.d.ts +5 -0
  27. package/dist/commands/pull.js +26 -1
  28. package/dist/commands/push.d.ts +9 -0
  29. package/dist/commands/push.js +116 -40
  30. package/dist/commands/snippet/push.js +3 -2
  31. package/dist/commands/theme/style/diff.js +1 -1
  32. package/dist/commands/theme/style/push.js +1 -1
  33. package/dist/commands/theme/style/rollback.js +1 -1
  34. package/dist/commands/theme/template/push.d.ts +2 -0
  35. package/dist/commands/theme/template/push.js +42 -13
  36. package/dist/commands/validate.js +3 -0
  37. package/dist/lib/base.d.ts +7 -0
  38. package/dist/lib/base.js +55 -2
  39. package/dist/lib/capabilities.d.ts +5 -0
  40. package/dist/lib/capabilities.js +16 -0
  41. package/dist/lib/declared-plugins.d.ts +9 -0
  42. package/dist/lib/declared-plugins.js +21 -0
  43. package/dist/lib/declared-type-command.js +3 -0
  44. package/dist/lib/dev-watch.d.ts +1 -1
  45. package/dist/lib/dev-watch.js +11 -13
  46. package/dist/lib/diff-command.d.ts +17 -0
  47. package/dist/lib/diff-command.js +125 -9
  48. package/dist/lib/environment-urls.d.ts +6 -0
  49. package/dist/lib/environment-urls.js +59 -0
  50. package/dist/lib/exclude.d.ts +16 -0
  51. package/dist/lib/exclude.js +102 -0
  52. package/dist/lib/load-snippets.js +3 -5
  53. package/dist/lib/php-files-command.js +5 -3
  54. package/dist/lib/push-command.d.ts +26 -0
  55. package/dist/lib/push-command.js +78 -12
  56. package/dist/lib/readdir-tolerant.js +6 -3
  57. package/dist/lib/resource-selection.d.ts +9 -0
  58. package/dist/lib/resource-selection.js +49 -0
  59. package/dist/lib/resource-state.d.ts +2 -0
  60. package/dist/lib/resource-state.js +25 -4
  61. package/dist/lib/rollback-command.js +1 -1
  62. package/dist/lib/rollback-materialize.js +1 -1
  63. package/dist/lib/validate-local.d.ts +1 -0
  64. package/dist/lib/validate-local.js +71 -12
  65. package/dist/lib/wp-client.d.ts +2 -0
  66. package/dist/lib/wp-client.js +36 -10
  67. package/dist/types/plugin.d.ts +2 -1
  68. package/dist/types/plugin.js +32 -1
  69. package/dist/types/project-config.generated.d.ts +15 -4
  70. package/dist/utils/block-format.d.ts +2 -0
  71. package/dist/utils/block-format.js +18 -2
  72. package/dist/utils/option-format.d.ts +1 -0
  73. package/dist/utils/option-format.js +14 -1
  74. package/dist/utils/page-format.js +2 -1
  75. package/dist/utils/plugins.d.ts +3 -0
  76. package/dist/utils/plugins.js +6 -1
  77. package/dist/utils/rename-retry.d.ts +1 -0
  78. package/dist/utils/rename-retry.js +20 -0
  79. package/dist/utils/resource-dirs.js +4 -4
  80. package/dist/utils/version.d.ts +2 -0
  81. package/dist/utils/version.js +21 -0
  82. package/dist/utils/wporg.d.ts +5 -0
  83. package/dist/utils/wporg.js +41 -0
  84. package/oclif.manifest.json +2131 -1150
  85. package/package.json +14 -14
  86. package/schemas/project-config.schema.json +21 -4
  87. package/dist/commands/api/publish.d.ts +0 -9
  88. package/dist/commands/api/publish.js +0 -45
  89. package/dist/commands/snippet/publish.d.ts +0 -10
  90. package/dist/commands/snippet/publish.js +0 -62
  91. package/dist/lib/resolve-linked-project.d.ts +0 -8
  92. package/dist/lib/resolve-linked-project.js +0 -27
@@ -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, isFirewallBlock, 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.
@@ -146,7 +156,7 @@ export default class Doctor extends LoopressCommand {
146
156
  catch (error) {
147
157
  lookup = { error };
148
158
  }
149
- if ('error' in lookup && isForbiddenError(lookup.error)) {
159
+ if ('error' in lookup && isForbiddenError(lookup.error) && !isFirewallBlock(lookup.error)) {
150
160
  this.out('- Leftover temporary admin accounts: not checked, these credentials cannot list users.');
151
161
  return;
152
162
  }
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { errorMessage, isRecord } from '../../lib/narrow.js';
5
5
  import { PushCommand } from '../../lib/push-command.js';
@@ -9,6 +9,7 @@ import { getResourceStateProvider } from '../../lib/resource-state.js';
9
9
  import { isNotFoundError } from '../../lib/wp-client.js';
10
10
  import { FORM_ENDPOINT, formEndpoint, getFormId, getFormTitle } from '../../utils/form-format.js';
11
11
  import { pluralize } from '../../utils/pluralize.js';
12
+ import { renameWithRetry } from '../../utils/rename-retry.js';
12
13
  import { toSlug } from '../../utils/to-slug.js';
13
14
  export default class Push extends PushCommand {
14
15
  static args = {
@@ -66,7 +67,7 @@ export default class Push extends PushCommand {
66
67
  async ensureCanonicalFilename(filePath, id, title) {
67
68
  const canonicalPath = join(dirname(filePath), `${id}-${toSlug(title, 'untitled')}.json`);
68
69
  if (filePath !== canonicalPath)
69
- await rename(filePath, canonicalPath);
70
+ await renameWithRetry(filePath, canonicalPath);
70
71
  }
71
72
  // One file is read in isolation: a corrupted or hand-broken JSON file must only skip that
72
73
  // form, not abort loading the rest of the directory, same principle as loadObjects() in
@@ -1,9 +1,14 @@
1
1
  import { Flags, ux } from '@oclif/core';
2
- import ttyTable from 'tty-table';
2
+ import ttyTableModule, {} from 'tty-table';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
4
  import { withSpinner } from '../../lib/spinner.js';
5
5
  import { OPTIONS_ENDPOINT } from '../../utils/option-format.js';
6
6
  import { pluralize } from '../../utils/pluralize.js';
7
+ // tty-table 7 ships CJS-flavored typings (`types` before `import` in its exports map), so under
8
+ // Node16 resolution TS types the default import as the whole CJS namespace, while at runtime the
9
+ // ESM build's default export is the factory itself (it exports no named `Table`).
10
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
11
+ const ttyTable = ttyTableModule;
7
12
  // Same green/yellow pairing drives both SOURCE? and PLUGIN, so a row's whole "where did this come
8
13
  // from" story reads as one color at a glance instead of requiring the "?" to be spotted in text.
9
14
  function confidenceColor(option) {
@@ -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
  }
@@ -23,8 +23,8 @@ export default class Audit extends LoopressCommand {
23
23
  static flags: {};
24
24
  run(): Promise<AuditResult>;
25
25
  private auditOne;
26
- private fetchHealth;
27
26
  private fetchVulnerabilities;
28
27
  private report;
28
+ private reportToConsole;
29
29
  }
30
30
  export {};
@@ -1,11 +1,14 @@
1
1
  import got from 'got';
2
+ import { authManager } from '../../config/auth.manager.js';
3
+ import { API_URL } from '../../lib/api-client.js';
2
4
  import { LoopressCommand } from '../../lib/base.js';
3
5
  import { errorMessage, isRecord } from '../../lib/narrow.js';
4
6
  import { withSpinner } from '../../lib/spinner.js';
7
+ import {} from '../../types/config.js';
5
8
  import { pinVersion } from '../../types/plugin.js';
6
9
  import { compareVersions } from '../../utils/version.js';
10
+ import { fetchPluginInfo } from '../../utils/wporg.js';
7
11
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
8
- const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
9
12
  export default class Audit extends LoopressCommand {
10
13
  static description = 'Check the plugins in loopress.json for known vulnerabilities (wpvulnerability.net) and health issues';
11
14
  static enableJsonFlag = true;
@@ -25,10 +28,20 @@ export default class Audit extends LoopressCommand {
25
28
  advisories.push(...found.advisories);
26
29
  health.push(...found.health);
27
30
  }
28
- return this.report(advisories, health, slugs.length);
31
+ // Print first: report() may set the exit code, and the console request must not delay the output.
32
+ const result = this.report(advisories, health, slugs.length);
33
+ await this.reportToConsole(advisories, health, manifest);
34
+ // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
35
+ if (advisories.length > 0) {
36
+ if (this.jsonEnabled())
37
+ process.exitCode = 1;
38
+ else
39
+ this.exit(1);
40
+ }
41
+ return result;
29
42
  }
30
43
  async auditOne(slug, pinned) {
31
- const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
44
+ const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), fetchPluginInfo(slug)]);
32
45
  // "latest" is the newest WordPress.org release, the one a push installs: audit that version.
33
46
  // Unknown (WordPress.org unreachable), every advisory is reported, safer than none.
34
47
  const version = pinned === 'latest' && typeof info?.version === 'string' ? info.version : pinned;
@@ -38,18 +51,6 @@ export default class Audit extends LoopressCommand {
38
51
  const health = info ? describeHealth(slug, pinned, info) : [];
39
52
  return { advisories, health };
40
53
  }
41
- async fetchHealth(slug) {
42
- try {
43
- const body = await got(WPORG_INFO, {
44
- searchParams: { action: 'plugin_information', 'request[slug]': slug },
45
- timeout: { request: 10_000 },
46
- }).json();
47
- return typeof body === 'object' && !('error' in body) ? body : null;
48
- }
49
- catch {
50
- return null;
51
- }
52
- }
53
54
  async fetchVulnerabilities(slug) {
54
55
  let body;
55
56
  try {
@@ -64,7 +65,8 @@ export default class Audit extends LoopressCommand {
64
65
  'Audit aborted so a service outage is not reported as a clean result.', { cause: error });
65
66
  }
66
67
  // A 200 with the wrong shape (degraded service) must not read as "no advisories" either.
67
- const list = body?.data?.vulnerability;
68
+ // wpvulnerability.net answers `"vulnerability": null` when it has no advisories: that is an empty list.
69
+ const list = body?.data?.vulnerability === null ? [] : body?.data?.vulnerability;
68
70
  if (!Array.isArray(list) || list.some((raw) => !isRecord(raw))) {
69
71
  throw new TypeError(`Unexpected answer from the vulnerability database (wpvulnerability.net) for "${slug}". ` +
70
72
  'Audit aborted so a malformed response is not reported as a clean result.');
@@ -84,15 +86,35 @@ export default class Audit extends LoopressCommand {
84
86
  const noun = advisories.length === 1 ? 'advisory' : 'advisories';
85
87
  this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
86
88
  'Vulnerability data: wpvulnerability.net.');
87
- // exit() throws before oclif prints the --json result, which a CI reading it needs most here.
88
- if (advisories.length > 0) {
89
- if (this.jsonEnabled())
90
- process.exitCode = 1;
91
- else
92
- this.exit(1);
93
- }
94
89
  return { advisories, health, status: 'issues' };
95
90
  }
91
+ // Best-effort, like `recordDeployment`: when a token is present, sends the console the advisories
92
+ // and health notes (slug, version, CVE, severity, fixed version, title). Silent on any failure,
93
+ // and it never touches the output or the exit code.
94
+ async reportToConsole(advisories, health, manifest) {
95
+ try {
96
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
97
+ const url = this.siteConfig?.url;
98
+ if (!token || !url)
99
+ return;
100
+ const sent = advisories.map(({ cve, fixedIn, severity, slug, title }) => ({
101
+ cve,
102
+ fixedIn,
103
+ severity,
104
+ slug,
105
+ title,
106
+ version: pinVersion(manifest[slug] ?? 'latest'),
107
+ }));
108
+ await got.post(`${API_URL}/plugin-audits`, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ json: { advisories: sent, health, url },
111
+ timeout: { request: 3000 },
112
+ });
113
+ }
114
+ catch {
115
+ // non-blocking: reporting must never change the audit's outcome
116
+ }
117
+ }
96
118
  }
97
119
  function advisoryMeta(a) {
98
120
  const fixed = a.fixedIn ? `fixed in ${a.fixedIn}` : undefined;
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type VersionJump } from '../../utils/version.js';
3
+ export type OutdatedPlugin = {
4
+ current: string;
5
+ jump: null | VersionJump;
6
+ latest: null | string;
7
+ slug: string;
8
+ status: 'closed' | 'current' | 'error' | 'outdated' | 'unknown';
9
+ };
10
+ type OutdatedResult = {
11
+ outdated: number;
12
+ plugins: OutdatedPlugin[];
13
+ };
14
+ export default class Outdated extends LoopressCommand {
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {};
19
+ run(): Promise<OutdatedResult>;
20
+ private printTable;
21
+ }
22
+ export {};