@loopress/cli 0.30.0 → 0.32.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 +494 -222
- package/dist/commands/acf/diff.d.ts +2 -2
- package/dist/commands/acf/diff.js +3 -1
- package/dist/commands/acf/list.d.ts +1 -0
- package/dist/commands/acf/list.js +2 -1
- package/dist/commands/acf/pull.d.ts +1 -0
- package/dist/commands/acf/pull.js +2 -1
- package/dist/commands/acf/push.d.ts +7 -2
- package/dist/commands/acf/push.js +14 -5
- package/dist/commands/acf/rollback.d.ts +2 -2
- package/dist/commands/acf/rollback.js +3 -1
- package/dist/commands/composer/pull.js +1 -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/diff.d.ts +1 -0
- package/dist/commands/diff.js +13 -2
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +29 -2
- package/dist/commands/form/list.js +1 -1
- package/dist/commands/form/pull.js +1 -1
- package/dist/commands/form/push.d.ts +2 -1
- package/dist/commands/form/push.js +6 -3
- package/dist/commands/init.js +0 -23
- package/dist/commands/menu/push.d.ts +2 -1
- package/dist/commands/menu/push.js +1 -1
- package/dist/commands/option/push.d.ts +2 -1
- package/dist/commands/option/push.js +5 -3
- package/dist/commands/page/push.d.ts +4 -1
- package/dist/commands/page/push.js +43 -10
- package/dist/commands/pattern/push.js +1 -1
- package/dist/commands/pattern/rm.js +1 -1
- package/dist/commands/plugin/audit.js +42 -25
- package/dist/commands/plugin/push.js +1 -1
- package/dist/commands/plugin/status.js +1 -1
- 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 +37 -5
- package/dist/commands/push.d.ts +6 -2
- package/dist/commands/push.js +64 -19
- package/dist/commands/snippet/list.js +1 -1
- package/dist/commands/snippet/pull.js +1 -1
- package/dist/commands/snippet/push.d.ts +3 -1
- package/dist/commands/snippet/push.js +26 -5
- package/dist/commands/theme/pull.js +1 -1
- package/dist/commands/theme/push.d.ts +1 -0
- package/dist/commands/theme/push.js +3 -3
- package/dist/commands/theme/style/push.d.ts +2 -1
- package/dist/commands/theme/style/push.js +4 -5
- package/dist/commands/theme/template/list.js +1 -1
- package/dist/commands/theme/template/push.d.ts +11 -1
- package/dist/commands/theme/template/push.js +34 -5
- package/dist/commands/theme/version/pull.js +6 -1
- package/dist/commands/theme/version/push.js +1 -1
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +3 -0
- package/dist/lib/declared-type-command.d.ts +2 -1
- package/dist/lib/declared-type-command.js +1 -1
- package/dist/lib/diff-command.d.ts +3 -0
- package/dist/lib/diff-command.js +34 -5
- package/dist/lib/load-snippets.js +4 -0
- package/dist/lib/php-files-command.d.ts +11 -1
- package/dist/lib/php-files-command.js +39 -19
- package/dist/lib/plugin-detection.d.ts +1 -0
- package/dist/lib/plugin-detection.js +4 -0
- package/dist/lib/push-command.d.ts +21 -2
- package/dist/lib/push-command.js +105 -6
- package/dist/lib/resource-state.js +5 -5
- package/dist/lib/rollback-command.js +9 -2
- package/dist/lib/validate-local.js +21 -8
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +27 -1
- package/dist/types/snippet.d.ts +1 -0
- package/dist/utils/acf-format.d.ts +5 -0
- package/dist/utils/acf-format.js +29 -0
- package/dist/utils/php-top-level.d.ts +2 -0
- package/dist/utils/php-top-level.js +148 -0
- package/dist/utils/resource-dirs.d.ts +1 -0
- package/dist/utils/resource-dirs.js +7 -1
- package/dist/utils/template-format.d.ts +1 -0
- package/dist/utils/theme-styles-format.d.ts +2 -0
- package/dist/utils/theme-styles-format.js +32 -4
- package/oclif.manifest.json +1132 -919
- package/package.json +6 -3
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import { Args } from '@oclif/core';
|
|
3
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
2
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
3
5
|
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
6
|
+
import { isConflictCode } from '../../lib/wp-client.js';
|
|
4
7
|
import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
|
|
5
8
|
import { pluralize } from '../../utils/pluralize.js';
|
|
6
9
|
import { resolveResourceDir } from '../../utils/resource-dirs.js';
|
|
@@ -8,13 +11,15 @@ export default class Push extends PushCommand {
|
|
|
8
11
|
static args = {
|
|
9
12
|
slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
|
|
10
13
|
};
|
|
11
|
-
static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
|
|
14
|
+
static description = 'Push local static HTML pages (pages/<slug>.html) to WordPress, or only one when a slug is given. The `status` header (draft or publish, draft by default) is applied on every push.';
|
|
12
15
|
static enableJsonFlag = true;
|
|
13
|
-
static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
|
|
16
|
+
static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run', '$ lps page push about --overwrite'];
|
|
14
17
|
static flags = {
|
|
15
18
|
...PushCommand.dryRunFlag,
|
|
16
19
|
...PushCommand.yesFlag,
|
|
17
20
|
};
|
|
21
|
+
// Pages the site refused because a same-slug page exists there that Loopress didn't create.
|
|
22
|
+
unmanaged = [];
|
|
18
23
|
async run() {
|
|
19
24
|
const { args } = await this.parse(Push);
|
|
20
25
|
const path = resolveResourceDir('page', this.localConfig);
|
|
@@ -29,34 +34,62 @@ export default class Push extends PushCommand {
|
|
|
29
34
|
this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
|
|
30
35
|
}
|
|
31
36
|
this.log(`Found ${pluralize(selected.length, 'page')} to push`);
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
const provider = getResourceStateProvider('page');
|
|
38
|
+
const beforeState = await this.captureBeforePushState(provider, path, args.slug === undefined ? undefined : (id) => id === args.slug);
|
|
34
39
|
const pushed = [];
|
|
35
40
|
await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
|
|
36
|
-
await this.pushPage(page, task)
|
|
37
|
-
|
|
41
|
+
if (await this.pushPage(page, task))
|
|
42
|
+
pushed.push(page.slug);
|
|
38
43
|
});
|
|
44
|
+
await this.adoptUnmanaged(pushed);
|
|
45
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
39
46
|
if (this.failedCount > 0) {
|
|
40
47
|
this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
|
|
41
48
|
}
|
|
42
49
|
if (this.dryRun)
|
|
43
|
-
return { pushed, status: 'dry-run' };
|
|
50
|
+
return { changes: this.previewChanges, pushed, status: 'dry-run' };
|
|
44
51
|
await this.recordSuccess();
|
|
45
52
|
this.log('All pages pushed.');
|
|
46
53
|
return { pushed, status: 'success' };
|
|
47
54
|
}
|
|
48
|
-
|
|
55
|
+
// One prompt for every page the first pass set aside, then a second pass that takes them over.
|
|
56
|
+
// Without a terminal to ask in, they count as failed: CI must opt in with --overwrite.
|
|
57
|
+
async adoptUnmanaged(pushed) {
|
|
58
|
+
if (this.unmanaged.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const slugs = this.unmanaged.map((page) => page.slug);
|
|
61
|
+
this.warn(`Already on ${this.siteConfig.url} but not created by Loopress: ${slugs.join(', ')}. ` +
|
|
62
|
+
'Taking them over keeps their URL and ID, and shows your file instead of their current content (saved as a WordPress revision first). ' +
|
|
63
|
+
'Pass --overwrite to take them over.');
|
|
64
|
+
if (!isInteractive() || !(await confirm({ default: false, message: `Take over ${pluralize(slugs.length, 'page')}?` }))) {
|
|
65
|
+
this.failedCount += slugs.length;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await this.runPushTasks(this.unmanaged, (page) => page.slug, async (page, task) => {
|
|
69
|
+
if (await this.pushPage(page, task, true))
|
|
70
|
+
pushed.push(page.slug);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Resolves false when the page was set aside for adoptUnmanaged instead of pushed.
|
|
74
|
+
async pushPage(page, task, adopt = this.overwrite) {
|
|
49
75
|
if (this.dryRun) {
|
|
50
76
|
if (task)
|
|
51
77
|
task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
|
|
52
|
-
return;
|
|
78
|
+
return true;
|
|
53
79
|
}
|
|
54
80
|
try {
|
|
55
|
-
const result = await this.wp.put(PAGES_ENDPOINT, page);
|
|
81
|
+
const result = await this.wp.put(PAGES_ENDPOINT, adopt ? { ...page, adopt } : page);
|
|
56
82
|
if (task)
|
|
57
83
|
task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}${frontPageNote(result)}`;
|
|
84
|
+
return true;
|
|
58
85
|
}
|
|
59
86
|
catch (error) {
|
|
87
|
+
if (!adopt && isConflictCode(error, 'loopress_page_not_managed')) {
|
|
88
|
+
this.unmanaged.push(page);
|
|
89
|
+
// skip(): Listr's own "skipped" mark, not the green check of a page that was pushed.
|
|
90
|
+
task?.skip?.(`${page.slug} already exists on the site, not created by Loopress`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
60
93
|
this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
|
|
61
94
|
}
|
|
62
95
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { declaredPushCommand } from '../../lib/declared-type-command.js';
|
|
2
2
|
import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
|
|
3
3
|
export default declaredPushCommand(PATTERN_SPEC, 'Push local block patterns (patterns/<slug>.html, block markup under a title/categories header) to WordPress, ' +
|
|
4
|
-
'registered as loopress/<slug
|
|
4
|
+
'registered as loopress/<slug> and referenced with <!-- wp:pattern {"slug":"loopress/<slug>"} /-->. Create or update only, never deletes a pattern (use `lps pattern rm`).');
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { declaredRmCommand } from '../../lib/declared-type-command.js';
|
|
2
2
|
import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
|
|
3
|
-
export default declaredRmCommand(PATTERN_SPEC, 'Stop registering a Loopress block pattern on WordPress. A wp:pattern reference to it renders nothing, copies already inserted in posts stay.', 'A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.');
|
|
3
|
+
export default declaredRmCommand(PATTERN_SPEC, 'Stop registering a Loopress block pattern on WordPress. A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.', 'A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.');
|
|
@@ -6,7 +6,7 @@ import { compareVersions } from '../../utils/version.js';
|
|
|
6
6
|
const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
|
|
7
7
|
const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
|
|
8
8
|
export default class Audit extends LoopressCommand {
|
|
9
|
-
static description = 'Check the plugins in loopress.json for known vulnerabilities and health issues';
|
|
9
|
+
static description = 'Check the plugins in loopress.json for known vulnerabilities (wpvulnerability.net) and health issues';
|
|
10
10
|
static enableJsonFlag = true;
|
|
11
11
|
static examples = ['$ lps plugin audit'];
|
|
12
12
|
static flags = {};
|
|
@@ -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 = [];
|
|
@@ -20,7 +20,7 @@ const IN_SYNC = {
|
|
|
20
20
|
status: 'in-sync',
|
|
21
21
|
};
|
|
22
22
|
export default class Push extends PushCommand {
|
|
23
|
-
static description = 'Install plugins on WordPress to match loopress.json (or composer.json
|
|
23
|
+
static description = 'Install WordPress.org plugins on WordPress to match loopress.json (active state included) or composer.json, via Composer + WPackagist';
|
|
24
24
|
static enableJsonFlag = true;
|
|
25
25
|
static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
|
|
26
26
|
static flags = {
|
|
@@ -4,7 +4,7 @@ import { LoopressCommand } from '../../lib/base.js';
|
|
|
4
4
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
5
5
|
import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
|
|
6
6
|
export default class Status extends LoopressCommand {
|
|
7
|
-
static description = 'Compare the plugins on WordPress against loopress.json and report drift';
|
|
7
|
+
static description = 'Compare the plugins on WordPress against loopress.json and report drift (missing, wrong version, inactive, untracked)';
|
|
8
8
|
static enableJsonFlag = true;
|
|
9
9
|
static examples = ['$ lps plugin status'];
|
|
10
10
|
static flags = {};
|
|
@@ -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 globalStylesSkipNote;
|
|
21
22
|
}
|
|
22
23
|
export {};
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
2
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
3
|
+
import { INTEGRATION_MISSING } from '../lib/plugin-detection.js';
|
|
3
4
|
import { pluralize } from '../utils/pluralize.js';
|
|
5
|
+
import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
|
|
6
|
+
import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
|
|
4
7
|
// composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
|
|
5
8
|
// into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
|
|
6
9
|
// directories and their order does not matter, it mirrors `lps push` for familiarity.
|
|
@@ -11,17 +14,20 @@ const PULL_TARGETS = [
|
|
|
11
14
|
{ commandId: 'taxonomy:pull', label: 'taxonomies', supportsYes: true },
|
|
12
15
|
{ commandId: 'meta:pull', label: 'post meta', supportsYes: true },
|
|
13
16
|
{ commandId: 'role:pull', label: 'roles', supportsYes: true },
|
|
14
|
-
{ commandId: 'acf:pull', label: 'ACF', supportsYes: true },
|
|
17
|
+
{ commandId: 'acf:pull', label: 'ACF', needsPlugin: 'acf', supportsYes: true },
|
|
15
18
|
{ commandId: 'api:pull', label: 'API routes', supportsYes: true },
|
|
16
19
|
{ commandId: 'hook:pull', label: 'hooks', supportsYes: true },
|
|
17
|
-
{ commandId: 'form:pull', label: 'forms', supportsYes: true },
|
|
20
|
+
{ commandId: 'form:pull', label: 'forms', needsPlugin: 'form', supportsYes: true },
|
|
18
21
|
{ commandId: 'pattern:pull', label: 'patterns', supportsYes: true },
|
|
22
|
+
// Templates and parts have no pull. Global Styles are pulled only once the project tracks them,
|
|
23
|
+
// see globalStylesSkipNote.
|
|
24
|
+
{ commandId: 'theme:style:pull', label: 'Global Styles' },
|
|
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
|
-
static description = 'Pull
|
|
30
|
+
static description = 'Pull every tracked resource from WordPress into local files in one run: composer dependencies, plugins, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, Global Styles, menus, options, and snippets';
|
|
25
31
|
static enableJsonFlag = true;
|
|
26
32
|
static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
|
|
27
33
|
static flags = {
|
|
@@ -33,6 +39,12 @@ export default class Pull extends LoopressCommand {
|
|
|
33
39
|
const results = [];
|
|
34
40
|
this.warnRemovedSeoModule();
|
|
35
41
|
for (const target of PULL_TARGETS) {
|
|
42
|
+
const skipNote = target.commandId === 'theme:style:pull' ? await this.globalStylesSkipNote() : undefined;
|
|
43
|
+
if (skipNote) {
|
|
44
|
+
this.log(`\n→ ${target.label}: ${skipNote}`);
|
|
45
|
+
results.push({ label: target.label, status: 'skipped' });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
36
48
|
this.log(`\n→ Pulling ${target.label}...`);
|
|
37
49
|
try {
|
|
38
50
|
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
|
|
@@ -40,8 +52,19 @@ export default class Pull extends LoopressCommand {
|
|
|
40
52
|
results.push({ label: target.label, status: 'pulled' });
|
|
41
53
|
}
|
|
42
54
|
catch (error) {
|
|
43
|
-
this.failedCount++;
|
|
44
55
|
const { message } = error;
|
|
56
|
+
// A site without ACF (or a form or snippet plugin) is the common case, not a failure,
|
|
57
|
+
// as long as the project doesn't use that resource: a vanilla site must pull, and a
|
|
58
|
+
// promote must not stop on it. A project that does use it still fails.
|
|
59
|
+
if (target.needsPlugin && INTEGRATION_MISSING.test(message) && !projectUses(target.needsPlugin, this.localConfig)) {
|
|
60
|
+
// oclif's catch already set exitCode to 1 for the sub-command; a real failure still
|
|
61
|
+
// exits non-zero through the this.error() below.
|
|
62
|
+
process.exitCode = undefined;
|
|
63
|
+
this.log(`→ ${target.label}: not configured, skipped`);
|
|
64
|
+
results.push({ label: target.label, status: 'skipped' });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
this.failedCount++;
|
|
45
68
|
this.log(`✗ ${target.label} failed: ${message}`);
|
|
46
69
|
results.push({ error: message, label: target.label, status: 'failed' });
|
|
47
70
|
}
|
|
@@ -65,4 +88,13 @@ export default class Pull extends LoopressCommand {
|
|
|
65
88
|
argv.push('--yes');
|
|
66
89
|
return argv;
|
|
67
90
|
}
|
|
91
|
+
// The site unreachable or the theme list refused: let the step run and report it itself.
|
|
92
|
+
async globalStylesSkipNote() {
|
|
93
|
+
try {
|
|
94
|
+
return await globalStylesSkipNote(this.wp, resolveResourceDir('themeStyles', this.localConfig), 'pull');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
68
100
|
}
|
package/dist/commands/push.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { LoopressCommand } from '../lib/base.js';
|
|
|
2
2
|
type PushTargetResult = {
|
|
3
3
|
error?: string;
|
|
4
4
|
label: string;
|
|
5
|
-
|
|
5
|
+
result?: unknown;
|
|
6
|
+
status: 'failed' | 'pushed' | 'skipped';
|
|
6
7
|
};
|
|
7
8
|
type PushResult = {
|
|
8
9
|
results: PushTargetResult[];
|
|
@@ -12,13 +13,16 @@ export default class Push extends LoopressCommand {
|
|
|
12
13
|
static enableJsonFlag: boolean;
|
|
13
14
|
static examples: string[];
|
|
14
15
|
static flags: {
|
|
16
|
+
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
17
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
18
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
19
|
};
|
|
18
|
-
private failedCount;
|
|
19
20
|
run(): Promise<PushResult>;
|
|
20
21
|
private buildArgv;
|
|
21
22
|
private composerDeclaresPlugins;
|
|
23
|
+
private composerJsonPath;
|
|
24
|
+
private globalStylesSkipNote;
|
|
22
25
|
private guardProductionPush;
|
|
26
|
+
private skippedTargets;
|
|
23
27
|
}
|
|
24
28
|
export {};
|
package/dist/commands/push.js
CHANGED
|
@@ -4,9 +4,15 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { LoopressCommand } from '../lib/base.js';
|
|
5
5
|
import { guardProductionPush } from '../lib/guard-production-push.js';
|
|
6
6
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
7
|
+
import { PushCommand } from '../lib/push-command.js';
|
|
7
8
|
import { isLocalEnvironment } from '../lib/wp-client.js';
|
|
8
9
|
import { wpackagistRequire } from '../utils/composer.js';
|
|
9
|
-
import {
|
|
10
|
+
import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
|
|
11
|
+
import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
|
|
12
|
+
// Backed by a plugin the site may not have (ACF, a form or a snippet plugin): a project without
|
|
13
|
+
// the resource's directory doesn't use it, and its push, dry run included, would only stop on the
|
|
14
|
+
// site's 409 or the missing directory.
|
|
15
|
+
const PLUGIN_BACKED = { 'acf:push': 'acf', 'form:push': 'form', 'snippet:push': 'snippets' };
|
|
10
16
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
11
17
|
// relies on, then content itself.
|
|
12
18
|
const PUSH_TARGETS = [
|
|
@@ -25,53 +31,53 @@ const PUSH_TARGETS = [
|
|
|
25
31
|
{ commandId: 'pattern:push', label: 'patterns' },
|
|
26
32
|
// Before pages: WordPress refuses a page whose `template` it doesn't know yet.
|
|
27
33
|
{ commandId: 'theme:template:push', label: 'templates and parts' },
|
|
34
|
+
// Belong to whichever theme is active, so right after its templates. Theme versions stay out:
|
|
35
|
+
// installing or removing themes is `lps theme push`'s call.
|
|
36
|
+
{ commandId: 'theme:style:push', label: 'Global Styles' },
|
|
28
37
|
{ commandId: 'page:push', label: 'pages' },
|
|
29
38
|
{ commandId: 'menu:push', label: 'menus' },
|
|
30
39
|
{ commandId: 'option:push', label: 'options' },
|
|
31
40
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
32
41
|
];
|
|
33
42
|
export default class Push extends LoopressCommand {
|
|
34
|
-
static description = 'Push
|
|
43
|
+
static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
|
|
35
44
|
static enableJsonFlag = true;
|
|
36
45
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
37
46
|
static flags = {
|
|
38
47
|
...LoopressCommand.dryRunFlag,
|
|
39
48
|
...LoopressCommand.yesFlag,
|
|
49
|
+
overwrite: PushCommand.baseFlags.overwrite,
|
|
40
50
|
};
|
|
41
|
-
failedCount = 0;
|
|
42
51
|
async run() {
|
|
43
52
|
await this.guardProductionPush();
|
|
44
53
|
this.warnRemovedSeoModule();
|
|
45
54
|
const argv = this.buildArgv();
|
|
46
55
|
const results = [];
|
|
47
|
-
const
|
|
56
|
+
const skips = await this.skippedTargets();
|
|
48
57
|
for (const target of PUSH_TARGETS) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
const skip = skips.get(target.commandId);
|
|
59
|
+
if (skip) {
|
|
60
|
+
this.log(`\n→ ${target.label}: ${skip.note}`);
|
|
61
|
+
results.push({ label: target.label, status: skip.status });
|
|
52
62
|
continue;
|
|
53
63
|
}
|
|
54
64
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
55
65
|
try {
|
|
56
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
66
|
+
const result = await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
57
67
|
this.log(this.dryRun ? `✓ ${target.label} would push` : `✓ ${target.label} pushed`);
|
|
58
|
-
results.push({ label: target.label, status: 'pushed' });
|
|
68
|
+
results.push({ label: target.label, result, status: 'pushed' });
|
|
59
69
|
}
|
|
60
70
|
catch (error) {
|
|
61
|
-
this.failedCount++;
|
|
62
71
|
const { message } = error;
|
|
63
72
|
this.log(`✗ ${target.label} failed: ${message}`);
|
|
64
73
|
results.push({ error: message, label: target.label, status: 'failed' });
|
|
74
|
+
// Stop here: later targets depend on earlier ones (hooks use what composer installs,
|
|
75
|
+
// pages need their templates), so pushing them anyway can ship a silently broken site.
|
|
76
|
+
const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1).map((next) => next.label);
|
|
77
|
+
const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
|
|
78
|
+
this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
|
-
if (this.failedCount > 0) {
|
|
68
|
-
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
69
|
-
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
70
|
-
const failures = results
|
|
71
|
-
.filter((result) => result.status === 'failed')
|
|
72
|
-
.map((result) => `${result.label}: ${result.error}`);
|
|
73
|
-
this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
|
|
74
|
-
}
|
|
75
81
|
this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
|
|
76
82
|
return { results };
|
|
77
83
|
}
|
|
@@ -82,12 +88,15 @@ export default class Push extends LoopressCommand {
|
|
|
82
88
|
const argv = ['--env', this.siteConfig.name, '--yes'];
|
|
83
89
|
if (this.dryRun)
|
|
84
90
|
argv.push('--dry-run');
|
|
91
|
+
// Not implied by --yes: overwriting changes made outside Loopress is its own decision.
|
|
92
|
+
if (this.overwrite)
|
|
93
|
+
argv.push('--overwrite');
|
|
85
94
|
return argv;
|
|
86
95
|
}
|
|
87
96
|
// A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
|
|
88
97
|
// safety), so `composer:push` would only run Composer on the server a second time.
|
|
89
98
|
async composerDeclaresPlugins() {
|
|
90
|
-
const path =
|
|
99
|
+
const path = this.composerJsonPath();
|
|
91
100
|
if (!existsSync(path))
|
|
92
101
|
return false;
|
|
93
102
|
try {
|
|
@@ -99,6 +108,18 @@ export default class Push extends LoopressCommand {
|
|
|
99
108
|
return false;
|
|
100
109
|
}
|
|
101
110
|
}
|
|
111
|
+
composerJsonPath() {
|
|
112
|
+
return join(process.cwd(), this.rootDir, 'composer.json');
|
|
113
|
+
}
|
|
114
|
+
// The site unreachable or the theme list refused: let the step run and report it itself.
|
|
115
|
+
async globalStylesSkipNote() {
|
|
116
|
+
try {
|
|
117
|
+
return await globalStylesSkipNote(this.wp, resolveResourceDir('themeStyles', this.localConfig), 'push');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
102
123
|
// Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
|
|
103
124
|
// delegated command.
|
|
104
125
|
async guardProductionPush() {
|
|
@@ -109,4 +130,28 @@ export default class Push extends LoopressCommand {
|
|
|
109
130
|
yes: this.yes,
|
|
110
131
|
});
|
|
111
132
|
}
|
|
133
|
+
// Unconfigured is not a failure: a project that doesn't manage plugins or composer through
|
|
134
|
+
// Loopress must not stop the run before anything else gets pushed.
|
|
135
|
+
async skippedTargets() {
|
|
136
|
+
const skips = new Map();
|
|
137
|
+
const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
|
|
138
|
+
const hasComposerJson = existsSync(this.composerJsonPath());
|
|
139
|
+
const composerPushedByPlugins = await this.composerDeclaresPlugins();
|
|
140
|
+
if (composerPushedByPlugins)
|
|
141
|
+
skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
|
|
142
|
+
else if (!hasComposerJson)
|
|
143
|
+
skips.set('composer:push', notConfigured);
|
|
144
|
+
// With a composer.json, plugins come from it only; otherwise from loopress.json.
|
|
145
|
+
const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
|
|
146
|
+
if (!pluginsConfigured)
|
|
147
|
+
skips.set('plugin:push', notConfigured);
|
|
148
|
+
for (const [commandId, kind] of Object.entries(PLUGIN_BACKED)) {
|
|
149
|
+
if (!projectUses(kind, this.localConfig))
|
|
150
|
+
skips.set(commandId, notConfigured);
|
|
151
|
+
}
|
|
152
|
+
const stylesNote = await this.globalStylesSkipNote();
|
|
153
|
+
if (stylesNote)
|
|
154
|
+
skips.set('theme:style:push', { note: stylesNote, status: 'skipped' });
|
|
155
|
+
return skips;
|
|
156
|
+
}
|
|
112
157
|
}
|
|
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../../lib/base.js';
|
|
|
2
2
|
import { pluralize } from '../../utils/pluralize.js';
|
|
3
3
|
import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
4
4
|
export default class List extends LoopressCommand {
|
|
5
|
-
static description = 'List snippets
|
|
5
|
+
static description = 'List snippets currently on WordPress';
|
|
6
6
|
static enableJsonFlag = true;
|
|
7
7
|
static examples = ['$ lps snippet list'];
|
|
8
8
|
async run() {
|
|
@@ -17,7 +17,7 @@ export default class Pull extends LoopressCommand {
|
|
|
17
17
|
static args = {
|
|
18
18
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
19
19
|
};
|
|
20
|
-
static description = 'Pull snippets from WordPress';
|
|
20
|
+
static description = 'Pull snippets from WordPress into local files';
|
|
21
21
|
static enableJsonFlag = true;
|
|
22
22
|
static examples = ['$ lps snippet pull', '$ lps snippet pull ./snippets'];
|
|
23
23
|
static flags = {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { PushCommand } from '../../lib/push-command.js';
|
|
1
|
+
import { type PushChange, PushCommand } from '../../lib/push-command.js';
|
|
2
2
|
type PushedSnippet = {
|
|
3
3
|
id?: number;
|
|
4
4
|
name: string;
|
|
5
5
|
};
|
|
6
6
|
type PushResult = {
|
|
7
|
+
changes?: PushChange[];
|
|
7
8
|
pushed: PushedSnippet[];
|
|
8
9
|
status: 'dry-run' | 'success';
|
|
9
10
|
};
|
|
@@ -24,5 +25,6 @@ export default class Push extends PushCommand {
|
|
|
24
25
|
private loadSnippets;
|
|
25
26
|
private pushSnippet;
|
|
26
27
|
private toPayload;
|
|
28
|
+
private withoutDuplicateIds;
|
|
27
29
|
}
|
|
28
30
|
export {};
|