@loopress/cli 0.24.1 → 0.26.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 (106) hide show
  1. package/README.md +721 -90
  2. package/dist/commands/acf/push.d.ts +1 -0
  3. package/dist/commands/acf/push.js +25 -2
  4. package/dist/commands/acf/rollback.d.ts +2 -0
  5. package/dist/commands/acf/rollback.js +5 -0
  6. package/dist/commands/api/rollback.d.ts +2 -0
  7. package/dist/commands/api/rollback.js +5 -0
  8. package/dist/commands/diff.js +1 -1
  9. package/dist/commands/doctor.d.ts +17 -1
  10. package/dist/commands/doctor.js +55 -38
  11. package/dist/commands/form/pull.js +4 -1
  12. package/dist/commands/form/push.d.ts +1 -0
  13. package/dist/commands/form/push.js +29 -3
  14. package/dist/commands/form/rollback.d.ts +2 -0
  15. package/dist/commands/form/rollback.js +5 -0
  16. package/dist/commands/hook/rollback.d.ts +2 -0
  17. package/dist/commands/hook/rollback.js +5 -0
  18. package/dist/commands/init.js +2 -0
  19. package/dist/commands/menu/diff.d.ts +2 -0
  20. package/dist/commands/menu/diff.js +5 -0
  21. package/dist/commands/menu/list.d.ts +13 -0
  22. package/dist/commands/menu/list.js +32 -0
  23. package/dist/commands/menu/pull.d.ts +15 -0
  24. package/dist/commands/menu/pull.js +72 -0
  25. package/dist/commands/menu/push.d.ts +17 -0
  26. package/dist/commands/menu/push.js +120 -0
  27. package/dist/commands/menu/rollback.d.ts +2 -0
  28. package/dist/commands/menu/rollback.js +5 -0
  29. package/dist/commands/option/add.js +1 -1
  30. package/dist/commands/option/push.d.ts +1 -0
  31. package/dist/commands/option/push.js +26 -1
  32. package/dist/commands/option/rollback.d.ts +2 -0
  33. package/dist/commands/option/rollback.js +5 -0
  34. package/dist/commands/page/diff.d.ts +2 -0
  35. package/dist/commands/page/diff.js +5 -0
  36. package/dist/commands/page/list.d.ts +8 -0
  37. package/dist/commands/page/list.js +22 -0
  38. package/dist/commands/page/push.d.ts +20 -0
  39. package/dist/commands/page/push.js +60 -0
  40. package/dist/commands/plugin/push.d.ts +1 -0
  41. package/dist/commands/plugin/push.js +14 -0
  42. package/dist/commands/promote.d.ts +8 -1
  43. package/dist/commands/promote.js +2 -0
  44. package/dist/commands/pull.d.ts +11 -1
  45. package/dist/commands/pull.js +9 -2
  46. package/dist/commands/push.d.ts +11 -1
  47. package/dist/commands/push.js +10 -2
  48. package/dist/commands/seo/pull.js +6 -2
  49. package/dist/commands/seo/push.d.ts +1 -0
  50. package/dist/commands/seo/push.js +33 -3
  51. package/dist/commands/seo/rollback.d.ts +2 -0
  52. package/dist/commands/seo/rollback.js +5 -0
  53. package/dist/commands/snippet/push.d.ts +1 -0
  54. package/dist/commands/snippet/push.js +31 -1
  55. package/dist/commands/snippet/rollback.d.ts +2 -0
  56. package/dist/commands/snippet/rollback.js +5 -0
  57. package/dist/commands/theme-styles/diff.d.ts +2 -0
  58. package/dist/commands/theme-styles/diff.js +5 -0
  59. package/dist/commands/theme-styles/pull.d.ts +12 -0
  60. package/dist/commands/theme-styles/pull.js +31 -0
  61. package/dist/commands/theme-styles/push.d.ts +12 -0
  62. package/dist/commands/theme-styles/push.js +45 -0
  63. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  64. package/dist/commands/theme-styles/rollback.js +5 -0
  65. package/dist/lib/app-manifest.js +4 -27
  66. package/dist/lib/base.d.ts +2 -0
  67. package/dist/lib/base.js +6 -0
  68. package/dist/lib/dev-watch.d.ts +1 -1
  69. package/dist/lib/dev-watch.js +2 -1
  70. package/dist/lib/find-orphaned-files.js +5 -47
  71. package/dist/lib/load-files.js +4 -42
  72. package/dist/lib/php-files-command.d.ts +1 -0
  73. package/dist/lib/php-files-command.js +84 -47
  74. package/dist/lib/push-command.d.ts +4 -0
  75. package/dist/lib/push-command.js +49 -0
  76. package/dist/lib/readdir-tolerant.d.ts +1 -0
  77. package/dist/lib/readdir-tolerant.js +12 -0
  78. package/dist/lib/resource-state.js +116 -3
  79. package/dist/lib/rollback-command.d.ts +21 -0
  80. package/dist/lib/rollback-command.js +180 -0
  81. package/dist/lib/rollback-materialize.d.ts +1 -0
  82. package/dist/lib/rollback-materialize.js +121 -0
  83. package/dist/lib/snapshot-store.d.ts +30 -0
  84. package/dist/lib/snapshot-store.js +133 -0
  85. package/dist/lib/validate-local.js +7 -1
  86. package/dist/types/project-config.generated.d.ts +12 -0
  87. package/dist/utils/acf-format.d.ts +1 -0
  88. package/dist/utils/acf-format.js +6 -0
  89. package/dist/utils/form-format.d.ts +4 -0
  90. package/dist/utils/form-format.js +3 -7
  91. package/dist/utils/menu-format.d.ts +24 -0
  92. package/dist/utils/menu-format.js +8 -0
  93. package/dist/utils/option-format.d.ts +1 -0
  94. package/dist/utils/page-format.d.ts +27 -0
  95. package/dist/utils/page-format.js +111 -0
  96. package/dist/utils/resource-dirs.d.ts +3 -0
  97. package/dist/utils/resource-dirs.js +6 -0
  98. package/dist/utils/seo-format.d.ts +8 -0
  99. package/dist/utils/seo-format.js +3 -0
  100. package/dist/utils/snippet-format.d.ts +1 -0
  101. package/dist/utils/snippet-format.js +1 -0
  102. package/dist/utils/theme-styles-format.d.ts +26 -0
  103. package/dist/utils/theme-styles-format.js +55 -0
  104. package/oclif.manifest.json +1633 -168
  105. package/package.json +4 -2
  106. package/schemas/project-config.schema.json +15 -0
