@loopress/cli 0.27.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 (75) hide show
  1. package/README.md +267 -175
  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 +2 -0
  8. package/dist/commands/plugin/add.js +6 -3
  9. package/dist/commands/plugin/audit.js +4 -5
  10. package/dist/commands/plugin/pull.d.ts +3 -1
  11. package/dist/commands/plugin/pull.js +17 -9
  12. package/dist/commands/plugin/push.d.ts +5 -1
  13. package/dist/commands/plugin/push.js +90 -37
  14. package/dist/commands/plugin/status.d.ts +1 -0
  15. package/dist/commands/plugin/status.js +21 -2
  16. package/dist/commands/project/config.js +12 -8
  17. package/dist/commands/promote.d.ts +2 -0
  18. package/dist/commands/promote.js +69 -8
  19. package/dist/commands/pull.js +2 -2
  20. package/dist/commands/push.d.ts +1 -0
  21. package/dist/commands/push.js +37 -3
  22. package/dist/commands/template/diff.d.ts +10 -0
  23. package/dist/commands/template/diff.js +13 -0
  24. package/dist/commands/template/list.d.ts +13 -0
  25. package/dist/commands/template/list.js +23 -0
  26. package/dist/commands/template/push.d.ts +20 -0
  27. package/dist/commands/template/push.js +42 -0
  28. package/dist/commands/theme/pull.d.ts +3 -1
  29. package/dist/commands/theme/pull.js +10 -4
  30. package/dist/commands/theme/style/diff.d.ts +2 -0
  31. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  32. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  33. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  34. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  35. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  36. package/dist/commands/theme/style/rollback.d.ts +2 -0
  37. package/dist/commands/theme/style/rollback.js +6 -0
  38. package/dist/lib/api-client.js +3 -1
  39. package/dist/lib/app-build.d.ts +5 -0
  40. package/dist/lib/app-build.js +69 -0
  41. package/dist/lib/app-manifest.d.ts +4 -0
  42. package/dist/lib/app-manifest.js +8 -0
  43. package/dist/lib/base.js +11 -2
  44. package/dist/lib/bootstrap-full-install.js +45 -14
  45. package/dist/lib/dev-watch.d.ts +1 -1
  46. package/dist/lib/dev-watch.js +3 -1
  47. package/dist/lib/diff-command.d.ts +1 -0
  48. package/dist/lib/diff-command.js +6 -4
  49. package/dist/lib/resource-state.js +43 -1
  50. package/dist/lib/rollback-command.d.ts +1 -0
  51. package/dist/lib/rollback-command.js +8 -6
  52. package/dist/lib/rotate-app-password.js +2 -2
  53. package/dist/lib/validate-local.js +6 -0
  54. package/dist/lib/wp-client.d.ts +4 -1
  55. package/dist/lib/wp-client.js +25 -1
  56. package/dist/types/plugin.d.ts +8 -1
  57. package/dist/types/plugin.js +4 -1
  58. package/dist/types/project-config.generated.d.ts +19 -2
  59. package/dist/utils/composer.d.ts +14 -0
  60. package/dist/utils/composer.js +136 -0
  61. package/dist/utils/page-format.d.ts +8 -0
  62. package/dist/utils/page-format.js +34 -26
  63. package/dist/utils/plugins.d.ts +9 -4
  64. package/dist/utils/plugins.js +16 -7
  65. package/dist/utils/resource-dirs.d.ts +2 -0
  66. package/dist/utils/resource-dirs.js +4 -0
  67. package/dist/utils/seo-format.d.ts +2 -2
  68. package/dist/utils/template-format.d.ts +30 -0
  69. package/dist/utils/template-format.js +36 -0
  70. package/oclif.manifest.json +526 -324
  71. package/package.json +3 -2
  72. package/schemas/project-config.schema.json +25 -4
  73. package/dist/commands/theme-styles/diff.d.ts +0 -2
  74. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  75. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -5,7 +5,7 @@ import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
5
5
  import { isInteractive } from '../../lib/interactive.js';
6
6
  import { isLoopressFullActive } from '../../lib/plugin-detection.js';
7
7
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
- import { WpClient } from '../../lib/wp-client.js';
8
+ import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
9
9
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
10
10
  const NEW_PROJECT = '__new__';
11
11
  const AUTH_BROWSER = 'browser';
