@loopress/cli 0.20.1 → 0.22.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 (118) hide show
  1. package/README.md +254 -75
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +4 -6
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +4 -7
  6. package/dist/commands/api/list.d.ts +7 -4
  7. package/dist/commands/api/list.js +5 -11
  8. package/dist/commands/api/publish.js +7 -23
  9. package/dist/commands/api/pull.d.ts +8 -1
  10. package/dist/commands/api/pull.js +22 -18
  11. package/dist/commands/api/push.d.ts +7 -1
  12. package/dist/commands/api/push.js +50 -23
  13. package/dist/commands/composer/init.js +6 -6
  14. package/dist/commands/composer/pull.d.ts +7 -1
  15. package/dist/commands/composer/pull.js +4 -2
  16. package/dist/commands/composer/push.d.ts +8 -1
  17. package/dist/commands/composer/push.js +3 -1
  18. package/dist/commands/dev.d.ts +13 -0
  19. package/dist/commands/dev.js +97 -0
  20. package/dist/commands/doctor.js +6 -5
  21. package/dist/commands/form/list.d.ts +2 -4
  22. package/dist/commands/form/list.js +3 -10
  23. package/dist/commands/form/pull.js +15 -24
  24. package/dist/commands/form/push.js +17 -42
  25. package/dist/commands/init.js +7 -7
  26. package/dist/commands/login.js +9 -5
  27. package/dist/commands/logout.js +2 -1
  28. package/dist/commands/page/list.d.ts +2 -4
  29. package/dist/commands/page/list.js +3 -10
  30. package/dist/commands/page/pull.d.ts +13 -1
  31. package/dist/commands/page/pull.js +21 -29
  32. package/dist/commands/page/push.d.ts +11 -1
  33. package/dist/commands/page/push.js +31 -49
  34. package/dist/commands/plugin/add.js +3 -3
  35. package/dist/commands/plugin/pull.d.ts +8 -1
  36. package/dist/commands/plugin/pull.js +11 -11
  37. package/dist/commands/plugin/push.d.ts +12 -1
  38. package/dist/commands/plugin/push.js +41 -27
  39. package/dist/commands/project/config.js +12 -9
  40. package/dist/commands/project/list.d.ts +16 -1
  41. package/dist/commands/project/list.js +22 -16
  42. package/dist/commands/project/pull.js +3 -2
  43. package/dist/commands/project/push.d.ts +5 -0
  44. package/dist/commands/project/push.js +84 -63
  45. package/dist/commands/project/switch.js +2 -2
  46. package/dist/commands/promote.d.ts +18 -0
  47. package/dist/commands/promote.js +90 -0
  48. package/dist/commands/pull.d.ts +12 -0
  49. package/dist/commands/pull.js +51 -0
  50. package/dist/commands/push.d.ts +13 -0
  51. package/dist/commands/push.js +57 -0
  52. package/dist/commands/seo/list.d.ts +9 -2
  53. package/dist/commands/seo/list.js +9 -11
  54. package/dist/commands/seo/pull.js +14 -37
  55. package/dist/commands/seo/push.js +24 -48
  56. package/dist/commands/snippet/list.d.ts +3 -4
  57. package/dist/commands/snippet/list.js +5 -11
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.d.ts +13 -1
  60. package/dist/commands/snippet/pull.js +18 -20
  61. package/dist/commands/snippet/push.d.ts +11 -1
  62. package/dist/commands/snippet/push.js +29 -28
  63. package/dist/commands/status.d.ts +13 -1
  64. package/dist/commands/status.js +25 -18
  65. package/dist/commands/validate.d.ts +8 -0
  66. package/dist/commands/validate.js +24 -0
  67. package/dist/config/auth.manager.d.ts +2 -2
  68. package/dist/config/project-config.manager.d.ts +1 -1
  69. package/dist/config/project-config.manager.js +5 -6
  70. package/dist/lib/base.d.ts +11 -2
  71. package/dist/lib/base.js +54 -24
  72. package/dist/lib/bootstrap-full-install.d.ts +1 -1
  73. package/dist/lib/dev-watch.d.ts +16 -0
  74. package/dist/lib/dev-watch.js +71 -0
  75. package/dist/lib/find-orphaned-files.d.ts +3 -2
  76. package/dist/lib/find-orphaned-files.js +40 -4
  77. package/dist/lib/github-release.js +1 -1
  78. package/dist/lib/guard-production-push.d.ts +12 -0
  79. package/dist/lib/guard-production-push.js +20 -0
  80. package/dist/lib/interactive.js +1 -1
  81. package/dist/lib/load-files.d.ts +1 -0
  82. package/dist/lib/load-files.js +40 -11
  83. package/dist/lib/load-snippets.d.ts +1 -1
  84. package/dist/lib/load-snippets.js +66 -59
  85. package/dist/lib/local-callback-server.js +15 -8
  86. package/dist/lib/plugin-detection.d.ts +1 -1
  87. package/dist/lib/push-command.d.ts +3 -0
  88. package/dist/lib/push-command.js +22 -15
  89. package/dist/lib/put-or-create.d.ts +13 -0
  90. package/dist/lib/put-or-create.js +19 -0
  91. package/dist/lib/readdir-tolerant.d.ts +5 -0
  92. package/dist/lib/readdir-tolerant.js +11 -0
  93. package/dist/lib/resolve-linked-project.d.ts +8 -0
  94. package/dist/lib/resolve-linked-project.js +27 -0
  95. package/dist/lib/rotate-app-password.d.ts +1 -1
  96. package/dist/lib/rotate-app-password.js +1 -1
  97. package/dist/lib/temp-admin.d.ts +3 -3
  98. package/dist/lib/temp-admin.js +2 -2
  99. package/dist/lib/validate-local.d.ts +10 -0
  100. package/dist/lib/validate-local.js +183 -0
  101. package/dist/lib/wp-authorize-flow.js +5 -1
  102. package/dist/lib/wp-client.d.ts +2 -2
  103. package/dist/lib/wp-client.js +7 -0
  104. package/dist/types/plugin.d.ts +4 -4
  105. package/dist/types/snippet.d.ts +3 -3
  106. package/dist/utils/composer.d.ts +2 -2
  107. package/dist/utils/form-format.js +1 -1
  108. package/dist/utils/loopress-config.d.ts +1 -1
  109. package/dist/utils/page-format.js +2 -2
  110. package/dist/utils/plugins.d.ts +5 -5
  111. package/dist/utils/plugins.js +3 -3
  112. package/dist/utils/pluralize.d.ts +1 -0
  113. package/dist/utils/pluralize.js +5 -0
  114. package/dist/utils/seo-format.d.ts +6 -6
  115. package/dist/utils/snippet-format.d.ts +2 -2
  116. package/dist/utils/snippet-format.js +10 -5
  117. package/oclif.manifest.json +394 -57
  118. package/package.json +18 -16