@@ -1,5 +1,6 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Args, Flags } from '@oclif/core';
3
+ import { createHash } from 'node:crypto';
3
4
  import { mkdir, writeFile } from 'node:fs/promises';
4
5
  import { dirname, join, relative, sep } from 'node:path';
5
6
  import { authManager } from '../config/auth.manager.js';
@@ -11,12 +12,26 @@ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
11
12
  import { isInteractive } from './interactive.js';
12
13
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
13
14
  import { PushCommand } from './push-command.js';
15
+ import { getResourceStateProvider } from './resource-state.js';
14
16
  import { isApplicative404 } from './wp-client.js';
15
17
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
16
18
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
17
19
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
18
20
  // presence, so a file that would still fail server-side doesn't falsely pass this check.
19
21
  const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
22
+ // A file's expected revision (#234), computed from `beforeState`: the remote listing already
23
+ // read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
24
+ // for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
25
+ // resource-state.ts), the same string AbstractFilesController::push_file() hashes server-side to
26
+ // produce the `revision` it returns, so hashing it here the same way (sha256, hex) reproduces
27
+ // that server revision with no extra network round trip and no need for the server to echo a
28
+ // `revision` field back through the list endpoint for the CLI to consume. A filename absent from
29
+ // `beforeState` (a new file, or `beforeState` itself unavailable, e.g. a dry run or a failed
30
+ // remote read) has nothing to condition on: pushFile falls back to today's unconditional create.
31
+ function expectedRevisionFor(beforeState, filename) {
32
+ const content = beforeState?.get(filename);
33
+ return typeof content === 'string' ? createHash('sha256').update(content, 'utf8').digest('hex') : undefined;
34
+ }
20
35
  function pathArg(pathNoun) {
21
36
  return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
22
37
  }
