@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.
Files changed (82) hide show
  1. package/README.md +370 -203
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/push.js +1 -1
  9. package/dist/commands/composer/push.js +3 -3
  10. package/dist/commands/form/pull.d.ts +9 -1
  11. package/dist/commands/form/pull.js +3 -1
  12. package/dist/commands/form/push.d.ts +7 -1
  13. package/dist/commands/form/push.js +3 -1
  14. package/dist/commands/init.js +6 -38
  15. package/dist/commands/menu/pull.d.ts +7 -1
  16. package/dist/commands/menu/pull.js +2 -0
  17. package/dist/commands/menu/push.d.ts +7 -1
  18. package/dist/commands/menu/push.js +3 -1
  19. package/dist/commands/option/pull.js +4 -4
  20. package/dist/commands/option/push.js +2 -0
  21. package/dist/commands/page/push.js +3 -0
  22. package/dist/commands/plugin/pull.d.ts +4 -0
  23. package/dist/commands/plugin/pull.js +56 -10
  24. package/dist/commands/plugin/push.js +5 -4
  25. package/dist/commands/push.js +5 -4
  26. package/dist/commands/seo/pull.d.ts +8 -1
  27. package/dist/commands/seo/pull.js +2 -0
  28. package/dist/commands/seo/push.d.ts +7 -1
  29. package/dist/commands/seo/push.js +3 -1
  30. package/dist/commands/snippet/publish.js +1 -1
  31. package/dist/commands/snippet/pull.js +1 -1
  32. package/dist/commands/snippet/push.js +1 -1
  33. package/dist/commands/status.d.ts +1 -0
  34. package/dist/commands/status.js +4 -4
  35. package/dist/commands/theme/pull.d.ts +6 -10
  36. package/dist/commands/theme/pull.js +30 -32
  37. package/dist/commands/theme/push.d.ts +8 -9
  38. package/dist/commands/theme/push.js +54 -111
  39. package/dist/commands/theme/style/pull.d.ts +7 -1
  40. package/dist/commands/theme/style/pull.js +3 -1
  41. package/dist/commands/theme/style/push.d.ts +8 -1
  42. package/dist/commands/theme/style/push.js +4 -1
  43. package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
  44. package/dist/commands/{template → theme/template}/diff.js +3 -3
  45. package/dist/commands/{template → theme/template}/list.d.ts +2 -2
  46. package/dist/commands/{template → theme/template}/list.js +5 -5
  47. package/dist/commands/{template → theme/template}/push.d.ts +1 -1
  48. package/dist/commands/{template → theme/template}/push.js +8 -6
  49. package/dist/commands/theme/version/pull.d.ts +23 -0
  50. package/dist/commands/theme/version/pull.js +43 -0
  51. package/dist/commands/theme/version/push.d.ts +22 -0
  52. package/dist/commands/theme/version/push.js +123 -0
  53. package/dist/hooks/finally.d.ts +2 -1
  54. package/dist/hooks/finally.js +27 -4
  55. package/dist/lib/app-manifest.js +2 -2
  56. package/dist/lib/base.d.ts +1 -0
  57. package/dist/lib/base.js +3 -0
  58. package/dist/lib/dev-watch.js +2 -2
  59. package/dist/lib/diff-state.js +1 -0
  60. package/dist/lib/github-release.js +0 -1
  61. package/dist/lib/local-callback-server.d.ts +12 -6
  62. package/dist/lib/local-callback-server.js +21 -22
  63. package/dist/lib/php-files-command.js +3 -1
  64. package/dist/lib/push-command.d.ts +5 -0
  65. package/dist/lib/push-command.js +30 -8
  66. package/dist/lib/resource-state.js +7 -6
  67. package/dist/lib/snapshot-store.js +2 -2
  68. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  69. package/dist/lib/wp-authorize-flow.js +24 -28
  70. package/dist/lib/wp-site-diagnostic.js +37 -0
  71. package/dist/utils/composer.d.ts +3 -1
  72. package/dist/utils/composer.js +14 -6
  73. package/dist/utils/option-format.d.ts +3 -1
  74. package/dist/utils/option-format.js +14 -3
  75. package/dist/utils/page-format.d.ts +5 -1
  76. package/dist/utils/page-format.js +7 -3
  77. package/dist/utils/plugin-sync.js +1 -1
  78. package/dist/utils/resource-dirs.js +2 -2
  79. package/dist/utils/template-format.d.ts +2 -1
  80. package/dist/utils/template-format.js +10 -3
  81. package/oclif.manifest.json +937 -361
  82. package/package.json +22 -16
