@loopress/cli 0.31.0 → 0.32.1
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 +440 -221
- 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 +12 -4
- 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/diff.d.ts +1 -0
- package/dist/commands/diff.js +13 -2
- package/dist/commands/doctor.js +1 -1
- 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 +2 -2
- 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 +1 -1
- package/dist/commands/page/push.d.ts +4 -1
- package/dist/commands/page/push.js +40 -8
- package/dist/commands/pattern/push.js +1 -1
- package/dist/commands/pattern/rm.js +1 -1
- package/dist/commands/plugin/audit.js +1 -1
- package/dist/commands/plugin/push.js +1 -1
- package/dist/commands/plugin/status.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +22 -10
- package/dist/commands/push.d.ts +2 -0
- package/dist/commands/push.js +29 -9
- package/dist/commands/snippet/list.js +1 -1
- package/dist/commands/snippet/pull.js +1 -1
- package/dist/commands/snippet/push.d.ts +2 -1
- package/dist/commands/snippet/push.js +2 -2
- 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/push.js +1 -1
- 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 +1 -0
- package/dist/lib/diff-command.js +10 -2
- package/dist/lib/php-files-command.d.ts +3 -1
- package/dist/lib/php-files-command.js +34 -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 +17 -0
- package/dist/lib/push-command.js +42 -3
- package/dist/lib/validate-local.js +12 -5
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +13 -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 +153 -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 +548 -537
- package/package.json +2 -2
package/dist/commands/push.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { join
|
|
3
|
+
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
7
|
import { PushCommand } from '../lib/push-command.js';
|
|
8
8
|
import { isLocalEnvironment } from '../lib/wp-client.js';
|
|
9
9
|
import { wpackagistRequire } from '../utils/composer.js';
|
|
10
|
-
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
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' };
|
|
11
16
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
12
17
|
// relies on, then content itself.
|
|
13
18
|
const PUSH_TARGETS = [
|
|
@@ -26,13 +31,16 @@ const PUSH_TARGETS = [
|
|
|
26
31
|
{ commandId: 'pattern:push', label: 'patterns' },
|
|
27
32
|
// Before pages: WordPress refuses a page whose `template` it doesn't know yet.
|
|
28
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' },
|
|
29
37
|
{ commandId: 'page:push', label: 'pages' },
|
|
30
38
|
{ commandId: 'menu:push', label: 'menus' },
|
|
31
39
|
{ commandId: 'option:push', label: 'options' },
|
|
32
40
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
33
41
|
];
|
|
34
42
|
export default class Push extends LoopressCommand {
|
|
35
|
-
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';
|
|
36
44
|
static enableJsonFlag = true;
|
|
37
45
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
38
46
|
static flags = {
|
|
@@ -55,9 +63,9 @@ export default class Push extends LoopressCommand {
|
|
|
55
63
|
}
|
|
56
64
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
57
65
|
try {
|
|
58
|
-
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));
|
|
59
67
|
this.log(this.dryRun ? `✓ ${target.label} would push` : `✓ ${target.label} pushed`);
|
|
60
|
-
results.push({ label: target.label, status: 'pushed' });
|
|
68
|
+
results.push({ label: target.label, result, status: 'pushed' });
|
|
61
69
|
}
|
|
62
70
|
catch (error) {
|
|
63
71
|
const { message } = error;
|
|
@@ -103,6 +111,15 @@ export default class Push extends LoopressCommand {
|
|
|
103
111
|
composerJsonPath() {
|
|
104
112
|
return join(process.cwd(), this.rootDir, 'composer.json');
|
|
105
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
|
+
}
|
|
106
123
|
// Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
|
|
107
124
|
// delegated command.
|
|
108
125
|
async guardProductionPush() {
|
|
@@ -128,10 +145,13 @@ export default class Push extends LoopressCommand {
|
|
|
128
145
|
const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
|
|
129
146
|
if (!pluginsConfigured)
|
|
130
147
|
skips.set('plugin:push', notConfigured);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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' });
|
|
135
155
|
return skips;
|
|
136
156
|
}
|
|
137
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
|
};
|
|
@@ -13,7 +13,7 @@ export default class Push extends PushCommand {
|
|
|
13
13
|
static args = {
|
|
14
14
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
15
15
|
};
|
|
16
|
-
static description = 'Push
|
|
16
|
+
static description = 'Push local snippet files to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
|
|
17
17
|
static enableJsonFlag = true;
|
|
18
18
|
static examples = ['$ lps snippet push', '$ lps snippet push ./snippets'];
|
|
19
19
|
static flags = {
|
|
@@ -40,7 +40,7 @@ export default class Push extends PushCommand {
|
|
|
40
40
|
this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
|
|
41
41
|
}
|
|
42
42
|
if (this.dryRun)
|
|
43
|
-
return { pushed, status: 'dry-run' };
|
|
43
|
+
return { changes: this.previewChanges, pushed, status: 'dry-run' };
|
|
44
44
|
await this.recordSuccess();
|
|
45
45
|
this.log('All snippets pushed.');
|
|
46
46
|
return { pushed, status: 'success' };
|
|
@@ -7,7 +7,7 @@ const PULL_TARGETS = [
|
|
|
7
7
|
{ commandId: 'theme:style:pull', label: 'Global Styles' },
|
|
8
8
|
];
|
|
9
9
|
export default class Pull extends LoopressCommand {
|
|
10
|
-
static description = 'Pull everything theme related from WordPress: theme versions
|
|
10
|
+
static description = 'Pull everything theme related from WordPress: theme versions into loopress.json, then Global Styles into a local file';
|
|
11
11
|
static enableJsonFlag = true;
|
|
12
12
|
static examples = ['$ lps theme pull', '$ lps theme pull --env staging', '$ lps theme pull --dry-run'];
|
|
13
13
|
static flags = {
|
|
@@ -13,7 +13,7 @@ const PUSH_TARGETS = [
|
|
|
13
13
|
{ commandId: 'theme:style:push', label: 'Global Styles' },
|
|
14
14
|
];
|
|
15
15
|
export default class Push extends LoopressCommand {
|
|
16
|
-
static description = 'Push everything theme related to WordPress: theme versions, templates and parts, then Global Styles';
|
|
16
|
+
static description = 'Push everything theme related to WordPress: theme versions from loopress.json, then block templates and parts, then Global Styles. Never switches the active theme.';
|
|
17
17
|
static enableJsonFlag = true;
|
|
18
18
|
static examples = ['$ lps theme push', '$ lps theme push --env staging', '$ lps theme push --dry-run'];
|
|
19
19
|
static flags = {
|
|
@@ -46,9 +46,9 @@ export default class Push extends LoopressCommand {
|
|
|
46
46
|
argv.push('--force');
|
|
47
47
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
48
48
|
try {
|
|
49
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
49
|
+
const result = await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
50
50
|
this.log(`✓ ${target.label} pushed`);
|
|
51
|
-
results.push({ label: target.label, status: 'pushed' });
|
|
51
|
+
results.push({ label: target.label, result, status: 'pushed' });
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
54
|
const { message } = error;
|
|
@@ -32,12 +32,11 @@ export default class Push extends PushCommand {
|
|
|
32
32
|
}
|
|
33
33
|
throw error;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
this.log(`[dry-run] Would push: ${file}`);
|
|
37
|
-
return { file, status: 'dry-run' };
|
|
38
|
-
}
|
|
35
|
+
// Only the active theme's file is pushed, whatever else the directory holds.
|
|
39
36
|
const provider = getResourceStateProvider('theme-styles');
|
|
40
|
-
const beforeState = await this.captureBeforePushState(provider, dir);
|
|
37
|
+
const beforeState = await this.captureBeforePushState(provider, dir, (name) => name === stylesheet);
|
|
38
|
+
if (this.dryRun)
|
|
39
|
+
return { changes: this.previewChanges, file, status: 'dry-run' };
|
|
41
40
|
const local = JSON.parse(raw);
|
|
42
41
|
await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
|
|
43
42
|
this.log(`Pushed: ${file}`);
|
|
@@ -2,7 +2,7 @@ import { LoopressCommand } from '../../../lib/base.js';
|
|
|
2
2
|
import { pluralize } from '../../../utils/pluralize.js';
|
|
3
3
|
import { CHILD_THEME_ENDPOINT } from '../../../utils/template-format.js';
|
|
4
4
|
export default class List extends LoopressCommand {
|
|
5
|
-
static description = 'Show the Loopress child theme of the active block theme: its templates
|
|
5
|
+
static description = 'Show the Loopress child theme of the active block theme: whether it is active, its templates and parts, and those edited in the Site Editor';
|
|
6
6
|
static enableJsonFlag = true;
|
|
7
7
|
static examples = ['$ lps theme template list'];
|
|
8
8
|
async run() {
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import { PushCommand } from '../../../lib/push-command.js';
|
|
1
|
+
import { type PushChange, PushCommand } from '../../../lib/push-command.js';
|
|
2
|
+
type CssChange = {
|
|
3
|
+
id: string;
|
|
4
|
+
status: 'unknown' | PushChange['status'];
|
|
5
|
+
};
|
|
2
6
|
type PushResult = {
|
|
3
7
|
active?: boolean;
|
|
4
8
|
blockStyles: string[];
|
|
9
|
+
changes?: {
|
|
10
|
+
css: CssChange[];
|
|
11
|
+
parts: PushChange[];
|
|
12
|
+
templates: PushChange[];
|
|
13
|
+
};
|
|
5
14
|
customized?: string[];
|
|
6
15
|
parts: string[];
|
|
7
16
|
status: 'dry-run' | 'success';
|
|
@@ -18,5 +27,6 @@ export default class Push extends PushCommand {
|
|
|
18
27
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
28
|
};
|
|
20
29
|
run(): Promise<PushResult>;
|
|
30
|
+
private previewChildTheme;
|
|
21
31
|
}
|
|
22
32
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { logChanges, pushChanges, PushCommand } from '../../../lib/push-command.js';
|
|
3
3
|
import { formatPageProblems } from '../../../utils/page-format.js';
|
|
4
4
|
import { pluralize } from '../../../utils/pluralize.js';
|
|
5
5
|
import { resolveResourceDir } from '../../../utils/resource-dirs.js';
|
|
@@ -33,9 +33,11 @@ export default class Push extends PushCommand {
|
|
|
33
33
|
this.log(`Found ${pluralize(templates.length, 'template')}, ${pluralize(parts.length, 'part')}, ` +
|
|
34
34
|
`${css.style ? 'a global stylesheet' : 'no global stylesheet'} and ${pluralize(css.blocks.length, 'block stylesheet')}`);
|
|
35
35
|
if (this.dryRun) {
|
|
36
|
-
await this.
|
|
37
|
-
this.log(
|
|
38
|
-
|
|
36
|
+
const changes = await this.previewChildTheme(templates, parts, css);
|
|
37
|
+
logChanges((message) => { this.log(message); }, changes.templates, 'templates/');
|
|
38
|
+
logChanges((message) => { this.log(message); }, changes.parts, 'parts/');
|
|
39
|
+
logChanges((message) => { this.log(message); }, changes.css, 'css/');
|
|
40
|
+
return { ...names, changes, status: 'dry-run' };
|
|
39
41
|
}
|
|
40
42
|
const child = await this.wp.put(CHILD_THEME_ENDPOINT, { ...css, parts, templates });
|
|
41
43
|
await this.recordSuccess();
|
|
@@ -62,4 +64,31 @@ export default class Push extends PushCommand {
|
|
|
62
64
|
unknownBlocks,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
67
|
+
// Same read and same refusal as previewRemote, one GET for templates, parts and CSS.
|
|
68
|
+
async previewChildTheme(templates, parts, css) {
|
|
69
|
+
let child;
|
|
70
|
+
try {
|
|
71
|
+
child = await this.wp.get(CHILD_THEME_ENDPOINT);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.error(`The site would refuse this push: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
const localCss = new Map(css.blocks.map((block) => [`blocks/${block.name}.css`, sha256(block.css)]));
|
|
77
|
+
if (css.style !== '')
|
|
78
|
+
localCss.set('style.css', sha256(css.style));
|
|
79
|
+
const cssChanges = child.css
|
|
80
|
+
? pushChanges(localCss, new Map(Object.entries(child.css)), { mirror: true })
|
|
81
|
+
: [...localCss.keys()].toSorted((a, b) => a.localeCompare(b)).map((id) => ({ id, status: 'unknown' }));
|
|
82
|
+
return {
|
|
83
|
+
css: cssChanges,
|
|
84
|
+
parts: pushChanges(bySlug(parts), bySlug(child.parts), { mirror: true }),
|
|
85
|
+
templates: pushChanges(bySlug(templates), bySlug(child.templates), { mirror: true }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function bySlug(files) {
|
|
90
|
+
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
91
|
+
}
|
|
92
|
+
function sha256(text) {
|
|
93
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
65
94
|
}
|
|
@@ -9,7 +9,7 @@ import { lockedWpackagistSlugs } from '../../../utils/plugins.js';
|
|
|
9
9
|
import { diffThemes, parseInstalledThemes } from '../../../utils/themes.js';
|
|
10
10
|
const IN_SYNC = { installed: [], pinned: [], removed: [], status: 'in-sync' };
|
|
11
11
|
export default class Push extends PushCommand {
|
|
12
|
-
static description = 'Install themes on WordPress to match loopress.json (versions only, never switches the active theme)';
|
|
12
|
+
static description = 'Install WordPress.org themes on WordPress to match loopress.json, via Composer + WPackagist (versions only, never switches the active theme)';
|
|
13
13
|
static enableJsonFlag = true;
|
|
14
14
|
static examples = ['$ lps theme version push', '$ lps theme version push --force'];
|
|
15
15
|
static flags = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type DeclaredTypeSpec, type RegisteredItem } from '../utils/declared-type-format.js';
|
|
2
2
|
import { LoopressCommand } from './base.js';
|
|
3
3
|
import { type CommandClass } from './php-files-command.js';
|
|
4
|
-
import { PushCommand } from './push-command.js';
|
|
4
|
+
import { type PushChange, PushCommand } from './push-command.js';
|
|
5
5
|
type PushResult = {
|
|
6
|
+
changes?: PushChange[];
|
|
6
7
|
path: string;
|
|
7
8
|
pushed: string[];
|
|
8
9
|
status: 'dry-run' | 'success';
|
|
@@ -54,7 +54,7 @@ export function declaredPushCommand(spec, description) {
|
|
|
54
54
|
this.error(`${pluralize(this.failedCount, spec.noun, spec.plural)} failed to push.`);
|
|
55
55
|
}
|
|
56
56
|
if (this.dryRun)
|
|
57
|
-
return { path, pushed, status: 'dry-run' };
|
|
57
|
+
return { changes: this.previewChanges, path, pushed, status: 'dry-run' };
|
|
58
58
|
await this.recordSuccess();
|
|
59
59
|
this.log(`All ${spec.plural} pushed.`);
|
|
60
60
|
return { path, pushed, status: 'success' };
|
package/dist/lib/diff-command.js
CHANGED
|
@@ -4,6 +4,7 @@ import { configManager } from '../config/project-config.manager.js';
|
|
|
4
4
|
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
5
5
|
import { LoopressCommand } from './base.js';
|
|
6
6
|
import { compareStates, isEmptyDiff } from './diff-state.js';
|
|
7
|
+
import { INTEGRATION_MISSING } from './plugin-detection.js';
|
|
7
8
|
import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
|
|
8
9
|
import { WpClient } from './wp-client.js';
|
|
9
10
|
const c = ux.colorize;
|
|
@@ -64,7 +65,8 @@ export class DiffCommand extends LoopressCommand {
|
|
|
64
65
|
let added = 0;
|
|
65
66
|
let removed = 0;
|
|
66
67
|
let changed = 0;
|
|
67
|
-
|
|
68
|
+
const checked = results.filter((result) => !result.skipped);
|
|
69
|
+
for (const result of checked) {
|
|
68
70
|
resources[result.resource] = result.json;
|
|
69
71
|
if (result.json.error !== undefined) {
|
|
70
72
|
failed = true;
|
|
@@ -80,6 +82,9 @@ export class DiffCommand extends LoopressCommand {
|
|
|
80
82
|
if (result.drift)
|
|
81
83
|
drift = true;
|
|
82
84
|
}
|
|
85
|
+
const skipped = results.filter((result) => result.skipped);
|
|
86
|
+
for (const result of skipped)
|
|
87
|
+
this.out(`${c('bold', result.title)} ${c('dim', 'not configured, skipped')}`);
|
|
83
88
|
this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
|
|
84
89
|
if (failed)
|
|
85
90
|
this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
|
|
@@ -121,7 +126,10 @@ export class DiffCommand extends LoopressCommand {
|
|
|
121
126
|
}
|
|
122
127
|
catch (error) {
|
|
123
128
|
const { message } = error;
|
|
124
|
-
|
|
129
|
+
const empty = { added: [], changed: [], removed: [] };
|
|
130
|
+
if (target.optional && INTEGRATION_MISSING.test(message))
|
|
131
|
+
return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title };
|
|
132
|
+
return { drift: false, json: { ...empty, error: message }, resource: target.resource, title: target.title };
|
|
125
133
|
}
|
|
126
134
|
}
|
|
127
135
|
out(message) {
|
|
@@ -2,7 +2,7 @@ import { type Config } from '@oclif/core';
|
|
|
2
2
|
import { type EnvironmentConfig } from '../types/config.js';
|
|
3
3
|
import { type ResourceDirKind } from '../utils/resource-dirs.js';
|
|
4
4
|
import { LoopressCommand } from './base.js';
|
|
5
|
-
import { PushCommand } from './push-command.js';
|
|
5
|
+
import { type PushChange, PushCommand } from './push-command.js';
|
|
6
6
|
export type PhpFilesResource = {
|
|
7
7
|
afterPush?: (context: {
|
|
8
8
|
filenames: string[];
|
|
@@ -40,6 +40,7 @@ export type RuntimeError = {
|
|
|
40
40
|
};
|
|
41
41
|
export declare function formatRuntimeError(error: RuntimeError): string;
|
|
42
42
|
type PushResult = {
|
|
43
|
+
changes?: PushChange[];
|
|
43
44
|
pruned: string[];
|
|
44
45
|
pushed: string[];
|
|
45
46
|
status: 'dry-run' | 'success';
|
|
@@ -67,6 +68,7 @@ export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
|
67
68
|
export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
68
69
|
run(): Promise<RmResult>;
|
|
69
70
|
};
|
|
71
|
+
export declare function phpFileProblems(spec: Pick<PhpFilesResource, 'filenamePattern' | 'invalidFilenameHint'>, filename: string, content: string): string[];
|
|
70
72
|
export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
|
|
71
73
|
export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
|
|
72
74
|
export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
|
|
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join, relative, sep } from 'node:path';
|
|
6
6
|
import { authManager } from '../config/auth.manager.js';
|
|
7
|
+
import { firstTopLevelSideEffect, startsWithStrictTypes } from '../utils/php-top-level.js';
|
|
7
8
|
import { pluralize } from '../utils/pluralize.js';
|
|
8
9
|
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
9
10
|
import { ApiClient } from './api-client.js';
|
|
@@ -22,6 +23,29 @@ export function formatRuntimeError(error) {
|
|
|
22
23
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
23
24
|
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
24
25
|
const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
|
|
26
|
+
// What push checks on a route or hook file before sending it (the server rejects the same):
|
|
27
|
+
// shared with `lps validate`. The server still runs what needs WordPress (PHP syntax, class-name
|
|
28
|
+
// collisions), so passing these doesn't guarantee the push.
|
|
29
|
+
// `filename`: the path under the resource dir, without `.php`.
|
|
30
|
+
export function phpFileProblems(spec, filename, content) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
if (!spec.filenamePattern.test(filename))
|
|
33
|
+
problems.push(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
|
|
34
|
+
const declareCount = content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
35
|
+
if (declareCount !== 1) {
|
|
36
|
+
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
37
|
+
problems.push(`${filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`);
|
|
38
|
+
}
|
|
39
|
+
else if (!startsWithStrictTypes(content)) {
|
|
40
|
+
problems.push(`${filename}.php: "declare(strict_types=1);" must be the first statement, right after <?php (one in a comment doesn't count)`);
|
|
41
|
+
}
|
|
42
|
+
const sideEffect = firstTopLevelSideEffect(content);
|
|
43
|
+
if (sideEffect !== null) {
|
|
44
|
+
problems.push(`${filename}.php: code outside the class (${sideEffect}) would run at load time, move it into the class. ` +
|
|
45
|
+
"Don't add an ABSPATH guard: the plugin adds one on push.");
|
|
46
|
+
}
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
25
49
|
// A file's expected revision (#234), computed from `beforeState`: the remote listing already
|
|
26
50
|
// read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
|
|
27
51
|
// for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
|
|
@@ -94,10 +118,8 @@ export function resourcePushCommand(spec) {
|
|
|
94
118
|
// files without also landing the new ones.
|
|
95
119
|
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
|
|
96
120
|
if (this.dryRun) {
|
|
97
|
-
for (const file of files)
|
|
98
|
-
this.log(`[dry-run] Would push: ${file.filename}`);
|
|
99
121
|
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
100
|
-
return { pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
|
|
122
|
+
return { changes: this.previewChanges, pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
|
|
101
123
|
}
|
|
102
124
|
const { pruned, pushed } = files.length === 0 && pruneList.length === 0
|
|
103
125
|
? { pruned: [], pushed: [] }
|
|
@@ -180,14 +202,7 @@ export function resourcePushCommand(spec) {
|
|
|
180
202
|
return orphans;
|
|
181
203
|
}
|
|
182
204
|
validateFileLocally(file, task) {
|
|
183
|
-
|
|
184
|
-
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
185
|
-
this.reportTaskFailure(message, new Error(message), task);
|
|
186
|
-
}
|
|
187
|
-
const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
188
|
-
if (declareCount !== 1) {
|
|
189
|
-
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
190
|
-
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
205
|
+
for (const message of phpFileProblems(spec, file.filename, file.content)) {
|
|
191
206
|
this.reportTaskFailure(message, new Error(message), task);
|
|
192
207
|
}
|
|
193
208
|
if (task)
|
|
@@ -353,14 +368,14 @@ export const API_FILES_RESOURCE = {
|
|
|
353
368
|
filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
|
|
354
369
|
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
|
|
355
370
|
label: 'API routes',
|
|
356
|
-
listDescription: 'List custom API route files
|
|
371
|
+
listDescription: 'List custom API route files currently on WordPress',
|
|
357
372
|
noun: 'route file',
|
|
358
373
|
orphanReason: 'whose route no longer exists on WordPress',
|
|
359
374
|
pathLabel: 'API',
|
|
360
375
|
pathNoun: 'api directory',
|
|
361
|
-
pullDescription: 'Pull custom API route files from WordPress',
|
|
362
|
-
pushDescription: 'Push custom API route files to WordPress',
|
|
363
|
-
rmDescription: 'Remove
|
|
376
|
+
pullDescription: 'Pull custom API route files from WordPress into local files',
|
|
377
|
+
pushDescription: 'Push local custom API route files to WordPress',
|
|
378
|
+
rmDescription: 'Remove one custom API route file from WordPress',
|
|
364
379
|
};
|
|
365
380
|
export const HOOK_FILES_RESOURCE = {
|
|
366
381
|
cliName: 'hook',
|
|
@@ -372,12 +387,12 @@ export const HOOK_FILES_RESOURCE = {
|
|
|
372
387
|
filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
|
|
373
388
|
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
|
|
374
389
|
label: 'hooks',
|
|
375
|
-
listDescription: 'List hook files (actions, filters, cron)
|
|
390
|
+
listDescription: 'List hook files (actions, filters, cron) currently on WordPress',
|
|
376
391
|
noun: 'hook file',
|
|
377
392
|
orphanReason: 'no longer present on WordPress',
|
|
378
393
|
pathLabel: 'Hooks',
|
|
379
394
|
pathNoun: 'hooks directory',
|
|
380
|
-
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
|
|
381
|
-
pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
|
|
382
|
-
rmDescription: 'Remove
|
|
395
|
+
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress into local files',
|
|
396
|
+
pushDescription: 'Push local hook files (actions, filters, cron) to WordPress',
|
|
397
|
+
rmDescription: 'Remove one hook file (action, filter, cron) from WordPress',
|
|
383
398
|
};
|
|
@@ -9,3 +9,7 @@ export async function isLoopressFullActive(wp) {
|
|
|
9
9
|
const plugins = await wp.get('wp/v2/plugins');
|
|
10
10
|
return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
|
|
11
11
|
}
|
|
12
|
+
// How the Loopress plugin reports a resource's integration missing: "ACF is not active", "No
|
|
13
|
+
// supported form plugin is active", "No supported snippet plugin is active". Global Styles say a
|
|
14
|
+
// classic theme is active. The aggregate commands skip such a resource the project doesn't use.
|
|
15
|
+
export const INTEGRATION_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b|\bis a classic theme\b/;
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
2
|
import { type ResourceState } from './diff-state.js';
|
|
3
3
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
|
+
export type PushChange = {
|
|
5
|
+
id: string;
|
|
6
|
+
status: 'changed' | 'created' | 'removed' | 'unchanged';
|
|
7
|
+
};
|
|
8
|
+
export declare function pushChanges(local: ResourceState, remote: ResourceState, options?: {
|
|
9
|
+
mirror?: boolean;
|
|
10
|
+
writes?: (id: string) => boolean;
|
|
11
|
+
}): PushChange[];
|
|
12
|
+
export declare function logChanges(log: (message: string) => void, changes: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}>, prefix?: string): void;
|
|
4
16
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
5
17
|
static baseFlags: {
|
|
6
18
|
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -8,6 +20,11 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
8
20
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
21
|
};
|
|
10
22
|
protected failedCount: number;
|
|
23
|
+
protected previewChanges: PushChange[];
|
|
24
|
+
protected previewStates?: {
|
|
25
|
+
local: ResourceState;
|
|
26
|
+
remote: ResourceState;
|
|
27
|
+
};
|
|
11
28
|
private refusedByGuard;
|
|
12
29
|
protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
|
|
13
30
|
catch(err: Error): Promise<void>;
|
package/dist/lib/push-command.js
CHANGED
|
@@ -11,15 +11,41 @@ import { guardProductionPush } from './guard-production-push.js';
|
|
|
11
11
|
import { isInteractive } from './interactive.js';
|
|
12
12
|
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
13
13
|
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
14
|
+
export function pushChanges(local, remote, options = {}) {
|
|
15
|
+
const changes = [...local.keys()]
|
|
16
|
+
.filter((id) => options.writes?.(id) ?? true)
|
|
17
|
+
.map((id) => {
|
|
18
|
+
if (!remote.has(id))
|
|
19
|
+
return { id, status: 'created' };
|
|
20
|
+
return { id, status: isDeepStrictEqual(local.get(id), remote.get(id)) ? 'unchanged' : 'changed' };
|
|
21
|
+
});
|
|
22
|
+
if (options.mirror) {
|
|
23
|
+
for (const id of remote.keys())
|
|
24
|
+
if (!local.has(id))
|
|
25
|
+
changes.push({ id, status: 'removed' });
|
|
26
|
+
}
|
|
27
|
+
return changes.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
28
|
+
}
|
|
29
|
+
export function logChanges(log, changes, prefix = '') {
|
|
30
|
+
for (const change of changes)
|
|
31
|
+
log(`[dry-run] ${change.status}: ${prefix}${change.id}`);
|
|
32
|
+
}
|
|
14
33
|
export class PushCommand extends LoopressCommand {
|
|
15
34
|
// Passed by an aggregate command (`lps theme push`) that already confirmed production once,
|
|
16
35
|
// so its steps skip that prompt while still asking their own confirmations (uninstalls).
|
|
17
36
|
static baseFlags = {
|
|
18
37
|
...LoopressCommand.baseFlags,
|
|
19
|
-
overwrite: Flags.boolean({
|
|
38
|
+
overwrite: Flags.boolean({
|
|
39
|
+
description: 'Push even over changes made on the site outside Loopress since your last push. Pages: also take over a same-slug page Loopress did not create',
|
|
40
|
+
}),
|
|
20
41
|
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
21
42
|
};
|
|
22
43
|
failedCount = 0;
|
|
44
|
+
// A dry run's per-item preview, set by captureBeforePushState: what the push would create,
|
|
45
|
+
// change or leave as it is on the site.
|
|
46
|
+
previewChanges = [];
|
|
47
|
+
// Both sides those statuses were computed from, for a push that details a changed item.
|
|
48
|
+
previewStates;
|
|
23
49
|
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
24
50
|
// deployment history with failure records.
|
|
25
51
|
refusedByGuard = false;
|
|
@@ -29,13 +55,26 @@ export class PushCommand extends LoopressCommand {
|
|
|
29
55
|
// interrupt the push flow" stance recordDeployment takes; `undefined` tells
|
|
30
56
|
// `writeAfterPushSnapshot` there is no restore point to complete.
|
|
31
57
|
//
|
|
32
|
-
// On a dry run, the same read is the preview's check against the site (see previewRemote)
|
|
58
|
+
// On a dry run, the same read is the preview's check against the site (see previewRemote), and
|
|
59
|
+
// the reference for its per-item statuses (`previewChanges`).
|
|
33
60
|
//
|
|
34
61
|
// `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
|
|
35
62
|
// skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
|
|
36
63
|
async captureBeforePushState(provider, dir, writes) {
|
|
37
64
|
if (this.dryRun) {
|
|
38
|
-
|
|
65
|
+
const remote = await this.previewRemote(provider, dir);
|
|
66
|
+
await this.guardOutsideEdits(provider, dir, remote, writes);
|
|
67
|
+
try {
|
|
68
|
+
const local = await provider.local(dir, () => { });
|
|
69
|
+
this.previewStates = { local, remote };
|
|
70
|
+
this.previewChanges = pushChanges(local, remote, { writes });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// The push reports the unreadable files itself, but an empty `changes` must not read as
|
|
74
|
+
// "nothing changes".
|
|
75
|
+
this.warn(`Could not list what this push would change: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
logChanges((message) => { this.log(message); }, this.previewChanges);
|
|
39
78
|
return undefined;
|
|
40
79
|
}
|
|
41
80
|
let remote;
|