@loopress/cli 0.26.0 → 0.28.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 (87) hide show
  1. package/README.md +275 -177
  2. package/dist/commands/app/pull.js +17 -4
  3. package/dist/commands/app/push.d.ts +3 -0
  4. package/dist/commands/app/push.js +29 -5
  5. package/dist/commands/composer/push.js +1 -19
  6. package/dist/commands/diff.js +1 -1
  7. package/dist/commands/init.js +7 -1
  8. package/dist/commands/page/push.js +2 -2
  9. package/dist/commands/plugin/add.d.ts +8 -1
  10. package/dist/commands/plugin/add.js +10 -5
  11. package/dist/commands/plugin/audit.js +4 -5
  12. package/dist/commands/plugin/pull.d.ts +3 -1
  13. package/dist/commands/plugin/pull.js +17 -9
  14. package/dist/commands/plugin/push.d.ts +5 -1
  15. package/dist/commands/plugin/push.js +90 -37
  16. package/dist/commands/plugin/status.d.ts +1 -0
  17. package/dist/commands/plugin/status.js +21 -2
  18. package/dist/commands/project/config.js +12 -8
  19. package/dist/commands/promote.d.ts +2 -0
  20. package/dist/commands/promote.js +70 -8
  21. package/dist/commands/pull.js +8 -4
  22. package/dist/commands/push.d.ts +1 -0
  23. package/dist/commands/push.js +42 -4
  24. package/dist/commands/template/diff.d.ts +10 -0
  25. package/dist/commands/template/diff.js +13 -0
  26. package/dist/commands/template/list.d.ts +13 -0
  27. package/dist/commands/template/list.js +23 -0
  28. package/dist/commands/template/push.d.ts +20 -0
  29. package/dist/commands/template/push.js +42 -0
  30. package/dist/commands/theme/add.d.ts +8 -1
  31. package/dist/commands/theme/add.js +4 -2
  32. package/dist/commands/theme/pull.d.ts +3 -1
  33. package/dist/commands/theme/pull.js +10 -4
  34. package/dist/commands/theme/style/diff.d.ts +2 -0
  35. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  36. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  37. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  38. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  39. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  40. package/dist/commands/theme/style/rollback.d.ts +2 -0
  41. package/dist/commands/theme/style/rollback.js +6 -0
  42. package/dist/lib/api-client.js +26 -1
  43. package/dist/lib/app-build.d.ts +5 -0
  44. package/dist/lib/app-build.js +69 -0
  45. package/dist/lib/app-manifest.d.ts +4 -0
  46. package/dist/lib/app-manifest.js +8 -0
  47. package/dist/lib/base.js +11 -2
  48. package/dist/lib/bootstrap-full-install.js +45 -14
  49. package/dist/lib/dev-watch.d.ts +1 -1
  50. package/dist/lib/dev-watch.js +4 -1
  51. package/dist/lib/diff-command.d.ts +1 -0
  52. package/dist/lib/diff-command.js +6 -4
  53. package/dist/lib/json-delegation.d.ts +1 -0
  54. package/dist/lib/json-delegation.js +18 -0
  55. package/dist/lib/load-snippets.js +1 -1
  56. package/dist/lib/php-files-command.js +4 -3
  57. package/dist/lib/readdir-tolerant.js +15 -5
  58. package/dist/lib/resource-state.js +43 -1
  59. package/dist/lib/rollback-command.d.ts +1 -0
  60. package/dist/lib/rollback-command.js +8 -6
  61. package/dist/lib/rotate-app-password.js +2 -2
  62. package/dist/lib/snapshot-store.js +14 -0
  63. package/dist/lib/validate-local.js +34 -7
  64. package/dist/lib/wp-client.d.ts +4 -1
  65. package/dist/lib/wp-client.js +56 -3
  66. package/dist/types/plugin.d.ts +8 -1
  67. package/dist/types/plugin.js +4 -1
  68. package/dist/types/project-config.generated.d.ts +19 -2
  69. package/dist/utils/composer.d.ts +14 -0
  70. package/dist/utils/composer.js +136 -0
  71. package/dist/utils/loopress-config.d.ts +1 -0
  72. package/dist/utils/loopress-config.js +14 -0
  73. package/dist/utils/page-format.d.ts +12 -0
  74. package/dist/utils/page-format.js +41 -26
  75. package/dist/utils/plugins.d.ts +9 -4
  76. package/dist/utils/plugins.js +16 -7
  77. package/dist/utils/resource-dirs.d.ts +2 -0
  78. package/dist/utils/resource-dirs.js +4 -0
  79. package/dist/utils/seo-format.d.ts +2 -2
  80. package/dist/utils/template-format.d.ts +30 -0
  81. package/dist/utils/template-format.js +36 -0
  82. package/oclif.manifest.json +536 -320
  83. package/package.json +7 -4
  84. package/schemas/project-config.schema.json +25 -4
  85. package/dist/commands/theme-styles/diff.d.ts +0 -2
  86. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  87. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -0,0 +1,23 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { pluralize } from '../../utils/pluralize.js';
