@loopress/cli 0.24.1 → 0.25.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 (96) hide show
  1. package/README.md +629 -88
  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 +1 -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/plugin/push.d.ts +1 -0
  35. package/dist/commands/plugin/push.js +14 -0
  36. package/dist/commands/promote.d.ts +8 -1
  37. package/dist/commands/promote.js +2 -0
  38. package/dist/commands/pull.d.ts +11 -1
  39. package/dist/commands/pull.js +9 -2
  40. package/dist/commands/push.d.ts +11 -1
  41. package/dist/commands/push.js +9 -2
  42. package/dist/commands/seo/pull.js +6 -2
  43. package/dist/commands/seo/push.d.ts +1 -0
  44. package/dist/commands/seo/push.js +33 -3
  45. package/dist/commands/seo/rollback.d.ts +2 -0
  46. package/dist/commands/seo/rollback.js +5 -0
  47. package/dist/commands/snippet/push.d.ts +1 -0
  48. package/dist/commands/snippet/push.js +31 -1
  49. package/dist/commands/snippet/rollback.d.ts +2 -0
  50. package/dist/commands/snippet/rollback.js +5 -0
  51. package/dist/commands/theme-styles/diff.d.ts +2 -0
  52. package/dist/commands/theme-styles/diff.js +5 -0
  53. package/dist/commands/theme-styles/pull.d.ts +12 -0
  54. package/dist/commands/theme-styles/pull.js +31 -0
  55. package/dist/commands/theme-styles/push.d.ts +12 -0
  56. package/dist/commands/theme-styles/push.js +45 -0
  57. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  58. package/dist/commands/theme-styles/rollback.js +5 -0
  59. package/dist/lib/app-manifest.js +4 -27
  60. package/dist/lib/base.d.ts +2 -0
  61. package/dist/lib/base.js +6 -0
  62. package/dist/lib/find-orphaned-files.js +5 -47
  63. package/dist/lib/load-files.js +4 -42
  64. package/dist/lib/php-files-command.d.ts +1 -0
  65. package/dist/lib/php-files-command.js +84 -47
  66. package/dist/lib/push-command.d.ts +4 -0
  67. package/dist/lib/push-command.js +49 -0
  68. package/dist/lib/readdir-tolerant.d.ts +1 -0
  69. package/dist/lib/readdir-tolerant.js +12 -0
  70. package/dist/lib/resource-state.js +92 -3
  71. package/dist/lib/rollback-command.d.ts +21 -0
  72. package/dist/lib/rollback-command.js +180 -0
  73. package/dist/lib/rollback-materialize.d.ts +1 -0
  74. package/dist/lib/rollback-materialize.js +121 -0
  75. package/dist/lib/snapshot-store.d.ts +30 -0
  76. package/dist/lib/snapshot-store.js +133 -0
  77. package/dist/lib/validate-local.js +2 -1
  78. package/dist/types/project-config.generated.d.ts +8 -0
  79. package/dist/utils/acf-format.d.ts +1 -0
  80. package/dist/utils/acf-format.js +6 -0
  81. package/dist/utils/form-format.d.ts +4 -0
  82. package/dist/utils/form-format.js +3 -7
  83. package/dist/utils/menu-format.d.ts +24 -0
  84. package/dist/utils/menu-format.js +8 -0
  85. package/dist/utils/option-format.d.ts +1 -0
  86. package/dist/utils/resource-dirs.d.ts +2 -0
  87. package/dist/utils/resource-dirs.js +4 -0
  88. package/dist/utils/seo-format.d.ts +8 -0
  89. package/dist/utils/seo-format.js +3 -0
  90. package/dist/utils/snippet-format.d.ts +1 -0
  91. package/dist/utils/snippet-format.js +1 -0
  92. package/dist/utils/theme-styles-format.d.ts +26 -0
  93. package/dist/utils/theme-styles-format.js +55 -0
  94. package/oclif.manifest.json +1736 -465
  95. package/package.json +4 -2
  96. package/schemas/project-config.schema.json +10 -0
@@ -2,9 +2,11 @@ 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';
6
7
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
7
8
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
9
+ import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
8
10
  import { loadFiles } from './load-files.js';
9
11
  import { loadSnippets } from './load-snippets.js';
