@loopress/cli 0.24.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.
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { API_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(API_FILES_RESOURCE);
@@ -1,6 +1,8 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ import { type LockDriftEntry } from '../../utils/plugin-sync.js';
2
3
  type PushResult = {
3
4
  hasLock: boolean;
5
+ lockDrift: LockDriftEntry[];
4
6
  packageCount: number;
5
7
  status: 'dry-run' | 'success';
6
8
  };
@@ -14,5 +16,6 @@ export default class ComposerPush extends PushCommand {
14
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  };
16
18
  run(): Promise<PushResult>;
19
+ private reportLockDrift;
17
20
  }
18
21
  export {};
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { isTimeoutError } from '../../lib/wp-client.js';
7
- import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
7
+ import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
8
  // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
9
  // understands. `composer/installers` is owned by the server scaffold, never sent.
10
10
  function toIntent(require) {
@@ -25,7 +25,7 @@ function toIntent(require) {
25
25
  return intent;
26
26
  }
27
27
  export default class ComposerPush extends PushCommand {
28
- static description = 'Push composer.json (and composer.lock, if present) to WordPress and run Composer';
28
+ static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
29
29
  static enableJsonFlag = true;
30
30
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
31
31
  static flags = {
@@ -47,14 +47,16 @@ export default class ComposerPush extends PushCommand {
47
47
  const packageCount = Object.keys(require).length;
48
48
  const intent = toIntent(require);
49
49
  const hasLock = existsSync(composerLockPath);
50
+ // The server resolves composer.json itself; it never installs from this lock (a crafted
51
+ // lock could point package downloads at arbitrary hosts). It is sent only so the server
52
+ // can report which pinned versions its own resolution moved. `lps composer pull` brings
53
+ // the resolved lock back.
50
54
  const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
51
55
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
52
56
  if (lock)
53
- this.log(' + composer.lock included (reproducible install)');
54
- else
55
- this.warn('No composer.lock found. The server will resolve versions freely.');
57
+ this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
56
58
  if (this.dryRun)
57
- return { hasLock, packageCount, status: 'dry-run' };
59
+ return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
58
60
  this.log('Running Composer on the server, this can take a few minutes...');
59
61
  let response;
60
62
  try {
@@ -72,7 +74,22 @@ export default class ComposerPush extends PushCommand {
72
74
  if (response.output.trim())
73
75
  this.log(response.output.trim());
74
76
  this.log('Composer run completed on the server.');
77
+ const lockDrift = response.lockDrift ?? [];
78
+ this.reportLockDrift(lockDrift);
75
79
  await this.recordSuccess();
76
- return { hasLock, packageCount, status: 'success' };
80
+ return { hasLock, lockDrift, packageCount, status: 'success' };
81
+ }
82
+ // The server resolves composer.json from scratch and never installs from the uploaded lock,
83
+ // so a project that committed a lock can see versions move. Spell out exactly what changed
84
+ // and point at `lps composer pull` to bring the resolved lock back locally.
85
+ reportLockDrift(lockDrift) {
86
+ if (lockDrift.length === 0)
87
+ return;
88
+ this.log('');
89
+ this.log(`The server resolved ${lockDrift.length} ${lockDrift.length === 1 ? 'package' : 'packages'} to a different version than your local composer.lock:`);
90
+ for (const { from, name, to } of lockDrift) {
91
+ this.log(` ${name}: ${from ?? '(absent)'} -> ${to ?? '(removed)'}`);
92
+ }
93
+ this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
77
94
  }
78
95
  }
@@ -6,9 +6,11 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ 'allow-notifications': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
13
+ private allowNotifications;
12
14
  run(): Promise<void>;
13
15
  private ensureCanonicalFilename;
14
16
  private loadFiles;
@@ -1,4 +1,4 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { readFile, rename } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
@@ -12,15 +12,21 @@ export default class Push extends PushCommand {
12
12
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
13
13
  };
14
14
  static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
15
- static examples = ['$ lps form push'];
15
+ static examples = ['$ lps form push', '$ lps form push --allow-notifications'];
16
16
  static flags = {
17
17
  ...PushCommand.dryRunFlag,
18
18
  ...PushCommand.yesFlag,
19
+ 'allow-notifications': Flags.boolean({
20
+ default: false,
21
+ description: "Also push each form's notification and confirmation settings (recipients, sender, messages). Off by default: the server keeps its own so a stray push can't redirect submissions.",
22
+ }),
19
23
  };
24
+ allowNotifications = false;
20
25
  async run() {
21
- const { args } = await this.parse(Push);
26
+ const { args, flags } = await this.parse(Push);
22
27
  const { url } = this.siteConfig;
23
28
  const path = this.resolveFormPath(args.path);
29
+ this.allowNotifications = flags['allow-notifications'];
24
30
  this.log(`Pushing forms to ${url}`);
25
31
  this.log(`Forms path: ${path}`);
26
32
  const files = await this.loadFiles(path);
@@ -78,9 +84,10 @@ export default class Push extends PushCommand {
78
84
  }
79
85
  try {
80
86
  const id = getFormId(data);
87
+ const payload = this.allowNotifications ? { ...data, allowNotifications: true } : data;
81
88
  const { body, created } = await putOrCreate(this.wp, {
82
89
  id,
83
- payload: data,
90
+ payload,
84
91
  postEndpoint: FORM_ENDPOINT,
85
92
  putEndpoint: (formId) => `${FORM_ENDPOINT}/${formId}`,
86
93
  });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(HOOK_FILES_RESOURCE);
@@ -13,8 +13,9 @@ export default class Login extends Command {
13
13
  }
14
14
  async waitForCallback() {
15
15
  return waitForLocalCallback({
16
- buildUrl(callbackBaseUrl) {
17
- const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback`);
16
+ allowedOrigins: [CONSOLE_URL],
17
+ buildUrl(callbackBaseUrl, state) {
18
+ const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback?state=${state}`);
18
19
  return `${CONSOLE_URL}/cli-auth?callbackUrl=${callbackUrl}`;
19
20
  },
20
21
  handleRequest(url, { resolveWithPage, respondBadRequest }) {
@@ -6,9 +6,11 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ 'allow-external-redirects': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
13
+ private allowExternalRedirects;
12
14
  run(): Promise<void>;
13
15
  private jsonFilesIn;
14
16
  private pushPostMeta;
@@ -1,4 +1,4 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
@@ -11,15 +11,21 @@ export default class Push extends PushCommand {
11
11
  path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
12
12
  };
13
13
  static description = 'Push SEO settings, post meta, and redirects to WordPress. Local redirect files created remotely are renamed on disk to the `<id>-<slug>` convention. Fails clearly per file if the active SEO plugin does not support redirects.';
14
- static examples = ['$ lps seo push'];
14
+ static examples = ['$ lps seo push', '$ lps seo push --allow-external-redirects'];
15
15
  static flags = {
16
16
  ...PushCommand.dryRunFlag,
17
17
  ...PushCommand.yesFlag,
18
+ 'allow-external-redirects': Flags.boolean({
19
+ default: false,
20
+ description: 'Allow pushing a redirect whose target points off this site. Off by default: the server rejects an off-site `urlTo` so a stray push can\'t 301 traffic away.',
21
+ }),
18
22
  };
23
+ allowExternalRedirects = false;
19
24
  async run() {
20
- const { args } = await this.parse(Push);
25
+ const { args, flags } = await this.parse(Push);
21
26
  const { url } = this.siteConfig;
22
27
  const path = this.resolveSeoPath(args.path);
28
+ this.allowExternalRedirects = flags['allow-external-redirects'];
23
29
  this.log(`Pushing SEO configuration to ${url}`);
24
30
  this.log(`SEO path: ${path}`);
25
31
  await this.pushSettings(path);
@@ -74,7 +80,13 @@ export default class Push extends PushCommand {
74
80
  }
75
81
  try {
76
82
  const redirect = JSON.parse(await readFile(filePath, 'utf8'));
77
- const payload = { headerCode: redirect.headerCode, sources: redirect.sources, status: redirect.status, urlTo: redirect.urlTo };
83
+ const payload = {
84
+ headerCode: redirect.headerCode,
85
+ sources: redirect.sources,
86
+ status: redirect.status,
87
+ urlTo: redirect.urlTo,
88
+ ...(this.allowExternalRedirects && { allowExternal: true }),
89
+ };
78
90
  // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
79
91
  // falls back to POST instead of failing, adopting whatever id the site assigns.
80
92
  const { body, created } = await putOrCreate(this.wp, {
@@ -1,4 +1,4 @@
1
- import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, SENTRY_DSN, } from '../lib/sentry.js';
1
+ import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, scrubEvent, SENTRY_DSN, } from '../lib/sentry.js';
2
2
  // oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
3
3
  // is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
4
4
  // the error, if any, so it's where we report crashes before the process exits.
@@ -12,6 +12,9 @@ const hook = async function (options) {
12
12
  try {
13
13
  const Sentry = await import('@sentry/node');
14
14
  Sentry.init({
15
+ // Strip the site URL and the raw server response body out of every event. A WordPress
16
+ // request failure otherwise carries both in its message (F26).
17
+ beforeSend: scrubEvent,
15
18
  dsn: SENTRY_DSN,
16
19
  environment: resolveEnvironment(),
17
20
  release: this.config.version,
@@ -7,9 +7,22 @@ export type CallbackHelpers<T> = {
7
7
  /**
8
8
  * Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
9
9
  * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
10
+ *
11
+ * The loopback server is unauthenticated, so any local process or web page open during the wait
12
+ * could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
13
+ *
14
+ * - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
15
+ * back (constant-time compare) on any request that carries credentials;
16
+ * - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
17
+ * stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
18
+ * `Origin` and are allowed, which is why `state` is the primary control.
19
+ *
20
+ * The first request that looks like a callback (valid or not) shuts the server down, so a
21
+ * failed guess gets no second try within the window.
10
22
  */
11
23
  export declare function waitForLocalCallback<T>(options: {
12
- buildUrl: (callbackBaseUrl: string) => string;
24
+ allowedOrigins: string[];
25
+ buildUrl: (callbackBaseUrl: string, state: string) => string;
13
26
  handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
14
27
  log: (message: string) => void;
15
28
  openingMessage: string;
@@ -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,16 @@ 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;
29
32
  };
30
33
  type PushResult = {
34
+ pruned: string[];
31
35
  pushed: string[];
32
36
  status: 'dry-run' | 'success';
33
37
  };
@@ -36,6 +40,11 @@ type PullResult = {
36
40
  pulled: string[];
37
41
  status: 'dry-run' | 'success';
38
42
  };
43
+ type RmResult = {
44
+ filename: string;
45
+ removed: boolean;
46
+ status: 'aborted' | 'dry-run' | 'success';
47
+ };
39
48
  export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
40
49
  export type PushFilesCommand = Omit<PushCommand, 'run'> & {
41
50
  run(): Promise<PushResult>;
@@ -44,11 +53,15 @@ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
44
53
  run(): Promise<PullResult>;
45
54
  };
46
55
  export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
47
- run(): Promise<PhpFile[]>;
56
+ run(): Promise<RemotePhpFile[]>;
57
+ };
58
+ export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
59
+ run(): Promise<RmResult>;
48
60
  };
49
61
  export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
50
62
  export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
51
63
  export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
64
+ export declare function resourceRmCommand(spec: PhpFilesResource): CommandClass<RmFilesCommand>;
52
65
  export declare const API_FILES_RESOURCE: PhpFilesResource;
53
66
  export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
54
67
  export {};
@@ -1,4 +1,5 @@
1
- import { Args } from '@oclif/core';
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join, relative, sep } from 'node:path';
4
5
  import { authManager } from '../config/auth.manager.js';
@@ -7,8 +8,10 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
7
8
  import { ApiClient } from './api-client.js';
8
9
  import { LoopressCommand } from './base.js';
9
10
  import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
11
+ import { isInteractive } from './interactive.js';
10
12
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
11
13
  import { PushCommand } from './push-command.js';
14
+ import { isApplicative404 } from './wp-client.js';
12
15
  // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
13
16
  // an absent declare(strict_types=1); and one that appears more than once (it needs a single
14
17
  // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
@@ -35,10 +38,21 @@ export function resourcePushCommand(spec) {
35
38
  static args = pathArg(spec.pathNoun);
36
39
  static description = spec.pushDescription;
37
40
  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 };
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
+ };
40
54
  async run() {
41
- const { args } = await this.parse(ResourcePush);
55
+ const { args, flags } = await this.parse(ResourcePush);
42
56
  const { url } = this.siteConfig;
43
57
  const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
44
58
  this.log(`Pushing ${spec.label} to ${url}`);
@@ -46,23 +60,66 @@ export function resourcePushCommand(spec) {
46
60
  const files = await this.loadFiles(path);
47
61
  this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
48
62
  const pushed = [];
63
+ const publicRoutes = [];
49
64
  await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
50
- await this.pushFile(file, task);
65
+ const result = await this.pushFile(file, task);
51
66
  pushed.push(file.filename);
67
+ if (result?.public)
68
+ publicRoutes.push(file.filename);
52
69
  });
53
70
  if (this.failedCount > 0) {
54
71
  this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
55
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))) : [];
56
77
  if (this.dryRun)
57
- return { pushed, status: 'dry-run' };
78
+ return { pruned, pushed, status: 'dry-run' };
58
79
  await this.recordSuccess();
59
80
  await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
60
81
  this.log(`All ${spec.label} pushed.`);
61
- return { pushed, status: 'success' };
82
+ return { pruned, pushed, status: 'success' };
62
83
  }
63
84
  async loadFiles(path) {
64
85
  return loadPhpFiles(this, path);
65
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
+ }
66
123
  async pushFile(file, task) {
67
124
  if (!spec.filenamePattern.test(file.filename)) {
68
125
  const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
@@ -77,7 +134,7 @@ export function resourcePushCommand(spec) {
77
134
  if (this.dryRun) {
78
135
  if (task)
79
136
  task.output = `[dry-run] Would push: ${file.filename}`;
80
- return;
137
+ return undefined;
81
138
  }
82
139
  try {
83
140
  const result = await this.wp.put(spec.endpoint, {
@@ -90,6 +147,7 @@ export function resourcePushCommand(spec) {
90
147
  ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
91
148
  : `Pushed: ${file.filename}`;
92
149
  }
150
+ return result;
93
151
  }
94
152
  catch (error) {
95
153
  this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
@@ -112,19 +170,28 @@ export function resourcePullCommand(spec) {
112
170
  this.log(`Pulling ${spec.label} from ${url}`);
113
171
  this.log(`${spec.pathLabel} path: ${path}`);
114
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');
115
181
  // 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`).
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.
117
184
  const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
118
185
  extensions: ['.php'],
119
186
  key: basenameKey,
120
187
  recursive: true,
121
188
  });
122
- const pulled = files.map((file) => file.filename);
123
- await this.pullDirectory(path, files, orphans, {
189
+ const pulled = readable.map((file) => file.filename);
190
+ await this.pullDirectory(path, readable, orphans, {
124
191
  alwaysCreateDir: true,
125
- dryRunMessage: `Would pull ${pluralize(files.length, spec.noun)} to ${path}`,
192
+ dryRunMessage: `Would pull ${pluralize(readable.length, spec.noun)} to ${path}`,
126
193
  orphanReason: spec.orphanReason,
127
- pulledMessage: `Pulled ${pluralize(files.length, spec.noun)} to ${path}`,
194
+ pulledMessage: `Pulled ${pluralize(readable.length, spec.noun)} to ${path}`,
128
195
  title: (file) => file.filename,
129
196
  async write(file, writeDir) {
130
197
  const filePath = join(writeDir, `${file.filename}.php`);
@@ -156,13 +223,66 @@ export function resourceListCommand(spec) {
156
223
  this.log(`Found ${pluralize(files.length, spec.noun)}:`);
157
224
  this.log('');
158
225
  for (const file of files) {
159
- this.log(` ${file.filename}`);
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.');
160
232
  }
161
233
  return files;
162
234
  }
163
235
  }
164
236
  return ResourceList;
165
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
+ }
166
286
  export const API_FILES_RESOURCE = {
167
287
  // Best-effort report of the current route list to the Loopress cloud, purely for console
168
288
  // visibility (US-18): the routes are already live on WordPress by this point, so this can
@@ -195,6 +315,7 @@ export const API_FILES_RESOURCE = {
195
315
  pathNoun: 'api directory',
196
316
  pullDescription: 'Pull custom API route files from WordPress',
197
317
  pushDescription: 'Push custom API route files to WordPress',
318
+ rmDescription: 'Remove a custom API route file from WordPress',
198
319
  };
199
320
  export const HOOK_FILES_RESOURCE = {
200
321
  cliName: 'hook',
@@ -213,4 +334,5 @@ export const HOOK_FILES_RESOURCE = {
213
334
  pathNoun: 'hooks directory',
214
335
  pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
215
336
  pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
337
+ rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
216
338
  };
@@ -1,3 +1,4 @@
1
+ import type { ErrorEvent } from '@sentry/node';
1
2
  export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
2
3
  export declare function isTelemetryDisabled(): boolean;
3
4
  export declare function resolveEnvironment(): string;
@@ -6,3 +7,5 @@ export declare function runtimeContext(): {
6
7
  os: string;
7
8
  };
8
9
  export declare function redactArgv(argv: string[]): string[];
10
+ export declare function scrubErrorMessage(value: string): string;
11
+ export declare function scrubEvent(event: ErrorEvent): ErrorEvent;