@loopress/cli 0.23.0 → 0.24.1

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 (101) hide show
  1. package/README.md +603 -94
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/api/rm.d.ts +2 -0
  13. package/dist/commands/api/rm.js +2 -0
  14. package/dist/commands/composer/pull.js +6 -16
  15. package/dist/commands/composer/push.d.ts +3 -0
  16. package/dist/commands/composer/push.js +24 -7
  17. package/dist/commands/dev.js +1 -1
  18. package/dist/commands/diff.d.ts +13 -0
  19. package/dist/commands/diff.js +42 -0
  20. package/dist/commands/form/diff.d.ts +2 -0
  21. package/dist/commands/form/diff.js +5 -0
  22. package/dist/commands/form/push.d.ts +2 -0
  23. package/dist/commands/form/push.js +11 -4
  24. package/dist/commands/hook/diff.d.ts +2 -0
  25. package/dist/commands/hook/diff.js +5 -0
  26. package/dist/commands/hook/list.d.ts +2 -0
  27. package/dist/commands/hook/list.js +2 -0
  28. package/dist/commands/hook/pull.d.ts +2 -0
  29. package/dist/commands/hook/pull.js +2 -0
  30. package/dist/commands/hook/push.d.ts +2 -0
  31. package/dist/commands/hook/push.js +2 -0
  32. package/dist/commands/hook/rm.d.ts +2 -0
  33. package/dist/commands/hook/rm.js +2 -0
  34. package/dist/commands/init.js +1 -1
  35. package/dist/commands/login.js +3 -2
  36. package/dist/commands/option/add.d.ts +15 -0
  37. package/dist/commands/option/add.js +47 -0
  38. package/dist/commands/option/diff.d.ts +2 -0
  39. package/dist/commands/option/diff.js +5 -0
  40. package/dist/commands/{page → option}/list.d.ts +5 -1
  41. package/dist/commands/option/list.js +151 -0
  42. package/dist/commands/{page → option}/pull.d.ts +3 -12
  43. package/dist/commands/option/pull.js +75 -0
  44. package/dist/commands/{page → option}/push.d.ts +3 -8
  45. package/dist/commands/option/push.js +66 -0
  46. package/dist/commands/option/remove.d.ts +22 -0
  47. package/dist/commands/option/remove.js +69 -0
  48. package/dist/commands/plugin/push.d.ts +0 -1
  49. package/dist/commands/plugin/push.js +3 -10
  50. package/dist/commands/pull.js +3 -2
  51. package/dist/commands/push.js +4 -3
  52. package/dist/commands/seo/diff.d.ts +2 -0
  53. package/dist/commands/seo/diff.js +5 -0
  54. package/dist/commands/seo/push.d.ts +2 -0
  55. package/dist/commands/seo/push.js +16 -4
  56. package/dist/commands/snippet/diff.d.ts +2 -0
  57. package/dist/commands/snippet/diff.js +5 -0
  58. package/dist/commands/theme/push.d.ts +0 -1
  59. package/dist/commands/theme/push.js +3 -10
  60. package/dist/hooks/finally.js +4 -1
  61. package/dist/lib/base.d.ts +2 -1
  62. package/dist/lib/base.js +12 -22
  63. package/dist/lib/dev-watch.d.ts +1 -1
  64. package/dist/lib/dev-watch.js +1 -2
  65. package/dist/lib/diff-command.d.ts +47 -0
  66. package/dist/lib/diff-command.js +180 -0
  67. package/dist/lib/diff-state.d.ts +22 -0
  68. package/dist/lib/diff-state.js +88 -0
  69. package/dist/lib/find-orphaned-files.js +1 -1
  70. package/dist/lib/interactive.d.ts +1 -0
  71. package/dist/lib/interactive.js +10 -0
  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 +67 -0
  75. package/dist/lib/php-files-command.js +338 -0
  76. package/dist/lib/put-or-create.js +1 -1
  77. package/dist/lib/readdir-tolerant.d.ts +1 -0
  78. package/dist/lib/readdir-tolerant.js +3 -1
  79. package/dist/lib/resource-state.d.ts +14 -0
  80. package/dist/lib/resource-state.js +361 -0
  81. package/dist/lib/sentry.d.ts +3 -0
  82. package/dist/lib/sentry.js +30 -0
  83. package/dist/lib/validate-local.js +15 -7
  84. package/dist/lib/wp-authorize-flow.d.ts +4 -0
  85. package/dist/lib/wp-authorize-flow.js +10 -8
  86. package/dist/lib/wp-client.d.ts +5 -0
  87. package/dist/lib/wp-client.js +56 -0
  88. package/dist/types/project-config.generated.d.ts +8 -4
  89. package/dist/utils/option-format.d.ts +31 -0
  90. package/dist/utils/option-format.js +69 -0
  91. package/dist/utils/plugin-sync.d.ts +6 -0
  92. package/dist/utils/resource-dirs.d.ts +13 -0
  93. package/dist/utils/resource-dirs.js +34 -0
  94. package/oclif.manifest.json +1488 -301
  95. package/package.json +11 -8
  96. package/schemas/project-config.schema.json +10 -5
  97. package/dist/commands/page/list.js +0 -19
  98. package/dist/commands/page/pull.js +0 -57
  99. package/dist/commands/page/push.js +0 -132
  100. package/dist/utils/page-format.d.ts +0 -7
  101. package/dist/utils/page-format.js +0 -78
