@loopress/cli 0.21.0 → 0.23.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 (103) hide show
  1. package/README.md +457 -67
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/app/list.d.ts +17 -0
  11. package/dist/commands/app/list.js +35 -0
  12. package/dist/commands/app/pull.d.ts +27 -0
  13. package/dist/commands/app/pull.js +94 -0
  14. package/dist/commands/app/push.d.ts +28 -0
  15. package/dist/commands/app/push.js +130 -0
  16. package/dist/commands/app/remove.d.ts +20 -0
  17. package/dist/commands/app/remove.js +42 -0
  18. package/dist/commands/composer/push.d.ts +1 -0
  19. package/dist/commands/composer/push.js +43 -19
  20. package/dist/commands/dev.js +1 -1
  21. package/dist/commands/doctor.js +2 -1
  22. package/dist/commands/form/list.d.ts +2 -4
  23. package/dist/commands/form/list.js +3 -10
  24. package/dist/commands/form/pull.js +15 -24
  25. package/dist/commands/form/push.js +17 -42
  26. package/dist/commands/init.js +1 -0
  27. package/dist/commands/login.js +6 -2
  28. package/dist/commands/logout.js +2 -1
  29. package/dist/commands/page/pull.js +17 -28
  30. package/dist/commands/page/push.js +27 -52
  31. package/dist/commands/plugin/add.d.ts +1 -0
  32. package/dist/commands/plugin/add.js +18 -8
  33. package/dist/commands/plugin/audit.d.ts +30 -0
  34. package/dist/commands/plugin/audit.js +138 -0
  35. package/dist/commands/plugin/pull.d.ts +1 -1
  36. package/dist/commands/plugin/pull.js +13 -15
  37. package/dist/commands/plugin/push.d.ts +14 -5
  38. package/dist/commands/plugin/push.js +171 -60
  39. package/dist/commands/plugin/status.d.ts +22 -0
  40. package/dist/commands/plugin/status.js +65 -0
  41. package/dist/commands/project/config.js +4 -1
  42. package/dist/commands/project/list.d.ts +16 -1
  43. package/dist/commands/project/list.js +22 -16
  44. package/dist/commands/project/pull.js +3 -2
  45. package/dist/commands/project/push.d.ts +5 -0
  46. package/dist/commands/project/push.js +79 -58
  47. package/dist/commands/promote.d.ts +18 -0
  48. package/dist/commands/promote.js +90 -0
  49. package/dist/commands/pull.d.ts +12 -0
  50. package/dist/commands/pull.js +51 -0
  51. package/dist/commands/push.d.ts +13 -0
  52. package/dist/commands/push.js +57 -0
  53. package/dist/commands/seo/list.d.ts +9 -2
  54. package/dist/commands/seo/list.js +9 -11
  55. package/dist/commands/seo/pull.js +14 -37
  56. package/dist/commands/seo/push.js +24 -48
  57. package/dist/commands/snippet/list.js +2 -1
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.js +15 -20
  60. package/dist/commands/snippet/push.js +21 -30
  61. package/dist/commands/theme/add.d.ts +13 -0
  62. package/dist/commands/theme/add.js +35 -0
  63. package/dist/commands/theme/pull.d.ts +21 -0
  64. package/dist/commands/theme/pull.js +37 -0
  65. package/dist/commands/theme/push.d.ts +23 -0
  66. package/dist/commands/theme/push.js +130 -0
  67. package/dist/commands/theme/status.d.ts +20 -0
  68. package/dist/commands/theme/status.js +51 -0
  69. package/dist/commands/validate.d.ts +8 -0
  70. package/dist/commands/validate.js +24 -0
  71. package/dist/lib/app-manifest.d.ts +36 -0
  72. package/dist/lib/app-manifest.js +162 -0
  73. package/dist/lib/base.d.ts +10 -0
  74. package/dist/lib/base.js +56 -22
  75. package/dist/lib/guard-production-push.d.ts +12 -0
  76. package/dist/lib/guard-production-push.js +20 -0
  77. package/dist/lib/load-snippets.js +65 -60
  78. package/dist/lib/local-callback-server.js +9 -4
  79. package/dist/lib/push-command.d.ts +3 -0
  80. package/dist/lib/push-command.js +22 -15
  81. package/dist/lib/put-or-create.d.ts +13 -0
  82. package/dist/lib/put-or-create.js +19 -0
  83. package/dist/lib/readdir-tolerant.d.ts +5 -0
  84. package/dist/lib/readdir-tolerant.js +11 -0
  85. package/dist/lib/resolve-linked-project.d.ts +8 -0
  86. package/dist/lib/resolve-linked-project.js +27 -0
  87. package/dist/lib/validate-local.d.ts +10 -0
  88. package/dist/lib/validate-local.js +183 -0
  89. package/dist/types/plugin.d.ts +1 -0
  90. package/dist/types/project-config.generated.d.ts +11 -1
  91. package/dist/utils/plugin-sync.d.ts +26 -0
  92. package/dist/utils/plugin-sync.js +31 -0
  93. package/dist/utils/plugins.d.ts +15 -2
  94. package/dist/utils/plugins.js +57 -12
  95. package/dist/utils/pluralize.d.ts +1 -0
  96. package/dist/utils/pluralize.js +5 -0
  97. package/dist/utils/themes.d.ts +30 -0
  98. package/dist/utils/themes.js +29 -0
  99. package/dist/utils/version.d.ts +2 -0
  100. package/dist/utils/version.js +21 -0
  101. package/oclif.manifest.json +986 -125
  102. package/package.json +25 -20
  103. package/schemas/project-config.schema.json +22 -4