@@ -48,16 +48,20 @@ export default class Config extends Command {
48
48
  const rawUrl = await input({
49
49
  message: 'WordPress URL',
50
50
  validate(value) {
51
+ let parsed;
51
52
  try {
52
- const parsed = new URL(value);
53
- if (!['http:', 'https:'].includes(parsed.protocol)) {
54
- return 'URL must start with http:// or https://';
55
- }
56
- return true;
53
+ parsed = new URL(value);
57
54
  }
58
55
  catch {
59
56
  return 'Invalid URL';
60
57
  }
58
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
59
+ return 'URL must start with http:// or https://';
60
+ }
61
+ if (parsed.protocol === 'http:' && !isLocalEnvironment(envName)) {
62
+ return `Plain HTTP would send the Application Password in cleartext. Use HTTPS. Plain HTTP is allowed only for the "local" environment (this one is "${envName}").`;
63
+ }
64
+ return true;
61
65
  },
62
66
  });
63
67
  let url = rawUrl;
@@ -88,10 +92,10 @@ export default class Config extends Command {
88
92
  this.log('→ Run `lps project switch` to change the active project or environment');
89
93
  }
90
94
  async maybeBootstrapLoopressFull(env) {
91
- const { token, url } = env;
95
+ const { name, token, url } = env;
92
96
  if (!token)
93
97
  return; // env was just built with one a few lines up; guard only narrows the type
94
- const wp = new WpClient(url, token);
98
+ const wp = new WpClient(url, token, name);
95
99
  // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
96
100
  // there" rather than surprising the user with an account-creating prompt they didn't ask
97
101
  // for; `lps project config` can simply be re-run if it genuinely isn't installed.
@@ -17,8 +17,10 @@ export default class Promote extends Command {
17
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  };
19
19
  run(): Promise<PromoteResult>;
20
+ private assertDirsInsideProject;
20
21
  private confirmPromotion;
21
22
  private delegateArgv;
23
+ private enterScratchCopy;
22
24
  private requireEnvironment;
23
25
  private resolveProject;
24
26
  }
@@ -1,16 +1,21 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Args, Command } from '@oclif/core';
3
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins -- stable since 22.3, works unflagged on 22.0+
4
+ import { cp, mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
7
  import { configManager } from '../config/project-config.manager.js';
4
8
  import { LoopressCommand } from '../lib/base.js';
5
9
  import { isInteractive } from '../lib/interactive.js';
6
10
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
11
  import { readLocalConfig } from '../utils/loopress-config.js';
12
+ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
8
13
  export default class Promote extends Command {
9
14
  static args = {
10
15
  from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
11
16
  to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
12
17
  };
13
- static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process.';
18
+ static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
14
19
  static enableJsonFlag = true;
15
20
  static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
16
21
  static flags = {
@@ -29,19 +34,55 @@ export default class Promote extends Command {
29
34
  }
30
35
  if (!dryRun)
31
36
  await this.confirmPromotion(from.name, to.name, to.url, yes);
32
- this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
37
+ // A dry-run pull writes nothing, so a dry-run push right after would preview the current
38
+ // local files instead of <from>. Instead, really pull into a throwaway copy of the project
39
+ // and dry-run the push from there: the preview is what a real promotion would push, and the
40
+ // user's own files stay untouched.
41
+ const originalCwd = process.cwd();
42
+ const scratch = dryRun ? await this.enterScratchCopy(originalCwd) : undefined;
43
+ // The delegated pull is real, so without this its auto-rotation could revoke a stale app
44
+ // password during what the user asked to be a preview (see LoopressCommand.maybeAutoRotate).
45
+ const previousNoRotate = process.env.LOOPRESS_NO_AUTO_ROTATE;
46
+ if (scratch)
47
+ process.env.LOOPRESS_NO_AUTO_ROTATE = '1';
33
48
  try {
34
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, dryRun)));
49
+ this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
50
+ try {
51
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
52
+ }
53
+ catch (error) {
54
+ // A partial pull must never be pushed onward: stop before touching <to>.
55
+ this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
56
+ }
57
+ this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
58
+ await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
35
59
  }
36
- catch (error) {
37
- // A partial pull must never be pushed onward: stop before touching <to>.
38
- this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
60
+ finally {
61
+ if (scratch) {
62
+ if (previousNoRotate === undefined)
63
+ delete process.env.LOOPRESS_NO_AUTO_ROTATE;
64
+ else
65
+ process.env.LOOPRESS_NO_AUTO_ROTATE = previousNoRotate;
66
+ process.chdir(originalCwd);
67
+ await rm(scratch, { force: true, recursive: true });
68
+ }
39
69
  }
40
- this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
41
- await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
42
70
  this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
43
71
  return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
44
72
  }
