@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
@@ -4,14 +4,18 @@ import { createHash } from 'node:crypto';
4
4
  import { mkdir, writeFile } from 'node:fs/promises';
5
5
  import { dirname, join, relative, sep } from 'node:path';
6
6
  import { authManager } from '../config/auth.manager.js';
7
+ import {} from '../types/config.js';
7
8
  import { firstTopLevelSideEffect, startsWithStrictTypes } from '../utils/php-top-level.js';
8
9
  import { pluralize } from '../utils/pluralize.js';
9
10
  import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
10
11
  import { ApiClient } from './api-client.js';
11
12
  import { LoopressCommand } from './base.js';
13
+ import {} from './diff-state.js';
14
+ import { isExcluded } from './exclude.js';
12
15
  import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
13
16
  import { isInteractive } from './interactive.js';
14
17
  import { loadFiles as loadDirectoryFiles } from './load-files.js';
18
+ import { errorMessage } from './narrow.js';
15
19
  import { PushCommand } from './push-command.js';
16
20
  import { getResourceStateProvider } from './resource-state.js';
17
21
  import { isApplicative404 } from './wp-client.js';
@@ -82,7 +86,7 @@ export function resourcePushCommand(spec) {
82
86
  static enableJsonFlag = true;
83
87
  static examples = [
84
88
  `$ lps ${spec.cliName} push`,
85
- `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
89
+ `$ lps ${spec.cliName} push ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
86
90
  `$ lps ${spec.cliName} push --prune`,
87
91
  ];
88
92
  static flags = {
@@ -116,7 +120,7 @@ export function resourcePushCommand(spec) {
116
120
  // confirmation), but their removal itself happens inside the same atomic swap as the push
117
121
  // below, not as separate DELETE calls, so a mid-push failure can't remove server-side
118
122
  // files without also landing the new ones.
119
- const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
123
+ const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename)), path) : [];
120
124
  if (this.dryRun) {
121
125
  await this.writeAfterPushSnapshot(provider, path, beforeState);
122
126
  return { changes: this.previewChanges, pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
@@ -158,14 +162,14 @@ export function resourcePushCommand(spec) {
158
162
  result = await this.wp.post(`${spec.endpoint}/batch`, body);
159
163
  }
160
164
  catch (error) {
161
- this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error.message}`);
165
+ this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${errorMessage(error)}`);
162
166
  }
163
167
  for (const file of result.files) {
164
168
  this.log(file.syntax_check === 'skipped'
165
169
  ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
166
170
  : `Pushed: ${file.filename}`);
167
171
  if (file.public) {
168
- this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
172
+ this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://docs.loopress.dev/api/routes/#authentication-and-permissions`);
169
173
  }
170
174
  }
171
175
  for (const filename of result.pruned)