@@ -1,3 +1,4 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { createServer } from 'node:http';
2
3
  import { openBrowser } from './open-browser.js';
3
4
  async function readBody(req) {
@@ -13,23 +14,28 @@ function parseFormData(body) {
13
14
  * Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
14
15
  * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
15
16
  */
16
- export function waitForLocalCallback(options) {
17
+ export async function waitForLocalCallback(options) {
17
18
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
18
19
  return new Promise((resolve, reject) => {
19
- function finish(res, page, settle) {
20
+ function finish(res, page) {
20
21
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
21
22
  res.end(page);
22
23
  clearTimeout(timer);
23
24
  server.close();
24
- settle();
25
25
  }
26
- const server = createServer(async (req, res) => {
26
+ async function handleIncoming(req, res) {
27
27
  try {
28
28
  const url = new URL(req.url ?? '/', 'http://localhost');
29
29
  const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
30
30
  options.handleRequest(url, {
31
- rejectWithPage: (page, error) => finish(res, page, () => reject(error)),
32
- resolveWithPage: (page, value) => finish(res, page, () => resolve(value)),
31
+ rejectWithPage(page, error) {
32
+ finish(res, page);
33
+ reject(error);
34
+ },
35
+ resolveWithPage(page, value) {
36
+ finish(res, page);
37
+ resolve(value);
38
+ },
33
39
  respondBadRequest(message) {
34
40
  res.writeHead(400, { 'Content-Type': 'text/plain' });
35
41
  res.end(message);
@@ -41,9 +47,10 @@ export function waitForLocalCallback(options) {
41
47
  res.writeHead(500);
42
48
  res.end('Internal error');
43
49
  server.close();
44
- reject(error);
50
+ reject(error instanceof Error ? error : new Error(String(error)));
45
51
  }
46
- });
52
+ }
53
+ const server = createServer((req, res) => { void handleIncoming(req, res); });
47
54
  server.on('error', (err) => {
48
55
  clearTimeout(timer);
49
56
  reject(err);
@@ -1,4 +1,4 @@
1
- import { WpClient } from './wp-client.js';
1
+ import { type WpClient } from './wp-client.js';
2
2
  /**
3
3
  * Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
4
4
  * (`src/utils/plugins.ts`), which deliberately filters Loopress's own plugin slugs out of its
@@ -10,4 +10,7 @@ export declare abstract class PushCommand extends LoopressCommand {
10
10
  protected reportTaskFailure(message: string, error: unknown, task?: {
11
11
  output: string;
12
12
  }): never;
13
+ protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
14
+ output: string;
15
+ }) => Promise<void>): Promise<void>;
13
16
  }
@@ -1,9 +1,9 @@
1
- import { confirm } from '@inquirer/prompts';
2
1
  import got from 'got';
2
+ import { Listr } from 'listr2';
3
3
  import { authManager } from '../config/auth.manager.js';
4
4
  import { API_URL } from './api-client.js';
5
5
  import { LoopressCommand } from './base.js';
6
- import { isInteractive } from './interactive.js';
6
+ import { guardProductionPush } from './guard-production-push.js';
7
7
  export class PushCommand extends LoopressCommand {
8
8
  failedCount = 0;
9
9
  // Refusals of the production guard never reach the site, so they must not pollute the
@@ -15,20 +15,16 @@ export class PushCommand extends LoopressCommand {
15
15
  }
16
16
  return super.catch(err);
17
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
18
  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
- }
19
+ await guardProductionPush({
20
+ dryRun: this.dryRun,
21
+ error: (message) => this.error(message),
22
+ onRefuse: () => {
23
+ this.refusedByGuard = true;
24
+ },
25
+ siteConfig: this.siteConfig,
26
+ yes: this.yes,
27
+ });
32
28
  }
33
29
  async init() {
34
30
  await super.init();
@@ -66,4 +62,15 @@ export class PushCommand extends LoopressCommand {
66
62
  this.failedCount++;
67
63
  throw error;
68
64
  }
65
+ // Shared shape of every resource's push list: sequential (`concurrent: false` avoids
66
+ // clobbering `failedCount` and interleaving WordPress writes), and a failing item never
67
+ // stops its siblings (`exitOnError: false`) so `failedCount` reflects every failure, not
68
+ // just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
69
+ // and reports its own failure through `reportTaskFailure`.
70
+ async runPushTasks(items, label, task) {
71
+ await new Listr(items.map((item) => ({
72
+ task: async (_ctx, taskCtx) => task(item, taskCtx),
73
+ title: `Push ${label(item)}`,
74
+ })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
75
+ }
69
76
  }
@@ -0,0 +1,13 @@
1
+ import { type WpClient } from './wp-client.js';
2
+ type PutOrCreateOptions = {
3
+ id: null | number;
4
+ payload: Record<string, unknown>;
5
+ postEndpoint: string;
6
+ postPayload?: Record<string, unknown>;
7
+ putEndpoint: (id: number) => string;
8
+ };
9
+ export declare function putOrCreate<T>(wp: WpClient, options: PutOrCreateOptions): Promise<{
10
+ body: T;
11
+ created: boolean;
12
+ }>;
13
+ export {};
@@ -0,0 +1,19 @@
1
+ import { isNotFoundError } from './wp-client.js';
2
+ // Update-or-create dance shared by every id-based resource (form, page, seo redirect,
3
+ // snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
4
+ // fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
5
+ // database reset.
6
+ export async function putOrCreate(wp, options) {
7
+ const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
8
+ if (id !== null) {
9
+ try {
10
+ const body = await wp.put(putEndpoint(id), payload);
11
+ return { body, created: false };
12
+ }
13
+ catch (error) {
14
+ if (!isNotFoundError(error))
15
+ throw error;
16
+ }
17
+ }
18
+ return { body: await wp.post(postEndpoint, postPayload), created: true };
19
+ }
@@ -0,0 +1,5 @@
1
+ import { type Dirent } from 'node:fs';
2
+ export declare function readdirTolerant(dir: string): Promise<string[]>;
3
+ export declare function readdirTolerant(dir: string, options: {
4
+ withFileTypes: true;
5
+ }): Promise<Dirent[]>;
@@ -0,0 +1,11 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ export async function readdirTolerant(dir, options) {
3
+ try {
4
+ return options?.withFileTypes ? await readdir(dir, { withFileTypes: true }) : await readdir(dir);
5
+ }
6
+ catch (error) {
7
+ if (error.code === 'ENOENT')
8
+ return [];
9
+ throw error;
10
+ }
11
+ }
@@ -0,0 +1,8 @@
1
+ import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
+ type LinkedProject = {
3
+ apiProjectId: string;
4
+ localConfig: LoopressLocalConfig;
5
+ token: string;
6
+ };
7
+ export declare function resolveLinkedProject(fail: (message: string) => never): Promise<LinkedProject>;
8
+ export {};
@@ -0,0 +1,27 @@
1
+ import { authManager } from '../config/auth.manager.js';
2
+ import { configManager } from '../config/project-config.manager.js';
3
+ import { readLocalConfig } from '../utils/loopress-config.js';
4
+ // Shared preamble for `api publish` and `snippet publish`. Both target the Loopress cloud (never
5
+ // a WordPress site), so neither extends `LoopressCommand`/`PushCommand` and neither needs an
6
+ // environment resolved, but both need a logged-in user and a project already linked to the
7
+ // account (`lps project push`). `fail` is the caller's `this.error`, so its messages keep
8
+ // oclif's formatting and exit code; typed `=> never` so callers still narrow after the guards.
9
+ export async function resolveLinkedProject(fail) {
10
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
11
+ if (!token) {
12
+ fail('Not logged in. Run `lps login` first.');
13
+ }
14
+ const localConfig = await readLocalConfig();
15
+ const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
16
+ if (!projectId) {
17
+ fail('No project configured. Run `lps project config` first.');
18
+ }
19
+ const project = configManager.getProject(projectId);
20
+ if (!project) {
21
+ fail(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
22
+ }
23
+ if (!project.apiProjectId) {
24
+ fail(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project push\` first.`);
25
+ }
26
+ return { apiProjectId: project.apiProjectId, localConfig, token };
27
+ }
@@ -1,4 +1,4 @@
1
- import { EnvironmentConfig } from '../types/config.js';
1
+ import { type EnvironmentConfig } from '../types/config.js';
2
2
  export declare const APP_PASSWORD_MAX_AGE_MS: number;
3
3
  export declare function isAppPasswordStale(addedAt: string): boolean;
4
4
  export declare function rotateAppPassword(env: EnvironmentConfig & {
@@ -7,7 +7,7 @@ export function isAppPasswordStale(addedAt) {
7
7
  // Create the new credential, confirm it actually authenticates on its own, only then revoke
8
8
  // the old one. Reversing this order risks locking the user out if the new one is somehow bad.
9
9
  export async function rotateAppPassword(env) {
10
- const [user] = env.token.split(':');
10
+ const [user] = env.token.split(':', 1);
11
11
  const oldClient = new WpClient(env.url, env.token);
12
12
  const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
13
13
  const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
@@ -1,9 +1,9 @@
1
- import { WpClient } from './wp-client.js';
2
- export interface TempAdmin {
1
+ import { type WpClient } from './wp-client.js';
2
+ export type TempAdmin = {
3
3
  id: number;
4
4
  password: string;
5
5
  username: string;
6
- }
6
+ };
7
7
  /**
8
8
  * Creates a temporary WordPress administrator, the only way to obtain `install_plugins` when
9
9
  * all the CLI has is an application password. `.invalid` is the RFC 2606 reserved TLD for
@@ -32,7 +32,7 @@ export async function deleteTempAdmin(wp, admin) {
32
32
  catch (error) {
33
33
  throw new Error(`Failed to remove the temporary admin account "${admin.username}" (id ${admin.id}) from the site. Remove it manually from wp-admin.`, { cause: error });
34
34
  }
35
- const stillExists = await wp
35
+ const isStillExists = await wp
36
36
  .get(`wp/v2/users/${admin.id}`)
37
37
  .then(() => true)
38
38
  .catch((error) => {
@@ -40,7 +40,7 @@ export async function deleteTempAdmin(wp, admin) {
40
40
  return false;
41
41
  throw error;
42
42
  });
43
- if (stillExists) {
43
+ if (isStillExists) {
44
44
  throw new Error(`Temporary admin account "${admin.username}" (id ${admin.id}) still exists after deletion. Remove it manually from wp-admin.`);
45
45
  }
46
46
  }
@@ -0,0 +1,10 @@
1
+ export type Problem = {
2
+ file: string;
3
+ message: string;
4
+ };
5
+ export type ValidateResult = {
6
+ checked: number;
7
+ problems: Problem[];
8
+ valid: boolean;
9
+ };
10
+ export declare function validateLocal(cwd: string): Promise<ValidateResult>;
@@ -0,0 +1,183 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { configManager } from '../config/project-config.manager.js';
4
+ import { parseType } from '../utils/snippet-format.js';
5
+ import { loadSnippets } from './load-snippets.js';
6
+ import { readdirTolerant } from './readdir-tolerant.js';
7
+ // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
8
+ // and api routes (`.php`, not JSON) are checked separately.
9
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'pageDir', 'seoDir'];
10
+ const DEFAULT_DIR = {
11
+ acfDir: 'acf',
12
+ apiDir: 'api',
13
+ formDir: 'forms',
14
+ pageDir: 'pages',
15
+ seoDir: 'seo',
16
+ snippetsDir: 'snippets',
17
+ };
18
+ // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
19
+ // and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
20
+ // real project, `composer.json` is well formed. Never throws for a bad file, it collects the
21
+ // problem and moves on.
22
+ export async function validateLocal(cwd) {
23
+ const problems = [];
24
+ let checked = 0;
25
+ const localConfig = await readConfig(cwd, problems);
26
+ const rootDir = typeof localConfig.rootDir === 'string' ? localConfig.rootDir : '.';
27
+ const resolve = (key) => {
28
+ const override = localConfig[key];
29
+ return join(cwd, rootDir, typeof override === 'string' ? override : DEFAULT_DIR[key]);
30
+ };
31
+ checkLoopressJson(localConfig, problems);
32
+ checked += 1;
33
+ checked += await checkComposerJson(cwd, problems);
34
+ for (const key of JSON_RESOURCE_DIRS) {
35
+ checked += await checkJsonDir(resolve(key), problems);
36
+ }
37
+ checked += await checkSnippets(resolve('snippetsDir'), problems);
38
+ checked += await checkApiDir(resolve('apiDir'), problems);
39
+ return { checked, problems, valid: problems.length === 0 };
40
+ }
41
+ async function checkApiDir(dir, problems) {
42
+ const entries = await readdirTolerant(dir, { withFileTypes: true });
43
+ let checked = 0;
44
+ for (const entry of entries) {
45
+ if (!entry.isFile() || !entry.name.endsWith('.php'))
46
+ continue;
47
+ checked += 1;
48
+ const filePath = join(dir, entry.name);
49
+ const content = await readFile(filePath, 'utf8');
50
+ if (content.trim() === '')
51
+ problems.push({ file: filePath, message: 'API route file is empty' });
52
+ }
53
+ return checked;
54
+ }
55
+ async function checkComposerJson(cwd, problems) {
56
+ let raw;
57
+ try {
58
+ raw = await readFile(join(cwd, 'composer.json'), 'utf8');
59
+ }
60
+ catch {
61
+ // No composer.json is fine, Composer sync is optional.
62
+ return 0;
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ }
68
+ catch (error) {
69
+ problems.push({ file: 'composer.json', message: `not valid JSON: ${error.message}` });
70
+ return 1;
71
+ }
72
+ for (const key of ['require', 'require-dev']) {
73
+ const section = parsed[key];
74
+ if (section !== undefined && (typeof section !== 'object' || section === null || Array.isArray(section))) {
75
+ problems.push({ file: 'composer.json', message: `"${key}" must be an object` });
76
+ }
77
+ }
78
+ return 1;
79
+ }
80
+ async function checkJsonDir(dir, problems) {
81
+ const files = (await readdirTolerant(dir)).filter((file) => file.endsWith('.json'));
82
+ let checked = 0;
83
+ for (const file of files) {
84
+ checked += 1;
85
+ const filePath = join(dir, file);
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(await readFile(filePath, 'utf8'));
89
+ }
90
+ catch (error) {
91
+ problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
92
+ continue;
93
+ }
94
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
95
+ problems.push({ file: filePath, message: 'expected a JSON object' });
96
+ }
97
+ }
98
+ return checked;
99
+ }
100
+ function checkLoopressJson(localConfig, problems) {
101
+ const { projectId } = localConfig;
102
+ if (typeof projectId === 'string') {
103
+ const project = configManager.getProject(projectId);
104
+ if (!project) {
105
+ problems.push({
106
+ file: 'loopress.json',
107
+ message: `projectId "${projectId}" is not a configured project (run \`lps project config\`)`,
108
+ });
109
+ }
110
+ else if (Object.keys(project.environments).length === 0) {
111
+ problems.push({ file: 'loopress.json', message: `project "${project.name}" has no environments configured` });
112
+ }
113
+ }
114
+ const { plugins } = localConfig;
115
+ if (plugins === undefined)
116
+ return;
117
+ if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
118
+ problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string' });
119
+ return;
120
+ }
121
+ for (const [slug, version] of Object.entries(plugins)) {
122
+ if (typeof version !== 'string')
123
+ problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a string` });
124
+ }
125
+ }
126
+ async function checkSnippets(dir, problems) {
127
+ const listing = await readdirTolerant(dir);
128
+ if (listing.length === 0)
129
+ return 0;
130
+ // loadSnippets reports unreadable files and unparseable sidecars through onSkip.
131
+ let snippets;
132
+ try {
133
+ snippets = await loadSnippets(dir, (message) => {
134
+ problems.push({ file: dir, message });
135
+ });
136
+ }
137
+ catch (error) {
138
+ problems.push({ file: dir, message: error.message });
139
+ return 0;
140
+ }
141
+ // loadSnippets coerces an invalid `type` to the extension default rather than failing, so
142
+ // check the raw sidecars for a bogus enum value the schema would reject.
143
+ const sidecars = listing.filter((file) => file.endsWith('.json'));
144
+ for (const file of sidecars) {
145
+ const filePath = join(dir, file);
146
+ let meta;
147
+ try {
148
+ meta = JSON.parse(await readFile(filePath, 'utf8'));
149
+ }
150
+ catch {
151
+ problems.push({ file: filePath, message: 'not valid JSON' });
152
+ continue;
153
+ }
154
+ if (meta.type !== undefined && parseType(meta.type) === null) {
155
+ problems.push({ file: filePath, message: `"type": ${JSON.stringify(meta.type)} is not one of css, html, js, php, text` });
156
+ }
157
+ if (meta.id !== undefined && !Number.isSafeInteger(meta.id)) {
158
+ problems.push({ file: filePath, message: '"id" must be an integer' });
159
+ }
160
+ }
161
+ return snippets.length + sidecars.length;
162
+ }
163
+ // Mirrors utils/loopress-config.ts:readLocalConfig, but rooted at an explicit directory rather
164
+ // than process.cwd() so the whole check is driveable from a test without process.chdir(), which
165
+ // vitest's worker threads forbid. A missing file just means "no config".
166
+ async function readConfig(cwd, problems) {
167
+ let raw;
168
+ try {
169
+ raw = await readFile(join(cwd, 'loopress.json'), 'utf8');
170
+ }
171
+ catch (error) {
172
+ if (error.code === 'ENOENT')
173
+ return {};
174
+ throw error;
175
+ }
176
+ try {
177
+ return JSON.parse(raw);
178
+ }
179
+ catch {
180
+ problems.push({ file: 'loopress.json', message: 'loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.' });
181
+ return {};
182
+ }
183
+ }
@@ -16,7 +16,7 @@ import { renderResultPage, waitForLocalCallback } from './local-callback-server.
16
16
  * Application Password to the local callback server.
17
17
  * 6. The local server extracts the credentials and resolves the promise.
18
18
  */
19
- export function authorizeWithBrowser(siteUrl, log) {
19
+ export async function authorizeWithBrowser(siteUrl, log) {
20
20
  return waitForLocalCallback({
21
21
  buildUrl(callbackBaseUrl) {
22
22
  const relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
@@ -31,7 +31,11 @@ export function authorizeWithBrowser(siteUrl, log) {
31
31
  rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
32
32
  return;
33
33
  }
34
+ // `||`, not `??`: an empty-string field counts as absent here, same as the `!password`
35
+ // check below, so falls through to the query-param fallback instead of stopping at "".
36
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
34
37
  const password = body.password || url.searchParams.get('password') || '';
38
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
35
39
  const userLogin = body.user_login || url.searchParams.get('user_login') || '';
36
40
  if (!password || !userLogin) {
37
41
  respondBadRequest('Missing password or user_login');
@@ -13,9 +13,9 @@ export declare class WpClient {
13
13
  put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
14
14
  private request;
15
15
  }
16
- interface RequestOptions {
16
+ type RequestOptions = {
17
17
  timeoutMs?: number;
18
- }
18
+ };
19
19
  export declare function isNotFoundError(error: unknown): boolean;
20
20
  export declare function isTimeoutError(error: unknown): boolean;
21
21
  export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
@@ -1,4 +1,5 @@
1
1
  import got from 'got';
2
+ import { Buffer } from 'node:buffer';
2
3
  export const REQUEST_TIMEOUT_MS = 30_000;
3
4
  /**
4
5
  * HTTP client for a WordPress site's REST API.
@@ -81,6 +82,12 @@ function extractServerErrorMessage(body) {
81
82
  return undefined;
82
83
  try {
83
84
  const parsed = JSON.parse(body);
85
+ // WP core's own generic body for a route it never registered (e.g. a plugin too old to
86
+ // expose this endpoint yet) is never more useful than the "is the plugin up to date?"
87
+ // fallback below; surfacing it verbatim buries a version-skew mismatch behind text that
88
+ // reads like the endpoint doesn't exist at all, pointing the user in the wrong direction.
89
+ if (parsed.code === 'rest_no_route')
90
+ return undefined;
84
91
  const reason = parsed.error ?? parsed.message;
85
92
  const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
86
93
  const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
@@ -1,14 +1,14 @@
1
- export interface InstalledPlugin {
1
+ export type InstalledPlugin = {
2
2
  active: boolean;
3
3
  file: string;
4
4
  name: string;
5
5
  slug: string;
6
6
  version: string;
7
- }
8
- export interface WpNativePlugin {
7
+ };
8
+ export type WpNativePlugin = {
9
9
  name: string;
10
10
  plugin: string;
11
11
  status: 'active' | 'inactive' | 'network-active';
12
12
  version: string;
13
- }
13
+ };
14
14
  export type PluginManifest = Record<string, string>;
@@ -1,5 +1,5 @@
1
- import { SnippetInsertMethod, SnippetLocation, SnippetType } from '../utils/snippet-format.js';
2
- export interface Snippet {
1
+ import { type SnippetInsertMethod, type SnippetLocation, type SnippetType } from '../utils/snippet-format.js';
2
+ export type Snippet = {
3
3
  active: boolean;
4
4
  code: string;
5
5
  id?: number;
@@ -11,4 +11,4 @@ export interface Snippet {
11
11
  shortcodeAttributes: string[];
12
12
  tags: string[];
13
13
  type: SnippetType;
14
- }
14
+ };
@@ -1,4 +1,4 @@
1
- export interface ComposerJson {
1
+ export type ComposerJson = {
2
2
  config?: {
3
3
  'allow-plugins'?: Record<string, boolean>;
4
4
  };
@@ -12,7 +12,7 @@ export interface ComposerJson {
12
12
  }>;
13
13
  require?: Record<string, string>;
14
14
  'require-dev'?: Record<string, string>;
15
- }
15
+ };
16
16
  export declare function readComposerJson(): Promise<ComposerJson | null>;
17
17
  export declare function readComposerLock(): Promise<null | string>;
18
18
  export declare function getComposerManagedSlugs(composerJson: ComposerJson): string[];
@@ -8,7 +8,7 @@ export const FORM_ENDPOINT = 'loopress/v1/forms';
8
8
  // through pull/push untouched.
9
9
  export function getFormId(data) {
10
10
  const id = Number(data.id);
11
- return Number.isInteger(id) && id > 0 ? id : null;
11
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
12
12
  }
13
13
  export function getFormTitle(data) {
14
14
  const settings = data.settings;
@@ -1,4 +1,4 @@
1
- import { LoopressProjectConfiguration } from '../types/project-config.generated.js';
1
+ import { type LoopressProjectConfiguration } from '../types/project-config.generated.js';
2
2
  export type LoopressLocalConfig = LoopressProjectConfiguration;
3
3
  export declare function readLocalConfig(): Promise<LoopressLocalConfig>;
4
4
  export declare function writeLocalConfig(config: LoopressLocalConfig): Promise<void>;
@@ -7,7 +7,7 @@ export const PAGE_ENDPOINT = 'wp/v2/pages';
7
7
  export const PAGE_LIST_QUERY = 'per_page=100';
8
8
  export function getPageId(data) {
9
9
  const id = Number(data.id);
10
- return Number.isInteger(id) && id > 0 ? id : null;
10
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
11
11
  }
12
12
  // `title`/`content`/`excerpt` come back as `{raw, rendered}` from `?context=edit` (what `page
13
13
  // pull` requests) but round-trip fine as plain strings too for a hand-created file, same
@@ -56,7 +56,7 @@ const PAGE_META_FIELDS = [
56
56
  export function pickPageMeta(data) {
57
57
  const meta = {};
58
58
  for (const field of PAGE_META_FIELDS) {
59
- if (field in data)
59
+ if (Object.hasOwn(data, field))
60
60
  meta[field] = data[field];
61
61
  }
62
62
  return meta;
@@ -1,5 +1,5 @@
1
- import { InstalledPlugin, PluginManifest, WpNativePlugin } from '../types/plugin.js';
2
- export interface PluginDiff {
1
+ import { type InstalledPlugin, type PluginManifest, type WpNativePlugin } from '../types/plugin.js';
2
+ export type PluginDiff = {
3
3
  toActivate: Array<{
4
4
  file: string;
5
5
  slug: string;
@@ -8,8 +8,8 @@ export interface PluginDiff {
8
8
  slug: string;
9
9
  }>;
10
10
  upToDate: string[];
11
- }
12
- export interface MergeResult {
11
+ };
12
+ export type MergeResult = {
13
13
  added: string[];
14
14
  merged: PluginManifest;
15
15
  updated: Array<{
@@ -17,7 +17,7 @@ export interface MergeResult {
17
17
  slug: string;
18
18
  to: string;
19
19
  }>;
20
- }
20
+ };
21
21
  export declare function mergePluginManifest(existing: PluginManifest, incoming: PluginManifest): MergeResult;
22
22
  export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
23
23
  export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[]): PluginDiff;
@@ -6,9 +6,9 @@
6
6
  const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-light']);
7
7
  export function mergePluginManifest(existing, incoming) {
8
8
  const merged = { ...existing, ...incoming };
9
- const added = Object.keys(incoming).filter((s) => !(s in existing));
9
+ const added = Object.keys(incoming).filter((s) => !Object.hasOwn(existing, s));
10
10
  const updated = Object.keys(incoming)
11
- .filter((s) => s in existing && existing[s] !== incoming[s])
11
+ .filter((s) => Object.hasOwn(existing, s) && existing[s] !== incoming[s])
12
12
  .map((s) => ({ from: existing[s], slug: s, to: incoming[s] }));
13
13
  return { added, merged, updated };
14
14
  }
@@ -16,7 +16,7 @@ export function mergePluginManifest(existing, incoming) {
16
16
  // single-file plugin) with the `.php` extension stripped; the WordPress.org slug is just the
17
17
  // folder name (or the bare id itself for a single-file plugin).
18
18
  function slugFromPluginFile(file) {
19
- return file.split('/')[0];
19
+ return file.split('/', 1)[0];
20
20
  }
21
21
  export function parseInstalledPlugins(raw) {
22
22
  return raw