@loopress/cli 0.30.0 → 0.31.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 +196 -143
- package/dist/commands/acf/push.js +2 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +13 -0
- package/dist/commands/content/copy.d.ts +28 -0
- package/dist/commands/content/copy.js +97 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +28 -1
- package/dist/commands/form/push.js +4 -1
- package/dist/commands/option/push.js +4 -2
- package/dist/commands/page/push.js +3 -2
- package/dist/commands/plugin/audit.js +41 -24
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +7 -3
- package/dist/commands/pull.d.ts +2 -1
- package/dist/commands/pull.js +24 -4
- package/dist/commands/push.d.ts +4 -2
- package/dist/commands/push.js +42 -17
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +24 -3
- package/dist/commands/theme/version/pull.js +6 -1
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +3 -0
- package/dist/lib/diff-command.d.ts +2 -0
- package/dist/lib/diff-command.js +24 -3
- package/dist/lib/load-snippets.js +4 -0
- package/dist/lib/php-files-command.d.ts +8 -0
- package/dist/lib/php-files-command.js +5 -0
- package/dist/lib/push-command.d.ts +4 -2
- package/dist/lib/push-command.js +65 -5
- package/dist/lib/resource-state.js +5 -5
- package/dist/lib/rollback-command.js +9 -2
- package/dist/lib/validate-local.js +9 -3
- package/dist/lib/wp-client.js +14 -1
- package/dist/types/snippet.d.ts +1 -0
- package/oclif.manifest.json +895 -693
- package/package.json +5 -2
|
@@ -26,7 +26,8 @@ export default class Push extends PushCommand {
|
|
|
26
26
|
this.log(`Pushing ACF configuration to ${url}`);
|
|
27
27
|
this.log(`ACF path: ${path}`);
|
|
28
28
|
const provider = getResourceStateProvider('acf');
|
|
29
|
-
|
|
29
|
+
// `--type` leaves the other types untouched: only the pushed ones count as overwritten.
|
|
30
|
+
const beforeState = await this.captureBeforePushState(provider, path, (id) => types.some((type) => id.startsWith(`${type}/`)));
|
|
30
31
|
for (const type of types) {
|
|
31
32
|
await this.pushType(type, path);
|
|
32
33
|
}
|
|
@@ -2,6 +2,7 @@ import { Flags } from '@oclif/core';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { confirmUninstall } from '../../lib/interactive.js';
|
|
5
6
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
7
|
import { isLocalEnvironment, isTimeoutError } from '../../lib/wp-client.js';
|
|
7
8
|
import { requireToInstall, toIntent } from '../../utils/composer.js';
|
|
@@ -37,8 +38,15 @@ export default class ComposerPush extends PushCommand {
|
|
|
37
38
|
this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
|
|
38
39
|
if (lock)
|
|
39
40
|
this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
|
|
41
|
+
// The server renders composer.json from this intent alone, so a WordPress.org plugin or theme
|
|
42
|
+
// the site has and this file lacks gets uninstalled, active or not: say so before it happens.
|
|
43
|
+
const toRemove = await this.wpackagistToRemove(require);
|
|
44
|
+
if (toRemove.length > 0)
|
|
45
|
+
this.log(`Would uninstall from the site, absent from composer.json: ${toRemove.join(', ')}`);
|
|
40
46
|
if (this.dryRun)
|
|
41
47
|
return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
|
|
48
|
+
if (!(await confirmUninstall(toRemove, this.yes)))
|
|
49
|
+
this.error('Aborted.');
|
|
42
50
|
this.log('Running Composer on the server, this can take a few minutes...');
|
|
43
51
|
let response;
|
|
44
52
|
try {
|
|
@@ -74,4 +82,9 @@ export default class ComposerPush extends PushCommand {
|
|
|
74
82
|
}
|
|
75
83
|
this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
|
|
76
84
|
}
|
|
85
|
+
async wpackagistToRemove(require) {
|
|
86
|
+
const { composerJson } = await this.wp.get('loopress/v1/composer/json');
|
|
87
|
+
const current = JSON.parse(composerJson).require ?? {};
|
|
88
|
+
return Object.keys(current).filter((name) => name.startsWith('wpackagist-') && !Object.hasOwn(require, name));
|
|
89
|
+
}
|
|
77
90
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { WpClient } from '../../lib/wp-client.js';
|
|
3
|
+
type ContentCopyResult = {
|
|
4
|
+
from: string;
|
|
5
|
+
posts: number;
|
|
6
|
+
status: 'copied' | 'dry-run';
|
|
7
|
+
terms: number;
|
|
8
|
+
};
|
|
9
|
+
export default class Copy extends LoopressCommand {
|
|
10
|
+
static args: {
|
|
11
|
+
from: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
12
|
+
};
|
|
13
|
+
static baseFlags: {
|
|
14
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
};
|
|
16
|
+
static description: string;
|
|
17
|
+
static enableJsonFlag: boolean;
|
|
18
|
+
static examples: string[];
|
|
19
|
+
static flags: {
|
|
20
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
22
|
+
};
|
|
23
|
+
run(): Promise<ContentCopyResult>;
|
|
24
|
+
protected sourceWp(from: string): WpClient;
|
|
25
|
+
private confirmReplace;
|
|
26
|
+
private copy;
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags } from '@oclif/core';
|
|
3
|
+
import { configManager } from '../../config/project-config.manager.js';
|
|
4
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
6
|
+
import { stdoutToStderr } from '../../lib/json-delegation.js';
|
|
7
|
+
import { isLocalEnvironment, LOCAL_ENVIRONMENT_NAME, WpClient } from '../../lib/wp-client.js';
|
|
8
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
9
|
+
const BATCH_SIZE = 100;
|
|
10
|
+
// Code goes up, content comes down: the content of <from> replaces the local site's, IDs
|
|
11
|
+
// unchanged, never the reverse and never into the repo. Not a `pull`: nothing is written to
|
|
12
|
+
// local files. The target is always "local", so there is no --env (this.wp is the local site),
|
|
13
|
+
// <from> names the source like `lps promote`. Terms go first, so the post batches can attach
|
|
14
|
+
// their term relationships. The plugin refuses the import on any site whose environment type is
|
|
15
|
+
// not "local", whatever this command is pointed at.
|
|
16
|
+
export default class Copy extends LoopressCommand {
|
|
17
|
+
static args = {
|
|
18
|
+
from: Args.string({ description: 'Environment to copy the content from', required: true }),
|
|
19
|
+
};
|
|
20
|
+
// Hidden, not removed: the type of LoopressCommand.baseFlags requires it. Any value other than
|
|
21
|
+
// "local" is refused in run().
|
|
22
|
+
static baseFlags = {
|
|
23
|
+
env: Flags.string({ hidden: true }),
|
|
24
|
+
};
|
|
25
|
+
static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
|
|
26
|
+
static enableJsonFlag = true;
|
|
27
|
+
static examples = ['$ lps content copy production', '$ lps content copy staging --dry-run'];
|
|
28
|
+
static flags = {
|
|
29
|
+
...LoopressCommand.dryRunFlag,
|
|
30
|
+
...LoopressCommand.yesFlag,
|
|
31
|
+
};
|
|
32
|
+
async run() {
|
|
33
|
+
const { args } = await this.parse(Copy);
|
|
34
|
+
const { from } = args;
|
|
35
|
+
if (!isLocalEnvironment(this.siteConfig.name)) {
|
|
36
|
+
this.error(`Content is only ever copied into the "${LOCAL_ENVIRONMENT_NAME}" environment. Run \`lps project config\` to add it if the project has none.`);
|
|
37
|
+
}
|
|
38
|
+
if (isLocalEnvironment(from)) {
|
|
39
|
+
this.error(`<from> must be another environment than "${LOCAL_ENVIRONMENT_NAME}", e.g. \`lps content copy production\`.`);
|
|
40
|
+
}
|
|
41
|
+
const source = this.sourceWp(from);
|
|
42
|
+
if (!this.dryRun)
|
|
43
|
+
await this.confirmReplace(from);
|
|
44
|
+
const info = await source.get('loopress/v1/content');
|
|
45
|
+
if (!this.dryRun) {
|
|
46
|
+
await this.wp.post('loopress/v1/content/reset', { home: info.home, uploadsUrl: info.uploadsUrl });
|
|
47
|
+
}
|
|
48
|
+
this.log(`\n→ Copying terms from ${from}...`);
|
|
49
|
+
const terms = await this.copy('terms', source);
|
|
50
|
+
this.log(`→ Copying posts from ${from}...`);
|
|
51
|
+
const posts = await this.copy('posts', source);
|
|
52
|
+
const summary = `${pluralize(posts, 'post')} and ${pluralize(terms, 'term')}`;
|
|
53
|
+
if (this.dryRun) {
|
|
54
|
+
this.log(`\n[dry-run] Would replace the local content with ${summary} from ${from}, then push the project to ${LOCAL_ENVIRONMENT_NAME}.`);
|
|
55
|
+
return { from, posts, status: 'dry-run', terms };
|
|
56
|
+
}
|
|
57
|
+
this.log(`\n→ Pushing the project to ${LOCAL_ENVIRONMENT_NAME}...`);
|
|
58
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', ['--env', LOCAL_ENVIRONMENT_NAME, '--yes']));
|
|
59
|
+
this.log(`\nCopied ${summary} from ${from}.`);
|
|
60
|
+
return { from, posts, status: 'copied', terms };
|
|
61
|
+
}
|
|
62
|
+
// Overridable seam for tests, like `wpClient` on LoopressCommand.
|
|
63
|
+
sourceWp(from) {
|
|
64
|
+
const project = configManager.getProject(this.projectId);
|
|
65
|
+
const env = project?.environments[from];
|
|
66
|
+
if (!env) {
|
|
67
|
+
this.error(`Environment "${from}" not found. Available: ${Object.keys(project?.environments ?? {}).join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
if (!env.token)
|
|
70
|
+
this.error(`No credentials configured for ${env.url}. Run \`lps project config\` to add them.`);
|
|
71
|
+
return new WpClient(env.url, env.token, env.name);
|
|
72
|
+
}
|
|
73
|
+
async confirmReplace(from) {
|
|
74
|
+
if (this.yes)
|
|
75
|
+
return;
|
|
76
|
+
const message = `This deletes the posts, pages and terms of the "${LOCAL_ENVIRONMENT_NAME}" site and replaces them with ${from}'s.`;
|
|
77
|
+
if (!isInteractive())
|
|
78
|
+
this.error(`${message} Pass --yes to confirm.`);
|
|
79
|
+
const isProceed = await confirm({ default: true, message: `${message} Continue?` });
|
|
80
|
+
if (!isProceed)
|
|
81
|
+
this.error('Aborted.');
|
|
82
|
+
}
|
|
83
|
+
// Pages through the source, each page posted to the local site as is.
|
|
84
|
+
async copy(kind, source) {
|
|
85
|
+
let count = 0;
|
|
86
|
+
let after = 0;
|
|
87
|
+
while (after !== null) {
|
|
88
|
+
const page = await source.get(`loopress/v1/content/${kind}?after=${after}&limit=${BATCH_SIZE}`);
|
|
89
|
+
const { next, ...rows } = page;
|
|
90
|
+
count += rows[kind].length;
|
|
91
|
+
if (!this.dryRun)
|
|
92
|
+
await this.wp.post(`loopress/v1/content/${kind}`, rows);
|
|
93
|
+
after = next;
|
|
94
|
+
}
|
|
95
|
+
return count;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configManager } from '../config/project-config.manager.js';
|
|
2
2
|
import { LoopressCommand } from '../lib/base.js';
|
|
3
|
+
import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
|
|
3
4
|
import { findTempAdmins } from '../lib/temp-admin.js';
|
|
4
5
|
import { isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
|
|
5
6
|
import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
|
|
@@ -35,8 +36,10 @@ export default class Doctor extends LoopressCommand {
|
|
|
35
36
|
// application password without depending on any Loopress feature.
|
|
36
37
|
const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
|
|
37
38
|
// Skipped when the credentials were just rejected: it would only repeat that same 401.
|
|
38
|
-
if (isAuthenticated)
|
|
39
|
+
if (isAuthenticated) {
|
|
39
40
|
await this.checkLeftoverTempAdmins();
|
|
41
|
+
await this.checkHookErrors();
|
|
42
|
+
}
|
|
40
43
|
pluginVersion = await this.reportPluginVersion();
|
|
41
44
|
}
|
|
42
45
|
else {
|
|
@@ -76,6 +79,30 @@ export default class Doctor extends LoopressCommand {
|
|
|
76
79
|
return false;
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
// A hook that fails to load or throws while running doesn't break the site, it just quietly
|
|
83
|
+
// does nothing, and its only trace is an error_log nobody reads on managed hosting.
|
|
84
|
+
async checkHookErrors() {
|
|
85
|
+
let lookup;
|
|
86
|
+
try {
|
|
87
|
+
lookup = { files: await this.wp.get(HOOK_FILES_RESOURCE.endpoint) };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (isNotFoundError(error))
|
|
91
|
+
return; // Loopress Light has no hooks
|
|
92
|
+
lookup = { error };
|
|
93
|
+
}
|
|
94
|
+
await this.check('Hooks load and run without errors', async () => {
|
|
95
|
+
if ('error' in lookup)
|
|
96
|
+
throw lookup.error;
|
|
97
|
+
const files = Array.isArray(lookup.files) ? lookup.files : [];
|
|
98
|
+
const problems = files.flatMap((file) => [
|
|
99
|
+
...(file.error ? [`hooks/${file.filename}.php: ${file.error}`] : []),
|
|
100
|
+
...(file.runtimeError ? [`hooks/${file.filename}.php: ${formatRuntimeError(file.runtimeError)}`] : []),
|
|
101
|
+
]);
|
|
102
|
+
if (problems.length > 0)
|
|
103
|
+
throw new Error(problems.join('\n '));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
79
106
|
// Report only, never deletes: a doctor run shouldn't change the site. A leftover is a live
|
|
80
107
|
// administrator with a password nobody holds, so it fails the run. Credentials without
|
|
81
108
|
// `list_users` can't look, which is informational, not a failure: nothing was found wrong.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
|
-
import { readFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join } from 'node:path';
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
5
|
import { putOrCreate } from '../../lib/put-or-create.js';
|
|
@@ -120,6 +120,9 @@ export default class Push extends PushCommand {
|
|
|
120
120
|
putEndpoint: formEndpoint,
|
|
121
121
|
});
|
|
122
122
|
const canonicalId = created ? getFormId(body) : id;
|
|
123
|
+
// The id is read from the file, not its name: without it the next push creates the form again.
|
|
124
|
+
if (created && canonicalId !== null)
|
|
125
|
+
await writeFile(filePath, JSON.stringify({ ...data, id: canonicalId }, null, 2) + '\n');
|
|
123
126
|
if (canonicalId !== null)
|
|
124
127
|
await this.ensureCanonicalFilename(filePath, canonicalId, title);
|
|
125
128
|
if (task)
|
|
@@ -23,8 +23,6 @@ export default class Push extends PushCommand {
|
|
|
23
23
|
const { url } = this.siteConfig;
|
|
24
24
|
this.log(`Pushing tracked options to ${url}`);
|
|
25
25
|
this.log(`Options path: ${dir}`);
|
|
26
|
-
const provider = getResourceStateProvider('option');
|
|
27
|
-
const beforeState = await this.captureBeforePushState(provider, dir);
|
|
28
26
|
const tracked = await loadFiles(dir, {
|
|
29
27
|
extension: '.json',
|
|
30
28
|
onSkip: (message) => {
|
|
@@ -33,6 +31,10 @@ export default class Push extends PushCommand {
|
|
|
33
31
|
parse: parseLocalOption,
|
|
34
32
|
});
|
|
35
33
|
const { skipped, writable } = partitionByReadonly(tracked);
|
|
34
|
+
const provider = getResourceStateProvider('option');
|
|
35
|
+
// Readonly options are never written, so a site-side change to one is no reason to stop.
|
|
36
|
+
const writableNames = new Set(writable.map((option) => option.name));
|
|
37
|
+
const beforeState = await this.captureBeforePushState(provider, dir, (name) => writableNames.has(name));
|
|
36
38
|
if (skipped.length > 0) {
|
|
37
39
|
this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
|
|
38
40
|
}
|
|
@@ -29,13 +29,14 @@ export default class Push extends PushCommand {
|
|
|
29
29
|
this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
|
|
30
30
|
}
|
|
31
31
|
this.log(`Found ${pluralize(selected.length, 'page')} to push`);
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const provider = getResourceStateProvider('page');
|
|
33
|
+
const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
|
|
34
34
|
const pushed = [];
|
|
35
35
|
await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
|
|
36
36
|
await this.pushPage(page, task);
|
|
37
37
|
pushed.push(page.slug);
|
|
38
38
|
});
|
|
39
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
39
40
|
if (this.failedCount > 0) {
|
|
40
41
|
this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
|
|
41
42
|
}
|
|
@@ -28,7 +28,12 @@ export default class Audit extends LoopressCommand {
|
|
|
28
28
|
}
|
|
29
29
|
async auditOne(slug, pinned) {
|
|
30
30
|
const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
|
|
31
|
-
|
|
31
|
+
// "latest" is the newest WordPress.org release, the one a push installs: audit that version.
|
|
32
|
+
// Unknown (WordPress.org unreachable), every advisory is reported, safer than none.
|
|
33
|
+
const version = pinned === 'latest' && typeof info?.version === 'string' ? info.version : pinned;
|
|
34
|
+
const advisories = vulns
|
|
35
|
+
.filter((v) => version === 'latest' || affects(version, v.range))
|
|
36
|
+
.map(({ range: _range, ...v }) => ({ ...v, slug }));
|
|
32
37
|
const health = info ? describeHealth(slug, pinned, info) : [];
|
|
33
38
|
return { advisories, health };
|
|
34
39
|
}
|
|
@@ -72,8 +77,13 @@ export default class Audit extends LoopressCommand {
|
|
|
72
77
|
const noun = advisories.length === 1 ? 'advisory' : 'advisories';
|
|
73
78
|
this.log(`\n${advisories.length} vulnerability ${noun}, ${health.length} health note(s). ` +
|
|
74
79
|
'Vulnerability data: wpvulnerability.net.');
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
// exit() throws before oclif prints the --json result, which a CI reading it needs most here.
|
|
81
|
+
if (advisories.length > 0) {
|
|
82
|
+
if (this.jsonEnabled())
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
else
|
|
85
|
+
this.exit(1);
|
|
86
|
+
}
|
|
77
87
|
return { advisories, health, status: 'issues' };
|
|
78
88
|
}
|
|
79
89
|
}
|
|
@@ -86,34 +96,41 @@ function normalizeAdvisory(raw) {
|
|
|
86
96
|
const cvss = (raw.cvss ?? {});
|
|
87
97
|
const source = Array.isArray(raw.source) ? raw.source : [];
|
|
88
98
|
const cve = typeof raw.cve === 'string' ? raw.cve : source.find((s) => s.id?.startsWith('CVE-'))?.id;
|
|
99
|
+
const range = extractRange(raw);
|
|
89
100
|
return {
|
|
90
101
|
cve,
|
|
91
|
-
fixedIn:
|
|
102
|
+
fixedIn: range.max?.op === 'lt' ? range.max.version : undefined,
|
|
103
|
+
range,
|
|
92
104
|
score: cvss.score,
|
|
93
105
|
severity: cvss.severity,
|
|
94
|
-
title: typeof raw.name === 'string' ? raw.name : 'Unnamed advisory',
|
|
106
|
+
title: typeof raw.name === 'string' ? decodeEntities(raw.name) : 'Unnamed advisory',
|
|
95
107
|
};
|
|
96
108
|
}
|
|
97
|
-
// wpvulnerability's `
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
// wpvulnerability's `operator`: `max_version` with `max_operator` "lt" (fixed in that version),
|
|
110
|
+
// "le" (affected up to it, no fix yet) or "eq" (that version only), and an optional lower bound `min_version` with
|
|
111
|
+
// `min_operator` "gt" or "ge". No upper bound means every version from the lower one on.
|
|
112
|
+
function extractRange(raw) {
|
|
113
|
+
const op = (raw.operator ?? {});
|
|
114
|
+
const bound = (version, operator) => typeof version === 'string' && version !== '' && typeof operator === 'string' ? { op: operator, version } : undefined;
|
|
115
|
+
return { max: bound(op.max_version, op.max_operator), min: bound(op.min_version, op.min_operator) };
|
|
116
|
+
}
|
|
117
|
+
// A bound we can't evaluate (odd version strings, unknown operator) counts as affected: a false
|
|
118
|
+
// positive is safer than hiding an advisory.
|
|
119
|
+
function affects(version, range) {
|
|
120
|
+
const within = (bound, ok) => {
|
|
121
|
+
if (!bound)
|
|
122
|
+
return true;
|
|
123
|
+
const order = compareVersions(version, bound.version);
|
|
124
|
+
return order === null || !Object.hasOwn(ok, bound.op) || ok[bound.op](order);
|
|
125
|
+
};
|
|
126
|
+
return (within(range.min, { ge: (o) => o >= 0, gt: (o) => o > 0 }) && within(range.max, { eq: (o) => o === 0, le: (o) => o <= 0, lt: (o) => o < 0 }));
|
|
109
127
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return order === null || order < 0;
|
|
128
|
+
// Advisory names come HTML-encoded ("WPForms – AI Form Builder").
|
|
129
|
+
function decodeEntities(text) {
|
|
130
|
+
return text
|
|
131
|
+
.replaceAll(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
132
|
+
.replaceAll('"', '"')
|
|
133
|
+
.replaceAll('&', '&');
|
|
117
134
|
}
|
|
118
135
|
function describeHealth(slug, pinned, info) {
|
|
119
136
|
const notes = [];
|
|
@@ -13,6 +13,7 @@ export default class Promote extends Command {
|
|
|
13
13
|
static enableJsonFlag: boolean;
|
|
14
14
|
static examples: string[];
|
|
15
15
|
static flags: {
|
|
16
|
+
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
17
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
18
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
19
|
};
|
package/dist/commands/promote.js
CHANGED
|
@@ -8,6 +8,7 @@ import { configManager } from '../config/project-config.manager.js';
|
|
|
8
8
|
import { LoopressCommand } from '../lib/base.js';
|
|
9
9
|
import { isInteractive } from '../lib/interactive.js';
|
|
10
10
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
11
|
+
import { PushCommand } from '../lib/push-command.js';
|
|
11
12
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
12
13
|
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
13
14
|
export default class Promote extends Command {
|
|
@@ -21,10 +22,11 @@ export default class Promote extends Command {
|
|
|
21
22
|
static flags = {
|
|
22
23
|
...LoopressCommand.dryRunFlag,
|
|
23
24
|
...LoopressCommand.yesFlag,
|
|
25
|
+
overwrite: PushCommand.baseFlags.overwrite,
|
|
24
26
|
};
|
|
25
27
|
async run() {
|
|
26
28
|
const { args, flags } = await this.parse(Promote);
|
|
27
|
-
const { yes } = flags;
|
|
29
|
+
const { overwrite, yes } = flags;
|
|
28
30
|
const dryRun = flags['dry-run'];
|
|
29
31
|
const { environments, projectName } = await this.resolveProject();
|
|
30
32
|
const from = this.requireEnvironment(environments, projectName, args.from);
|
|
@@ -52,10 +54,12 @@ export default class Promote extends Command {
|
|
|
52
54
|
}
|
|
53
55
|
catch (error) {
|
|
54
56
|
// A partial pull must never be pushed onward: stop before touching <to>.
|
|
55
|
-
|
|
57
|
+
// "untouched" is only true of <to>: a real (non dry-run) pull has already rewritten the files.
|
|
58
|
+
const filesNote = dryRun ? '' : ` Your local files may already hold part of ${from.name}, review them before pushing.`;
|
|
59
|
+
this.error(`Pull from ${from.name} failed, nothing was pushed to ${to.name}.${filesNote} ${error.message}`);
|
|
56
60
|
}
|
|
57
61
|
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
58
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
|
|
62
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', [...this.delegateArgv(to.name, dryRun), ...(overwrite ? ['--overwrite'] : [])]));
|
|
59
63
|
}
|
|
60
64
|
finally {
|
|
61
65
|
if (scratch) {
|
package/dist/commands/pull.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
|
|
|
2
2
|
type PullTargetResult = {
|
|
3
3
|
error?: string;
|
|
4
4
|
label: string;
|
|
5
|
-
status: 'failed' | 'pulled';
|
|
5
|
+
status: 'failed' | 'pulled' | 'skipped';
|
|
6
6
|
};
|
|
7
7
|
type PullResult = {
|
|
8
8
|
results: PullTargetResult[];
|
|
@@ -18,5 +18,6 @@ export default class Pull extends LoopressCommand {
|
|
|
18
18
|
private failedCount;
|
|
19
19
|
run(): Promise<PullResult>;
|
|
20
20
|
private buildArgv;
|
|
21
|
+
private usesResource;
|
|
21
22
|
}
|
|
22
23
|
export {};
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
4
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
3
5
|
import { pluralize } from '../utils/pluralize.js';
|
|
6
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
7
|
+
// How the plugin reports its integration missing: "ACF is not active", "No supported form
|
|
8
|
+
// plugin is active", "No supported snippet plugin is active".
|
|
9
|
+
const PLUGIN_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b/;
|
|
4
10
|
// composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
|
|
5
11
|
// into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
|
|
6
12
|
// directories and their order does not matter, it mirrors `lps push` for familiarity.
|
|
@@ -11,14 +17,14 @@ const PULL_TARGETS = [
|
|
|
11
17
|
{ commandId: 'taxonomy:pull', label: 'taxonomies', supportsYes: true },
|
|
12
18
|
{ commandId: 'meta:pull', label: 'post meta', supportsYes: true },
|
|
13
19
|
{ commandId: 'role:pull', label: 'roles', supportsYes: true },
|
|
14
|
-
{ commandId: 'acf:pull', label: 'ACF', supportsYes: true },
|
|
20
|
+
{ commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
|
|
15
21
|
{ commandId: 'api:pull', label: 'API routes', supportsYes: true },
|
|
16
22
|
{ commandId: 'hook:pull', label: 'hooks', supportsYes: true },
|
|
17
|
-
{ commandId: 'form:pull', label: 'forms', supportsYes: true },
|
|
23
|
+
{ commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
|
|
18
24
|
{ commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
|
|
19
25
|
{ commandId: 'menu:pull', label: 'menus', supportsYes: true },
|
|
20
26
|
{ commandId: 'option:pull', label: 'options', supportsYes: true },
|
|
21
|
-
{ commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
|
|
27
|
+
{ commandId: 'snippet:pull', label: 'snippets', needsPlugin: 'snippets', supportsYes: true },
|
|
22
28
|
];
|
|
23
29
|
export default class Pull extends LoopressCommand {
|
|
24
30
|
static description = 'Pull all content, plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, menus, options, and snippets, from WordPress';
|
|
@@ -40,8 +46,19 @@ export default class Pull extends LoopressCommand {
|
|
|
40
46
|
results.push({ label: target.label, status: 'pulled' });
|
|
41
47
|
}
|
|
42
48
|
catch (error) {
|
|
43
|
-
this.failedCount++;
|
|
44
49
|
const { message } = error;
|
|
50
|
+
// A site without ACF (or a form or snippet plugin) is the common case, not a failure,
|
|
51
|
+
// as long as the project doesn't use that resource: a vanilla site must pull, and a
|
|
52
|
+
// promote must not stop on it. A project that does use it still fails.
|
|
53
|
+
if (target.needsPlugin && PLUGIN_MISSING.test(message) && !this.usesResource(target.needsPlugin)) {
|
|
54
|
+
// oclif's catch already set exitCode to 1 for the sub-command; a real failure still
|
|
55
|
+
// exits non-zero through the this.error() below.
|
|
56
|
+
process.exitCode = undefined;
|
|
57
|
+
this.log(`→ ${target.label}: not configured, skipped`);
|
|
58
|
+
results.push({ label: target.label, status: 'skipped' });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
this.failedCount++;
|
|
45
62
|
this.log(`✗ ${target.label} failed: ${message}`);
|
|
46
63
|
results.push({ error: message, label: target.label, status: 'failed' });
|
|
47
64
|
}
|
|
@@ -65,4 +82,7 @@ export default class Pull extends LoopressCommand {
|
|
|
65
82
|
argv.push('--yes');
|
|
66
83
|
return argv;
|
|
67
84
|
}
|
|
85
|
+
usesResource(kind) {
|
|
86
|
+
return existsSync(resolve(resolveResourceDir(kind, this.localConfig)));
|
|
87
|
+
}
|
|
68
88
|
}
|
package/dist/commands/push.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../lib/base.js';
|
|
|
2
2
|
type PushTargetResult = {
|
|
3
3
|
error?: string;
|
|
4
4
|
label: string;
|
|
5
|
-
status: 'failed' | 'pushed';
|
|
5
|
+
status: 'failed' | 'pushed' | 'skipped';
|
|
6
6
|
};
|
|
7
7
|
type PushResult = {
|
|
8
8
|
results: PushTargetResult[];
|
|
@@ -12,13 +12,15 @@ export default class Push extends LoopressCommand {
|
|
|
12
12
|
static enableJsonFlag: boolean;
|
|
13
13
|
static examples: string[];
|
|
14
14
|
static flags: {
|
|
15
|
+
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
16
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
17
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
18
|
};
|
|
18
|
-
private failedCount;
|
|
19
19
|
run(): Promise<PushResult>;
|
|
20
20
|
private buildArgv;
|
|
21
21
|
private composerDeclaresPlugins;
|
|
22
|
+
private composerJsonPath;
|
|
22
23
|
private guardProductionPush;
|
|
24
|
+
private skippedTargets;
|
|
23
25
|
}
|
|
24
26
|
export {};
|