@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
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('snippet', {
3
+ description: 'Restore snippets to a snapshot taken automatically before an earlier `lps snippet push`',
4
+ pathNoun: 'snippets directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('theme-styles', {
3
+ description: "Show what differs in the active block theme's Global Styles (Site Editor > Styles) between your local file and a WordPress environment, or between two environments",
4
+ pathNoun: 'theme styles directory',
5
+ });
@@ -0,0 +1,12 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ import { Args } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../utils/theme-styles-format.js';
6
+ export default class Pull extends LoopressCommand {
7
+ static args = {
8
+ path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
9
+ };
10
+ static description = "Pull the active block theme's Global Styles customizations (Site Editor > Styles) from WordPress into a local file";
11
+ static examples = ['$ lps theme-styles pull'];
12
+ static flags = {
13
+ ...LoopressCommand.dryRunFlag,
14
+ };
15
+ async run() {
16
+ const { args } = await this.parse(Pull);
17
+ const { url } = this.siteConfig;
18
+ const dir = this.resolveThemeStylesPath(args.path);
19
+ this.log(`Pulling Global Styles from ${url}`);
20
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
21
+ const item = await this.wp.get(globalStylesEndpoint(id));
22
+ const file = join(dir, themeStylesFileName(stylesheet));
23
+ if (this.dryRun) {
24
+ this.log(`[dry-run] Would pull Global Styles for "${stylesheet}" to ${file}`);
25
+ return;
26
+ }
27
+ await mkdir(dir, { recursive: true });
28
+ await writeFile(file, JSON.stringify(canonicalGlobalStyles(item), null, 2) + '\n');
29
+ this.log(`Pulled Global Styles for "${stylesheet}" to ${file}`);
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,45 @@
1
+ import { Args } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { PushCommand } from '../../lib/push-command.js';
5
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
6
+ import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../utils/theme-styles-format.js';
7
+ export default class Push extends PushCommand {
8
+ static args = {
9
+ path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
10
+ };
11
+ static description = "Push the local Global Styles file to the active block theme's Site Editor > Styles on WordPress";
12
+ static examples = ['$ lps theme-styles push'];
13
+ static flags = {
14
+ ...PushCommand.dryRunFlag,
15
+ };
16
+ async run() {
17
+ const { args } = await this.parse(Push);
18
+ const { url } = this.siteConfig;
19
+ const dir = this.resolveThemeStylesPath(args.path);
20
+ this.log(`Pushing Global Styles to ${url}`);
21
+ const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
22
+ const file = join(dir, themeStylesFileName(stylesheet));
23
+ let raw;
24
+ try {
25
+ raw = await readFile(file, 'utf8');
26
+ }
27
+ catch (error) {
28
+ if (error.code === 'ENOENT') {
29
+ this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme-styles pull\` first.`);
30
+ }
31
+ throw error;
32
+ }
33
+ if (this.dryRun) {
34
+ this.log(`[dry-run] Would push: ${file}`);
35
+ return;
36
+ }
37
+ const provider = getResourceStateProvider('theme-styles');
38
+ const beforeState = await this.captureBeforePushState(provider, dir);
39
+ const local = JSON.parse(raw);
40
+ await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
41
+ this.log(`Pushed: ${file}`);
42
+ await this.writeAfterPushSnapshot(provider, dir, beforeState);
43
+ await this.recordSuccess();
44
+ }
45
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('theme-styles', {
3
+ description: 'Restore the active theme’s Global Styles to a snapshot taken automatically before an earlier `lps theme-styles push`',
4
+ pathNoun: 'theme styles directory',
5
+ });
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readdir, readFile } from 'node:fs/promises';
3
- import { extname, join, relative } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { walkFiles } from './readdir-tolerant.js';
4
5
  export const APP_CONFIG_FILENAME = 'loopress.app.json';
5
6
  const DEFAULT_ASSETS_DIR = 'dist';
6
7
  // Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
@@ -14,35 +15,11 @@ const ALLOWED_EXTENSIONS = new Set([
14
15
  export function isAllowedAsset(relPath) {
15
16
  return ALLOWED_EXTENSIONS.has(extname(relPath).toLowerCase());
16
17
  }
17
- async function walk(dir, root) {
18
- let entries;
19
- try {
20
- entries = await readdir(dir, { withFileTypes: true });
21
- }
22
- catch (error) {
23
- if (error.code === 'ENOENT')
24
- return [];
25
- throw error;
26
- }
27
- const out = [];
28
- for (const entry of entries) {
29
- const abs = join(dir, entry.name);
30
- if (entry.isDirectory()) {
31
- out.push(...(await walk(abs, root)));
32
- }
33
- else if (entry.isFile()) {
34
- // Identity is always '/'-joined, never the OS separator: it travels over HTTP and is
35
- // matched against the server's own '/'-joined paths.
36
- out.push(relative(root, abs).replaceAll(/[\\/]/g, '/'));
37
- }
38
- }
39
- return out;
40
- }
41
18
  // Builds the sorted file list of a dist directory. Throws on a file whose extension the
42
19
  // server would reject, so the push fails locally with a clear message instead of a 400
43
20
  // halfway through the upload.
44
21
  export async function buildFileList(distDir) {
45
- const relPaths = (await walk(distDir, distDir)).sort((a, b) => a.localeCompare(b));
22
+ const relPaths = (await walkFiles(distDir)).sort((a, b) => a.localeCompare(b));
46
23
  const files = [];
47
24
  for (const path of relPaths) {
48
25
  if (!isAllowedAsset(path)) {
@@ -36,9 +36,11 @@ export declare abstract class LoopressCommand extends Command {
36
36
  protected resolveAppsPath(override?: string): string;
37
37
  protected resolveFormPath(override?: string): string;
38
38
  protected resolveHooksPath(override?: string): string;
39
+ protected resolveMenuPath(override?: string): string;
39
40
  protected resolveOptionsPath(override?: string): string;
40
41
  protected resolveSeoPath(override?: string): string;
41
42
  protected resolveSnippetsPath(override?: string): string;
43
+ protected resolveThemeStylesPath(override?: string): string;
42
44
  private pickEnvironment;
43
45
  private resolveEnvironment;
44
46
  private resolveEnvironmentForConfiguredProject;
package/dist/lib/base.js CHANGED
@@ -147,6 +147,9 @@ export class LoopressCommand extends Command {
147
147
  resolveHooksPath(override) {
148
148
  return resolveResourceDir('hooks', this.localConfig, override);
149
149
  }
150
+ resolveMenuPath(override) {
151
+ return resolveResourceDir('menu', this.localConfig, override);
152
+ }
150
153
  resolveOptionsPath(override) {
151
154
  return resolveResourceDir('options', this.localConfig, override);
152
155
  }
@@ -156,6 +159,9 @@ export class LoopressCommand extends Command {
156
159
  resolveSnippetsPath(override) {
157
160
  return resolveResourceDir('snippets', this.localConfig, override);
158
161
  }
162
+ resolveThemeStylesPath(override) {
163
+ return resolveResourceDir('themeStyles', this.localConfig, override);
164
+ }
159
165
  pickEnvironment(project, envName) {
160
166
  const env = project.environments[envName];
161
167
  if (!env) {
@@ -1,51 +1,9 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { extname, join } from 'node:path';
3
- // Same shape as load-files.ts's own walker, kept separate rather than shared: this one
4
- // filters against a list of extensions (snippet pull needs more than one), that one
5
- // against a single extension, and reconciling the two would cost more than the ~15 duplicated
6
- // lines it would save.
7
- async function walk(dir, extensions) {
8
- let entries;
9
- try {
10
- entries = await readdir(dir, { withFileTypes: true });
11
- }
12
- catch (error) {
13
- if (error.code === 'ENOENT')
14
- return [];
15
- throw error;
16
- }
17
- const relativePaths = [];
18
- for (const entry of entries) {
19
- if (entry.isDirectory()) {
20
- // join() builds the real OS path to actually walk, but the returned identity is always
21
- // '/'-joined regardless of platform: it's matched against `keep`, filenames the server
22
- // sends with '/', never the OS separator. join() here (backslash on Windows) would make
23
- // every nested file's identity never match, findOrphanedFiles() would then report a
24
- // live, kept file as orphaned and pull.ts would delete it.
25
- const nested = await walk(join(dir, entry.name), extensions);
26
- relativePaths.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
27
- }
28
- else if (extensions.includes(extname(entry.name))) {
29
- relativePaths.push(entry.name);
30
- }
31
- }
32
- return relativePaths;
33
- }
1
+ import { extname } from 'node:path';
2
+ import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
34
3
  export async function findOrphanedFiles(dir, keep, matcher) {
35
- let files;
36
- if (matcher.recursive) {
37
- files = await walk(dir, matcher.extensions);
38
- }
39
- else {
40
- try {
41
- files = await readdir(dir);
42
- }
43
- catch (error) {
44
- if (error.code === 'ENOENT')
45
- return [];
46
- throw error;
47
- }
48
- }
4
+ // walkFiles already returns '/'-joined identities regardless of platform: matched against
5
+ // `keep`, filenames the server sends with '/', never the OS separator.
6
+ const files = matcher.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
49
7
  return files.filter((file) => {
50
8
  const ext = extname(file);
51
9
  if (!matcher.extensions.includes(ext))
@@ -1,52 +1,14 @@
1
- import { readdir, readFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { extname, join } from 'node:path';
3
- // Recursion for the api resource type only (path-param route files can live in
4
- // subdirectories, e.g. api/invoice-pdf/[order_id].php); every other resource type stays
5
- // flat. withFileTypes avoids a second stat() per entry to tell files from directories.
6
- async function walk(dir, extension) {
7
- let entries;
8
- try {
9
- entries = await readdir(dir, { withFileTypes: true });
10
- }
11
- catch (error) {
12
- if (error.code === 'ENOENT')
13
- return [];
14
- throw error;
15
- }
16
- const filePaths = [];
17
- for (const entry of entries) {
18
- const entryPath = join(dir, entry.name);
19
- if (entry.isDirectory()) {
20
- filePaths.push(...(await walk(entryPath, extension)));
21
- }
22
- else if (extname(entry.name) === extension) {
23
- filePaths.push(entryPath);
24
- }
25
- }
26
- return filePaths;
27
- }
3
+ import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
28
4
  // Loads every `<extension>` file of a directory through `parse`. One file is read in
29
5
  // isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
30
6
  // only skip that file, never abort loading the rest of the directory, so `parse` throwing is
31
7
  // reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
32
8
  // "nothing to load", not an error.
33
9
  export async function loadFiles(dir, options) {
34
- let filePaths;
35
- if (options.recursive) {
36
- filePaths = await walk(dir, options.extension);
37
- }
38
- else {
39
- let entries;
40
- try {
41
- entries = await readdir(dir);
42
- }
43
- catch (error) {
44
- if (error.code === 'ENOENT')
45
- return [];
46
- throw error;
47
- }
48
- filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
49
- }
10
+ const entries = options.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
11
+ const filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
50
12
  const items = [];
51
13
  for (const filePath of filePaths) {
52
14
  try {
@@ -29,6 +29,7 @@ type RemotePhpFile = {
29
29
  error?: string;
30
30
  filename: string;
31
31
  public?: boolean;
32
+ revision?: string;
32
33
  };
33
34
  type PushResult = {
34
35
  pruned: string[];
@@ -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
+ }