@loopress/cli 0.17.0 → 0.19.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 (68) hide show
  1. package/README.md +266 -59
  2. package/dist/commands/acf/pull.d.ts +1 -1
  3. package/dist/commands/acf/pull.js +12 -25
  4. package/dist/commands/acf/push.d.ts +1 -1
  5. package/dist/commands/acf/push.js +18 -47
  6. package/dist/commands/api/list.d.ts +9 -0
  7. package/dist/commands/api/list.js +26 -0
  8. package/dist/commands/api/pull.d.ts +13 -0
  9. package/dist/commands/api/pull.js +49 -0
  10. package/dist/commands/api/push.d.ts +15 -0
  11. package/dist/commands/api/push.js +70 -0
  12. package/dist/commands/composer/init.js +11 -1
  13. package/dist/commands/composer/push.d.ts +1 -0
  14. package/dist/commands/composer/push.js +17 -4
  15. package/dist/commands/doctor.d.ts +8 -0
  16. package/dist/commands/doctor.js +79 -0
  17. package/dist/commands/form/list.d.ts +9 -0
  18. package/dist/commands/form/list.js +26 -0
  19. package/dist/commands/form/pull.d.ts +13 -0
  20. package/dist/commands/form/pull.js +60 -0
  21. package/dist/commands/form/push.d.ts +16 -0
  22. package/dist/commands/form/push.js +122 -0
  23. package/dist/commands/init.d.ts +2 -0
  24. package/dist/commands/init.js +64 -33
  25. package/dist/commands/plugin/push.d.ts +1 -1
  26. package/dist/commands/plugin/push.js +2 -14
  27. package/dist/commands/project/config.js +4 -0
  28. package/dist/commands/project/push.d.ts +5 -0
  29. package/dist/commands/project/push.js +21 -11
  30. package/dist/commands/seo/pull.d.ts +1 -3
  31. package/dist/commands/seo/pull.js +15 -43
  32. package/dist/commands/seo/push.d.ts +1 -1
  33. package/dist/commands/seo/push.js +4 -17
  34. package/dist/commands/snippet/publish.js +2 -2
  35. package/dist/commands/snippet/pull.d.ts +1 -4
  36. package/dist/commands/snippet/pull.js +12 -60
  37. package/dist/commands/snippet/push.d.ts +1 -1
  38. package/dist/commands/snippet/push.js +4 -13
  39. package/dist/commands/status.d.ts +4 -0
  40. package/dist/commands/status.js +25 -4
  41. package/dist/config/project-config.manager.js +2 -2
  42. package/dist/lib/api-client.d.ts +2 -1
  43. package/dist/lib/api-client.js +10 -3
  44. package/dist/lib/base.d.ts +11 -0
  45. package/dist/lib/base.js +71 -2
  46. package/dist/lib/find-orphaned-files.d.ts +7 -0
  47. package/dist/lib/find-orphaned-files.js +29 -0
  48. package/dist/lib/interactive.d.ts +1 -0
  49. package/dist/lib/interactive.js +7 -0
  50. package/dist/lib/load-files.d.ts +5 -0
  51. package/dist/lib/load-files.js +31 -0
  52. package/dist/lib/push-command.d.ts +7 -0
  53. package/dist/lib/push-command.js +40 -2
  54. package/dist/lib/wp-client.d.ts +9 -4
  55. package/dist/lib/wp-client.js +26 -12
  56. package/dist/types/project-config.generated.d.ts +8 -0
  57. package/dist/utils/composer.d.ts +3 -0
  58. package/dist/utils/form-format.d.ts +3 -0
  59. package/dist/utils/form-format.js +17 -0
  60. package/dist/utils/seo-format.d.ts +1 -0
  61. package/dist/utils/seo-format.js +5 -0
  62. package/dist/utils/snippet-format.d.ts +2 -0
  63. package/dist/utils/snippet-format.js +27 -1
  64. package/dist/utils/to-slug.d.ts +1 -0
  65. package/dist/utils/to-slug.js +7 -0
  66. package/oclif.manifest.json +748 -96
  67. package/package.json +26 -17
  68. package/schemas/project-config.schema.json +10 -0
