@loopress/cli 0.24.0 → 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 (112) hide show
  1. package/README.md +716 -98
  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/rm.d.ts +2 -0
  7. package/dist/commands/api/rm.js +2 -0
  8. package/dist/commands/api/rollback.d.ts +2 -0
  9. package/dist/commands/api/rollback.js +5 -0
  10. package/dist/commands/composer/push.d.ts +3 -0
  11. package/dist/commands/composer/push.js +24 -7
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/doctor.d.ts +17 -1
  14. package/dist/commands/doctor.js +55 -38
  15. package/dist/commands/form/pull.js +4 -1
  16. package/dist/commands/form/push.d.ts +3 -0
  17. package/dist/commands/form/push.js +39 -6
  18. package/dist/commands/form/rollback.d.ts +2 -0
  19. package/dist/commands/form/rollback.js +5 -0
  20. package/dist/commands/hook/rm.d.ts +2 -0
  21. package/dist/commands/hook/rm.js +2 -0
  22. package/dist/commands/hook/rollback.d.ts +2 -0
  23. package/dist/commands/hook/rollback.js +5 -0
  24. package/dist/commands/init.js +1 -0
  25. package/dist/commands/login.js +3 -2
  26. package/dist/commands/menu/diff.d.ts +2 -0
  27. package/dist/commands/menu/diff.js +5 -0
  28. package/dist/commands/menu/list.d.ts +13 -0
  29. package/dist/commands/menu/list.js +32 -0
  30. package/dist/commands/menu/pull.d.ts +15 -0
  31. package/dist/commands/menu/pull.js +72 -0
  32. package/dist/commands/menu/push.d.ts +17 -0
  33. package/dist/commands/menu/push.js +120 -0
  34. package/dist/commands/menu/rollback.d.ts +2 -0
  35. package/dist/commands/menu/rollback.js +5 -0
  36. package/dist/commands/option/add.js +1 -1
  37. package/dist/commands/option/push.d.ts +1 -0
  38. package/dist/commands/option/push.js +26 -1
  39. package/dist/commands/option/rollback.d.ts +2 -0
  40. package/dist/commands/option/rollback.js +5 -0
  41. package/dist/commands/plugin/push.d.ts +1 -0
  42. package/dist/commands/plugin/push.js +14 -0
  43. package/dist/commands/promote.d.ts +8 -1
  44. package/dist/commands/promote.js +2 -0
  45. package/dist/commands/pull.d.ts +11 -1
  46. package/dist/commands/pull.js +9 -2
  47. package/dist/commands/push.d.ts +11 -1
  48. package/dist/commands/push.js +9 -2
  49. package/dist/commands/seo/pull.js +6 -2
  50. package/dist/commands/seo/push.d.ts +3 -0
  51. package/dist/commands/seo/push.js +49 -7
  52. package/dist/commands/seo/rollback.d.ts +2 -0
  53. package/dist/commands/seo/rollback.js +5 -0
  54. package/dist/commands/snippet/push.d.ts +1 -0
  55. package/dist/commands/snippet/push.js +31 -1
  56. package/dist/commands/snippet/rollback.d.ts +2 -0
  57. package/dist/commands/snippet/rollback.js +5 -0
  58. package/dist/commands/theme-styles/diff.d.ts +2 -0
  59. package/dist/commands/theme-styles/diff.js +5 -0
  60. package/dist/commands/theme-styles/pull.d.ts +12 -0
  61. package/dist/commands/theme-styles/pull.js +31 -0
  62. package/dist/commands/theme-styles/push.d.ts +12 -0
  63. package/dist/commands/theme-styles/push.js +45 -0
  64. package/dist/commands/theme-styles/rollback.d.ts +2 -0
  65. package/dist/commands/theme-styles/rollback.js +5 -0
  66. package/dist/hooks/finally.js +4 -1
  67. package/dist/lib/app-manifest.js +4 -27
  68. package/dist/lib/base.d.ts +2 -0
  69. package/dist/lib/base.js +6 -0
  70. package/dist/lib/find-orphaned-files.js +5 -47
  71. package/dist/lib/load-files.js +4 -42
  72. package/dist/lib/local-callback-server.d.ts +14 -1
  73. package/dist/lib/local-callback-server.js +40 -1
  74. package/dist/lib/php-files-command.d.ts +17 -3
  75. package/dist/lib/php-files-command.js +196 -37
  76. package/dist/lib/push-command.d.ts +4 -0
  77. package/dist/lib/push-command.js +49 -0
  78. package/dist/lib/readdir-tolerant.d.ts +1 -0
  79. package/dist/lib/readdir-tolerant.js +12 -0
  80. package/dist/lib/resource-state.js +92 -3
  81. package/dist/lib/rollback-command.d.ts +21 -0
  82. package/dist/lib/rollback-command.js +180 -0
  83. package/dist/lib/rollback-materialize.d.ts +1 -0
  84. package/dist/lib/rollback-materialize.js +121 -0
  85. package/dist/lib/sentry.d.ts +3 -0
  86. package/dist/lib/sentry.js +30 -0
  87. package/dist/lib/snapshot-store.d.ts +30 -0
  88. package/dist/lib/snapshot-store.js +133 -0
  89. package/dist/lib/validate-local.js +2 -1
  90. package/dist/lib/wp-authorize-flow.d.ts +4 -0
  91. package/dist/lib/wp-authorize-flow.js +10 -8
  92. package/dist/types/project-config.generated.d.ts +8 -0
  93. package/dist/utils/acf-format.d.ts +1 -0
  94. package/dist/utils/acf-format.js +6 -0
  95. package/dist/utils/form-format.d.ts +4 -0
  96. package/dist/utils/form-format.js +3 -7
  97. package/dist/utils/menu-format.d.ts +24 -0
  98. package/dist/utils/menu-format.js +8 -0
  99. package/dist/utils/option-format.d.ts +2 -1
  100. package/dist/utils/option-format.js +22 -4
  101. package/dist/utils/plugin-sync.d.ts +6 -0
  102. package/dist/utils/resource-dirs.d.ts +2 -0
  103. package/dist/utils/resource-dirs.js +4 -0
  104. package/dist/utils/seo-format.d.ts +8 -0
  105. package/dist/utils/seo-format.js +3 -0
  106. package/dist/utils/snippet-format.d.ts +1 -0
  107. package/dist/utils/snippet-format.js +1 -0
  108. package/dist/utils/theme-styles-format.d.ts +26 -0
  109. package/dist/utils/theme-styles-format.js +55 -0
  110. package/oclif.manifest.json +1837 -386
  111. package/package.json +5 -3
  112. 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