10
12
  import { isApplicative404, isNotFoundError } from './wp-client.js';
@@ -239,12 +241,16 @@ const seoProvider = {
239
241
  },
240
242
  async remote(wp, onWarn) {
241
243
  const state = new Map();
242
- const settings = await wp.get(SEO_SETTINGS_ENDPOINT);
244
+ // `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
245
+ // RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
246
+ // configuration, and comparing it here would flag drift whenever the mere act of reading
247
+ // changed nothing meaningful.
248
+ const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
243
249
  state.set('settings', settings);
244
250
  for (const postType of DEFAULT_POST_TYPES) {
245
251
  const posts = await wp.get(seoPostMetaEndpoint(postType));
246
- for (const post of posts)
247
- state.set(`post-meta/${postType}/${post.slug}`, post);
252
+ for (const { meta, slug, title } of posts)
253
+ state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
248
254
  }
249
255
  // Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
250
256
  // as `seo pull`.
@@ -264,6 +270,55 @@ const seoProvider = {
264
270
  resource: 'seo',
265
271
  title: 'SEO',
266
272
  };
273
+ // ---- Menus --------------------------------------------------------------------------------
274
+ // Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
275
+ // REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
276
+ // portable and compared as-is. `warnings` is diagnostic (a dangling item, an off-environment
277
+ // custom URL, ...), not tracked configuration, so it's dropped before comparing, the same
278
+ // "volatile key" treatment ACF/forms/redirects give their own server-computed fields. `revision`
279
+ // (#234) is bookkeeping for menu push's conditional-write precondition, not tracked configuration
280
+ // either: it's a pure hash of name + items, so it never disagrees when they don't, but a local
281
+ // file pulled before this field existed simply wouldn't have it, which must never show up as
282
+ // drift on its own.
283
+ const MENU_VOLATILE_KEYS = ['revision', 'warnings'];
284
+ // The reserved local filename for menu locations, see commands/menu/pull.ts.
285
+ const MENU_LOCATIONS_FILE = 'menu-locations';
286
+ function canonicalMenu(menu) {
287
+ return omit(menu, MENU_VOLATILE_KEYS);
288
+ }
289
+ const menuProvider = {
290
+ dirKind: 'menu',
291
+ async local(dir, onWarn) {
292
+ const state = new Map();
293
+ const entries = await loadFiles(dir, {
294
+ extension: '.json',
295
+ onSkip: onWarn,
296
+ parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
297
+ });
298
+ for (const { file, value } of entries) {
299
+ if (file === MENU_LOCATIONS_FILE) {
300
+ state.set(MENU_LOCATIONS_FILE, value);
301
+ continue;
302
+ }
303
+ const slug = getMenuSlug(value) ?? `local:${file}`;
304
+ state.set(`menu/${slug}`, canonicalMenu(value));
305
+ }
306
+ return state;
307
+ },
308
+ async remote(wp) {
309
+ const state = new Map();
310
+ const menus = await wp.get(MENU_ENDPOINT);
311
+ for (const menu of menus) {
312
+ const slug = getMenuSlug(menu);
313
+ if (slug !== null)
314
+ state.set(`menu/${slug}`, canonicalMenu(menu));
315
+ }
316
+ state.set(MENU_LOCATIONS_FILE, await wp.get(MENU_LOCATIONS_ENDPOINT));
317
+ return state;
318
+ },
319
+ resource: 'menu',
320
+ title: 'Menus',
321
+ };
267
322
  // ---- Options ------------------------------------------------------------------------------
268
323
  // Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
269
324
  // lists names, never values (by design, see OptionsController), so both sides compare exactly
@@ -310,6 +365,38 @@ const optionsProvider = {
310
365
  resource: 'option',
311
366
  title: 'Options',
312
367
  };
368
+ // ---- Theme styles (Global Styles) ----------------------------------------------------------
369
+ // Local files are named `<stylesheet>-global-styles.json`, keyed by stylesheet so a directory
370
+ // that has accumulated files from more than one previously-active theme still compares each one
371
+ // individually rather than colliding on a single key.
372
+ const themeStylesProvider = {
373
+ dirKind: 'themeStyles',
374
+ async local(dir, onWarn) {
375
+ const entries = await loadFiles(dir, {
376
+ extension: '.json',
377
+ onSkip: onWarn,
378
+ parse(raw, filePath) {
379
+ const name = basename(filePath);
380
+ const stylesheet = name.endsWith(THEME_STYLES_FILE_SUFFIX) ? name.slice(0, -THEME_STYLES_FILE_SUFFIX.length) : basename(filePath, '.json');
381
+ return { stylesheet, value: readJson(raw) };
382
+ },
383
+ });
384
+ const state = new Map();
385
+ for (const { stylesheet, value } of entries)
386
+ state.set(stylesheet, canonicalGlobalStyles(value));
387
+ return state;
388
+ },
389
+ // Only the active theme's Global Styles are compared (see theme-styles-format.ts): a classic
390
+ // active theme has no Styles screen at all, surfaced here as a normal provider error, the same
391
+ // "inconclusive for this resource" treatment DiffCommand gives any other fetch failure.
392
+ async remote(wp) {
393
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(wp);
394
+ const item = await wp.get(globalStylesEndpoint(id));
395
+ return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
396
+ },
397
+ resource: 'theme-styles',
398
+ title: 'Theme styles',
399
+ };
313
400
  export const RESOURCE_STATE_PROVIDERS = [
314
401
  snippetProvider,
315
402
  formProvider,
@@ -317,7 +404,9 @@ export const RESOURCE_STATE_PROVIDERS = [
317
404
  apiProvider,
318
405
  hookProvider,
319
406
  seoProvider,
407
+ menuProvider,
320
408
  optionsProvider,
409
+ themeStylesProvider,
321
410
  ];
322
411
  export function getResourceStateProvider(resource) {
323
412
  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;
@@ -0,0 +1,180 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags, ux } from '@oclif/core';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { pluralize } from '../utils/pluralize.js';
7
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
8
+ import { LoopressCommand } from './base.js';
9
+ import { compareStates, isEmptyDiff } from './diff-state.js';
10
+ import { guardProductionPush } from './guard-production-push.js';
11
+ import { isInteractive } from './interactive.js';
12
+ import { getResourceStateProvider } from './resource-state.js';
13
+ import { materializeSnapshot } from './rollback-materialize.js';
14
+ import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
15
+ const c = ux.colorize;
16
+ function summarize(diff) {
17
+ return { added: diff.added, changed: diff.changed.map((change) => change.id), removed: diff.removed };
18
+ }
19
+ function indentPatch(patch) {
20
+ return patch
21
+ .split('\n')
22
+ .map((line) => (line === '' ? line : ` ${line}`))
23
+ .join('\n')
24
+ .trimEnd();
25
+ }
26
+ // Builds the `lps <resource> rollback` command for one resource-state-backed resource (the 9
27
+ // listed in resource-state.ts's RESOURCE_STATE_PROVIDERS). `lps <resource> push` writes a
28
+ // snapshot of the environment's state right before every real push (see snapshot-store.ts);
29
+ // this command restores one: by default the most recent, or `--to <id>` for an older one.
30
+ //
31
+ // A rollback is only safe when nothing else touched the environment between the original push
32
+ // and now, otherwise it silently overwrites a legitimate later change. So before restoring
33
+ // anything, this runs the equivalent of `lps <resource> diff` between the environment's current
34
+ // state and the state the original push expected to leave behind, and requires explicit
35
+ // confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
36
+ export function resourceRollbackCommand(resource, options) {
37
+ class ResourceRollback extends LoopressCommand {
38
+ static args = {
39
+ path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
40
+ };
41
+ static description = options.description;
42
+ static enableJsonFlag = true;
43
+ static examples = [
44
+ `$ lps ${resource} rollback`,
45
+ `$ lps ${resource} rollback --list`,
46
+ `$ lps ${resource} rollback --to 1732000000000`,
47
+ `$ lps ${resource} rollback --dry-run`,
48
+ ];
49
+ static flags = {
50
+ ...LoopressCommand.dryRunFlag,
51
+ ...LoopressCommand.yesFlag,
52
+ list: Flags.boolean({ description: 'List available snapshots instead of rolling back' }),
53
+ to: Flags.string({ description: 'Roll back to this snapshot id instead of the most recent one' }),
54
+ };
55
+ async run() {
56
+ const { args, flags } = await this.parse(ResourceRollback);
57
+ const provider = getResourceStateProvider(resource);
58
+ const rootDir = resolve(process.cwd(), this.rootDir);
59
+ if (flags.list) {
60
+ const snapshots = await listSnapshots(rootDir, resource, this.siteConfig.name);
61
+ this.renderList(snapshots);
62
+ return { snapshots, status: 'listed' };
63
+ }
64
+ const dir = resolveResourceDir(provider.dirKind, this.localConfig, args.path);
65
+ let snapshot;
66
+ try {
67
+ snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
68
+ }
69
+ catch (error) {
70
+ this.error(error.message);
71
+ }
72
+ await guardProductionPush({
73
+ dryRun: this.dryRun,
74
+ error: (message) => this.error(message),
75
+ siteConfig: this.siteConfig,
76
+ yes: this.yes,
77
+ });
78
+ this.log(`Rolling back ${provider.title} on ${this.siteConfig.url} to the snapshot from ${snapshot.createdAt} (${snapshot.id})`);
79
+ const warn = (message) => {
80
+ this.warn(message);
81
+ };
82
+ const currentState = await provider.remote(this.wp, warn, dir);
83
+ // Has anything else changed the environment since the original push? Compare its current
84
+ // state against what that push expected to leave behind (`afterState`), not against the
85
+ // snapshot we're about to restore (`beforeState`), those are expected to differ, that's
86
+ // the whole point of rolling back.
87
+ const drift = compareStates(recordToState(snapshot.afterState), currentState, {
88
+ left: `expected (right after the ${snapshot.createdAt} push)`,
89
+ right: 'current',
90
+ });
91
+ const driftSummary = isEmptyDiff(drift) ? undefined : summarize(drift);
92
+ if (driftSummary) {
93
+ this.renderDiff(`${provider.title} has changed on ${this.siteConfig.url} since that push:`, drift);
94
+ await this.confirmDespiteDrift();
95
+ }
96
+ const restoreState = recordToState(snapshot.beforeState);
97
+ const restoreDiff = compareStates(currentState, restoreState, { left: 'current', right: `snapshot ${snapshot.id}` });
98
+ if (isEmptyDiff(restoreDiff)) {
99
+ this.log('Nothing to restore, the environment already matches this snapshot.');
100
+ return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'no-op' };
101
+ }
102
+ this.renderDiff(`This would restore ${provider.title} to:`, restoreDiff);
103
+ // `<resource> push` only creates and updates, it never deletes (see each resource's own
104
+ // push command, e.g. option push's "upsert only, never deletes an untracked option"):
105
+ // an item present now but absent from the snapshot stays present after "restoring" it.
106
+ if (restoreDiff.removed.length > 0) {
107
+ 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.`);
109
+ }
110
+ if (this.dryRun) {
111
+ return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
112
+ }
113
+ // Only the items that actually need restoring, not the whole snapshot: an item already
114
+ // matching `beforeState` needs no push, and re-pushing it anyway would put every other
115
+ // item this resource ever had at push time (however unrelated to what actually drifted)
116
+ // through the delegated push's own validation, where a single unrelated failure would
117
+ // fail the whole rollback.
118
+ const idsToRestore = new Set([...restoreDiff.added, ...restoreDiff.changed.map((change) => change.id)]);
119
+ const stateToRestore = Object.fromEntries(Object.entries(snapshot.beforeState).filter(([id]) => idsToRestore.has(id)));
120
+ const tmpDir = await mkdtemp(join(tmpdir(), `loopress-rollback-${resource}-`));
121
+ try {
122
+ await materializeSnapshot(resource, stateToRestore, tmpDir);
123
+ // tmpDir is positional (the same [PATH] arg every resource push command takes, see e.g.
124
+ // commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
125
+ // production guard and the drift confirmation above already covered what the delegated
126
+ // push's own guard would ask again, same reasoning as the top-level `lps push`
127
+ // delegating to each resource's push command in commands/push.ts.
128
+ await this.config.runCommand(`${resource}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
129
+ }
130
+ finally {
131
+ // A cleanup failure here must never hide a real error from the push above: warn about
132
+ // it and let the original error (if any) keep propagating instead of being replaced.
133
+ await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
134
+ this.warn(`Could not remove the temporary directory "${tmpDir}": ${error.message}`);
135
+ });
136
+ }
137
+ this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);
138
+ return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'success' };
139
+ }
140
+ async confirmDespiteDrift() {
141
+ if (this.dryRun || this.yes)
142
+ return;
143
+ if (!isInteractive()) {
144
+ this.error('The environment has changed since this snapshot was taken. Re-run with --yes to roll back anyway (overwriting those later changes).');
145
+ }
146
+ const proceed = await confirm({
147
+ default: false,
148
+ message: 'The environment has changed since this snapshot was taken. Roll back anyway, overwriting those later changes?',
149
+ });
150
+ if (!proceed)
151
+ this.error('Aborted.');
152
+ }
153
+ renderDiff(heading, diff) {
154
+ if (this.jsonEnabled())
155
+ return;
156
+ this.log(c('bold', heading));
157
+ for (const id of diff.added)
158
+ this.log(c('green', ` + ${id}`));
159
+ for (const id of diff.removed)
160
+ this.log(c('red', ` - ${id}`));
161
+ for (const change of diff.changed) {
162
+ this.log(c('yellow', ` ~ ${change.id}`));
163
+ this.log(indentPatch(change.patch));
164
+ }
165
+ }
166
+ renderList(snapshots) {
167
+ if (this.jsonEnabled())
168
+ return;
169
+ if (snapshots.length === 0) {
170
+ this.log(`No snapshots found for "${resource}" on "${this.siteConfig.name}". A snapshot is written automatically by \`lps ${resource} push\`.`);
171
+ return;
172
+ }
173
+ this.log(`Snapshots for "${resource}" on "${this.siteConfig.name}" (most recent first):`);
174
+ for (const snapshot of snapshots) {
175
+ this.log(` ${snapshot.id} ${snapshot.createdAt}`);
176
+ }
177
+ }
178
+ }
179
+ return ResourceRollback;
180
+ }
@@ -0,0 +1 @@
1
+ export declare function materializeSnapshot(resource: string, state: Record<string, unknown>, dir: string): Promise<void>;
@@ -0,0 +1,121 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { getAcfKey } from '../utils/acf-format.js';
4
+ import { getFormId, getFormTitle } from '../utils/form-format.js';
5
+ import { getMenuSlug } from '../utils/menu-format.js';
6
+ import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
7
+ import { redirectFileBase } from '../utils/seo-format.js';
8
+ import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
9
+ import { themeStylesFileName } from '../utils/theme-styles-format.js';
10
+ import { toSlug } from '../utils/to-slug.js';
11
+ const SNIPPET_EXTENSIONS = { css: 'css', html: 'html', js: 'js', php: 'php', text: 'txt' };
12
+ async function writeJson(path, value) {
13
+ await mkdir(dirname(path), { recursive: true });
14
+ await writeFile(path, JSON.stringify(value, null, 2) + '\n');
15
+ }
16
+ // id: the snapshot's map key, a numeric WordPress id (snapshots only ever come from
17
+ // `provider.remote()`, which never produces the "local:<file>" placeholder ids a not-yet-pushed
18
+ // local file gets, see resource-state.ts). value: the canonical snippet fields resource-state.ts
19
+ // compares (no `id`, no `description`, `code` with its `<?php` tag already stripped), exactly
20
+ // what `normalizeSnippet` + `stripPhpOpeningTag` produce, so it plugs straight into the same
21
+ // `buildSnippetFile`/`buildMetaFile` pair `snippet pull` uses to write files.
22
+ async function snippet(id, value, dir) {
23
+ const fields = value;
24
+ const normalized = { ...fields, description: '', id: Number(id) };
25
+ const ext = SNIPPET_EXTENSIONS[normalized.type];
26
+ const base = `${normalized.id}-${toSlug(normalized.name)}`;
27
+ await mkdir(dir, { recursive: true });
28
+ await writeFile(join(dir, `${base}.${ext}`), buildSnippetFile(normalized));
29
+ await writeFile(join(dir, `${base}.json`), buildMetaFile(normalized));
30
+ }
31
+ // value is the whole form JSON (minus `modified`/`modified_gmt`), the same shape `form pull`
32
+ // writes verbatim; `form push` re-sends it as-is.
33
+ async function form(id, value, dir) {
34
+ const data = value;
35
+ const formId = getFormId(data) ?? Number(id);
36
+ const slug = toSlug(getFormTitle(data), 'untitled');
37
+ await writeJson(join(dir, `${formId}-${slug}.json`), data);
38
+ }
39
+ // id is `<type>/<key>` (see resource-state.ts's acfProvider); value is the whole ACF export
40
+ // object (minus `modified`), already carrying its own `key`.
41
+ async function acf(id, value, dir) {
42
+ const separator = id.indexOf('/');
43
+ const type = id.slice(0, separator);
44
+ const data = value;
45
+ const key = getAcfKey(data) ?? id.slice(separator + 1);
46
+ await writeJson(join(dir, type, `${key}.json`), data);
47
+ }
48
+ // api/hook: id is the filename (possibly nested, `/`-joined); value is the raw PHP file text.
49
+ async function phpFile(id, value, dir) {
50
+ const filePath = join(dir, `${id}.php`);
51
+ await mkdir(dirname(filePath), { recursive: true });
52
+ await writeFile(filePath, String(value));
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
+ // ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
77
+ async function menu(id, value, dir) {
78
+ if (id === 'menu-locations') {
79
+ await writeJson(join(dir, 'menu-locations.json'), value);
80
+ return;
81
+ }
82
+ const data = value;
83
+ const slug = getMenuSlug(data) ?? id.slice('menu/'.length);
84
+ await writeJson(join(dir, `${slug}.json`), data);
85
+ }
86
+ // `value` is `{autoload, value}` (resource-state.ts's optionsProvider deliberately drops
87
+ // `readonly`: it's a local policy flag with no WordPress counterpart). Restoring it as writable
88
+ // by default would silently lift that protection for a rollback that happens to include a
89
+ // dangerous option (siteurl, home, ...); defaulting to the same policy `option add` itself uses
90
+ // keeps a rolled-back option exactly as protected as a freshly tracked one, never less.
91
+ async function option(id, value, dir) {
92
+ const { autoload, value: optionValue } = value;
93
+ const local = { autoload, name: id, readonly: defaultReadonlyFor(id), value: optionValue };
94
+ await writeJson(join(dir, optionFileName(id)), local);
95
+ }
96
+ // id is the stylesheet slug; value is `{settings, styles}` (canonicalGlobalStyles' shape).
97
+ async function themeStyles(id, value, dir) {
98
+ await writeJson(join(dir, themeStylesFileName(id)), value);
99
+ }
100
+ const MATERIALIZERS = {
101
+ acf,
102
+ api: phpFile,
103
+ form,
104
+ hook: phpFile,
105
+ menu,
106
+ option,
107
+ seo,
108
+ snippet,
109
+ 'theme-styles': themeStyles,
110
+ };
111
+ // Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
112
+ // have produced, so the existing `<resource> push` command can push it unmodified: a rollback
113
+ // is push() applied to an archived state instead of the current local files.
114
+ export async function materializeSnapshot(resource, state, dir) {
115
+ const materialize = MATERIALIZERS[resource];
116
+ if (!materialize)
117
+ throw new Error(`Rollback is not supported for "${resource}".`);
118
+ await mkdir(dir, { recursive: true });
119
+ for (const [id, value] of Object.entries(state))
120
+ await materialize(id, value, dir);
121
+ }
@@ -0,0 +1,30 @@
1
+ import { type ResourceState } from './diff-state.js';
2
+ export declare const SNAPSHOT_RETENTION = 10;
3
+ export type Snapshot = {
4
+ afterState: Record<string, unknown>;
5
+ beforeState: Record<string, unknown>;
6
+ createdAt: string;
7
+ environment: string;
8
+ resource: string;
9
+ };
10
+ export type SnapshotSummary = {
11
+ createdAt: string;
12
+ environment: string;
13
+ id: string;
14
+ resource: string;
15
+ };
16
+ export declare function stateToRecord(state: ResourceState): Record<string, unknown>;
17
+ export declare function recordToState(record: Record<string, unknown>): ResourceState;
18
+ export declare function writeSnapshot(options: {
19
+ afterState: ResourceState;
20
+ beforeState: ResourceState;
21
+ environment: string;
22
+ resource: string;
23
+ retain?: number;
24
+ rootDir: string;
25
+ }): Promise<string>;
26
+ export declare function listSnapshots(rootDir: string, resource: string, environment: string): Promise<SnapshotSummary[]>;
27
+ export declare function readSnapshot(rootDir: string, resource: string, environment: string, id?: string): Promise<Snapshot & {
28
+ id: string;
29
+ }>;
30
+ export declare function pruneSnapshots(rootDir: string, resource: string, environment: string, retain: number): Promise<void>;
@@ -0,0 +1,133 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // How many snapshots `writeSnapshot` keeps per resource before pruning the oldest, so the
4
+ // cache directory doesn't grow without bound across a project's lifetime.
5
+ export const SNAPSHOT_RETENTION = 10;
6
+ // `ResourceState` (a Map, for O(1) lookups during comparison) has no direct JSON
7
+ // representation; snapshots round-trip through a plain object instead. Keys are sorted so two
8
+ // snapshots of the same state serialize identically (stable diffs, deterministic tests).
9
+ export function stateToRecord(state) {
10
+ return Object.fromEntries([...state].sort(([a], [b]) => a.localeCompare(b)));
11
+ }
12
+ export function recordToState(record) {
13
+ return new Map(Object.entries(record));
14
+ }
15
+ // Environment names are free text (whatever `lps project config` was given), not guaranteed to
16
+ // be filesystem-safe path segments; anything outside a conservative safe set becomes '-'.
17
+ function sanitizeForPath(name) {
18
+ return name.replaceAll(/[^\w-]/g, '-');
19
+ }
20
+ // Scoped by environment, not just resource: a project with multiple environments (staging,
21
+ // production, ...) sharing one rootDir must never let a staging push's snapshot answer a
22
+ // production rollback, or a staging push's retention prune a production restore point.
23
+ function snapshotsDir(rootDir, resource, environment) {
24
+ return join(rootDir, '.loopress', 'snapshots', resource, sanitizeForPath(environment));
25
+ }
26
+ function snapshotPath(rootDir, resource, environment, id) {
27
+ return join(snapshotsDir(rootDir, resource, environment), `${id}.json`);
28
+ }
29
+ function isEnoent(error) {
30
+ return error.code === 'ENOENT';
31
+ }
32
+ function isEexist(error) {
33
+ return error.code === 'EEXIST';
34
+ }
35
+ // Snapshot ids are the millisecond timestamp they were written at: sortable, unique enough for
36
+ // a single-machine CLI run, and directly usable as `--to <id>`.
37
+ function isSnapshotFilename(name) {
38
+ return /^\d+\.json$/.test(name);
39
+ }
40
+ // Writes a snapshot for `resource`/`environment`, then prunes down to `retain` (oldest first),
41
+ // so a project that pushes often never accumulates an unbounded `.loopress/snapshots/`
42
+ // directory. Returns the new snapshot's id.
43
+ //
44
+ // Ids are millisecond timestamps; two pushes of the same resource/environment landing in the
45
+ // same millisecond (only realistic from concurrent processes, never a single sequential CLI
46
+ // run) would otherwise silently overwrite one restore point with another. `wx` (exclusive
47
+ // create) turns that into a detected collision instead, retried against the next millisecond.
48
+ export async function writeSnapshot(options) {
49
+ const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
50
+ const dir = snapshotsDir(rootDir, resource, environment);
51
+ await mkdir(dir, { recursive: true });
52
+ const snapshot = {
53
+ afterState: stateToRecord(afterState),
54
+ beforeState: stateToRecord(beforeState),
55
+ createdAt: new Date().toISOString(),
56
+ environment,
57
+ resource,
58
+ };
59
+ const content = JSON.stringify(snapshot, null, 2) + '\n';
60
+ let id = Date.now();
61
+ for (;;) {
62
+ try {
63
+ await writeFile(join(dir, `${id}.json`), content, { flag: 'wx' });
64
+ break;
65
+ }
66
+ catch (error) {
67
+ if (!isEexist(error))
68
+ throw error;
69
+ id += 1;
70
+ }
71
+ }
72
+ await pruneSnapshots(rootDir, resource, environment, retain);
73
+ return String(id);
74
+ }
75
+ // Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
76
+ // "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
77
+ // a never-pulled local directory.
78
+ export async function listSnapshots(rootDir, resource, environment) {
79
+ const dir = snapshotsDir(rootDir, resource, environment);
80
+ let entries;
81
+ try {
82
+ entries = await readdir(dir);
83
+ }
84
+ catch (error) {
85
+ if (isEnoent(error))
86
+ return [];
87
+ throw error;
88
+ }
89
+ const summaries = [];
90
+ const snapshotFiles = entries.filter((entry) => isSnapshotFilename(entry));
91
+ for (const entry of snapshotFiles) {
92
+ const id = entry.slice(0, -'.json'.length);
93
+ try {
94
+ const parsed = JSON.parse(await readFile(join(dir, entry), 'utf8'));
95
+ summaries.push({ createdAt: parsed.createdAt, environment: parsed.environment, id, resource });
96
+ }
97
+ catch {
98
+ // A corrupted or partially-written snapshot file (e.g. an interrupted write) is skipped
99
+ // rather than failing the whole listing; it stays on disk for a human to inspect.
100
+ }
101
+ }
102
+ return summaries.sort((a, b) => Number(b.id) - Number(a.id));
103
+ }
104
+ // `id` undefined reads the most recent snapshot for this environment (the common
105
+ // `lps <resource> rollback` case). Throws a message ready to show the user directly: no
106
+ // snapshots at all for this environment, or that specific id missing (renamed, deleted, or
107
+ // written under a different resource/environment).
108
+ export async function readSnapshot(rootDir, resource, environment, id) {
109
+ let targetId = id;
110
+ if (targetId === undefined) {
111
+ const [latest] = await listSnapshots(rootDir, resource, environment);
112
+ if (!latest) {
113
+ throw new Error(`No snapshots found for "${resource}" on "${environment}". A snapshot is written automatically by \`lps ${resource} push\`.`);
114
+ }
115
+ targetId = latest.id;
116
+ }
117
+ let raw;
118
+ try {
119
+ raw = await readFile(snapshotPath(rootDir, resource, environment, targetId), 'utf8');
120
+ }
121
+ catch (error) {
122
+ if (isEnoent(error)) {
123
+ throw new Error(`Snapshot "${targetId}" not found for "${resource}" on "${environment}". Run \`lps ${resource} rollback --list\` to see what's available.`, { cause: error });
124
+ }
125
+ throw error;
126
+ }
127
+ return { ...JSON.parse(raw), id: targetId };
128
+ }
129
+ export async function pruneSnapshots(rootDir, resource, environment, retain) {
130
+ const summaries = await listSnapshots(rootDir, resource, environment);
131
+ const stale = summaries.slice(retain);
132
+ await Promise.all(stale.map(async (summary) => rm(snapshotPath(rootDir, resource, environment, summary.id), { force: true })));
133
+ }
@@ -6,12 +6,13 @@ import { loadSnippets } from './load-snippets.js';
6
6
  import { readdirTolerant } from './readdir-tolerant.js';
7
7
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
8
8
  // and api routes (`.php`, not JSON) are checked separately.
9
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir'];
9
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
10
10
  const DEFAULT_DIR = {
11
11
  acfDir: 'acf',
12
12
  apiDir: 'api',
13
13
  formDir: 'forms',
14
14
  hooksDir: 'hooks',
15
+ menuDir: 'menus',
15
16
  seoDir: 'seo',
16
17
  snippetsDir: 'snippets',
17
18
  };
@@ -39,6 +39,14 @@ export interface LoopressProjectConfiguration {
39
39
  * Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
40
40
  */
41
41
  optionsDir?: string;
42
+ /**
43
+ * Directory where nav menus (one JSON file per menu, plus menu-locations.json for the active theme's menu locations) are read from and written to, relative to rootDir.
44
+ */
45
+ menuDir?: string;
46
+ /**
47
+ * Directory where the active block theme's Global Styles customizations (Site Editor > Styles: colors, typography, spacing) are read from and written to, relative to rootDir. Classic (non-block) themes are not supported.
48
+ */
49
+ themeStylesDir?: string;
42
50
  /**
43
51
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
44
52
  */