@@ -1,6 +1,13 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  export declare abstract class PushCommand extends LoopressCommand {
3
+ protected failedCount: number;
4
+ private refusedByGuard;
3
5
  catch(err: Error): Promise<void>;
6
+ protected guardProductionPush(): Promise<void>;
7
+ init(): Promise<void>;
4
8
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
5
9
  protected recordSuccess(): Promise<void>;
10
+ protected reportTaskFailure(message: string, error: unknown, task?: {
11
+ output: string;
12
+ }): never;
6
13
  }
@@ -1,14 +1,39 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  import got from 'got';
2
3
  import { authManager } from '../config/auth.manager.js';
4
+ import { API_URL } from './api-client.js';
3
5
  import { LoopressCommand } from './base.js';
4
- const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
6
+ import { isInteractive } from './interactive.js';
5
7
  export class PushCommand extends LoopressCommand {
8
+ failedCount = 0;
9
+ // Refusals of the production guard never reach the site, so they must not pollute the
10
+ // deployment history with failure records.
11
+ refusedByGuard = false;
6
12
  async catch(err) {
7
- if (!this.dryRun && this.siteConfig) {
13
+ if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
8
14
  await this.recordDeployment('failure');
9
15
  }
10
16
  return super.catch(err);
11
17
  }
18
+ // Pushing to an environment named "production" needs explicit intent: a TTY confirmation
19
+ // (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
20
+ async guardProductionPush() {
21
+ if (this.siteConfig.name.toLowerCase() !== 'production' || this.dryRun || this.yes)
22
+ return;
23
+ if (!isInteractive()) {
24
+ this.refusedByGuard = true;
25
+ this.error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
26
+ }
27
+ const proceed = await confirm({ default: true, message: `Push to production (${this.siteConfig.url})?` });
28
+ if (!proceed) {
29
+ this.refusedByGuard = true;
30
+ this.error('Aborted.');
31
+ }
32
+ }
33
+ async init() {
34
+ await super.init();
35
+ await this.guardProductionPush();
36
+ }
12
37
  async recordDeployment(status) {
13
38
  const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
14
39
  if (!token)
@@ -28,4 +53,17 @@ export class PushCommand extends LoopressCommand {
28
53
  if (!this.dryRun)
29
54
  await this.recordDeployment('success');
30
55
  }
56
+ // Reports one failed Listr task: status lines go through `task.output` when running inside a
57
+ // Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
58
+ // called without a task (e.g. directly in tests). Rethrowing is what lets Listr mark the task
59
+ // failed (red cross) instead of completed; `exitOnError: false` on the list still lets sibling
60
+ // tasks run, and the accumulated `failedCount` drives the command's final "N failed" error.
61
+ reportTaskFailure(message, error, task) {
62
+ if (task)
63
+ task.output = message;
64
+ else
65
+ this.warn(` ${message}`);
66
+ this.failedCount++;
67
+ throw error;
68
+ }
31
69
  }
@@ -7,10 +7,15 @@ export declare class WpClient {
7
7
  private readonly siteUrl;
8
8
  private readonly client;
9
9
  constructor(siteUrl: string, token: string);
10
- get<T>(path: string): Promise<T>;
11
- post<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
12
- put<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
10
+ get<T>(path: string, options?: RequestOptions): Promise<T>;
11
+ post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
12
+ put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
13
13
  private request;
14
14
  }
15
+ interface RequestOptions {
16
+ timeoutMs?: number;
17
+ }
15
18
  export declare function isNotFoundError(error: unknown): boolean;
16
- export declare function formatWpError(error: unknown, url: string): string;
19
+ export declare function isTimeoutError(error: unknown): boolean;
20
+ export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
21
+ export {};
@@ -15,22 +15,23 @@ export class WpClient {
15
15
  timeout: { request: REQUEST_TIMEOUT_MS },
16
16
  });
17
17
  }
