@loopress/cli 0.24.1 → 0.25.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 +629 -88
- package/dist/commands/acf/push.d.ts +1 -0
- package/dist/commands/acf/push.js +25 -2
- package/dist/commands/acf/rollback.d.ts +2 -0
- package/dist/commands/acf/rollback.js +5 -0
- package/dist/commands/api/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +55 -38
- package/dist/commands/form/pull.js +4 -1
- package/dist/commands/form/push.d.ts +1 -0
- package/dist/commands/form/push.js +29 -3
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -0
- package/dist/commands/hook/rollback.d.ts +2 -0
- package/dist/commands/hook/rollback.js +5 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/menu/diff.d.ts +2 -0
- package/dist/commands/menu/diff.js +5 -0
- package/dist/commands/menu/list.d.ts +13 -0
- package/dist/commands/menu/list.js +32 -0
- package/dist/commands/menu/pull.d.ts +15 -0
- package/dist/commands/menu/pull.js +72 -0
- package/dist/commands/menu/push.d.ts +17 -0
- package/dist/commands/menu/push.js +120 -0
- package/dist/commands/menu/rollback.d.ts +2 -0
- package/dist/commands/menu/rollback.js +5 -0
- package/dist/commands/option/add.js +1 -1
- package/dist/commands/option/push.d.ts +1 -0
- package/dist/commands/option/push.js +26 -1
- package/dist/commands/option/rollback.d.ts +2 -0
- package/dist/commands/option/rollback.js +5 -0
- package/dist/commands/plugin/push.d.ts +1 -0
- package/dist/commands/plugin/push.js +14 -0
- package/dist/commands/promote.d.ts +8 -1
- package/dist/commands/promote.js +2 -0
- package/dist/commands/pull.d.ts +11 -1
- package/dist/commands/pull.js +9 -2
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +9 -2
- package/dist/commands/seo/pull.js +6 -2
- package/dist/commands/seo/push.d.ts +1 -0
- package/dist/commands/seo/push.js +33 -3
- package/dist/commands/seo/rollback.d.ts +2 -0
- package/dist/commands/seo/rollback.js +5 -0
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +31 -1
- package/dist/commands/snippet/rollback.d.ts +2 -0
- package/dist/commands/snippet/rollback.js +5 -0
- package/dist/commands/theme-styles/diff.d.ts +2 -0
- package/dist/commands/theme-styles/diff.js +5 -0
- package/dist/commands/theme-styles/pull.d.ts +12 -0
- package/dist/commands/theme-styles/pull.js +31 -0
- package/dist/commands/theme-styles/push.d.ts +12 -0
- package/dist/commands/theme-styles/push.js +45 -0
- package/dist/commands/theme-styles/rollback.d.ts +2 -0
- package/dist/commands/theme-styles/rollback.js +5 -0
- package/dist/lib/app-manifest.js +4 -27
- package/dist/lib/base.d.ts +2 -0
- package/dist/lib/base.js +6 -0
- package/dist/lib/find-orphaned-files.js +5 -47
- package/dist/lib/load-files.js +4 -42
- package/dist/lib/php-files-command.d.ts +1 -0
- package/dist/lib/php-files-command.js +84 -47
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +49 -0
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +12 -0
- package/dist/lib/resource-state.js +92 -3
- package/dist/lib/rollback-command.d.ts +21 -0
- package/dist/lib/rollback-command.js +180 -0
- package/dist/lib/rollback-materialize.d.ts +1 -0
- package/dist/lib/rollback-materialize.js +121 -0
- package/dist/lib/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +2 -1
- package/dist/types/project-config.generated.d.ts +8 -0
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +6 -0
- package/dist/utils/form-format.d.ts +4 -0
- package/dist/utils/form-format.js +3 -7
- package/dist/utils/menu-format.d.ts +24 -0
- package/dist/utils/menu-format.js +8 -0
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +8 -0
- package/dist/utils/seo-format.js +3 -0
- package/dist/utils/snippet-format.d.ts +1 -0
- package/dist/utils/snippet-format.js +1 -0
- package/dist/utils/theme-styles-format.d.ts +26 -0
- package/dist/utils/theme-styles-format.js +55 -0
- package/oclif.manifest.json +1736 -465
- package/package.json +4 -2
- package/schemas/project-config.schema.json +10 -0
|
@@ -2,7 +2,9 @@ import { Args, Flags } from '@oclif/core';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadFiles } from '../../lib/load-files.js';
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
6
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
+
import { ACF_OBJECT_TYPES, acfEndpoint, acfObjectEndpoint, getAcfKey } from '../../utils/acf-format.js';
|
|
6
8
|
import { pluralize } from '../../utils/pluralize.js';
|
|
7
9
|
export default class Push extends PushCommand {
|
|
8
10
|
static args = {
|
|
@@ -22,9 +24,12 @@ export default class Push extends PushCommand {
|
|
|
22
24
|
const types = (flags.type && flags.type.length > 0 ? flags.type : ACF_OBJECT_TYPES);
|
|
23
25
|
this.log(`Pushing ACF configuration to ${url}`);
|
|
24
26
|
this.log(`ACF path: ${path}`);
|
|
27
|
+
const provider = getResourceStateProvider('acf');
|
|
28
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
25
29
|
for (const type of types) {
|
|
26
30
|
await this.pushType(type, path);
|
|
27
31
|
}
|
|
32
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
28
33
|
if (this.failedCount > 0) {
|
|
29
34
|
this.error(`${pluralize(this.failedCount, 'ACF object')} failed to push.`);
|
|
30
35
|
}
|
|
@@ -33,6 +38,17 @@ export default class Push extends PushCommand {
|
|
|
33
38
|
await this.recordSuccess();
|
|
34
39
|
this.log('All ACF objects pushed.');
|
|
35
40
|
}
|
|
41
|
+
async currentRevision(type, key) {
|
|
42
|
+
try {
|
|
43
|
+
const current = await this.wp.get(acfObjectEndpoint(type, key));
|
|
44
|
+
return current.revision;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (isNotFoundError(error))
|
|
48
|
+
return undefined;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
36
52
|
async loadObjects(dir) {
|
|
37
53
|
return loadFiles(dir, {
|
|
38
54
|
extension: '.json',
|
|
@@ -57,7 +73,14 @@ export default class Push extends PushCommand {
|
|
|
57
73
|
return;
|
|
58
74
|
}
|
|
59
75
|
try {
|
|
60
|
-
|
|
76
|
+
// Read the object's current revision right before writing it, and send it back as
|
|
77
|
+
// `expectedRevision`: WordPress refuses the write (412) if something else changed the
|
|
78
|
+
// object in between, instead of this push silently overwriting it (#234). No revision to
|
|
79
|
+
// condition on for an object that doesn't exist remotely yet (a first push, an upsert
|
|
80
|
+
// create): falls back to today's unconditional write, same as before this existed.
|
|
81
|
+
const expectedRevision = await this.currentRevision(type, key);
|
|
82
|
+
const body = expectedRevision === undefined ? object : { ...object, expectedRevision };
|
|
83
|
+
await this.wp.post(acfEndpoint(type), body);
|
|
61
84
|
if (task)
|
|
62
85
|
task.output = `Pushed: ${key}`;
|
|
63
86
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceRollbackCommand } from '../../lib/rollback-command.js';
|
|
2
|
+
export default resourceRollbackCommand('acf', {
|
|
3
|
+
description: 'Restore ACF field groups, post types, taxonomies, and options pages to a snapshot taken automatically before an earlier `lps acf push`',
|
|
4
|
+
pathNoun: 'ACF directory',
|
|
5
|
+
});
|
package/dist/commands/diff.js
CHANGED
|
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
|
|
|
7
7
|
export default class Diff extends DiffCommand {
|
|
8
8
|
static description = [
|
|
9
9
|
'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
|
|
10
|
-
'Covers snippets, forms, ACF, API routes, Hooks, SEO, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
10
|
+
'Covers snippets, forms, ACF, API routes, Hooks, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
11
11
|
'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
|
|
12
12
|
].join(' ');
|
|
13
13
|
static enableJsonFlag = true;
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
|
+
export type DoctorCheck = {
|
|
3
|
+
message?: string;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
title: string;
|
|
6
|
+
};
|
|
7
|
+
export type DoctorResult = {
|
|
8
|
+
checks: DoctorCheck[];
|
|
9
|
+
environment: string;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
pluginVersion?: string;
|
|
12
|
+
project: string;
|
|
13
|
+
url: string;
|
|
14
|
+
};
|
|
2
15
|
export default class Doctor extends LoopressCommand {
|
|
3
16
|
static description: string;
|
|
17
|
+
static enableJsonFlag: boolean;
|
|
4
18
|
static examples: string[];
|
|
5
|
-
|
|
19
|
+
private readonly checks;
|
|
20
|
+
run(): Promise<DoctorResult>;
|
|
6
21
|
private check;
|
|
22
|
+
private out;
|
|
7
23
|
private reportPluginVersion;
|
|
8
24
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -5,76 +5,93 @@ import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
|
|
|
5
5
|
import { pluralize } from '../utils/pluralize.js';
|
|
6
6
|
export default class Doctor extends LoopressCommand {
|
|
7
7
|
static description = 'Diagnose connectivity, plugin and credential problems for the targeted environment';
|
|
8
|
+
static enableJsonFlag = true;
|
|
8
9
|
static examples = ['$ lps doctor', '$ lps doctor --env production'];
|
|
10
|
+
checks = [];
|
|
9
11
|
async run() {
|
|
10
12
|
const { name, token, url } = this.siteConfig;
|
|
11
13
|
const project = this.localConfig.projectId
|
|
12
14
|
? configManager.getProject(this.localConfig.projectId)
|
|
13
15
|
: configManager.getCurrentProject();
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
18
|
-
|
|
16
|
+
const projectName = project?.name ?? '(unknown)';
|
|
17
|
+
this.out(`Project: ${projectName}`);
|
|
18
|
+
this.out(`Environment: ${name}`);
|
|
19
|
+
this.out(`URL: ${url}`);
|
|
20
|
+
this.out('');
|
|
21
|
+
let pluginVersion;
|
|
19
22
|
const isReachable = await this.check('WordPress REST API reachable', async () => {
|
|
20
23
|
const diagnostic = await diagnoseWpSite(url);
|
|
21
24
|
if (!diagnostic.ok)
|
|
22
25
|
throw new Error(diagnostic.reason);
|
|
23
26
|
});
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
if (isReachable) {
|
|
28
|
+
if (token) {
|
|
29
|
+
// The namespace index answers regardless of which Loopress features are active, so a
|
|
30
|
+
// 404 here can only mean the plugin itself is missing or outdated (WpClient's 404
|
|
31
|
+
// message already says exactly that).
|
|
32
|
+
await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
|
|
33
|
+
// wp/v2/users/me is WordPress core and requires authentication, so it validates the
|
|
34
|
+
// application password without depending on any Loopress feature.
|
|
35
|
+
await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
|
|
36
|
+
pluginVersion = await this.reportPluginVersion();
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.checks.push({
|
|
40
|
+
message: `No credentials stored for ${url}. Run \`lps project config\` to add them.`,
|
|
41
|
+
ok: false,
|
|
42
|
+
title: 'Credentials configured',
|
|
43
|
+
});
|
|
44
|
+
this.out('✗ Credentials configured');
|
|
45
|
+
this.out(` No credentials stored for ${url}. Run \`lps project config\` to add them.`);
|
|
46
|
+
this.out('- Remaining checks skipped without credentials.');
|
|
47
|
+
}
|
|
45
48
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (failed > 0) {
|
|
49
|
-
this.error(`${pluralize(failed, 'check')} failed.`, { exit: 1 });
|
|
49
|
+
else {
|
|
50
|
+
this.out('- Remaining checks skipped while the site is unreachable.');
|
|
50
51
|
}
|
|
51
|
-
this.
|
|
52
|
+
const failed = this.checks.filter((check) => !check.ok).length;
|
|
53
|
+
const ok = failed === 0;
|
|
54
|
+
this.out('');
|
|
55
|
+
this.out(ok ? 'All checks passed.' : `${pluralize(failed, 'check')} failed.`);
|
|
56
|
+
if (!ok)
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return { checks: this.checks, environment: name, ok, pluginVersion, project: projectName, url };
|
|
52
59
|
}
|
|
53
60
|
async check(title, run) {
|
|
54
61
|
try {
|
|
55
62
|
await run();
|
|
56
|
-
this.
|
|
63
|
+
this.checks.push({ ok: true, title });
|
|
64
|
+
this.out(`✓ ${title}`);
|
|
57
65
|
return true;
|
|
58
66
|
}
|
|
59
67
|
catch (error) {
|
|
60
|
-
|
|
61
|
-
this.
|
|
68
|
+
const { message } = error;
|
|
69
|
+
this.checks.push({ message, ok: false, title });
|
|
70
|
+
this.out(`✗ ${title}`);
|
|
71
|
+
this.out(` ${message}`);
|
|
62
72
|
return false;
|
|
63
73
|
}
|
|
64
74
|
}
|
|
75
|
+
out(message) {
|
|
76
|
+
if (!this.jsonEnabled())
|
|
77
|
+
this.log(message);
|
|
78
|
+
}
|
|
65
79
|
// Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
|
|
66
80
|
// predates it, neither should flip the doctor to a failure when everything else works.
|
|
67
81
|
async reportPluginVersion() {
|
|
68
82
|
try {
|
|
69
83
|
const status = await this.wp.get('loopress/v1/update');
|
|
70
|
-
|
|
84
|
+
const version = status.current_version ?? '(unknown)';
|
|
85
|
+
this.out(`✓ Plugin version: ${version}`);
|
|
86
|
+
return version;
|
|
71
87
|
}
|
|
72
88
|
catch (error) {
|
|
73
89
|
if (isNotFoundError(error)) {
|
|
74
|
-
this.
|
|
75
|
-
return;
|
|
90
|
+
this.out('- Plugin version: not exposed by this plugin edition.');
|
|
91
|
+
return undefined;
|
|
76
92
|
}
|
|
77
|
-
this.
|
|
93
|
+
this.out(`- Plugin version: could not be read. ${error.message}`);
|
|
94
|
+
return undefined;
|
|
78
95
|
}
|
|
79
96
|
}
|
|
80
97
|
}
|
|
@@ -39,7 +39,10 @@ export default class Pull extends LoopressCommand {
|
|
|
39
39
|
async write(form, writeDir) {
|
|
40
40
|
const id = getFormId(form);
|
|
41
41
|
const title = getFormTitle(form);
|
|
42
|
-
|
|
42
|
+
// `revision` is server bookkeeping (see RemoteForm in form-format.ts), never part of
|
|
43
|
+
// the tracked configuration: never persisted locally.
|
|
44
|
+
const local = Object.fromEntries(Object.entries(form).filter(([key]) => key !== 'revision'));
|
|
45
|
+
await writeFile(join(writeDir, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(local, null, 2) + '\n');
|
|
43
46
|
},
|
|
44
47
|
});
|
|
45
48
|
if (this.dryRun)
|
|
@@ -4,7 +4,9 @@ import { dirname, extname, join } from 'node:path';
|
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
5
|
import { putOrCreate } from '../../lib/put-or-create.js';
|
|
6
6
|
import { readdirTolerant } from '../../lib/readdir-tolerant.js';
|
|
7
|
-
import {
|
|
7
|
+
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
8
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
9
|
+
import { FORM_ENDPOINT, formEndpoint, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
10
|
import { pluralize } from '../../utils/pluralize.js';
|
|
9
11
|
import { toSlug } from '../../utils/to-slug.js';
|
|
10
12
|
export default class Push extends PushCommand {
|
|
@@ -29,9 +31,12 @@ export default class Push extends PushCommand {
|
|
|
29
31
|
this.allowNotifications = flags['allow-notifications'];
|
|
30
32
|
this.log(`Pushing forms to ${url}`);
|
|
31
33
|
this.log(`Forms path: ${path}`);
|
|
34
|
+
const provider = getResourceStateProvider('form');
|
|
35
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
32
36
|
const files = await this.loadFiles(path);
|
|
33
37
|
this.log(`Found ${pluralize(files.length, 'form')} to push`);
|
|
34
38
|
await this.runPushTasks(files, ({ data }) => getFormTitle(data), async ({ data, filePath }, task) => this.pushForm(filePath, data, task));
|
|
39
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
35
40
|
if (this.failedCount > 0) {
|
|
36
41
|
this.error(`${pluralize(this.failedCount, 'form')} failed to push.`);
|
|
37
42
|
}
|
|
@@ -40,6 +45,17 @@ export default class Push extends PushCommand {
|
|
|
40
45
|
await this.recordSuccess();
|
|
41
46
|
this.log('All forms pushed.');
|
|
42
47
|
}
|
|
48
|
+
async currentRevision(id) {
|
|
49
|
+
try {
|
|
50
|
+
const current = await this.wp.get(formEndpoint(id));
|
|
51
|
+
return current.revision;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (isNotFoundError(error))
|
|
55
|
+
return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
43
59
|
// Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
|
|
44
60
|
// whenever it doesn't already match (a hand-created file with no id, or a stale slug after
|
|
45
61
|
// a title change in the WordPress admin), same principle as ensureCanonicalFilename in
|
|
@@ -85,11 +101,21 @@ export default class Push extends PushCommand {
|
|
|
85
101
|
try {
|
|
86
102
|
const id = getFormId(data);
|
|
87
103
|
const payload = this.allowNotifications ? { ...data, allowNotifications: true } : data;
|
|
104
|
+
// Read the form's current revision right before writing it, and send it back as
|
|
105
|
+
// `expectedRevision`: WordPress refuses the write (412) if something else changed the
|
|
106
|
+
// form in between, instead of this push silently overwriting it (#234). No revision to
|
|
107
|
+
// condition on for a form that doesn't exist remotely yet (a first push, or one whose id
|
|
108
|
+
// 404s below and falls back to create): that PUT attempt just 404s too, same as before
|
|
109
|
+
// this existed. Kept out of the POST fallback body (`postPayload`): it is a precondition
|
|
110
|
+
// on an existing resource, meaningless (and never persisted) on a freshly created one.
|
|
111
|
+
const expectedRevision = id === null ? undefined : await this.currentRevision(id);
|
|
112
|
+
const putPayload = expectedRevision === undefined ? payload : { ...payload, expectedRevision };
|
|
88
113
|
const { body, created } = await putOrCreate(this.wp, {
|
|
89
114
|
id,
|
|
90
|
-
payload,
|
|
115
|
+
payload: putPayload,
|
|
91
116
|
postEndpoint: FORM_ENDPOINT,
|
|
92
|
-
|
|
117
|
+
postPayload: payload,
|
|
118
|
+
putEndpoint: formEndpoint,
|
|
93
119
|
});
|
|
94
120
|
const canonicalId = created ? getFormId(body) : id;
|
|
95
121
|
if (canonicalId !== null)
|
package/dist/commands/init.js
CHANGED
|
@@ -15,6 +15,7 @@ const SNIPPET_PROVIDERS = [
|
|
|
15
15
|
const FEATURES = [
|
|
16
16
|
{ dir: 'acf', key: 'acfDir', label: 'ACF' },
|
|
17
17
|
{ dir: 'seo', key: 'seoDir', label: 'SEO' },
|
|
18
|
+
{ dir: 'menus', key: 'menuDir', label: 'Menus' },
|
|
18
19
|
{ dir: 'forms', key: 'formDir', label: 'Forms' },
|
|
19
20
|
{ dir: 'api', key: 'apiDir', label: 'Custom API routes' },
|
|
20
21
|
{ dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('menu', {
|
|
3
|
+
description: 'Show what differs in nav menus and the active theme menu locations between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'menus directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type Menu, type MenuLocations } from '../../utils/menu-format.js';
|
|
3
|
+
type ListResult = {
|
|
4
|
+
locations: MenuLocations;
|
|
5
|
+
menus: Menu[];
|
|
6
|
+
};
|
|
7
|
+
export default class List extends LoopressCommand {
|
|
8
|
+
static description: string;
|
|
9
|
+
static enableJsonFlag: boolean;
|
|
10
|
+
static examples: string[];
|
|
11
|
+
run(): Promise<ListResult>;
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../../utils/menu-format.js';
|
|
3
|
+
function countItems(items) {
|
|
4
|
+
return items.reduce((total, item) => total + 1 + countItems(item.children), 0);
|
|
5
|
+
}
|
|
6
|
+
export default class List extends LoopressCommand {
|
|
7
|
+
static description = 'List nav menus and the active theme menu locations on WordPress';
|
|
8
|
+
static enableJsonFlag = true;
|
|
9
|
+
static examples = ['$ lps menu list'];
|
|
10
|
+
async run() {
|
|
11
|
+
const menus = await this.wp.get(MENU_ENDPOINT);
|
|
12
|
+
const locations = await this.wp.get(MENU_LOCATIONS_ENDPOINT);
|
|
13
|
+
if (menus.length === 0) {
|
|
14
|
+
this.log('(no menus)');
|
|
15
|
+
}
|
|
16
|
+
for (const menu of menus) {
|
|
17
|
+
this.log(`${menu.slug} (${menu.name}): ${countItems(menu.items)} item(s)`);
|
|
18
|
+
for (const warning of menu.warnings)
|
|
19
|
+
this.warn(`${menu.slug}: ${warning}`);
|
|
20
|
+
}
|
|
21
|
+
this.log('');
|
|
22
|
+
this.log('locations:');
|
|
23
|
+
const entries = Object.entries(locations);
|
|
24
|
+
if (entries.length === 0) {
|
|
25
|
+
this.log(' (none registered by the active theme)');
|
|
26
|
+
}
|
|
27
|
+
for (const [location, slug] of entries) {
|
|
28
|
+
this.log(` ${location}: ${slug ?? '(unassigned)'}`);
|
|
29
|
+
}
|
|
30
|
+
return { locations, menus };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private pullLocations;
|
|
14
|
+
private pullMenus;
|
|
15
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
+
import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
|
|
6
|
+
import { MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../../utils/menu-format.js';
|
|
7
|
+
// The reserved local filename for menu locations (see pullLocations()): a menu genuinely slugged
|
|
8
|
+
// "menu-locations" would collide with it, so it's skipped rather than silently overwriting that file.
|
|
9
|
+
const LOCATIONS_FILE_BASENAME = 'menu-locations';
|
|
10
|
+
export default class Pull extends LoopressCommand {
|
|
11
|
+
static args = {
|
|
12
|
+
path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
|
|
13
|
+
};
|
|
14
|
+
static description = 'Pull nav menus and the active theme menu locations from WordPress';
|
|
15
|
+
static examples = ['$ lps menu pull'];
|
|
16
|
+
static flags = {
|
|
17
|
+
...LoopressCommand.dryRunFlag,
|
|
18
|
+
...LoopressCommand.yesFlag,
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { args } = await this.parse(Pull);
|
|
22
|
+
const { url } = this.siteConfig;
|
|
23
|
+
const path = this.resolveMenuPath(args.path);
|
|
24
|
+
this.log(`Pulling nav menus from ${url}`);
|
|
25
|
+
this.log(`Menus path: ${path}`);
|
|
26
|
+
await this.pullMenus(path);
|
|
27
|
+
await this.pullLocations(path);
|
|
28
|
+
}
|
|
29
|
+
async pullLocations(basePath) {
|
|
30
|
+
const file = join(basePath, `${LOCATIONS_FILE_BASENAME}.json`);
|
|
31
|
+
const locations = await this.wp.get(MENU_LOCATIONS_ENDPOINT);
|
|
32
|
+
if (this.dryRun) {
|
|
33
|
+
this.log(`[dry-run] Would pull locations to ${file}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await mkdir(basePath, { recursive: true });
|
|
37
|
+
await writeFile(file, JSON.stringify(locations, null, 2) + '\n');
|
|
38
|
+
this.log(`Pulled locations to ${file}`);
|
|
39
|
+
}
|
|
40
|
+
async pullMenus(dir) {
|
|
41
|
+
const fetched = await this.wp.get(MENU_ENDPOINT);
|
|
42
|
+
const remote = fetched.filter((menu) => {
|
|
43
|
+
if (menu.slug === LOCATIONS_FILE_BASENAME) {
|
|
44
|
+
this.warn(`Skipping menu "${menu.slug}": this filename is reserved for ${LOCATIONS_FILE_BASENAME}.json`);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
const orphans = await findOrphanedFiles(dir, new Set(remote.map((menu) => menu.slug)), {
|
|
50
|
+
extensions: ['.json'],
|
|
51
|
+
key: (base) => (base === LOCATIONS_FILE_BASENAME ? null : basenameKey(base)),
|
|
52
|
+
});
|
|
53
|
+
await this.pullDirectory(dir, remote, orphans, {
|
|
54
|
+
dryRunMessage: `Would pull ${remote.length} menu(s) to ${dir}`,
|
|
55
|
+
orphanReason: `in ${dir} no longer present on WordPress`,
|
|
56
|
+
pulledMessage: `Pulled ${remote.length} menu(s) to ${dir}`,
|
|
57
|
+
title: (menu) => menu.slug,
|
|
58
|
+
// revision/warnings are server bookkeeping, never part of the tracked configuration (see
|
|
59
|
+
// their doc comments on Menu in menu-format.ts): never persisted locally.
|
|
60
|
+
async write(menu, writeDir) {
|
|
61
|
+
const local = { items: menu.items, name: menu.name, slug: menu.slug };
|
|
62
|
+
await writeFile(join(writeDir, `${menu.slug}.json`), JSON.stringify(local, null, 2) + '\n');
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
if (this.dryRun)
|
|
66
|
+
return;
|
|
67
|
+
for (const menu of remote) {
|
|
68
|
+
for (const warning of menu.warnings)
|
|
69
|
+
this.warn(`${menu.slug}: ${warning}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private currentRevision;
|
|
14
|
+
private pushLocations;
|
|
15
|
+
private pushMenuFile;
|
|
16
|
+
private pushMenus;
|
|
17
|
+
}
|