@loopress/cli 0.32.2 → 0.34.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 (166) hide show
  1. package/README.md +549 -179
  2. package/dist/commands/acf/list.d.ts +1 -1
  3. package/dist/commands/acf/list.js +3 -4
  4. package/dist/commands/acf/pull.d.ts +1 -1
  5. package/dist/commands/acf/pull.js +9 -6
  6. package/dist/commands/acf/push.d.ts +1 -1
  7. package/dist/commands/acf/push.js +5 -4
  8. package/dist/commands/api/publish.js +2 -1
  9. package/dist/commands/app/pull.js +2 -1
  10. package/dist/commands/app/push.js +7 -5
  11. package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
  12. package/dist/commands/app/{remove.js → rm.js} +2 -1
  13. package/dist/commands/block/diff.d.ts +2 -0
  14. package/dist/commands/block/diff.js +5 -0
  15. package/dist/commands/block/list.d.ts +8 -0
  16. package/dist/commands/block/list.js +23 -0
  17. package/dist/commands/block/pull.d.ts +21 -0
  18. package/dist/commands/block/pull.js +67 -0
  19. package/dist/commands/block/push.d.ts +20 -0
  20. package/dist/commands/block/push.js +53 -0
  21. package/dist/commands/block/rm.d.ts +23 -0
  22. package/dist/commands/block/rm.js +69 -0
  23. package/dist/commands/block/rollback.d.ts +2 -0
  24. package/dist/commands/block/rollback.js +5 -0
  25. package/dist/commands/composer/init.js +1 -0
  26. package/dist/commands/composer/push.js +5 -4
  27. package/dist/commands/content/copy.d.ts +3 -0
  28. package/dist/commands/content/copy.js +79 -2
  29. package/dist/commands/dev.d.ts +2 -2
  30. package/dist/commands/dev.js +13 -18
  31. package/dist/commands/diff.d.ts +6 -3
  32. package/dist/commands/diff.js +63 -13
  33. package/dist/commands/doctor.d.ts +4 -2
  34. package/dist/commands/doctor.js +49 -25
  35. package/dist/commands/form/pull.js +7 -5
  36. package/dist/commands/form/push.js +4 -3
  37. package/dist/commands/init.js +4 -3
  38. package/dist/commands/menu/push.js +8 -8
  39. package/dist/commands/option/list.js +5 -2
  40. package/dist/commands/option/push.js +3 -1
  41. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  42. package/dist/commands/option/{remove.js → rm.js} +2 -1
  43. package/dist/commands/page/push.js +3 -1
  44. package/dist/commands/plugin/add.d.ts +1 -0
  45. package/dist/commands/plugin/add.js +50 -9
  46. package/dist/commands/plugin/audit.d.ts +4 -4
  47. package/dist/commands/plugin/audit.js +35 -15
  48. package/dist/commands/plugin/pull.js +3 -2
  49. package/dist/commands/plugin/push.js +41 -6
  50. package/dist/commands/plugin/status.js +3 -1
  51. package/dist/commands/project/config.js +7 -7
  52. package/dist/commands/project/pull.js +3 -1
  53. package/dist/commands/project/push.js +6 -4
  54. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  55. package/dist/commands/project/{remove.js → rm.js} +3 -2
  56. package/dist/commands/project/switch.js +6 -4
  57. package/dist/commands/promote.d.ts +1 -0
  58. package/dist/commands/promote.js +15 -6
  59. package/dist/commands/pull.d.ts +7 -0
  60. package/dist/commands/pull.js +67 -18
  61. package/dist/commands/push.d.ts +11 -1
  62. package/dist/commands/push.js +157 -48
  63. package/dist/commands/snippet/publish.js +9 -5
  64. package/dist/commands/snippet/push.d.ts +1 -1
  65. package/dist/commands/snippet/push.js +15 -12
  66. package/dist/commands/status.js +1 -0
  67. package/dist/commands/theme/activate.d.ts +21 -0
  68. package/dist/commands/theme/activate.js +44 -0
  69. package/dist/commands/theme/pull.js +2 -1
  70. package/dist/commands/theme/push.js +7 -3
  71. package/dist/commands/theme/style/diff.js +1 -1
  72. package/dist/commands/theme/style/push.js +8 -3
  73. package/dist/commands/theme/style/rollback.js +1 -1
  74. package/dist/commands/theme/template/push.d.ts +2 -0
  75. package/dist/commands/theme/template/push.js +66 -18
  76. package/dist/commands/validate.js +3 -0
  77. package/dist/config/auth.manager.d.ts +10 -7
  78. package/dist/config/auth.manager.js +5 -1
  79. package/dist/config/json-file.d.ts +1 -1
  80. package/dist/config/project-config.manager.d.ts +2 -2
  81. package/dist/config/project-config.manager.js +13 -21
  82. package/dist/lib/api-client.js +16 -21
  83. package/dist/lib/app-build.js +6 -2
  84. package/dist/lib/app-manifest.js +6 -6
  85. package/dist/lib/base.d.ts +8 -1
  86. package/dist/lib/base.js +43 -9
  87. package/dist/lib/bootstrap-full-install.js +8 -6
  88. package/dist/lib/capabilities.d.ts +44 -0
  89. package/dist/lib/capabilities.js +85 -0
  90. package/dist/lib/declared-plugins.d.ts +9 -0
  91. package/dist/lib/declared-plugins.js +21 -0
  92. package/dist/lib/declared-type-command.js +7 -2
  93. package/dist/lib/dev-watch.d.ts +1 -1
  94. package/dist/lib/dev-watch.js +13 -15
  95. package/dist/lib/diff-command.d.ts +19 -3
  96. package/dist/lib/diff-command.js +132 -14
  97. package/dist/lib/diff-state.js +2 -3
  98. package/dist/lib/environment-urls.d.ts +6 -0
  99. package/dist/lib/environment-urls.js +59 -0
  100. package/dist/lib/exclude.d.ts +16 -0
  101. package/dist/lib/exclude.js +102 -0
  102. package/dist/lib/load-files.js +2 -1
  103. package/dist/lib/load-snippets.js +39 -43
  104. package/dist/lib/local-callback-server.js +7 -1
  105. package/dist/lib/narrow.d.ts +5 -0
  106. package/dist/lib/narrow.js +26 -0
  107. package/dist/lib/php-files-command.js +13 -8
  108. package/dist/lib/plugin-detection.d.ts +0 -1
  109. package/dist/lib/plugin-detection.js +2 -4
  110. package/dist/lib/push-command.d.ts +5 -1
  111. package/dist/lib/push-command.js +48 -7
  112. package/dist/lib/put-or-create.d.ts +2 -2
  113. package/dist/lib/readdir-tolerant.js +9 -4
  114. package/dist/lib/resource-selection.d.ts +9 -0
  115. package/dist/lib/resource-selection.js +49 -0
  116. package/dist/lib/resource-state.d.ts +2 -0
  117. package/dist/lib/resource-state.js +60 -14
  118. package/dist/lib/rollback-command.d.ts +1 -1
  119. package/dist/lib/rollback-command.js +4 -3
  120. package/dist/lib/rollback-materialize.js +29 -9
  121. package/dist/lib/rotate-app-password.js +1 -0
  122. package/dist/lib/sentry.js +2 -2
  123. package/dist/lib/snapshot-store.d.ts +10 -7
  124. package/dist/lib/snapshot-store.js +21 -5
  125. package/dist/lib/spinner.js +4 -0
  126. package/dist/lib/validate-local.d.ts +1 -0
  127. package/dist/lib/validate-local.js +102 -28
  128. package/dist/lib/wp-client.d.ts +7 -1
  129. package/dist/lib/wp-client.js +60 -69
  130. package/dist/lib/wp-site-diagnostic.js +3 -5
  131. package/dist/types/global-config.generated.d.ts +1 -1
  132. package/dist/types/plugin.d.ts +7 -5
  133. package/dist/types/plugin.js +6 -1
  134. package/dist/types/project-config.generated.d.ts +24 -5
  135. package/dist/types/snippet.d.ts +2 -2
  136. package/dist/types/snippet.js +1 -1
  137. package/dist/utils/acf-format.js +6 -3
  138. package/dist/utils/block-format.d.ts +29 -0
  139. package/dist/utils/block-format.js +109 -0
  140. package/dist/utils/composer.d.ts +7 -15
  141. package/dist/utils/composer.js +43 -16
  142. package/dist/utils/declared-type-format.js +26 -19
  143. package/dist/utils/form-format.js +2 -2
  144. package/dist/utils/loopress-config.js +9 -1
  145. package/dist/utils/menu-format.d.ts +3 -1
  146. package/dist/utils/menu-format.js +5 -0
  147. package/dist/utils/option-format.d.ts +2 -2
  148. package/dist/utils/option-format.js +15 -25
  149. package/dist/utils/page-format.js +9 -7
  150. package/dist/utils/php-top-level.js +15 -4
  151. package/dist/utils/plugin-sync.d.ts +7 -7
  152. package/dist/utils/plugin-sync.js +11 -11
  153. package/dist/utils/plugins.js +9 -6
  154. package/dist/utils/resource-dirs.d.ts +2 -0
  155. package/dist/utils/resource-dirs.js +13 -5
  156. package/dist/utils/snippet-format.d.ts +1 -1
  157. package/dist/utils/snippet-format.js +5 -3
  158. package/dist/utils/template-format.js +2 -2
  159. package/dist/utils/theme-css.js +14 -6
  160. package/dist/utils/theme-styles-format.d.ts +3 -0
  161. package/dist/utils/theme-styles-format.js +15 -5
  162. package/dist/utils/version.js +1 -1
  163. package/oclif.manifest.json +2661 -1235
  164. package/package.json +45 -20
  165. package/schemas/global-config.schema.json +1 -1
  166. package/schemas/project-config.schema.json +32 -5