@@ -57,25 +72,34 @@ export function resourcePushCommand(spec) {
57
72
  const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
58
73
  this.log(`Pushing ${spec.label} to ${url}`);
59
74
  this.log(`${spec.pathLabel} path: ${path}`);
75
+ const provider = getResourceStateProvider(spec.cliName);
76
+ const beforeState = await this.captureBeforePushState(provider, path);
60
77
  const files = await this.loadFiles(path);
61
78
  this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
62
- const pushed = [];
63
- const publicRoutes = [];
79
+ // Local, network-free checks only (filename shape, the declare(strict_types=1) guard):
80
+ // catches a bad file before anything is staged on WordPress, same as before #236. The
81
+ // actual write is no longer per-file here, it's the single atomic batch request below.
64
82
  await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
65
- const result = await this.pushFile(file, task);
66
- pushed.push(file.filename);
67
- if (result?.public)
68
- publicRoutes.push(file.filename);
83
+ this.validateFileLocally(file, task);
69
84
  });
70
85
  if (this.failedCount > 0) {
71
86
  this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
72
87
  }
73
- for (const route of publicRoutes) {
74
- this.warn(`${route}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
88
+ // Resolved before the batch request: which files to prune is a local decision (a GET plus
89
+ // confirmation), but their removal itself happens inside the same atomic swap as the push
90
+ // below, not as separate DELETE calls, so a mid-push failure can't remove server-side
91
+ // files without also landing the new ones.
92
+ const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
93
+ if (this.dryRun) {
94
+ for (const file of files)
95
+ this.log(`[dry-run] Would push: ${file.filename}`);
96
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
97
+ return { pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
75
98
  }
76
- const pruned = flags.prune ? await this.prune(new Set(files.map((file) => file.filename))) : [];
77
- if (this.dryRun)
78
- return { pruned, pushed, status: 'dry-run' };
99
+ const { pruned, pushed } = files.length === 0 && pruneList.length === 0
100
+ ? { pruned: [], pushed: [] }
101
+ : await this.pushBatch(files, pruneList, beforeState);
102
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
79
103
  await this.recordSuccess();
80
104
  await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
81
105
  this.log(`All ${spec.label} pushed.`);
@@ -84,12 +108,52 @@ export function resourcePushCommand(spec) {
84
108
  async loadFiles(path) {
85
109
  return loadPhpFiles(this, path);
86
110
  }
111
+ // One request stages every file and every pruned filename, then swaps them all into place
112
+ // together (#236): a failure anywhere in the batch (a stale expectedRevision, a class
113
+ // collision, a syntax error) leaves WordPress completely unchanged, not partially pushed.
114
+ async pushBatch(files, pruneList, beforeState) {
115
+ const body = {
116
+ files: files.map((file) => {
117
+ const entry = { content: file.content, filename: file.filename };
118
+ // `beforeState` was read once, at the start of this push run (see `expectedRevisionFor`
119
+ // above): there is no per-file GET endpoint to re-read it right before this batch the
120
+ // way `option push` does. Sent back as `expectedRevision`, WordPress still refuses the
121
+ // whole batch (412) if something changed the file in between, instead of silently
122
+ // overwriting it (#234); the vulnerable window is this run's own duration rather than a
123
+ // single request's, wider than option's but still far smaller than not checking at all.
124
+ const expectedRevision = expectedRevisionFor(beforeState, file.filename);
125
+ if (expectedRevision !== undefined)
126
+ entry.expectedRevision = expectedRevision;
127
+ return entry;
128
+ }),
129
+ prune: pruneList,
130
+ };
131
+ let result;
132
+ try {
133
+ result = await this.wp.post(`${spec.endpoint}/batch`, body);
134
+ }
135
+ catch (error) {
136
+ this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error.message}`);
137
+ }
138
+ for (const file of result.files) {
139
+ this.log(file.syntax_check === 'skipped'
140
+ ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
141
+ : `Pushed: ${file.filename}`);
142
+ if (file.public) {
143
+ this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
144
+ }
145
+ }
146
+ for (const filename of result.pruned)
147
+ this.log(`Pruned: ${filename}`);
148
+ return { pruned: result.pruned, pushed: result.files.map((file) => file.filename) };
149
+ }
87
150
  // Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
