@loopress/cli 0.32.1 → 0.33.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 (149) hide show
  1. package/README.md +511 -154
  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 +5 -4
  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 +48 -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.js +4 -3
  30. package/dist/commands/diff.d.ts +1 -0
  31. package/dist/commands/diff.js +33 -3
  32. package/dist/commands/doctor.d.ts +4 -2
  33. package/dist/commands/doctor.js +37 -23
  34. package/dist/commands/form/pull.js +7 -5
  35. package/dist/commands/form/push.js +4 -3
  36. package/dist/commands/init.js +4 -3
  37. package/dist/commands/menu/push.js +8 -8
  38. package/dist/commands/option/list.js +5 -2
  39. package/dist/commands/option/push.js +2 -1
  40. package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
  41. package/dist/commands/option/{remove.js → rm.js} +2 -1
  42. package/dist/commands/page/push.js +2 -1
  43. package/dist/commands/plugin/audit.d.ts +4 -4
  44. package/dist/commands/plugin/audit.js +35 -15
  45. package/dist/commands/plugin/pull.js +3 -2
  46. package/dist/commands/plugin/push.js +2 -2
  47. package/dist/commands/plugin/status.js +3 -1
  48. package/dist/commands/project/config.js +7 -6
  49. package/dist/commands/project/pull.js +3 -1
  50. package/dist/commands/project/push.js +6 -4
  51. package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
  52. package/dist/commands/project/{remove.js → rm.js} +3 -2
  53. package/dist/commands/project/switch.js +6 -4
  54. package/dist/commands/promote.js +7 -2
  55. package/dist/commands/pull.d.ts +2 -0
  56. package/dist/commands/pull.js +42 -18
  57. package/dist/commands/push.d.ts +2 -1
  58. package/dist/commands/push.js +69 -25
  59. package/dist/commands/snippet/publish.js +9 -5
  60. package/dist/commands/snippet/push.d.ts +1 -1
  61. package/dist/commands/snippet/push.js +15 -12
  62. package/dist/commands/status.js +1 -0
  63. package/dist/commands/theme/activate.d.ts +21 -0
  64. package/dist/commands/theme/activate.js +44 -0
  65. package/dist/commands/theme/pull.js +2 -1
  66. package/dist/commands/theme/push.js +7 -3
  67. package/dist/commands/theme/style/push.js +7 -2
  68. package/dist/commands/theme/template/push.js +25 -6
  69. package/dist/config/auth.manager.d.ts +10 -7
  70. package/dist/config/auth.manager.js +5 -1
  71. package/dist/config/json-file.d.ts +1 -1
  72. package/dist/config/project-config.manager.d.ts +2 -2
  73. package/dist/config/project-config.manager.js +13 -21
  74. package/dist/lib/api-client.js +16 -21
  75. package/dist/lib/app-build.js +6 -2
  76. package/dist/lib/app-manifest.js +6 -6
  77. package/dist/lib/base.d.ts +2 -1
  78. package/dist/lib/base.js +10 -8
  79. package/dist/lib/bootstrap-full-install.js +8 -6
  80. package/dist/lib/capabilities.d.ts +44 -0
  81. package/dist/lib/capabilities.js +85 -0
  82. package/dist/lib/declared-type-command.js +4 -2
  83. package/dist/lib/dev-watch.js +5 -5
  84. package/dist/lib/diff-command.d.ts +2 -3
  85. package/dist/lib/diff-command.js +9 -7
  86. package/dist/lib/diff-state.js +2 -3
  87. package/dist/lib/load-files.js +2 -1
  88. package/dist/lib/load-snippets.js +37 -42
  89. package/dist/lib/local-callback-server.js +7 -1
  90. package/dist/lib/narrow.d.ts +5 -0
  91. package/dist/lib/narrow.js +26 -0
  92. package/dist/lib/php-files-command.js +8 -5
  93. package/dist/lib/plugin-detection.d.ts +0 -1
  94. package/dist/lib/plugin-detection.js +2 -4
  95. package/dist/lib/push-command.d.ts +1 -1
  96. package/dist/lib/push-command.js +19 -7
  97. package/dist/lib/put-or-create.d.ts +2 -2
  98. package/dist/lib/readdir-tolerant.js +3 -1
  99. package/dist/lib/resource-state.d.ts +1 -1
  100. package/dist/lib/resource-state.js +58 -19
  101. package/dist/lib/rollback-command.d.ts +1 -1
  102. package/dist/lib/rollback-command.js +3 -2
  103. package/dist/lib/rollback-materialize.js +28 -8
  104. package/dist/lib/rotate-app-password.js +1 -0
  105. package/dist/lib/sentry.js +2 -2
  106. package/dist/lib/snapshot-store.d.ts +10 -7
  107. package/dist/lib/snapshot-store.js +21 -5
  108. package/dist/lib/spinner.js +4 -0
  109. package/dist/lib/validate-local.js +35 -20
  110. package/dist/lib/wp-client.d.ts +6 -1
  111. package/dist/lib/wp-client.js +54 -69
  112. package/dist/lib/wp-site-diagnostic.js +3 -5
  113. package/dist/types/global-config.generated.d.ts +1 -1
  114. package/dist/types/plugin.d.ts +7 -5
  115. package/dist/types/plugin.js +6 -1
  116. package/dist/types/project-config.generated.d.ts +9 -1
  117. package/dist/types/snippet.d.ts +2 -2
  118. package/dist/types/snippet.js +1 -1
  119. package/dist/utils/acf-format.d.ts +1 -0
  120. package/dist/utils/acf-format.js +11 -3
  121. package/dist/utils/block-format.d.ts +27 -0
  122. package/dist/utils/block-format.js +93 -0
  123. package/dist/utils/composer.d.ts +7 -15
  124. package/dist/utils/composer.js +43 -16
  125. package/dist/utils/declared-type-format.js +26 -19
  126. package/dist/utils/form-format.js +2 -2
  127. package/dist/utils/loopress-config.js +9 -1
  128. package/dist/utils/menu-format.d.ts +3 -1
  129. package/dist/utils/menu-format.js +5 -0
  130. package/dist/utils/option-format.d.ts +2 -2
  131. package/dist/utils/option-format.js +15 -25
  132. package/dist/utils/page-format.js +7 -6
  133. package/dist/utils/php-top-level.js +15 -4
  134. package/dist/utils/plugin-sync.d.ts +7 -7
  135. package/dist/utils/plugin-sync.js +11 -11
  136. package/dist/utils/plugins.js +9 -6
  137. package/dist/utils/resource-dirs.d.ts +2 -0
  138. package/dist/utils/resource-dirs.js +9 -1
  139. package/dist/utils/snippet-format.d.ts +1 -1
  140. package/dist/utils/snippet-format.js +5 -3
  141. package/dist/utils/template-format.js +2 -2
  142. package/dist/utils/theme-css.js +14 -6
  143. package/dist/utils/theme-styles-format.d.ts +3 -0
  144. package/dist/utils/theme-styles-format.js +44 -6
  145. package/dist/utils/version.js +1 -1
  146. package/oclif.manifest.json +1588 -1043
  147. package/package.json +42 -17
  148. package/schemas/global-config.schema.json +1 -1
  149. package/schemas/project-config.schema.json +11 -1