3
+ import { CHILD_THEME_ENDPOINT } from '../../utils/template-format.js';
4
+ export default class List extends LoopressCommand {
5
+ static description = 'Show the Loopress child theme of the active block theme: its templates, parts, and those edited in the Site Editor';
6
+ static enableJsonFlag = true;
7
+ static examples = ['$ lps template list'];
8
+ async run() {
9
+ const child = await this.wp.get(CHILD_THEME_ENDPOINT);
10
+ // The markup is only needed by `template diff`, it would drown the JSON output here.
11
+ const result = { ...child, parts: child.parts.map((part) => part.slug), templates: child.templates.map((template) => template.slug) };
12
+ if (!child.exists) {
13
+ this.log(`No Loopress child theme yet (${child.stylesheet}), run lps template push.`);
14
+ return result;
15
+ }
16
+ this.log(`${child.stylesheet} (child of ${child.parent}, ${child.active ? 'active' : 'not active'})`);
17
+ this.log(` ${pluralize(result.templates.length, 'template')}: ${result.templates.join(', ') || 'none'}`);
18
+ this.log(` ${pluralize(result.parts.length, 'part')}: ${result.parts.join(', ') || 'none'}`);
19
+ if (child.customized.length > 0)
20
+ this.log(` Edited in the Site Editor: ${child.customized.join(', ')}`);
21
+ return result;
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ active?: boolean;
4
+ customized?: string[];
5
+ parts: string[];
6
+ status: 'dry-run' | 'success';
7
+ stylesheet?: string;
8
+ templates: string[];
9
+ };
10
+ export default class Push extends PushCommand {
11
+ static description: string;
12
+ static enableJsonFlag: boolean;
13
+ static examples: string[];
14
+ static flags: {
15
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ };
18
+ run(): Promise<PushResult>;
19
+ }
20
+ export {};
@@ -0,0 +1,42 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ import { formatPageProblems } from '../../utils/page-format.js';
3
+ import { pluralize } from '../../utils/pluralize.js';
4
+ import { resolveResourceDir } from '../../utils/resource-dirs.js';
5
+ import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../utils/template-format.js';
6
+ export default class Push extends PushCommand {
7
+ static description = 'Push block templates (templates/<slug>.html) and template parts (parts/<slug>.html) as the files of a child theme of the active block theme, <parent>-loopress. The child mirrors the project: files removed locally are removed from it. It is never activated for you.';
8
+ static enableJsonFlag = true;
9
+ static examples = ['$ lps template push', '$ lps template push --dry-run'];
10
+ static flags = {
11
+ ...PushCommand.dryRunFlag,
12
+ ...PushCommand.yesFlag,
13
+ };
14
+ async run() {
15
+ await this.parse(Push);
16
+ const templatesPath = resolveResourceDir('template', this.localConfig);
17
+ const partsPath = resolveResourceDir('part', this.localConfig);
18
+ this.log(`Pushing templates and parts to ${this.siteConfig.url}`);
19
+ this.log(`Templates path: ${templatesPath}, parts path: ${partsPath}`);
20
+ const { parts, problems, templates } = await readLocalTemplates(templatesPath, partsPath);
21
+ if (problems.length > 0) {
22
+ this.error(`Nothing was pushed, fix these files first:\n ${formatPageProblems(problems)}`);
23
+ }
24
+ const names = { parts: parts.map((part) => part.slug), templates: templates.map((template) => template.slug) };
25
+ this.log(`Found ${pluralize(templates.length, 'template')} and ${pluralize(parts.length, 'part')}`);
26
+ if (this.dryRun) {
27
+ this.log('[dry-run] Would rewrite the child theme with them.');
28
+ return { ...names, status: 'dry-run' };
29
+ }
30
+ const child = await this.wp.put(CHILD_THEME_ENDPOINT, { parts, templates });
31
+ await this.recordSuccess();
32
+ this.log(`Child theme ${child.stylesheet} written.`);
33
+ if (!child.active) {
34
+ this.warn(`${child.stylesheet} is not the active theme, so nothing changed on the site yet. Activate it in Appearance > Themes.`);
35
+ }
36
+ if (child.customized.length > 0) {
37
+ this.warn(`Edited in the Site Editor, their database copy still wins over the pushed file: ${child.customized.join(', ')}. ` +
38
+ 'Clear their customizations in Appearance > Editor to use the pushed version.');
39
+ }
40
+ return { ...names, active: child.active, customized: child.customized, status: 'success', stylesheet: child.stylesheet };
41
+ }
42
+ }
@@ -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
  }