@@ -8,12 +8,72 @@ const TYPE_BY_EXTENSION = {
8
8
  '.php': 'php',
9
9
  '.txt': 'text',
10
10
  };
11
+ // One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
12
+ // unreadable file, ...) must only skip that snippet, not abort loading every other one.
13
+ async function loadSnippet(path, file, onSkip) {
14
+ const ext = extname(file);
15
+ if (!Object.hasOwn(TYPE_BY_EXTENSION, ext))
16
+ return undefined;
17
+ const filePath = join(path, file);
18
+ const metaPath = join(path, `${basename(file, ext)}.json`);
19
+ let content;
20
+ try {
21
+ content = await readFile(filePath, 'utf8');
22
+ }
23
+ catch (error) {
24
+ onSkip?.(`Skipping "${filePath}": ${error.message}`);
25
+ return undefined;
26
+ }
27
+ let id;
28
+ let name;
29
+ let type;
30
+ let isActive = false;
31
+ let tags = [];
32
+ let location = null;
33
+ let insertMethod = null;
34
+ let priority = 10;
35
+ let shortcodeAttributes = [];
36
+ try {
37
+ const metaContent = await readFile(metaPath, 'utf8');
38
+ const meta = JSON.parse(metaContent);
39
+ id = meta.id;
40
+ // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
41
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
42
+ name = meta.name || undefined;
43
+ type = parseType(meta.type) ?? undefined;
44
+ isActive = Boolean(meta.active);
45
+ tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
46
+ location = parseLocation(meta.location);
47
+ insertMethod = parseInsertMethod(meta.insertMethod);
48
+ priority = meta.priority ?? 10;
49
+ shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
50
+ }
51
+ catch (error) {
52
+ if (error.code !== 'ENOENT') {
53
+ onSkip?.(`Skipping "${metaPath}": ${error.message}`);
54
+ return undefined;
55
+ }
56
+ }
57
+ const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
58
+ return {
59
+ active: isActive,
60
+ code: content,
61
+ id,
62
+ insertMethod: insertMethod ?? 'auto',
63
+ location: location ?? defaultLocationForType(resolvedType),
64
+ name: name ?? basename(file, ext),
65
+ path: filePath,
66
+ priority,
67
+ shortcodeAttributes,
68
+ tags,
69
+ type: resolvedType,
70
+ };
71
+ }
11
72
  // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
12
73
  // (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
13
74
  // directory the same way. `onSkip` is optional so callers that don't care about per-file
14
75
  // warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
