@loopress/cli 0.28.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 +370 -203
- 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/push.js +1 -1
- package/dist/commands/composer/push.js +3 -3
- 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 -38
- 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/pull.d.ts +4 -0
- package/dist/commands/plugin/pull.js +56 -10
- package/dist/commands/plugin/push.js +5 -4
- package/dist/commands/push.js +5 -4
- 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 -10
- package/dist/commands/theme/pull.js +30 -32
- package/dist/commands/theme/push.d.ts +8 -9
- package/dist/commands/theme/push.js +54 -111
- package/dist/commands/theme/style/pull.d.ts +7 -1
- package/dist/commands/theme/style/pull.js +3 -1
- package/dist/commands/theme/style/push.d.ts +8 -1
- package/dist/commands/theme/style/push.js +4 -1
- package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
- package/dist/commands/{template → theme/template}/diff.js +3 -3
- package/dist/commands/{template → theme/template}/list.d.ts +2 -2
- package/dist/commands/{template → theme/template}/list.js +5 -5
- package/dist/commands/{template → theme/template}/push.d.ts +1 -1
- package/dist/commands/{template → theme/template}/push.js +8 -6
- 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/app-manifest.js +2 -2
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +3 -0
- package/dist/lib/dev-watch.js +2 -2
- 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 +7 -6
- package/dist/lib/snapshot-store.js +2 -2
- package/dist/lib/wp-authorize-flow.d.ts +10 -17
- package/dist/lib/wp-authorize-flow.js +24 -28
- package/dist/lib/wp-site-diagnostic.js +37 -0
- package/dist/utils/composer.d.ts +3 -1
- package/dist/utils/composer.js +14 -6
- package/dist/utils/option-format.d.ts +3 -1
- package/dist/utils/option-format.js +14 -3
- package/dist/utils/page-format.d.ts +5 -1
- package/dist/utils/page-format.js +7 -3
- package/dist/utils/plugin-sync.js +1 -1
- package/dist/utils/resource-dirs.js +2 -2
- package/dist/utils/template-format.d.ts +2 -1
- package/dist/utils/template-format.js +10 -3
- package/oclif.manifest.json +937 -361
- package/package.json +22 -16
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)
|
|
@@ -7,7 +7,7 @@ import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
|
7
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 } from '../utils/template-format.js';
|
|
10
|
+
import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
|
|
11
11
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
12
12
|
import { loadFiles } from './load-files.js';
|
|
13
13
|
import { loadSnippets } from './load-snippets.js';
|
|
@@ -236,7 +236,7 @@ async function childThemeState(wp, kind) {
|
|
|
236
236
|
const templateProvider = {
|
|
237
237
|
dirKind: 'template',
|
|
238
238
|
async local(dir) {
|
|
239
|
-
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile);
|
|
239
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
|
|
240
240
|
if (problems.length > 0)
|
|
241
241
|
throw new Error(formatPageProblems(problems));
|
|
242
242
|
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
@@ -248,7 +248,7 @@ const templateProvider = {
|
|
|
248
248
|
const partProvider = {
|
|
249
249
|
dirKind: 'part',
|
|
250
250
|
async local(dir) {
|
|
251
|
-
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile);
|
|
251
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
|
|
252
252
|
if (problems.length > 0)
|
|
253
253
|
throw new Error(formatPageProblems(problems));
|
|
254
254
|
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
@@ -408,12 +408,13 @@ const optionsProvider = {
|
|
|
408
408
|
const tracked = await loadFiles(dir, {
|
|
409
409
|
extension: '.json',
|
|
410
410
|
onSkip: onWarn,
|
|
411
|
-
parse:
|
|
411
|
+
parse: parseLocalOption,
|
|
412
412
|
});
|
|
413
413
|
const state = new Map();
|
|
414
|
-
for (const name of tracked) {
|
|
414
|
+
for (const { name, refs } of tracked) {
|
|
415
415
|
try {
|
|
416
|
-
|
|
416
|
+
// With the file's refs, so post references compare as paths on both sides.
|
|
417
|
+
const option = await wp.get(optionEndpoint(name, refs));
|
|
417
418
|
state.set(name, { autoload: option.autoload, value: option.value });
|
|
418
419
|
}
|
|
419
420
|
catch (error) {
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
/**
|
|
4
|
+
* WordPress 7.0 is the first version that accepts a loopback `http://127.0.0.1` success_url on
|
|
5
|
+
* authorize-application.php whatever the site's environment type (wp_is_authorize_application_redirect_url_valid).
|
|
6
|
+
* Earlier versions only accept it when WP_ENVIRONMENT_TYPE is `local`, which isn't exposed publicly,
|
|
7
|
+
* so a 6.x site is sent to manual entry even though a local one would have worked.
|
|
8
|
+
*/
|
|
9
|
+
const MIN_BROWSER_AUTH_MAJOR = 7;
|
|
3
10
|
/**
|
|
4
11
|
* Pre-flight checks run before starting the browser authorization flow, so failures
|
|
5
12
|
* (unreachable site, blocked REST API, Application Passwords disabled) surface as an
|
|
@@ -29,8 +36,38 @@ export async function diagnoseWpSite(siteUrl) {
|
|
|
29
36
|
reason: `Application Passwords are not available on ${siteUrl}. The site may be older than WordPress 5.6, require HTTPS, or have the feature disabled by a plugin or filter.`,
|
|
30
37
|
};
|
|
31
38
|
}
|
|
39
|
+
const version = await detectWpVersion(siteUrl);
|
|
40
|
+
if (version && Number(version.split('.', 1)[0]) < MIN_BROWSER_AUTH_MAJOR) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: `${siteUrl} runs WordPress ${version}. Before WordPress ${MIN_BROWSER_AUTH_MAJOR}.0, WordPress refuses to send the Application Password back to the CLI on this machine (http://127.0.0.1), so authorizing in the browser is not possible. Create an Application Password in wp-admin (Users > Profile) and enter it below.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
32
46
|
return { ok: true };
|
|
33
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Best effort, from the public generator tag: the feed's `<generator>` first, then the home page
|
|
50
|
+
* `<meta name="generator">`. Security plugins often strip both, in which case this returns
|
|
51
|
+
* undefined and the browser flow is attempted anyway.
|
|
52
|
+
*/
|
|
53
|
+
async function detectWpVersion(siteUrl) {
|
|
54
|
+
const sources = [
|
|
55
|
+
{ pattern: /<generator>[^<]*[?&]v=(\d+(?:\.\d+)*)/, url: `${siteUrl}/feed/` },
|
|
56
|
+
{ pattern: /<meta name="generator" content="WordPress (\d+(?:\.\d+)*)/, url: `${siteUrl}/` },
|
|
57
|
+
];
|
|
58
|
+
for (const { pattern, url } of sources) {
|
|
59
|
+
try {
|
|
60
|
+
const body = await got.get(url, { timeout: { request: REQUEST_TIMEOUT_MS } }).text();
|
|
61
|
+
const version = pattern.exec(body)?.[1];
|
|
62
|
+
if (version)
|
|
63
|
+
return version;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Feed disabled or page unreachable: try the next source.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
34
71
|
function describe(error) {
|
|
35
72
|
return error instanceof Error ? error.message : String(error);
|
|
36
73
|
}
|
package/dist/utils/composer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SyncIntent } from './plugin-sync.js';
|
|
2
2
|
import { type MergeResult } from './plugins.js';
|
|
3
|
+
export declare const NOT_ON_WORDPRESS_ORG = "not on WordPress.org";
|
|
3
4
|
export type ComposerJson = {
|
|
4
5
|
config?: {
|
|
5
6
|
'allow-plugins'?: Record<string, boolean>;
|
|
@@ -29,4 +30,5 @@ export declare function pullIntoComposerJson(path: string, kind: 'plugin' | 'the
|
|
|
29
30
|
log: (message: string) => void;
|
|
30
31
|
}): Promise<ComposerPullResult>;
|
|
31
32
|
export declare function toIntent(require: Record<string, string>): SyncIntent;
|
|
32
|
-
export declare function
|
|
33
|
+
export declare function requireToInstall(composerJson: ComposerJson, includeDev: boolean): Record<string, string>;
|
|
34
|
+
export declare function wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme', includeDev?: boolean): Record<string, string>;
|
package/dist/utils/composer.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import writeFileAtomic from 'write-file-atomic';
|
|
5
5
|
import { isExactVersion } from './version.js';
|
|
6
|
+
// Why pullIntoComposerJson skips a plugin it can't install: premium or custom, not on WPackagist.
|
|
7
|
+
export const NOT_ON_WORDPRESS_ORG = 'not on WordPress.org';
|
|
6
8
|
export async function readComposerJson() {
|
|
7
9
|
const path = join(process.cwd(), 'composer.json');
|
|
8
10
|
if (!existsSync(path))
|
|
@@ -100,7 +102,7 @@ function planComposerPull(composerJson, prefix, incoming) {
|
|
|
100
102
|
}
|
|
101
103
|
return { candidates, result };
|
|
102
104
|
}
|
|
103
|
-
// `lps plugin pull` / `lps theme pull` on a project that has a composer.json: pin installed
|
|
105
|
+
// `lps plugin pull` / `lps theme version pull` on a project that has a composer.json: pin installed
|
|
104
106
|
// packages to their live versions as `wpackagist-<kind>/<slug>` under `require`, leaving every
|
|
105
107
|
// other key untouched. The server adds the WPackagist repository and composer/installers on push.
|
|
106
108
|
// - A declared version constraint (^9.4, *, ...) is the user's intent: never overwritten, only an
|
|
@@ -119,7 +121,7 @@ export async function pullIntoComposerJson(path, kind, incoming, { dryRun, log }
|
|
|
119
121
|
result.merged[slug] = incoming[slug];
|
|
120
122
|
}
|
|
121
123
|
else {
|
|
122
|
-
result.skipped.push({ reason:
|
|
124
|
+
result.skipped.push({ reason: NOT_ON_WORDPRESS_ORG, slug });
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
if (!dryRun) {
|
|
@@ -159,11 +161,17 @@ export function toIntent(require) {
|
|
|
159
161
|
}
|
|
160
162
|
return intent;
|
|
161
163
|
}
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
164
|
+
// What a push installs: `require`, plus `require-dev` on the local environment only (Query
|
|
165
|
+
// Monitor, or a premium plugin's free edition to develop against). A remote site never gets
|
|
166
|
+
// `require-dev`, the same split as `composer install --no-dev` on a production server.
|
|
167
|
+
export function requireToInstall(composerJson, includeDev) {
|
|
168
|
+
return includeDev ? { ...composerJson.require, ...composerJson['require-dev'] } : { ...composerJson.require };
|
|
169
|
+
}
|
|
170
|
+
// The `wpackagist-<kind>/<slug>` entries of what a push installs (see requireToInstall), keyed
|
|
171
|
+
// by slug, with the version or constraint as written.
|
|
172
|
+
export function wpackagistRequire(composerJson, kind, includeDev = false) {
|
|
165
173
|
const prefix = `wpackagist-${kind}/`;
|
|
166
|
-
return Object.fromEntries(Object.entries(composerJson
|
|
174
|
+
return Object.fromEntries(Object.entries(requireToInstall(composerJson, includeDev))
|
|
167
175
|
.filter(([name]) => name.startsWith(prefix))
|
|
168
176
|
.map(([name, version]) => [name.slice(prefix.length), version]));
|
|
169
177
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export declare const OPTIONS_ENDPOINT = "loopress/v1/options";
|
|
2
|
-
export declare function optionEndpoint(name: string): string;
|
|
2
|
+
export declare function optionEndpoint(name: string, refs?: OptionRefs): string;
|
|
3
3
|
export declare const RESERVED_OPTION_NAMES: readonly ["active_plugins", "stylesheet", "template"];
|
|
4
4
|
export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "initial_db_version", "cron", "rewrite_rules", "WPLANG", "default_role", "users_can_register", "uninstall_plugins", "mailserver_url", "mailserver_login", "mailserver_pass", "mailserver_port"];
|
|
5
|
+
export type OptionRefs = Record<string, string>;
|
|
5
6
|
export type LocalOption = {
|
|
6
7
|
autoload: string;
|
|
7
8
|
name: string;
|
|
8
9
|
readonly?: boolean;
|
|
10
|
+
refs?: OptionRefs;
|
|
9
11
|
value: unknown;
|
|
10
12
|
};
|
|
11
13
|
export type RemoteOption = {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const OPTIONS_ENDPOINT = 'loopress/v1/options';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
// `refs` asks WordPress to return the declared post references as paths (see OptionRefs).
|
|
3
|
+
export function optionEndpoint(name, refs) {
|
|
4
|
+
const endpoint = `${OPTIONS_ENDPOINT}/${encodeURIComponent(name)}`;
|
|
5
|
+
return refs && Object.keys(refs).length > 0 ? `${endpoint}?refs=${encodeURIComponent(JSON.stringify(refs))}` : endpoint;
|
|
4
6
|
}
|
|
5
7
|
// Mirrors OptionsService::RESERVED_NAMES on the WordPress side (the actual, unbypassable
|
|
6
8
|
// enforcement lives there). Checked here too so `option add` fails fast with a clear pointer to
|
|
@@ -52,7 +54,7 @@ export function parseLocalOption(raw) {
|
|
|
52
54
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
53
55
|
throw new Error('not a JSON object');
|
|
54
56
|
}
|
|
55
|
-
const { autoload, name, value } = parsed;
|
|
57
|
+
const { autoload, name, refs, value } = parsed;
|
|
56
58
|
if (typeof name !== 'string' || name === '') {
|
|
57
59
|
throw new Error('missing a "name" string');
|
|
58
60
|
}
|
|
@@ -65,5 +67,14 @@ export function parseLocalOption(raw) {
|
|
|
65
67
|
if (value === undefined) {
|
|
66
68
|
throw new Error('missing a "value" field');
|
|
67
69
|
}
|
|
70
|
+
if (refs !== undefined && !isOptionRefs(refs)) {
|
|
71
|
+
throw new TypeError('"refs" must map a value path to a post type, e.g. {"purchase_page": "page"}');
|
|
72
|
+
}
|
|
68
73
|
return parsed;
|
|
69
74
|
}
|
|
75
|
+
function isOptionRefs(refs) {
|
|
76
|
+
return (typeof refs === 'object' &&
|
|
77
|
+
refs !== null &&
|
|
78
|
+
!Array.isArray(refs) &&
|
|
79
|
+
Object.entries(refs).every(([path, postType]) => path !== '' && typeof postType === 'string' && postType !== ''));
|
|
80
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export declare const PAGES_ENDPOINT = "loopress/v1/pages";
|
|
2
2
|
export declare const PAGE_SLUG_PATTERN: RegExp;
|
|
3
|
+
export type SlugRule = {
|
|
4
|
+
message: string;
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
};
|
|
3
7
|
export declare const PAGE_STATUSES: readonly ["draft", "publish"];
|
|
4
8
|
export type PageStatus = (typeof PAGE_STATUSES)[number];
|
|
5
9
|
export type Page = {
|
|
@@ -32,7 +36,7 @@ export declare function readLocalPages(dir: string): Promise<{
|
|
|
32
36
|
pages: Page[];
|
|
33
37
|
problems: PageProblem[];
|
|
34
38
|
}>;
|
|
35
|
-
export declare function readLocalHtmlFiles<T>(dir: string, parse: (slug: string, raw: string) => T): Promise<{
|
|
39
|
+
export declare function readLocalHtmlFiles<T>(dir: string, parse: (slug: string, raw: string) => T, slugRule?: SlugRule): Promise<{
|
|
36
40
|
files: T[];
|
|
37
41
|
problems: PageProblem[];
|
|
38
42
|
}>;
|
|
@@ -4,6 +4,10 @@ export const PAGES_ENDPOINT = 'loopress/v1/pages';
|
|
|
4
4
|
// Mirrored server-side in PagesController. No leading, trailing or doubled hyphen: WordPress's
|
|
5
5
|
// sanitize_title() would rewrite those, and the page would no longer match its file name.
|
|
6
6
|
export const PAGE_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const PAGE_SLUG_RULE = {
|
|
8
|
+
message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")',
|
|
9
|
+
pattern: PAGE_SLUG_PATTERN,
|
|
10
|
+
};
|
|
7
11
|
export const PAGE_STATUSES = ['draft', 'publish'];
|
|
8
12
|
const HEADER_KEYS = ['title', 'status', 'full-width', 'hide-title', 'template'];
|
|
9
13
|
function parseBoolean(key, value) {
|
|
@@ -82,7 +86,7 @@ export async function readLocalPages(dir) {
|
|
|
82
86
|
return { pages: files, problems };
|
|
83
87
|
}
|
|
84
88
|
// The directory rules of readLocalPages(), for any flat folder of `<slug>.html` files.
|
|
85
|
-
export async function readLocalHtmlFiles(dir, parse) {
|
|
89
|
+
export async function readLocalHtmlFiles(dir, parse, slugRule = PAGE_SLUG_RULE) {
|
|
86
90
|
let entries;
|
|
87
91
|
try {
|
|
88
92
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -108,8 +112,8 @@ export async function readLocalHtmlFiles(dir, parse) {
|
|
|
108
112
|
continue;
|
|
109
113
|
}
|
|
110
114
|
const slug = basename(entry.name, '.html');
|
|
111
|
-
if (!
|
|
112
|
-
problems.push({ file: filePath, message:
|
|
115
|
+
if (!slugRule.pattern.test(slug)) {
|
|
116
|
+
problems.push({ file: filePath, message: slugRule.message });
|
|
113
117
|
continue;
|
|
114
118
|
}
|
|
115
119
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Shared helpers for driving the server-side `loopress/v1/composer/sync` endpoint, used by
|
|
2
|
-
// `lps plugin push`, `lps theme push` and `lps composer push`. The endpoint runs Composer +
|
|
2
|
+
// `lps plugin push`, `lps theme version push` and `lps composer push`. The endpoint runs Composer +
|
|
3
3
|
// WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
|
|
4
4
|
// never a composer.json, so the plugin stays the single authority on that file's shape.
|
|
5
5
|
import { compareVersions } from './version.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
// The default subdirectory for each file-backed resource, relative to rootDir.
|
|
3
|
-
//
|
|
2
|
+
// The default subdirectory for each file-backed resource, relative to rootDir. Used whenever
|
|
3
|
+
// loopress.json has no `<kind>Dir` key, which is the case for a fresh `lps init`.
|
|
4
4
|
export const RESOURCE_DIR_DEFAULTS = {
|
|
5
5
|
acf: 'acf',
|
|
6
6
|
api: 'api',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type PageProblem } from './page-format.js';
|
|
1
|
+
import { type PageProblem, type SlugRule } from './page-format.js';
|
|
2
2
|
export declare const CHILD_THEME_ENDPOINT = "loopress/v1/child-theme";
|
|
3
|
+
export declare const TEMPLATE_SLUG_RULE: SlugRule;
|
|
3
4
|
export type Template = {
|
|
4
5
|
html: string;
|
|
5
6
|
postTypes?: string[];
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { parseHtmlHeader, readLocalHtmlFiles, titleFromSlug } from './page-format.js';
|
|
2
2
|
export const CHILD_THEME_ENDPOINT = 'loopress/v1/child-theme';
|
|
3
|
+
// Mirrored server-side in ChildThemeController. Looser than a page slug: the template hierarchy
|
|
4
|
+
// embeds post type and taxonomy slugs as is (`taxonomy-download_tag`, `single-my_type`), and those
|
|
5
|
+
// follow sanitize_key(), so lowercase letters, digits, `_` and `-`.
|
|
6
|
+
export const TEMPLATE_SLUG_RULE = {
|
|
7
|
+
message: 'the file name must be lowercase letters, digits, "_" and "-" (e.g. "taxonomy-download_tag.html")',
|
|
8
|
+
pattern: /^[a-z0-9_-]+$/,
|
|
9
|
+
};
|
|
3
10
|
// A block delimiter as the first line (`<!-- wp:template-part ... /-->`, the usual one) is markup,
|
|
4
11
|
// not a header.
|
|
5
12
|
const BLOCK_START = /^\s*<!--\s*\/?wp:/;
|
|
@@ -28,9 +35,9 @@ export function parsePartFile(slug, raw) {
|
|
|
28
35
|
const nameArea = /^(header|footer)(-|$)/.exec(slug)?.[1] ?? 'uncategorized';
|
|
29
36
|
return { area: meta.area || nameArea, html: body, slug, title: meta.title || titleFromSlug(slug) };
|
|
30
37
|
}
|
|
31
|
-
// Same directory rules as pages/ (flat, `.html` only,
|
|
38
|
+
// Same directory rules as pages/ (flat, `.html` only), with template slugs, every problem at once.
|
|
32
39
|
export async function readLocalTemplates(templatesDir, partsDir) {
|
|
33
|
-
const templates = await readLocalHtmlFiles(templatesDir, parseTemplateFile);
|
|
34
|
-
const parts = await readLocalHtmlFiles(partsDir, parsePartFile);
|
|
40
|
+
const templates = await readLocalHtmlFiles(templatesDir, parseTemplateFile, TEMPLATE_SLUG_RULE);
|
|
41
|
+
const parts = await readLocalHtmlFiles(partsDir, parsePartFile, TEMPLATE_SLUG_RULE);
|
|
35
42
|
return { parts: parts.files, problems: [...templates.problems, ...parts.problems], templates: templates.files };
|
|
36
43
|
}
|