@@ -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);
@@ -0,0 +1,67 @@
1
+ import { type Config } from '@oclif/core';
2
+ import { type EnvironmentConfig } from '../types/config.js';
3
+ import { type ResourceDirKind } from '../utils/resource-dirs.js';
4
+ import { LoopressCommand } from './base.js';
5
+ import { PushCommand } from './push-command.js';
6
+ export type PhpFilesResource = {
7
+ afterPush?: (context: {
8
+ filenames: string[];
9
+ siteConfig: EnvironmentConfig;
10
+ }) => Promise<void>;
11
+ cliName: string;
12
+ dirKind: ResourceDirKind;
13
+ emptyListMessage: string;
14
+ endpoint: string;
15
+ filenamePattern: RegExp;
16
+ invalidFilenameHint: string;
17
+ label: string;
18
+ listDescription: string;
19
+ noun: string;
20
+ orphanReason: string;
21
+ pathLabel: string;
22
+ pathNoun: string;
23
+ pullDescription: string;
24
+ pushDescription: string;
25
+ rmDescription: string;
26
+ };
27
+ type RemotePhpFile = {
28
+ content?: string;
29
+ error?: string;
30
+ filename: string;
31
+ public?: boolean;
32
+ };
33
+ type PushResult = {
34
+ pruned: string[];
35
+ pushed: string[];
36
+ status: 'dry-run' | 'success';
37
+ };
38
+ type PullResult = {
39
+ orphans: string[];
40
+ pulled: string[];
41
+ status: 'dry-run' | 'success';
42
+ };
43
+ type RmResult = {
44
+ filename: string;
45
+ removed: boolean;
46
+ status: 'aborted' | 'dry-run' | 'success';
47
+ };
48
+ export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
49
+ export type PushFilesCommand = Omit<PushCommand, 'run'> & {
50
+ run(): Promise<PushResult>;
51
+ };
52
+ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
53
+ run(): Promise<PullResult>;
54
+ };
55
+ export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
56
+ run(): Promise<RemotePhpFile[]>;
57
+ };
58
+ export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
59
+ run(): Promise<RmResult>;
60
+ };
61
+ export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
62
+ export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
63
+ export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
64
+ export declare function resourceRmCommand(spec: PhpFilesResource): CommandClass<RmFilesCommand>;
65
+ export declare const API_FILES_RESOURCE: PhpFilesResource;
66
+ export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
67
+ export {};
@@ -0,0 +1,338 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, relative, sep } from 'node:path';
5
+ import { authManager } from '../config/auth.manager.js';
6
+ import { pluralize } from '../utils/pluralize.js';
7
+ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
+ import { ApiClient } from './api-client.js';
9
+ import { LoopressCommand } from './base.js';
10
+ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
11
+ import { isInteractive } from './interactive.js';
12
+ import { loadFiles as loadDirectoryFiles } from './load-files.js';
13
+ import { PushCommand } from './push-command.js';
14
+ import { isApplicative404 } from './wp-client.js';
15
+ // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
16
+ // an absent declare(strict_types=1); and one that appears more than once (it needs a single
17
+ // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
18
+ // presence, so a file that would still fail server-side doesn't falsely pass this check.
19
+ const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
20
+ function pathArg(pathNoun) {
21
+ return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
22
+ }
23
+ // relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects '/',
24
+ // same as any URL or import path.
25
+ async function loadPhpFiles(command, dir) {
26
+ return loadDirectoryFiles(dir, {
27
+ extension: '.php',
28
+ onSkip(message) { command.warn(message); },
29
+ parse: (raw, filePath) => ({
30
+ content: raw,
31
+ filename: relative(dir, filePath).slice(0, -'.php'.length).split(sep).join('/'),
32
+ }),
33
+ recursive: true,
34
+ });
35
+ }
36
+ export function resourcePushCommand(spec) {
37
+ class ResourcePush extends PushCommand {
38
+ static args = pathArg(spec.pathNoun);
39
+ static description = spec.pushDescription;
40
+ static enableJsonFlag = true;
41
+ static examples = [
42
+ `$ lps ${spec.cliName} push`,
43
+ `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
44
+ `$ lps ${spec.cliName} push --prune`,
45
+ ];
46
+ static flags = {
47
+ ...PushCommand.dryRunFlag,
48
+ ...PushCommand.yesFlag,
49
+ prune: Flags.boolean({
50
+ default: false,
51
+ description: `Delete server-side ${spec.noun}s not present locally after pushing`,
52
+ }),
53
+ };
54
+ async run() {
55
+ const { args, flags } = await this.parse(ResourcePush);
56
+ const { url } = this.siteConfig;
57
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
58
+ this.log(`Pushing ${spec.label} to ${url}`);
59
+ this.log(`${spec.pathLabel} path: ${path}`);
60
+ const files = await this.loadFiles(path);
61
+ this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
62
+ const pushed = [];
63
+ const publicRoutes = [];
64
+ 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);
69
+ });
70
+ if (this.failedCount > 0) {
71
+ this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
72
+ }
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`);
75
+ }
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' };
79
+ await this.recordSuccess();
80
+ await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
81
+ this.log(`All ${spec.label} pushed.`);
82
+ return { pruned, pushed, status: 'success' };
83
+ }
84
+ async loadFiles(path) {
85
+ return loadPhpFiles(this, path);
86
+ }
87
+ // 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) {
93
+ const remote = await this.wp.get(spec.endpoint);
94
+ const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
95
+ if (orphans.length === 0)
96
+ return [];
97
+ const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
98
+ if (this.dryRun) {
99
+ this.log(`[dry-run] Would prune ${summary}`);
100
+ return orphans;
101
+ }
102
+ if (!this.yes) {
103
+ if (!isInteractive()) {
104
+ const count = pluralize(orphans.length, `server-side ${spec.noun}`);
105
+ this.error(`--prune would delete ${count} but stdin is not a TTY. Re-run with --yes to confirm, or without --prune.`);
106
+ }
107
+ const ok = await confirm({ default: false, message: `Prune ${summary}?` });
108
+ if (!ok) {
109
+ this.log('Kept the server-side files, nothing pruned.');
110
+ return [];
111
+ }
112
+ }
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;
122
+ }
123
+ async pushFile(file, task) {
124
+ if (!spec.filenamePattern.test(file.filename)) {
125
+ const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
126
+ this.reportTaskFailure(message, new Error(message), task);
127
+ }
128
+ const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
129
+ if (declareCount !== 1) {
130
+ const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
131
+ const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
132
+ this.reportTaskFailure(message, new Error(message), task);
133
+ }
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
+ }
155
+ }
156
+ }
157
+ return ResourcePush;
158
+ }
159
+ export function resourcePullCommand(spec) {
160
+ class ResourcePull extends LoopressCommand {
161
+ static args = pathArg(spec.pathNoun);
162
+ static description = spec.pullDescription;
163
+ static enableJsonFlag = true;
164
+ static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
165
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
166
+ async run() {
167
+ const { args } = await this.parse(ResourcePull);
168
+ const { url } = this.siteConfig;
169
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
170
+ this.log(`Pulling ${spec.label} from ${url}`);
171
+ this.log(`${spec.pathLabel} path: ${path}`);
172
+ const files = await this.wp.get(spec.endpoint);
173
+ // A file the server would not read back (over the size limit, see LP-SEC-02) arrives
174
+ // with no content: report it and leave any local copy untouched rather than overwriting
175
+ // it with nothing.
176
+ const unreadable = files.filter((file) => typeof file.content !== 'string');
177
+ for (const file of unreadable) {
178
+ this.warn(`Skipped ${file.filename}: ${file.error ?? 'the server did not return its content'}`);
179
+ }
180
+ const readable = files.filter((file) => typeof file.content === 'string');
181
+ // A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
182
+ // additive-only, but pull already cleans up locally, same as `snippet pull`). The
183
+ // unreadable ones still exist remotely, so keep them out of the orphan set.
184
+ const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
185
+ extensions: ['.php'],
186
+ key: basenameKey,
187
+ recursive: true,
188
+ });
189
+ const pulled = readable.map((file) => file.filename);
190
+ await this.pullDirectory(path, readable, orphans, {
191
+ alwaysCreateDir: true,
192
+ dryRunMessage: `Would pull ${pluralize(readable.length, spec.noun)} to ${path}`,
193
+ orphanReason: spec.orphanReason,
194
+ pulledMessage: `Pulled ${pluralize(readable.length, spec.noun)} to ${path}`,
195
+ title: (file) => file.filename,
196
+ async write(file, writeDir) {
197
+ const filePath = join(writeDir, `${file.filename}.php`);
198
+ // filename can contain '/' (a nested slug, e.g. content/filters or a path-param
199
+ // route invoice-pdf/[order_id]): writeFile() doesn't create parent directories on
200
+ // its own the way mkdir()'s recursive option does above for the top-level path.
201
+ await mkdir(dirname(filePath), { recursive: true });
202
+ await writeFile(filePath, file.content);
203
+ },
204
+ });
205
+ if (this.dryRun)
206
+ return { orphans, pulled, status: 'dry-run' };
207
+ return { orphans, pulled, status: 'success' };
208
+ }
209
+ }
210
+ return ResourcePull;
211
+ }
212
+ export function resourceListCommand(spec) {
213
+ class ResourceList extends LoopressCommand {
214
+ static description = spec.listDescription;
215
+ static enableJsonFlag = true;
216
+ static examples = [`$ lps ${spec.cliName} list`];
217
+ async run() {
218
+ const files = await this.wp.get(spec.endpoint);
219
+ if (files.length === 0) {
220
+ this.log(spec.emptyListMessage);
221
+ return files;
222
+ }
223
+ this.log(`Found ${pluralize(files.length, spec.noun)}:`);
224
+ this.log('');
225
+ for (const file of files) {
226
+ const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
227
+ this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
228
+ }
229
+ if (files.some((file) => file.public)) {
230
+ this.log('');
231
+ this.warn('[PUBLIC] routes run for anyone, with no authentication. Review them.');
232
+ }
233
+ return files;
234
+ }
235
+ }
236
+ return ResourceList;
237
+ }
238
+ export function resourceRmCommand(spec) {
239
+ class ResourceRm extends LoopressCommand {
240
+ static args = {
241
+ filename: Args.string({
242
+ description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
243
+ required: true,
244
+ }),
245
+ };
246
+ static description = spec.rmDescription;
247
+ static enableJsonFlag = true;
248
+ static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} rm hello --yes`];
249
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
250
+ async run() {
251
+ const { args } = await this.parse(ResourceRm);
252
+ const { filename } = args;
253
+ const { url } = this.siteConfig;
254
+ if (!spec.filenamePattern.test(filename)) {
255
+ this.error(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
256
+ }
257
+ if (this.dryRun) {
258
+ this.log(`[dry-run] Would remove ${filename} from ${url}`);
259
+ return { filename, removed: false, status: 'dry-run' };
260
+ }
261
+ if (!this.yes) {
262
+ if (!isInteractive()) {
263
+ this.error(`Removing ${filename} needs confirmation. Re-run with --yes.`);
264
+ }
265
+ const ok = await confirm({ default: false, message: `Remove ${filename} from ${url}?` });
266
+ if (!ok) {
267
+ this.log('Aborted.');
268
+ return { filename, removed: false, status: 'aborted' };
269
+ }
270
+ }
271
+ try {
272
+ await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
273
+ }
274
+ catch (error) {
275
+ if (isApplicative404(error, 'File not found')) {
276
+ this.error(`${filename} is not on ${url}.`);
277
+ }
278
+ throw error;
279
+ }
280
+ this.log(`Removed ${filename} from ${url}`);
281
+ return { filename, removed: true, status: 'success' };
282
+ }
283
+ }
284
+ return ResourceRm;
285
+ }
286
+ export const API_FILES_RESOURCE = {
287
+ // Best-effort report of the current route list to the Loopress cloud, purely for console
288
+ // visibility (US-18): the routes are already live on WordPress by this point, so this can
289
+ // never block or fail the push, same reasoning as `recordDeployment` in PushCommand.
290
+ async afterPush({ filenames, siteConfig }) {
291
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
292
+ const { apiEnvironmentId } = siteConfig;
293
+ if (!token || !apiEnvironmentId)
294
+ return;
295
+ try {
296
+ await new ApiClient(token).put('api-routes', { environmentId: apiEnvironmentId, filenames });
297
+ }
298
+ catch {
299
+ // non-blocking: reporting the route list must never interrupt the push flow
300
+ }
301
+ },
302
+ cliName: 'api',
303
+ dirKind: 'api',
304
+ emptyListMessage: 'No API route files found',
305
+ endpoint: 'loopress/v1/api-files',
306
+ // Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
307
+ // segment like [order_id], joined by '/'.
308
+ filenamePattern: /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
309
+ invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php")',
310
+ label: 'API routes',
311
+ listDescription: 'List custom API route files from WordPress',
312
+ noun: 'route file',
313
+ orphanReason: 'whose route no longer exists on WordPress',
314
+ pathLabel: 'API',
315
+ pathNoun: 'api directory',
316
+ pullDescription: 'Pull custom API route files from WordPress',
317
+ pushDescription: 'Push custom API route files to WordPress',
318
+ rmDescription: 'Remove a custom API route file from WordPress',
319
+ };
320
+ export const HOOK_FILES_RESOURCE = {
321
+ cliName: 'hook',
322
+ dirKind: 'hooks',
323
+ emptyListMessage: 'No hook files found',
324
+ endpoint: 'loopress/v1/hook-files',
325
+ // Mirrors HookFilesController::isValidFilename(): kebab-case segments joined by '/', no
326
+ // bracketed dynamic-segment alternative (a hook slug is never a URL path).
327
+ filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
328
+ invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
329
+ label: 'hooks',
330
+ listDescription: 'List hook files (actions, filters, cron) from WordPress',
331
+ noun: 'hook file',
332
+ orphanReason: 'no longer present on WordPress',
333
+ pathLabel: 'Hooks',
334
+ pathNoun: 'hooks directory',
335
+ pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
336
+ pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
337
+ rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
338
+ };
@@ -1,5 +1,5 @@
1
1
  import { isNotFoundError } from './wp-client.js';
2
- // Update-or-create dance shared by every id-based resource (form, page, seo redirect,
2
+ // Update-or-create dance shared by every id-based resource (form, seo redirect,
3
3
  // snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
4
4
  // fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
5
5
  // database reset.
@@ -1,5 +1,6 @@
1
1
  import { type Dirent } from 'node:fs';
2
2
  export declare function readdirTolerant(dir: string): Promise<string[]>;
3
3
  export declare function readdirTolerant(dir: string, options: {
4
+ recursive?: boolean;
4
5
  withFileTypes: true;
5
6
  }): Promise<Dirent[]>;
@@ -1,7 +1,9 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  export async function readdirTolerant(dir, options) {
3
3
  try {
4
- return options?.withFileTypes ? await readdir(dir, { withFileTypes: true }) : await readdir(dir);
4
+ return options?.withFileTypes
5
+ ? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
6
+ : await readdir(dir, { recursive: options?.recursive });
5
7
  }
6
8
  catch (error) {
7
9
  if (error.code === 'ENOENT')
@@ -0,0 +1,14 @@
1
+ import { type ResourceDirKind } from '../utils/resource-dirs.js';
2
+ import { type ResourceState } from './diff-state.js';
3
+ import { type WpClient } from './wp-client.js';
4
+ export type ResourceStateProvider = {
5
+ dirKind: ResourceDirKind;
6
+ local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
7
+ remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
+ resource: string;
9
+ title: string;
10
+ };
11
+ export declare const RESOURCE_STATE_PROVIDERS: ResourceStateProvider[];
12
+ export declare function getResourceStateProvider(resource: string): ResourceStateProvider;
13
+ export declare function composerRemoteState(wp: WpClient): Promise<ResourceState>;
14
+ export declare function composerLocalState(rootDir: string): Promise<ResourceState>;