88
- // no local counterpart is deleted. Opt-in (`--prune`) because it removes code from a live
89
- // site. In a non-TTY without `--yes` it refuses rather than prunes: a stray local path in
90
- // CI must not silently wipe production routes, and unlike `pull`'s local deletions these
91
- // are not recoverable from the repo.
92
- async prune(localFilenames) {
151
+ // no local counterpart is a candidate for removal. Opt-in (`--prune`) because it removes
152
+ // code from a live site. In a non-TTY without `--yes` it refuses rather than prunes: a stray
153
+ // local path in CI must not silently wipe production routes, and unlike `pull`'s local
154
+ // deletions these are not recoverable from the repo. Only decides *which* filenames to
155
+ // prune; pushBatch() is what actually removes them, atomically alongside the push.
156
+ async resolvePruneList(localFilenames) {
93
157
  const remote = await this.wp.get(spec.endpoint);
94
158
  const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
95
159
  if (orphans.length === 0)
@@ -110,17 +174,9 @@ export function resourcePushCommand(spec) {
110
174
  return [];
111
175
  }
112
176
  }
113
- // Sequential, like runPushTasks: keeps the "Pruned: …" lines ordered and the WordPress
114
- // writes serial.
115
- const deleted = [];
116
- for (const filename of orphans) {
117
- await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
118
- this.log(`Pruned: ${filename}`);
119
- deleted.push(filename);
120
- }
121
- return deleted;
177
+ return orphans;
122
178
  }
123
- async pushFile(file, task) {
179
+ validateFileLocally(file, task) {
124
180
  if (!spec.filenamePattern.test(file.filename)) {
125
181
  const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
126
182
  this.reportTaskFailure(message, new Error(message), task);
@@ -131,27 +187,8 @@ export function resourcePushCommand(spec) {
131
187
  const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
132
188
  this.reportTaskFailure(message, new Error(message), task);
133
189
  }
134
- if (this.dryRun) {
135
- if (task)
136
- task.output = `[dry-run] Would push: ${file.filename}`;
137
- return undefined;
138
- }
139
- try {
140
- const result = await this.wp.put(spec.endpoint, {
141
- content: file.content,
142
- filename: file.filename,
143
- });
144
- if (task) {
145
- task.output =
146
- result.syntax_check === 'skipped'
147
- ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
148
- : `Pushed: ${file.filename}`;
149
- }
150
- return result;
151
- }
152
- catch (error) {
153
- this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
154
- }
190
+ if (task)
191
+ task.output = `Validated: ${file.filename}`;
155
192
  }
156
193
  }
157
194
  return ResourcePush;
@@ -1,7 +1,10 @@
1
1
  import { LoopressCommand } from './base.js';
2
+ import { type ResourceState } from './diff-state.js';
3
+ import { type ResourceStateProvider } from './resource-state.js';
2
4
  export declare abstract class PushCommand extends LoopressCommand {
3
5
  protected failedCount: number;
4
6
  private refusedByGuard;
7
+ protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
5
8
  catch(err: Error): Promise<void>;
6
9
  protected guardProductionPush(): Promise<void>;
7
10
  init(): Promise<void>;
@@ -13,4 +16,5 @@ export declare abstract class PushCommand extends LoopressCommand {
13
16
  protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
14
17
  output: string;
15
18
  }) => Promise<void>): Promise<void>;