@@ -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. A dry run changes
16
- // nothing, so there is nothing to snapshot. Best-effort: a read failure (the site briefly
17
- // unreachable) must never block the push itself, the same "never interrupt the push flow"
18
- // stance recordDeployment takes; `undefined` tells `writeAfterPushSnapshot` there is no
19
- // restore point to complete.
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
- return super.catch(err);
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: (raw) => parseLocalOption(raw).name,
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
- const option = await wp.get(optionEndpoint(name));
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].sort(([a], [b]) => a.localeCompare(b)));
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.sort((a, b) => Number(b.id) - Number(a.id));
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
- * Relays the authorization through the Loopress API so that WordPress's
7
- * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
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
- * Flow:
12
- * 1. Start a local HTTP server on `127.0.0.1`.
13
- * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
14
- * callback URL and the target WordPress site as query params.
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 `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
23
- * form's POST target), so it comes back as a query param on the credential POST and
24
- * `waitForLocalCallback` can match it before this handler runs.
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
- * Relays the authorization through the Loopress API so that WordPress's
4
- * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
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
- * Flow:
9
- * 1. Start a local HTTP server on `127.0.0.1`.
10
- * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
11
- * callback URL and the target WordPress site as query params.
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 `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
20
- * form's POST target), so it comes back as a query param on the credential POST and
21
- * `waitForLocalCallback` can match it before this handler runs.
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
- allowedOrigins: ['https://api.loopress.dev'],
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 relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
22
+ const callbackUrl = `${callbackBaseUrl}/?state=${state}`;
28
23
  const params = new URLSearchParams({
29
- callbackUrl: `${callbackBaseUrl}?state=${state}`,
30
- wpUrl: siteUrl,
24
+ app_name: 'Loopress',
25
+ reject_url: `${callbackUrl}&cancelled=1`,
26
+ success_url: callbackUrl,
31
27
  });
32
- return `${relayUrl}?${params}`;
28
+ return `${siteUrl}/wp-admin/authorize-application.php?${params}`;
33
29
  },
34
- handleRequest(url, { resolveWithPage, rejectWithPage, respondBadRequest, body }) {
35
- if (url.searchParams.has('cancelled') || body.cancelled) {
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
- // Credentials come from the relay's form POST only. Reading them from the query string
40
- // would let them land in shell history, proxy logs and the browser address bar (F24).
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
  }
@@ -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 wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme'): Record<string, string>;
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>;
@@ -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: 'not on WordPress.org', slug });
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
- // The `wpackagist-<kind>/<slug>` entries of a composer.json `require`, keyed by slug, with the
163
- // version or constraint as written.
164
- export function wpackagistRequire(composerJson, kind) {
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.require ?? {})
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
- export function optionEndpoint(name) {
3
- return `${OPTIONS_ENDPOINT}/${encodeURIComponent(name)}`;
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 (!PAGE_SLUG_PATTERN.test(slug)) {
112
- problems.push({ file: filePath, message: 'the file name must be lowercase letters and digits separated by single hyphens (e.g. "legal-notice.html")' });
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. `lps init`
3
- // writes these same defaults into loopress.json, keep the two in sync.
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, strict slugs), every problem at once.
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
  }