@@ -1,8 +1,10 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type ComposerPullResult } from '../../utils/composer.js';
2
3
  type PullResult = {
3
4
  added: string[];
4
5
  merged: Record<string, string>;
5
- status: 'composer-managed' | 'dry-run' | 'success';
6
+ skipped?: ComposerPullResult['skipped'];
7
+ status: 'dry-run' | 'success';
6
8
  updated: Array<{
7
9
  from: string;
8
10
  slug: string;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
+ import { pullIntoComposerJson } from '../../utils/composer.js';
4
5
  import { writeLocalConfig } from '../../utils/loopress-config.js';
5
6
  import { mergePluginManifest } from '../../utils/plugins.js';
6
7
  import { parseInstalledThemes } from '../../utils/themes.js';
@@ -13,14 +14,19 @@ export default class Pull extends LoopressCommand {
13
14
  };
14
15
  async run() {
15
16
  const { url } = this.siteConfig;
16
- if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
17
- this.warn('This project has a composer.json, which is authoritative. Run `lps composer pull` instead.');
18
- return { added: [], merged: this.localConfig.themes ?? {}, status: 'composer-managed', updated: [] };
19
- }
20
17
  this.log(`Pulling themes from ${url}`);
21
18
  const raw = await this.wp.get('wp/v2/themes');
22
19
  const installed = parseInstalledThemes(raw);
23
20
  const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
21
+ // A composer.json is authoritative over loopress.json: pin the live versions there instead.
22
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
23
+ if (existsSync(composerJsonPath)) {
24
+ const result = await pullIntoComposerJson(composerJsonPath, 'theme', incoming, {
25
+ dryRun: this.dryRun,
26
+ log: this.log.bind(this),
27
+ });
28
+ return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
29
+ }
24
30
  const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
25
31
  if (this.dryRun) {
26
32
  this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -1,5 +1,6 @@
1
- import { resourceDiffCommand } from '../../lib/diff-command.js';
1
+ import { resourceDiffCommand } from '../../../lib/diff-command.js';
2
2
  export default resourceDiffCommand('theme-styles', {
3
3
  description: "Show what differs in the active block theme's Global Styles (Site Editor > Styles) between your local file and a WordPress environment, or between two environments",
4
+ command: 'theme style',
4
5
  pathNoun: 'theme styles directory',
5
6
  });
@@ -1,4 +1,4 @@
1
- import { LoopressCommand } from '../../lib/base.js';
1
+ import { LoopressCommand } from '../../../lib/base.js';
2
2
  export default class Pull extends LoopressCommand {
3
3
  static args: {
4
4
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
@@ -1,14 +1,14 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { LoopressCommand } from '../../lib/base.js';
5
- import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../utils/theme-styles-format.js';
4
+ import { LoopressCommand } from '../../../lib/base.js';
5
+ import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../../utils/theme-styles-format.js';
6
6
  export default class Pull extends LoopressCommand {
7
7
  static args = {
8
8
  path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
9
9
  };
10
10
  static description = "Pull the active block theme's Global Styles customizations (Site Editor > Styles) from WordPress into a local file";
11
- static examples = ['$ lps theme-styles pull'];
11
+ static examples = ['$ lps theme style pull'];
12
12
  static flags = {
13
13
  ...LoopressCommand.dryRunFlag,
14
14
  };
@@ -1,4 +1,4 @@
1
- import { PushCommand } from '../../lib/push-command.js';
1
+ import { PushCommand } from '../../../lib/push-command.js';
2
2
  export default class Push extends PushCommand {
3
3
  static args: {
4
4
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
@@ -1,15 +1,15 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { PushCommand } from '../../lib/push-command.js';
5
- import { getResourceStateProvider } from '../../lib/resource-state.js';
6
- import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../utils/theme-styles-format.js';
4
+ import { PushCommand } from '../../../lib/push-command.js';
5
+ import { getResourceStateProvider } from '../../../lib/resource-state.js';
6
+ import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../../utils/theme-styles-format.js';
7
7
  export default class Push extends PushCommand {
8
8
  static args = {
9
9
  path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
10
10
  };
11
11
  static description = "Push the local Global Styles file to the active block theme's Site Editor > Styles on WordPress";
12
- static examples = ['$ lps theme-styles push'];
12
+ static examples = ['$ lps theme style push'];
13
13
  static flags = {
14
14
  ...PushCommand.dryRunFlag,
15
15
  };
@@ -26,7 +26,7 @@ export default class Push extends PushCommand {
26
26
  }
27
27
  catch (error) {
28
28
  if (error.code === 'ENOENT') {
29
- this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme-styles pull\` first.`);
29
+ this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme style pull\` first.`);
30
30
  }
31
31
  throw error;
32
32
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { resourceRollbackCommand } from '../../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('theme-styles', {
3
+ description: "Restore the active theme's Global Styles to a snapshot taken automatically before an earlier `lps theme style push`",
4
+ command: 'theme style',
5
+ pathNoun: 'theme styles directory',
6
+ });
@@ -43,11 +43,36 @@ 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
+ // An empty body (not just a missing one) falls back to got's message too, never a bare ": ".
49
+ const body = err.response?.body === '' ? undefined : err.response?.body;
50
+ return `Request rejected (${status}) on ${url}: ${reason ?? body ?? err.message}`;
51
+ }
52
+ // Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
53
+ // the body; got's own message ("Request failed with status code 400") never does.
54
+ if (status !== undefined && reason) {
55
+ return `Request failed (${status}) on ${url}: ${reason}`;
48
56
  }
49
57
  if (err.name === 'TimeoutError') {
50
58
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
51
59
  }
52
60
  return err.message ?? String(error);
53
61
  }
62
+ // NestJS's HttpException body: `{statusCode, message, error}`, where `message` is a list for a
63
+ // ValidationPipe failure (one entry per invalid field).
64
+ function nestErrorMessage(body) {
65
+ if (!body)
66
+ return undefined;
67
+ try {
68
+ const { message } = JSON.parse(body);
69
+ if (typeof message === 'string' && message.trim())
70
+ return message;
71
+ if (Array.isArray(message) && message.length > 0)
72
+ return message.join('; ');
73
+ }
74
+ catch {
75
+ // not JSON (a proxy's HTML error page): nothing to extract
76
+ }
77
+ return undefined;
78
+ }
@@ -0,0 +1,5 @@
1
+ declare const PACKAGE_MANAGERS: readonly ["bun", "npm", "pnpm", "yarn"];
2
+ type PackageManager = (typeof PACKAGE_MANAGERS)[number];
3
+ export declare function detectPackageManager(appDir: string, packageManagerField?: string): PackageManager;
4
+ export declare function buildApp(appDir: string, onStart?: (command: string) => void): Promise<string | undefined>;
5
+ export {};
@@ -0,0 +1,69 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ // The package managers `lps app push` may spawn. An allowlist, not the raw `packageManager`
6
+ // field: the name ends up as a process to run.
7
+ const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
8
+ const LOCKFILES = [
9
+ ['pnpm-lock.yaml', 'pnpm'],
10
+ ['yarn.lock', 'yarn'],
11
+ ['bun.lock', 'bun'],
12
+ ['bun.lockb', 'bun'],
13
+ ['package-lock.json', 'npm'],
14
+ ];
15
+ // How many trailing lines of build output a failure message carries.
16
+ const OUTPUT_TAIL_LINES = 20;
17
+ // `packageManager` field first (corepack's own source of truth), then the nearest lockfile
18
+ // walking up to the repository root (an app inside a workspace shares the root lockfile),
19
+ // then npm.
20
+ export function detectPackageManager(appDir, packageManagerField) {
21
+ const fromField = packageManagerField?.split('@', 1)[0];
22
+ if (PACKAGE_MANAGERS.includes(fromField))
23
+ return fromField;
24
+ for (let dir = appDir;; dir = dirname(dir)) {
25
+ const lockfile = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
26
+ if (lockfile)
27
+ return lockfile[1];
28
+ if (existsSync(join(dir, '.git')) || dirname(dir) === dir)
29
+ return 'npm';
30
+ }
31
+ }
32
+ // Runs the app's own `build` script, if its package.json declares one. Returns the command it
33
+ // ran, or undefined when there is nothing to build (no package.json, or no build script: the
34
+ // dist/ folder is then taken as is). Throws with the tail of the output when the build fails,
35
+ // so a broken build never ships the stale dist/ left over from the previous one.
36
+ export async function buildApp(appDir, onStart) {
37
+ const packageJsonPath = join(appDir, 'package.json');
38
+ if (!existsSync(packageJsonPath))
39
+ return undefined;
40
+ const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
41
+ if (!pkg.scripts?.build)
42
+ return undefined;
43
+ const pm = detectPackageManager(appDir, pkg.packageManager);
44
+ const command = `${pm} run build`;
45
+ onStart?.(command);
46
+ const { code, output } = await new Promise((resolve, reject) => {
47
+ // Windows resolves `npm` to `npm.cmd`, which only a shell finds. `pm` comes from the
48
+ // allowlist above, so the shell never sees user input.
49
+ const child = spawn(pm, ['run', 'build'], { cwd: appDir, shell: process.platform === 'win32' });
50
+ let out = '';
51
+ const collect = (chunk) => {
52
+ out += chunk;
53
+ };
54
+ child.stdout.setEncoding('utf8').on('data', collect);
55
+ child.stderr.setEncoding('utf8').on('data', collect);
56
+ child.on('error', reject);
57
+ child.on('close', (exitCode) => {
58
+ resolve({ code: exitCode, output: out });
59
+ });
60
+ });
61
+ if (code !== 0) {
62
+ const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
63
+ const hint = existsSync(join(appDir, 'node_modules'))
64
+ ? ''
65
+ : `\nnode_modules is missing: run \`${pm} install\` in ${appDir} first.`;
66
+ throw new Error(`\`${command}\` failed (exit code ${code}):\n${tail}${hint}`);
67
+ }
68
+ return command;
69
+ }
@@ -7,8 +7,11 @@ export type AppEntry = {
7
7
  scripts: string[];
8
8
  styles: string[];
9
9
  };
10
+ export declare const CROSS_ORIGIN_POLICIES: readonly ["same-origin", "same-site"];
11
+ export type CrossOriginPolicy = (typeof CROSS_ORIGIN_POLICIES)[number];
10
12
  export type AppConfigFile = {
11
13
  assetsDir?: string;
14
+ crossOrigin?: CrossOriginPolicy;
12
15
  entry?: AppEntry;
13
16
  mountSelector?: string;
14
17
  name?: string;
@@ -16,6 +19,7 @@ export type AppConfigFile = {
16
19
  };
17
20
  export type AppManifest = {
18
21
  buildId: string;
22
+ crossOrigin?: CrossOriginPolicy;
19
23
  entry: AppEntry;
20
24
  files: AppFile[];
21
25
  mountSelector: string;
@@ -2,6 +2,9 @@ import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
4
  import { walkFiles } from './readdir-tolerant.js';
5
+ // Cross-Origin-Resource-Policy the server sends with the app's files. Mirrors the server's
6
+ // AppManifest::SUPPORTED_CROSS_ORIGIN; `cross-origin` is the browser default, i.e. the field unset.
7
+ export const CROSS_ORIGIN_POLICIES = ['same-origin', 'same-site'];
5
8
  export const APP_CONFIG_FILENAME = 'loopress.app.json';
6
9
  const DEFAULT_ASSETS_DIR = 'dist';
7
10
  // Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
@@ -89,6 +92,10 @@ export function parseAppConfig(raw, appDir) {
89
92
  if (config.routing !== undefined && config.routing !== 'hash') {
90
93
  throw new Error(`${join(appDir, APP_CONFIG_FILENAME)}: routing "${config.routing}" is not supported, only "hash" works without a server rewrite`);
91
94
  }
95
+ if (config.crossOrigin !== undefined && !CROSS_ORIGIN_POLICIES.includes(config.crossOrigin)) {
96
+ const supported = CROSS_ORIGIN_POLICIES.map((policy) => JSON.stringify(policy)).join(' or ');
97
+ throw new Error(`${join(appDir, APP_CONFIG_FILENAME)}: crossOrigin "${config.crossOrigin}" is not supported, use ${supported}`);
98
+ }
92
99
  return config;
93
100
  }
94
101
  // Assembles the full manifest for one app directory: reads loopress.app.json, hashes the
@@ -129,6 +136,7 @@ export async function loadAppManifest(appDir, dirName) {
129
136
  distDir,
130
137
  manifest: {
131
138
  buildId: computeBuildId(files),
139
+ ...(config.crossOrigin && { crossOrigin: config.crossOrigin }),
132
140
  entry,
133
141
  files,
134
142
  mountSelector: config.mountSelector ?? `#loopress-app-${name}`,
package/dist/lib/base.js CHANGED
@@ -35,11 +35,11 @@ export class LoopressCommand extends Command {
35
35
  }
36
36
  get wp() {
37
37
  if (!this.wpClient) {
38
- const { token, url } = this.siteConfig;
38
+ const { name, token, url } = this.siteConfig;
39
39
  if (!token) {
40
40
  this.error(`No credentials configured for ${url}. Run \`lps project config\` to add them.`);
41
41
  }
42
- this.wpClient = new WpClient(url, token);
42
+ this.wpClient = new WpClient(url, token, name);
43
43
  }
44
44
  return this.wpClient;
45
45
  }
@@ -65,12 +65,21 @@ export class LoopressCommand extends Command {
65
65
  // `dryRun` is excluded since rotating writes a new credential, a real side effect a
66
66
  // --dry-run run promises not to cause. `Rotate` overrides this to a no-op: its own `run()`
67
67
  // already rotates unconditionally, so the background check would only redo the same work.
68
+ // CI is excluded too: rotating revokes the old password, but a runner's config.json (a CI
69
+ // secret copy) is thrown away at the end of the job, so every later run would get a 401.
68
70
  async maybeAutoRotate() {
69
71
  if (this.dryRun)
70
72
  return;
71
73
  const { token } = this.siteConfig;
72
74
  if (!token || !isAppPasswordStale(this.siteConfig.addedAt))
73
75
  return;
76
+ // Set by `lps promote --dry-run` around its delegated pull: a preview must not revoke anything.
77
+ if (process.env.LOOPRESS_NO_AUTO_ROTATE)
78
+ return;
79
+ if (process.env.CI) {
80
+ this.warn(`The app password for "${this.siteConfig.name}" is older than 90 days. Auto-rotation is skipped in CI, run \`lps project rotate\` where this config.json lives, then update the CI secret.`);
81
+ return;
82
+ }
74
83
  try {
75
84
  const rotated = await rotateAppPassword({ ...this.siteConfig, token });
76
85
  this.siteConfig = rotated;
@@ -14,24 +14,55 @@ export async function bootstrapLoopressFull(wp, siteUrl, log) {
14
14
  const zipPath = await downloadLatestFullZip();
15
15
  log('Creating a temporary admin account to install it...');
16
16
  const admin = await createTempAdmin(wp);
17
+ // The temp account is a real administrator; it must never outlive this function, including
18
+ // when the process is interrupted mid-install (Ctrl-C, or a hung/killed headless browser).
19
+ // removeTempAdmin() memoises a single deletion so the normal path and either signal handler
20
+ // all await the same in-flight promise instead of racing or deleting twice.
21
+ let cleanupPromise;
22
+ const removeTempAdmin = async () => {
23
+ cleanupPromise ??= deleteTempAdmin(wp, admin);
24
+ return cleanupPromise;
25
+ };
26
+ const onSignal = (signal) => {
27
+ // Finish (or start) the deletion, surface a manual-removal hint if it fails since the normal
28
+ // error path below won't run, then re-raise the signal with our handler gone so Node's
29
+ // default termination runs with the right exit code.
30
+ removeTempAdmin()
31
+ .catch((error) => {
32
+ log(`Could not remove the temporary admin account "${admin.username}"; remove it manually in wp-admin. (${error.message})`);
33
+ })
34
+ .finally(() => {
35
+ process.removeListener('SIGINT', onSignal);
36
+ process.removeListener('SIGTERM', onSignal);
37
+ process.kill(process.pid, signal);
38
+ });
39
+ };
40
+ process.once('SIGINT', onSignal);
41
+ process.once('SIGTERM', onSignal);
17
42
  let installError;
18
- try {
19
- log('Installing and activating Loopress Full...');
20
- await runBrowserInstall(admin, siteUrl, zipPath);
21
- }
22
- catch (error) {
23
- installError = error;
24
- }
25
- if (!installError) {
26
- log('Loopress Full installed and activated.');
27
- }
28
- log('Removing the temporary admin account...');
29
43
  let cleanupError;
30
44
  try {
31
- await deleteTempAdmin(wp, admin);
45
+ try {
46
+ log('Installing and activating Loopress Full...');
47
+ await runBrowserInstall(admin, siteUrl, zipPath);
48
+ log('Loopress Full installed and activated.');
49
+ }
50
+ catch (error) {
51
+ installError = error;
52
+ }
53
+ log('Removing the temporary admin account...');
54
+ try {
55
+ await removeTempAdmin();
56
+ }
57
+ catch (error) {
58
+ cleanupError = error;
59
+ }
32
60
  }
33
- catch (error) {
34
- cleanupError = error;
61
+ finally {
62
+ // Only stop guarding once the deletion has settled: a signal during the removal above must
63
+ // still trigger (or await) cleanup rather than terminating the process with the account live.
64
+ process.removeListener('SIGINT', onSignal);
65
+ process.removeListener('SIGTERM', onSignal);
35
66
  }
36
67
  const manualFallback = `Install it manually: upload ${zipPath} at ${siteUrl}/wp-admin/plugin-install.php?tab=upload`;
37
68
  if (installError && cleanupError) {
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'pages' | 'plugins' | 'snippets';
2
+ export type ResourceType = 'api' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
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', 'pages', 'api', 'plugins'];
3
+ export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', '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,8 +15,11 @@ 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' },
18
19
  pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
19
20
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
21
+ parts: { commandId: 'template:push', path: join(rootDir, localConfig.partDir ?? 'parts'), type: 'parts' },
22
+ templates: { commandId: 'template:push', path: join(rootDir, localConfig.templateDir ?? 'templates'), type: 'templates' },
20
23
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
21
24
  };
22
25
  return types
@@ -41,6 +41,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
41
41
  private wpForEnvironment;
42
42
  }
43
43
  export declare function resourceDiffCommand(resource: string, options: {
44
+ command?: string;
44
45
  description: string;
45
46
  pathNoun: string;
46
47
  }): typeof DiffCommand;
@@ -143,12 +143,14 @@ export class DiffCommand extends LoopressCommand {
143
143
  if (!env.token) {
144
144
  this.error(`No credentials configured for "${envName}". Run \`lps project config\` to add them.`);
145
145
  }
146
- return new WpClient(env.url, env.token);
146
+ return new WpClient(env.url, env.token, env.name);
147
147
  }
148
148
  }
149
149
  // Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
150
150
  // only in wording and which provider they target, so the whole body lives here.
151
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
151
152
  export function resourceDiffCommand(resource, options) {
153
+ const command = options.command ?? resource;
152
154
  class ResourceDiff extends DiffCommand {
153
155
  static args = {
154
156
  path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
@@ -156,9 +158,9 @@ export function resourceDiffCommand(resource, options) {
156
158
  static description = options.description;
157
159
  static enableJsonFlag = true;
158
160
  static examples = [
159
- `$ lps ${resource} diff`,
160
- `$ lps ${resource} diff --env staging`,
161
- `$ lps ${resource} diff --env staging --against production`,
161
+ `$ lps ${command} diff`,
162
+ `$ lps ${command} diff --env staging`,
163
+ `$ lps ${command} diff --env staging --against production`,
162
164
  ];
163
165
  static flags = { ...DiffCommand.againstFlag };
164
166
  async run() {
@@ -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
+ }