19
+ protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
16
20
  }
@@ -1,14 +1,35 @@
1
1
  import got from 'got';
2
2
  import { Listr } from 'listr2';
3
+ import { resolve } from 'node:path';
3
4
  import { authManager } from '../config/auth.manager.js';
4
5
  import { API_URL } from './api-client.js';
5
6
  import { LoopressCommand } from './base.js';
6
7
  import { guardProductionPush } from './guard-production-push.js';
8
+ import { writeSnapshot } from './snapshot-store.js';
7
9
  export class PushCommand extends LoopressCommand {
8
10
  failedCount = 0;
9
11
  // Refusals of the production guard never reach the site, so they must not pollute the
10
12
  // deployment history with failure records.
11
13
  refusedByGuard = false;
14
+ // 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.
20
+ async captureBeforePushState(provider, dir) {
21
+ if (this.dryRun)
22
+ return undefined;
23
+ try {
24
+ return await provider.remote(this.wp, (message) => {
25
+ this.warn(message);
26
+ }, dir);
27
+ }
28
+ catch (error) {
29
+ this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
30
+ return undefined;
31
+ }
32
+ }
12
33
  async catch(err) {
13
34
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
14
35
  await this.recordDeployment('failure');
@@ -73,4 +94,32 @@ export class PushCommand extends LoopressCommand {
73
94
  title: `Push ${label(item)}`,
74
95
  })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
75
96
  }
97
+ // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
98
+ // with a fresh remote read taken right after the push, and writes both as one rollback
99
+ // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
100
+ // the local files that were pushed, is what keeps this correct for every resource's own
101
+ // partial-push semantics: a readonly option skipped by `option push`, a snippet or SEO
102
+ // redirect that gets a server-assigned id on create, a theme-styles push that only touches the
103
+ // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
104
+ // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
105
+ // against exactly this after-state, so it has to be what actually landed, not what was sent.
106
+ async writeAfterPushSnapshot(provider, dir, beforeState) {
107
+ if (!beforeState || this.dryRun)
108
+ return;
109
+ try {
110
+ const afterState = await provider.remote(this.wp, (message) => {
111
+ this.warn(message);
112
+ }, dir);
113
+ await writeSnapshot({
114
+ afterState,
115
+ beforeState,
116
+ environment: this.siteConfig.name,
117
+ resource: provider.resource,
118
+ rootDir: resolve(process.cwd(), this.rootDir),
119
+ });
120
+ }
121
+ catch (error) {
122
+ this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
123
+ }
124
+ }
76
125
  }
@@ -4,3 +4,4 @@ export declare function readdirTolerant(dir: string, options: {
4
4
  recursive?: boolean;
5
5
  withFileTypes: true;
6
6
  }): Promise<Dirent[]>;
7
+ export declare function walkFiles(dir: string): Promise<string[]>;
@@ -1,4 +1,5 @@
1
1
  import { readdir } from 'node:fs/promises';
2
+ import { join, relative, sep } from 'node:path';
2
3
  export async function readdirTolerant(dir, options) {
3
4
  try {
4
5
  return options?.withFileTypes
@@ -11,3 +12,14 @@ export async function readdirTolerant(dir, options) {
11
12
  throw error;
12
13
  }
13
14
  }
15
+ // Every file under `dir`, at any depth, as a path relative to `dir`. Always '/'-joined,
16
+ // never the OS separator: callers match these identities against server-sent filenames
17
+ // (which are always '/'-joined) or embed them in an API payload, so a Windows run must
18
+ // produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
19
+ // same tolerance as readdirTolerant.
20
+ export async function walkFiles(dir) {
21
+ const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
22
+ return entries
23
+ .filter((entry) => entry.isFile())
24
+ .map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join('/'));
25
+ }
@@ -2,9 +2,12 @@ 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
4
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
5
+ import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
5
6
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
7
+ import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
6
8
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
7
9
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
10
+ import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
8
11
  import { loadFiles } from './load-files.js';
9
12
  import { loadSnippets } from './load-snippets.js';
