@loopress/cli 0.29.0 → 0.31.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 (154) hide show
  1. package/README.md +1369 -360
  2. package/dist/commands/acf/push.js +2 -1
  3. package/dist/commands/composer/push.d.ts +1 -0
  4. package/dist/commands/composer/push.js +13 -0
  5. package/dist/commands/content/copy.d.ts +28 -0
  6. package/dist/commands/content/copy.js +97 -0
  7. package/dist/commands/cpt/diff.js +5 -0
  8. package/dist/commands/cpt/list.d.ts +2 -0
  9. package/dist/commands/cpt/list.js +4 -0
  10. package/dist/commands/cpt/pull.d.ts +2 -0
  11. package/dist/commands/cpt/pull.js +4 -0
  12. package/dist/commands/cpt/push.d.ts +2 -0
  13. package/dist/commands/cpt/push.js +4 -0
  14. package/dist/commands/cpt/rm.d.ts +2 -0
  15. package/dist/commands/cpt/rm.js +3 -0
  16. package/dist/commands/cpt/rollback.js +5 -0
  17. package/dist/commands/dev.js +14 -4
  18. package/dist/commands/diff.js +1 -1
  19. package/dist/commands/doctor.d.ts +2 -0
  20. package/dist/commands/doctor.js +57 -2
  21. package/dist/commands/form/push.js +4 -1
  22. package/dist/commands/meta/diff.d.ts +2 -0
  23. package/dist/commands/meta/diff.js +5 -0
  24. package/dist/commands/meta/list.d.ts +19 -0
  25. package/dist/commands/meta/list.js +21 -0
  26. package/dist/commands/meta/pull.d.ts +2 -0
  27. package/dist/commands/meta/pull.js +4 -0
  28. package/dist/commands/meta/push.d.ts +2 -0
  29. package/dist/commands/meta/push.js +4 -0
  30. package/dist/commands/meta/rm.d.ts +2 -0
  31. package/dist/commands/meta/rm.js +3 -0
  32. package/dist/commands/meta/rollback.d.ts +2 -0
  33. package/dist/commands/meta/rollback.js +5 -0
  34. package/dist/commands/option/list.js +2 -8
  35. package/dist/commands/option/push.js +4 -2
  36. package/dist/commands/page/push.js +3 -2
  37. package/dist/commands/pattern/diff.d.ts +2 -0
  38. package/dist/commands/pattern/diff.js +5 -0
  39. package/dist/commands/pattern/list.d.ts +2 -0
  40. package/dist/commands/pattern/list.js +3 -0
  41. package/dist/commands/pattern/pull.d.ts +2 -0
  42. package/dist/commands/pattern/pull.js +4 -0
  43. package/dist/commands/pattern/push.d.ts +2 -0
  44. package/dist/commands/pattern/push.js +4 -0
  45. package/dist/commands/pattern/rm.d.ts +2 -0
  46. package/dist/commands/pattern/rm.js +3 -0
  47. package/dist/commands/pattern/rollback.d.ts +2 -0
  48. package/dist/commands/pattern/rollback.js +5 -0
  49. package/dist/commands/plugin/audit.js +43 -25
  50. package/dist/commands/project/config.d.ts +1 -0
  51. package/dist/commands/project/config.js +20 -1
  52. package/dist/commands/project/list.d.ts +1 -1
  53. package/dist/commands/project/list.js +6 -6
  54. package/dist/commands/project/pull.js +11 -8
  55. package/dist/commands/project/push.js +23 -18
  56. package/dist/commands/project/remove.js +1 -1
  57. package/dist/commands/project/switch.d.ts +0 -1
  58. package/dist/commands/project/switch.js +16 -36
  59. package/dist/commands/promote.d.ts +1 -0
  60. package/dist/commands/promote.js +7 -3
  61. package/dist/commands/pull.d.ts +2 -1
  62. package/dist/commands/pull.js +31 -6
  63. package/dist/commands/push.d.ts +4 -2
  64. package/dist/commands/push.js +51 -19
  65. package/dist/commands/role/diff.d.ts +2 -0
  66. package/dist/commands/role/diff.js +5 -0
  67. package/dist/commands/role/list.d.ts +2 -0
  68. package/dist/commands/role/list.js +4 -0
  69. package/dist/commands/role/pull.d.ts +2 -0
  70. package/dist/commands/role/pull.js +4 -0
  71. package/dist/commands/role/push.d.ts +2 -0
  72. package/dist/commands/role/push.js +4 -0
  73. package/dist/commands/role/rm.d.ts +2 -0
  74. package/dist/commands/role/rm.js +3 -0
  75. package/dist/commands/role/rollback.d.ts +2 -0
  76. package/dist/commands/role/rollback.js +5 -0
  77. package/dist/commands/snippet/push.d.ts +1 -0
  78. package/dist/commands/snippet/push.js +24 -3
  79. package/dist/commands/status.d.ts +2 -3
  80. package/dist/commands/status.js +21 -53
  81. package/dist/commands/taxonomy/diff.d.ts +2 -0
  82. package/dist/commands/taxonomy/diff.js +5 -0
  83. package/dist/commands/taxonomy/list.d.ts +2 -0
  84. package/dist/commands/taxonomy/list.js +5 -0
  85. package/dist/commands/taxonomy/pull.d.ts +2 -0
  86. package/dist/commands/taxonomy/pull.js +4 -0
  87. package/dist/commands/taxonomy/push.d.ts +2 -0
  88. package/dist/commands/taxonomy/push.js +4 -0
  89. package/dist/commands/taxonomy/rm.d.ts +2 -0
  90. package/dist/commands/taxonomy/rm.js +3 -0
  91. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  92. package/dist/commands/taxonomy/rollback.js +5 -0
  93. package/dist/commands/theme/template/push.d.ts +2 -0
  94. package/dist/commands/theme/template/push.js +26 -5
  95. package/dist/commands/theme/version/pull.js +6 -1
  96. package/dist/config/project-config.manager.d.ts +3 -3
  97. package/dist/config/project-config.manager.js +22 -31
  98. package/dist/hooks/finally.js +11 -3
  99. package/dist/lib/api-client.js +2 -1
  100. package/dist/lib/app-build.js +3 -1
  101. package/dist/lib/base.d.ts +2 -2
  102. package/dist/lib/base.js +33 -38
  103. package/dist/lib/declared-type-command.d.ts +35 -0
  104. package/dist/lib/declared-type-command.js +192 -0
  105. package/dist/lib/dev-watch.d.ts +1 -1
  106. package/dist/lib/dev-watch.js +7 -3
  107. package/dist/lib/diff-command.d.ts +2 -0
  108. package/dist/lib/diff-command.js +24 -3
  109. package/dist/lib/github-release.js +4 -0
  110. package/dist/lib/load-snippets.js +4 -0
  111. package/dist/lib/local-callback-server.js +3 -1
  112. package/dist/lib/php-files-command.d.ts +8 -0
  113. package/dist/lib/php-files-command.js +5 -0
  114. package/dist/lib/push-command.d.ts +5 -2
  115. package/dist/lib/push-command.js +96 -15
  116. package/dist/lib/put-or-create.js +3 -4
  117. package/dist/lib/resource-state.js +38 -85
  118. package/dist/lib/rollback-command.js +9 -2
  119. package/dist/lib/rollback-materialize.js +13 -24
  120. package/dist/lib/spinner.d.ts +2 -0
  121. package/dist/lib/spinner.js +72 -0
  122. package/dist/lib/temp-admin.d.ts +10 -1
  123. package/dist/lib/temp-admin.js +27 -2
  124. package/dist/lib/validate-local.js +40 -12
  125. package/dist/lib/wp-client.d.ts +1 -0
  126. package/dist/lib/wp-client.js +21 -2
  127. package/dist/lib/wp-site-diagnostic.js +3 -2
  128. package/dist/types/global-config.generated.d.ts +1 -5
  129. package/dist/types/project-config.generated.d.ts +28 -4
  130. package/dist/types/snippet.d.ts +1 -0
  131. package/dist/utils/composer.js +2 -1
  132. package/dist/utils/declared-type-format.d.ts +52 -0
  133. package/dist/utils/declared-type-format.js +233 -0
  134. package/dist/utils/resource-dirs.d.ts +9 -3
  135. package/dist/utils/resource-dirs.js +16 -4
  136. package/dist/utils/template-format.d.ts +1 -0
  137. package/dist/utils/theme-css.d.ts +19 -0
  138. package/dist/utils/theme-css.js +100 -0
  139. package/oclif.manifest.json +3294 -1213
  140. package/package.json +32 -16
  141. package/schemas/global-config.schema.json +2 -6
  142. package/schemas/project-config.schema.json +37 -7
  143. package/dist/commands/seo/diff.js +0 -5
  144. package/dist/commands/seo/list.d.ts +0 -18
  145. package/dist/commands/seo/list.js +0 -58
  146. package/dist/commands/seo/pull.d.ts +0 -24
  147. package/dist/commands/seo/pull.js +0 -89
  148. package/dist/commands/seo/push.d.ts +0 -29
  149. package/dist/commands/seo/push.js +0 -178
  150. package/dist/commands/seo/rollback.js +0 -5
  151. package/dist/utils/seo-format.d.ts +0 -29
  152. package/dist/utils/seo-format.js +0 -18
  153. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  154. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { LOCAL_ENVIRONMENT_NAME } from '../lib/wp-client.js';
