@loopress/cli 0.25.0 → 0.27.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 (38) hide show
  1. package/README.md +197 -101
  2. package/dist/commands/diff.js +1 -1
  3. package/dist/commands/init.js +6 -1
  4. package/dist/commands/page/diff.d.ts +2 -0
  5. package/dist/commands/page/diff.js +5 -0
  6. package/dist/commands/page/list.d.ts +8 -0
  7. package/dist/commands/page/list.js +22 -0
  8. package/dist/commands/page/push.d.ts +20 -0
  9. package/dist/commands/page/push.js +60 -0
  10. package/dist/commands/plugin/add.d.ts +8 -1
  11. package/dist/commands/plugin/add.js +4 -2
  12. package/dist/commands/promote.js +3 -2
  13. package/dist/commands/pull.js +6 -2
  14. package/dist/commands/push.js +8 -3
  15. package/dist/commands/theme/add.d.ts +8 -1
  16. package/dist/commands/theme/add.js +4 -2
  17. package/dist/lib/api-client.js +24 -1
  18. package/dist/lib/dev-watch.d.ts +1 -1
  19. package/dist/lib/dev-watch.js +3 -1
  20. package/dist/lib/json-delegation.d.ts +1 -0
  21. package/dist/lib/json-delegation.js +18 -0
  22. package/dist/lib/load-snippets.js +1 -1
  23. package/dist/lib/php-files-command.js +4 -3
  24. package/dist/lib/readdir-tolerant.js +15 -5
  25. package/dist/lib/resource-state.js +24 -0
  26. package/dist/lib/snapshot-store.js +14 -0
  27. package/dist/lib/validate-local.js +33 -7
  28. package/dist/lib/wp-client.js +31 -2
  29. package/dist/types/project-config.generated.d.ts +4 -0
  30. package/dist/utils/loopress-config.d.ts +1 -0
  31. package/dist/utils/loopress-config.js +14 -0
  32. package/dist/utils/page-format.d.ts +31 -0
  33. package/dist/utils/page-format.js +118 -0
  34. package/dist/utils/resource-dirs.d.ts +1 -0
  35. package/dist/utils/resource-dirs.js +2 -0
  36. package/oclif.manifest.json +506 -298
  37. package/package.json +5 -3
  38. package/schemas/project-config.schema.json +5 -0
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
7
7
  export default class Diff extends DiffCommand {
8
8
  static description = [
9
9
  'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
10
- 'Covers snippets, forms, ACF, API routes, Hooks, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
10
+ 'Covers snippets, forms, ACF, API routes, Hooks, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
11
11
  'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
12
12
  ].join(' ');
13
13
  static enableJsonFlag = true;
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { configManager } from '../config/project-config.manager.js';
6
6
  import { isInteractive } from '../lib/interactive.js';
7
- import { writeLocalConfig } from '../utils/loopress-config.js';
7
+ import { ensureLfGitattributes, writeLocalConfig } from '../utils/loopress-config.js';
8
8
  // WordPress.org slugs for the two snippet plugins the Loopress WordPress plugin supports
9
9
  // (see SnippetModule.php, which wires up both providers and auto-detects the active one).
10
10
  const SNIPPET_PROVIDERS = [
@@ -20,6 +20,7 @@ const FEATURES = [
20
20
  { dir: 'api', key: 'apiDir', label: 'Custom API routes' },
21
21
  { dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
22
22
  { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
23
+ { dir: 'pages', key: 'pageDir', label: 'Static pages' },
23
24
  ];
24
25
  export default class Init extends Command {
25
26
  static description = 'Initialize a loopress.json config file in the current directory';
@@ -67,6 +68,7 @@ export default class Init extends Command {
67
68
  config[feature.key] = feature.dir;
68
69
  }
69
70
  await writeLocalConfig(config);
71
+ const isGitattributesUpdated = await ensureLfGitattributes();
70
72
  let isProviderAdded = false;
71
73
  if (providerChoice !== '__none__') {
72
74
  try {
@@ -88,6 +90,9 @@ export default class Init extends Command {
88
90
  if (isProviderAdded) {
89
91
  this.log(` Plugin: ${providerChoice}`);
90
92
  }
93
+ if (isGitattributesUpdated) {
94
+ this.log('✓ .gitattributes: LF line endings, so files match across macOS, Linux and Windows');
95
+ }
91
96
  this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
92
97
  }
93
98
  // When nothing is configured yet, the useful path is configuring a project right here, not
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('page', {
3
+ description: 'Show what differs in static pages between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'pages directory',
5
+ });
@@ -0,0 +1,8 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type RemotePage } from '../../utils/page-format.js';
3
+ export default class List extends LoopressCommand {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ run(): Promise<Array<Omit<RemotePage, 'html'>>>;
8
+ }
@@ -0,0 +1,22 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { PAGES_ENDPOINT } from '../../utils/page-format.js';
3
+ import { pluralize } from '../../utils/pluralize.js';
4
+ export default class List extends LoopressCommand {
5
+ static description = 'List the static pages managed by Loopress on WordPress';
6
+ static enableJsonFlag = true;
7
+ static examples = ['$ lps page list'];
8
+ async run() {
9
+ const pages = await this.wp.get(PAGES_ENDPOINT);
10
+ // The HTML is only needed by `page diff`, it would drown the JSON output here.
11
+ const listed = pages.map(({ html: _html, ...page }) => page);
12
+ if (listed.length === 0) {
13
+ this.log('No pages managed by Loopress');
14
+ return listed;
15
+ }
16
+ this.log(`Found ${pluralize(listed.length, 'page')}:`);
17
+ this.log('');
18
+ for (const page of listed)
19
+ this.log(` ${page.slug} ${page.status} ${page.link}`);
20
+ return listed;
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ pushed: string[];
4
+ status: 'dry-run' | 'success';
5
+ };
6
+ export default class Push extends PushCommand {
7
+ static args: {
8
+ slug: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
9
+ };
10
+ static description: string;
11
+ static enableJsonFlag: boolean;
12
+ static examples: string[];
13
+ static flags: {
14
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<PushResult>;
18
+ private pushPage;
19
+ }
20
+ export {};
@@ -0,0 +1,60 @@
1
+ import { Args } from '@oclif/core';
2
+ import { PushCommand } from '../../lib/push-command.js';
3
+ import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
4
+ import { pluralize } from '../../utils/pluralize.js';
5
+ import { resolveResourceDir } from '../../utils/resource-dirs.js';
6
+ export default class Push extends PushCommand {
7
+ static args = {
8
+ slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
9
+ };
10
+ static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
11
+ static enableJsonFlag = true;
12
+ static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
13
+ static flags = {
14
+ ...PushCommand.dryRunFlag,
15
+ ...PushCommand.yesFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Push);
19
+ const path = resolveResourceDir('page', this.localConfig);
20
+ this.log(`Pushing pages to ${this.siteConfig.url}`);
21
+ this.log(`Pages path: ${path}`);
22
+ const { pages, problems } = await readLocalPages(path);
23
+ if (problems.length > 0) {
24
+ this.error(`Nothing was pushed, fix these pages first:\n ${formatPageProblems(problems)}`);
25
+ }
26
+ const selected = args.slug === undefined ? pages : pages.filter((page) => page.slug === args.slug);
27
+ if (args.slug !== undefined && selected.length === 0) {
28
+ this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
29
+ }
30
+ this.log(`Found ${pluralize(selected.length, 'page')} to push`);
31
+ const pushed = [];
32
+ await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
33
+ await this.pushPage(page, task);
34
+ pushed.push(page.slug);
35
+ });
36
+ if (this.failedCount > 0) {
37
+ this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
38
+ }
39
+ if (this.dryRun)
40
+ return { pushed, status: 'dry-run' };
41
+ await this.recordSuccess();
42
+ this.log('All pages pushed.');
43
+ return { pushed, status: 'success' };
44
+ }
45
+ async pushPage(page, task) {
46
+ if (this.dryRun) {
47
+ if (task)
48
+ task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
49
+ return;
50
+ }
51
+ try {
52
+ const result = await this.wp.put(PAGES_ENDPOINT, page);
53
+ if (task)
54
+ task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}${frontPageNote(result)}`;
55
+ }
56
+ catch (error) {
57
+ this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
58
+ }
59
+ }
60
+ }
@@ -1,13 +1,20 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type AddResult = {
3
+ slug: string;
4
+ status: 'added' | 'dry-run' | 'unchanged' | 'updated';
5
+ version: string;
6
+ };
2
7
  export default class Add extends LoopressCommand {
3
8
  static args: {
4
9
  slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
10
  };
6
11
  static description: string;
12
+ static enableJsonFlag: boolean;
7
13
  static examples: string[];
8
14
  static flags: {
9
15
  version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
17
  };
12
- run(): Promise<void>;
18
+ run(): Promise<AddResult>;
13
19
  }
20
+ export {};
@@ -7,6 +7,7 @@ export default class Add extends LoopressCommand {
7
7
  slug: Args.string({ description: 'Plugin slug on WordPress.org', required: true }),
8
8
  };
9
9
  static description = 'Add a WordPress.org plugin to loopress.json';
10
+ static enableJsonFlag = true;
10
11
  static examples = [
11
12
  '$ lps plugin add woocommerce',
12
13
  '$ lps plugin add woocommerce --version 9.4.2',
@@ -26,17 +27,18 @@ export default class Add extends LoopressCommand {
26
27
  const existing = this.localConfig.plugins ?? {};
27
28
  if (existing[slug] === version) {
28
29
  this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
29
- return;
30
+ return { slug, status: 'unchanged', version };
30
31
  }
31
32
  const isUpdated = existing[slug] !== undefined;
32
33
  if (this.dryRun) {
33
34
  this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
34
- return;
35
+ return { slug, status: 'dry-run', version };
35
36
  }
36
37
  await writeLocalConfig({
37
38
  ...this.localConfig,
38
39
  plugins: { ...existing, [slug]: version },
39
40
  });
40
41
  this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
42
+ return { slug, status: isUpdated ? 'updated' : 'added', version };
41
43
  }
42
44
  }
@@ -3,6 +3,7 @@ import { Args, Command } from '@oclif/core';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
4
  import { LoopressCommand } from '../lib/base.js';
5
5
  import { isInteractive } from '../lib/interactive.js';
6
+ import { stdoutToStderr } from '../lib/json-delegation.js';
6
7
  import { readLocalConfig } from '../utils/loopress-config.js';
7
8
  export default class Promote extends Command {
8
9
  static args = {
@@ -30,14 +31,14 @@ export default class Promote extends Command {
30
31
  await this.confirmPromotion(from.name, to.name, to.url, yes);
31
32
  this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
32
33
  try {
33
- await this.config.runCommand('pull', this.delegateArgv(from.name, dryRun));
34
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, dryRun)));
34
35
  }
35
36
  catch (error) {
36
37
  // A partial pull must never be pushed onward: stop before touching <to>.
37
38
  this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
38
39
  }
39
40
  this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
40
- await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
41
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
41
42
  this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
42
43
  return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
43
44
  }
@@ -1,4 +1,5 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
+ import { stdoutToStderr } from '../lib/json-delegation.js';
2
3
  import { pluralize } from '../utils/pluralize.js';
3
4
  // composer before plugins: `plugin:pull` reads the local composer.json to skip
4
5
  // Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
@@ -29,7 +30,7 @@ export default class Pull extends LoopressCommand {
29
30
  for (const target of PULL_TARGETS) {
30
31
  this.log(`\n→ Pulling ${target.label}...`);
31
32
  try {
32
- await this.config.runCommand(target.commandId, this.buildArgv(target));
33
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
33
34
  this.log(`✓ ${target.label} pulled`);
34
35
  results.push({ label: target.label, status: 'pulled' });
35
36
  }
@@ -41,7 +42,10 @@ export default class Pull extends LoopressCommand {
41
42
  }
42
43
  }
43
44
  if (this.failedCount > 0) {
44
- this.error(`${pluralize(this.failedCount, 'resource')} failed to pull.`);
45
+ // Each failure's own reason, not just the count: under --json the per-resource lines above
46
+ // never reach stdout, and this message is all an MCP caller or a script gets to see.
47
+ const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
48
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
45
49
  }
46
50
  this.log('\nAll resources pulled.');
47
51
  return { results };
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
2
  import { guardProductionPush } from '../lib/guard-production-push.js';
3
+ import { stdoutToStderr } from '../lib/json-delegation.js';
3
4
  import { pluralize } from '../utils/pluralize.js';
4
5
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
5
6
  // relies on, then content itself.
@@ -10,13 +11,14 @@ const PUSH_TARGETS = [
10
11
  { commandId: 'api:push', label: 'API routes' },
11
12
  { commandId: 'hook:push', label: 'hooks' },
12
13
  { commandId: 'form:push', label: 'forms' },
14
+ { commandId: 'page:push', label: 'pages' },
13
15
  { commandId: 'seo:push', label: 'SEO' },
14
16
  { commandId: 'menu:push', label: 'menus' },
15
17
  { commandId: 'option:push', label: 'options' },
16
18
  { commandId: 'snippet:push', label: 'snippets' },
17
19
  ];
18
20
  export default class Push extends LoopressCommand {
19
- static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, to WordPress';
21
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
20
22
  static enableJsonFlag = true;
21
23
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
22
24
  static flags = {
@@ -31,7 +33,7 @@ export default class Push extends LoopressCommand {
31
33
  for (const target of PUSH_TARGETS) {
32
34
  this.log(`\n→ Pushing ${target.label}...`);
33
35
  try {
34
- await this.config.runCommand(target.commandId, argv);
36
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
35
37
  this.log(`✓ ${target.label} pushed`);
36
38
  results.push({ label: target.label, status: 'pushed' });
37
39
  }
@@ -43,7 +45,10 @@ export default class Push extends LoopressCommand {
43
45
  }
44
46
  }
45
47
  if (this.failedCount > 0) {
46
- this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
48
+ // Each failure's own reason, not just the count: under --json the per-resource lines above
49
+ // never reach stdout, and this message is all an MCP caller or a script gets to see.
50
+ const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
51
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
47
52
  }
48
53
  this.log('\nAll resources pushed.');
49
54
  return { results };
@@ -1,13 +1,20 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type AddResult = {
3
+ slug: string;
4
+ status: 'added' | 'dry-run' | 'unchanged' | 'updated';
5
+ version: string;
6
+ };
2
7
  export default class Add extends LoopressCommand {
3
8
  static args: {
4
9
  slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
10
  };
6
11
  static description: string;
12
+ static enableJsonFlag: boolean;
7
13
  static examples: string[];
8
14
  static flags: {
9
15
  version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
17
  };
12
- run(): Promise<void>;
18
+ run(): Promise<AddResult>;
13
19
  }
20
+ export {};
@@ -7,6 +7,7 @@ export default class Add extends LoopressCommand {
7
7
  slug: Args.string({ description: 'Theme slug on WordPress.org', required: true }),
8
8
  };
9
9
  static description = 'Add a WordPress.org theme to loopress.json';
10
+ static enableJsonFlag = true;
10
11
  static examples = ['$ lps theme add generatepress', '$ lps theme add generatepress --version 3.4.0'];
11
12
  static flags = {
12
13
  ...LoopressCommand.dryRunFlag,
@@ -22,14 +23,15 @@ export default class Add extends LoopressCommand {
22
23
  const existing = this.localConfig.themes ?? {};
23
24
  if (existing[slug] === version) {
24
25
  this.log(`${slug} is already pinned to ${version}, nothing to do.`);
25
- return;
26
+ return { slug, status: 'unchanged', version };
26
27
  }
27
28
  const isUpdated = existing[slug] !== undefined;
28
29
  if (this.dryRun) {
29
30
  this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version})`);
30
- return;
31
+ return { slug, status: 'dry-run', version };
31
32
  }
32
33
  await writeLocalConfig({ ...this.localConfig, themes: { ...existing, [slug]: version } });
33
34
  this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps theme push\` to apply.`);
35
+ return { slug, status: isUpdated ? 'updated' : 'added', version };
34
36
  }
35
37
  }
@@ -43,11 +43,34 @@ function formatApiError(error, url, timeoutMs) {
43
43
  if (status === 401) {
44
44
  return `Not logged in or session expired on ${url}. Run \`lps login\` again.`;
45
45
  }
46
+ const reason = nestErrorMessage(err.response?.body);
46
47
  if (status === 403) {
47
- return `Request rejected (${status}) on ${url}: ${err.response?.body ?? err.message}`;
48
+ return `Request rejected (${status}) on ${url}: ${reason ?? err.response?.body ?? err.message}`;
49
+ }
50
+ // Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
51
+ // the body; got's own message ("Request failed with status code 400") never does.
52
+ if (status !== undefined && reason) {
53
+ return `Request failed (${status}) on ${url}: ${reason}`;
48
54
  }
49
55
  if (err.name === 'TimeoutError') {
50
56
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
51
57
  }
52
58
  return err.message ?? String(error);
53
59
  }
60
+ // NestJS's HttpException body: `{statusCode, message, error}`, where `message` is a list for a
61
+ // ValidationPipe failure (one entry per invalid field).
62
+ function nestErrorMessage(body) {
63
+ if (!body)
64
+ return undefined;
65
+ try {
66
+ const { message } = JSON.parse(body);
67
+ if (typeof message === 'string' && message.trim())
68
+ return message;
69
+ if (Array.isArray(message) && message.length > 0)
70
+ return message.join('; ');
71
+ }
72
+ catch {
73
+ // not JSON (a proxy's HTML error page): nothing to extract
74
+ }
75
+ return undefined;
76
+ }
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'plugins' | 'snippets';
2
+ export type ResourceType = 'api' | 'hooks' | 'pages' | 'plugins' | 'snippets';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
- export const RESOURCE_TYPES = ['snippets', 'api', 'plugins'];
3
+ export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'hooks', 'plugins'];
4
4
  // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
5
  // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
6
  export function resolveResourceTypes(only, skip = []) {
@@ -15,6 +15,8 @@ export function buildWatchTargets(types, localConfig, cwd) {
15
15
  const rootDir = join(cwd, localConfig.rootDir ?? '.');
16
16
  const candidates = {
17
17
  api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
18
+ hooks: { commandId: 'hook:push', path: join(rootDir, localConfig.hooksDir ?? 'hooks'), type: 'hooks' },
19
+ pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
18
20
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
19
21
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
20
22
  };
@@ -0,0 +1 @@
1
+ export declare function stdoutToStderr<T>(enabled: boolean, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,18 @@
1
+ // `lps push`/`pull`/`promote --json` must put exactly one JSON document on stdout: that's what
2
+ // the MCP server (and any script) parses. The resource commands they delegate to aren't in JSON
3
+ // mode themselves, so their progress lines and Listr renderers would land on stdout ahead of it.
4
+ // While `enabled`, their stdout goes to stderr instead: still visible to a human, out of the
5
+ // JSON. Patching the stream's write is the one place every writer goes through (oclif's
6
+ // ux.stdout, Listr's renderers, a stray console.log).
7
+ export async function stdoutToStderr(enabled, work) {
8
+ if (!enabled)
9
+ return work();
10
+ const original = process.stdout.write;
11
+ process.stdout.write = process.stderr.write.bind(process.stderr);
12
+ try {
13
+ return await work();
14
+ }
15
+ finally {
16
+ process.stdout.write = original;
17
+ }
18
+ }
@@ -70,7 +70,7 @@ async function loadSnippet(path, file, onSkip) {
70
70
  };
71
71
  }
72
72
  // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
73
- // (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
73
+ // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
74
74
  // directory the same way. `onSkip` is optional so callers that don't care about per-file
75
75
  // warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
76
76
  export async function loadSnippets(path, onSkip) {
@@ -341,9 +341,10 @@ export const API_FILES_RESOURCE = {
341
341
  emptyListMessage: 'No API route files found',
342
342
  endpoint: 'loopress/v1/api-files',
343
343
  // Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
344
- // segment like [order_id], joined by '/'.
345
- filenamePattern: /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
346
- invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php")',
344
+ // segment like [order_id], joined by '/', and never a root "index" (api/index.php is
345
+ // reserved; a nested orders/index.php is the /orders route).
346
+ filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
347
+ invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
347
348
  label: 'API routes',
348
349
  listDescription: 'List custom API route files from WordPress',
349
350
  noun: 'route file',
@@ -1,4 +1,4 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  export async function readdirTolerant(dir, options) {
4
4
  try {
@@ -16,10 +16,20 @@ export async function readdirTolerant(dir, options) {
16
16
  // never the OS separator: callers match these identities against server-sent filenames
17
17
  // (which are always '/'-joined) or embed them in an API payload, so a Windows run must
18
18
  // produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
19
- // same tolerance as readdirTolerant.
19
+ // same tolerance as readdirTolerant. A symlink to a file counts as that file (a project can
20
+ // link a route in from a shared checkout); a broken one, or one to a directory, is skipped.
20
21
  export async function walkFiles(dir) {
21
22
  const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
22
- return entries
23
- .filter((entry) => entry.isFile())
24
- .map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join('/'));
23
+ const files = [];
24
+ for (const entry of entries) {
25
+ const path = join(entry.parentPath, entry.name);
26
+ if (entry.isFile() || (entry.isSymbolicLink() && (await isFile(path))))
27
+ files.push(relative(dir, path).split(sep).join('/'));
28
+ }
29
+ return files;
30
+ }
31
+ async function isFile(path) {
32
+ return stat(path)
33
+ .then((s) => s.isFile())
34
+ .catch(() => false);
25
35
  }
@@ -4,6 +4,7 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
4
4
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
5
5
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
6
6
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
7
+ import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
7
8
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
8
9
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
9
10
  import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
@@ -194,6 +195,28 @@ const hookProvider = phpFilesProvider({
194
195
  resource: 'hook',
195
196
  title: 'Hooks',
196
197
  });
198
+ // ---- Pages ------------------------------------------------------------------------------
199
+ // Keyed by slug (the page's identity on both sides). `link` is server-derived and never
200
+ // compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
201
+ // than hiding that page, the same stop-everything stance as `page push`.
202
+ function canonicalPage(page) {
203
+ return { html: page.html, status: page.status, title: page.title };
204
+ }
205
+ const pageProvider = {
206
+ dirKind: 'page',
207
+ async local(dir) {
208
+ const { pages, problems } = await readLocalPages(dir);
209
+ if (problems.length > 0)
210
+ throw new Error(formatPageProblems(problems));
211
+ return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
212
+ },
213
+ async remote(wp) {
214
+ const pages = await wp.get(PAGES_ENDPOINT);
215
+ return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
216
+ },
217
+ resource: 'page',
218
+ title: 'Pages',
219
+ };
197
220
  // ---- SEO --------------------------------------------------------------------------------
198
221
  function canonicalRedirect(redirect) {
199
222
  return omit(redirect, REDIRECT_VOLATILE_KEYS);
@@ -403,6 +426,7 @@ export const RESOURCE_STATE_PROVIDERS = [
403
426
  acfProvider,
404
427
  apiProvider,
405
428
  hookProvider,
429
+ pageProvider,
406
430
  seoProvider,
407
431
  menuProvider,
408
432
  optionsProvider,
@@ -49,6 +49,7 @@ export async function writeSnapshot(options) {
49
49
  const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
50
50
  const dir = snapshotsDir(rootDir, resource, environment);
51
51
  await mkdir(dir, { recursive: true });
52
+ await ignoreCacheDirInGit(rootDir);
52
53
  const snapshot = {
53
54
  afterState: stateToRecord(afterState),
54
55
  beforeState: stateToRecord(beforeState),
@@ -72,6 +73,19 @@ export async function writeSnapshot(options) {
72
73
  await pruneSnapshots(rootDir, resource, environment, retain);
73
74
  return String(id);
74
75
  }
76
+ // `.loopress/` lives inside the user's project and holds the environment's full remote state
77
+ // (snippet code, form definitions, option values): a `git add .` must never pick it up. The
78
+ // directory ignores itself, the way `.pytest_cache` does, instead of relying on every project
79
+ // to add it to its own .gitignore. `wx` so a hand-edited file is never overwritten.
80
+ async function ignoreCacheDirInGit(rootDir) {
81
+ try {
82
+ await writeFile(join(rootDir, '.loopress', '.gitignore'), '# Written by the Loopress CLI: local rollback snapshots, never commit them.\n*\n', { flag: 'wx' });
83
+ }
84
+ catch (error) {
85
+ if (!isEexist(error))
86
+ throw error;
87
+ }
88
+ }
75
89
  // Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
76
90
  // "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
77
91
  // a never-pulled local directory.