10
13
  import { isApplicative404, isNotFoundError } from './wp-client.js';
@@ -192,6 +195,28 @@ const hookProvider = phpFilesProvider({
192
195
  resource: 'hook',
193
196
  title: 'Hooks',
194
197
  });
198
+ // ---- Pages ------------------------------------------------------------------------------
199
+ // Keyed by slug (the page's identity on both sides). `link` is server-derived and never
200
+ // compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
201
+ // than hiding that page, the same stop-everything stance as `page push`.
202
+ function canonicalPage(page) {
203
+ return { html: page.html, status: page.status, title: page.title };
204
+ }
205
+ const pageProvider = {
206
+ dirKind: 'page',
207
+ async local(dir) {
208
+ const { pages, problems } = await readLocalPages(dir);
209
+ if (problems.length > 0)
210
+ throw new Error(formatPageProblems(problems));
211
+ return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
212
+ },
213
+ async remote(wp) {
214
+ const pages = await wp.get(PAGES_ENDPOINT);
215
+ return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
216
+ },
217
+ resource: 'page',
218
+ title: 'Pages',
219
+ };
195
220
  // ---- SEO --------------------------------------------------------------------------------
196
221
  function canonicalRedirect(redirect) {
197
222
  return omit(redirect, REDIRECT_VOLATILE_KEYS);
@@ -239,12 +264,16 @@ const seoProvider = {
239
264
  },
240
265
  async remote(wp, onWarn) {
241
266
  const state = new Map();
242
- const settings = await wp.get(SEO_SETTINGS_ENDPOINT);
267
+ // `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
268
+ // RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
269
+ // configuration, and comparing it here would flag drift whenever the mere act of reading
270
+ // changed nothing meaningful.
271
+ const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
243
272
  state.set('settings', settings);
244
273
  for (const postType of DEFAULT_POST_TYPES) {
245
274
  const posts = await wp.get(seoPostMetaEndpoint(postType));
246
- for (const post of posts)
247
- state.set(`post-meta/${postType}/${post.slug}`, post);
275
+ for (const { meta, slug, title } of posts)
276
+ state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
248
277
  }
249
278
  // Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
250
279
  // as `seo pull`.
@@ -264,6 +293,55 @@ const seoProvider = {
264
293
  resource: 'seo',
265
294
  title: 'SEO',
266
295
  };
296
+ // ---- Menus --------------------------------------------------------------------------------
297
+ // Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
298
+ // REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
299
+ // portable and compared as-is. `warnings` is diagnostic (a dangling item, an off-environment
300
+ // custom URL, ...), not tracked configuration, so it's dropped before comparing, the same
301
+ // "volatile key" treatment ACF/forms/redirects give their own server-computed fields. `revision`
302
+ // (#234) is bookkeeping for menu push's conditional-write precondition, not tracked configuration
303
+ // either: it's a pure hash of name + items, so it never disagrees when they don't, but a local
304
+ // file pulled before this field existed simply wouldn't have it, which must never show up as
305
+ // drift on its own.
306
+ const MENU_VOLATILE_KEYS = ['revision', 'warnings'];
307
+ // The reserved local filename for menu locations, see commands/menu/pull.ts.
308
+ const MENU_LOCATIONS_FILE = 'menu-locations';
309
+ function canonicalMenu(menu) {
310
+ return omit(menu, MENU_VOLATILE_KEYS);
311
+ }
312
+ const menuProvider = {
313
+ dirKind: 'menu',
314
+ async local(dir, onWarn) {
315
+ const state = new Map();
316
+ const entries = await loadFiles(dir, {
317
+ extension: '.json',
318
+ onSkip: onWarn,
319
+ parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
320
+ });
321
+ for (const { file, value } of entries) {
322
+ if (file === MENU_LOCATIONS_FILE) {
323
+ state.set(MENU_LOCATIONS_FILE, value);
324
+ continue;
325
+ }
326
+ const slug = getMenuSlug(value) ?? `local:${file}`;
327
+ state.set(`menu/${slug}`, canonicalMenu(value));
328
+ }
329
+ return state;
330
+ },
331
+ async remote(wp) {
332
+ const state = new Map();
333
+ const menus = await wp.get(MENU_ENDPOINT);
334
+ for (const menu of menus) {
335
+ const slug = getMenuSlug(menu);
336
+ if (slug !== null)
337
+ state.set(`menu/${slug}`, canonicalMenu(menu));
338
+ }
339
+ state.set(MENU_LOCATIONS_FILE, await wp.get(MENU_LOCATIONS_ENDPOINT));
340
+ return state;
341
+ },
342
+ resource: 'menu',
343
+ title: 'Menus',
344
+ };
267
345
  // ---- Options ------------------------------------------------------------------------------
268
346
  // Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
269
347
  // lists names, never values (by design, see OptionsController), so both sides compare exactly
@@ -310,14 +388,49 @@ const optionsProvider = {
310
388
  resource: 'option',
311
389
  title: 'Options',
312
390
  };
391
+ // ---- Theme styles (Global Styles) ----------------------------------------------------------
392
+ // Local files are named `<stylesheet>-global-styles.json`, keyed by stylesheet so a directory
393
+ // that has accumulated files from more than one previously-active theme still compares each one
394
+ // individually rather than colliding on a single key.
395
+ const themeStylesProvider = {
396
+ dirKind: 'themeStyles',
397
+ async local(dir, onWarn) {
398
+ const entries = await loadFiles(dir, {
399
+ extension: '.json',
400
+ onSkip: onWarn,
401
+ parse(raw, filePath) {
402
+ const name = basename(filePath);
403
+ const stylesheet = name.endsWith(THEME_STYLES_FILE_SUFFIX) ? name.slice(0, -THEME_STYLES_FILE_SUFFIX.length) : basename(filePath, '.json');
404
+ return { stylesheet, value: readJson(raw) };
405
+ },
406
+ });
407
+ const state = new Map();
408
+ for (const { stylesheet, value } of entries)
409
+ state.set(stylesheet, canonicalGlobalStyles(value));
410
+ return state;
411
+ },
412
+ // Only the active theme's Global Styles are compared (see theme-styles-format.ts): a classic
413
+ // active theme has no Styles screen at all, surfaced here as a normal provider error, the same
414
+ // "inconclusive for this resource" treatment DiffCommand gives any other fetch failure.
415
+ async remote(wp) {
416
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(wp);
417
+ const item = await wp.get(globalStylesEndpoint(id));
418
+ return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
419
+ },
420
+ resource: 'theme-styles',
421
+ title: 'Theme styles',
422
+ };
313
423
  export const RESOURCE_STATE_PROVIDERS = [
314
424
  snippetProvider,
315
425
  formProvider,
316
426
  acfProvider,
317
427
  apiProvider,
318
428
  hookProvider,
429
+ pageProvider,
319
430
  seoProvider,
431
+ menuProvider,
320
432
  optionsProvider,
433
+ themeStylesProvider,
321
434
  ];
322
435
  export function getResourceStateProvider(resource) {
323
436
  const provider = RESOURCE_STATE_PROVIDERS.find((candidate) => candidate.resource === resource);
@@ -0,0 +1,21 @@
1
+ import { LoopressCommand } from './base.js';
2
+ import { type SnapshotSummary } from './snapshot-store.js';
3
+ export type RollbackResult = {
4
+ drift?: {
5
+ added: string[];
6
+ changed: string[];
7
+ removed: string[];
8
+ };
9
+ id?: string;
10
+ restored?: {
11
+ added: string[];
12
+ changed: string[];
13
+ removed: string[];
14
+ };
15
+ snapshots?: SnapshotSummary[];
16
+ status: 'dry-run' | 'listed' | 'no-op' | 'success';
17
+ };
18
+ export declare function resourceRollbackCommand(resource: string, options: {
19
+ description: string;
20
+ pathNoun: string;
21
+ }): typeof LoopressCommand;