@loopress/cli 0.29.0 → 0.30.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 (132) hide show
  1. package/README.md +1301 -345
  2. package/dist/commands/cpt/diff.js +5 -0
  3. package/dist/commands/cpt/list.d.ts +2 -0
  4. package/dist/commands/cpt/list.js +4 -0
  5. package/dist/commands/cpt/pull.d.ts +2 -0
  6. package/dist/commands/cpt/pull.js +4 -0
  7. package/dist/commands/cpt/push.d.ts +2 -0
  8. package/dist/commands/cpt/push.js +4 -0
  9. package/dist/commands/cpt/rm.d.ts +2 -0
  10. package/dist/commands/cpt/rm.js +3 -0
  11. package/dist/commands/cpt/rollback.js +5 -0
  12. package/dist/commands/dev.js +14 -4
  13. package/dist/commands/diff.js +1 -1
  14. package/dist/commands/doctor.d.ts +1 -0
  15. package/dist/commands/doctor.js +30 -2
  16. package/dist/commands/meta/diff.d.ts +2 -0
  17. package/dist/commands/meta/diff.js +5 -0
  18. package/dist/commands/meta/list.d.ts +19 -0
  19. package/dist/commands/meta/list.js +21 -0
  20. package/dist/commands/meta/pull.d.ts +2 -0
  21. package/dist/commands/meta/pull.js +4 -0
  22. package/dist/commands/meta/push.d.ts +2 -0
  23. package/dist/commands/meta/push.js +4 -0
  24. package/dist/commands/meta/rm.d.ts +2 -0
  25. package/dist/commands/meta/rm.js +3 -0
  26. package/dist/commands/meta/rollback.d.ts +2 -0
  27. package/dist/commands/meta/rollback.js +5 -0
  28. package/dist/commands/option/list.js +2 -8
  29. package/dist/commands/pattern/diff.d.ts +2 -0
  30. package/dist/commands/pattern/diff.js +5 -0
  31. package/dist/commands/pattern/list.d.ts +2 -0
  32. package/dist/commands/pattern/list.js +3 -0
  33. package/dist/commands/pattern/pull.d.ts +2 -0
  34. package/dist/commands/pattern/pull.js +4 -0
  35. package/dist/commands/pattern/push.d.ts +2 -0
  36. package/dist/commands/pattern/push.js +4 -0
  37. package/dist/commands/pattern/rm.d.ts +2 -0
  38. package/dist/commands/pattern/rm.js +3 -0
  39. package/dist/commands/pattern/rollback.d.ts +2 -0
  40. package/dist/commands/pattern/rollback.js +5 -0
  41. package/dist/commands/plugin/audit.js +2 -1
  42. package/dist/commands/project/config.d.ts +1 -0
  43. package/dist/commands/project/config.js +20 -1
  44. package/dist/commands/project/list.d.ts +1 -1
  45. package/dist/commands/project/list.js +6 -6
  46. package/dist/commands/project/pull.js +11 -8
  47. package/dist/commands/project/push.js +23 -18
  48. package/dist/commands/project/remove.js +1 -1
  49. package/dist/commands/project/switch.d.ts +0 -1
  50. package/dist/commands/project/switch.js +16 -36
  51. package/dist/commands/pull.js +7 -2
  52. package/dist/commands/push.js +9 -2
  53. package/dist/commands/role/diff.d.ts +2 -0
  54. package/dist/commands/role/diff.js +5 -0
  55. package/dist/commands/role/list.d.ts +2 -0
  56. package/dist/commands/role/list.js +4 -0
  57. package/dist/commands/role/pull.d.ts +2 -0
  58. package/dist/commands/role/pull.js +4 -0
  59. package/dist/commands/role/push.d.ts +2 -0
  60. package/dist/commands/role/push.js +4 -0
  61. package/dist/commands/role/rm.d.ts +2 -0
  62. package/dist/commands/role/rm.js +3 -0
  63. package/dist/commands/role/rollback.d.ts +2 -0
  64. package/dist/commands/role/rollback.js +5 -0
  65. package/dist/commands/status.d.ts +2 -3
  66. package/dist/commands/status.js +21 -53
  67. package/dist/commands/taxonomy/diff.d.ts +2 -0
  68. package/dist/commands/taxonomy/diff.js +5 -0
  69. package/dist/commands/taxonomy/list.d.ts +2 -0
  70. package/dist/commands/taxonomy/list.js +5 -0
  71. package/dist/commands/taxonomy/pull.d.ts +2 -0
  72. package/dist/commands/taxonomy/pull.js +4 -0
  73. package/dist/commands/taxonomy/push.d.ts +2 -0
  74. package/dist/commands/taxonomy/push.js +4 -0
  75. package/dist/commands/taxonomy/rm.d.ts +2 -0
  76. package/dist/commands/taxonomy/rm.js +3 -0
  77. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  78. package/dist/commands/taxonomy/rollback.js +5 -0
  79. package/dist/commands/theme/template/push.d.ts +2 -0
  80. package/dist/commands/theme/template/push.js +26 -5
  81. package/dist/config/project-config.manager.d.ts +3 -3
  82. package/dist/config/project-config.manager.js +22 -31
  83. package/dist/hooks/finally.js +11 -3
  84. package/dist/lib/api-client.js +2 -1
  85. package/dist/lib/app-build.js +3 -1
  86. package/dist/lib/base.d.ts +1 -2
  87. package/dist/lib/base.js +30 -38
  88. package/dist/lib/declared-type-command.d.ts +35 -0
  89. package/dist/lib/declared-type-command.js +192 -0
  90. package/dist/lib/dev-watch.d.ts +1 -1
  91. package/dist/lib/dev-watch.js +7 -3
  92. package/dist/lib/github-release.js +4 -0
  93. package/dist/lib/local-callback-server.js +3 -1
  94. package/dist/lib/push-command.d.ts +1 -0
  95. package/dist/lib/push-command.js +31 -10
  96. package/dist/lib/put-or-create.js +3 -4
  97. package/dist/lib/resource-state.js +33 -80
  98. package/dist/lib/rollback-materialize.js +13 -24
  99. package/dist/lib/spinner.d.ts +2 -0
  100. package/dist/lib/spinner.js +72 -0
  101. package/dist/lib/temp-admin.d.ts +10 -1
  102. package/dist/lib/temp-admin.js +27 -2
  103. package/dist/lib/validate-local.js +31 -9
  104. package/dist/lib/wp-client.d.ts +1 -0
  105. package/dist/lib/wp-client.js +7 -1
  106. package/dist/lib/wp-site-diagnostic.js +3 -2
  107. package/dist/types/global-config.generated.d.ts +1 -5
  108. package/dist/types/project-config.generated.d.ts +28 -4
  109. package/dist/utils/composer.js +2 -1
  110. package/dist/utils/declared-type-format.d.ts +52 -0
  111. package/dist/utils/declared-type-format.js +233 -0
  112. package/dist/utils/resource-dirs.d.ts +9 -3
  113. package/dist/utils/resource-dirs.js +16 -4
  114. package/dist/utils/template-format.d.ts +1 -0
  115. package/dist/utils/theme-css.d.ts +19 -0
  116. package/dist/utils/theme-css.js +100 -0
  117. package/oclif.manifest.json +3012 -1133
  118. package/package.json +28 -15
  119. package/schemas/global-config.schema.json +2 -6
  120. package/schemas/project-config.schema.json +37 -7
  121. package/dist/commands/seo/diff.js +0 -5
  122. package/dist/commands/seo/list.d.ts +0 -18
  123. package/dist/commands/seo/list.js +0 -58
  124. package/dist/commands/seo/pull.d.ts +0 -24
  125. package/dist/commands/seo/pull.js +0 -89
  126. package/dist/commands/seo/push.d.ts +0 -29
  127. package/dist/commands/seo/push.js +0 -178
  128. package/dist/commands/seo/rollback.js +0 -5
  129. package/dist/utils/seo-format.d.ts +0 -29
  130. package/dist/utils/seo-format.js +0 -18
  131. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  132. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -27,10 +27,18 @@ async function report(context, options) {
27
27
  dsn: SENTRY_DSN,
28
28
  environment: resolveEnvironment(),
29
29
  release: context.config.version,
30
+ // Sentry 11 collects all of these by default. Headers carry the app password, stack frame
31
+ // locals can hold it too, and this reports from users' own machines: collect none of it.
32
+ dataCollection: {
33
+ cookies: false,
34
+ httpBodies: [],
35
+ httpHeaders: false,
36
+ stackFrameVariables: false,
37
+ urlQueryParams: false,
38
+ userInfo: false,
39
+ },
30
40
  // Node defaults `server_name` to os.hostname(), which is often the user's real name
31
- // (e.g. "jane-doe-macbook-pro"). sendDefaultPii covers IP addresses and similar, off by
32
- // default but set explicitly since this reports from users' own machines.
33
- sendDefaultPii: false,
41
+ // (e.g. "jane-doe-macbook-pro").
34
42
  serverName: 'loopress',
35
43
  });
