@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.
- package/README.md +236 -207
- package/dist/commands/app/push.js +2 -1
- package/dist/commands/block/push.js +9 -4
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +10 -16
- package/dist/commands/diff.d.ts +6 -4
- package/dist/commands/diff.js +49 -29
- package/dist/commands/doctor.js +13 -3
- package/dist/commands/form/push.js +3 -2
- package/dist/commands/option/list.js +6 -1
- package/dist/commands/option/push.js +1 -0
- package/dist/commands/page/push.js +1 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/audit.d.ts +1 -1
- package/dist/commands/plugin/audit.js +45 -23
- package/dist/commands/plugin/outdated.d.ts +22 -0
- package/dist/commands/plugin/outdated.js +67 -0
- package/dist/commands/plugin/push.d.ts +2 -1
- package/dist/commands/plugin/push.js +109 -23
- package/dist/commands/plugin/update.d.ts +36 -0
- package/dist/commands/plugin/update.js +121 -0
- package/dist/commands/project/config.js +0 -1
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +8 -4
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +26 -1
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +116 -40
- package/dist/commands/snippet/push.js +3 -2
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +1 -1
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +42 -13
- package/dist/commands/validate.js +3 -0
- package/dist/lib/base.d.ts +7 -0
- package/dist/lib/base.js +55 -2
- package/dist/lib/capabilities.d.ts +5 -0
- package/dist/lib/capabilities.js +16 -0
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +3 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +11 -13
- package/dist/lib/diff-command.d.ts +17 -0
- package/dist/lib/diff-command.js +125 -9
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-snippets.js +3 -5
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +26 -0
- package/dist/lib/push-command.js +78 -12
- package/dist/lib/readdir-tolerant.js +6 -3
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +25 -4
- package/dist/lib/rollback-command.js +1 -1
- package/dist/lib/rollback-materialize.js +1 -1
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +71 -12
- package/dist/lib/wp-client.d.ts +2 -0
- package/dist/lib/wp-client.js +36 -10
- package/dist/types/plugin.d.ts +2 -1
- package/dist/types/plugin.js +32 -1
- package/dist/types/project-config.generated.d.ts +15 -4
- package/dist/utils/block-format.d.ts +2 -0
- package/dist/utils/block-format.js +18 -2
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/option-format.js +14 -1
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/plugins.d.ts +3 -0
- package/dist/utils/plugins.js +6 -1
- package/dist/utils/rename-retry.d.ts +1 -0
- package/dist/utils/rename-retry.js +20 -0
- package/dist/utils/resource-dirs.js +4 -4
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/dist/utils/wporg.d.ts +5 -0
- package/dist/utils/wporg.js +41 -0
- package/oclif.manifest.json +2131 -1150
- package/package.json +14 -14
- package/schemas/project-config.schema.json +21 -4
- package/dist/commands/api/publish.d.ts +0 -9
- package/dist/commands/api/publish.js +0 -45
- package/dist/commands/snippet/publish.d.ts +0 -10
- package/dist/commands/snippet/publish.js +0 -62
- package/dist/lib/resolve-linked-project.d.ts +0 -8
- 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
|
|
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);
|
package/dist/commands/dev.d.ts
CHANGED
|
@@ -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:
|
|
7
|
-
skip:
|
|
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;
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import { Command
|
|
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=
|
|
15
|
+
static examples = ['$ lps dev', '$ lps dev --only=snippet,api', '$ lps dev --skip=plugin'];
|
|
14
16
|
static flags = {
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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.
|
package/dist/commands/diff.d.ts
CHANGED
|
@@ -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:
|
|
8
|
-
skip:
|
|
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
|
|
14
|
-
private
|
|
15
|
+
private siteMissing;
|
|
16
|
+
private withSiteSkip;
|
|
15
17
|
}
|
package/dist/commands/diff.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
77
|
-
|
|
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
|
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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 () =>
|
|
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,
|
|
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
|
|
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
|
|
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,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
|
-
|
|
42
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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),
|
|
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
|
-
|
|
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 {};
|