+ }
@@ -1,3 +1,4 @@
1
+ import type { ErrorEvent } from '@sentry/node';
1
2
  export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
2
3
  export declare function isTelemetryDisabled(): boolean;
3
4
  export declare function resolveEnvironment(): string;
@@ -6,3 +7,5 @@ export declare function runtimeContext(): {
6
7
  os: string;
7
8
  };
8
9
  export declare function redactArgv(argv: string[]): string[];
10
+ export declare function scrubErrorMessage(value: string): string;
11
+ export declare function scrubEvent(event: ErrorEvent): ErrorEvent;
@@ -31,3 +31,33 @@ export function redactArgv(argv) {
31
31
  return eqIndex === -1 ? arg : arg.slice(0, eqIndex);
32
32
  });
33
33
  }
34
+ // The site origin in front of a `/wp-json/` REST path: collapse it but keep the path, which is
35
+ // Loopress's own API surface (identical on every site, no user data) and useful for triage.
36
+ const SITE_PREFIX = /\bhttps?:\/\/[^\s"'<>)]*?\/wp-json\//gi;
37
+ // Any other absolute URL (bare site root, a package URL on the summary line): blank it whole.
38
+ const ANY_URL = /\bhttps?:\/\/[^\s"'<>)]+/gi;
39
+ // A WordPress request failure (`formatWpError`) puts the site URL on the first line and, for a
40
+ // 4xx/5xx, appends the server's raw response body after a newline: a Composer trace, a WP fatal,
41
+ // sometimes absolute server paths, package URLs or a connection string. None of that should
42
+ // leave the user's machine. Keep the first line (the "which endpoint, which status" summary)
43
+ // with the site host blanked, and drop everything after it. The terminal still prints the
44
+ // full, unmodified message.
45
+ export function scrubErrorMessage(value) {
46
+ const firstLine = value.split('\n', 1)[0] ?? '';
47
+ return firstLine.replaceAll(SITE_PREFIX, '<site>/wp-json/').replaceAll(ANY_URL, '<site>').slice(0, 300);
48
+ }
49
+ // Sentry `beforeSend`: runs on every event before it is sent. Scrubs the exception message(s)
50
+ // (including chained `cause` errors, which the linkedErrors integration copies in) and drops
51
+ // any request context. `extra.argv` is already redacted upstream by `redactArgv`.
52
+ export function scrubEvent(event) {
53
+ if (typeof event.message === 'string') {
54
+ event.message = scrubErrorMessage(event.message);
55
+ }
56
+ for (const exception of event.exception?.values ?? []) {
57
+ if (typeof exception.value === 'string') {
58
+ exception.value = scrubErrorMessage(exception.value);
59
+ }
60
+ }
61
+ delete event.request;
62
+ return event;
63
+ }
@@ -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>;