@@ -2,10 +2,13 @@ import { Args } from '@oclif/core';
2
2
  import { readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { basename, dirname, extname, join } from 'node:path';
4
4
  import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
5
+ import { errorCode, errorMessage, isRecord } from '../../lib/narrow.js';
5
6
  import { PushCommand } from '../../lib/push-command.js';
6
7
  import { putOrCreate } from '../../lib/put-or-create.js';
7
8
  import { getResourceStateProvider } from '../../lib/resource-state.js';
8
9
  import { isNotFoundError } from '../../lib/wp-client.js';
10
+ import {} from '../../types/snippet.generated.js';
11
+ import {} from '../../types/snippet.js';
9
12
  import { pluralize } from '../../utils/pluralize.js';
10
13
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
11
14
  import { toSlug } from '../../utils/to-slug.js';
@@ -65,18 +68,18 @@ export default class Push extends PushCommand {
65
68
  const currentBase = basename(snippet.path, ext);
66
69
  const canonicalBase = `${id}-${toSlug(name)}`;
67
70
  const oldMetaPath = join(dir, `${currentBase}.json`);
68
- let meta = {};
71
+ let existing = {};
69
72
  try {
70
- const existing = await readFile(oldMetaPath, 'utf8');
71
- meta = JSON.parse(existing);
73
+ const parsed = JSON.parse(await readFile(oldMetaPath, 'utf8'));
74
+ if (isRecord(parsed))
75
+ existing = parsed;
72
76
  }
73
77
  catch (error) {
74
- if (error.code !== 'ENOENT')
78
+ if (errorCode(error) !== 'ENOENT')
75
79
  throw error;
76
80
  }
77
- meta.id = id;
78
- meta.name = name;
79
- meta.type = snippet.type;
81
+ // Spread, not rebuilt: keys the CLI doesn't know about survive, in their original order.
82
+ const meta = { ...existing, id, name, type: snippet.type };
80
83
  // Persist the id against the *current* file pairing before renaming anything, so a
81
84
  // crash between the rename and the sidecar write still leaves a valid `<name>.<ext>` /
82
85
  // `<name>.json` pair with the id on disk, and a retried `snippet push` won't re-create
@@ -95,7 +98,7 @@ export default class Push extends PushCommand {
95
98
  return await loadSnippetsFromDisk(path, (message) => { this.warn(message); });
96
99
  }
97
100
  catch (error) {
98
- this.error(error.message);
101
+ this.error(errorMessage(error));
99
102
  }
100
103
  }
101
104
  async pushSnippet(snippet, task) {
@@ -134,15 +137,15 @@ export default class Push extends PushCommand {
134
137
  id = createdSnippet.id;
135
138
  await this.ensureCanonicalFilename(snippet, createdSnippet.id, createdSnippet.name);
136
139
  }
137
- else {
138
- await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
140
+ else if (id !== undefined) {
141
+ await this.ensureCanonicalFilename(snippet, id, snippet.name);
139
142
  }
140
143
  if (task)
141
144
  task.output = `Pushed: ${snippet.name}`;
142
145
  return id;
143
146
  }
144
147
  catch (error) {
145
- this.reportTaskFailure(`Failed to push ${snippet.name}: ${error.message}`, error, task);
148
+ this.reportTaskFailure(`Failed to push ${snippet.name}: ${errorMessage(error)}`, error, task);
146
149
  }
147
150
  }
148
151
  toPayload(snippet) {
@@ -172,7 +175,7 @@ export default class Push extends PushCommand {
172
175
  pathsById.set(id, [...(pathsById.get(id) ?? []), path]);
173
176
  }
174
177
  return snippets.filter((snippet) => {
175
- const paths = snippet.id === undefined ? [] : pathsById.get(snippet.id);
178
+ const paths = snippet.id === undefined ? [] : (pathsById.get(snippet.id) ?? []);
176
179
  if (paths.length < 2)
177
180
  return true;
178
181
  this.failedCount++;
@@ -1,5 +1,6 @@
1
1
  import { Command, Flags, ux } from '@oclif/core';
2
2
  import { configManager } from '../config/project-config.manager.js';
3
+ import {} from '../types/config.js';
3
4
  import { readLocalConfig } from '../utils/loopress-config.js';
4
5
  const c = ux.colorize;
5
6
  export default class Status extends Command {
@@ -0,0 +1,21 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type ActivateResult = {
3
+ active: string;
4
+ previous: string;
5
+ status: 'activated' | 'dry-run' | 'unchanged';
6
+ };
7
+ export default class Activate extends LoopressCommand {
8
+ static args: {
9
+ stylesheet: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
10
+ };
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<ActivateResult>;
19
+ private childStylesheet;
20
+ }
21
+ export {};
@@ -0,0 +1,44 @@
1
+ import { Args } from '@oclif/core';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { activateTheme } from '../../lib/capabilities.js';
4
+ import { guardProductionPush } from '../../lib/guard-production-push.js';
5
+ export default class Activate extends LoopressCommand {
6
+ static args = {
7
+ stylesheet: Args.string({ description: 'Theme to activate (default: the Loopress child theme, <parent>-loopress)' }),
8
+ };
9
+ static description = 'Activate a theme on WordPress, the Loopress child theme by default. A child theme without settings of its own starts from a copy of its parent\'s theme mods and Global Styles. Set "activateChildTheme": true in loopress.json to have `lps theme template push` keep the child active.';
10
+ static enableJsonFlag = true;
11
+ static examples = ['$ lps theme activate', '$ lps theme activate twentytwentyfive --env staging', '$ lps theme activate --dry-run'];
12
+ static flags = {
13
+ ...LoopressCommand.dryRunFlag,
14
+ ...LoopressCommand.yesFlag,
15
+ };
16
+ async run() {
17
+ const { args } = await this.parse(Activate);
18
+ const { theme } = await this.capabilities();
19
+ const stylesheet = args.stylesheet ?? this.childStylesheet(theme);
20
+ if (stylesheet === theme.active) {
21
+ this.log(`${stylesheet} is already the active theme.`);
22
+ return { active: stylesheet, previous: theme.active, status: 'unchanged' };
23
+ }
24
+ if (this.dryRun) {
25
+ this.log(`[dry-run] Would activate ${stylesheet} instead of ${theme.active}.`);
26
+ return { active: stylesheet, previous: theme.active, status: 'dry-run' };
27
+ }
28
+ await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
29
+ const activation = await activateTheme(this.wp, stylesheet);
30
+ this.log(`Activated ${activation.active} (was ${activation.previous}).`);
31
+ return { active: activation.active, previous: activation.previous, status: 'activated' };
32
+ }
33
+ childStylesheet(theme) {
34
+ if (theme.child === undefined) {
35
+ this.error('The Loopress child theme needs Loopress Full. Name the theme to activate instead.');
36
+ }
37
+ else if (theme.child === null) {
38
+ this.error(`The active theme "${theme.active}" can't have a Loopress child theme: it needs a block theme that isn't already a child theme.`);
39
+ }
40
+ if (!theme.child.exists)
41
+ this.error(`${theme.child.stylesheet} doesn't exist yet. Run \`lps theme template push\` first.`);
42
+ return theme.child.stylesheet;
43
+ }
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
2
  import { stdoutToStderr } from '../../lib/json-delegation.js';
3
+ import { errorMessage } from '../../lib/narrow.js';
3
4
  import { pluralize } from '../../utils/pluralize.js';
4
5
  // Templates and parts have no pull: the child theme is written from the project, never read back.
5
6
  const PULL_TARGETS = [
@@ -26,7 +27,7 @@ export default class Pull extends LoopressCommand {
26
27
  results.push({ label: target.label, status: 'pulled' });
27
28
  }
28
29
  catch (error) {
29
- const { message } = error;
30
+ const message = errorMessage(error);
30
31
  this.log(`✗ ${target.label} failed: ${message}`);
31
32
  results.push({ error: message, label: target.label, status: 'failed' });
32
33
  }
@@ -4,7 +4,9 @@ import { join } from 'node:path';
4
4
  import { LoopressCommand } from '../../lib/base.js';
5
5
  import { guardProductionPush } from '../../lib/guard-production-push.js';
6
6
  import { stdoutToStderr } from '../../lib/json-delegation.js';
7
+ import { errorMessage } from '../../lib/narrow.js';
7
8
  import { pluralize } from '../../utils/pluralize.js';
9
+ import { projectHasChildThemeFiles } from '../../utils/resource-dirs.js';
8
10
  // The parent theme must be installed before its child theme is written, and Global Styles
9
11
  // belong to whichever theme is active, so they go last.
10
12
  const PUSH_TARGETS = [
@@ -13,7 +15,7 @@ const PUSH_TARGETS = [
13
15
  { commandId: 'theme:style:push', label: 'Global Styles' },
14
16
  ];
15
17
  export default class Push extends LoopressCommand {
16
- static description = 'Push everything theme related to WordPress: theme versions from loopress.json, then block templates and parts, then Global Styles. Never switches the active theme.';
18
+ static description = 'Push everything theme related to WordPress: theme versions from loopress.json, then block templates and parts, then Global Styles. Switches the active theme only to the Loopress child, when loopress.json sets "activateChildTheme": true.';
17
19
  static enableJsonFlag = true;
18
20
  static examples = ['$ lps theme push', '$ lps theme push --env staging', '$ lps theme push --dry-run'];
19
21
  static flags = {
@@ -34,7 +36,9 @@ export default class Push extends LoopressCommand {
34
36
  // Nothing to install and no composer.json: the versions step would only fail on an empty
35
37
  // manifest, which must not fail a templates or styles only project.
36
38
  const hasVersions = Object.keys(this.localConfig.themes ?? {}).length > 0 || existsSync(join(process.cwd(), this.rootDir, 'composer.json'));
37
- const targets = hasVersions ? PUSH_TARGETS : PUSH_TARGETS.filter((target) => target.commandId !== 'theme:version:push');
39
+ // Same for templates: without templates, parts or CSS the step would refuse (nothing to push).
40
+ const hasChildTheme = projectHasChildThemeFiles(this.localConfig);
41
+ const targets = PUSH_TARGETS.filter((target) => (hasVersions || target.commandId !== 'theme:version:push') && (hasChildTheme || target.commandId !== 'theme:template:push'));
38
42
  const results = [];
39
43
  for (const target of targets) {
40
44
  const argv = ['--env', this.siteConfig.name, '--production-confirmed'];
@@ -51,7 +55,7 @@ export default class Push extends LoopressCommand {
51
55
  results.push({ label: target.label, result, status: 'pushed' });
52
56
  }
53
57
  catch (error) {
54
- const { message } = error;
58
+ const message = errorMessage(error);
55
59
  this.log(`✗ ${target.label} failed: ${message}`);
56
60
  results.push({ error: message, label: target.label, status: 'failed' });
57
61
  }
@@ -1,5 +1,5 @@
1
1
  import { resourceDiffCommand } from '../../../lib/diff-command.js';
2
- export default resourceDiffCommand('theme-styles', {
2
+ export default resourceDiffCommand('theme-style', {
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
4
  command: 'theme style',
5
5
  pathNoun: 'theme styles directory',
@@ -1,6 +1,7 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { errorCode, isRecord } from '../../../lib/narrow.js';
4
5
  import { PushCommand } from '../../../lib/push-command.js';
5
6
  import { getResourceStateProvider } from '../../../lib/resource-state.js';
6
7
  import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../../utils/theme-styles-format.js';
@@ -27,18 +28,22 @@ export default class Push extends PushCommand {
27
28
  raw = await readFile(file, 'utf8');
28
29
  }
29
30
  catch (error) {
30
- if (error.code === 'ENOENT') {
31
+ if (errorCode(error) === 'ENOENT') {
31
32
  this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme style pull\` first.`);
32
33
  }
33
34
  throw error;
34
35
  }
35
36
  // Only the active theme's file is pushed, whatever else the directory holds.
36
- const provider = getResourceStateProvider('theme-styles');
37
+ const provider = getResourceStateProvider('theme-style');
37
38
  const beforeState = await this.captureBeforePushState(provider, dir, (name) => name === stylesheet);
38
39
  if (this.dryRun)
39
40
  return { changes: this.previewChanges, file, status: 'dry-run' };
40
41
  const local = JSON.parse(raw);
41
- await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
42
+ // Never fall back to empty settings/styles: that push would wipe the site's customizations.
43
+ if (!isRecord(local))
44
+ throw new Error(`${file} is not a JSON object`);
45
+ const { settings, styles } = local;
46
+ await this.wp.post(globalStylesEndpoint(id), { settings: settings ?? {}, styles: styles ?? {} });
42
47
  this.log(`Pushed: ${file}`);
43
48
  await this.writeAfterPushSnapshot(provider, dir, beforeState);
44
49
  await this.recordSuccess();
@@ -1,5 +1,5 @@
1
1
  import { resourceRollbackCommand } from '../../../lib/rollback-command.js';
2
- export default resourceRollbackCommand('theme-styles', {
2
+ export default resourceRollbackCommand('theme-style', {
3
3
  description: "Restore the active theme's Global Styles to a snapshot taken automatically before an earlier `lps theme style push`",
4
4
  command: 'theme style',
5
5
  pathNoun: 'theme styles directory',
@@ -27,6 +27,8 @@ export default class Push extends PushCommand {
27
27
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
28
28
  };
29
29
  run(): Promise<PushResult>;
30
+ private keptFromSite;
30
31
  private previewChildTheme;
32
+ private readChildTheme;
31
33
  }
32
34
  export {};
@@ -1,12 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { activateTheme } from '../../../lib/capabilities.js';
4
+ import {} from '../../../lib/diff-state.js';
5
+ import { hasExclusions, isExcluded } from '../../../lib/exclude.js';
6
+ import { errorMessage } from '../../../lib/narrow.js';
2
7
  import { logChanges, pushChanges, PushCommand } from '../../../lib/push-command.js';
3
8
  import { formatPageProblems } from '../../../utils/page-format.js';
4
9
  import { pluralize } from '../../../utils/pluralize.js';
5
- import { resolveResourceDir } from '../../../utils/resource-dirs.js';
10
+ import { projectHasChildThemeFiles, resolveResourceDir } from '../../../utils/resource-dirs.js';
6
11
  import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../../utils/template-format.js';
7
12
  import { bundleThemeCss } from '../../../utils/theme-css.js';
8
13
  export default class Push extends PushCommand {
9
- static description = 'Push block templates (theme/templates/<slug>.html), template parts (theme/parts/<slug>.html) and the theme CSS (theme/css/style.css, theme/blocks/<namespace>/<block>.css, bundled with esbuild) 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.';
14
+ static description = 'Push block templates (theme/templates/<slug>.html), template parts (theme/parts/<slug>.html) and the theme CSS (theme/css/style.css, theme/blocks/<namespace>/<block>.css, bundled with esbuild) 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 activated only when loopress.json sets "activateChildTheme": true, see `lps theme activate`.';
10
15
  static enableJsonFlag = true;
11
16
  static examples = ['$ lps theme template push', '$ lps theme template push --dry-run'];
12
17
  static flags = {
@@ -17,6 +22,10 @@ export default class Push extends PushCommand {
17
22
  await this.parse(Push);
18
23
  const templatesPath = resolveResourceDir('template', this.localConfig);
19
24
  const partsPath = resolveResourceDir('part', this.localConfig);
25
+ // Pushing would only create an empty child theme as a side effect.
26
+ if (!projectHasChildThemeFiles(this.localConfig)) {
27
+ this.error(`Nothing to push: no ${templatesPath}, ${partsPath}, ${resolveResourceDir('css', this.localConfig)} or ${resolveResourceDir('blockCss', this.localConfig)} directory.`);
28
+ }
20
29
  this.log(`Pushing templates and parts to ${this.siteConfig.url}`);
21
30
  this.log(`Templates path: ${templatesPath}, parts path: ${partsPath}`);
22
31
  const { parts, problems, templates } = await readLocalTemplates(templatesPath, partsPath);
@@ -32,18 +41,37 @@ export default class Push extends PushCommand {
32
41
  };
33
42
  this.log(`Found ${pluralize(templates.length, 'template')}, ${pluralize(parts.length, 'part')}, ` +
34
43
  `${css.style ? 'a global stylesheet' : 'no global stylesheet'} and ${pluralize(css.blocks.length, 'block stylesheet')}`);
44
+ this.warnEnvironmentUrls([
45
+ ...templates.map((template) => ({ id: `templates/${template.slug}`, value: template.html })),
46
+ ...parts.map((part) => ({ id: `parts/${part.slug}`, value: part.html })),
47
+ ]);
48
+ // The PUT replaces every file of the child theme: what the site has under a path this
49
+ // environment excludes (loopress.json) is sent back as it is, so it is left alone.
50
+ const kept = await this.keptFromSite(templatesPath, partsPath, templates, parts);
35
51
  if (this.dryRun) {
36
- const changes = await this.previewChildTheme(templates, parts, css);
52
+ const changes = await this.previewChildTheme(templates, parts, css, kept);
37
53
  logChanges((message) => { this.log(message); }, changes.templates, 'templates/');
38
54
  logChanges((message) => { this.log(message); }, changes.parts, 'parts/');
39
55
  logChanges((message) => { this.log(message); }, changes.css, 'css/');
56
+ if (this.localConfig.activateChildTheme) {
57
+ const { child } = (await this.capabilities()).theme;
58
+ if (child && !child.active)
59
+ this.log(`[dry-run] Would activate ${child.stylesheet}.`);
60
+ }
40
61
  return { ...names, changes, status: 'dry-run' };
41
62
  }
42
- const child = await this.wp.put(CHILD_THEME_ENDPOINT, { ...css, parts, templates });
63
+ const child = await this.wp.put(CHILD_THEME_ENDPOINT, { ...css, parts: [...parts, ...kept.parts], templates: [...templates, ...kept.templates] });
43
64
  await this.recordSuccess();
44
65
  this.log(`Child theme ${child.stylesheet} written.`);
45
- if (!child.active) {
46
- this.warn(`${child.stylesheet} is not the active theme, so nothing changed on the site yet. Activate it in Appearance > Themes.`);
66
+ // Activated only when the project asks for it: a theme switch changes every per-theme setting.
67
+ let { active } = child;
68
+ if (!active && this.localConfig.activateChildTheme) {
69
+ const activation = await activateTheme(this.wp, child.stylesheet);
70
+ active = true;
71
+ this.log(`Activated ${child.stylesheet} (was ${activation.previous}), as loopress.json's activateChildTheme asks.`);
72
+ }
73
+ if (!active) {
74
+ this.warn(`${child.stylesheet} is not the active theme, so nothing changed on the site yet. Run \`lps theme activate\`, or set "activateChildTheme": true in loopress.json.`);
47
75
  }
48
76
  if (child.customized.length > 0) {
49
77
  this.warn(`Edited in the Site Editor, their database copy still wins over the pushed file: ${child.customized.join(', ')}. ` +
@@ -57,22 +85,30 @@ export default class Push extends PushCommand {
57
85
  }
58
86
  return {
59
87
  ...names,
60
- active: child.active,
88
+ active,
61
89
  customized: child.customized,
62
90
  status: 'success',
63
91
  stylesheet: child.stylesheet,
64
92
  unknownBlocks,
65
93
  };
66
94
  }
67
- // Same read and same refusal as previewRemote, one GET for templates, parts and CSS.
68
- async previewChildTheme(templates, parts, css) {
69
- let child;
70
- try {
71
- child = await this.wp.get(CHILD_THEME_ENDPOINT);
72
- }
73
- catch (error) {
74
- this.error(`The site would refuse this push: ${error.message}`);
75
- }
95
+ // The site's templates and parts under an excluded path the project doesn't push. Refuses when
96
+ // the site can't be read: pushing blind would remove them.
97
+ async keptFromSite(templatesPath, partsPath, templates, parts) {
98
+ if (!hasExclusions())
99
+ return { parts: [], templates: [] };
100
+ const child = await this.readChildTheme();
101
+ const localTemplates = new Set(templates.map((template) => template.slug));
102
+ const localParts = new Set(parts.map((part) => part.slug));
103
+ return {
104
+ parts: child.parts.filter((part) => !localParts.has(part.slug) && isExcluded(join(partsPath, `${part.slug}.html`))),
105
+ templates: child.templates.filter((template) => !localTemplates.has(template.slug) && isExcluded(join(templatesPath, `${template.slug}.html`))),
106
+ };
107
+ }
108
+ // Same read and same refusal as previewRemote, one GET for templates, parts and CSS. Kept
109
+ // files are held back, they are neither changes nor removals.
110
+ async previewChildTheme(templates, parts, css, kept) {
111
+ const child = await this.readChildTheme();
76
112
  const localCss = new Map(css.blocks.map((block) => [`blocks/${block.name}.css`, sha256(block.css)]));
77
113
  if (css.style !== '')
78
114
  localCss.set('style.css', sha256(css.style));
@@ -81,14 +117,26 @@ export default class Push extends PushCommand {
81
117
  : [...localCss.keys()].toSorted((a, b) => a.localeCompare(b)).map((id) => ({ id, status: 'unknown' }));
82
118
  return {
83
119
  css: cssChanges,
84
- parts: pushChanges(bySlug(parts), bySlug(child.parts), { mirror: true }),
85
- templates: pushChanges(bySlug(templates), bySlug(child.templates), { mirror: true }),
120
+ parts: withoutKept(pushChanges(bySlug([...parts, ...kept.parts]), bySlug(child.parts), { mirror: true }), kept.parts),
121
+ templates: withoutKept(pushChanges(bySlug([...templates, ...kept.templates]), bySlug(child.templates), { mirror: true }), kept.templates),
86
122
  };
87
123
  }
124
+ async readChildTheme() {
125
+ try {
126
+ return await this.wp.get(CHILD_THEME_ENDPOINT);
127
+ }
128
+ catch (error) {
129
+ this.error(`The site would refuse this push: ${errorMessage(error)}`);
130
+ }
131
+ }
88
132
  }
89
133
  function bySlug(files) {
90
134
  return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
91
135
  }
136
+ function withoutKept(changes, kept) {
137
+ const slugs = new Set(kept.map((file) => file.slug));
138
+ return changes.filter((change) => !slugs.has(change.id));
139
+ }
92
140
  function sha256(text) {
93
141
  return createHash('sha256').update(text, 'utf8').digest('hex');
94
142
  }
@@ -11,6 +11,9 @@ export default class Validate extends Command {
11
11
  for (const problem of result.problems) {
12
12
  this.log(` ✗ ${problem.file}: ${problem.message}`);
13
13
  }
14
+ for (const warning of result.warnings) {
15
+ this.log(` ! ${warning.file}: ${warning.message}`);
16
+ }
14
17
  this.log('');
15
18
  this.log(result.valid
16
19
  ? `${result.checked} file(s) checked, no problems found.`
@@ -1,11 +1,13 @@
1
- export type ConsoleAuth = {
2
- email?: string;
3
- savedAt: string;
4
- token: string;
5
- };
1
+ import { z } from 'zod';
2
+ declare const CONSOLE_AUTH: z.ZodObject<{
3
+ email: z.ZodOptional<z.ZodString>;
4
+ savedAt: z.ZodString;
5
+ token: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export type ConsoleAuth = z.infer<typeof CONSOLE_AUTH>;
6
8
  export declare class AuthManager {
7
- private dataDir?;
8
- constructor(dataDir?: string | undefined);
9
+ private dataDir;
10
+ constructor(dataDir?: string);
9
11
  clearAuth(): void;
10
12
  getAuth(): ConsoleAuth | null;
11
13
  getAuthFilePath(): string;
@@ -14,3 +16,4 @@ export declare class AuthManager {
14
16
  private requireDataDir;
15
17
  }
16
18
  export declare const authManager: AuthManager;
19
+ export {};
@@ -1,6 +1,8 @@
1
1
  import { existsSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
5
+ const CONSOLE_AUTH = z.object({ email: z.string().optional(), savedAt: z.string(), token: z.string() });
4
6
  export class AuthManager {
5
7
  dataDir;
6
8
  constructor(dataDir) {
@@ -12,7 +14,9 @@ export class AuthManager {
12
14
  unlinkSync(filePath);
13
15
  }
14
16
  getAuth() {
15
- return readJsonFile(this.getAuthFilePath());
17
+ // A hand-edited or truncated auth.json reads as logged out, not as a token-less session.
18
+ const result = CONSOLE_AUTH.safeParse(readJsonFile(this.getAuthFilePath()));
19
+ return result.success ? result.data : null;
16
20
  }
17
21
  getAuthFilePath() {
18
22
  return join(this.requireDataDir(), 'auth.json');
@@ -1,2 +1,2 @@
1
- export declare function readJsonFile<T>(filePath: string): null | T;
1
+ export declare function readJsonFile(filePath: string): unknown;
2
2
  export declare function writeJsonFileAtomic(filePath: string, data: unknown): void;
@@ -1,7 +1,7 @@
1
1
  import { type EnvironmentConfig, type LoopressConfig, type ProjectConfig } from '../types/config.js';
2
2
  export declare class ProjectConfigManager {
3
- private configDir?;
4
- constructor(configDir?: string | undefined);
3
+ private configDir;
4
+ constructor(configDir?: string);
5
5
  createProjectId(name: string): string;
6
6
  ensureConfigDir(): void;
7
7
  findProjectByApiId(apiProjectId: string): null | (ProjectConfig & {
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { isRecord } from '../lib/narrow.js';
3
4
  import { LOCAL_ENVIRONMENT_NAME } from '../lib/wp-client.js';
5
+ import {} from '../types/config.js';
4
6
  import { toSlug } from '../utils/to-slug.js';
5
7
  import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
6
8
  export class ProjectConfigManager {
@@ -55,10 +57,10 @@ export class ProjectConfigManager {
55
57
  // remembered choice is exactly the hidden state that sends a push to the wrong site.
56
58
  getDefaultEnvironment(projectId) {
57
59
  const environments = this.getProject(projectId)?.environments ?? {};
58
- const names = Object.keys(environments);
59
60
  if (Object.hasOwn(environments, LOCAL_ENVIRONMENT_NAME))
60
- return environments[LOCAL_ENVIRONMENT_NAME];
61
- return names.length === 1 ? environments[names[0]] : null;
61
+ return environments[LOCAL_ENVIRONMENT_NAME] ?? null;
62
+ const [only, ...others] = Object.values(environments);
63
+ return others.length === 0 ? (only ?? null) : null;
62
64
  }
63
65
  getEnvironment(projectId, envName) {
64
66
  const project = this.getProject(projectId);
@@ -165,10 +167,7 @@ export class ProjectConfigManager {
165
167
  writeJsonFileAtomic(this.getConfigFilePath(), config);
166
168
  }
167
169
  isProjectConfig(value) {
168
- if (typeof value !== 'object' || value === null)
169
- return false;
170
- const candidate = value;
171
- return typeof candidate.name === 'string' && typeof candidate.environments === 'object' && candidate.environments !== null;
170
+ return isRecord(value) && typeof value.name === 'string' && isRecord(value.environments);
172
171
  }
173
172
  // Real CLI runs get this from the `init` hook (src/hooks/init.ts) before any command runs.
174
173
  // Throwing when it's unset (rather than falling back to a hardcoded path) surfaces tests that
@@ -179,25 +178,21 @@ export class ProjectConfigManager {
179
178
  return this.configDir;
180
179
  }
181
180
  sanitizeConfig(value) {
182
- if (typeof value !== 'object' || value === null)
181
+ if (!isRecord(value))
183
182
  return { currentProject: null, projects: {} };
184
- const candidate = value;
185
- const telemetry = this.sanitizeTelemetry(candidate.telemetry);
183
+ const telemetry = this.sanitizeTelemetry(value.telemetry);
186
184
  return {
187
- currentProject: this.sanitizeCurrentProject(candidate.currentProject),
188
- projects: this.sanitizeProjects(candidate.projects),
185
+ currentProject: this.sanitizeCurrentProject(value.currentProject),
186
+ projects: this.sanitizeProjects(value.projects),
189
187
  ...(telemetry && { telemetry }),
190
188
  };
191
189
  }
192
190
  sanitizeCurrentProject(value) {
193
- if (value === null || typeof value !== 'object')
194
- return null;
195
- const pointer = value;
196
191
  // A legacy `env` field, from when switch also picked the environment, is dropped here.
197
- return typeof pointer.id === 'string' ? { id: pointer.id } : null;
192
+ return isRecord(value) && typeof value.id === 'string' ? { id: value.id } : null;
198
193
  }
199
194
  sanitizeProjects(value) {
200
- if (typeof value !== 'object' || value === null)
195
+ if (!isRecord(value))
201
196
  return {};
202
197
  const projects = {};
203
198
  for (const [id, project] of Object.entries(value)) {
@@ -207,10 +202,7 @@ export class ProjectConfigManager {
207
202
  return projects;
208
203
  }
209
204
  sanitizeTelemetry(value) {
210
- if (typeof value !== 'object' || value === null)
211
- return undefined;
212
- const candidate = value;
213
- return typeof candidate.disabled === 'boolean' ? { disabled: candidate.disabled } : undefined;
205
+ return isRecord(value) && typeof value.disabled === 'boolean' ? { disabled: value.disabled } : undefined;
214
206
  }
215
207
  }
216
208
  export const configManager = new ProjectConfigManager();
@@ -1,6 +1,7 @@
1
- import got from 'got';
1
+ import got, {} from 'got';
2
+ import { errorMessage, isRecord } from './narrow.js';
2
3
  import { withSpinner } from './spinner.js';
3
- import { REQUEST_TIMEOUT_MS } from './wp-client.js';
4
+ import { parseBody, REQUEST_TIMEOUT_MS, responseOf } from './wp-client.js';
4
5
  export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
5
6
  /**
6
7
  * HTTP client for the Loopress cloud API (projects, environments, credentials).
@@ -31,6 +32,8 @@ export class ApiClient {
31
32
  async request(method, path, json) {
32
33
  try {
33
34
  const response = await withSpinner('Waiting for the Loopress API', async () => this.client(path, { json, method }));
35
+ // The API contract boundary: each endpoint's shape is the caller's `T`, not checked here.
36
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
34
37
  return (response.body ? JSON.parse(response.body) : undefined);
35
38
  }
36
39
  catch (error) {
@@ -39,41 +42,33 @@ export class ApiClient {
39
42
  }
40
43
  }
41
44
  function formatApiError(error, url, timeoutMs) {
42
- const err = error;
43
- const status = err.response?.statusCode;
45
+ const { body, statusCode: status } = responseOf(error);
44
46
  if (status === 401) {
45
47
  return `Not logged in or session expired on ${url}. Run \`lps login\` again.`;
46
48
  }
47
- const reason = nestErrorMessage(err.response?.body);
49
+ const reason = nestErrorMessage(body);
48
50
  if (status === 403) {
49
51
  // An empty body (not just a missing one) falls back to got's message too, never a bare ": ".
50
- const body = err.response?.body === '' ? undefined : err.response?.body;
51
- return `Request rejected (${status}) on ${url}: ${reason ?? body ?? err.message}`;
52
+ return `Request rejected (${status}) on ${url}: ${reason ?? (body === '' ? undefined : body) ?? errorMessage(error)}`;
52
53
  }
53
54
  // Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
54
55
  // the body; got's own message ("Request failed with status code 400") never does.
55
56
  if (status !== undefined && reason) {
56
57
  return `Request failed (${status}) on ${url}: ${reason}`;
57
58
  }
58
- if (err.name === 'TimeoutError') {
59
+ if (isRecord(error) && error.name === 'TimeoutError') {
59
60
  return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
60
61
  }
61
- return err.message ?? String(error);
62
+ return errorMessage(error);
62
63
  }
63
64
  // NestJS's HttpException body: `{statusCode, message, error}`, where `message` is a list for a
64
65
  // ValidationPipe failure (one entry per invalid field).
65
66
  function nestErrorMessage(body) {
66
- if (!body)
67
- return undefined;
68
- try {
69
- const { message } = JSON.parse(body);
70
- if (typeof message === 'string' && message.trim())
71
- return message;
72
- if (Array.isArray(message) && message.length > 0)
73
- return message.join('; ');
74
- }
75
- catch {
76
- // not JSON (a proxy's HTML error page): nothing to extract
77
- }
67
+ // Not JSON (a proxy's HTML error page) reads as no fields: nothing to extract.
68
+ const { message } = parseBody(body);
69
+ if (typeof message === 'string' && message.trim())
70
+ return message;
71
+ if (Array.isArray(message) && message.length > 0)
72
+ return message.join('; ');
78
73
  return undefined;
79
74
  }