@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
|
@@ -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 = {
|
|
@@ -28,7 +28,7 @@ export default class Push extends PushCommand {
|
|
|
28
28
|
this.log(`Snippets path: ${path}`);
|
|
29
29
|
const provider = getResourceStateProvider('snippet');
|
|
30
30
|
const beforeState = await this.captureBeforePushState(provider, path);
|
|
31
|
-
const snippets = await this.loadSnippets(path);
|
|
31
|
+
const snippets = this.withoutDuplicateIds(await this.loadSnippets(path));
|
|
32
32
|
this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
|
|
33
33
|
const pushed = [];
|
|
34
34
|
await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
|
|
@@ -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' };
|
|
@@ -148,8 +148,11 @@ export default class Push extends PushCommand {
|
|
|
148
148
|
toPayload(snippet) {
|
|
149
149
|
return {
|
|
150
150
|
active: snippet.active,
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// Only PHP files carry an editor-only `<?php` tag: an HTML snippet starting with an inline
|
|
152
|
+
// `<?php ... ?>` block is its real content and must reach WordPress untouched.
|
|
153
|
+
code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
|
|
154
|
+
// The sidecar's description wins, so a pull then push never erases one written in wp-admin.
|
|
155
|
+
description: snippet.description ?? `Imported from ${snippet.path}`,
|
|
153
156
|
insertMethod: snippet.insertMethod,
|
|
154
157
|
location: snippet.location,
|
|
155
158
|
name: snippet.name,
|
|
@@ -159,4 +162,22 @@ export default class Push extends PushCommand {
|
|
|
159
162
|
type: snippet.type,
|
|
160
163
|
};
|
|
161
164
|
}
|
|
165
|
+
// Two files claiming the same id would each PUT over that id, the last one silently replacing
|
|
166
|
+
// the other on the site (seen after switching snippet provider, whose ids restart). Neither is
|
|
167
|
+
// pushed, and the push fails, until the files are fixed by hand.
|
|
168
|
+
withoutDuplicateIds(snippets) {
|
|
169
|
+
const pathsById = new Map();
|
|
170
|
+
for (const { id, path } of snippets) {
|
|
171
|
+
if (id !== undefined)
|
|
172
|
+
pathsById.set(id, [...(pathsById.get(id) ?? []), path]);
|
|
173
|
+
}
|
|
174
|
+
return snippets.filter((snippet) => {
|
|
175
|
+
const paths = snippet.id === undefined ? [] : pathsById.get(snippet.id);
|
|
176
|
+
if (paths.length < 2)
|
|
177
|
+
return true;
|
|
178
|
+
this.failedCount++;
|
|
179
|
+
this.warn(`Skipping "${snippet.path}": id ${snippet.id} is also used by ${paths.filter((p) => p !== snippet.path).join(', ')}.`);
|
|
180
|
+
return false;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
162
183
|
}
|
|
@@ -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
|
}
|
|
@@ -16,8 +16,13 @@ export default class Pull extends LoopressCommand {
|
|
|
16
16
|
const { url } = this.siteConfig;
|
|
17
17
|
this.log(`Pulling themes from ${url}`);
|
|
18
18
|
const raw = await this.wp.get('wp/v2/themes');
|
|
19
|
+
// A theme with no Version header can't be pinned, and `theme version push` would then fail on
|
|
20
|
+
// it: the Loopress child theme (`<parent>-loopress`, written by `theme template push`) is one.
|
|
19
21
|
const installed = parseInstalledThemes(raw);
|
|
20
|
-
const
|
|
22
|
+
const unversioned = installed.filter((t) => t.version === '').map((t) => t.slug);
|
|
23
|
+
const incoming = Object.fromEntries(installed.filter((t) => t.version !== '').map((t) => [t.slug, t.version]));
|
|
24
|
+
if (unversioned.length > 0)
|
|
25
|
+
this.log(` - Skipped: ${unversioned.join(', ')} (no version to pin)`);
|
|
21
26
|
// A composer.json is authoritative over loopress.json: pin the live versions there instead.
|
|
22
27
|
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
23
28
|
if (existsSync(composerJsonPath)) {
|
|
@@ -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 = {
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
14
14
|
};
|
|
15
15
|
protected dryRun: boolean;
|
|
16
16
|
protected localConfig: LoopressLocalConfig;
|
|
17
|
+
protected overwrite: boolean;
|
|
17
18
|
protected productionConfirmed: boolean;
|
|
18
19
|
protected projectId: string;
|
|
19
20
|
protected siteConfig: EnvironmentConfig;
|
package/dist/lib/base.js
CHANGED
|
@@ -28,6 +28,8 @@ export class LoopressCommand extends Command {
|
|
|
28
28
|
};
|
|
29
29
|
dryRun = false;
|
|
30
30
|
localConfig = {};
|
|
31
|
+
// Set by PushCommand's --overwrite, see PushCommand.guardOutsideEdits.
|
|
32
|
+
overwrite = false;
|
|
31
33
|
// Set by PushCommand's hidden --production-confirmed, see there.
|
|
32
34
|
productionConfirmed = false;
|
|
33
35
|
projectId;
|
|
@@ -58,6 +60,7 @@ export class LoopressCommand extends Command {
|
|
|
58
60
|
}));
|
|
59
61
|
this.dryRun = Boolean(flags['dry-run']);
|
|
60
62
|
this.yes = Boolean(flags.yes);
|
|
63
|
+
this.overwrite = Boolean(flags.overwrite);
|
|
61
64
|
this.productionConfirmed = Boolean(flags['production-confirmed']);
|
|
62
65
|
this.localConfig = await readLocalConfig();
|
|
63
66
|
const resolved = this.resolveEnvironment(flags.env);
|
|
@@ -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' };
|
|
@@ -16,6 +16,7 @@ export type DiffJson = {
|
|
|
16
16
|
};
|
|
17
17
|
export type DiffTarget = {
|
|
18
18
|
left: () => Promise<ResourceState>;
|
|
19
|
+
optional?: boolean;
|
|
19
20
|
resource: string;
|
|
20
21
|
right: () => Promise<ResourceState>;
|
|
21
22
|
title: string;
|
|
@@ -23,6 +24,7 @@ export type DiffTarget = {
|
|
|
23
24
|
type DiffSides = {
|
|
24
25
|
againstWp: undefined | WpClient;
|
|
25
26
|
leftLabel: string;
|
|
27
|
+
rightHeading: string;
|
|
26
28
|
rightLabel: string;
|
|
27
29
|
warn: (message: string) => void;
|
|
28
30
|
};
|
|
@@ -37,6 +39,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
|
|
|
37
39
|
abstract run(): Promise<DiffJson>;
|
|
38
40
|
private computeResource;
|
|
39
41
|
private out;
|
|
42
|
+
private renderFailure;
|
|
40
43
|
private renderSection;
|
|
41
44
|
private wpForEnvironment;
|
|
42
45
|
}
|
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;
|
|
@@ -47,8 +48,16 @@ export class DiffCommand extends LoopressCommand {
|
|
|
47
48
|
// a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
|
|
48
49
|
// when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
|
|
49
50
|
async report(targets, sides) {
|
|
50
|
-
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.
|
|
51
|
+
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
|
|
51
52
|
const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
|
|
53
|
+
// Every resource failing the same way (site down, bad credentials) is one problem: say it
|
|
54
|
+
// once instead of once per resource.
|
|
55
|
+
const sharedError = sharedFailure(results.map((result) => result.json.error));
|
|
56
|
+
if (sharedError !== undefined) {
|
|
57
|
+
// Named, since some resources may have compared fine (one that needs no request).
|
|
58
|
+
const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
|
|
59
|
+
this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
|
|
60
|
+
}
|
|
52
61
|
const resources = {};
|
|
53
62
|
let drift = false;
|
|
54
63
|
let failed = false;
|
|
@@ -56,12 +65,12 @@ export class DiffCommand extends LoopressCommand {
|
|
|
56
65
|
let added = 0;
|
|
57
66
|
let removed = 0;
|
|
58
67
|
let changed = 0;
|
|
59
|
-
|
|
68
|
+
const checked = results.filter((result) => !result.skipped);
|
|
69
|
+
for (const result of checked) {
|
|
60
70
|
resources[result.resource] = result.json;
|
|
61
71
|
if (result.json.error !== undefined) {
|
|
62
72
|
failed = true;
|
|
63
|
-
|
|
64
|
-
this.out(`${c('bold', result.title)} ${errorLabel}`);
|
|
73
|
+
this.renderFailure(result.title, result.json.error, sharedError);
|
|
65
74
|
continue;
|
|
66
75
|
}
|
|
67
76
|
compared += 1;
|
|
@@ -73,6 +82,9 @@ export class DiffCommand extends LoopressCommand {
|
|
|
73
82
|
if (result.drift)
|
|
74
83
|
drift = true;
|
|
75
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')}`);
|
|
76
88
|
this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
|
|
77
89
|
if (failed)
|
|
78
90
|
this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
|
|
@@ -91,6 +103,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
91
103
|
return {
|
|
92
104
|
againstWp: against ? this.wpForEnvironment(against) : undefined,
|
|
93
105
|
leftLabel: this.siteConfig.name,
|
|
106
|
+
rightHeading: against ?? 'local files',
|
|
94
107
|
rightLabel: against ?? 'local',
|
|
95
108
|
warn: (message) => {
|
|
96
109
|
this.warn(message);
|
|
@@ -113,13 +126,23 @@ export class DiffCommand extends LoopressCommand {
|
|
|
113
126
|
}
|
|
114
127
|
catch (error) {
|
|
115
128
|
const { message } = error;
|
|
116
|
-
|
|
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 };
|
|
117
133
|
}
|
|
118
134
|
}
|
|
119
135
|
out(message) {
|
|
120
136
|
if (!this.jsonEnabled())
|
|
121
137
|
this.log(message);
|
|
122
138
|
}
|
|
139
|
+
renderFailure(title, error, sharedError) {
|
|
140
|
+
// Already printed once by `report` when every failure is the same.
|
|
141
|
+
if (sharedError !== undefined)
|
|
142
|
+
return;
|
|
143
|
+
const errorLabel = c('red', `error: ${error}`);
|
|
144
|
+
this.out(`${c('bold', title)} ${errorLabel}`);
|
|
145
|
+
}
|
|
123
146
|
renderSection(title, stateDiff) {
|
|
124
147
|
if (isEmptyDiff(stateDiff)) {
|
|
125
148
|
this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
|
|
@@ -180,3 +203,9 @@ function indentPatch(patch) {
|
|
|
180
203
|
.join('\n')
|
|
181
204
|
.trimEnd();
|
|
182
205
|
}
|
|
206
|
+
// The one error every failed resource shares, when more than one failed (see `report`).
|
|
207
|
+
function sharedFailure(errors) {
|
|
208
|
+
const failures = errors.filter((error) => error !== undefined);
|
|
209
|
+
const distinct = new Set(failures);
|
|
210
|
+
return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
|
|
211
|
+
}
|
|
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
26
26
|
}
|
|
27
27
|
let id;
|
|
28
28
|
let name;
|
|
29
|
+
let description;
|
|
29
30
|
let type;
|
|
30
31
|
let isActive = false;
|
|
31
32
|
let tags = [];
|
|
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
40
41
|
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
41
42
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
42
43
|
name = meta.name || undefined;
|
|
44
|
+
// Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
|
|
45
|
+
description = meta.description;
|
|
43
46
|
type = parseType(meta.type) ?? undefined;
|
|
44
47
|
isActive = Boolean(meta.active);
|
|
45
48
|
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
58
61
|
return {
|
|
59
62
|
active: isActive,
|
|
60
63
|
code: content,
|
|
64
|
+
description,
|
|
61
65
|
id,
|
|
62
66
|
insertMethod: insertMethod ?? 'auto',
|
|
63
67
|
location: location ?? defaultLocationForType(resolvedType),
|
|
@@ -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[];
|
|
@@ -30,8 +30,17 @@ type RemotePhpFile = {
|
|
|
30
30
|
filename: string;
|
|
31
31
|
public?: boolean;
|
|
32
32
|
revision?: string;
|
|
33
|
+
runtimeError?: RuntimeError;
|
|
33
34
|
};
|
|
35
|
+
export type RuntimeError = {
|
|
36
|
+
at: string;
|
|
37
|
+
message: string;
|
|
38
|
+
method: string;
|
|
39
|
+
type: 'action' | 'cron' | 'filter';
|
|
40
|
+
};
|
|
41
|
+
export declare function formatRuntimeError(error: RuntimeError): string;
|
|
34
42
|
type PushResult = {
|
|
43
|
+
changes?: PushChange[];
|
|
35
44
|
pruned: string[];
|
|
36
45
|
pushed: string[];
|
|
37
46
|
status: 'dry-run' | 'success';
|
|
@@ -59,6 +68,7 @@ export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
|
59
68
|
export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
60
69
|
run(): Promise<RmResult>;
|
|
61
70
|
};
|
|
71
|
+
export declare function phpFileProblems(spec: Pick<PhpFilesResource, 'filenamePattern' | 'invalidFilenameHint'>, filename: string, content: string): string[];
|
|
62
72
|
export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
|
|
63
73
|
export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
|
|
64
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';
|
|
@@ -14,11 +15,37 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
|
14
15
|
import { PushCommand } from './push-command.js';
|
|
15
16
|
import { getResourceStateProvider } from './resource-state.js';
|
|
16
17
|
import { isApplicative404 } from './wp-client.js';
|
|
18
|
+
export function formatRuntimeError(error) {
|
|
19
|
+
return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
|
|
20
|
+
}
|
|
17
21
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
18
22
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
19
23
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
20
24
|
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
21
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
|
+
}
|
|
22
49
|
// A file's expected revision (#234), computed from `beforeState`: the remote listing already
|
|
23
50
|
// read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
|
|
24
51
|
// for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
|
|
@@ -91,10 +118,8 @@ export function resourcePushCommand(spec) {
|
|
|
91
118
|
// files without also landing the new ones.
|
|
92
119
|
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
|
|
93
120
|
if (this.dryRun) {
|
|
94
|
-
for (const file of files)
|
|
95
|
-
this.log(`[dry-run] Would push: ${file.filename}`);
|
|
96
121
|
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
97
|
-
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' };
|
|
98
123
|
}
|
|
99
124
|
const { pruned, pushed } = files.length === 0 && pruneList.length === 0
|
|
100
125
|
? { pruned: [], pushed: [] }
|
|
@@ -177,14 +202,7 @@ export function resourcePushCommand(spec) {
|
|
|
177
202
|
return orphans;
|
|
178
203
|
}
|
|
179
204
|
validateFileLocally(file, task) {
|
|
180
|
-
|
|
181
|
-
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
182
|
-
this.reportTaskFailure(message, new Error(message), task);
|
|
183
|
-
}
|
|
184
|
-
const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
185
|
-
if (declareCount !== 1) {
|
|
186
|
-
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
187
|
-
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)) {
|
|
188
206
|
this.reportTaskFailure(message, new Error(message), task);
|
|
189
207
|
}
|
|
190
208
|
if (task)
|
|
@@ -262,6 +280,8 @@ export function resourceListCommand(spec) {
|
|
|
262
280
|
for (const file of files) {
|
|
263
281
|
const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
|
|
264
282
|
this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
|
|
283
|
+
if (file.runtimeError)
|
|
284
|
+
this.log(` ${formatRuntimeError(file.runtimeError)}`);
|
|
265
285
|
}
|
|
266
286
|
if (files.some((file) => file.public)) {
|
|
267
287
|
this.log('');
|
|
@@ -348,14 +368,14 @@ export const API_FILES_RESOURCE = {
|
|
|
348
368
|
filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
|
|
349
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)',
|
|
350
370
|
label: 'API routes',
|
|
351
|
-
listDescription: 'List custom API route files
|
|
371
|
+
listDescription: 'List custom API route files currently on WordPress',
|
|
352
372
|
noun: 'route file',
|
|
353
373
|
orphanReason: 'whose route no longer exists on WordPress',
|
|
354
374
|
pathLabel: 'API',
|
|
355
375
|
pathNoun: 'api directory',
|
|
356
|
-
pullDescription: 'Pull custom API route files from WordPress',
|
|
357
|
-
pushDescription: 'Push custom API route files to WordPress',
|
|
358
|
-
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',
|
|
359
379
|
};
|
|
360
380
|
export const HOOK_FILES_RESOURCE = {
|
|
361
381
|
cliName: 'hook',
|
|
@@ -367,12 +387,12 @@ export const HOOK_FILES_RESOURCE = {
|
|
|
367
387
|
filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
|
|
368
388
|
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
|
|
369
389
|
label: 'hooks',
|
|
370
|
-
listDescription: 'List hook files (actions, filters, cron)
|
|
390
|
+
listDescription: 'List hook files (actions, filters, cron) currently on WordPress',
|
|
371
391
|
noun: 'hook file',
|
|
372
392
|
orphanReason: 'no longer present on WordPress',
|
|
373
393
|
pathLabel: 'Hooks',
|
|
374
394
|
pathNoun: 'hooks directory',
|
|
375
|
-
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
|
|
376
|
-
pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
|
|
377
|
-
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',
|
|
378
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/;
|