3
4
  import { toSlug } from '../utils/to-slug.js';
4
5
  import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
5
6
  export class ProjectConfigManager {
@@ -39,15 +40,6 @@ export class ProjectConfigManager {
39
40
  getConfigFilePath() {
40
41
  return join(this.requireConfigDir(), 'config.json');
41
42
  }
42
- getCurrentEnv() {
43
- const config = this.readConfig();
44
- if (!config.currentProject)
45
- return null;
46
- const project = config.projects[config.currentProject.id];
47
- if (!project)
48
- return null;
49
- return project.environments[config.currentProject.env] ?? null;
50
- }
51
43
  getCurrentProject() {
52
44
  const config = this.readConfig();
53
45
  if (!config.currentProject)
@@ -57,6 +49,17 @@ export class ProjectConfigManager {
57
49
  return null;
58
50
  return { ...project, id: config.currentProject.id };
59
51
  }
52
+ // The environment a command targets without --env: "local" when the project has one (where
53
+ // you work, so it's the safe default), otherwise its only environment. Null when the project
54
+ // has several remote ones and no local: the user must say which one with --env, since a
55
+ // remembered choice is exactly the hidden state that sends a push to the wrong site.
56
+ getDefaultEnvironment(projectId) {
57
+ const environments = this.getProject(projectId)?.environments ?? {};
58
+ const names = Object.keys(environments);
59
+ if (Object.hasOwn(environments, LOCAL_ENVIRONMENT_NAME))
60
+ return environments[LOCAL_ENVIRONMENT_NAME];
61
+ return names.length === 1 ? environments[names[0]] : null;
62
+ }
60
63
  getEnvironment(projectId, envName) {
61
64
  const project = this.getProject(projectId);
62
65
  if (!project)
@@ -71,14 +74,11 @@ export class ProjectConfigManager {
71
74
  return this.readConfig().telemetry?.disabled ?? false;
72
75
  }
73
76
  listEnvironments(projectId) {
74
- const config = this.readConfig();
75
- const project = config.projects[projectId];
77
+ const project = this.getProject(projectId);
76
78
  if (!project)
77
79
  return [];
78
- return Object.values(project.environments).map((env) => ({
79
- ...env,
80
- isCurrent: config.currentProject?.id === projectId && config.currentProject.env === env.name,
81
- }));
80
+ const defaultEnv = this.getDefaultEnvironment(projectId);
81
+ return Object.values(project.environments).map((env) => ({ ...env, isDefault: env.name === defaultEnv?.name }));
82
82
  }
83
83
  listProjects() {
84
84
  const config = this.readConfig();
@@ -101,10 +101,6 @@ export class ProjectConfigManager {
101
101
  if (!project)
102
102
  return;
103
103
  Reflect.deleteProperty(project.environments, envName);
104
- if (config.currentProject?.id === projectId && config.currentProject.env === envName) {
105
- const remaining = Object.keys(project.environments);
106
- config.currentProject = remaining.length > 0 ? { env: remaining[0], id: projectId } : null;
107
- }
108
104
  this.writeConfig(config);
109
105
  }
110
106
  removeProject(id) {
@@ -112,9 +108,7 @@ export class ProjectConfigManager {
112
108
  Reflect.deleteProperty(config.projects, id);
113
109
  if (config.currentProject?.id === id) {
114
110
  const [nextId] = Object.keys(config.projects);
115
- const nextProject = nextId ? config.projects[nextId] : undefined;
116
- const [nextEnv] = nextProject ? Object.keys(nextProject.environments) : [];
117
- config.currentProject = nextId && nextEnv ? { env: nextEnv, id: nextId } : null;
111
+ config.currentProject = nextId ? { id: nextId } : null;
118
112
  }
119
113
  this.writeConfig(config);
120
114
  }
@@ -124,11 +118,11 @@ export class ProjectConfigManager {
124
118
  setConfigDir(configDir) {
125
119
  this.configDir = configDir;
126
120
  }
127
- setCurrent(projectId, envName) {
121
+ setCurrent(projectId) {
128
122
  const config = this.readConfig();
129
123
  if (!Object.hasOwn(config.projects, projectId))
130
124
  return;
131
- config.currentProject = { env: envName, id: projectId };
125
+ config.currentProject = { id: projectId };
132
126
  this.writeConfig(config);
133
127
  }
134
128
  setEnvironment(projectId, envName, env) {
@@ -137,7 +131,7 @@ export class ProjectConfigManager {
137
131
  if (!project)
138
132
  return;
139
133
  project.environments[envName] = env;
140
- config.currentProject ??= { env: envName, id: projectId };
134
+ config.currentProject ??= { id: projectId };
141
135
  this.writeConfig(config);
142
136
  }
143
137
  setEnvironmentApiId(projectId, envName, apiEnvironmentId) {
@@ -151,11 +145,7 @@ export class ProjectConfigManager {
151
145
  setProject(id, project) {
152
146
  const config = this.readConfig();
153
147
  config.projects[id] = project;
154
- if (!config.currentProject) {
155
- const [firstEnv] = Object.keys(project.environments);
156
- if (firstEnv)
157
- config.currentProject = { env: firstEnv, id };
158
- }
148
+ config.currentProject ??= { id };
159
149
  this.writeConfig(config);
160
150
  }
161
151
  setProjectApiId(id, apiProjectId) {
@@ -203,7 +193,8 @@ export class ProjectConfigManager {
203
193
  if (value === null || typeof value !== 'object')
204
194
  return null;
205
195
  const pointer = value;
206
- return typeof pointer.id === 'string' && typeof pointer.env === 'string' ? { env: pointer.env, id: pointer.id } : null;
196
+ // A legacy `env` field, from when switch also picked the environment, is dropped here.
197
+ return typeof pointer.id === 'string' ? { id: pointer.id } : null;
207
198
  }
208
199
  sanitizeProjects(value) {
209
200
  if (typeof value !== 'object' || value === null)
@@ -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'))
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
14
14
  };
15
15
  protected dryRun: boolean;
16
16
  protected localConfig: LoopressLocalConfig;
17
+ protected overwrite: boolean;
17
18
  protected productionConfirmed: boolean;
18
19
  protected projectId: string;
19
20
  protected siteConfig: EnvironmentConfig;
@@ -39,10 +40,9 @@ export declare abstract class LoopressCommand extends Command {
39
40
  protected resolveHooksPath(override?: string): string;
40
41
  protected resolveMenuPath(override?: string): string;
41
42
  protected resolveOptionsPath(override?: string): string;
42
- protected resolveSeoPath(override?: string): string;
43
43
  protected resolveSnippetsPath(override?: string): string;
44
44
  protected resolveThemeStylesPath(override?: string): string;
45
+ protected warnRemovedSeoModule(): void;
45
46
  private pickEnvironment;
46
47
  private resolveEnvironment;
47
- private resolveEnvironmentForConfiguredProject;
48
48
  }
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 = {
@@ -26,6 +28,8 @@ export class LoopressCommand extends Command {
26
28
  };
27
29
  dryRun = false;
28
30
  localConfig = {};
31
+ // Set by PushCommand's --overwrite, see PushCommand.guardOutsideEdits.
32
+ overwrite = false;
29
33
  // Set by PushCommand's hidden --production-confirmed, see there.
30
34
  productionConfirmed = false;
31
35
  projectId;
@@ -56,6 +60,7 @@ export class LoopressCommand extends Command {
56
60
  }));
57
61
  this.dryRun = Boolean(flags['dry-run']);
58
62
  this.yes = Boolean(flags.yes);
63
+ this.overwrite = Boolean(flags.overwrite);
59
64
  this.productionConfirmed = Boolean(flags['production-confirmed']);
60
65
  this.localConfig = await readLocalConfig();
61
66
  const resolved = this.resolveEnvironment(flags.env);
@@ -107,13 +112,15 @@ export class LoopressCommand extends Command {
107
112
  }
108
113
  if (options.alwaysCreateDir === true || items.length > 0)
109
114
  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();
115
+ await withoutSpinner(async () => {
116
+ await new Listr(items.map((item) => ({
117
+ async task(_ctx, task) {
118
+ await options.write(item, dir);
119
+ task.output = `Pulled: ${options.title(item)}`;
120
+ },
121
+ title: `Pull ${options.title(item)}`,
122
+ })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
123
+ });
117
124
  await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
118
125
  this.log(options.pulledMessage);
119
126
  }
@@ -165,15 +172,19 @@ export class LoopressCommand extends Command {
165
172
  resolveOptionsPath(override) {
166
173
  return resolveResourceDir('options', this.localConfig, override);
167
174
  }
168
- resolveSeoPath(override) {
169
- return resolveResourceDir('seo', this.localConfig, override);
170
- }
171
175
  resolveSnippetsPath(override) {
172
176
  return resolveResourceDir('snippets', this.localConfig, override);
173
177
  }
174
178
  resolveThemeStylesPath(override) {
175
179
  return resolveResourceDir('themeStyles', this.localConfig, override);
176
180
  }
181
+ // `lps seo` was removed: a leftover seo/ folder or seoDir key would otherwise be silently
182
+ // ignored by `lps pull` / `lps push`. Remove once projects have had a release to migrate.
183
+ warnRemovedSeoModule() {
184
+ if (!('seoDir' in this.localConfig) && !existsSync(join(this.rootDir, 'seo')))
185
+ return;
186
+ 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.');
187
+ }
177
188
  pickEnvironment(project, envName) {
178
189
  const env = project.environments[envName];
179
190
  if (!env) {
@@ -181,24 +192,12 @@ export class LoopressCommand extends Command {
181
192
  }
182
193
  return env;
183
194
  }
195
+ // Order: --env, then the project's default (see getDefaultEnvironment). The project comes from
196
+ // loopress.json when pinned, from `lps project switch` otherwise.
184
197
  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) {
198
+ const projectId = this.localConfig.projectId ?? configManager.getCurrentProject()?.id;
199
+ if (!projectId)
200
+ this.error('No project configured. Run `lps project config` first.');
202
201
  const project = configManager.getProject(projectId);
203
202
  if (!project) {
204
203
  this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
@@ -209,14 +208,10 @@ export class LoopressCommand extends Command {
209
208
  }
210
209
  if (envName)
211
210
  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.`);
211
+ const env = configManager.getDefaultEnvironment(projectId);
212
+ if (!env) {
213
+ this.error(`Project "${project.name}" has no "local" environment, pass --env to pick one. Available: ${envNames.join(', ')}`);
219
214
  }
220
- return { env: currentEnv, projectId };
215
+ return { env, projectId };
221
216
  }
222
217
  }
@@ -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
@@ -23,6 +23,7 @@ export type DiffTarget = {
23
23
  type DiffSides = {
24
24
  againstWp: undefined | WpClient;
25
25
  leftLabel: string;
26
+ rightHeading: string;
26
27
  rightLabel: string;
27
28
  warn: (message: string) => void;
28
29
  };
@@ -37,6 +38,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
37
38
  abstract run(): Promise<DiffJson>;
38
39
  private computeResource;
39
40
  private out;
41
+ private renderFailure;
40
42
  private renderSection;
41
43
  private wpForEnvironment;
42
44
  }