15
76
  export async function loadSnippets(path, onSkip) {
16
- const snippets = [];
17
77
  let files;
18
78
  try {
19
79
  files = await readdir(path);
@@ -21,66 +81,11 @@ export async function loadSnippets(path, onSkip) {
21
81
  catch (error) {
22
82
  throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
23
83
  }
84
+ const snippets = [];
24
85
  for (const file of files) {
25
- const ext = extname(file);
26
- if (!Object.hasOwn(TYPE_BY_EXTENSION, ext))
27
- continue;
28
- const filePath = join(path, file);
29
- const metaPath = join(path, `${basename(file, ext)}.json`);
30
- // One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
31
- // unreadable file, ...) must only skip that snippet, not abort loading every other one.
32
- let content;
33
- try {
34
- content = await readFile(filePath, 'utf8');
35
- }
36
- catch (error) {
37
- onSkip?.(`Skipping "${filePath}": ${error.message}`);
38
- continue;
39
- }
40
- let id;
41
- let name;
42
- let type;
43
- let isActive = false;
44
- let tags = [];
45
- let location = null;
46
- let insertMethod = null;
47
- let priority = 10;
48
- let shortcodeAttributes = [];
49
- try {
50
- const metaContent = await readFile(metaPath, 'utf8');
51
- const meta = JSON.parse(metaContent);
52
- id = meta.id;
53
- // `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
54
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
55
- name = meta.name || undefined;
56
- type = parseType(meta.type) ?? undefined;
57
- isActive = Boolean(meta.active);
58
- tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
59
- location = parseLocation(meta.location);
60
- insertMethod = parseInsertMethod(meta.insertMethod);
61
- priority = meta.priority ?? 10;
62
- shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
63
- }
64
- catch (error) {
65
- if (error.code !== 'ENOENT') {
66
- onSkip?.(`Skipping "${metaPath}": ${error.message}`);
67
- continue;
68
- }
69
- }
70
- const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
71
- snippets.push({
72
- active: isActive,
73
- code: content,
74
- id,
75
- insertMethod: insertMethod ?? 'auto',
76
- location: location ?? defaultLocationForType(resolvedType),
77
- name: name ?? basename(file, ext),
78
- path: filePath,
79
- priority,
80
- shortcodeAttributes,
81
- tags,
82
- type: resolvedType,
83
- });
86
+ const snippet = await loadSnippet(path, file, onSkip);
87
+ if (snippet)
88
+ snippets.push(snippet);
84
89
  }
85
90
  return snippets;
86
91
  }
@@ -17,20 +17,25 @@ function parseFormData(body) {
17
17
  export async function waitForLocalCallback(options) {
18
18
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
19
19
  return new Promise((resolve, reject) => {
20
- function finish(res, page, settle) {
20
+ function finish(res, page) {
21
21
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
22
22
  res.end(page);
23
23
  clearTimeout(timer);
24
24
  server.close();
25
- settle();
26
25
  }
27
26
  async function handleIncoming(req, res) {
28
27
  try {
29
28
  const url = new URL(req.url ?? '/', 'http://localhost');
30
29
  const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
31
30
  options.handleRequest(url, {
32
- rejectWithPage(page, error) { finish(res, page, () => { reject(error); }); },
33
- 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
+ },
34
39
  respondBadRequest(message) {
35
40
  res.writeHead(400, { 'Content-Type': 'text/plain' });
36
41
  res.end(message);
@@ -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 isProceed = await confirm({ default: true, message: `Push to production (${this.siteConfig.url})?` });
28
- if (!isProceed) {
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
+ }
@@ -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
+ }
@@ -8,6 +8,7 @@ export type InstalledPlugin = {
8
8
  export type WpNativePlugin = {
9
9
  name: string;
10
10
  plugin: string;
11
+ plugin_uri: string;
11
12
  status: 'active' | 'inactive' | 'network-active';
12
13
  version: string;
13
14
  };
@@ -31,14 +31,24 @@ export interface LoopressProjectConfiguration {
31
31
  * Directory where custom API route files are read from and written to, relative to rootDir.
32
32
  */
33
33
  apiDir?: string;
34
+ /**
35
+ * Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
36
+ */
37
+ appsDir?: string;
34
38
  /**
35
39
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
36
40
  */
37
41
  projectId?: string;
38
42
  /**
39
- * WordPress.org plugins managed by Loopress. Keys are plugin slugs; values are written as "latest" since installs go through WordPress's native plugin API, which only installs the current version. Older files may still have a pinned version string here from before this was the case; it is read but no longer acted on.
43
+ * WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
40
44
  */
41
45
  plugins?: {
42
46
  [k: string]: string;
43
47
  };
48
+ /**
49
+ * WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme. Ignored when a composer.json exists in rootDir.
50
+ */
51
+ themes?: {
52
+ [k: string]: string;
53
+ };
44
54
  }
@@ -0,0 +1,26 @@
1
+ export declare const SYNC_TIMEOUT_MS = 600000;
2
+ export type SyncIntent = {
3
+ libraries?: Record<string, string>;
4
+ plugins?: Record<string, string>;
5
+ themes?: Record<string, string>;
6
+ };
7
+ export type SyncPayload = {
8
+ force: boolean;
9
+ intent: SyncIntent;
10
+ lock: null | string;
11
+ };
12
+ export type SyncResponse = {
13
+ composerJson: string;
14
+ composerLock: string;
15
+ message: string;
16
+ output: string;
17
+ removed: string[];
18
+ };
19
+ export type Collision = {
20
+ installedVersion: string;
21
+ path: string;
22
+ slug: string;
23
+ type: 'plugin' | 'theme';
24
+ };
25
+ export declare function parseCollisions(error: unknown): Collision[] | null;
26
+ export declare function isDowngrade(from: string, to: string): boolean;
@@ -0,0 +1,31 @@
1
+ // Shared helpers for driving the server-side `loopress/v1/composer/sync` endpoint, used by
2
+ // `lps plugin push`, `lps theme push` and `lps composer push`. The endpoint runs Composer +
3
+ // WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
4
+ // never a composer.json, so the plugin stays the single authority on that file's shape.
5
+ import { compareVersions } from './version.js';
6
+ // A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
7
+ export const SYNC_TIMEOUT_MS = 600_000;
8
+ // 422 from the sync endpoint when the intent references a plugin/theme whose folder already
9
+ // exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
10
+ // Re-running with --force lets the server remove the folder so Composer can take it over.
11
+ export function parseCollisions(error) {
12
+ const body = error.cause?.response;
13
+ if (body?.statusCode !== 422 || !body.body)
14
+ return null;
15
+ try {
16
+ const parsed = JSON.parse(body.body);
17
+ if (parsed.error === 'unmanaged_plugins_present' && Array.isArray(parsed.collisions)) {
18
+ return parsed.collisions;
19
+ }
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ return null;
25
+ }
26
+ // Only gates whether a version change needs --force. Unorderable inputs ("latest", a
27
+ // constraint) count as "not a downgrade" so they don't block the push.
28
+ export function isDowngrade(from, to) {
29
+ const order = compareVersions(to, from);
30
+ return order !== null && order < 0;
31
+ }
@@ -1,13 +1,25 @@
1
1
  import { type InstalledPlugin, type PluginManifest, type WpNativePlugin } from '../types/plugin.js';
2
2
  export type PluginDiff = {
3
+ collisions: Array<{
4
+ installedVersion: string;
5
+ slug: string;
6
+ }>;
7
+ inSync: string[];
3
8
  toActivate: Array<{
4
9
  file: string;
5
10
  slug: string;
6
11
  }>;
7
12
  toInstall: Array<{
8
13
  slug: string;
14
+ version: string;
15
+ }>;
16
+ toPin: Array<{
17
+ from: string;
18
+ slug: string;
19
+ to: string;
9
20
  }>;
10
- upToDate: string[];
21
+ toRemove: string[];
22
+ untrackedActive: string[];
11
23
  };
12
24
  export type MergeResult = {
13
25
  added: string[];
@@ -20,4 +32,5 @@ export type MergeResult = {
20
32
  };
21
33
  export declare function mergePluginManifest(existing: PluginManifest, incoming: PluginManifest): MergeResult;
22
34
  export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
23
- export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[]): PluginDiff;
35
+ export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
36
+ export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;