@loopress/cli 0.32.1 → 0.33.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 +511 -154
- package/dist/commands/acf/list.d.ts +1 -1
- package/dist/commands/acf/list.js +3 -4
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +9 -6
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +5 -4
- package/dist/commands/api/publish.js +2 -1
- package/dist/commands/app/pull.js +2 -1
- package/dist/commands/app/push.js +5 -4
- package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/app/{remove.js → rm.js} +2 -1
- package/dist/commands/block/diff.d.ts +2 -0
- package/dist/commands/block/diff.js +5 -0
- package/dist/commands/block/list.d.ts +8 -0
- package/dist/commands/block/list.js +23 -0
- package/dist/commands/block/pull.d.ts +21 -0
- package/dist/commands/block/pull.js +67 -0
- package/dist/commands/block/push.d.ts +20 -0
- package/dist/commands/block/push.js +48 -0
- package/dist/commands/block/rm.d.ts +23 -0
- package/dist/commands/block/rm.js +69 -0
- package/dist/commands/block/rollback.d.ts +2 -0
- package/dist/commands/block/rollback.js +5 -0
- package/dist/commands/composer/init.js +1 -0
- package/dist/commands/composer/push.js +5 -4
- package/dist/commands/content/copy.d.ts +3 -0
- package/dist/commands/content/copy.js +79 -2
- package/dist/commands/dev.js +4 -3
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +33 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +37 -23
- package/dist/commands/form/pull.js +7 -5
- package/dist/commands/form/push.js +4 -3
- package/dist/commands/init.js +4 -3
- package/dist/commands/menu/push.js +8 -8
- package/dist/commands/option/list.js +5 -2
- package/dist/commands/option/push.js +2 -1
- package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/option/{remove.js → rm.js} +2 -1
- package/dist/commands/page/push.js +2 -1
- package/dist/commands/plugin/audit.d.ts +4 -4
- package/dist/commands/plugin/audit.js +35 -15
- package/dist/commands/plugin/pull.js +3 -2
- package/dist/commands/plugin/push.js +2 -2
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -6
- package/dist/commands/project/pull.js +3 -1
- package/dist/commands/project/push.js +6 -4
- package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/project/{remove.js → rm.js} +3 -2
- package/dist/commands/project/switch.js +6 -4
- package/dist/commands/promote.js +7 -2
- package/dist/commands/pull.d.ts +2 -0
- package/dist/commands/pull.js +42 -18
- package/dist/commands/push.d.ts +2 -1
- package/dist/commands/push.js +69 -25
- package/dist/commands/snippet/publish.js +9 -5
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +15 -12
- package/dist/commands/status.js +1 -0
- package/dist/commands/theme/activate.d.ts +21 -0
- package/dist/commands/theme/activate.js +44 -0
- package/dist/commands/theme/pull.js +2 -1
- package/dist/commands/theme/push.js +7 -3
- package/dist/commands/theme/style/push.js +7 -2
- package/dist/commands/theme/template/push.js +25 -6
- package/dist/config/auth.manager.d.ts +10 -7
- package/dist/config/auth.manager.js +5 -1
- package/dist/config/json-file.d.ts +1 -1
- package/dist/config/project-config.manager.d.ts +2 -2
- package/dist/config/project-config.manager.js +13 -21
- package/dist/lib/api-client.js +16 -21
- package/dist/lib/app-build.js +6 -2
- package/dist/lib/app-manifest.js +6 -6
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +10 -8
- package/dist/lib/bootstrap-full-install.js +8 -6
- package/dist/lib/capabilities.d.ts +44 -0
- package/dist/lib/capabilities.js +85 -0
- package/dist/lib/declared-type-command.js +4 -2
- package/dist/lib/dev-watch.js +5 -5
- package/dist/lib/diff-command.d.ts +2 -3
- package/dist/lib/diff-command.js +9 -7
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +37 -42
- package/dist/lib/local-callback-server.js +7 -1
- package/dist/lib/narrow.d.ts +5 -0
- package/dist/lib/narrow.js +26 -0
- package/dist/lib/php-files-command.js +8 -5
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +1 -1
- package/dist/lib/push-command.js +19 -7
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +1 -1
- package/dist/lib/resource-state.js +58 -19
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +3 -2
- package/dist/lib/rollback-materialize.js +28 -8
- package/dist/lib/rotate-app-password.js +1 -0
- package/dist/lib/sentry.js +2 -2
- package/dist/lib/snapshot-store.d.ts +10 -7
- package/dist/lib/snapshot-store.js +21 -5
- package/dist/lib/spinner.js +4 -0
- package/dist/lib/validate-local.js +35 -20
- package/dist/lib/wp-client.d.ts +6 -1
- package/dist/lib/wp-client.js +54 -69
- package/dist/lib/wp-site-diagnostic.js +3 -5
- package/dist/types/global-config.generated.d.ts +1 -1
- package/dist/types/plugin.d.ts +7 -5
- package/dist/types/plugin.js +6 -1
- package/dist/types/project-config.generated.d.ts +9 -1
- package/dist/types/snippet.d.ts +2 -2
- package/dist/types/snippet.js +1 -1
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +11 -3
- package/dist/utils/block-format.d.ts +27 -0
- package/dist/utils/block-format.js +93 -0
- package/dist/utils/composer.d.ts +7 -15
- package/dist/utils/composer.js +43 -16
- package/dist/utils/declared-type-format.js +26 -19
- package/dist/utils/form-format.js +2 -2
- package/dist/utils/loopress-config.js +9 -1
- package/dist/utils/menu-format.d.ts +3 -1
- package/dist/utils/menu-format.js +5 -0
- package/dist/utils/option-format.d.ts +2 -2
- package/dist/utils/option-format.js +15 -25
- package/dist/utils/page-format.js +7 -6
- package/dist/utils/php-top-level.js +15 -4
- package/dist/utils/plugin-sync.d.ts +7 -7
- package/dist/utils/plugin-sync.js +11 -11
- package/dist/utils/plugins.js +9 -6
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +9 -1
- package/dist/utils/snippet-format.d.ts +1 -1
- package/dist/utils/snippet-format.js +5 -3
- package/dist/utils/template-format.js +2 -2
- package/dist/utils/theme-css.js +14 -6
- package/dist/utils/theme-styles-format.d.ts +3 -0
- package/dist/utils/theme-styles-format.js +44 -6
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1588 -1043
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -2,10 +2,14 @@ import { confirm } from '@inquirer/prompts';
|
|
|
2
2
|
import { Args, Flags } from '@oclif/core';
|
|
3
3
|
import { configManager } from '../../config/project-config.manager.js';
|
|
4
4
|
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
+
import { activateTheme, getCapabilities } from '../../lib/capabilities.js';
|
|
5
6
|
import { isInteractive } from '../../lib/interactive.js';
|
|
6
7
|
import { stdoutToStderr } from '../../lib/json-delegation.js';
|
|
7
8
|
import { isLocalEnvironment, LOCAL_ENVIRONMENT_NAME, WpClient } from '../../lib/wp-client.js';
|
|
9
|
+
import {} from '../../types/plugin.js';
|
|
10
|
+
import { parseInstalledPlugins } from '../../utils/plugins.js';
|
|
8
11
|
import { pluralize } from '../../utils/pluralize.js';
|
|
12
|
+
import { CHILD_THEME_SUFFIX } from '../../utils/theme-styles-format.js';
|
|
9
13
|
const BATCH_SIZE = 100;
|
|
10
14
|
// Code goes up, content comes down: the content of <from> replaces the local site's, IDs
|
|
11
15
|
// unchanged, never the reverse and never into the repo. Not a `pull`: nothing is written to
|
|
@@ -22,7 +26,7 @@ export default class Copy extends LoopressCommand {
|
|
|
22
26
|
static baseFlags = {
|
|
23
27
|
env: Flags.string({ hidden: true }),
|
|
24
28
|
};
|
|
25
|
-
static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
|
|
29
|
+
static description = 'Replace the local site\'s content (posts, pages, public custom post types, terms) with the content of <from>, activate the theme and plugins active there (when installed locally), then push the project to the local site so tracked configuration wins. Media stay served by <from>, users, comments and orders are never copied.';
|
|
26
30
|
static enableJsonFlag = true;
|
|
27
31
|
static examples = ['$ lps content copy production', '$ lps content copy staging --dry-run'];
|
|
28
32
|
static flags = {
|
|
@@ -41,6 +45,8 @@ export default class Copy extends LoopressCommand {
|
|
|
41
45
|
const source = this.sourceWp(from);
|
|
42
46
|
if (!this.dryRun)
|
|
43
47
|
await this.confirmReplace(from);
|
|
48
|
+
// Read before the reset: a failing read must not leave the local site emptied and unpushed.
|
|
49
|
+
const alignment = await this.readAlignment(source, from);
|
|
44
50
|
const info = await source.get('loopress/v1/content');
|
|
45
51
|
if (!this.dryRun) {
|
|
46
52
|
await this.wp.post('loopress/v1/content/reset', { home: info.home, uploadsUrl: info.uploadsUrl });
|
|
@@ -49,6 +55,10 @@ export default class Copy extends LoopressCommand {
|
|
|
49
55
|
const terms = await this.copy('terms', source);
|
|
50
56
|
this.log(`→ Copying posts from ${from}...`);
|
|
51
57
|
const posts = await this.copy('posts', source);
|
|
58
|
+
// Before the push: it must meet the source's plugins (a snippet push needs its plugin active)
|
|
59
|
+
// and its theme (templates and Global Styles go to the active one).
|
|
60
|
+
await this.alignPlugins(alignment, from);
|
|
61
|
+
const pendingChild = await this.alignTheme(alignment, from);
|
|
52
62
|
const summary = `${pluralize(posts, 'post')} and ${pluralize(terms, 'term')}`;
|
|
53
63
|
if (this.dryRun) {
|
|
54
64
|
this.log(`\n[dry-run] Would replace the local content with ${summary} from ${from}, then push the project to ${LOCAL_ENVIRONMENT_NAME}.`);
|
|
@@ -56,6 +66,9 @@ export default class Copy extends LoopressCommand {
|
|
|
56
66
|
}
|
|
57
67
|
this.log(`\n→ Pushing the project to ${LOCAL_ENVIRONMENT_NAME}...`);
|
|
58
68
|
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', ['--env', LOCAL_ENVIRONMENT_NAME, '--yes']));
|
|
69
|
+
// The push may have activated it already, through the project's activateChildTheme.
|
|
70
|
+
if (pendingChild && (await activateTheme(this.wp, pendingChild)).changed)
|
|
71
|
+
this.log(`Activated ${pendingChild}, like on ${from}.`);
|
|
59
72
|
this.log(`\nCopied ${summary} from ${from}.`);
|
|
60
73
|
return { from, posts, status: 'copied', terms };
|
|
61
74
|
}
|
|
@@ -70,6 +83,54 @@ export default class Copy extends LoopressCommand {
|
|
|
70
83
|
this.error(`No credentials configured for ${env.url}. Run \`lps project config\` to add them.`);
|
|
71
84
|
return new WpClient(env.url, env.token, env.name);
|
|
72
85
|
}
|
|
86
|
+
// Activates locally the plugins active on <from> but inactive here. Never installs: a plugin
|
|
87
|
+
// missing here is reported, composer.json is where it gets declared.
|
|
88
|
+
async alignPlugins({ localPlugins, sourcePlugins }, from) {
|
|
89
|
+
const local = new Map(localPlugins.map((plugin) => [plugin.slug, plugin]));
|
|
90
|
+
const activeThere = sourcePlugins.filter((plugin) => plugin.active);
|
|
91
|
+
const missing = activeThere.filter((plugin) => !local.has(plugin.slug)).map((plugin) => plugin.slug);
|
|
92
|
+
if (missing.length > 0)
|
|
93
|
+
this.warn(`Active on ${from} but not installed locally: ${missing.join(', ')}. Declare them in composer.json.`);
|
|
94
|
+
const toActivate = activeThere.flatMap((plugin) => {
|
|
95
|
+
const here = local.get(plugin.slug);
|
|
96
|
+
return here && !here.active ? [here] : [];
|
|
97
|
+
});
|
|
98
|
+
for (const plugin of toActivate) {
|
|
99
|
+
if (this.dryRun) {
|
|
100
|
+
this.log(`[dry-run] Would activate ${plugin.slug}, active on ${from}.`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
|
|
104
|
+
this.log(`Activated ${plugin.slug}, like on ${from}.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Activates locally the theme active on <from>. When that is the Loopress child, not written
|
|
108
|
+
// here yet, its parent is activated now and the child returned, to activate once the push has
|
|
109
|
+
// written it.
|
|
110
|
+
async alignTheme({ here, installedThemes: installed, there }, from) {
|
|
111
|
+
if (here.active === there.active)
|
|
112
|
+
return undefined;
|
|
113
|
+
const isChild = there.active === `${there.template}${CHILD_THEME_SUFFIX}`;
|
|
114
|
+
let now;
|
|
115
|
+
if (installed.has(there.active))
|
|
116
|
+
now = there.active;
|
|
117
|
+
else if (isChild && installed.has(there.template))
|
|
118
|
+
now = there.template;
|
|
119
|
+
if (!now) {
|
|
120
|
+
this.warn(`${there.active} is active on ${from} but not installed locally, the local site keeps ${here.active}.`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const later = now === there.active ? undefined : there.active;
|
|
124
|
+
if (this.dryRun) {
|
|
125
|
+
this.log(`[dry-run] Would activate ${later ?? now} instead of ${here.active}, like on ${from}.`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (now !== here.active) {
|
|
129
|
+
await activateTheme(this.wp, now);
|
|
130
|
+
this.log(`Activated ${now}, like on ${from}.`);
|
|
131
|
+
}
|
|
132
|
+
return later;
|
|
133
|
+
}
|
|
73
134
|
async confirmReplace(from) {
|
|
74
135
|
if (this.yes)
|
|
75
136
|
return;
|
|
@@ -87,11 +148,27 @@ export default class Copy extends LoopressCommand {
|
|
|
87
148
|
while (after !== null) {
|
|
88
149
|
const page = await source.get(`loopress/v1/content/${kind}?after=${after}&limit=${BATCH_SIZE}`);
|
|
89
150
|
const { next, ...rows } = page;
|
|
90
|
-
|
|
151
|
+
const batch = rows[kind];
|
|
152
|
+
// The local site is already emptied: a page without its list must stop the copy, not count as 0.
|
|
153
|
+
if (!Array.isArray(batch))
|
|
154
|
+
this.error(`${kind} page after ${after} from the source has no "${kind}" list.`);
|
|
155
|
+
count += batch.length;
|
|
91
156
|
if (!this.dryRun)
|
|
92
157
|
await this.wp.post(`loopress/v1/content/${kind}`, rows);
|
|
93
158
|
after = next;
|
|
94
159
|
}
|
|
95
160
|
return count;
|
|
96
161
|
}
|
|
162
|
+
// Everything alignPlugins and alignTheme read. The local theme list only matters when the
|
|
163
|
+
// active themes differ.
|
|
164
|
+
async readAlignment(source, from) {
|
|
165
|
+
const [sourcePlugins, localPlugins, { theme: there }, { theme: here }] = await Promise.all([
|
|
166
|
+
source.get('wp/v2/plugins').then(parseInstalledPlugins),
|
|
167
|
+
this.wp.get('wp/v2/plugins').then(parseInstalledPlugins),
|
|
168
|
+
getCapabilities(source, from),
|
|
169
|
+
this.capabilities(),
|
|
170
|
+
]);
|
|
171
|
+
const installedThemes = here.active === there.active ? new Set() : new Set((await this.wp.get('wp/v2/themes')).map((theme) => theme.stylesheet));
|
|
172
|
+
return { here, installedThemes, localPlugins, sourcePlugins, there };
|
|
173
|
+
}
|
|
97
174
|
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -3,10 +3,11 @@ import { watch as watchFiles } from 'chokidar';
|
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { configManager } from '../config/project-config.manager.js';
|
|
5
5
|
import { buildWatchTargets, createDebouncedBatcher, resolveResourceTypes, RESOURCE_TYPES, resourceTypeForPath } from '../lib/dev-watch.js';
|
|
6
|
+
import { errorMessage, isOneOf } from '../lib/narrow.js';
|
|
6
7
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
7
8
|
const DEBOUNCE_MS = 400;
|
|
8
9
|
// Noise no one wants pushed: VCS internals, deps, and editor/OS droppings.
|
|
9
|
-
const IGNORED = /(
|
|
10
|
+
const IGNORED = /(?:^|[/\\])(?:(?:\.git|node_modules|\.DS_Store)(?:[/\\]|$)|[^/\\]*\.swp$)/;
|
|
10
11
|
export default class Dev extends Command {
|
|
11
12
|
static description = 'Watch project files and push changes to the local WordPress instance as they happen. Always targets the "local" environment, run `lps snippet push` etc. directly for any other environment.';
|
|
12
13
|
static examples = ['$ lps dev', '$ lps dev --only=snippets,api', '$ lps dev --skip=plugins'];
|
|
@@ -40,7 +41,7 @@ export default class Dev extends Command {
|
|
|
40
41
|
.map((value) => value.trim())
|
|
41
42
|
.filter(Boolean)
|
|
42
43
|
.map((value) => {
|
|
43
|
-
if (!RESOURCE_TYPES
|
|
44
|
+
if (!isOneOf(RESOURCE_TYPES, value)) {
|
|
44
45
|
this.error(`Unknown resource type "${value}" in --${flagName}. Valid types: ${RESOURCE_TYPES.join(', ')}`);
|
|
45
46
|
}
|
|
46
47
|
return value;
|
|
@@ -69,7 +70,7 @@ export default class Dev extends Command {
|
|
|
69
70
|
this.log(`✓ ${label} synced`);
|
|
70
71
|
}
|
|
71
72
|
catch (error) {
|
|
72
|
-
this.log(`✗ ${label} failed: ${error
|
|
73
|
+
this.log(`✗ ${label} failed: ${errorMessage(error)}`);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
}
|
package/dist/commands/diff.d.ts
CHANGED
package/dist/commands/diff.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { getCapabilities, missingIntegration, missingModule } from '../lib/capabilities.js';
|
|
2
5
|
import { DiffCommand } from '../lib/diff-command.js';
|
|
3
6
|
import { RESOURCE_STATE_PROVIDERS } from '../lib/resource-state.js';
|
|
7
|
+
import {} from '../lib/wp-client.js';
|
|
4
8
|
import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
|
|
5
9
|
import { globalStylesFiles } from '../utils/theme-styles-format.js';
|
|
6
10
|
// Everything `lps diff` can compare, in report order. Composer isn't a directory-backed
|
|
@@ -9,7 +13,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
|
|
|
9
13
|
export default class Diff extends DiffCommand {
|
|
10
14
|
static description = [
|
|
11
15
|
'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
|
|
12
|
-
'Covers snippets, forms, ACF, API routes, hooks, templates, template parts, pages, menus, custom post types, taxonomies, post meta, roles, block patterns, options, theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
16
|
+
'Covers snippets, forms, ACF, API routes, hooks, dynamic blocks, templates, template parts, pages, menus, custom post types, taxonomies, post meta, roles, block patterns, options, theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
13
17
|
'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
|
|
14
18
|
].join(' ');
|
|
15
19
|
static enableJsonFlag = true;
|
|
@@ -31,8 +35,19 @@ export default class Diff extends DiffCommand {
|
|
|
31
35
|
const selected = this.selectResources(flags.only, flags.skip);
|
|
32
36
|
const providers = RESOURCE_STATE_PROVIDERS.filter((provider) => selected.has(provider.resource));
|
|
33
37
|
const targets = [
|
|
34
|
-
...(await Promise.all(providers.map(async (provider) => ({
|
|
35
|
-
|
|
38
|
+
...(await Promise.all(providers.map(async (provider) => ({
|
|
39
|
+
...this.providerTarget(provider, sides),
|
|
40
|
+
skip: !(await this.projectUses(provider)) && (await this.siteLacks((capabilities) => missingIntegration(provider.dirKind, capabilities), sides)),
|
|
41
|
+
})))),
|
|
42
|
+
...(selected.has('composer')
|
|
43
|
+
? [
|
|
44
|
+
{
|
|
45
|
+
...this.composerTarget(sides),
|
|
46
|
+
skip: !existsSync(resolve(process.cwd(), this.rootDir, 'composer.json')) &&
|
|
47
|
+
(await this.siteLacks((capabilities) => missingModule('dependencies', 'Composer files', capabilities), sides)),
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
: []),
|
|
36
51
|
];
|
|
37
52
|
return this.report(targets, sides);
|
|
38
53
|
}
|
|
@@ -50,4 +65,19 @@ export default class Diff extends DiffCommand {
|
|
|
50
65
|
const base = only && only.length > 0 ? only : RESOURCES;
|
|
51
66
|
return new Set(base.filter((resource) => !(skip ?? []).includes(resource)));
|
|
52
67
|
}
|
|
68
|
+
// Either side can't hold the resource. Capabilities that can't be read (site down, plugin too
|
|
69
|
+
// old) skip nothing: the comparison then reports the failure itself, as inconclusive.
|
|
70
|
+
async siteLacks(missing, sides) {
|
|
71
|
+
try {
|
|
72
|
+
// Inside the try: `this.wp` errors out without credentials, the comparison reports that.
|
|
73
|
+
const sites = [[this.wp, sides.leftLabel]];
|
|
74
|
+
if (sides.againstWp)
|
|
75
|
+
sites.push([sides.againstWp, sides.rightLabel]);
|
|
76
|
+
const all = await Promise.all(sites.map(async ([wp, name]) => getCapabilities(wp, name)));
|
|
77
|
+
return all.some((capabilities) => missing(capabilities) !== undefined);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
53
83
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
|
+
import { type SiteCapabilities } from '../lib/capabilities.js';
|
|
2
3
|
export type DoctorCheck = {
|
|
3
4
|
message?: string;
|
|
4
5
|
ok: boolean;
|
|
5
6
|
title: string;
|
|
6
7
|
};
|
|
7
8
|
export type DoctorResult = {
|
|
9
|
+
capabilities?: SiteCapabilities | undefined;
|
|
8
10
|
checks: DoctorCheck[];
|
|
9
11
|
environment: string;
|
|
10
12
|
ok: boolean;
|
|
11
|
-
pluginVersion?: string;
|
|
13
|
+
pluginVersion?: string | undefined;
|
|
12
14
|
project: string;
|
|
13
15
|
url: string;
|
|
14
16
|
};
|
|
@@ -19,8 +21,8 @@ export default class Doctor extends LoopressCommand {
|
|
|
19
21
|
private readonly checks;
|
|
20
22
|
run(): Promise<DoctorResult>;
|
|
21
23
|
private check;
|
|
24
|
+
private checkCapabilities;
|
|
22
25
|
private checkHookErrors;
|
|
23
26
|
private checkLeftoverTempAdmins;
|
|
24
27
|
private out;
|
|
25
|
-
private reportPluginVersion;
|
|
26
28
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { configManager } from '../config/project-config.manager.js';
|
|
2
2
|
import { LoopressCommand } from '../lib/base.js';
|
|
3
|
+
import {} from '../lib/capabilities.js';
|
|
4
|
+
import { errorMessage } from '../lib/narrow.js';
|
|
3
5
|
import { formatRuntimeError, HOOK_FILES_RESOURCE } from '../lib/php-files-command.js';
|
|
4
6
|
import { findTempAdmins } from '../lib/temp-admin.js';
|
|
5
7
|
import { isForbiddenError, isNotFoundError } from '../lib/wp-client.js';
|
|
@@ -20,7 +22,7 @@ export default class Doctor extends LoopressCommand {
|
|
|
20
22
|
this.out(`Environment: ${name}`);
|
|
21
23
|
this.out(`URL: ${url}`);
|
|
22
24
|
this.out('');
|
|
23
|
-
let
|
|
25
|
+
let capabilities;
|
|
24
26
|
const isReachable = await this.check('WordPress REST API reachable', async () => {
|
|
25
27
|
const diagnostic = await diagnoseWpSite(url);
|
|
26
28
|
if (!diagnostic.ok)
|
|
@@ -31,16 +33,18 @@ export default class Doctor extends LoopressCommand {
|
|
|
31
33
|
// The namespace index answers regardless of which Loopress features are active, so a
|
|
32
34
|
// 404 here can only mean the plugin itself is missing or outdated (WpClient's 404
|
|
33
35
|
// message already says exactly that).
|
|
34
|
-
await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
|
|
36
|
+
const isInstalled = await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1'));
|
|
35
37
|
// wp/v2/users/me is WordPress core and requires authentication, so it validates the
|
|
36
38
|
// application password without depending on any Loopress feature.
|
|
37
39
|
const isAuthenticated = await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me'));
|
|
38
40
|
// Skipped when the credentials were just rejected: it would only repeat that same 401.
|
|
39
41
|
if (isAuthenticated) {
|
|
42
|
+
// Without the plugin it would only repeat that same 404.
|
|
43
|
+
if (isInstalled)
|
|
44
|
+
capabilities = await this.checkCapabilities();
|
|
40
45
|
await this.checkLeftoverTempAdmins();
|
|
41
46
|
await this.checkHookErrors();
|
|
42
47
|
}
|
|
43
|
-
pluginVersion = await this.reportPluginVersion();
|
|
44
48
|
}
|
|
45
49
|
else {
|
|
46
50
|
this.checks.push({
|
|
@@ -62,7 +66,7 @@ export default class Doctor extends LoopressCommand {
|
|
|
62
66
|
this.out(ok ? 'All checks passed.' : `${pluralize(failed, 'check')} failed.`);
|
|
63
67
|
if (!ok)
|
|
64
68
|
process.exitCode = 1;
|
|
65
|
-
return { checks: this.checks, environment: name, ok, pluginVersion, project: projectName, url };
|
|
69
|
+
return { capabilities, checks: this.checks, environment: name, ok, pluginVersion: capabilities?.version, project: projectName, url };
|
|
66
70
|
}
|
|
67
71
|
async check(title, run) {
|
|
68
72
|
try {
|
|
@@ -72,13 +76,41 @@ export default class Doctor extends LoopressCommand {
|
|
|
72
76
|
return true;
|
|
73
77
|
}
|
|
74
78
|
catch (error) {
|
|
75
|
-
const
|
|
79
|
+
const message = errorMessage(error);
|
|
76
80
|
this.checks.push({ message, ok: false, title });
|
|
77
81
|
this.out(`✗ ${title}`);
|
|
78
82
|
this.out(` ${message}`);
|
|
79
83
|
return false;
|
|
80
84
|
}
|
|
81
85
|
}
|
|
86
|
+
// The CLI decides what to run from these, so an older plugin without them fails the doctor.
|
|
87
|
+
// The rest is informational.
|
|
88
|
+
async checkCapabilities() {
|
|
89
|
+
let capabilities;
|
|
90
|
+
await this.check('Plugin recent enough for this CLI (loopress/v1/capabilities)', async () => {
|
|
91
|
+
capabilities = await this.capabilities();
|
|
92
|
+
});
|
|
93
|
+
if (!capabilities)
|
|
94
|
+
return undefined;
|
|
95
|
+
const { edition, environmentType, integrations, theme, version } = capabilities;
|
|
96
|
+
const state = (active) => {
|
|
97
|
+
if (active === undefined)
|
|
98
|
+
return 'needs Loopress Full';
|
|
99
|
+
return active ? 'active' : 'none';
|
|
100
|
+
};
|
|
101
|
+
this.out(`- Plugin: Loopress ${edition === 'full' ? 'Full' : 'Light'} ${version}, environment type "${environmentType}"`);
|
|
102
|
+
this.out(`- Integrations: ACF ${state(integrations.acf)}, forms ${state(integrations.form)}, snippets ${state(integrations.snippets)}`);
|
|
103
|
+
this.out(`- Theme: ${theme.active} (${theme.isBlock ? 'block theme' : 'classic theme'})`);
|
|
104
|
+
if (theme.child) {
|
|
105
|
+
let childState = 'not pushed yet';
|
|
106
|
+
if (theme.child.active)
|
|
107
|
+
childState = 'active';
|
|
108
|
+
else if (theme.child.exists)
|
|
109
|
+
childState = 'not active, `lps theme activate`';
|
|
110
|
+
this.out(`- Loopress child theme: ${theme.child.stylesheet}, ${childState}`);
|
|
111
|
+
}
|
|
112
|
+
return capabilities;
|
|
113
|
+
}
|
|
82
114
|
// A hook that fails to load or throws while running doesn't break the site, it just quietly
|
|
83
115
|
// does nothing, and its only trace is an error_log nobody reads on managed hosting.
|
|
84
116
|
async checkHookErrors() {
|
|
@@ -131,22 +163,4 @@ export default class Doctor extends LoopressCommand {
|
|
|
131
163
|
if (!this.jsonEnabled())
|
|
132
164
|
this.log(message);
|
|
133
165
|
}
|
|
134
|
-
// Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
|
|
135
|
-
// predates it, neither should flip the doctor to a failure when everything else works.
|
|
136
|
-
async reportPluginVersion() {
|
|
137
|
-
try {
|
|
138
|
-
const status = await this.wp.get('loopress/v1/update');
|
|
139
|
-
const version = status.current_version ?? '(unknown)';
|
|
140
|
-
this.out(`✓ Plugin version: ${version}`);
|
|
141
|
-
return version;
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
if (isNotFoundError(error)) {
|
|
145
|
-
this.out('- Plugin version: not exposed by this plugin edition.');
|
|
146
|
-
return undefined;
|
|
147
|
-
}
|
|
148
|
-
this.out(`- Plugin version: could not be read. ${error.message}`);
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
166
|
}
|
|
@@ -24,11 +24,14 @@ export default class Pull extends LoopressCommand {
|
|
|
24
24
|
this.log(`Pulling forms from ${url}`);
|
|
25
25
|
this.log(`Forms path: ${path}`);
|
|
26
26
|
const remoteList = await this.wp.get(FORM_ENDPOINT);
|
|
27
|
-
const withId = remoteList.
|
|
27
|
+
const withId = remoteList.flatMap((form) => {
|
|
28
|
+
const id = getFormId(form);
|
|
29
|
+
return id === null ? [] : [{ form, id }];
|
|
30
|
+
});
|
|
28
31
|
const skipped = remoteList.length - withId.length;
|
|
29
32
|
// Only matches files following the `<id>-<slug>.json` convention pull/push themselves
|
|
30
33
|
// produce, same principle as `snippet pull`.
|
|
31
|
-
const orphans = await findOrphanedFiles(path, new Set(withId.map((
|
|
34
|
+
const orphans = await findOrphanedFiles(path, new Set(withId.map(({ id }) => String(id))), {
|
|
32
35
|
extensions: ['.json'],
|
|
33
36
|
key: numericPrefixKey,
|
|
34
37
|
});
|
|
@@ -36,9 +39,8 @@ export default class Pull extends LoopressCommand {
|
|
|
36
39
|
dryRunMessage: `Would pull ${pluralize(withId.length, 'form')} to ${path}`,
|
|
37
40
|
orphanReason: `in ${path} no longer present on WordPress`,
|
|
38
41
|
pulledMessage: `Pulled ${pluralize(withId.length, 'form')} to ${path}`,
|
|
39
|
-
title: (form) => getFormTitle(form),
|
|
40
|
-
async write(form, writeDir) {
|
|
41
|
-
const id = getFormId(form);
|
|
42
|
+
title: ({ form }) => getFormTitle(form),
|
|
43
|
+
async write({ form, id }, writeDir) {
|
|
42
44
|
const title = getFormTitle(form);
|
|
43
45
|
// `revision` is server bookkeeping (see RemoteForm in form-format.ts), never part of
|
|
44
46
|
// the tracked configuration: never persisted locally.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join } from 'node:path';
|
|
4
|
+
import { errorMessage, isRecord } from '../../lib/narrow.js';
|
|
4
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
6
|
import { putOrCreate } from '../../lib/put-or-create.js';
|
|
6
7
|
import { readdirTolerant } from '../../lib/readdir-tolerant.js';
|
|
@@ -82,10 +83,10 @@ export default class Push extends PushCommand {
|
|
|
82
83
|
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
83
84
|
}
|
|
84
85
|
catch (error) {
|
|
85
|
-
this.warn(`Skipping "${filePath}": ${error
|
|
86
|
+
this.warn(`Skipping "${filePath}": ${errorMessage(error)}`);
|
|
86
87
|
continue;
|
|
87
88
|
}
|
|
88
|
-
if (
|
|
89
|
+
if (!isRecord(parsed)) {
|
|
89
90
|
this.warn(`Skipping "${filePath}": not a JSON object`);
|
|
90
91
|
continue;
|
|
91
92
|
}
|
|
@@ -129,7 +130,7 @@ export default class Push extends PushCommand {
|
|
|
129
130
|
task.output = `Pushed: ${title}`;
|
|
130
131
|
}
|
|
131
132
|
catch (error) {
|
|
132
|
-
this.reportTaskFailure(`Failed to push ${title}: ${error
|
|
133
|
+
this.reportTaskFailure(`Failed to push ${title}: ${errorMessage(error)}`, error, task);
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -66,8 +66,9 @@ export default class Init extends Command {
|
|
|
66
66
|
this.log('Still no project configured, falling back to manual entry.');
|
|
67
67
|
return this.promptManualProjectId();
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const [only] = projects;
|
|
70
|
+
if (only && projects.length === 1) {
|
|
71
|
+
return { projectId: only.id, projectLabel: only.name };
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
const choice = await select({
|
|
@@ -77,6 +78,6 @@ export default class Init extends Command {
|
|
|
77
78
|
if (choice === '__manual__') {
|
|
78
79
|
return this.promptManualProjectId();
|
|
79
80
|
}
|
|
80
|
-
return { projectId: choice, projectLabel: projects.find((p) => p.id === choice)
|
|
81
|
+
return { projectId: choice, projectLabel: projects.find((p) => p.id === choice)?.name ?? choice };
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { extname, join } from 'node:path';
|
|
4
|
+
import { errorCode, errorMessage, isRecord } from '../../lib/narrow.js';
|
|
4
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
6
|
import { readdirTolerant } from '../../lib/readdir-tolerant.js';
|
|
6
7
|
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
7
8
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
-
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT, menuEndpoint } from '../../utils/menu-format.js';
|
|
9
|
+
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS, MENU_LOCATIONS_ENDPOINT, menuEndpoint } from '../../utils/menu-format.js';
|
|
9
10
|
import { pluralize } from '../../utils/pluralize.js';
|
|
10
11
|
// A menu literally slugged "menu-locations" would still collide, but that's far less likely
|
|
11
12
|
// than the plain "locations" this used to be (WordPress's own menu-locations feature).
|
|
@@ -59,7 +60,7 @@ export default class Push extends PushCommand {
|
|
|
59
60
|
raw = await readFile(file, 'utf8');
|
|
60
61
|
}
|
|
61
62
|
catch (error) {
|
|
62
|
-
if (error
|
|
63
|
+
if (errorCode(error) === 'ENOENT')
|
|
63
64
|
return;
|
|
64
65
|
throw error;
|
|
65
66
|
}
|
|
@@ -68,12 +69,12 @@ export default class Push extends PushCommand {
|
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
try {
|
|
71
|
-
await this.wp.put(MENU_LOCATIONS_ENDPOINT, JSON.parse(raw));
|
|
72
|
+
await this.wp.put(MENU_LOCATIONS_ENDPOINT, MENU_LOCATIONS.parse(JSON.parse(raw)));
|
|
72
73
|
this.log(`Pushed: ${file}`);
|
|
73
74
|
}
|
|
74
75
|
catch (error) {
|
|
75
76
|
this.failedCount++;
|
|
76
|
-
this.warn(`Failed to push ${file}: ${error
|
|
77
|
+
this.warn(`Failed to push ${file}: ${errorMessage(error)}`);
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
async pushMenuFile(filePath, task) {
|
|
@@ -83,10 +84,9 @@ export default class Push extends PushCommand {
|
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
try {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
87
|
+
const record = JSON.parse(await readFile(filePath, 'utf8'));
|
|
88
|
+
if (!isRecord(record))
|
|
88
89
|
throw new Error('not a JSON object');
|
|
89
|
-
const record = parsed;
|
|
90
90
|
const slug = getMenuSlug(record);
|
|
91
91
|
if (slug === null)
|
|
92
92
|
throw new Error('missing or invalid "slug"');
|
|
@@ -109,7 +109,7 @@ export default class Push extends PushCommand {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
catch (error) {
|
|
112
|
-
this.reportTaskFailure(`Failed to push ${filePath}: ${error
|
|
112
|
+
this.reportTaskFailure(`Failed to push ${filePath}: ${errorMessage(error)}`, error, task);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
async pushMenus(basePath) {
|
|
@@ -79,11 +79,14 @@ const NON_TTY_FALLBACK_COLUMNS = 300;
|
|
|
79
79
|
// column. See confidenceColor() above for SOURCE?/PLUGIN's shared sûr/pas sûr color coding.
|
|
80
80
|
function renderTable(rows, { includeCore }) {
|
|
81
81
|
const previousColumns = process.env.COLUMNS;
|
|
82
|
-
|
|
82
|
+
// Undefined on a non-TTY stdout, despite its `number` type.
|
|
83
|
+
const ttyColumns = process.stdout.columns > 0 ? process.stdout.columns : undefined;
|
|
84
|
+
if (!previousColumns && ttyColumns === undefined)
|
|
83
85
|
process.env.COLUMNS = String(NON_TTY_FALLBACK_COLUMNS);
|
|
84
86
|
// Mirrors tty-table's own getAvailableWidth(): its column-width percentages are computed against
|
|
85
87
|
// this same number, so fillPercent() above needs it too to reserve the right amount for AUTOLOAD/CORE.
|
|
86
|
-
const
|
|
88
|
+
const envColumns = Number(process.env.COLUMNS);
|
|
89
|
+
const width = (ttyColumns ?? (envColumns > 0 ? envColumns : NON_TTY_FALLBACK_COLUMNS)) - 2;
|
|
87
90
|
const header = [
|
|
88
91
|
{ align: 'left', headerAlign: 'left', value: 'NAME', width: columnWidth(COLUMN_WEIGHT.name, width, includeCore) },
|
|
89
92
|
{ align: 'left', headerAlign: 'left', value: 'AUTOLOAD' },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { loadFiles } from '../../lib/load-files.js';
|
|
3
|
+
import { errorMessage } from '../../lib/narrow.js';
|
|
3
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
4
5
|
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
5
6
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
@@ -89,7 +90,7 @@ export default class Push extends PushCommand {
|
|
|
89
90
|
task.output = `Pushed: ${option.name}`;
|
|
90
91
|
}
|
|
91
92
|
catch (error) {
|
|
92
|
-
this.reportTaskFailure(`Failed to push ${option.name}: ${error
|
|
93
|
+
this.reportTaskFailure(`Failed to push ${option.name}: ${errorMessage(error)}`, error, task);
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -8,13 +8,14 @@ import { isInteractive } from '../../lib/interactive.js';
|
|
|
8
8
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
9
9
|
import { optionEndpoint, optionFileName } from '../../utils/option-format.js';
|
|
10
10
|
export default class Remove extends LoopressCommand {
|
|
11
|
+
static aliases = ['option:remove'];
|
|
11
12
|
static args = {
|
|
12
13
|
name: Args.string({ description: 'Option name to stop tracking', required: true }),
|
|
13
14
|
};
|
|
14
15
|
static description = 'Stop tracking an option locally and delete it from WordPress. Local untracking always happens (git-reversible); ' +
|
|
15
16
|
'pass --local-only to skip the WordPress delete and only stop tracking.';
|
|
16
17
|
static enableJsonFlag = true;
|
|
17
|
-
static examples = ['$ lps option
|
|
18
|
+
static examples = ['$ lps option rm wpseo_titles', '$ lps option rm wpseo_titles --local-only'];
|
|
18
19
|
static flags = {
|
|
19
20
|
...LoopressCommand.dryRunFlag,
|
|
20
21
|
...LoopressCommand.yesFlag,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Args } from '@oclif/core';
|
|
3
3
|
import { isInteractive } from '../../lib/interactive.js';
|
|
4
|
+
import { errorMessage } from '../../lib/narrow.js';
|
|
4
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
6
|
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
6
7
|
import { isConflictCode } from '../../lib/wp-client.js';
|
|
@@ -90,7 +91,7 @@ export default class Push extends PushCommand {
|
|
|
90
91
|
task?.skip?.(`${page.slug} already exists on the site, not created by Loopress`);
|
|
91
92
|
return false;
|
|
92
93
|
}
|
|
93
|
-
this.reportTaskFailure(`Failed to push ${page.slug}: ${error
|
|
94
|
+
this.reportTaskFailure(`Failed to push ${page.slug}: ${errorMessage(error)}`, error, task);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
2
|
type Advisory = {
|
|
3
|
-
cve?: string;
|
|
4
|
-
fixedIn?: string;
|
|
5
|
-
score?: number;
|
|
6
|
-
severity?: string;
|
|
3
|
+
cve?: string | undefined;
|
|
4
|
+
fixedIn?: string | undefined;
|
|
5
|
+
score?: number | undefined;
|
|
6
|
+
severity?: string | undefined;
|
|
7
7
|
slug: string;
|
|
8
8
|
title: string;
|
|
9
9
|
};
|