@@ -2,17 +2,22 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { LoopressCommand } from '../lib/base.js';
5
+ import { integrationFix, missingIntegration, missingModule } from '../lib/capabilities.js';
5
6
  import { guardProductionPush } from '../lib/guard-production-push.js';
6
7
  import { stdoutToStderr } from '../lib/json-delegation.js';
8
+ import { errorMessage } from '../lib/narrow.js';
7
9
  import { PushCommand } from '../lib/push-command.js';
8
10
  import { isLocalEnvironment } from '../lib/wp-client.js';
9
- import { wpackagistRequire } from '../utils/composer.js';
10
- import { projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
11
+ import { parseComposerJson, wpackagistRequire } from '../utils/composer.js';
12
+ import { projectHasChildThemeFiles, projectUses, resolveResourceDir } from '../utils/resource-dirs.js';
11
13
  import { globalStylesSkipNote } from '../utils/theme-styles-format.js';
12
14
  // Backed by a plugin the site may not have (ACF, a form or a snippet plugin): a project without
13
15
  // the resource's directory doesn't use it, and its push, dry run included, would only stop on the
14
16
  // site's 409 or the missing directory.
15
17
  const PLUGIN_BACKED = { 'acf:push': 'acf', 'form:push': 'form', 'snippet:push': 'snippets' };
18
+ // Features of Loopress Full only: on Light, skipped while the project doesn't use them, refused
19
+ // upfront when it does. On Full they run as usual, used or not.
20
+ const FULL_ONLY = { 'api:push': 'api', 'block:push': 'block', 'hook:push': 'hooks', 'page:push': 'page', 'pattern:push': 'pattern' };
16
21
  // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
17
22
  // relies on, then content itself.
18
23
  const PUSH_TARGETS = [
@@ -26,6 +31,8 @@ const PUSH_TARGETS = [
26
31
  { commandId: 'acf:push', label: 'ACF' },
27
32
  { commandId: 'api:push', label: 'API routes' },
28
33
  { commandId: 'hook:push', label: 'hooks' },
34
+ // Before templates and pages, which place them (`wp:loopress/<slug>`).
35
+ { commandId: 'block:push', label: 'dynamic blocks' },
29
36
  { commandId: 'form:push', label: 'forms' },
30
37
  // Before templates and pages, which can reference them (`wp:pattern`).
31
38
  { commandId: 'pattern:push', label: 'patterns' },
@@ -40,7 +47,7 @@ const PUSH_TARGETS = [
40
47
  { commandId: 'snippet:push', label: 'snippets' },
41
48
  ];
42
49
  export default class Push extends LoopressCommand {
43
- static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
50
+ static description = 'Push every local resource to WordPress in one run: plugins, composer dependencies, post types, taxonomies, post meta, roles, ACF, API routes, hooks, dynamic blocks, forms, patterns, templates and parts, Global Styles, pages, menus, options, and snippets';
44
51
  static enableJsonFlag = true;
45
52
  static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
46
53
  static flags = {
@@ -50,7 +57,6 @@ export default class Push extends LoopressCommand {
50
57
  };
51
58
  async run() {
52
59
  await this.guardProductionPush();
53
- this.warnRemovedSeoModule();
54
60
  const argv = this.buildArgv();
55
61
  const results = [];
56
62
  const skips = await this.skippedTargets();
@@ -68,7 +74,7 @@ export default class Push extends LoopressCommand {
68
74
  results.push({ label: target.label, result, status: 'pushed' });
69
75
  }
70
76
  catch (error) {
71
- const { message } = error;
77
+ const message = errorMessage(error);
72
78
  this.log(`✗ ${target.label} failed: ${message}`);
73
79
  results.push({ error: message, label: target.label, status: 'failed' });
74
80
  // Stop here: later targets depend on earlier ones (hooks use what composer installs,
@@ -81,6 +87,28 @@ export default class Push extends LoopressCommand {
81
87
  this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
82
88
  return { results };
83
89
  }
90
+ // A step the project uses on a site that can't hold it would only fail at its turn, after
91
+ // everything before it was pushed: refuse the whole run upfront, saying how to fix it. An unused
92
+ // one is skipped, saying why when the site lacks it.
93
+ applySiteSteps(steps, skips, declared) {
94
+ const blockers = [];
95
+ for (const { commandId, missing, skipUnused, used } of steps) {
96
+ if (used && missing) {
97
+ const label = PUSH_TARGETS.find((target) => target.commandId === commandId)?.label;
98
+ const fix = integrationFix(missing, declared);
99
+ const fixSentence = fix ? ` ${fix}.` : '';
100
+ blockers.push(`${label}: ${missing.reason} on ${this.siteConfig.name}.${fixSentence}`);
101
+ }
102
+ else if (!used && missing?.needsFull) {
103
+ skips.set(commandId, { note: `${missing.reason}, skipped`, status: 'skipped' });
104
+ }
105
+ else if (!used && skipUnused) {
106
+ skips.set(commandId, { note: 'not configured, skipped', status: 'skipped' });
107
+ }
108
+ }
109
+ if (blockers.length > 0)
110
+ this.error(`Nothing was pushed, the project uses what this site can't hold:\n ${blockers.join('\n ')}`);
111
+ }
84
112
  // Every delegated push always gets --yes: either this command's own guard already confirmed
85
113
  // production once above, or the target isn't production and there's nothing to confirm.
86
114
  // Without this, each delegated command would re-run its own production guard.
@@ -93,24 +121,22 @@ export default class Push extends LoopressCommand {
93
121
  argv.push('--overwrite');
94
122
  return argv;
95
123
  }
96
- // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
97
- // safety), so `composer:push` would only run Composer on the server a second time.
98
- async composerDeclaresPlugins() {
99
- const path = this.composerJsonPath();
100
- if (!existsSync(path))
101
- return false;
124
+ composerJsonPath() {
125
+ return join(process.cwd(), this.rootDir, 'composer.json');
126
+ }
127
+ // The wordpress.org plugins the project declares: composer.json's wpackagist plugins when it
128
+ // exists, loopress.json's otherwise. Unreadable or invalid composer.json: none, the commands report it.
129
+ async declaredPlugins(hasComposerJson) {
130
+ if (!hasComposerJson)
131
+ return Object.keys(this.localConfig.plugins ?? {});
102
132
  try {
103
- const composerJson = JSON.parse(await readFile(path, 'utf8'));
104
- return Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(this.siteConfig.name))).length > 0;
133
+ const composerJson = parseComposerJson(await readFile(this.composerJsonPath(), 'utf8'));
134
+ return Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(this.siteConfig.name)));
105
135
  }
106
136
  catch {
107
- // Unreadable: let both commands run and report the parse error themselves.
108
- return false;
137
+ return [];
109
138
  }
110
139
  }
111
- composerJsonPath() {
112
- return join(process.cwd(), this.rootDir, 'composer.json');
113
- }
114
140
  // The site unreachable or the theme list refused: let the step run and report it itself.
115
141
  async globalStylesSkipNote() {
116
142
  try {
@@ -136,19 +162,37 @@ export default class Push extends LoopressCommand {
136
162
  const skips = new Map();
137
163
  const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
138
164
  const hasComposerJson = existsSync(this.composerJsonPath());
139
- const composerPushedByPlugins = await this.composerDeclaresPlugins();
165
+ const declaredPlugins = await this.declaredPlugins(hasComposerJson);
166
+ // A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
167
+ // safety), so `composer:push` would only run Composer on the server a second time.
168
+ const composerPushedByPlugins = hasComposerJson && declaredPlugins.length > 0;
140
169
  if (composerPushedByPlugins)
141
170
  skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
142
171
  else if (!hasComposerJson)
143
172
  skips.set('composer:push', notConfigured);
144
173
  // With a composer.json, plugins come from it only; otherwise from loopress.json.
145
- const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
146
- if (!pluginsConfigured)
174
+ if (declaredPlugins.length === 0)
147
175
  skips.set('plugin:push', notConfigured);
148
- for (const [commandId, kind] of Object.entries(PLUGIN_BACKED)) {
149
- if (!projectUses(kind, this.localConfig))
150
- skips.set(commandId, notConfigured);
151
- }
176
+ const capabilities = await this.capabilities();
177
+ const missingDependencies = missingModule('dependencies', 'plugins and Composer', capabilities);
178
+ const steps = [
179
+ ...['plugin:push', 'composer:push'].map((commandId) => ({ commandId, missing: missingDependencies, skipUnused: false, used: !skips.has(commandId) })),
180
+ ...Object.entries(PLUGIN_BACKED).map(([commandId, kind]) => ({
181
+ commandId,
182
+ missing: missingIntegration(kind, capabilities),
183
+ skipUnused: true,
184
+ used: projectUses(kind, this.localConfig),
185
+ })),
186
+ ...Object.entries(FULL_ONLY).map(([commandId, kind]) => ({
187
+ commandId,
188
+ missing: missingIntegration(kind, capabilities),
189
+ skipUnused: false,
190
+ used: projectUses(kind, this.localConfig),
191
+ })),
192
+ // Without templates, parts or CSS, the step would only write an empty child theme.
193
+ { commandId: 'theme:template:push', missing: missingIntegration('template', capabilities), skipUnused: true, used: projectHasChildThemeFiles(this.localConfig) },
194
+ ];
195
+ this.applySiteSteps(steps, skips, { fromComposer: hasComposerJson, plugins: declaredPlugins });
152
196
  const stylesNote = await this.globalStylesSkipNote();
153
197
  if (stylesNote)
154
198
  skips.set('theme:style:push', { note: stylesNote, status: 'skipped' });
@@ -1,9 +1,11 @@
1
1
  import { Args, Command } from '@oclif/core';
2
- import { join } from 'node:path';
3
2
  import { ApiClient } from '../../lib/api-client.js';
4
3
  import { loadSnippets } from '../../lib/load-snippets.js';
4
+ import { errorMessage } from '../../lib/narrow.js';
5
5
  import { resolveLinkedProject } from '../../lib/resolve-linked-project.js';
6
+ import {} from '../../types/snippet.js';
6
7
  import { pluralize } from '../../utils/pluralize.js';
8
+ import { resolveResourceDir } from '../../utils/resource-dirs.js';
7
9
  import { toSlug } from '../../utils/to-slug.js';
8
10
  // Publishes to the Loopress api (not a WordPress site), so this does not extend
9
11
  // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
@@ -18,14 +20,16 @@ export default class Publish extends Command {
18
20
  async run() {
19
21
  const { args } = await this.parse(Publish);
20
22
  const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
21
- const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
23
+ const path = resolveResourceDir('snippets', localConfig, args.path);
22
24
  this.log(`Publishing snippets from ${path}`);
23
25
  let snippets;
24
26
  try {
25
- snippets = await loadSnippets(path);
27
+ snippets = await loadSnippets(path, (message) => {
28
+ this.warn(message);
29
+ });
26
30
  }
27
31
  catch (error) {
28
- this.error(error.message);
32
+ this.error(errorMessage(error));
29
33
  }
30
34
  const api = new ApiClient(token);
31
35
  try {
@@ -34,7 +38,7 @@ export default class Publish extends Command {
34
38
  });
35
39
  }
36
40
  catch (error) {
37
- this.error(error.message);
41
+ this.error(errorMessage(error));
38
42
  }
39
43
  this.log(`Published ${pluralize(snippets.length, 'snippet')} to your Loopress account.`);
40
44
  }
@@ -1,6 +1,6 @@
1
1
  import { type PushChange, PushCommand } from '../../lib/push-command.js';
2
2
  type PushedSnippet = {
3
- id?: number;
3
+ id?: number | undefined;
4
4
  name: string;
5
5
  };
6
6
  type PushResult = {
@@ -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,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,7 +28,7 @@ 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;
@@ -38,7 +39,11 @@ export default class Push extends PushCommand {
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,12 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { activateTheme } from '../../../lib/capabilities.js';
3
+ import {} from '../../../lib/diff-state.js';
4
+ import { errorMessage } from '../../../lib/narrow.js';
2
5
  import { logChanges, pushChanges, PushCommand } from '../../../lib/push-command.js';
3
6
  import { formatPageProblems } from '../../../utils/page-format.js';
4
7
  import { pluralize } from '../../../utils/pluralize.js';
5
- import { resolveResourceDir } from '../../../utils/resource-dirs.js';
8
+ import { projectHasChildThemeFiles, resolveResourceDir } from '../../../utils/resource-dirs.js';
6
9
  import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../../utils/template-format.js';
7
10
  import { bundleThemeCss } from '../../../utils/theme-css.js';
8
11
  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.';
12
+ 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
13
  static enableJsonFlag = true;
11
14
  static examples = ['$ lps theme template push', '$ lps theme template push --dry-run'];
12
15
  static flags = {
@@ -17,6 +20,10 @@ export default class Push extends PushCommand {
17
20
  await this.parse(Push);
18
21
  const templatesPath = resolveResourceDir('template', this.localConfig);
19
22
  const partsPath = resolveResourceDir('part', this.localConfig);
23
+ // Pushing would only create an empty child theme as a side effect.
24
+ if (!projectHasChildThemeFiles(this.localConfig)) {
25
+ this.error(`Nothing to push: no ${templatesPath}, ${partsPath}, ${resolveResourceDir('css', this.localConfig)} or ${resolveResourceDir('blockCss', this.localConfig)} directory.`);
26
+ }
20
27
  this.log(`Pushing templates and parts to ${this.siteConfig.url}`);
21
28
  this.log(`Templates path: ${templatesPath}, parts path: ${partsPath}`);
22
29
  const { parts, problems, templates } = await readLocalTemplates(templatesPath, partsPath);
@@ -37,13 +44,25 @@ export default class Push extends PushCommand {
37
44
  logChanges((message) => { this.log(message); }, changes.templates, 'templates/');
38
45
  logChanges((message) => { this.log(message); }, changes.parts, 'parts/');
39
46
  logChanges((message) => { this.log(message); }, changes.css, 'css/');
47
+ if (this.localConfig.activateChildTheme) {
48
+ const { child } = (await this.capabilities()).theme;
49
+ if (child && !child.active)
50
+ this.log(`[dry-run] Would activate ${child.stylesheet}.`);
51
+ }
40
52
  return { ...names, changes, status: 'dry-run' };
41
53
  }
42
54
  const child = await this.wp.put(CHILD_THEME_ENDPOINT, { ...css, parts, templates });
43
55
  await this.recordSuccess();
44
56
  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.`);
57
+ // Activated only when the project asks for it: a theme switch changes every per-theme setting.
58
+ let { active } = child;
59
+ if (!active && this.localConfig.activateChildTheme) {
60
+ const activation = await activateTheme(this.wp, child.stylesheet);
61
+ active = true;
62
+ this.log(`Activated ${child.stylesheet} (was ${activation.previous}), as loopress.json's activateChildTheme asks.`);
63
+ }
64
+ if (!active) {
65
+ 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
66
  }
48
67
  if (child.customized.length > 0) {
49
68
  this.warn(`Edited in the Site Editor, their database copy still wins over the pushed file: ${child.customized.join(', ')}. ` +
@@ -57,7 +76,7 @@ export default class Push extends PushCommand {
57
76
  }
58
77
  return {
59
78
  ...names,
60
- active: child.active,
79
+ active,
61
80
  customized: child.customized,
62
81
  status: 'success',
63
82
  stylesheet: child.stylesheet,
@@ -71,7 +90,7 @@ export default class Push extends PushCommand {
71
90
  child = await this.wp.get(CHILD_THEME_ENDPOINT);
72
91
  }
73
92
  catch (error) {
74
- this.error(`The site would refuse this push: ${error.message}`);
93
+ this.error(`The site would refuse this push: ${errorMessage(error)}`);
75
94
  }
76
95
  const localCss = new Map(css.blocks.map((block) => [`blocks/${block.name}.css`, sha256(block.css)]));
77
96
  if (css.style !== '')
@@ -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 & {