@loopress/cli 0.27.0 → 0.29.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 +448 -189
- package/bin/dev.js +0 -0
- package/dist/commands/acf/pull.d.ts +9 -1
- package/dist/commands/acf/pull.js +2 -0
- package/dist/commands/acf/push.d.ts +9 -1
- package/dist/commands/acf/push.js +3 -1
- package/dist/commands/api/publish.js +1 -1
- package/dist/commands/app/pull.js +17 -4
- package/dist/commands/app/push.d.ts +3 -0
- package/dist/commands/app/push.js +30 -6
- package/dist/commands/composer/push.js +3 -21
- package/dist/commands/diff.js +1 -1
- package/dist/commands/form/pull.d.ts +9 -1
- package/dist/commands/form/pull.js +3 -1
- package/dist/commands/form/push.d.ts +7 -1
- package/dist/commands/form/push.js +3 -1
- package/dist/commands/init.js +6 -36
- package/dist/commands/menu/pull.d.ts +7 -1
- package/dist/commands/menu/pull.js +2 -0
- package/dist/commands/menu/push.d.ts +7 -1
- package/dist/commands/menu/push.js +3 -1
- package/dist/commands/option/pull.js +4 -4
- package/dist/commands/option/push.js +2 -0
- package/dist/commands/page/push.js +3 -0
- package/dist/commands/plugin/add.js +6 -3
- package/dist/commands/plugin/audit.js +4 -5
- package/dist/commands/plugin/pull.d.ts +7 -1
- package/dist/commands/plugin/pull.js +68 -14
- package/dist/commands/plugin/push.d.ts +5 -1
- package/dist/commands/plugin/push.js +92 -38
- package/dist/commands/plugin/status.d.ts +1 -0
- package/dist/commands/plugin/status.js +21 -2
- package/dist/commands/project/config.js +12 -8
- package/dist/commands/promote.d.ts +2 -0
- package/dist/commands/promote.js +69 -8
- package/dist/commands/pull.js +2 -2
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/push.js +40 -5
- package/dist/commands/seo/pull.d.ts +8 -1
- package/dist/commands/seo/pull.js +2 -0
- package/dist/commands/seo/push.d.ts +7 -1
- package/dist/commands/seo/push.js +3 -1
- package/dist/commands/snippet/publish.js +1 -1
- package/dist/commands/snippet/pull.js +1 -1
- package/dist/commands/snippet/push.js +1 -1
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +4 -4
- package/dist/commands/theme/pull.d.ts +6 -8
- package/dist/commands/theme/pull.js +30 -26
- package/dist/commands/theme/push.d.ts +8 -9
- package/dist/commands/theme/push.js +54 -111
- package/dist/commands/theme/style/diff.d.ts +2 -0
- package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
- package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
- package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
- package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
- package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
- package/dist/commands/theme/style/rollback.d.ts +2 -0
- package/dist/commands/theme/style/rollback.js +6 -0
- package/dist/commands/theme/template/diff.d.ts +10 -0
- package/dist/commands/theme/template/diff.js +13 -0
- package/dist/commands/theme/template/list.d.ts +13 -0
- package/dist/commands/theme/template/list.js +23 -0
- package/dist/commands/theme/template/push.d.ts +20 -0
- package/dist/commands/theme/template/push.js +44 -0
- package/dist/commands/theme/version/pull.d.ts +23 -0
- package/dist/commands/theme/version/pull.js +43 -0
- package/dist/commands/theme/version/push.d.ts +22 -0
- package/dist/commands/theme/version/push.js +123 -0
- package/dist/hooks/finally.d.ts +2 -1
- package/dist/hooks/finally.js +27 -4
- package/dist/lib/api-client.js +3 -1
- package/dist/lib/app-build.d.ts +5 -0
- package/dist/lib/app-build.js +69 -0
- package/dist/lib/app-manifest.d.ts +4 -0
- package/dist/lib/app-manifest.js +10 -2
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +14 -2
- package/dist/lib/bootstrap-full-install.js +45 -14
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +3 -1
- package/dist/lib/diff-command.d.ts +1 -0
- package/dist/lib/diff-command.js +6 -4
- package/dist/lib/diff-state.js +1 -0
- package/dist/lib/github-release.js +0 -1
- package/dist/lib/local-callback-server.d.ts +12 -6
- package/dist/lib/local-callback-server.js +21 -22
- package/dist/lib/php-files-command.js +3 -1
- package/dist/lib/push-command.d.ts +5 -0
- package/dist/lib/push-command.js +30 -8
- package/dist/lib/resource-state.js +47 -4
- package/dist/lib/rollback-command.d.ts +1 -0
- package/dist/lib/rollback-command.js +8 -6
- package/dist/lib/rotate-app-password.js +2 -2
- package/dist/lib/snapshot-store.js +2 -2
- package/dist/lib/validate-local.js +6 -0
- package/dist/lib/wp-authorize-flow.d.ts +10 -17
- package/dist/lib/wp-authorize-flow.js +24 -28
- package/dist/lib/wp-client.d.ts +4 -1
- package/dist/lib/wp-client.js +25 -1
- package/dist/lib/wp-site-diagnostic.js +37 -0
- package/dist/types/plugin.d.ts +8 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/project-config.generated.d.ts +19 -2
- package/dist/utils/composer.d.ts +16 -0
- package/dist/utils/composer.js +144 -0
- package/dist/utils/option-format.d.ts +3 -1
- package/dist/utils/option-format.js +14 -3
- package/dist/utils/page-format.d.ts +12 -0
- package/dist/utils/page-format.js +40 -28
- package/dist/utils/plugin-sync.js +1 -1
- package/dist/utils/plugins.d.ts +9 -4
- package/dist/utils/plugins.js +16 -7
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +6 -2
- package/dist/utils/seo-format.d.ts +2 -2
- package/dist/utils/template-format.d.ts +31 -0
- package/dist/utils/template-format.js +43 -0
- package/oclif.manifest.json +841 -63
- package/package.json +24 -17
- package/schemas/project-config.schema.json +25 -4
- package/dist/commands/theme-styles/diff.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.js +0 -5
|
@@ -2,15 +2,6 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { openBrowser } from './open-browser.js';
|
|
5
|
-
async function readBody(req) {
|
|
6
|
-
const chunks = [];
|
|
7
|
-
for await (const chunk of req)
|
|
8
|
-
chunks.push(chunk);
|
|
9
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
10
|
-
}
|
|
11
|
-
function parseFormData(body) {
|
|
12
|
-
return Object.fromEntries(new URLSearchParams(body));
|
|
13
|
-
}
|
|
14
5
|
/** Constant-time compare that also tolerates a length mismatch without throwing. */
|
|
15
6
|
function safeEqual(a, b) {
|
|
16
7
|
const bufferA = Buffer.from(a);
|
|
@@ -22,13 +13,14 @@ function safeEqual(a, b) {
|
|
|
22
13
|
* local server; this factors out the server setup, timeout, and browser-opening boilerplate.
|
|
23
14
|
*
|
|
24
15
|
* The loopback server is unauthenticated, so any local process or web page open during the wait
|
|
25
|
-
* could otherwise
|
|
16
|
+
* could otherwise send forged credentials to it (RFC 8252 section 8.9). Two guards close that:
|
|
26
17
|
*
|
|
27
18
|
* - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
|
|
28
|
-
* back (constant-time compare) on any request
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `Origin
|
|
19
|
+
* back (constant-time compare) on any request with a query string, the only place credentials
|
|
20
|
+
* are read from;
|
|
21
|
+
* - an `Origin` allowlist: the legitimate callbacks are top-level redirects, which send no
|
|
22
|
+
* `Origin`, so a request from a page script (carrying its own origin) is rejected. That's
|
|
23
|
+
* defense in depth, `state` is the primary control.
|
|
32
24
|
*
|
|
33
25
|
* The first request that looks like a callback (valid or not) shuts the server down, so a
|
|
34
26
|
* failed guess gets no second try within the window.
|
|
@@ -38,23 +30,26 @@ export async function waitForLocalCallback(options) {
|
|
|
38
30
|
const state = randomBytes(32).toString('hex');
|
|
39
31
|
return new Promise((resolve, reject) => {
|
|
40
32
|
function finish(res, page) {
|
|
41
|
-
|
|
33
|
+
// `Connection: close` (here and below): server.close() only refuses new connections, a
|
|
34
|
+
// kept-alive socket would otherwise still reach this handler after the shutdown.
|
|
35
|
+
res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html; charset=utf-8' });
|
|
42
36
|
res.end(page);
|
|
43
37
|
clearTimeout(timer);
|
|
44
38
|
server.close();
|
|
45
39
|
}
|
|
46
40
|
function rejectRequest(res, message) {
|
|
47
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
41
|
+
res.writeHead(403, { Connection: 'close', 'Content-Type': 'text/plain' });
|
|
48
42
|
res.end(message);
|
|
49
43
|
clearTimeout(timer);
|
|
50
44
|
server.close();
|
|
51
45
|
reject(new Error(message));
|
|
52
46
|
}
|
|
53
|
-
|
|
47
|
+
function handleIncoming(req, res) {
|
|
54
48
|
try {
|
|
55
49
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
// Any query string counts, not just one carrying `state`: credentials are read from the
|
|
51
|
+
// query, so a request with `password=` but no `state` must not slip past the check.
|
|
52
|
+
const looksLikeCallback = url.search !== '';
|
|
58
53
|
if (looksLikeCallback) {
|
|
59
54
|
const { origin } = req.headers;
|
|
60
55
|
if (origin !== undefined && !options.allowedOrigins.includes(origin)) {
|
|
@@ -79,7 +74,6 @@ export async function waitForLocalCallback(options) {
|
|
|
79
74
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
80
75
|
res.end(message);
|
|
81
76
|
},
|
|
82
|
-
body,
|
|
83
77
|
});
|
|
84
78
|
}
|
|
85
79
|
catch (error) {
|
|
@@ -89,7 +83,7 @@ export async function waitForLocalCallback(options) {
|
|
|
89
83
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
90
84
|
}
|
|
91
85
|
}
|
|
92
|
-
const server = createServer(
|
|
86
|
+
const server = createServer(handleIncoming);
|
|
93
87
|
server.on('error', (err) => {
|
|
94
88
|
clearTimeout(timer);
|
|
95
89
|
reject(err);
|
|
@@ -100,13 +94,17 @@ export async function waitForLocalCallback(options) {
|
|
|
100
94
|
}, timeoutMs);
|
|
101
95
|
server.listen(0, '127.0.0.1', () => {
|
|
102
96
|
const { port } = server.address();
|
|
103
|
-
const targetUrl = options.buildUrl(`http
|
|
97
|
+
const targetUrl = options.buildUrl(`http://${options.callbackHost ?? 'localhost'}:${port}`, state);
|
|
104
98
|
options.log(options.openingMessage);
|
|
105
99
|
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
106
100
|
openBrowser(targetUrl);
|
|
107
101
|
});
|
|
108
102
|
});
|
|
109
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The page rewrites its own URL to `/` on load, so the callback query string (an Application
|
|
106
|
+
* Password, a console token) doesn't stay in the browser history or address bar.
|
|
107
|
+
*/
|
|
110
108
|
export function renderResultPage(options) {
|
|
111
109
|
return `<!DOCTYPE html>
|
|
112
110
|
<html lang="en">
|
|
@@ -144,6 +142,7 @@ export function renderResultPage(options) {
|
|
|
144
142
|
<h1>${options.heading}</h1>
|
|
145
143
|
<p>You can close this tab and return to your terminal.</p>
|
|
146
144
|
</div>
|
|
145
|
+
<script>history.replaceState(null, '', '/')</script>
|
|
147
146
|
</body>
|
|
148
147
|
</html>`;
|
|
149
148
|
}
|
|
@@ -274,6 +274,8 @@ export function resourceListCommand(spec) {
|
|
|
274
274
|
}
|
|
275
275
|
export function resourceRmCommand(spec) {
|
|
276
276
|
class ResourceRm extends LoopressCommand {
|
|
277
|
+
// `remove` matches `lps app remove` and `lps option remove`, `rm` stays for existing scripts.
|
|
278
|
+
static aliases = [`${spec.cliName}:remove`];
|
|
277
279
|
static args = {
|
|
278
280
|
filename: Args.string({
|
|
279
281
|
description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
|
|
@@ -282,7 +284,7 @@ export function resourceRmCommand(spec) {
|
|
|
282
284
|
};
|
|
283
285
|
static description = spec.rmDescription;
|
|
284
286
|
static enableJsonFlag = true;
|
|
285
|
-
static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName}
|
|
287
|
+
static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} remove hello --yes`];
|
|
286
288
|
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
287
289
|
async run() {
|
|
288
290
|
const { args } = await this.parse(ResourceRm);
|
|
@@ -2,12 +2,17 @@ import { LoopressCommand } from './base.js';
|
|
|
2
2
|
import { type ResourceState } from './diff-state.js';
|
|
3
3
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
4
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
5
|
+
static baseFlags: {
|
|
6
|
+
'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
5
9
|
protected failedCount: number;
|
|
6
10
|
private refusedByGuard;
|
|
7
11
|
protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
|
|
8
12
|
catch(err: Error): Promise<void>;
|
|
9
13
|
protected guardProductionPush(): Promise<void>;
|
|
10
14
|
init(): Promise<void>;
|
|
15
|
+
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
|
|
11
16
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
12
17
|
protected recordSuccess(): Promise<void>;
|
|
13
18
|
protected reportTaskFailure(message: string, error: unknown, task?: {
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
1
2
|
import got from 'got';
|
|
2
3
|
import { Listr } from 'listr2';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
@@ -7,19 +8,28 @@ import { LoopressCommand } from './base.js';
|
|
|
7
8
|
import { guardProductionPush } from './guard-production-push.js';
|
|
8
9
|
import { writeSnapshot } from './snapshot-store.js';
|
|
9
10
|
export class PushCommand extends LoopressCommand {
|
|
11
|
+
// Passed by an aggregate command (`lps theme push`) that already confirmed production once,
|
|
12
|
+
// so its steps skip that prompt while still asking their own confirmations (uninstalls).
|
|
13
|
+
static baseFlags = {
|
|
14
|
+
...LoopressCommand.baseFlags,
|
|
15
|
+
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
16
|
+
};
|
|
10
17
|
failedCount = 0;
|
|
11
18
|
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
12
19
|
// deployment history with failure records.
|
|
13
20
|
refusedByGuard = false;
|
|
14
21
|
// Reads `provider`'s remote state right before a real push, for `writeAfterPushSnapshot` to
|
|
15
|
-
// pair with the actual post-push remote state once the push completes.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
22
|
+
// pair with the actual post-push remote state once the push completes. Best-effort: a read
|
|
23
|
+
// failure (the site briefly unreachable) must never block the push itself, the same "never
|
|
24
|
+
// interrupt the push flow" stance recordDeployment takes; `undefined` tells
|
|
25
|
+
// `writeAfterPushSnapshot` there is no restore point to complete.
|
|
26
|
+
//
|
|
27
|
+
// On a dry run, the same read is the preview's check against the site (see previewRemote).
|
|
20
28
|
async captureBeforePushState(provider, dir) {
|
|
21
|
-
if (this.dryRun)
|
|
29
|
+
if (this.dryRun) {
|
|
30
|
+
await this.previewRemote(provider, dir);
|
|
22
31
|
return undefined;
|
|
32
|
+
}
|
|
23
33
|
try {
|
|
24
34
|
return await provider.remote(this.wp, (message) => {
|
|
25
35
|
this.warn(message);
|
|
@@ -34,7 +44,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
34
44
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
35
45
|
await this.recordDeployment('failure');
|
|
36
46
|
}
|
|
37
|
-
|
|
47
|
+
await super.catch(err);
|
|
38
48
|
}
|
|
39
49
|
async guardProductionPush() {
|
|
40
50
|
await guardProductionPush({
|
|
@@ -44,13 +54,25 @@ export class PushCommand extends LoopressCommand {
|
|
|
44
54
|
this.refusedByGuard = true;
|
|
45
55
|
},
|
|
46
56
|
siteConfig: this.siteConfig,
|
|
47
|
-
yes: this.yes,
|
|
57
|
+
yes: this.yes || this.productionConfirmed,
|
|
48
58
|
});
|
|
49
59
|
}
|
|
50
60
|
async init() {
|
|
51
61
|
await super.init();
|
|
52
62
|
await this.guardProductionPush();
|
|
53
63
|
}
|
|
64
|
+
// A dry run used to stay local, so it announced "pushed" for a resource the site would refuse
|
|
65
|
+
// (ACF or Rank Math missing: a 400 or 409 on the real push). It now reads the site the same way
|
|
66
|
+
// `lps diff` does and fails the same way, so the preview, and the MCP server's preview and
|
|
67
|
+
// pre-apply check built on it, see what the real push will hit.
|
|
68
|
+
async previewRemote(provider, dir) {
|
|
69
|
+
try {
|
|
70
|
+
await provider.remote(this.wp, () => { }, dir);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.error(`The site would refuse this push: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
async recordDeployment(status) {
|
|
55
77
|
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
56
78
|
if (!token)
|
|
@@ -4,9 +4,10 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
|
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
5
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
6
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
|
-
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
|
|
7
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
8
8
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
9
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
10
|
+
import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
|
|
10
11
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
11
12
|
import { loadFiles } from './load-files.js';
|
|
12
13
|
import { loadSnippets } from './load-snippets.js';
|
|
@@ -217,6 +218,45 @@ const pageProvider = {
|
|
|
217
218
|
resource: 'page',
|
|
218
219
|
title: 'Pages',
|
|
219
220
|
};
|
|
221
|
+
// ---- Templates and parts ---------------------------------------------------------------
|
|
222
|
+
// Both live in the Loopress child theme (ChildThemeController), one GET answers for both. A
|
|
223
|
+
// template or part edited in the Site Editor gets `siteEditor: 'customized'` on the remote side
|
|
224
|
+
// only, so it always shows as drift: its database copy is what the site renders, not the file.
|
|
225
|
+
async function childThemeState(wp, kind) {
|
|
226
|
+
const child = await wp.get(CHILD_THEME_ENDPOINT);
|
|
227
|
+
const files = child[kind];
|
|
228
|
+
const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
229
|
+
for (const entry of child.customized) {
|
|
230
|
+
const [dir, slug] = entry.split('/', 2);
|
|
231
|
+
if (dir === kind)
|
|
232
|
+
state.set(slug, { ...state.get(slug), siteEditor: 'customized' });
|
|
233
|
+
}
|
|
234
|
+
return state;
|
|
235
|
+
}
|
|
236
|
+
const templateProvider = {
|
|
237
|
+
dirKind: 'template',
|
|
238
|
+
async local(dir) {
|
|
239
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
|
|
240
|
+
if (problems.length > 0)
|
|
241
|
+
throw new Error(formatPageProblems(problems));
|
|
242
|
+
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
243
|
+
},
|
|
244
|
+
remote: async (wp) => childThemeState(wp, 'templates'),
|
|
245
|
+
resource: 'template',
|
|
246
|
+
title: 'Templates',
|
|
247
|
+
};
|
|
248
|
+
const partProvider = {
|
|
249
|
+
dirKind: 'part',
|
|
250
|
+
async local(dir) {
|
|
251
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
|
|
252
|
+
if (problems.length > 0)
|
|
253
|
+
throw new Error(formatPageProblems(problems));
|
|
254
|
+
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
255
|
+
},
|
|
256
|
+
remote: async (wp) => childThemeState(wp, 'parts'),
|
|
257
|
+
resource: 'part',
|
|
258
|
+
title: 'Template parts',
|
|
259
|
+
};
|
|
220
260
|
// ---- SEO --------------------------------------------------------------------------------
|
|
221
261
|
function canonicalRedirect(redirect) {
|
|
222
262
|
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
@@ -368,12 +408,13 @@ const optionsProvider = {
|
|
|
368
408
|
const tracked = await loadFiles(dir, {
|
|
369
409
|
extension: '.json',
|
|
370
410
|
onSkip: onWarn,
|
|
371
|
-
parse:
|
|
411
|
+
parse: parseLocalOption,
|
|
372
412
|
});
|
|
373
413
|
const state = new Map();
|
|
374
|
-
for (const name of tracked) {
|
|
414
|
+
for (const { name, refs } of tracked) {
|
|
375
415
|
try {
|
|
376
|
-
|
|
416
|
+
// With the file's refs, so post references compare as paths on both sides.
|
|
417
|
+
const option = await wp.get(optionEndpoint(name, refs));
|
|
377
418
|
state.set(name, { autoload: option.autoload, value: option.value });
|
|
378
419
|
}
|
|
379
420
|
catch (error) {
|
|
@@ -426,6 +467,8 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
426
467
|
acfProvider,
|
|
427
468
|
apiProvider,
|
|
428
469
|
hookProvider,
|
|
470
|
+
templateProvider,
|
|
471
|
+
partProvider,
|
|
429
472
|
pageProvider,
|
|
430
473
|
seoProvider,
|
|
431
474
|
menuProvider,
|
|
@@ -16,6 +16,7 @@ export type RollbackResult = {
|
|
|
16
16
|
status: 'dry-run' | 'listed' | 'no-op' | 'success';
|
|
17
17
|
};
|
|
18
18
|
export declare function resourceRollbackCommand(resource: string, options: {
|
|
19
|
+
command?: string;
|
|
19
20
|
description: string;
|
|
20
21
|
pathNoun: string;
|
|
21
22
|
}): typeof LoopressCommand;
|
|
@@ -33,7 +33,9 @@ function indentPatch(patch) {
|
|
|
33
33
|
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
34
34
|
// state and the state the original push expected to leave behind, and requires explicit
|
|
35
35
|
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
36
|
+
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
|
|
36
37
|
export function resourceRollbackCommand(resource, options) {
|
|
38
|
+
const command = options.command ?? resource;
|
|
37
39
|
class ResourceRollback extends LoopressCommand {
|
|
38
40
|
static args = {
|
|
39
41
|
path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
|
|
@@ -41,10 +43,10 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
41
43
|
static description = options.description;
|
|
42
44
|
static enableJsonFlag = true;
|
|
43
45
|
static examples = [
|
|
44
|
-
`$ lps ${
|
|
45
|
-
`$ lps ${
|
|
46
|
-
`$ lps ${
|
|
47
|
-
`$ lps ${
|
|
46
|
+
`$ lps ${command} rollback`,
|
|
47
|
+
`$ lps ${command} rollback --list`,
|
|
48
|
+
`$ lps ${command} rollback --to 1732000000000`,
|
|
49
|
+
`$ lps ${command} rollback --dry-run`,
|
|
48
50
|
];
|
|
49
51
|
static flags = {
|
|
50
52
|
...LoopressCommand.dryRunFlag,
|
|
@@ -105,7 +107,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
105
107
|
// an item present now but absent from the snapshot stays present after "restoring" it.
|
|
106
108
|
if (restoreDiff.removed.length > 0) {
|
|
107
109
|
this.warn(`${pluralize(restoreDiff.removed.length, 'item')} present now but not in this snapshot will NOT be removed ` +
|
|
108
|
-
`(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${
|
|
110
|
+
`(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${command} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
|
|
109
111
|
}
|
|
110
112
|
if (this.dryRun) {
|
|
111
113
|
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
|
|
@@ -125,7 +127,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
125
127
|
// production guard and the drift confirmation above already covered what the delegated
|
|
126
128
|
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
127
129
|
// delegating to each resource's push command in commands/push.ts.
|
|
128
|
-
await this.config.runCommand(`${
|
|
130
|
+
await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
|
|
129
131
|
}
|
|
130
132
|
finally {
|
|
131
133
|
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
@@ -8,14 +8,14 @@ export function isAppPasswordStale(addedAt) {
|
|
|
8
8
|
// the old one. Reversing this order risks locking the user out if the new one is somehow bad.
|
|
9
9
|
export async function rotateAppPassword(env) {
|
|
10
10
|
const [user] = env.token.split(':', 1);
|
|
11
|
-
const oldClient = new WpClient(env.url, env.token);
|
|
11
|
+
const oldClient = new WpClient(env.url, env.token, env.name);
|
|
12
12
|
const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
13
13
|
const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
|
|
14
14
|
if (!created.password) {
|
|
15
15
|
throw new Error(`WordPress did not return a password when creating the new application password on ${env.url}.`);
|
|
16
16
|
}
|
|
17
17
|
const newToken = `${user}:${created.password}`;
|
|
18
|
-
const newClient = new WpClient(env.url, newToken);
|
|
18
|
+
const newClient = new WpClient(env.url, newToken, env.name);
|
|
19
19
|
try {
|
|
20
20
|
await newClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
21
21
|
}
|
|
@@ -7,7 +7,7 @@ export const SNAPSHOT_RETENTION = 10;
|
|
|
7
7
|
// representation; snapshots round-trip through a plain object instead. Keys are sorted so two
|
|
8
8
|
// snapshots of the same state serialize identically (stable diffs, deterministic tests).
|
|
9
9
|
export function stateToRecord(state) {
|
|
10
|
-
return Object.fromEntries([...state].
|
|
10
|
+
return Object.fromEntries([...state].toSorted(([a], [b]) => a.localeCompare(b)));
|
|
11
11
|
}
|
|
12
12
|
export function recordToState(record) {
|
|
13
13
|
return new Map(Object.entries(record));
|
|
@@ -113,7 +113,7 @@ export async function listSnapshots(rootDir, resource, environment) {
|
|
|
113
113
|
// rather than failing the whole listing; it stays on disk for a human to inspect.
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
return summaries.
|
|
116
|
+
return summaries.toSorted((a, b) => Number(b.id) - Number(a.id));
|
|
117
117
|
}
|
|
118
118
|
// `id` undefined reads the most recent snapshot for this environment (the common
|
|
119
119
|
// `lps <resource> rollback` case). Throws a message ready to show the user directly: no
|
|
@@ -4,6 +4,7 @@ import { configManager } from '../config/project-config.manager.js';
|
|
|
4
4
|
import { parseLocalOption } from '../utils/option-format.js';
|
|
5
5
|
import { readLocalPages } from '../utils/page-format.js';
|
|
6
6
|
import { parseType } from '../utils/snippet-format.js';
|
|
7
|
+
import { readLocalTemplates } from '../utils/template-format.js';
|
|
7
8
|
import { loadSnippets } from './load-snippets.js';
|
|
8
9
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
9
10
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
@@ -17,8 +18,10 @@ const DEFAULT_DIR = {
|
|
|
17
18
|
menuDir: 'menus',
|
|
18
19
|
optionsDir: 'options',
|
|
19
20
|
pageDir: 'pages',
|
|
21
|
+
partDir: 'parts',
|
|
20
22
|
seoDir: 'seo',
|
|
21
23
|
snippetsDir: 'snippets',
|
|
24
|
+
templateDir: 'templates',
|
|
22
25
|
themeStylesDir: 'theme',
|
|
23
26
|
};
|
|
24
27
|
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
@@ -49,6 +52,9 @@ export async function validateLocal(cwd) {
|
|
|
49
52
|
const pages = await readLocalPages(resolve('pageDir'));
|
|
50
53
|
checked += pages.pages.length + pages.problems.length;
|
|
51
54
|
problems.push(...pages.problems);
|
|
55
|
+
const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
|
|
56
|
+
checked += templates.templates.length + templates.parts.length + templates.problems.length;
|
|
57
|
+
problems.push(...templates.problems);
|
|
52
58
|
return { checked, problems, valid: problems.length === 0 };
|
|
53
59
|
}
|
|
54
60
|
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
@@ -3,24 +3,17 @@ export type AuthorizeResult = {
|
|
|
3
3
|
userLogin: string;
|
|
4
4
|
};
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* `success_url` / `reject_url
|
|
8
|
-
* redirect from WordPress and forwards the credentials back to a local callback
|
|
9
|
-
* server via a form POST (keeping them out of the browser's address bar).
|
|
6
|
+
* Asks WordPress for an Application Password through its own authorize-application.php page,
|
|
7
|
+
* with the CLI's loopback server as `success_url` / `reject_url`:
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 3. That page redirects the user to WordPress's authorize-application.php,
|
|
16
|
-
* passing the API's callback endpoint as `success_url` (HTTPS).
|
|
17
|
-
* 4. After the user approves, WordPress redirects to the API callback.
|
|
18
|
-
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
19
|
-
* Application Password to the local callback server.
|
|
20
|
-
* 6. The local server extracts the credentials and resolves the promise.
|
|
9
|
+
* 1. Start a local HTTP server on 127.0.0.1.
|
|
10
|
+
* 2. Open the browser on `<site>/wp-admin/authorize-application.php`.
|
|
11
|
+
* 3. After the user approves, WordPress redirects to the loopback URL with `user_login` and
|
|
12
|
+
* `password` appended to its query string (`state` is kept, WordPress only adds params).
|
|
21
13
|
*
|
|
22
|
-
* The
|
|
23
|
-
*
|
|
24
|
-
* `
|
|
14
|
+
* The callback host must be the literal `127.0.0.1`, not `localhost`: WordPress 7.0+ only exempts
|
|
15
|
+
* `127.0.0.1` and `[::1]` from its HTTPS requirement on these URLs. Older versions are screened
|
|
16
|
+
* out beforehand by `diagnoseWpSite`. The credentials land in the browser history as part of the
|
|
17
|
+
* callback URL, so the result page rewrites that history entry (see `renderResultPage`).
|
|
25
18
|
*/
|
|
26
19
|
export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
|
|
@@ -1,44 +1,40 @@
|
|
|
1
1
|
import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* `success_url` / `reject_url
|
|
5
|
-
* redirect from WordPress and forwards the credentials back to a local callback
|
|
6
|
-
* server via a form POST (keeping them out of the browser's address bar).
|
|
3
|
+
* Asks WordPress for an Application Password through its own authorize-application.php page,
|
|
4
|
+
* with the CLI's loopback server as `success_url` / `reject_url`:
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 3. That page redirects the user to WordPress's authorize-application.php,
|
|
13
|
-
* passing the API's callback endpoint as `success_url` (HTTPS).
|
|
14
|
-
* 4. After the user approves, WordPress redirects to the API callback.
|
|
15
|
-
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
16
|
-
* Application Password to the local callback server.
|
|
17
|
-
* 6. The local server extracts the credentials and resolves the promise.
|
|
6
|
+
* 1. Start a local HTTP server on 127.0.0.1.
|
|
7
|
+
* 2. Open the browser on `<site>/wp-admin/authorize-application.php`.
|
|
8
|
+
* 3. After the user approves, WordPress redirects to the loopback URL with `user_login` and
|
|
9
|
+
* `password` appended to its query string (`state` is kept, WordPress only adds params).
|
|
18
10
|
*
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
11
|
+
* The callback host must be the literal `127.0.0.1`, not `localhost`: WordPress 7.0+ only exempts
|
|
12
|
+
* `127.0.0.1` and `[::1]` from its HTTPS requirement on these URLs. Older versions are screened
|
|
13
|
+
* out beforehand by `diagnoseWpSite`. The credentials land in the browser history as part of the
|
|
14
|
+
* callback URL, so the result page rewrites that history entry (see `renderResultPage`).
|
|
22
15
|
*/
|
|
23
16
|
export async function authorizeWithBrowser(siteUrl, log) {
|
|
24
17
|
return waitForLocalCallback({
|
|
25
|
-
|
|
18
|
+
// WordPress reaches the callback through a top-level GET redirect, which carries no Origin,
|
|
19
|
+
// so any request that does carry one isn't WordPress.
|
|
20
|
+
allowedOrigins: [],
|
|
26
21
|
buildUrl(callbackBaseUrl, state) {
|
|
27
|
-
const
|
|
22
|
+
const callbackUrl = `${callbackBaseUrl}/?state=${state}`;
|
|
28
23
|
const params = new URLSearchParams({
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
app_name: 'Loopress',
|
|
25
|
+
reject_url: `${callbackUrl}&cancelled=1`,
|
|
26
|
+
success_url: callbackUrl,
|
|
31
27
|
});
|
|
32
|
-
return `${
|
|
28
|
+
return `${siteUrl}/wp-admin/authorize-application.php?${params}`;
|
|
33
29
|
},
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
callbackHost: '127.0.0.1',
|
|
31
|
+
handleRequest(url, { resolveWithPage, rejectWithPage, respondBadRequest }) {
|
|
32
|
+
if (url.searchParams.has('cancelled')) {
|
|
36
33
|
rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
|
|
37
34
|
return;
|
|
38
35
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const { password, user_login: userLogin } = body;
|
|
36
|
+
const password = url.searchParams.get('password');
|
|
37
|
+
const userLogin = url.searchParams.get('user_login');
|
|
42
38
|
if (!password || !userLogin) {
|
|
43
39
|
respondBadRequest('Missing password or user_login');
|
|
44
40
|
return;
|
|
@@ -46,7 +42,7 @@ export async function authorizeWithBrowser(siteUrl, log) {
|
|
|
46
42
|
resolveWithPage(SUCCESS_PAGE, { password, userLogin });
|
|
47
43
|
},
|
|
48
44
|
log,
|
|
49
|
-
openingMessage: 'Opening WordPress in your browser to authorize Loopress
|
|
45
|
+
openingMessage: 'Opening WordPress in your browser to authorize Loopress...\nIf WordPress says the URL must be served over a secure connection (WordPress older than 7.0), press Ctrl-C and run `lps project config` again, choosing manual entry.',
|
|
50
46
|
timeoutMessage: 'Authorization timed out after 5 minutes.',
|
|
51
47
|
});
|
|
52
48
|
}
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
2
|
+
export declare const LOCAL_ENVIRONMENT_NAME = "local";
|
|
3
|
+
export declare function isLocalEnvironment(environmentName: string | undefined): boolean;
|
|
4
|
+
export declare function assertSecureSiteUrl(siteUrl: string, environmentName: string | undefined): void;
|
|
2
5
|
/**
|
|
3
6
|
* HTTP client for a WordPress site's REST API.
|
|
4
7
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
@@ -6,7 +9,7 @@ export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
|
6
9
|
export declare class WpClient {
|
|
7
10
|
private readonly siteUrl;
|
|
8
11
|
private readonly client;
|
|
9
|
-
constructor(siteUrl: string, token: string);
|
|
12
|
+
constructor(siteUrl: string, token: string, environmentName?: string);
|
|
10
13
|
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
14
|
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
12
15
|
getAll<T>(path: string, options?: RequestOptions & {
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
4
|
+
// The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
|
|
5
|
+
// by its config name (the same "local" environment `lps dev` targets), never by the URL's host.
|
|
6
|
+
// Locality is a property of the configured environment, not of a hostname, so a real remote site
|
|
7
|
+
// can never opt into cleartext just by resolving to a `.local`/loopback-looking name.
|
|
8
|
+
export const LOCAL_ENVIRONMENT_NAME = 'local';
|
|
9
|
+
export function isLocalEnvironment(environmentName) {
|
|
10
|
+
return environmentName === LOCAL_ENVIRONMENT_NAME;
|
|
11
|
+
}
|
|
12
|
+
// Throws when a URL would send credentials in cleartext: http:// for any environment other than
|
|
13
|
+
// the local one. The trust boundary is here, at the client, so a hand-edited config.json or
|
|
14
|
+
// loopress.json cannot bypass the check the interactive prompt also applies.
|
|
15
|
+
export function assertSecureSiteUrl(siteUrl, environmentName) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(siteUrl);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`Invalid site URL: ${siteUrl}`);
|
|
22
|
+
}
|
|
23
|
+
if (parsed.protocol === 'http:' && !isLocalEnvironment(environmentName)) {
|
|
24
|
+
throw new Error(`Refusing to send credentials over plain HTTP for the "${environmentName ?? 'unknown'}" environment: the Application Password would travel in cleartext. Use HTTPS. Plain HTTP is allowed only for the "${LOCAL_ENVIRONMENT_NAME}" environment.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
4
27
|
/**
|
|
5
28
|
* HTTP client for a WordPress site's REST API.
|
|
6
29
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
@@ -8,8 +31,9 @@ export const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
8
31
|
export class WpClient {
|
|
9
32
|
siteUrl;
|
|
10
33
|
client;
|
|
11
|
-
constructor(siteUrl, token) {
|
|
34
|
+
constructor(siteUrl, token, environmentName) {
|
|
12
35
|
this.siteUrl = siteUrl;
|
|
36
|
+
assertSecureSiteUrl(siteUrl, environmentName);
|
|
13
37
|
this.client = got.extend({
|
|
14
38
|
headers: { Authorization: `Basic ${Buffer.from(token).toString('base64')}` },
|
|
15
39
|
prefixUrl: `${siteUrl}/wp-json`,
|