@loopress/cli 0.29.0 → 0.31.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 (154) hide show
  1. package/README.md +1369 -360
  2. package/dist/commands/acf/push.js +2 -1
  3. package/dist/commands/composer/push.d.ts +1 -0
  4. package/dist/commands/composer/push.js +13 -0
  5. package/dist/commands/content/copy.d.ts +28 -0
  6. package/dist/commands/content/copy.js +97 -0
  7. package/dist/commands/cpt/diff.js +5 -0
  8. package/dist/commands/cpt/list.d.ts +2 -0
  9. package/dist/commands/cpt/list.js +4 -0
  10. package/dist/commands/cpt/pull.d.ts +2 -0
  11. package/dist/commands/cpt/pull.js +4 -0
  12. package/dist/commands/cpt/push.d.ts +2 -0
  13. package/dist/commands/cpt/push.js +4 -0
  14. package/dist/commands/cpt/rm.d.ts +2 -0
  15. package/dist/commands/cpt/rm.js +3 -0
  16. package/dist/commands/cpt/rollback.js +5 -0
  17. package/dist/commands/dev.js +14 -4
  18. package/dist/commands/diff.js +1 -1
  19. package/dist/commands/doctor.d.ts +2 -0
  20. package/dist/commands/doctor.js +57 -2
  21. package/dist/commands/form/push.js +4 -1
  22. package/dist/commands/meta/diff.d.ts +2 -0
  23. package/dist/commands/meta/diff.js +5 -0
  24. package/dist/commands/meta/list.d.ts +19 -0
  25. package/dist/commands/meta/list.js +21 -0
  26. package/dist/commands/meta/pull.d.ts +2 -0
  27. package/dist/commands/meta/pull.js +4 -0
  28. package/dist/commands/meta/push.d.ts +2 -0
  29. package/dist/commands/meta/push.js +4 -0
  30. package/dist/commands/meta/rm.d.ts +2 -0
  31. package/dist/commands/meta/rm.js +3 -0
  32. package/dist/commands/meta/rollback.d.ts +2 -0
  33. package/dist/commands/meta/rollback.js +5 -0
  34. package/dist/commands/option/list.js +2 -8
  35. package/dist/commands/option/push.js +4 -2
  36. package/dist/commands/page/push.js +3 -2
  37. package/dist/commands/pattern/diff.d.ts +2 -0
  38. package/dist/commands/pattern/diff.js +5 -0
  39. package/dist/commands/pattern/list.d.ts +2 -0
  40. package/dist/commands/pattern/list.js +3 -0
  41. package/dist/commands/pattern/pull.d.ts +2 -0
  42. package/dist/commands/pattern/pull.js +4 -0
  43. package/dist/commands/pattern/push.d.ts +2 -0
  44. package/dist/commands/pattern/push.js +4 -0
  45. package/dist/commands/pattern/rm.d.ts +2 -0
  46. package/dist/commands/pattern/rm.js +3 -0
  47. package/dist/commands/pattern/rollback.d.ts +2 -0
  48. package/dist/commands/pattern/rollback.js +5 -0
  49. package/dist/commands/plugin/audit.js +43 -25
  50. package/dist/commands/project/config.d.ts +1 -0
  51. package/dist/commands/project/config.js +20 -1
  52. package/dist/commands/project/list.d.ts +1 -1
  53. package/dist/commands/project/list.js +6 -6
  54. package/dist/commands/project/pull.js +11 -8
  55. package/dist/commands/project/push.js +23 -18
  56. package/dist/commands/project/remove.js +1 -1
  57. package/dist/commands/project/switch.d.ts +0 -1
  58. package/dist/commands/project/switch.js +16 -36
  59. package/dist/commands/promote.d.ts +1 -0
  60. package/dist/commands/promote.js +7 -3
  61. package/dist/commands/pull.d.ts +2 -1
  62. package/dist/commands/pull.js +31 -6
  63. package/dist/commands/push.d.ts +4 -2
  64. package/dist/commands/push.js +51 -19
  65. package/dist/commands/role/diff.d.ts +2 -0
  66. package/dist/commands/role/diff.js +5 -0
  67. package/dist/commands/role/list.d.ts +2 -0
  68. package/dist/commands/role/list.js +4 -0
  69. package/dist/commands/role/pull.d.ts +2 -0
  70. package/dist/commands/role/pull.js +4 -0
  71. package/dist/commands/role/push.d.ts +2 -0
  72. package/dist/commands/role/push.js +4 -0
  73. package/dist/commands/role/rm.d.ts +2 -0
  74. package/dist/commands/role/rm.js +3 -0
  75. package/dist/commands/role/rollback.d.ts +2 -0
  76. package/dist/commands/role/rollback.js +5 -0
  77. package/dist/commands/snippet/push.d.ts +1 -0
  78. package/dist/commands/snippet/push.js +24 -3
  79. package/dist/commands/status.d.ts +2 -3
  80. package/dist/commands/status.js +21 -53
  81. package/dist/commands/taxonomy/diff.d.ts +2 -0
  82. package/dist/commands/taxonomy/diff.js +5 -0
  83. package/dist/commands/taxonomy/list.d.ts +2 -0
  84. package/dist/commands/taxonomy/list.js +5 -0
  85. package/dist/commands/taxonomy/pull.d.ts +2 -0
  86. package/dist/commands/taxonomy/pull.js +4 -0
  87. package/dist/commands/taxonomy/push.d.ts +2 -0
  88. package/dist/commands/taxonomy/push.js +4 -0
  89. package/dist/commands/taxonomy/rm.d.ts +2 -0
  90. package/dist/commands/taxonomy/rm.js +3 -0
  91. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  92. package/dist/commands/taxonomy/rollback.js +5 -0
  93. package/dist/commands/theme/template/push.d.ts +2 -0
  94. package/dist/commands/theme/template/push.js +26 -5
  95. package/dist/commands/theme/version/pull.js +6 -1
  96. package/dist/config/project-config.manager.d.ts +3 -3
  97. package/dist/config/project-config.manager.js +22 -31
  98. package/dist/hooks/finally.js +11 -3
  99. package/dist/lib/api-client.js +2 -1
  100. package/dist/lib/app-build.js +3 -1
  101. package/dist/lib/base.d.ts +2 -2
  102. package/dist/lib/base.js +33 -38
  103. package/dist/lib/declared-type-command.d.ts +35 -0
  104. package/dist/lib/declared-type-command.js +192 -0
  105. package/dist/lib/dev-watch.d.ts +1 -1
  106. package/dist/lib/dev-watch.js +7 -3
  107. package/dist/lib/diff-command.d.ts +2 -0
  108. package/dist/lib/diff-command.js +24 -3
  109. package/dist/lib/github-release.js +4 -0
  110. package/dist/lib/load-snippets.js +4 -0
  111. package/dist/lib/local-callback-server.js +3 -1
  112. package/dist/lib/php-files-command.d.ts +8 -0
  113. package/dist/lib/php-files-command.js +5 -0
  114. package/dist/lib/push-command.d.ts +5 -2
  115. package/dist/lib/push-command.js +96 -15
  116. package/dist/lib/put-or-create.js +3 -4
  117. package/dist/lib/resource-state.js +38 -85
  118. package/dist/lib/rollback-command.js +9 -2
  119. package/dist/lib/rollback-materialize.js +13 -24
  120. package/dist/lib/spinner.d.ts +2 -0
  121. package/dist/lib/spinner.js +72 -0
  122. package/dist/lib/temp-admin.d.ts +10 -1
  123. package/dist/lib/temp-admin.js +27 -2
  124. package/dist/lib/validate-local.js +40 -12
  125. package/dist/lib/wp-client.d.ts +1 -0
  126. package/dist/lib/wp-client.js +21 -2
  127. package/dist/lib/wp-site-diagnostic.js +3 -2
  128. package/dist/types/global-config.generated.d.ts +1 -5
  129. package/dist/types/project-config.generated.d.ts +28 -4
  130. package/dist/types/snippet.d.ts +1 -0
  131. package/dist/utils/composer.js +2 -1
  132. package/dist/utils/declared-type-format.d.ts +52 -0
  133. package/dist/utils/declared-type-format.js +233 -0
  134. package/dist/utils/resource-dirs.d.ts +9 -3
  135. package/dist/utils/resource-dirs.js +16 -4
  136. package/dist/utils/template-format.d.ts +1 -0
  137. package/dist/utils/theme-css.d.ts +19 -0
  138. package/dist/utils/theme-css.js +100 -0
  139. package/oclif.manifest.json +3294 -1213
  140. package/package.json +32 -16
  141. package/schemas/global-config.schema.json +2 -6
  142. package/schemas/project-config.schema.json +37 -7
  143. package/dist/commands/seo/diff.js +0 -5
  144. package/dist/commands/seo/list.d.ts +0 -18
  145. package/dist/commands/seo/list.js +0 -58
  146. package/dist/commands/seo/pull.d.ts +0 -24
  147. package/dist/commands/seo/pull.js +0 -89
  148. package/dist/commands/seo/push.d.ts +0 -29
  149. package/dist/commands/seo/push.js +0 -178
  150. package/dist/commands/seo/rollback.js +0 -5
  151. package/dist/utils/seo-format.d.ts +0 -29
  152. package/dist/utils/seo-format.js +0 -18
  153. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  154. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -47,8 +47,16 @@ export class DiffCommand extends LoopressCommand {
47
47
  // a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
48
48
  // when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
49
49
  async report(targets, sides) {
50
- this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightLabel)}\n`);
50
+ this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
51
51
  const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
52
+ // Every resource failing the same way (site down, bad credentials) is one problem: say it
53
+ // once instead of once per resource.
54
+ const sharedError = sharedFailure(results.map((result) => result.json.error));
55
+ if (sharedError !== undefined) {
56
+ // Named, since some resources may have compared fine (one that needs no request).
57
+ const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
58
+ this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
59
+ }
52
60
  const resources = {};
53
61
  let drift = false;
54
62
  let failed = false;
@@ -60,8 +68,7 @@ export class DiffCommand extends LoopressCommand {
60
68
  resources[result.resource] = result.json;
61
69
  if (result.json.error !== undefined) {
62
70
  failed = true;
63
- const errorLabel = c('red', `error: ${result.json.error}`);
64
- this.out(`${c('bold', result.title)} ${errorLabel}`);
71
+ this.renderFailure(result.title, result.json.error, sharedError);
65
72
  continue;
66
73
  }
67
74
  compared += 1;
@@ -91,6 +98,7 @@ export class DiffCommand extends LoopressCommand {
91
98
  return {
92
99
  againstWp: against ? this.wpForEnvironment(against) : undefined,
93
100
  leftLabel: this.siteConfig.name,
101
+ rightHeading: against ?? 'local files',
94
102
  rightLabel: against ?? 'local',
95
103
  warn: (message) => {
96
104
  this.warn(message);
@@ -120,6 +128,13 @@ export class DiffCommand extends LoopressCommand {
120
128
  if (!this.jsonEnabled())
121
129
  this.log(message);
122
130
  }
131
+ renderFailure(title, error, sharedError) {
132
+ // Already printed once by `report` when every failure is the same.
133
+ if (sharedError !== undefined)
134
+ return;
135
+ const errorLabel = c('red', `error: ${error}`);
136
+ this.out(`${c('bold', title)} ${errorLabel}`);
137
+ }
123
138
  renderSection(title, stateDiff) {
124
139
  if (isEmptyDiff(stateDiff)) {
125
140
  this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
@@ -180,3 +195,9 @@ function indentPatch(patch) {
180
195
  .join('\n')
181
196
  .trimEnd();
182
197
  }
198
+ // The one error every failed resource shares, when more than one failed (see `report`).
199
+ function sharedFailure(errors) {
200
+ const failures = errors.filter((error) => error !== undefined);
201
+ const distinct = new Set(failures);
202
+ return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
203
+ }
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { pipeline } from 'node:stream/promises';
7
+ import { withSpinner } from './spinner.js';
7
8
  const REPO = 'loopress/loopress';
8
9
  const ASSET_NAME = 'loopress-full.zip';
9
10
  /**
@@ -17,6 +18,9 @@ const ASSET_NAME = 'loopress-full.zip';
17
18
  * "the one zip asset".
18
19
  */
19
20
  export async function downloadLatestFullZip() {
21
+ return withSpinner('Downloading Loopress Full', download);
22
+ }
23
+ async function download() {
20
24
  const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
21
25
  headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
22
26
  searchParams: { 'per_page': 30 },
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
26
26
  }
27
27
  let id;
28
28
  let name;
29
+ let description;
29
30
  let type;
30
31
  let isActive = false;
31
32
  let tags = [];
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
40
41
  // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
41
42
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
42
43
  name = meta.name || undefined;
44
+ // Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
45
+ description = meta.description;
43
46
  type = parseType(meta.type) ?? undefined;
44
47
  isActive = Boolean(meta.active);
45
48
  tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
58
61
  return {
59
62
  active: isActive,
60
63
  code: content,
64
+ description,
61
65
  id,
62
66
  insertMethod: insertMethod ?? 'auto',
63
67
  location: location ?? defaultLocationForType(resolvedType),
@@ -2,6 +2,7 @@ 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
+ import { withSpinner } from './spinner.js';
5
6
  /** Constant-time compare that also tolerates a length mismatch without throwing. */
6
7
  function safeEqual(a, b) {
7
8
  const bufferA = Buffer.from(a);
@@ -28,7 +29,7 @@ function safeEqual(a, b) {
28
29
  export async function waitForLocalCallback(options) {
29
30
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
30
31
  const state = randomBytes(32).toString('hex');
31
- return new Promise((resolve, reject) => {
32
+ const callback = new Promise((resolve, reject) => {
32
33
  function finish(res, page) {
33
34
  // `Connection: close` (here and below): server.close() only refuses new connections, a
34
35
  // kept-alive socket would otherwise still reach this handler after the shutdown.
@@ -100,6 +101,7 @@ export async function waitForLocalCallback(options) {
100
101
  openBrowser(targetUrl);
101
102
  });
102
103
  });
104
+ return withSpinner('Waiting for the browser', async () => callback);
103
105
  }
104
106
  /**
105
107
  * The page rewrites its own URL to `/` on load, so the callback query string (an Application
@@ -30,7 +30,15 @@ type RemotePhpFile = {
30
30
  filename: string;
31
31
  public?: boolean;
32
32
  revision?: string;
33
+ runtimeError?: RuntimeError;
33
34
  };
35
+ export type RuntimeError = {
36
+ at: string;
37
+ message: string;
38
+ method: string;
39
+ type: 'action' | 'cron' | 'filter';
40
+ };
41
+ export declare function formatRuntimeError(error: RuntimeError): string;
34
42
  type PushResult = {
35
43
  pruned: string[];
36
44
  pushed: string[];
@@ -14,6 +14,9 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
14
14
  import { PushCommand } from './push-command.js';
15
15
  import { getResourceStateProvider } from './resource-state.js';
16
16
  import { isApplicative404 } from './wp-client.js';
17
+ export function formatRuntimeError(error) {
18
+ return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
19
+ }
17
20
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
18
21
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
19
22
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
@@ -262,6 +265,8 @@ export function resourceListCommand(spec) {
262
265
  for (const file of files) {
263
266
  const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
264
267
  this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
268
+ if (file.runtimeError)
269
+ this.log(` ${formatRuntimeError(file.runtimeError)}`);
265
270
  }
266
271
  if (files.some((file) => file.public)) {
267
272
  this.log('');
@@ -3,18 +3,20 @@ 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
5
  static baseFlags: {
6
+ overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  };
9
10
  protected failedCount: number;
10
11
  private refusedByGuard;
11
- protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
12
+ protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
12
13
  catch(err: Error): Promise<void>;
13
14
  protected guardProductionPush(): Promise<void>;
14
15
  init(): Promise<void>;
15
- protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
16
+ protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
16
17
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
17
18
  protected recordSuccess(): Promise<void>;
19
+ protected reportPushToSite(): Promise<void>;
18
20
  protected reportTaskFailure(message: string, error: unknown, task?: {
19
21
  output: string;
20
22
  }): never;
@@ -22,4 +24,5 @@ export declare abstract class PushCommand extends LoopressCommand {
22
24
  output: string;
23
25
  }) => Promise<void>): Promise<void>;
24
26
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
27
+ private guardOutsideEdits;
25
28
  }
@@ -1,17 +1,22 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  import { Flags } from '@oclif/core';
2
3
  import got from 'got';
3
4
  import { Listr } from 'listr2';
4
5
  import { resolve } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
5
7
  import { authManager } from '../config/auth.manager.js';
6
8
  import { API_URL } from './api-client.js';
7
9
  import { LoopressCommand } from './base.js';
8
10
  import { guardProductionPush } from './guard-production-push.js';
9
- import { writeSnapshot } from './snapshot-store.js';
11
+ import { isInteractive } from './interactive.js';
12
+ import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
13
+ import { withoutSpinner, withSpinner } from './spinner.js';
10
14
  export class PushCommand extends LoopressCommand {
11
15
  // Passed by an aggregate command (`lps theme push`) that already confirmed production once,
12
16
  // so its steps skip that prompt while still asking their own confirmations (uninstalls).
13
17
  static baseFlags = {
14
18
  ...LoopressCommand.baseFlags,
19
+ overwrite: Flags.boolean({ description: 'Push even over changes made on the site outside Loopress since your last push' }),
15
20
  'production-confirmed': Flags.boolean({ hidden: true }),
16
21
  };
17
22
  failedCount = 0;
@@ -25,13 +30,17 @@ export class PushCommand extends LoopressCommand {
25
30
  // `writeAfterPushSnapshot` there is no restore point to complete.
26
31
  //
27
32
  // On a dry run, the same read is the preview's check against the site (see previewRemote).
28
- async captureBeforePushState(provider, dir) {
33
+ //
34
+ // `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
35
+ // skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
36
+ async captureBeforePushState(provider, dir, writes) {
29
37
  if (this.dryRun) {
30
- await this.previewRemote(provider, dir);
38
+ await this.guardOutsideEdits(provider, dir, await this.previewRemote(provider, dir), writes);
31
39
  return undefined;
32
40
  }
41
+ let remote;
33
42
  try {
34
- return await provider.remote(this.wp, (message) => {
43
+ remote = await provider.remote(this.wp, (message) => {
35
44
  this.warn(message);
36
45
  }, dir);
37
46
  }
@@ -39,6 +48,8 @@ export class PushCommand extends LoopressCommand {
39
48
  this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
40
49
  return undefined;
41
50
  }
51
+ await this.guardOutsideEdits(provider, dir, remote, writes);
52
+ return remote;
42
53
  }
43
54
  async catch(err) {
44
55
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
@@ -67,7 +78,7 @@ export class PushCommand extends LoopressCommand {
67
78
  // pre-apply check built on it, see what the real push will hit.
68
79
  async previewRemote(provider, dir) {
69
80
  try {
70
- await provider.remote(this.wp, () => { }, dir);
81
+ return await provider.remote(this.wp, () => { }, dir);
71
82
  }
72
83
  catch (error) {
73
84
  this.error(`The site would refuse this push: ${error.message}`);
@@ -78,19 +89,37 @@ export class PushCommand extends LoopressCommand {
78
89
  if (!token)
79
90
  return;
80
91
  try {
81
- await got.post(`${API_URL}/deployments`, {
92
+ await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
82
93
  headers: { Authorization: `Bearer ${token}` },
83
94
  json: { status, url: this.siteConfig.url },
84
95
  timeout: { request: 3000 },
85
- });
96
+ }));
86
97
  }
87
98
  catch {
88
99
  // non-blocking: recording must never interrupt the push flow
89
100
  }
90
101
  }
91
102
  async recordSuccess() {
92
- if (!this.dryRun)
93
- await this.recordDeployment('success');
103
+ if (this.dryRun)
104
+ return;
105
+ await this.recordDeployment('success');
106
+ await this.reportPushToSite();
107
+ }
108
+ // Tells the plugin this push finished, for its Overview tab and the "managed by Loopress"
109
+ // warnings in wp-admin: one record per command (`this.id`, e.g. `acf:push`), since a single push
110
+ // sends one request per item. Best-effort like recordDeployment: a plugin older than the push
111
+ // log answers 404, one that doesn't know this command 400, neither may fail a push that landed.
112
+ async reportPushToSite() {
113
+ if (!this.id)
114
+ return;
115
+ try {
116
+ // Bounded: the site fingerprints the pushed resource before answering, which a large
117
+ // site can make slow, and a finished push must not wait on its own bookkeeping.
118
+ await this.wp.post('loopress/v1/pushes', { resource: this.id }, { timeoutMs: 5000 });
119
+ }
120
+ catch {
121
+ // non-blocking, see above
122
+ }
94
123
  }
95
124
  // Reports one failed Listr task: status lines go through `task.output` when running inside a
96
125
  // Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
@@ -111,17 +140,19 @@ export class PushCommand extends LoopressCommand {
111
140
  // just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
112
141
  // and reports its own failure through `reportTaskFailure`.
113
142
  async runPushTasks(items, label, task) {
114
- await new Listr(items.map((item) => ({
115
- task: async (_ctx, taskCtx) => task(item, taskCtx),
116
- title: `Push ${label(item)}`,
117
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
143
+ await withoutSpinner(async () => {
144
+ await new Listr(items.map((item) => ({
145
+ task: async (_ctx, taskCtx) => task(item, taskCtx),
146
+ title: `Push ${label(item)}`,
147
+ })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
148
+ });
118
149
  }
119
150
  // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
120
151
  // with a fresh remote read taken right after the push, and writes both as one rollback
121
152
  // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
122
153
  // the local files that were pushed, is what keeps this correct for every resource's own
123
- // partial-push semantics: a readonly option skipped by `option push`, a snippet or SEO
124
- // redirect that gets a server-assigned id on create, a theme-styles push that only touches the
154
+ // partial-push semantics: a readonly option skipped by `option push`, a snippet or form
155
+ // that gets a server-assigned id on create, a theme-styles push that only touches the
125
156
  // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
126
157
  // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
127
158
  // against exactly this after-state, so it has to be what actually landed, not what was sent.
@@ -144,4 +175,54 @@ export class PushCommand extends LoopressCommand {
144
175
  this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
145
176
  }
146
177
  }
178
+ // Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
179
+ // tool) since this machine's last push. Flags an id only when both hold:
180
+ // - the site no longer matches what that push left (the latest snapshot's `afterState`, the
181
+ // same reference rollback's drift check uses), and
182
+ // - this push would write a different value over it (local differs from the site), so a
183
+ // `pull` that already brought the change into the local files is no reason to stop.
184
+ // Only ids present locally count: a push is additive, it never touches a site-only id. And
185
+ // only ids the site still has with readable content: pushing over a deletion (`lps <resource>
186
+ // rm`, or wp-admin) loses nothing, and a file the site lists without its content (too large
187
+ // to send) can't be compared, so it would otherwise block every push.
188
+ // Ceiling: snapshots live in git-ignored `.loopress/`, so only the machine that pushed last
189
+ // is protected; a record kept on the site would cover teammates and CI.
190
+ async guardOutsideEdits(provider, dir, remote, writes) {
191
+ if (this.overwrite)
192
+ return;
193
+ let expected;
194
+ let local;
195
+ let pushedAt;
196
+ try {
197
+ const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
198
+ expected = recordToState(snapshot.afterState);
199
+ pushedAt = snapshot.createdAt;
200
+ local = await provider.local(dir, () => { });
201
+ }
202
+ catch {
203
+ // No earlier push from this machine, or unreadable local files the push reports itself.
204
+ return;
205
+ }
206
+ const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
207
+ remote.get(id) !== undefined &&
208
+ !sameEntry(remote, expected, id) &&
209
+ !sameEntry(local, remote, id));
210
+ if (overwritten.length === 0)
211
+ return;
212
+ const message = `Changed on ${this.siteConfig.url} outside Loopress since your last push (${pushedAt}), this push would overwrite: ${overwritten.join(', ')}. ` +
213
+ `Review with \`lps diff --only ${provider.resource}\`, bring the site's version into your files, or pass --overwrite.`;
214
+ // A dry run only previews: say what the real push would stop on.
215
+ if (this.dryRun) {
216
+ this.warn(message);
217
+ return;
218
+ }
219
+ if (!isInteractive())
220
+ this.error(message);
221
+ this.warn(message);
222
+ if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
223
+ this.error('Aborted.');
224
+ }
225
+ }
226
+ function sameEntry(left, right, id) {
227
+ return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
147
228
  }
@@ -1,8 +1,7 @@
1
1
  import { isNotFoundError } from './wp-client.js';
2
- // Update-or-create dance shared by every id-based resource (form, seo redirect,
3
- // snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
4
- // fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
5
- // database reset.
2
+ // Update-or-create dance shared by every id-based resource (form, snippet, unlike ACF which
3
+ // is key-based and only ever POSTs): PUT to the known id, and only fall back to POST when the
4
+ // site doesn't recognize that id (404), e.g. a fresh install or a database reset.
6
5
  export async function putOrCreate(wp, options) {
7
6
  const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
8
7
  if (id !== null) {
@@ -1,11 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
4
+ import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
4
5
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
5
6
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
6
7
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
7
8
  import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
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
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';
@@ -30,17 +30,15 @@ function omit(object, keys) {
30
30
  const ACF_VOLATILE_KEYS = ['modified'];
31
31
  // A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
32
32
  const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
33
- // A RankMath redirect's `hits` is a live visit counter.
34
- const REDIRECT_VOLATILE_KEYS = ['hits'];
35
33
  // ---- snippets -------------------------------------------------------------------------------
36
- // `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
37
- // push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
38
- // becomes the map key. `code` has its leading `<?php` stripped on both sides: `snippet pull`
39
- // re-adds it to PHP files for editor friendliness, WordPress stores only the body.
34
+ // `description` is dropped: a sidecar without one gets "Imported from <path>" on push, so
35
+ // comparing it would flag every such snippet as drifted. `id` becomes the map key. A PHP
36
+ // snippet's `code` has its leading `<?php` stripped on both sides: `snippet pull` re-adds it
37
+ // for editor friendliness, WordPress stores only the body. Other types keep it, it's content.
40
38
  function canonicalSnippet(snippet) {
41
39
  return {
42
40
  active: snippet.active,
43
- code: stripPhpOpeningTag(snippet.code),
41
+ code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
44
42
  insertMethod: snippet.insertMethod,
45
43
  location: snippet.location,
46
44
  name: snippet.name,
@@ -257,82 +255,6 @@ const partProvider = {
257
255
  resource: 'part',
258
256
  title: 'Template parts',
259
257
  };
260
- // ---- SEO --------------------------------------------------------------------------------
261
- function canonicalRedirect(redirect) {
262
- return omit(redirect, REDIRECT_VOLATILE_KEYS);
263
- }
264
- const seoProvider = {
265
- dirKind: 'seo',
266
- async local(dir, onWarn) {
267
- const state = new Map();
268
- // The provider settings blob (Yoast / RankMath) is compared whole. If a real payload turns
269
- // out to carry install-specific noise (a plugin version, a license hash), add those keys
270
- // to an omit() list here, the e2e round-trip is what will surface them.
271
- const settingsPath = join(dir, 'settings.json');
272
- try {
273
- const settings = readJson(await readFile(settingsPath, 'utf8'));
274
- state.set('settings', settings);
275
- }
276
- catch (error) {
277
- if (error.code !== 'ENOENT') {
278
- onWarn(`Skipping "${settingsPath}": ${error.message}`);
279
- }
280
- }
281
- for (const postType of DEFAULT_POST_TYPES) {
282
- const metaDir = join(dir, 'post-meta', postType);
283
- const entries = await loadFiles(metaDir, {
284
- extension: '.json',
285
- onSkip: onWarn,
286
- parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
287
- });
288
- for (const { file, value } of entries) {
289
- const slug = typeof value.slug === 'string' && value.slug !== '' ? value.slug : `local:${file}`;
290
- state.set(`post-meta/${postType}/${slug}`, value);
291
- }
292
- }
293
- const redirects = await loadFiles(join(dir, 'redirects'), {
294
- extension: '.json',
295
- onSkip: onWarn,
296
- parse: readJson,
297
- });
298
- for (const redirect of redirects) {
299
- const id = Number(redirect.id);
300
- if (Number.isSafeInteger(id) && id > 0)
301
- state.set(`redirects/${id}`, canonicalRedirect(redirect));
302
- }
303
- return state;
304
- },
305
- async remote(wp, onWarn) {
306
- const state = new Map();
307
- // `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
308
- // RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
309
- // configuration, and comparing it here would flag drift whenever the mere act of reading
310
- // changed nothing meaningful.
311
- const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
312
- state.set('settings', settings);
313
- for (const postType of DEFAULT_POST_TYPES) {
314
- const posts = await wp.get(seoPostMetaEndpoint(postType));
315
- for (const { meta, slug, title } of posts)
316
- state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
317
- }
318
- // Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
319
- // as `seo pull`.
320
- try {
321
- const redirects = await wp.get(SEO_REDIRECTS_ENDPOINT);
322
- for (const redirect of redirects) {
323
- state.set(`redirects/${redirect.id}`, canonicalRedirect(redirect));
324
- }
325
- }
326
- catch (error) {
327
- if (!isNotFoundError(error))
328
- throw error;
329
- onWarn(`Skipping redirects: not supported by the active SEO plugin`);
330
- }
331
- return state;
332
- },
333
- resource: 'seo',
334
- title: 'SEO',
335
- };
336
258
  // ---- Menus --------------------------------------------------------------------------------
337
259
  // Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
338
260
  // REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
@@ -382,6 +304,33 @@ const menuProvider = {
382
304
  resource: 'menu',
383
305
  title: 'Menus',
384
306
  };
307
+ // ---- Custom post types, taxonomies, post meta, roles and patterns ---------------------------------------
308
+ // Keyed by slug (the local file name), compared on the bare arguments: `revision` is push
309
+ // bookkeeping, never part of the file.
310
+ function declaredTypeProvider(spec, title) {
311
+ return {
312
+ dirKind: spec.dirKind,
313
+ async local(dir, onWarn) {
314
+ const entries = await loadFiles(dir, {
315
+ extension: fileExtension(spec),
316
+ onSkip: onWarn,
317
+ parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
318
+ });
319
+ return new Map(entries.map(({ args, slug }) => [slug, args]));
320
+ },
321
+ async remote(wp) {
322
+ const items = await wp.get(spec.endpoint);
323
+ return new Map(items.map(({ args, slug }) => [slug, args]));
324
+ },
325
+ resource: spec.cliName,
326
+ title,
327
+ };
328
+ }
329
+ const cptProvider = declaredTypeProvider(CPT_SPEC, 'Post types');
330
+ const taxonomyProvider = declaredTypeProvider(TAXONOMY_SPEC, 'Taxonomies');
331
+ const metaProvider = declaredTypeProvider(META_SPEC, 'Post meta');
332
+ const roleProvider = declaredTypeProvider(ROLE_SPEC, 'Roles');
333
+ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
385
334
  // ---- Options ------------------------------------------------------------------------------
386
335
  // Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
387
336
  // lists names, never values (by design, see OptionsController), so both sides compare exactly
@@ -470,8 +419,12 @@ export const RESOURCE_STATE_PROVIDERS = [
470
419
  templateProvider,
471
420
  partProvider,
472
421
  pageProvider,
473
- seoProvider,
474
422
  menuProvider,
423
+ cptProvider,
424
+ taxonomyProvider,
425
+ metaProvider,
426
+ roleProvider,
427
+ patternProvider,
475
428
  optionsProvider,
476
429
  themeStylesProvider,
477
430
  ];
@@ -126,8 +126,15 @@ export function resourceRollbackCommand(resource, options) {
126
126
  // commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
127
127
  // production guard and the drift confirmation above already covered what the delegated
128
128
  // push's own guard would ask again, same reasoning as the top-level `lps push`
129
- // delegating to each resource's push command in commands/push.ts.
130
- await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
129
+ // delegating to each resource's push command in commands/push.ts. --overwrite: the drift
130
+ // check above is the same question as the push's outside-edit check, already answered.
131
+ await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [
132
+ tmpDir,
133
+ '--env',
134
+ this.siteConfig.name,
135
+ '--yes',
136
+ '--overwrite',
137
+ ]);
131
138
  }
132
139
  finally {
133
140
  // A cleanup failure here must never hide a real error from the push above: warn about
@@ -1,10 +1,10 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { getAcfKey } from '../utils/acf-format.js';
4
+ import { CPT_SPEC, fileExtension, formatDeclaredFile, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
4
5
  import { getFormId, getFormTitle } from '../utils/form-format.js';
5
6
  import { getMenuSlug } from '../utils/menu-format.js';
6
7
  import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
7
- import { redirectFileBase } from '../utils/seo-format.js';
8
8
  import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
9
9
  import { themeStylesFileName } from '../utils/theme-styles-format.js';
10
10
  import { toSlug } from '../utils/to-slug.js';
@@ -51,28 +51,6 @@ async function phpFile(id, value, dir) {
51
51
  await mkdir(dirname(filePath), { recursive: true });
52
52
  await writeFile(filePath, String(value));
53
53
  }
54
- // ids are 'settings', `post-meta/<postType>/<slug>`, or `redirects/<id>` (see resource-state.ts's
55
- // seoProvider). Each value round-trips to the exact file `seo pull` would have written.
56
- async function seo(id, value, dir) {
57
- if (id === 'settings') {
58
- await writeJson(join(dir, 'settings.json'), value);
59
- return;
60
- }
61
- if (id.startsWith('post-meta/')) {
62
- const rest = id.slice('post-meta/'.length);
63
- const separator = rest.indexOf('/');
64
- const postType = rest.slice(0, separator);
65
- const slug = rest.slice(separator + 1);
66
- await writeJson(join(dir, 'post-meta', postType, `${slug}.json`), value);
67
- return;
68
- }
69
- if (id.startsWith('redirects/')) {
70
- const redirect = value;
71
- await writeJson(join(dir, 'redirects', `${redirectFileBase(redirect)}.json`), redirect);
72
- return;
73
- }
74
- throw new Error(`Unrecognized SEO snapshot id "${id}"`);
75
- }
76
54
  // ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
77
55
  async function menu(id, value, dir) {
78
56
  if (id === 'menu-locations') {
@@ -97,15 +75,26 @@ async function option(id, value, dir) {
97
75
  async function themeStyles(id, value, dir) {
98
76
  await writeJson(join(dir, themeStylesFileName(id)), value);
99
77
  }
78
+ // id is the slug, value the bare register_*() arguments (resource-state.ts's declaredTypeProvider),
79
+ // written in the spec's file format like `<resource> pull` does.
80
+ function declaredType(spec) {
81
+ return async (id, value, dir) => {
82
+ await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, value));
83
+ };
84
+ }
100
85
  const MATERIALIZERS = {
101
86
  acf,
102
87
  api: phpFile,
88
+ cpt: declaredType(CPT_SPEC),
103
89
  form,
104
90
  hook: phpFile,
105
91
  menu,
92
+ meta: declaredType(META_SPEC),
106
93
  option,
107
- seo,
94
+ pattern: declaredType(PATTERN_SPEC),
95
+ role: declaredType(ROLE_SPEC),
108
96
  snippet,
97
+ taxonomy: declaredType(TAXONOMY_SPEC),
109
98
  'theme-styles': themeStyles,
110
99
  };
111
100
  // Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
@@ -0,0 +1,2 @@
1
+ export declare function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T>;
2
+ export declare function withoutSpinner<T>(work: () => Promise<T>): Promise<T>;