18
- async get(path) {
19
- return this.request('get', path);
18
+ async get(path, options) {
19
+ return this.request('get', path, undefined, options);
20
20
  }
21
- async post(path, json) {
22
- return this.request('post', path, json);
21
+ async post(path, json, options) {
22
+ return this.request('post', path, json, options);
23
23
  }
24
- async put(path, json) {
25
- return this.request('put', path, json);
24
+ async put(path, json, options) {
25
+ return this.request('put', path, json, options);
26
26
  }
27
- async request(method, path, json) {
27
+ async request(method, path, json, options) {
28
+ const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
28
29
  try {
29
- const response = await this.client(path, { json, method });
30
+ const response = await this.client(path, { json, method, timeout: { request: timeoutMs } });
30
31
  return (response.body ? JSON.parse(response.body) : undefined);
31
32
  }
32
33
  catch (error) {
33
- throw new Error(formatWpError(error, `${this.siteUrl}/wp-json/${path}`), { cause: error });
34
+ throw new Error(formatWpError(error, `${this.siteUrl}/wp-json/${path}`, timeoutMs), { cause: error });
34
35
  }
35
36
  }
36
37
  }
@@ -38,7 +39,11 @@ export function isNotFoundError(error) {
38
39
  const cause = error?.cause;
39
40
  return cause?.response?.statusCode === 404;
40
41
  }
41
- export function formatWpError(error, url) {
42
+ export function isTimeoutError(error) {
43
+ const cause = error?.cause;
44
+ return cause?.name === 'TimeoutError';
45
+ }
46
+ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
42
47
  const err = error;
43
48
  const status = err.response?.statusCode;
44
49
  if (status === 401 || status === 403) {
@@ -52,7 +57,7 @@ export function formatWpError(error, url) {
52
57
  return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
53
58
  }
54
59
  if (err.name === 'TimeoutError') {
55
- return `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s on ${url}. Is the site reachable?`;
60
+ return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
56
61
  }
57
62
  return `Request to ${url} failed: ${err.message ?? String(error)}`;
58
63
  }
@@ -60,13 +65,22 @@ export function formatWpError(error, url) {
60
65
  // core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
61
66
  // Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
62
67
  // plugins are active...") actually reach the user instead of a bare, unhelpful status code.
68
+ //
69
+ // Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
70
+ // "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
71
+ // specifically so a caller that only reads `error` doesn't see it — which is exactly what this
72
+ // function used to do, hiding the one piece of text that actually explains the failure.
63
73
  function extractServerErrorMessage(body) {
64
74
  if (!body)
65
75
  return undefined;
66
76
  try {
67
77
  const parsed = JSON.parse(body);
68
78
  const reason = parsed.error ?? parsed.message;
69
- return typeof reason === 'string' && reason.trim() ? reason : undefined;
79
+ const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
80
+ const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
81
+ if (summary && detail)
82
+ return `${summary}\n${detail}`;
83
+ return summary ?? detail;
70
84
  }
71
85
  catch {
72
86
  return undefined;
@@ -19,6 +19,14 @@ export interface LoopressProjectConfiguration {
19
19
  * Directory where SEO settings, post meta, and redirects (RankMath and Yoast SEO, whichever is active) are read from and written to, relative to rootDir.
20
20
  */
21
21
  seoDir?: string;
22
+ /**
23
+ * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
+ */
25
+ formDir?: string;
26
+ /**
27
+ * Directory where custom API route files are read from and written to, relative to rootDir.
28
+ */
29
+ apiDir?: string;
22
30
  /**
23
31
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
24
32
  */
@@ -1,4 +1,7 @@
1
1
  export interface ComposerJson {
2
+ config?: {
3
+ 'allow-plugins'?: Record<string, boolean>;
4
+ };
2
5
  extra?: {
3
6
  'installer-paths'?: Record<string, string[]>;
4
7
  };
@@ -0,0 +1,3 @@
1
+ export declare const FORM_ENDPOINT = "loopress/v1/forms";
2
+ export declare function getFormId(data: Record<string, unknown>): null | number;
3
+ export declare function getFormTitle(data: Record<string, unknown>): string;
@@ -0,0 +1,17 @@
1
+ export const FORM_ENDPOINT = 'loopress/v1/forms';
2
+ // Deliberately loose, same reasoning as getAcfKey in acf-format.ts: a form plugin's own data
3
+ // format (WPForms today: fields, settings, notifications, confirmations, providers, meta; other
4
+ // WordPress form plugins may be supported later, same shape as snippet-format.ts's Code
5
+ // Snippets/WPCode split) is large, deeply nested, and versioned by whichever plugin release
6
+ // wrote it. We only need `id` for filenames/identity and `settings.form_title` for display
7
+ // (the field WPForms itself uses; a future provider may differ), everything else round-trips
8
+ // through pull/push untouched.
9
+ export function getFormId(data) {
10
+ const id = Number(data.id);
11
+ return Number.isInteger(id) && id > 0 ? id : null;
12
+ }
13
+ export function getFormTitle(data) {
14
+ const settings = data.settings;
15
+ const title = settings?.form_title;
16
+ return typeof title === 'string' && title.trim() !== '' ? title : '(untitled)';
17
+ }
@@ -18,3 +18,4 @@ export interface SeoRedirect {
18
18
  updatedAt: null | string;
19
19
  urlTo: string;
20
20
  }
21
+ export declare function redirectFileBase(redirect: SeoRedirect): string;
@@ -1,3 +1,4 @@
1
+ import { toSlug } from './to-slug.js';
1
2
  // Post types synced by `lps seo pull` when --post-type isn't given.
2
3
  export const DEFAULT_POST_TYPES = ['post', 'page'];
3
4
  export const SEO_SETTINGS_ENDPOINT = 'loopress/v1/seo/settings';
@@ -8,3 +9,7 @@ export function seoRedirectEndpoint(id) {
8
9
  export function seoPostMetaEndpoint(postType) {
9
10
  return `loopress/v1/seo/post-meta/${postType}`;
10
11
  }
12
+ // The `<id>-<slug>.json` on-disk convention shared by `seo pull` and `seo push`.
13
+ export function redirectFileBase(redirect) {
14
+ return `${redirect.id}-${toSlug(redirect.urlTo, 'redirect')}`;
15
+ }
@@ -20,4 +20,6 @@ export declare function parseLocation(raw: unknown): null | SnippetLocation;
20
20
  export declare function parseInsertMethod(raw: unknown): null | SnippetInsertMethod;
21
21
  export declare function defaultLocationForType(type: SnippetType): SnippetLocation;
22
22
  export declare function stripPhpOpeningTag(code: string): string;
23
+ export declare function buildSnippetFile(snippet: NormalizedSnippet): string;
24
+ export declare function buildMetaFile(snippet: NormalizedSnippet): string;
23
25
  export declare function normalizeSnippet(data: Record<string, unknown>): NormalizedSnippet;
@@ -34,10 +34,36 @@ function resolvePriority(raw) {
34
34
  }
35
35
  // Snippet files on disk keep the <?php opening tag so they're valid, syntax-highlighted PHP files.
36
36
  // The WordPress plugin stores just the executable body, so it's stripped before push and restored
37
- // on pull (see buildSnippetFile in commands/snippet/pull.ts).
37
+ // on pull (see buildSnippetFile below).
38
38
  export function stripPhpOpeningTag(code) {
39
39
  return code.replace(/^<\?php\s*/i, '');
40
40
  }
41
+ export function buildSnippetFile(snippet) {
42
+ if (snippet.type === 'php' && !snippet.code.trimStart().startsWith('<?')) {
43
+ return `<?php\n\n${snippet.code}`;
44
+ }
45
+ return snippet.code;
46
+ }
47
+ export function buildMetaFile(snippet) {
48
+ const meta = {
49
+ id: snippet.id,
50
+ name: snippet.name,
51
+ type: snippet.type,
52
+ active: snippet.active,
53
+ location: snippet.location,
54
+ };
55
+ if (snippet.description)
56
+ meta.description = snippet.description;
57
+ if (snippet.tags.length > 0)
58
+ meta.tags = snippet.tags;
59
+ if (snippet.insertMethod === 'shortcode')
60
+ meta.insertMethod = snippet.insertMethod;
61
+ if (snippet.priority !== 10)
62
+ meta.priority = snippet.priority;
63
+ if (snippet.shortcodeAttributes.length > 0)
64
+ meta.shortcodeAttributes = snippet.shortcodeAttributes;
65
+ return JSON.stringify(meta, null, 2) + '\n';
66
+ }
41
67
  // Defensive coercion for whatever JSON the `loopress/v1/snippets` endpoint returns, in case a
42
68
  // field is missing or malformed server-side.
43
69
  export function normalizeSnippet(data) {
@@ -0,0 +1 @@
1
+ export declare function toSlug(value: string, fallback?: string): string;
@@ -0,0 +1,7 @@
1
+ import slugify from 'slugify';
2
+ // The one slugification used everywhere a name becomes part of a filename or an api slug.
3
+ // `fallback` covers values that slugify to nothing (empty, punctuation-only); callers that
4
+ // can genuinely live with an empty slug just omit it.
5
+ export function toSlug(value, fallback = '') {
6
+ return slugify(value, { lower: true, strict: true }) || fallback;
7
+ }