36
44
  Sentry.captureException(options.error, {
@@ -1,4 +1,5 @@
1
1
  import got from 'got';
2
+ import { withSpinner } from './spinner.js';
2
3
  import { REQUEST_TIMEOUT_MS } from './wp-client.js';
3
4
  export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
4
5
  /**
@@ -29,7 +30,7 @@ export class ApiClient {
29
30
  }
30
31
  async request(method, path, json) {
31
32
  try {
32
- const response = await this.client(path, { json, method });
33
+ const response = await withSpinner('Waiting for the Loopress API', async () => this.client(path, { json, method }));
33
34
  return (response.body ? JSON.parse(response.body) : undefined);
34
35
  }
35
36
  catch (error) {
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
+ import { withSpinner } from './spinner.js';
5
6
  // The package managers `lps app push` may spawn. An allowlist, not the raw `packageManager`
6
7
  // field: the name ends up as a process to run.
7
8
  const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
@@ -43,7 +44,7 @@ export async function buildApp(appDir, onStart) {
43
44
  const pm = detectPackageManager(appDir, pkg.packageManager);
44
45
  const command = `${pm} run build`;
45
46
  onStart?.(command);
46
- const { code, output } = await new Promise((resolve, reject) => {
47
+ const build = new Promise((resolve, reject) => {
47
48
  // Windows resolves `npm` to `npm.cmd`, which only a shell finds. `pm` comes from the
48
49
  // allowlist above, so the shell never sees user input.
49
50
  const child = spawn(pm, ['run', 'build'], { cwd: appDir, shell: process.platform === 'win32' });
@@ -58,6 +59,7 @@ export async function buildApp(appDir, onStart) {
58
59
  resolve({ code: exitCode, output: out });
59
60
  });
60
61
  });
62
+ const { code, output } = await withSpinner(`Running ${command}`, async () => build);
61
63
  if (code !== 0) {
62
64
  const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
63
65
  const hint = existsSync(join(appDir, 'node_modules'))
@@ -39,10 +39,9 @@ export declare abstract class LoopressCommand extends Command {
39
39
  protected resolveHooksPath(override?: string): string;
40
40
  protected resolveMenuPath(override?: string): string;
41
41
  protected resolveOptionsPath(override?: string): string;
42
- protected resolveSeoPath(override?: string): string;
43
42
  protected resolveSnippetsPath(override?: string): string;
44
43
  protected resolveThemeStylesPath(override?: string): string;
44
+ protected warnRemovedSeoModule(): void;
45
45
  private pickEnvironment;
46
46
  private resolveEnvironment;
47
- private resolveEnvironmentForConfiguredProject;
48
47
  }
package/dist/lib/base.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Command, Flags } from '@oclif/core';
3
3
  import { Listr } from 'listr2';
4
+ import { existsSync } from 'node:fs';
4
5
  import { mkdir, rm } from 'node:fs/promises';
5
6
  import { join } from 'node:path';
6
7
  import { configManager } from '../config/project-config.manager.js';
@@ -9,13 +10,14 @@ import { pluralize } from '../utils/pluralize.js';
9
10
  import { resolveResourceDir } from '../utils/resource-dirs.js';
10
11
  import { isInteractive } from './interactive.js';
11
12
  import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
13
+ import { withoutSpinner } from './spinner.js';
12
14
  import { WpClient } from './wp-client.js';
13
15
  export class LoopressCommand extends Command {
14
- // On every subclass without opt-in: targeting an environment explicitly beats depending on
15
- // the machine-wide mutable state of `lps project switch`, which is shared across terminals.
16
+ // On every subclass without opt-in. Without it, commands target the "local" environment, so
17
+ // reaching a remote site is always spelled out in the command itself.
16
18
  static baseFlags = {
17
19
  env: Flags.string({
18
- description: 'Target environment by name, overriding the globally active one (lps project switch)',
20
+ description: 'Target environment by name (default: "local", or the project\'s only environment)',
19
21
  }),
20
22
  };
21
23
  static dryRunFlag = {
@@ -107,13 +109,15 @@ export class LoopressCommand extends Command {
107
109
  }
108
110
  if (options.alwaysCreateDir === true || items.length > 0)
109
111
  await mkdir(dir, { recursive: true });
110
- await new Listr(items.map((item) => ({
111
- async task(_ctx, task) {
112
- await options.write(item, dir);
113
- task.output = `Pulled: ${options.title(item)}`;
114
- },
115
- title: `Pull ${options.title(item)}`,
116
- })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
112
+ await withoutSpinner(async () => {
113
+ await new Listr(items.map((item) => ({
114
+ async task(_ctx, task) {
115
+ await options.write(item, dir);
116
+ task.output = `Pulled: ${options.title(item)}`;
117
+ },
118
+ title: `Pull ${options.title(item)}`,
119
+ })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
120
+ });
117
121
  await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
118
122
  this.log(options.pulledMessage);
119
123
  }
@@ -165,15 +169,19 @@ export class LoopressCommand extends Command {
165
169
  resolveOptionsPath(override) {
166
170
  return resolveResourceDir('options', this.localConfig, override);
167
171
  }
168
- resolveSeoPath(override) {
169
- return resolveResourceDir('seo', this.localConfig, override);
170
- }
171
172
  resolveSnippetsPath(override) {
172
173
  return resolveResourceDir('snippets', this.localConfig, override);
173
174
  }
174
175
  resolveThemeStylesPath(override) {
175
176
  return resolveResourceDir('themeStyles', this.localConfig, override);
176
177
  }
178
+ // `lps seo` was removed: a leftover seo/ folder or seoDir key would otherwise be silently
179
+ // ignored by `lps pull` / `lps push`. Remove once projects have had a release to migrate.
180
+ warnRemovedSeoModule() {
181
+ if (!('seoDir' in this.localConfig) && !existsSync(join(this.rootDir, 'seo')))
182
+ return;
183
+ this.warn('The SEO module was removed: seo/ is no longer pulled or pushed. Track SEO plugin settings with `lps option add rank-math-options-titles` (or `wpseo_titles`), then delete seo/ and any seoDir key from loopress.json.');
184
+ }
177
185
  pickEnvironment(project, envName) {
178
186
  const env = project.environments[envName];
179
187
  if (!env) {
@@ -181,24 +189,12 @@ export class LoopressCommand extends Command {
181
189
  }
182
190
  return env;
183
191
  }
192
+ // Order: --env, then the project's default (see getDefaultEnvironment). The project comes from
193
+ // loopress.json when pinned, from `lps project switch` otherwise.
184
194
  resolveEnvironment(envName) {
185
- if (this.localConfig.projectId) {
186
- return this.resolveEnvironmentForConfiguredProject(this.localConfig.projectId, envName);
187
- }
188
- if (envName) {
189
- const current = configManager.getCurrentProject();
190
- if (!current) {
191
- this.error('No project configured. Run `lps project config` first.');
192
- }
193
- return { env: this.pickEnvironment(current, envName), projectId: current.id };
194
- }
195
- const env = configManager.getCurrentEnv();
196
- const current = configManager.getCurrentProject();
197
- if (env && current)
198
- return { env, projectId: current.id };
199
- this.error('No environment configured. Run `lps project config` first.');
200
- }
201
- resolveEnvironmentForConfiguredProject(projectId, envName) {
195
+ const projectId = this.localConfig.projectId ?? configManager.getCurrentProject()?.id;
196
+ if (!projectId)
197
+ this.error('No project configured. Run `lps project config` first.');
202
198
  const project = configManager.getProject(projectId);
203
199
  if (!project) {
204
200
  this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
@@ -209,14 +205,10 @@ export class LoopressCommand extends Command {
209
205
  }
210
206
  if (envName)
211
207
  return { env: this.pickEnvironment(project, envName), projectId };
212
- if (envNames.length === 1) {
213
- return { env: project.environments[envNames[0]], projectId };
214
- }
215
- const current = configManager.getCurrentProject();
216
- const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
217
- if (!currentEnv) {
218
- this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
208
+ const env = configManager.getDefaultEnvironment(projectId);
209
+ if (!env) {
210
+ this.error(`Project "${project.name}" has no "local" environment, pass --env to pick one. Available: ${envNames.join(', ')}`);
219
211
  }
220
- return { env: currentEnv, projectId };
212
+ return { env, projectId };
221
213
  }
222
214
  }
@@ -0,0 +1,35 @@
1
+ import { type DeclaredTypeSpec, type RegisteredItem } from '../utils/declared-type-format.js';
2
+ import { LoopressCommand } from './base.js';
3
+ import { type CommandClass } from './php-files-command.js';
4
+ import { PushCommand } from './push-command.js';
5
+ type PushResult = {
6
+ path: string;
7
+ pushed: string[];
8
+ status: 'dry-run' | 'success';
9
+ };
10
+ type PullResult = {
11
+ path: string;
12
+ status: 'dry-run' | 'success';
13
+ };
14
+ type RmResult = {
15
+ removed: boolean;
16
+ slug: string;
17
+ status: 'aborted' | 'dry-run' | 'success';
18
+ };
19
+ export type DeclaredPushCommand = Omit<PushCommand, 'run'> & {
20
+ run(): Promise<PushResult>;
21
+ };
22
+ export declare function declaredPushCommand(spec: DeclaredTypeSpec, description: string): CommandClass<DeclaredPushCommand>;
23
+ export type DeclaredPullCommand = Omit<LoopressCommand, 'run'> & {
24
+ run(): Promise<PullResult>;
25
+ };
26
+ export declare function declaredPullCommand(spec: DeclaredTypeSpec, description: string): CommandClass<DeclaredPullCommand>;
27
+ export type DeclaredListCommand = Omit<LoopressCommand, 'run'> & {
28
+ run(): Promise<RegisteredItem[]>;
29
+ };
30
+ export declare function declaredListCommand(spec: DeclaredTypeSpec, description: string, countLabel: (count: number) => string): CommandClass<DeclaredListCommand>;
31
+ export type DeclaredRmCommand = Omit<LoopressCommand, 'run'> & {
32
+ run(): Promise<RmResult>;
33
+ };
34
+ export declare function declaredRmCommand(spec: DeclaredTypeSpec, description: string, keptNote: string): CommandClass<DeclaredRmCommand>;
35
+ export {};
@@ -0,0 +1,192 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args } from '@oclif/core';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fileExtension, formatDeclaredFile, itemEndpoint, parseDeclaredFile, } from '../utils/declared-type-format.js';
6
+ import { pluralize } from '../utils/pluralize.js';
7
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
8
+ import { LoopressCommand } from './base.js';
9
+ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
10
+ import { isInteractive } from './interactive.js';
11
+ import { loadFiles } from './load-files.js';
12
+ import { PushCommand } from './push-command.js';
13
+ import { getResourceStateProvider } from './resource-state.js';
14
+ import { isApplicative404, isNotFoundError } from './wp-client.js';
15
+ const SOURCE_LABELS = {
16
+ acf: 'ACF',
17
+ cptui: 'CPT UI',
18
+ loopress: 'Loopress',
19
+ other: 'theme or plugin',
20
+ wordpress: 'WordPress',
21
+ };
22
+ function pathArg(spec) {
23
+ return Args.string({ description: `Path to the ${spec.dir} directory (overrides project config)` });
24
+ }
25
+ export function declaredPushCommand(spec, description) {
26
+ class DeclaredPush extends PushCommand {
27
+ static args = { path: pathArg(spec) };
28
+ static description = description;
29
+ static enableJsonFlag = true;
30
+ static examples = [`$ lps ${spec.cliName} push`];
31
+ static flags = { ...PushCommand.dryRunFlag, ...PushCommand.yesFlag };
32
+ async run() {
33
+ const { args } = await this.parse(DeclaredPush);
34
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
35
+ this.log(`Pushing ${spec.plural} to ${this.siteConfig.url}`);
36
+ this.log(`Path: ${path}`);
37
+ const provider = getResourceStateProvider(spec.cliName);
38
+ const beforeState = await this.captureBeforePushState(provider, path);
39
+ const items = await loadFiles(path, {
40
+ extension: fileExtension(spec),
41
+ onSkip: (message) => {
42
+ this.warn(message);
43
+ },
44
+ parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
45
+ });
46
+ this.log(`Found ${pluralize(items.length, spec.noun, spec.plural)} to push`);
47
+ const pushed = [];
48
+ await this.runPushTasks(items, (item) => item.slug, async (item, task) => {
49
+ await this.pushItem(item, task);
50
+ pushed.push(item.slug);
51
+ });
52
+ await this.writeAfterPushSnapshot(provider, path, beforeState);
53
+ if (this.failedCount > 0) {
54
+ this.error(`${pluralize(this.failedCount, spec.noun, spec.plural)} failed to push.`);
55
+ }
56
+ if (this.dryRun)
57
+ return { path, pushed, status: 'dry-run' };
58
+ await this.recordSuccess();
59
+ this.log(`All ${spec.plural} pushed.`);
60
+ return { path, pushed, status: 'success' };
61
+ }
62
+ async currentRevision(slug) {
63
+ try {
64
+ return (await this.wp.get(itemEndpoint(spec, slug))).revision;
65
+ }
66
+ catch (error) {
67
+ if (isNotFoundError(error))
68
+ return undefined;
69
+ throw error;
70
+ }
71
+ }
72
+ async pushItem({ args, slug }, task) {
73
+ if (this.dryRun) {
74
+ if (task)
75
+ task.output = `[dry-run] Would push: ${slug}`;
76
+ return;
77
+ }
78
+ try {
79
+ // Conditional write (#234), same as `menu push`: WordPress refuses it (412) if the item
80
+ // changed since this read. Slug, reserved names and code arguments are checked there.
81
+ const expectedRevision = await this.currentRevision(slug);
82
+ const body = { args, slug };
83
+ if (expectedRevision !== undefined)
84
+ body.expectedRevision = expectedRevision;
85
+ await this.wp.post(spec.endpoint, body);
86
+ if (task)
87
+ task.output = `Pushed: ${slug}`;
88
+ }
89
+ catch (error) {
90
+ this.reportTaskFailure(`Failed to push ${slug}: ${error.message}`, error, task);
91
+ }
92
+ }
93
+ }
94
+ return DeclaredPush;
95
+ }
96
+ export function declaredPullCommand(spec, description) {
97
+ class DeclaredPull extends LoopressCommand {
98
+ static args = { path: pathArg(spec) };
99
+ static description = description;
100
+ static enableJsonFlag = true;
101
+ static examples = [`$ lps ${spec.cliName} pull`];
102
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
103
+ async run() {
104
+ const { args } = await this.parse(DeclaredPull);
105
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
106
+ this.log(`Pulling ${spec.plural} from ${this.siteConfig.url}`);
107
+ this.log(`Path: ${path}`);
108
+ const remote = await this.wp.get(spec.endpoint);
109
+ const orphans = await findOrphanedFiles(path, new Set(remote.map(({ slug }) => slug)), { extensions: [fileExtension(spec)], key: basenameKey });
110
+ await this.pullDirectory(path, remote, orphans, {
111
+ dryRunMessage: `Would pull ${pluralize(remote.length, spec.noun, spec.plural)} to ${path}`,
112
+ orphanReason: `in ${path} no longer present on WordPress`,
113
+ pulledMessage: `Pulled ${pluralize(remote.length, spec.noun, spec.plural)} to ${path}`,
114
+ title: ({ slug }) => slug,
115
+ // Only the arguments: the slug is the file name, the revision is push bookkeeping.
116
+ async write(item, dir) {
117
+ await writeFile(join(dir, `${item.slug}${fileExtension(spec)}`), formatDeclaredFile(spec, item.args));
118
+ },
119
+ });
120
+ return { path, status: this.dryRun ? 'dry-run' : 'success' };
121
+ }
122
+ }
123
+ return DeclaredPull;
124
+ }
125
+ // `countLabel` renders the item's count: "3 published" for a post type, "3 terms" for a taxonomy,
126
+ // nothing when it returns an empty string.
127
+ export function declaredListCommand(spec, description, countLabel) {
128
+ class DeclaredList extends LoopressCommand {
129
+ static description = description;
130
+ static enableJsonFlag = true;
131
+ static examples = [`$ lps ${spec.cliName} list`];
132
+ async run() {
133
+ const items = await this.wp.get(spec.registeredEndpoint);
134
+ for (const item of items) {
135
+ const attached = item.objectTypes && item.objectTypes.length > 0 ? `, on ${item.objectTypes.join(', ')}` : '';
136
+ const count = countLabel(item.count);
137
+ const counted = count ? ', ' + count : '';
138
+ this.log(`${item.slug} (${item.label}): ${SOURCE_LABELS[item.source]}${counted}${attached}`);
139
+ if (item.conflict) {
140
+ this.warn(`${item.slug}: declared in ${spec.dir}/ but already registered by ${SOURCE_LABELS[item.source]}, the Loopress version is skipped`);
141
+ }
142
+ }
143
+ return items;
144
+ }
145
+ }
146
+ return DeclaredList;
147
+ }
148
+ // The only way a Loopress item leaves WordPress: push never deletes (see Push Deletion Rules in
149
+ // the product docs). The local file is left alone, same as `hook rm`. `keptNote` says what
150
+ // happens to what used it ("Its posts stay in the database, hidden until it's pushed again.").
151
+ export function declaredRmCommand(spec, description, keptNote) {
152
+ class DeclaredRm extends LoopressCommand {
153
+ static aliases = [`${spec.cliName}:remove`];
154
+ static args = {
155
+ slug: Args.string({ description: `The ${spec.noun} slug, its ${spec.dir}/ file name without ${fileExtension(spec)}`, required: true }),
156
+ };
157
+ static description = description;
158
+ static enableJsonFlag = true;
159
+ static examples = [`$ lps ${spec.cliName} rm <slug>`, `$ lps ${spec.cliName} remove <slug> --yes`];
160
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
161
+ async run() {
162
+ const { args } = await this.parse(DeclaredRm);
163
+ const { slug } = args;
164
+ const { url } = this.siteConfig;
165
+ if (this.dryRun) {
166
+ this.log(`[dry-run] Would remove ${spec.noun} ${slug} from ${url}`);
167
+ return { removed: false, slug, status: 'dry-run' };
168
+ }
169
+ if (!this.yes) {
170
+ if (!isInteractive())
171
+ this.error(`Removing ${spec.noun} ${slug} needs confirmation. Re-run with --yes.`);
172
+ const ok = await confirm({ default: false, message: `Remove ${spec.noun} ${slug} from ${url}? ${keptNote}` });
173
+ if (!ok) {
174
+ this.log('Aborted.');
175
+ return { removed: false, slug, status: 'aborted' };
176
+ }
177
+ }
178
+ try {
179
+ await this.wp.delete(itemEndpoint(spec, slug));
180
+ }
181
+ catch (error) {
182
+ const notFound = `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} not found`;
183
+ if (isApplicative404(error, notFound))
184
+ this.error(`${spec.noun} ${slug} is not managed by Loopress on ${url}.`);
185
+ throw error;
186
+ }
187
+ this.log(`Removed ${spec.noun} ${slug} from ${url}. ${keptNote}`);
188
+ return { removed: true, slug, status: 'success' };
189
+ }
190
+ }
191
+ return DeclaredRm;
192
+ }
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
2
+ export type ResourceType = 'api' | 'blocks' | 'css' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
- export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', 'pages', 'api', 'hooks', 'plugins'];
3
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
4
+ export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', 'css', 'blocks', 'pages', 'api', 'hooks', 'plugins'];
4
5
  // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
6
  // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
7
  export function resolveResourceTypes(only, skip = []) {
@@ -15,11 +16,14 @@ export function buildWatchTargets(types, localConfig, cwd) {
15
16
  const rootDir = join(cwd, localConfig.rootDir ?? '.');
16
17
  const candidates = {
17
18
  api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
19
+ // The theme CSS is bundled into the child theme with the templates, by the same push.
20
+ blocks: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('blockCss', localConfig)), type: 'blocks' },
21
+ css: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('css', localConfig)), type: 'css' },
18
22
  hooks: { commandId: 'hook:push', path: join(rootDir, localConfig.hooksDir ?? 'hooks'), type: 'hooks' },
19
23
  pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
20
24
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
21
- parts: { commandId: 'theme:template:push', path: join(rootDir, localConfig.partDir ?? 'parts'), type: 'parts' },
22
- templates: { commandId: 'theme:template:push', path: join(rootDir, localConfig.templateDir ?? 'templates'), type: 'templates' },
25
+ parts: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('part', localConfig)), type: 'parts' },
26
+ templates: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('template', localConfig)), type: 'templates' },
23
27
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
24
28
  };
25
29
  return types
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { pipeline } from 'node:stream/promises';
7
+ import { withSpinner } from './spinner.js';
7
8
  const REPO = 'loopress/loopress';
8
9
  const ASSET_NAME = 'loopress-full.zip';
9
10
  /**
@@ -17,6 +18,9 @@ const ASSET_NAME = 'loopress-full.zip';
17
18
  * "the one zip asset".
18
19
  */
19
20
  export async function downloadLatestFullZip() {
21
+ return withSpinner('Downloading Loopress Full', download);
22
+ }
23
+ async function download() {
20
24
  const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
21
25
  headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
22
26
  searchParams: { 'per_page': 30 },
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { createServer } from 'node:http';
4
4
  import { openBrowser } from './open-browser.js';
5
+ import { withSpinner } from './spinner.js';
5
6
  /** Constant-time compare that also tolerates a length mismatch without throwing. */
6
7
  function safeEqual(a, b) {
7
8
  const bufferA = Buffer.from(a);
@@ -28,7 +29,7 @@ function safeEqual(a, b) {
28
29
  export async function waitForLocalCallback(options) {
29
30
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
30
31
  const state = randomBytes(32).toString('hex');
31
- return new Promise((resolve, reject) => {
32
+ const callback = new Promise((resolve, reject) => {
32
33
  function finish(res, page) {
33
34
  // `Connection: close` (here and below): server.close() only refuses new connections, a
34
35
  // kept-alive socket would otherwise still reach this handler after the shutdown.
@@ -100,6 +101,7 @@ export async function waitForLocalCallback(options) {
100
101
  openBrowser(targetUrl);
101
102
  });
102
103
  });
104
+ return withSpinner('Waiting for the browser', async () => callback);
103
105
  }
104
106
  /**
105
107
  * The page rewrites its own URL to `/` on load, so the callback query string (an Application
@@ -15,6 +15,7 @@ export declare abstract class PushCommand extends LoopressCommand {
15
15
  protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
16
16
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
17
17
  protected recordSuccess(): Promise<void>;
18
+ protected reportPushToSite(): Promise<void>;
18
19
  protected reportTaskFailure(message: string, error: unknown, task?: {
19
20
  output: string;
20
21
  }): never;
@@ -7,6 +7,7 @@ import { API_URL } from './api-client.js';
7
7
  import { LoopressCommand } from './base.js';
8
8
  import { guardProductionPush } from './guard-production-push.js';
9
9
  import { writeSnapshot } from './snapshot-store.js';
10
+ import { withoutSpinner, withSpinner } from './spinner.js';
10
11
  export class PushCommand extends LoopressCommand {
11
12
  // Passed by an aggregate command (`lps theme push`) that already confirmed production once,
12
13
  // so its steps skip that prompt while still asking their own confirmations (uninstalls).
@@ -78,19 +79,37 @@ export class PushCommand extends LoopressCommand {
78
79
  if (!token)
79
80
  return;
80
81
  try {
81
- await got.post(`${API_URL}/deployments`, {
82
+ await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
82
83
  headers: { Authorization: `Bearer ${token}` },
83
84
  json: { status, url: this.siteConfig.url },
84
85
  timeout: { request: 3000 },
85
- });
86
+ }));
86
87
  }
87
88
  catch {
88
89
  // non-blocking: recording must never interrupt the push flow
89
90
  }
90
91
  }
91
92
  async recordSuccess() {
92
- if (!this.dryRun)
93
- await this.recordDeployment('success');
93
+ if (this.dryRun)
94
+ return;
95
+ await this.recordDeployment('success');
96
+ await this.reportPushToSite();
97
+ }
98
+ // Tells the plugin this push finished, for its Overview tab and the "managed by Loopress"
99
+ // warnings in wp-admin: one record per command (`this.id`, e.g. `acf:push`), since a single push
100
+ // sends one request per item. Best-effort like recordDeployment: a plugin older than the push
101
+ // log answers 404, one that doesn't know this command 400, neither may fail a push that landed.
102
+ async reportPushToSite() {
103
+ if (!this.id)
104
+ return;
105
+ try {
106
+ // Bounded: the site fingerprints the pushed resource before answering, which a large
107
+ // site can make slow, and a finished push must not wait on its own bookkeeping.
108
+ await this.wp.post('loopress/v1/pushes', { resource: this.id }, { timeoutMs: 5000 });
109
+ }
110
+ catch {
111
+ // non-blocking, see above
112
+ }
94
113
  }
95
114
  // Reports one failed Listr task: status lines go through `task.output` when running inside a
96
115
  // Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
@@ -111,17 +130,19 @@ export class PushCommand extends LoopressCommand {
111
130
  // just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
112
131
  // and reports its own failure through `reportTaskFailure`.
113
132
  async runPushTasks(items, label, task) {
114
- await new Listr(items.map((item) => ({
115
- task: async (_ctx, taskCtx) => task(item, taskCtx),
116
- title: `Push ${label(item)}`,
117
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
133
+ await withoutSpinner(async () => {
134
+ await new Listr(items.map((item) => ({
135
+ task: async (_ctx, taskCtx) => task(item, taskCtx),
136
+ title: `Push ${label(item)}`,
137
+ })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
138
+ });
118
139
  }
119
140
  // Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
120
141
  // with a fresh remote read taken right after the push, and writes both as one rollback
121
142
  // snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
122
143
  // the local files that were pushed, is what keeps this correct for every resource's own
123
- // partial-push semantics: a readonly option skipped by `option push`, a snippet or SEO
124
- // redirect that gets a server-assigned id on create, a theme-styles push that only touches the
144
+ // partial-push semantics: a readonly option skipped by `option push`, a snippet or form
145
+ // that gets a server-assigned id on create, a theme-styles push that only touches the
125
146
  // active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
126
147
  // `lps <resource> rollback`'s drift check compares the environment's state at rollback time
127
148
  // against exactly this after-state, so it has to be what actually landed, not what was sent.
@@ -1,8 +1,7 @@
1
1
  import { isNotFoundError } from './wp-client.js';
2
- // Update-or-create dance shared by every id-based resource (form, seo redirect,
3
- // snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
4
- // fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
5
- // database reset.
2
+ // Update-or-create dance shared by every id-based resource (form, snippet, unlike ACF which
3
+ // is key-based and only ever POSTs): PUT to the known id, and only fall back to POST when the
4
+ // site doesn't recognize that id (404), e.g. a fresh install or a database reset.
6
5
  export async function putOrCreate(wp, options) {
7
6
  const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
8
7
  if (id !== null) {