@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
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
3
  import { createServer } from 'node:http';
3
4
  import { openBrowser } from './open-browser.js';
4
5
  async function readBody(req) {
@@ -10,12 +11,31 @@ async function readBody(req) {
10
11
  function parseFormData(body) {
11
12
  return Object.fromEntries(new URLSearchParams(body));
12
13
  }
14
+ /** Constant-time compare that also tolerates a length mismatch without throwing. */
15
+ function safeEqual(a, b) {
16
+ const bufferA = Buffer.from(a);
17
+ const bufferB = Buffer.from(b);
18
+ return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
19
+ }
13
20
  /**
14
21
  * Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
15
22
  * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
23
+ *
24
+ * The loopback server is unauthenticated, so any local process or web page open during the wait
25
+ * could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
26
+ *
27
+ * - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
28
+ * back (constant-time compare) on any request that carries credentials;
29
+ * - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
30
+ * stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
31
+ * `Origin` and are allowed, which is why `state` is the primary control.
32
+ *
33
+ * The first request that looks like a callback (valid or not) shuts the server down, so a
34
+ * failed guess gets no second try within the window.
16
35
  */
17
36
  export async function waitForLocalCallback(options) {
18
37
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
38
+ const state = randomBytes(32).toString('hex');
19
39
  return new Promise((resolve, reject) => {
20
40
  function finish(res, page) {
21
41
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -23,10 +43,29 @@ export async function waitForLocalCallback(options) {
23
43
  clearTimeout(timer);
24
44
  server.close();
25
45
  }
46
+ function rejectRequest(res, message) {
47
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
48
+ res.end(message);
49
+ clearTimeout(timer);
50
+ server.close();
51
+ reject(new Error(message));
52
+ }
26
53
  async function handleIncoming(req, res) {
27
54
  try {
28
55
  const url = new URL(req.url ?? '/', 'http://localhost');
29
56
  const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
57
+ const looksLikeCallback = url.searchParams.has('state') || Object.keys(body).length > 0;
58
+ if (looksLikeCallback) {
59
+ const { origin } = req.headers;
60
+ if (origin !== undefined && !options.allowedOrigins.includes(origin)) {
61
+ rejectRequest(res, 'Rejected a cross-origin request to the login callback server.');
62
+ return;
63
+ }
64
+ if (!safeEqual(url.searchParams.get('state') ?? '', state)) {
65
+ rejectRequest(res, 'Rejected a login callback with a missing or invalid state value.');
66
+ return;
67
+ }
68
+ }
30
69
  options.handleRequest(url, {
31
70
  rejectWithPage(page, error) {
32
71
  finish(res, page);
@@ -61,7 +100,7 @@ export async function waitForLocalCallback(options) {
61
100
  }, timeoutMs);
62
101
  server.listen(0, '127.0.0.1', () => {
63
102
  const { port } = server.address();
64
- const targetUrl = options.buildUrl(`http://localhost:${port}`);
103
+ const targetUrl = options.buildUrl(`http://localhost:${port}`, state);
65
104
  options.log(options.openingMessage);
66
105
  options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
67
106
  openBrowser(targetUrl);
@@ -22,12 +22,17 @@ export type PhpFilesResource = {
22
22
  pathNoun: string;
23
23
  pullDescription: string;
24
24
  pushDescription: string;
25
+ rmDescription: string;
25
26
  };
26
- type PhpFile = {
27
- content: string;
27
+ type RemotePhpFile = {
28
+ content?: string;
29
+ error?: string;
28
30
  filename: string;
31
+ public?: boolean;
32
+ revision?: string;
29
33
  };
30
34
  type PushResult = {
35
+ pruned: string[];
31
36
  pushed: string[];
32
37
  status: 'dry-run' | 'success';
33
38
  };
@@ -36,6 +41,11 @@ type PullResult = {
36
41
  pulled: string[];
37
42
  status: 'dry-run' | 'success';
38
43
  };
44
+ type RmResult = {
45
+ filename: string;
46
+ removed: boolean;
47
+ status: 'aborted' | 'dry-run' | 'success';
48
+ };
39
49
  export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
40
50
  export type PushFilesCommand = Omit<PushCommand, 'run'> & {
41
51
  run(): Promise<PushResult>;
@@ -44,11 +54,15 @@ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
44
54
  run(): Promise<PullResult>;
45
55
  };
46
56
  export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
47
- run(): Promise<PhpFile[]>;
57
+ run(): Promise<RemotePhpFile[]>;
58
+ };
59
+ export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
60
+ run(): Promise<RmResult>;
48
61
  };
49
62
  export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
50
63
  export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
51
64
  export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
65
+ export declare function resourceRmCommand(spec: PhpFilesResource): CommandClass<RmFilesCommand>;
52
66
  export declare const API_FILES_RESOURCE: PhpFilesResource;
53
67
  export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
54
68
  export {};
@@ -1,4 +1,6 @@
1
- import { Args } from '@oclif/core';
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { createHash } from 'node:crypto';
2
4
  import { mkdir, writeFile } from 'node:fs/promises';
3
5
  import { dirname, join, relative, sep } from 'node:path';
4
6
  import { authManager } from '../config/auth.manager.js';
@@ -7,13 +9,29 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
7
9
  import { ApiClient } from './api-client.js';
8
10
  import { LoopressCommand } from './base.js';
9
11
  import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
12
+ import { isInteractive } from './interactive.js';
10
13
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
11
14
  import { PushCommand } from './push-command.js';
15
+ import { getResourceStateProvider } from './resource-state.js';
16
+ import { isApplicative404 } from './wp-client.js';
12
17
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
13
18
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
14
19
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
15
20
  // presence, so a file that would still fail server-side doesn't falsely pass this check.
16
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
+ }
17
35
  function pathArg(pathNoun) {
18
36
  return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
19
37
  }
@@ -35,35 +53,130 @@ export function resourcePushCommand(spec) {
35
53
  static args = pathArg(spec.pathNoun);
36
54
  static description = spec.pushDescription;
37
55
  static enableJsonFlag = true;
38
- static examples = [`$ lps ${spec.cliName} push`, `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
39
- static flags = { ...PushCommand.dryRunFlag, ...PushCommand.yesFlag };
56
+ static examples = [
57
+ `$ lps ${spec.cliName} push`,
58
+ `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
59
+ `$ lps ${spec.cliName} push --prune`,
60
+ ];
61
+ static flags = {
62
+ ...PushCommand.dryRunFlag,
63
+ ...PushCommand.yesFlag,
64
+ prune: Flags.boolean({
65
+ default: false,
66
+ description: `Delete server-side ${spec.noun}s not present locally after pushing`,
67
+ }),
68
+ };
40
69
  async run() {
41
- const { args } = await this.parse(ResourcePush);
70
+ const { args, flags } = await this.parse(ResourcePush);
42
71
  const { url } = this.siteConfig;
43
72
  const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
44
73
  this.log(`Pushing ${spec.label} to ${url}`);
45
74
  this.log(`${spec.pathLabel} path: ${path}`);
75
+ const provider = getResourceStateProvider(spec.cliName);
76
+ const beforeState = await this.captureBeforePushState(provider, path);
46
77
  const files = await this.loadFiles(path);
47
78
  this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
48
- const pushed = [];
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.
49
82
  await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
50
- await this.pushFile(file, task);
51
- pushed.push(file.filename);
83
+ this.validateFileLocally(file, task);
52
84
  });
53
85
  if (this.failedCount > 0) {
54
86
  this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
55
87
  }
56
- if (this.dryRun)
57
- return { pushed, status: 'dry-run' };
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' };
98
+ }
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);
58
103
  await this.recordSuccess();
59
104
  await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
60
105
  this.log(`All ${spec.label} pushed.`);
61
- return { pushed, status: 'success' };
106
+ return { pruned, pushed, status: 'success' };
62
107
  }
63
108
  async loadFiles(path) {
64
109
  return loadPhpFiles(this, path);
65
110
  }
66
- async pushFile(file, task) {
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
+ }
150
+ // Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
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) {
157
+ const remote = await this.wp.get(spec.endpoint);
158
+ const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
159
+ if (orphans.length === 0)
160
+ return [];
161
+ const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
162
+ if (this.dryRun) {
163
+ this.log(`[dry-run] Would prune ${summary}`);
164
+ return orphans;
165
+ }
166
+ if (!this.yes) {
167
+ if (!isInteractive()) {
168
+ const count = pluralize(orphans.length, `server-side ${spec.noun}`);
169
+ this.error(`--prune would delete ${count} but stdin is not a TTY. Re-run with --yes to confirm, or without --prune.`);
170
+ }
171
+ const ok = await confirm({ default: false, message: `Prune ${summary}?` });
172
+ if (!ok) {
173
+ this.log('Kept the server-side files, nothing pruned.');
174
+ return [];
175
+ }
176
+ }
177
+ return orphans;
178
+ }
179
+ validateFileLocally(file, task) {
67
180
  if (!spec.filenamePattern.test(file.filename)) {
68
181
  const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
69
182
  this.reportTaskFailure(message, new Error(message), task);
@@ -74,26 +187,8 @@ export function resourcePushCommand(spec) {
74
187
  const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
75
188
  this.reportTaskFailure(message, new Error(message), task);
76
189
  }
77
- if (this.dryRun) {
78
- if (task)
79
- task.output = `[dry-run] Would push: ${file.filename}`;
80
- return;
81
- }
82
- try {
83
- const result = await this.wp.put(spec.endpoint, {
84
- content: file.content,
85
- filename: file.filename,
86
- });
87
- if (task) {
88
- task.output =
89
- result.syntax_check === 'skipped'
90
- ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
91
- : `Pushed: ${file.filename}`;
92
- }
93
- }
94
- catch (error) {
95
- this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
96
- }
190
+ if (task)
191
+ task.output = `Validated: ${file.filename}`;
97
192
  }
98
193
  }
99
194
  return ResourcePush;
@@ -112,19 +207,28 @@ export function resourcePullCommand(spec) {
112
207
  this.log(`Pulling ${spec.label} from ${url}`);
113
208
  this.log(`${spec.pathLabel} path: ${path}`);
114
209
  const files = await this.wp.get(spec.endpoint);
210
+ // A file the server would not read back (over the size limit, see LP-SEC-02) arrives
211
+ // with no content: report it and leave any local copy untouched rather than overwriting
212
+ // it with nothing.
213
+ const unreadable = files.filter((file) => typeof file.content !== 'string');
214
+ for (const file of unreadable) {
215
+ this.warn(`Skipped ${file.filename}: ${file.error ?? 'the server did not return its content'}`);
216
+ }
217
+ const readable = files.filter((file) => typeof file.content === 'string');
115
218
  // A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
116
- // additive-only, but pull already cleans up locally, same as `snippet pull`).
219
+ // additive-only, but pull already cleans up locally, same as `snippet pull`). The
220
+ // unreadable ones still exist remotely, so keep them out of the orphan set.
117
221
  const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
118
222
  extensions: ['.php'],
119
223
  key: basenameKey,
120
224
  recursive: true,
121
225
  });
122
- const pulled = files.map((file) => file.filename);
123
- await this.pullDirectory(path, files, orphans, {
226
+ const pulled = readable.map((file) => file.filename);
227
+ await this.pullDirectory(path, readable, orphans, {
124
228
  alwaysCreateDir: true,
125
- dryRunMessage: `Would pull ${pluralize(files.length, spec.noun)} to ${path}`,
229
+ dryRunMessage: `Would pull ${pluralize(readable.length, spec.noun)} to ${path}`,
126
230
  orphanReason: spec.orphanReason,
127
- pulledMessage: `Pulled ${pluralize(files.length, spec.noun)} to ${path}`,
231
+ pulledMessage: `Pulled ${pluralize(readable.length, spec.noun)} to ${path}`,
128
232
  title: (file) => file.filename,
129
233
  async write(file, writeDir) {
130
234
  const filePath = join(writeDir, `${file.filename}.php`);
@@ -156,13 +260,66 @@ export function resourceListCommand(spec) {
156
260
  this.log(`Found ${pluralize(files.length, spec.noun)}:`);
157
261
  this.log('');
158
262
  for (const file of files) {
159
- this.log(` ${file.filename}`);
263
+ const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
264
+ this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
265
+ }
266
+ if (files.some((file) => file.public)) {
267
+ this.log('');
268
+ this.warn('[PUBLIC] routes run for anyone, with no authentication. Review them.');
160
269
  }
161
270
  return files;
162
271
  }
163
272
  }
164
273
  return ResourceList;
165
274
  }
275
+ export function resourceRmCommand(spec) {
276
+ class ResourceRm extends LoopressCommand {
277
+ static args = {
278
+ filename: Args.string({
279
+ description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
280
+ required: true,
281
+ }),
282
+ };
283
+ static description = spec.rmDescription;
284
+ static enableJsonFlag = true;
285
+ static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} rm hello --yes`];
286
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
287
+ async run() {
288
+ const { args } = await this.parse(ResourceRm);
289
+ const { filename } = args;
290
+ const { url } = this.siteConfig;
291
+ if (!spec.filenamePattern.test(filename)) {
292
+ this.error(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
293
+ }
294
+ if (this.dryRun) {
295
+ this.log(`[dry-run] Would remove ${filename} from ${url}`);
296
+ return { filename, removed: false, status: 'dry-run' };
297
+ }
298
+ if (!this.yes) {
299
+ if (!isInteractive()) {
300
+ this.error(`Removing ${filename} needs confirmation. Re-run with --yes.`);
301
+ }
302
+ const ok = await confirm({ default: false, message: `Remove ${filename} from ${url}?` });
303
+ if (!ok) {
304
+ this.log('Aborted.');
305
+ return { filename, removed: false, status: 'aborted' };
306
+ }
307
+ }
308
+ try {
309
+ await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
310
+ }
311
+ catch (error) {
312
+ if (isApplicative404(error, 'File not found')) {
313
+ this.error(`${filename} is not on ${url}.`);
314
+ }
315
+ throw error;
316
+ }
317
+ this.log(`Removed ${filename} from ${url}`);
318
+ return { filename, removed: true, status: 'success' };
319
+ }
320
+ }
321
+ return ResourceRm;
322
+ }
166
323
  export const API_FILES_RESOURCE = {
167
324
  // Best-effort report of the current route list to the Loopress cloud, purely for console
168
325
  // visibility (US-18): the routes are already live on WordPress by this point, so this can
@@ -195,6 +352,7 @@ export const API_FILES_RESOURCE = {
195
352
  pathNoun: 'api directory',
196
353
  pullDescription: 'Pull custom API route files from WordPress',
197
354
  pushDescription: 'Push custom API route files to WordPress',
355
+ rmDescription: 'Remove a custom API route file from WordPress',
198
356
  };
199
357
  export const HOOK_FILES_RESOURCE = {
200
358
  cliName: 'hook',
@@ -213,4 +371,5 @@ export const HOOK_FILES_RESOURCE = {
213
371
  pathNoun: 'hooks directory',
214
372
  pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
215
373
  pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
374
+ rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
216
375
  };
@@ -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
+ }