@@ -178,9 +182,10 @@ export function resourcePushCommand(spec) {
178
182
  // local path in CI must not silently wipe production routes, and unlike `pull`'s local
179
183
  // deletions these are not recoverable from the repo. Only decides *which* filenames to
180
184
  // prune; pushBatch() is what actually removes them, atomically alongside the push.
181
- async resolvePruneList(localFilenames) {
185
+ // A file excluded from this environment (loopress.json) is left alone, never pruned.
186
+ async resolvePruneList(localFilenames, path) {
182
187
  const remote = await this.wp.get(spec.endpoint);
183
- const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
188
+ const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name) && !isExcluded(join(path, `${name}.php`)));
184
189
  if (orphans.length === 0)
185
190
  return [];
186
191
  const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
@@ -216,7 +221,7 @@ export function resourcePullCommand(spec) {
216
221
  static args = pathArg(spec.pathNoun);
217
222
  static description = spec.pullDescription;
218
223
  static enableJsonFlag = true;
219
- static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
224
+ static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
220
225
  static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
221
226
  async run() {
222
227
  const { args } = await this.parse(ResourcePull);
@@ -294,7 +299,7 @@ export function resourceListCommand(spec) {
294
299
  }
295
300
  export function resourceRmCommand(spec) {
296
301
  class ResourceRm extends LoopressCommand {
297
- // `remove` matches `lps app remove` and `lps option remove`, `rm` stays for existing scripts.
302
+ // `rm` is canonical everywhere, `remove` stays an alias for existing scripts.
298
303
  static aliases = [`${spec.cliName}:remove`];
299
304
  static args = {
300
305
  filename: Args.string({
@@ -6,4 +6,3 @@ import { type WpClient } from './wp-client.js';
6
6
  * see Loopress Full itself.
7
7
  */
8
8
  export declare function isLoopressFullActive(wp: WpClient): Promise<boolean>;
9
- export declare const INTEGRATION_MISSING: RegExp;
@@ -1,3 +1,5 @@
1
+ import {} from '../types/plugin.js';
2
+ import {} from './wp-client.js';
1
3
  const FULL_PLUGIN_PREFIX = 'loopress-full/';
2
4
  /**
3
5
  * Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
@@ -9,7 +11,3 @@ export async function isLoopressFullActive(wp) {
9
11
  const plugins = await wp.get('wp/v2/plugins');
10
12
  return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
11
13
  }
12
- // How the Loopress plugin reports a resource's integration missing: "ACF is not active", "No
13
- // supported form plugin is active", "No supported snippet plugin is active". Global Styles say a
14
- // classic theme is active. The aggregate commands skip such a resource the project doesn't use.
15
- export const INTEGRATION_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b|\bis a classic theme\b/;
@@ -1,5 +1,6 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  import { type ResourceState } from './diff-state.js';
3
+ import { type ScannedItem } from './environment-urls.js';
3
4
  import { type ResourceStateProvider } from './resource-state.js';
4
5
  export type PushChange = {
5
6
  id: string;
@@ -7,7 +8,7 @@ export type PushChange = {
7
8
  };
8
9
  export declare function pushChanges(local: ResourceState, remote: ResourceState, options?: {
9
10
  mirror?: boolean;
10
- writes?: (id: string) => boolean;
11
+ writes?: ((id: string) => boolean) | undefined;
11
12
  }): PushChange[];
12
13
  export declare function logChanges(log: (message: string) => void, changes: Array<{
13
14
  id: string;
@@ -19,6 +20,7 @@ export declare abstract class PushCommand extends LoopressCommand {
19
20
  'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
21
  env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
22
  };
23
+ static honorsExclude: boolean;
22
24
  protected failedCount: number;
23
25
  protected previewChanges: PushChange[];
24
26
  protected previewStates?: {
@@ -28,6 +30,7 @@ export declare abstract class PushCommand extends LoopressCommand {
28
30
  private refusedByGuard;
29
31
  protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
30
32
  catch(err: Error): Promise<void>;
33
+ finally(error: Error | undefined): Promise<void>;
31
34
  protected guardProductionPush(): Promise<void>;
32
35
  init(): Promise<void>;
33
36
  protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
@@ -40,6 +43,7 @@ export declare abstract class PushCommand extends LoopressCommand {
40
43
  protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
41
44
  output: string;
42
45
  }) => Promise<void>): Promise<void>;
46
+ protected warnEnvironmentUrls(items: ScannedItem[]): void;
43
47
  protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
44
48
  private guardOutsideEdits;
45
49
  }
@@ -2,15 +2,37 @@ import { confirm } from '@inquirer/prompts';
2
2
  import { Flags } from '@oclif/core';
3
3
  import got from 'got';
4
4
  import { Listr } from 'listr2';
5
+ import { execFileSync } from 'node:child_process';
5
6
  import { resolve } from 'node:path';
6
7
  import { isDeepStrictEqual } from 'node:util';
7
8
  import { authManager } from '../config/auth.manager.js';
9
+ import {} from '../types/config.js';
8
10
  import { API_URL } from './api-client.js';
9
11
  import { LoopressCommand } from './base.js';
12
+ import {} from './diff-state.js';
13
+ import { environmentUrlWarnings } from './environment-urls.js';
14
+ import { heldBackPaths } from './exclude.js';
10
15
  import { guardProductionPush } from './guard-production-push.js';
11
16
  import { isInteractive } from './interactive.js';
17
+ import { errorMessage } from './narrow.js';
18
+ import {} from './resource-state.js';
12
19
  import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
13
20
  import { withoutSpinner, withSpinner } from './spinner.js';
21
+ // The Git state the deployment record carries: the HEAD commit and whether the working tree has
22
+ // uncommitted changes. Empty outside a Git repository (or without git), so nothing is sent then.
23
+ function gitState() {
24
+ try {
25
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- git is resolved from PATH like in every dev tool
26
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
27
+ const commit = git(['rev-parse', 'HEAD']);
28
+ if (!commit)
29
+ return {};
30
+ return { commit, dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '' };
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
14
36
  export function pushChanges(local, remote, options = {}) {
15
37
  const changes = [...local.keys()]
16
38
  .filter((id) => options.writes?.(id) ?? true)
@@ -40,6 +62,7 @@ export class PushCommand extends LoopressCommand {
40
62
  }),
41
63
  'production-confirmed': Flags.boolean({ hidden: true }),
42
64
  };
65
+ static honorsExclude = true;
43
66
  failedCount = 0;
44
67
  // A dry run's per-item preview, set by captureBeforePushState: what the push would create,
45
68
  // change or leave as it is on the site.
@@ -72,7 +95,7 @@ export class PushCommand extends LoopressCommand {
72
95
  catch (error) {
73
96
  // The push reports the unreadable files itself, but an empty `changes` must not read as
74
97
  // "nothing changes".
75
- this.warn(`Could not list what this push would change: ${error.message}`);
98
+ this.warn(`Could not list what this push would change: ${errorMessage(error)}`);
76
99
  }
77
100
  logChanges((message) => { this.log(message); }, this.previewChanges);
78
101
  return undefined;
@@ -84,18 +107,29 @@ export class PushCommand extends LoopressCommand {
84
107
  }, dir);
85
108
  }
86
109
  catch (error) {
87
- this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
110
+ this.warn(`Could not read remote state for a rollback snapshot: ${errorMessage(error)}`);
88
111
  return undefined;
89
112
  }
90
113
  await this.guardOutsideEdits(provider, dir, remote, writes);
91
114
  return remote;
92
115
  }
93
116
  async catch(err) {
117
+ // Unset when init() failed before resolving the environment, despite its declared type.
94
118
  if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
95
119
  await this.recordDeployment('failure');
96
120
  }
97
121
  await super.catch(err);
98
122
  }
123
+ // Lists what the environment's `exclude` globs (loopress.json) kept out of this push, so a
124
+ // feature held back from it is never forgotten there.
125
+ async finally(error) {
126
+ const paths = error ? [] : heldBackPaths();
127
+ if (paths.length > 0) {
128
+ const prefix = this.dryRun ? '[dry-run] ' : '';
129
+ this.log(`${prefix}Held back from ${this.siteConfig.name} (exclude in loopress.json): ${paths.join(', ')}`);
130
+ }
131
+ await super.finally(error);
132
+ }
99
133
  async guardProductionPush() {
100
134
  await guardProductionPush({
101
135
  dryRun: this.dryRun,
@@ -120,7 +154,7 @@ export class PushCommand extends LoopressCommand {
120
154
  return await provider.remote(this.wp, () => { }, dir);
121
155
  }
122
156
  catch (error) {
123
- this.error(`The site would refuse this push: ${error.message}`);
157
+ this.error(`The site would refuse this push: ${errorMessage(error)}`);
124
158
  }
125
159
  }
126
160
  async recordDeployment(status) {
@@ -130,7 +164,7 @@ export class PushCommand extends LoopressCommand {
130
164
  try {
131
165
  await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
132
166
  headers: { Authorization: `Bearer ${token}` },
133
- json: { status, url: this.siteConfig.url },
167
+ json: { status, url: this.siteConfig.url, command: this.id, ...gitState() },
134
168
  timeout: { request: 3000 },
135
169
  }));
136
170
  }
@@ -186,12 +220,19 @@ export class PushCommand extends LoopressCommand {
186
220
  })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
187
221
  });
188
222
  }
223
+ // Absolute URLs to another environment of the project in what this push sends: warned about,
224
+ // never rewritten, on a dry run too.
225
+ warnEnvironmentUrls(items) {
226
+ const prefix = this.dryRun ? '[dry-run] ' : '';
227
+ for (const warning of environmentUrlWarnings(items, this.siteConfig, this.projectEnvironments))
228
+ this.warn(`${prefix}${warning}`);
229
+ }
189
230
  // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
190
231
  // with a fresh remote read taken right after the push, and writes both as one rollback
191
232
  // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
192
233
  // the local files that were pushed, is what keeps this correct for every resource's own
193
234
  // partial-push semantics: a readonly option skipped by `option push`, a snippet or form
194
- // that gets a server-assigned id on create, a theme-styles push that only touches the
235
+ // that gets a server-assigned id on create, a theme-style push that only touches the
195
236
  // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
196
237
  // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
197
238
  // against exactly this after-state, so it has to be what actually landed, not what was sent.
@@ -211,7 +252,7 @@ export class PushCommand extends LoopressCommand {
211
252
  });
212
253
  }
213
254
  catch (error) {
214
- this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
255
+ this.warn(`Could not save a rollback snapshot after this push: ${errorMessage(error)}`);
215
256
  }
216
257
  }
217
258
  // Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
@@ -247,7 +288,7 @@ export class PushCommand extends LoopressCommand {
247
288
  local = await provider.local(dir, () => { }, this.wp);
248
289
  }
249
290
  catch (error) {
250
- this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${error.message}. Retry, or pass --overwrite.`);
291
+ this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${errorMessage(error)}. Retry, or pass --overwrite.`);
251
292
  }
252
293
  const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
253
294
  remote.get(id) !== undefined &&
@@ -6,8 +6,8 @@ type PutOrCreateOptions = {
6
6
  postPayload?: Record<string, unknown>;
7
7
  putEndpoint: (id: number) => string;
8
8
  };
9
- export declare function putOrCreate<T>(wp: WpClient, options: PutOrCreateOptions): Promise<{
10
- body: T;
9
+ export declare function putOrCreate(wp: WpClient, options: PutOrCreateOptions): Promise<{
10
+ body: Record<string, unknown>;
11
11
  created: boolean;
12
12
  }>;
13
13
  export {};
@@ -1,13 +1,18 @@
1
+ import {} from 'node:fs';
1
2
  import { readdir, stat } from 'node:fs/promises';
2
- import { join, relative, sep } from 'node:path';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { withoutExcluded } from './exclude.js';
5
+ import { errorCode } from './narrow.js';
3
6
  export async function readdirTolerant(dir, options) {
4
7
  try {
8
+ // Excluded paths (lib/exclude.ts) are hidden here, so every resource listing through this
9
+ // helper leaves them out of a push or a diff on the environment that excludes them.
5
10
  return options?.withFileTypes
6
- ? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
7
- : await readdir(dir, { recursive: options?.recursive });
11
+ ? withoutExcluded(dir, await readdir(dir, { recursive: options.recursive, withFileTypes: true }), (entry) => resolve(entry.parentPath, entry.name))
12
+ : withoutExcluded(dir, await readdir(dir, { recursive: options?.recursive }), (entry) => entry);
8
13
  }
9
14
  catch (error) {
10
- if (error.code === 'ENOENT')
15
+ if (errorCode(error) === 'ENOENT')
11
16
  return [];
12
17
  throw error;
13
18
  }
@@ -0,0 +1,9 @@
1
+ import { Flags } from '@oclif/core';
2
+ export declare const RESOURCE_IDS: readonly ["plugin", "composer", "cpt", "taxonomy", "meta", "role", "acf", "api", "hook", "block", "form", "pattern", "template", "part", "css", "theme-style", "page", "menu", "option", "snippet"];
3
+ export type ResourceId = (typeof RESOURCE_IDS)[number];
4
+ export declare function selectionFlags(noun: string, valid: readonly string[]): {
5
+ only: ReturnType<typeof Flags.string>;
6
+ skip: ReturnType<typeof Flags.string>;
7
+ };
8
+ export declare function parseSelectionFlag(raw: string[] | undefined, flagName: 'only' | 'skip', valid: readonly string[], fail: (message: string) => never): string[];
9
+ export declare function selectResources<T extends string>(all: readonly T[], only?: readonly string[], skip?: readonly string[]): T[];
@@ -0,0 +1,49 @@
1
+ import { Flags } from '@oclif/core';
2
+ // One singular id per resource, shared by every command taking `--only` / `--skip`
3
+ // (`lps dev`, `lps diff`, `lps push`). Each command accepts the subset it can act on.
4
+ export const RESOURCE_IDS = [
5
+ 'plugin',
6
+ 'composer',
7
+ 'cpt',
8
+ 'taxonomy',
9
+ 'meta',
10
+ 'role',
11
+ 'acf',
12
+ 'api',
13
+ 'hook',
14
+ 'block',
15
+ 'form',
16
+ 'pattern',
17
+ 'template',
18
+ 'part',
19
+ 'css',
20
+ 'theme-style',
21
+ 'page',
22
+ 'menu',
23
+ 'option',
24
+ 'snippet',
25
+ ];
26
+ // Comma-separated or repeated: `--only a,b`, `--only a --only b`, or both.
27
+ export function selectionFlags(noun, valid) {
28
+ return {
29
+ only: Flags.string({ description: `Only ${noun} these resources (comma-separated or repeated): ${valid.join(', ')}`, multiple: true }),
30
+ skip: Flags.string({ description: `${noun.charAt(0).toUpperCase()}${noun.slice(1)} every resource except these (comma-separated or repeated)`, multiple: true }),
31
+ };
32
+ }
33
+ // Splits the raw flag values and rejects any id outside `valid`.
34
+ export function parseSelectionFlag(raw, flagName, valid, fail) {
35
+ return (raw ?? [])
36
+ .flatMap((value) => value.split(','))
37
+ .map((value) => value.trim())
38
+ .filter(Boolean)
39
+ .map((value) => {
40
+ if (!valid.includes(value))
41
+ fail(`Unknown resource "${value}" in --${flagName}. Valid resources: ${valid.join(', ')}`);
42
+ return value;
43
+ });
44
+ }
45
+ // `only` narrows `all`, `skip` then removes from whatever that is, so a combined
46
+ // `--only=a,b --skip=b` reads as: a, b, then drop b. The result always follows `all`'s order.
47
+ export function selectResources(all, only = [], skip = []) {
48
+ return all.filter((id) => (only.length === 0 || only.includes(id)) && !skip.includes(id));
49
+ }
@@ -3,6 +3,8 @@ import { type ResourceState } from './diff-state.js';
3
3
  import { type WpClient } from './wp-client.js';
4
4
  export type ResourceStateProvider = {
5
5
  dirKind: ResourceDirKind;
6
+ fileExtension?: string;
7
+ fileName?(id: string): string | undefined;
6
8
  local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
7
9
  remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
10
  resource: string;
@@ -1,20 +1,24 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
4
+ import { blockFilesToPush, BLOCKS_ENDPOINT, excludedBlockFiles, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
4
5
  import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
5
6
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
6
7
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
7
8
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
8
9
  import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
10
+ import {} from '../utils/resource-dirs.js';
9
11
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
10
12
  import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
11
13
  import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
14
+ import {} from './diff-state.js';
12
15
  import { loadFiles } from './load-files.js';
13
16
  import { loadSnippets } from './load-snippets.js';
17
+ import { causeOf, errorCode, isRecord } from './narrow.js';
14
18
  import { isApplicative404, isNotFoundError } from './wp-client.js';
15
19
  function readJson(raw) {
16
20
  const parsed = JSON.parse(raw);
17
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
18
22
  throw new Error('not a JSON object');
19
23
  }
20
24
  return parsed;
@@ -59,7 +63,7 @@ const snippetProvider = {
59
63
  catch (error) {
60
64
  // A directory that was never created is "nothing tracked locally", the same tolerance
61
65
  // loadFiles/readdirTolerant already have; loadSnippets wraps the ENOENT in its own Error.
62
- if (error.cause?.code === 'ENOENT')
66
+ if (errorCode(causeOf(error)) === 'ENOENT')
63
67
  return state;
64
68
  throw error;
65
69
  }
@@ -87,6 +91,7 @@ const snippetProvider = {
87
91
  // stance as form-format.ts.
88
92
  const formProvider = {
89
93
  dirKind: 'form',
94
+ fileExtension: '.json',
90
95
  async local(dir, onWarn) {
91
96
  const entries = await loadFiles(dir, {
92
97
  extension: '.json',
@@ -133,6 +138,7 @@ async function normalizeAcf(wp, type, objects) {
133
138
  }
134
139
  const acfProvider = {
135
140
  dirKind: 'acf',
141
+ fileExtension: '.json',
136
142
  async local(dir, onWarn, wp) {
137
143
  const state = new Map();
138
144
  for (const type of ACF_OBJECT_TYPES) {
@@ -166,20 +172,21 @@ const acfProvider = {
166
172
  title: 'ACF',
167
173
  };
168
174
  // ---- PHP file directories (API routes, hooks) -----------------------------------------
175
+ function filesToState(files) {
176
+ const state = new Map();
177
+ for (const file of files)
178
+ state.set(file.filename, file.content);
179
+ return state;
180
+ }
169
181
  // api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
170
182
  // verbatim (line diff on change), no server-side normalization to undo. Only the directory
171
183
  // kind, the endpoint and the labels differ.
172
184
  function phpFilesProvider(spec) {
173
- const toState = (files) => {
174
- const state = new Map();
175
- for (const file of files)
176
- state.set(file.filename, file.content);
177
- return state;
178
- };
179
185
  return {
180
186
  dirKind: spec.dirKind,
187
+ fileExtension: '.php',
181
188
  async local(dir, onWarn) {
182
- return toState(await loadFiles(dir, {
189
+ return filesToState(await loadFiles(dir, {
183
190
  extension: '.php',
184
191
  onSkip: onWarn,
185
192
  parse: (raw, filePath) => ({
@@ -190,7 +197,7 @@ function phpFilesProvider(spec) {
190
197
  }));
191
198
  },
192
199
  async remote(wp) {
193
- return toState(await wp.get(spec.endpoint));
200
+ return filesToState(await wp.get(spec.endpoint));
194
201
  },
195
202
  resource: spec.resource,
196
203
  title: spec.title,
@@ -208,6 +215,30 @@ const hookProvider = phpFilesProvider({
208
215
  resource: 'hook',
209
216
  title: 'Hooks',
210
217
  });
218
+ // ---- Dynamic blocks -------------------------------------------------------------------
219
+ // Keyed by slug, compared on the whole folder (file name => text). A local problem fails the
220
+ // resource, the same stance as pages: pushing would stop on it anyway.
221
+ const blockProvider = {
222
+ dirKind: 'block',
223
+ // A block is a folder: only the bare `blocks/<slug>` path names a site-only block.
224
+ fileExtension: '',
225
+ // A file the environment excludes inside a block folder keeps the site's copy, as the push
226
+ // sends it back: without `wp`, that file is just left out.
227
+ async local(dir, _onWarn, wp) {
228
+ const { blocks, problems } = await readLocalBlocks(dir);
229
+ if (problems.length > 0)
230
+ throw new Error(formatBlockProblems(problems));
231
+ const withExcluded = wp !== undefined && blocks.some((block) => excludedBlockFiles(dir, block.slug).length > 0);
232
+ const site = withExcluded ? new Map((await wp.get(BLOCKS_ENDPOINT)).map((block) => [block.slug, block.files])) : undefined;
233
+ return new Map(blocks.map((block) => [block.slug, site ? blockFilesToPush(dir, block, site.get(block.slug)) : block.files]));
234
+ },
235
+ async remote(wp) {
236
+ const blocks = await wp.get(BLOCKS_ENDPOINT);
237
+ return new Map(blocks.map((block) => [block.slug, block.files]));
238
+ },
239
+ resource: 'block',
240
+ title: 'Dynamic blocks',
241
+ };
211
242
  // ---- Pages ------------------------------------------------------------------------------
212
243
  // Keyed by slug (the page's identity on both sides). `link` is server-derived and never
213
244
  // compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
@@ -240,13 +271,16 @@ async function childThemeState(wp, kind) {
240
271
  const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
241
272
  for (const entry of child.customized) {
242
273
  const [dir, slug] = entry.split('/', 2);
243
- if (dir === kind)
244
- state.set(slug, { ...state.get(slug), siteEditor: 'customized' });
274
+ if (dir === kind && slug !== undefined) {
275
+ const existing = state.get(slug);
276
+ state.set(slug, { ...(isRecord(existing) && existing), siteEditor: 'customized' });
277
+ }
245
278
  }
246
279
  return state;
247
280
  }
248
281
  const templateProvider = {
249
282
  dirKind: 'template',
283
+ fileExtension: '.html',
250
284
  async local(dir) {
251
285
  const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile, TEMPLATE_SLUG_RULE);
252
286
  if (problems.length > 0)
@@ -259,6 +293,7 @@ const templateProvider = {
259
293
  };
260
294
  const partProvider = {
261
295
  dirKind: 'part',
296
+ fileExtension: '.html',
262
297
  async local(dir) {
263
298
  const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile, TEMPLATE_SLUG_RULE);
264
299
  if (problems.length > 0)
@@ -287,6 +322,12 @@ function canonicalMenu(menu) {
287
322
  }
288
323
  const menuProvider = {
289
324
  dirKind: 'menu',
325
+ fileExtension: '.json',
326
+ fileName(id) {
327
+ if (id === MENU_LOCATIONS_FILE)
328
+ return `${id}.json`;
329
+ return id.startsWith('menu/') ? `${id.slice('menu/'.length)}.json` : undefined;
330
+ },
290
331
  async local(dir, onWarn) {
291
332
  const state = new Map();
292
333
  const entries = await loadFiles(dir, {
@@ -324,6 +365,7 @@ const menuProvider = {
324
365
  function declaredTypeProvider(spec, title) {
325
366
  return {
326
367
  dirKind: spec.dirKind,
368
+ fileExtension: fileExtension(spec),
327
369
  async local(dir, onWarn) {
328
370
  const entries = await loadFiles(dir, {
329
371
  extension: fileExtension(spec),
@@ -353,6 +395,7 @@ const patternProvider = declaredTypeProvider(PATTERN_SPEC, 'Patterns');
353
395
  // sides so it can never itself show up as drift.
354
396
  const optionsProvider = {
355
397
  dirKind: 'options',
398
+ fileExtension: '.json',
356
399
  async local(dir, onWarn) {
357
400
  const entries = await loadFiles(dir, {
358
401
  extension: '.json',
@@ -398,6 +441,8 @@ const optionsProvider = {
398
441
  // individually rather than colliding on a single key.
399
442
  const themeStylesProvider = {
400
443
  dirKind: 'themeStyles',
444
+ fileExtension: '.json',
445
+ fileName: (stylesheet) => `${stylesheet}${THEME_STYLES_FILE_SUFFIX}`,
401
446
  async local(dir, onWarn) {
402
447
  const entries = await loadFiles(dir, {
403
448
  extension: '.json',
@@ -421,7 +466,7 @@ const themeStylesProvider = {
421
466
  const item = await wp.get(globalStylesEndpoint(id));
422
467
  return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
423
468
  },
424
- resource: 'theme-styles',
469
+ resource: 'theme-style',
425
470
  title: 'Theme styles',
426
471
  };
427
472
  export const RESOURCE_STATE_PROVIDERS = [
@@ -430,6 +475,7 @@ export const RESOURCE_STATE_PROVIDERS = [
430
475
  acfProvider,
431
476
  apiProvider,
432
477
  hookProvider,
478
+ blockProvider,
433
479
  templateProvider,
434
480
  partProvider,
435
481
  pageProvider,
@@ -476,7 +522,7 @@ export async function composerLocalState(rootDir) {
476
522
  state.set(file, await readFile(join(rootDir, file), 'utf8'));
477
523
  }
478
524
  catch (error) {
479
- if (error.code !== 'ENOENT')
525
+ if (errorCode(error) !== 'ENOENT')
480
526
  throw error;
481
527
  }
482
528
  }
@@ -1,7 +1,7 @@
1
1
  import { LoopressCommand } from './base.js';
2
2
  import { type SnapshotSummary } from './snapshot-store.js';
3
3
  export type RollbackResult = {
4
- drift?: {
4
+ drift?: undefined | {
5
5
  added: string[];
6
6
  changed: string[];
7
7
  removed: string[];
@@ -9,6 +9,7 @@ import { LoopressCommand } from './base.js';
9
9
  import { compareStates, isEmptyDiff } from './diff-state.js';
10
10
  import { guardProductionPush } from './guard-production-push.js';
11
11
  import { isInteractive } from './interactive.js';
12
+ import { errorMessage } from './narrow.js';
12
13
  import { getResourceStateProvider } from './resource-state.js';
13
14
  import { materializeSnapshot } from './rollback-materialize.js';
14
15
  import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
@@ -33,7 +34,7 @@ function indentPatch(patch) {
33
34
  // anything, this runs the equivalent of `lps <resource> diff` between the environment's current
34
35
  // state and the state the original push expected to leave behind, and requires explicit
35
36
  // confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
36
- // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
37
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
37
38
  export function resourceRollbackCommand(resource, options) {
38
39
  const command = options.command ?? resource;
39
40
  class ResourceRollback extends LoopressCommand {
@@ -69,7 +70,7 @@ export function resourceRollbackCommand(resource, options) {
69
70
  snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
70
71
  }
71
72
  catch (error) {
72
- this.error(error.message);
73
+ this.error(errorMessage(error));
73
74
  }
74
75
  await guardProductionPush({
75
76
  dryRun: this.dryRun,
@@ -140,7 +141,7 @@ export function resourceRollbackCommand(resource, options) {
140
141
  // A cleanup failure here must never hide a real error from the push above: warn about
141
142
  // it and let the original error (if any) keep propagating instead of being replaced.
142
143
  await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
143
- this.warn(`Could not remove the temporary directory "${tmpDir}": ${error.message}`);
144
+ this.warn(`Could not remove the temporary directory "${tmpDir}": ${errorMessage(error)}`);
144
145
  });
145
146
  }
146
147
  this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);