73
+ // The scratch pull writes wherever loopress.json points. An absolute or `../` rootDir or
74
+ // resource dir would land outside the copy, in the user's real files, during a dry run.
75
+ async assertDirsInsideProject(projectDir) {
76
+ const localConfig = await readLocalConfig();
77
+ const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
78
+ const outside = dirs.find((dir) => {
79
+ const rel = relative(projectDir, resolve(projectDir, dir));
80
+ return rel.startsWith('..') || isAbsolute(rel);
81
+ });
82
+ if (outside !== undefined) {
83
+ this.error(`--dry-run can't preview safely: "${outside}" in loopress.json is outside the project. Run \`lps diff --env <from> --against <to>\` instead.`);
84
+ }
85
+ }
45
86
  // Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
46
87
  // target, not just production, so it needs its own confirmation regardless of the name. That
47
88
  // prompt also calls out production when relevant, making a second production-only guard
@@ -70,6 +111,26 @@ export default class Promote extends Command {
70
111
  argv.push('--dry-run');
71
112
  return argv;
72
113
  }
114
+ // Copies the whole project (not just what `pull` rewrites): push also reads files pull never
115
+ // touches, and a real promotion pushes those as they are locally. node_modules and .git are
116
+ // never read by push. Returns the scratch root, cwd is left inside the copy.
117
+ async enterScratchCopy(projectDir) {
118
+ await this.assertDirsInsideProject(projectDir);
119
+ const scratch = await mkdtemp(join(tmpdir(), 'lps-promote-'));
120
+ try {
121
+ const copy = join(scratch, basename(projectDir));
122
+ await cp(projectDir, copy, {
123
+ filter: (source) => !['.git', 'node_modules'].includes(basename(source)),
124
+ recursive: true,
125
+ });
126
+ process.chdir(copy);
127
+ }
128
+ catch (error) {
129
+ await rm(scratch, { force: true, recursive: true });
130
+ throw error;
131
+ }
132
+ return scratch;
133
+ }
73
134
  requireEnvironment(environments, projectName, name) {
74
135
  const env = environments[name];
75
136
  if (!env) {
@@ -1,8 +1,8 @@
1
1
  import { LoopressCommand } from '../lib/base.js';
2
2
  import { stdoutToStderr } from '../lib/json-delegation.js';
3
3
  import { pluralize } from '../utils/pluralize.js';
4
- // composer before plugins: `plugin:pull` reads the local composer.json to skip
5
- // Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
4
+ // composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
5
+ // into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
6
6
  // directories and their order does not matter, it mirrors `lps push` for familiarity.
7
7
  const PULL_TARGETS = [
8
8
  { commandId: 'composer:pull', label: 'composer' },
@@ -18,6 +18,7 @@ export default class Push extends LoopressCommand {
18
18
  private failedCount;
19
19
  run(): Promise<PushResult>;
20
20
  private buildArgv;
21
+ private composerDeclaresPlugins;
21
22
  private guardProductionPush;
22
23
  }
23
24
  export {};
@@ -1,6 +1,10 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { LoopressCommand } from '../lib/base.js';
2
5
  import { guardProductionPush } from '../lib/guard-production-push.js';
3
6
  import { stdoutToStderr } from '../lib/json-delegation.js';
7
+ import { wpackagistRequire } from '../utils/composer.js';
4
8
  import { pluralize } from '../utils/pluralize.js';
5
9
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
6
10
  // relies on, then content itself.
@@ -11,6 +15,8 @@ const PUSH_TARGETS = [
11
15
  { commandId: 'api:push', label: 'API routes' },
12
16
  { commandId: 'hook:push', label: 'hooks' },
13
17
  { commandId: 'form:push', label: 'forms' },
18
+ // Before pages: WordPress refuses a page whose `template` it doesn't know yet.
19
+ { commandId: 'template:push', label: 'templates and parts' },
14
20
  { commandId: 'page:push', label: 'pages' },
15
21
  { commandId: 'seo:push', label: 'SEO' },
16
22
  { commandId: 'menu:push', label: 'menus' },
@@ -18,7 +24,7 @@ const PUSH_TARGETS = [
18
24
  { commandId: 'snippet:push', label: 'snippets' },
19
25
  ];
20
26
  export default class Push extends LoopressCommand {
21
- static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
27
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, templates, pages, SEO, menus, options, and snippets, to WordPress';
22
28
  static enableJsonFlag = true;
23
29
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
24
30
  static flags = {
@@ -30,7 +36,13 @@ export default class Push extends LoopressCommand {
30
36
  await this.guardProductionPush();
31
37
  const argv = this.buildArgv();
32
38
  const results = [];
39
+ const composerPushedByPlugins = await this.composerDeclaresPlugins();
33
40
  for (const target of PUSH_TARGETS) {
41
+ if (composerPushedByPlugins && target.commandId === 'composer:push') {
42
+ this.log('\n→ composer: already pushed whole by plugins');
43
+ results.push({ label: target.label, status: 'pushed' });
44
+ continue;
45
+ }
34
46
  this.log(`\n→ Pushing ${target.label}...`);
35
47
  try {
36
48
  await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
@@ -47,7 +59,9 @@ export default class Push extends LoopressCommand {
47
59
  if (this.failedCount > 0) {
48
60
  // Each failure's own reason, not just the count: under --json the per-resource lines above
49
61
  // 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}`);
62
+ const failures = results
63
+ .filter((result) => result.status === 'failed')
64
+ .map((result) => `${result.label}: ${result.error}`);
51
65
  this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
52
66
  }
53
67
  this.log('\nAll resources pushed.');
@@ -62,9 +76,29 @@ export default class Push extends LoopressCommand {
62
76
  argv.push('--dry-run');
63
77
  return argv;
64
78
  }
79
+ // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
80
+ // safety), so `composer:push` would only run Composer on the server a second time.
81
+ async composerDeclaresPlugins() {
82
+ const path = join(process.cwd(), this.rootDir, 'composer.json');
83
+ if (!existsSync(path))
84
+ return false;
85
+ try {
86
+ const composerJson = JSON.parse(await readFile(path, 'utf8'));
87
+ return Object.keys(wpackagistRequire(composerJson, 'plugin')).length > 0;
88
+ }
89
+ catch {
90
+ // Unreadable: let both commands run and report the parse error themselves.
91
+ return false;
92
+ }
93
+ }
65
94
  // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
66
95
  // delegated command.
67
96
  async guardProductionPush() {
68
- await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
97
+ await guardProductionPush({
98
+ dryRun: this.dryRun,
99
+ error: (message) => this.error(message),
100
+ siteConfig: this.siteConfig,
101
+ yes: this.yes,
102
+ });
69
103
  }
70
104
  }
@@ -0,0 +1,10 @@
1
+ import { DiffCommand, type DiffJson } from '../../lib/diff-command.js';
2
+ export default class Diff extends DiffCommand {
3
+ static description: string;
4
+ static enableJsonFlag: boolean;
5
+ static examples: string[];
6
+ static flags: {
7
+ against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ run(): Promise<DiffJson>;
10
+ }
@@ -0,0 +1,13 @@
1
+ import { DiffCommand } from '../../lib/diff-command.js';
2
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
3
+ export default class Diff extends DiffCommand {
4
+ static description = 'Show what differs in block templates and parts between your local files and a WordPress environment, or between two environments. Templates and parts edited in the Site Editor show up too.';
5
+ static enableJsonFlag = true;
6
+ static examples = ['$ lps template diff', '$ lps template diff --env staging', '$ lps template diff --env staging --against production'];
7
+ static flags = { ...DiffCommand.againstFlag };
8
+ async run() {
9
+ const { flags } = await this.parse(Diff);
10
+ const sides = this.resolveSides(flags.against);
11
+ return this.report(['template', 'part'].map((resource) => this.providerTarget(getResourceStateProvider(resource), sides)), sides);
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type ChildTheme } from '../../utils/template-format.js';
3
+ type ListResult = Omit<ChildTheme, 'parts' | 'templates'> & {
4
+ parts: string[];
5
+ templates: string[];
6
+ };
7
+ export default class List extends LoopressCommand {
8
+ static description: string;
9
+ static enableJsonFlag: boolean;
10
+ static examples: string[];
11
+ run(): Promise<ListResult>;
12
+ }
13
+ export {};
@@ -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,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
+ });
@@ -45,7 +45,9 @@ function formatApiError(error, url, timeoutMs) {
45
45
  }
46
46
  const reason = nestErrorMessage(err.response?.body);
47
47
  if (status === 403) {
48
- return `Request rejected (${status}) on ${url}: ${reason ?? 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}`;
49
51
  }
50
52
  // Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
51
53
  // the body; got's own message ("Request failed with status code 400") never does.
@@ -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
+ }