@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.
Files changed (124) hide show
  1. package/README.md +448 -189
  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/pull.js +17 -4
  9. package/dist/commands/app/push.d.ts +3 -0
  10. package/dist/commands/app/push.js +30 -6
  11. package/dist/commands/composer/push.js +3 -21
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/form/pull.d.ts +9 -1
  14. package/dist/commands/form/pull.js +3 -1
  15. package/dist/commands/form/push.d.ts +7 -1
  16. package/dist/commands/form/push.js +3 -1
  17. package/dist/commands/init.js +6 -36
  18. package/dist/commands/menu/pull.d.ts +7 -1
  19. package/dist/commands/menu/pull.js +2 -0
  20. package/dist/commands/menu/push.d.ts +7 -1
  21. package/dist/commands/menu/push.js +3 -1
  22. package/dist/commands/option/pull.js +4 -4
  23. package/dist/commands/option/push.js +2 -0
  24. package/dist/commands/page/push.js +3 -0
  25. package/dist/commands/plugin/add.js +6 -3
  26. package/dist/commands/plugin/audit.js +4 -5
  27. package/dist/commands/plugin/pull.d.ts +7 -1
  28. package/dist/commands/plugin/pull.js +68 -14
  29. package/dist/commands/plugin/push.d.ts +5 -1
  30. package/dist/commands/plugin/push.js +92 -38
  31. package/dist/commands/plugin/status.d.ts +1 -0
  32. package/dist/commands/plugin/status.js +21 -2
  33. package/dist/commands/project/config.js +12 -8
  34. package/dist/commands/promote.d.ts +2 -0
  35. package/dist/commands/promote.js +69 -8
  36. package/dist/commands/pull.js +2 -2
  37. package/dist/commands/push.d.ts +1 -0
  38. package/dist/commands/push.js +40 -5
  39. package/dist/commands/seo/pull.d.ts +8 -1
  40. package/dist/commands/seo/pull.js +2 -0
  41. package/dist/commands/seo/push.d.ts +7 -1
  42. package/dist/commands/seo/push.js +3 -1
  43. package/dist/commands/snippet/publish.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.js +1 -1
  46. package/dist/commands/status.d.ts +1 -0
  47. package/dist/commands/status.js +4 -4
  48. package/dist/commands/theme/pull.d.ts +6 -8
  49. package/dist/commands/theme/pull.js +30 -26
  50. package/dist/commands/theme/push.d.ts +8 -9
  51. package/dist/commands/theme/push.js +54 -111
  52. package/dist/commands/theme/style/diff.d.ts +2 -0
  53. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  54. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
  55. package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
  56. package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
  57. package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
  58. package/dist/commands/theme/style/rollback.d.ts +2 -0
  59. package/dist/commands/theme/style/rollback.js +6 -0
  60. package/dist/commands/theme/template/diff.d.ts +10 -0
  61. package/dist/commands/theme/template/diff.js +13 -0
  62. package/dist/commands/theme/template/list.d.ts +13 -0
  63. package/dist/commands/theme/template/list.js +23 -0
  64. package/dist/commands/theme/template/push.d.ts +20 -0
  65. package/dist/commands/theme/template/push.js +44 -0
  66. package/dist/commands/theme/version/pull.d.ts +23 -0
  67. package/dist/commands/theme/version/pull.js +43 -0
  68. package/dist/commands/theme/version/push.d.ts +22 -0
  69. package/dist/commands/theme/version/push.js +123 -0
  70. package/dist/hooks/finally.d.ts +2 -1
  71. package/dist/hooks/finally.js +27 -4
  72. package/dist/lib/api-client.js +3 -1
  73. package/dist/lib/app-build.d.ts +5 -0
  74. package/dist/lib/app-build.js +69 -0
  75. package/dist/lib/app-manifest.d.ts +4 -0
  76. package/dist/lib/app-manifest.js +10 -2
  77. package/dist/lib/base.d.ts +1 -0
  78. package/dist/lib/base.js +14 -2
  79. package/dist/lib/bootstrap-full-install.js +45 -14
  80. package/dist/lib/dev-watch.d.ts +1 -1
  81. package/dist/lib/dev-watch.js +3 -1
  82. package/dist/lib/diff-command.d.ts +1 -0
  83. package/dist/lib/diff-command.js +6 -4
  84. package/dist/lib/diff-state.js +1 -0
  85. package/dist/lib/github-release.js +0 -1
  86. package/dist/lib/local-callback-server.d.ts +12 -6
  87. package/dist/lib/local-callback-server.js +21 -22
  88. package/dist/lib/php-files-command.js +3 -1
  89. package/dist/lib/push-command.d.ts +5 -0
  90. package/dist/lib/push-command.js +30 -8
  91. package/dist/lib/resource-state.js +47 -4
  92. package/dist/lib/rollback-command.d.ts +1 -0
  93. package/dist/lib/rollback-command.js +8 -6
  94. package/dist/lib/rotate-app-password.js +2 -2
  95. package/dist/lib/snapshot-store.js +2 -2
  96. package/dist/lib/validate-local.js +6 -0
  97. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  98. package/dist/lib/wp-authorize-flow.js +24 -28
  99. package/dist/lib/wp-client.d.ts +4 -1
  100. package/dist/lib/wp-client.js +25 -1
  101. package/dist/lib/wp-site-diagnostic.js +37 -0
  102. package/dist/types/plugin.d.ts +8 -1
  103. package/dist/types/plugin.js +4 -1
  104. package/dist/types/project-config.generated.d.ts +19 -2
  105. package/dist/utils/composer.d.ts +16 -0
  106. package/dist/utils/composer.js +144 -0
  107. package/dist/utils/option-format.d.ts +3 -1
  108. package/dist/utils/option-format.js +14 -3
  109. package/dist/utils/page-format.d.ts +12 -0
  110. package/dist/utils/page-format.js +40 -28
  111. package/dist/utils/plugin-sync.js +1 -1
  112. package/dist/utils/plugins.d.ts +9 -4
  113. package/dist/utils/plugins.js +16 -7
  114. package/dist/utils/resource-dirs.d.ts +2 -0
  115. package/dist/utils/resource-dirs.js +6 -2
  116. package/dist/utils/seo-format.d.ts +2 -2
  117. package/dist/utils/template-format.d.ts +31 -0
  118. package/dist/utils/template-format.js +43 -0
  119. package/oclif.manifest.json +841 -63
  120. package/package.json +24 -17
  121. package/schemas/project-config.schema.json +25 -4
  122. package/dist/commands/theme-styles/diff.d.ts +0 -2
  123. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  124. 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 POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
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 that carries credentials;
29
- * - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
30
- * stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
31
- * `Origin` and are allowed, which is why `state` is the primary control.
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
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
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
- async function handleIncoming(req, res) {
47
+ function handleIncoming(req, res) {
54
48
  try {
55
49
  const url = new URL(req.url ?? '/', 'http://localhost');
56
- const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
57
- const looksLikeCallback = url.searchParams.has('state') || Object.keys(body).length > 0;
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((req, res) => { void handleIncoming(req, res); });
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://localhost:${port}`, state);
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} rm hello --yes`];
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?: {
@@ -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)
@@ -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: (raw) => parseLocalOption(raw).name,
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
- 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));
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 ${resource} rollback`,
45
- `$ lps ${resource} rollback --list`,
46
- `$ lps ${resource} rollback --to 1732000000000`,
47
- `$ lps ${resource} rollback --dry-run`,
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 ${resource} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
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(`${resource}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
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].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
@@ -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
- * 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,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 & {
@@ -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`,