@loopress/cli 0.20.1 → 0.22.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 (118) hide show
  1. package/README.md +254 -75
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +4 -6
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +4 -7
  6. package/dist/commands/api/list.d.ts +7 -4
  7. package/dist/commands/api/list.js +5 -11
  8. package/dist/commands/api/publish.js +7 -23
  9. package/dist/commands/api/pull.d.ts +8 -1
  10. package/dist/commands/api/pull.js +22 -18
  11. package/dist/commands/api/push.d.ts +7 -1
  12. package/dist/commands/api/push.js +50 -23
  13. package/dist/commands/composer/init.js +6 -6
  14. package/dist/commands/composer/pull.d.ts +7 -1
  15. package/dist/commands/composer/pull.js +4 -2
  16. package/dist/commands/composer/push.d.ts +8 -1
  17. package/dist/commands/composer/push.js +3 -1
  18. package/dist/commands/dev.d.ts +13 -0
  19. package/dist/commands/dev.js +97 -0
  20. package/dist/commands/doctor.js +6 -5
  21. package/dist/commands/form/list.d.ts +2 -4
  22. package/dist/commands/form/list.js +3 -10
  23. package/dist/commands/form/pull.js +15 -24
  24. package/dist/commands/form/push.js +17 -42
  25. package/dist/commands/init.js +7 -7
  26. package/dist/commands/login.js +9 -5
  27. package/dist/commands/logout.js +2 -1
  28. package/dist/commands/page/list.d.ts +2 -4
  29. package/dist/commands/page/list.js +3 -10
  30. package/dist/commands/page/pull.d.ts +13 -1
  31. package/dist/commands/page/pull.js +21 -29
  32. package/dist/commands/page/push.d.ts +11 -1
  33. package/dist/commands/page/push.js +31 -49
  34. package/dist/commands/plugin/add.js +3 -3
  35. package/dist/commands/plugin/pull.d.ts +8 -1
  36. package/dist/commands/plugin/pull.js +11 -11
  37. package/dist/commands/plugin/push.d.ts +12 -1
  38. package/dist/commands/plugin/push.js +41 -27
  39. package/dist/commands/project/config.js +12 -9
  40. package/dist/commands/project/list.d.ts +16 -1
  41. package/dist/commands/project/list.js +22 -16
  42. package/dist/commands/project/pull.js +3 -2
  43. package/dist/commands/project/push.d.ts +5 -0
  44. package/dist/commands/project/push.js +84 -63
  45. package/dist/commands/project/switch.js +2 -2
  46. package/dist/commands/promote.d.ts +18 -0
  47. package/dist/commands/promote.js +90 -0
  48. package/dist/commands/pull.d.ts +12 -0
  49. package/dist/commands/pull.js +51 -0
  50. package/dist/commands/push.d.ts +13 -0
  51. package/dist/commands/push.js +57 -0
  52. package/dist/commands/seo/list.d.ts +9 -2
  53. package/dist/commands/seo/list.js +9 -11
  54. package/dist/commands/seo/pull.js +14 -37
  55. package/dist/commands/seo/push.js +24 -48
  56. package/dist/commands/snippet/list.d.ts +3 -4
  57. package/dist/commands/snippet/list.js +5 -11
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.d.ts +13 -1
  60. package/dist/commands/snippet/pull.js +18 -20
  61. package/dist/commands/snippet/push.d.ts +11 -1
  62. package/dist/commands/snippet/push.js +29 -28
  63. package/dist/commands/status.d.ts +13 -1
  64. package/dist/commands/status.js +25 -18
  65. package/dist/commands/validate.d.ts +8 -0
  66. package/dist/commands/validate.js +24 -0
  67. package/dist/config/auth.manager.d.ts +2 -2
  68. package/dist/config/project-config.manager.d.ts +1 -1
  69. package/dist/config/project-config.manager.js +5 -6
  70. package/dist/lib/base.d.ts +11 -2
  71. package/dist/lib/base.js +54 -24
  72. package/dist/lib/bootstrap-full-install.d.ts +1 -1
  73. package/dist/lib/dev-watch.d.ts +16 -0
  74. package/dist/lib/dev-watch.js +71 -0
  75. package/dist/lib/find-orphaned-files.d.ts +3 -2
  76. package/dist/lib/find-orphaned-files.js +40 -4
  77. package/dist/lib/github-release.js +1 -1
  78. package/dist/lib/guard-production-push.d.ts +12 -0
  79. package/dist/lib/guard-production-push.js +20 -0
  80. package/dist/lib/interactive.js +1 -1
  81. package/dist/lib/load-files.d.ts +1 -0
  82. package/dist/lib/load-files.js +40 -11
  83. package/dist/lib/load-snippets.d.ts +1 -1
  84. package/dist/lib/load-snippets.js +66 -59
  85. package/dist/lib/local-callback-server.js +15 -8
  86. package/dist/lib/plugin-detection.d.ts +1 -1
  87. package/dist/lib/push-command.d.ts +3 -0
  88. package/dist/lib/push-command.js +22 -15
  89. package/dist/lib/put-or-create.d.ts +13 -0
  90. package/dist/lib/put-or-create.js +19 -0
  91. package/dist/lib/readdir-tolerant.d.ts +5 -0
  92. package/dist/lib/readdir-tolerant.js +11 -0
  93. package/dist/lib/resolve-linked-project.d.ts +8 -0
  94. package/dist/lib/resolve-linked-project.js +27 -0
  95. package/dist/lib/rotate-app-password.d.ts +1 -1
  96. package/dist/lib/rotate-app-password.js +1 -1
  97. package/dist/lib/temp-admin.d.ts +3 -3
  98. package/dist/lib/temp-admin.js +2 -2
  99. package/dist/lib/validate-local.d.ts +10 -0
  100. package/dist/lib/validate-local.js +183 -0
  101. package/dist/lib/wp-authorize-flow.js +5 -1
  102. package/dist/lib/wp-client.d.ts +2 -2
  103. package/dist/lib/wp-client.js +7 -0
  104. package/dist/types/plugin.d.ts +4 -4
  105. package/dist/types/snippet.d.ts +3 -3
  106. package/dist/utils/composer.d.ts +2 -2
  107. package/dist/utils/form-format.js +1 -1
  108. package/dist/utils/loopress-config.d.ts +1 -1
  109. package/dist/utils/page-format.js +2 -2
  110. package/dist/utils/plugins.d.ts +5 -5
  111. package/dist/utils/plugins.js +3 -3
  112. package/dist/utils/pluralize.d.ts +1 -0
  113. package/dist/utils/pluralize.js +5 -0
  114. package/dist/utils/seo-format.d.ts +6 -6
  115. package/dist/utils/snippet-format.d.ts +2 -2
  116. package/dist/utils/snippet-format.js +10 -5
  117. package/oclif.manifest.json +394 -57
  118. package/package.json +18 -16
@@ -6,6 +6,7 @@ import { configManager } from '../../config/project-config.manager.js';
6
6
  import { ApiClient } from '../../lib/api-client.js';
7
7
  import { LoopressCommand } from '../../lib/base.js';
8
8
  import { isInteractive } from '../../lib/interactive.js';
9
+ import { pluralize } from '../../utils/pluralize.js';
9
10
  import { toSlug } from '../../utils/to-slug.js';
10
11
  export default class Push extends Command {
11
12
  static description = 'Push locally configured projects, environments and credentials to your Loopress account';
@@ -16,7 +17,7 @@ export default class Push extends Command {
16
17
  yes = false;
17
18
  async run() {
18
19
  const { flags } = await this.parse(Push);
19
- this.yes = Boolean(flags.yes);
20
+ this.yes = flags.yes;
20
21
  const token = authManager.getAuth()?.token;
21
22
  if (!token) {
22
23
  this.error('Not logged in. Run `lps login` first.');
@@ -28,67 +29,15 @@ export default class Push extends Command {
28
29
  }
29
30
  const api = new ApiClient(token);
30
31
  const apiProjects = await this.fetchApiProjects(api);
31
- const claimedProjectIds = new Set();
32
- const envPlansByProject = new Map();
33
- const projectPlans = [];
34
- for (const project of projects) {
35
- const plan = await this.planProject(project, apiProjects, claimedProjectIds);
36
- projectPlans.push(plan);
37
- const apiProject = plan.apiProjectId ? apiProjects.find((candidate) => candidate.id === plan.apiProjectId) : undefined;
38
- const claimedEnvironmentIds = new Set();
39
- const envPlans = [];
40
- for (const env of configManager.listEnvironments(project.id)) {
41
- envPlans.push(plan.action === 'create'
42
- ? { action: 'create', env, projectId: project.id }
43
- : await this.planEnvironment(env, project.id, apiProject, claimedEnvironmentIds));
44
- }
45
- envPlansByProject.set(project.id, envPlans);
46
- }
32
+ const { envPlansByProject, projectPlans } = await this.planAll(projects, apiProjects);
47
33
  let projectCount = projectPlans.filter((plan) => plan.action === 'synced').length;
48
34
  let environmentCount = [...envPlansByProject.values()].flat().filter((plan) => plan.action === 'synced').length;
49
35
  const projectsNeedingWork = projectPlans.filter((plan) => plan.action !== 'synced');
50
- if (projectsNeedingWork.length > 0) {
51
- await new Listr(projectsNeedingWork.map((plan) => ({
52
- task: async (_ctx, task) => {
53
- await this.applyProject(api, plan, task);
54
- projectCount++;
55
- },
56
- title: plan.action === 'create'
57
- ? `Create project "${plan.project.name}" on the API`
58
- : `Link project "${plan.project.name}" to the API`,
59
- })), { concurrent: false, exitOnError: false }).run();
60
- }
61
- const envsNeedingWork = projectPlans
62
- .filter((plan) => Boolean(plan.apiProjectId))
63
- .flatMap((plan) => (envPlansByProject.get(plan.project.id) ?? [])
64
- .filter((envPlan) => envPlan.action !== 'synced')
65
- .map((envPlan) => ({ apiProjectId: plan.apiProjectId, envPlan, projectName: plan.project.name })));
66
- if (envsNeedingWork.length > 0) {
67
- await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
68
- task: async (_ctx, task) => {
69
- await this.applyEnvironment(api, apiProjectId, envPlan, task);
70
- environmentCount++;
71
- },
72
- title: envPlan.action === 'create'
73
- ? `Create environment "${envPlan.env.name}" on "${projectName}"`
74
- : `Link environment "${envPlan.env.name}" on "${projectName}"`,
75
- })), { concurrent: false, exitOnError: false }).run();
76
- }
77
- for (const plan of projectPlans) {
78
- if (!plan.apiProjectId)
79
- continue;
80
- for (const envPlan of envPlansByProject.get(plan.project.id) ?? []) {
81
- if (!envPlan.apiEnvironmentId || !envPlan.env.token)
82
- continue;
83
- try {
84
- await this.pushCredentials(api, plan.apiProjectId, envPlan.apiEnvironmentId, envPlan.env);
85
- }
86
- catch (error) {
87
- this.warn(`Failed to push "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
88
- }
89
- }
90
- }
91
- this.log(`\n✓ Pushed ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} to your Loopress account`);
36
+ projectCount += await this.applyProjectPlans(api, projectsNeedingWork);
37
+ const envsNeedingWork = this.collectEnvsNeedingWork(projectPlans, envPlansByProject);
38
+ environmentCount += await this.applyEnvironmentPlans(api, envsNeedingWork);
39
+ await this.pushAllCredentials(api, projectPlans, envPlansByProject);
40
+ this.log(`\n✓ Pushed ${pluralize(projectCount, 'project')}, ${pluralize(environmentCount, 'environment')} to your Loopress account`);
92
41
  }
93
42
  async applyEnvironment(api, apiProjectId, envPlan, task) {
94
43
  try {
@@ -113,6 +62,21 @@ export default class Push extends Command {
113
62
  throw error;
114
63
  }
115
64
  }
65
+ async applyEnvironmentPlans(api, envsNeedingWork) {
66
+ if (envsNeedingWork.length === 0)
67
+ return 0;
68
+ let count = 0;
69
+ await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
70
+ task: async (_ctx, task) => {
71
+ await this.applyEnvironment(api, apiProjectId, envPlan, task);
72
+ count++;
73
+ },
74
+ title: envPlan.action === 'create'
75
+ ? `Create environment "${envPlan.env.name}" on "${projectName}"`
76
+ : `Link environment "${envPlan.env.name}" on "${projectName}"`,
77
+ })), { concurrent: false, exitOnError: false }).run();
78
+ return count;
79
+ }
116
80
  async applyProject(api, plan, task) {
117
81
  try {
118
82
  if (plan.action === 'create') {
@@ -133,6 +97,28 @@ export default class Push extends Command {
133
97
  throw error;
134
98
  }
135
99
  }
100
+ async applyProjectPlans(api, projectsNeedingWork) {
101
+ if (projectsNeedingWork.length === 0)
102
+ return 0;
103
+ let count = 0;
104
+ await new Listr(projectsNeedingWork.map((plan) => ({
105
+ task: async (_ctx, task) => {
106
+ await this.applyProject(api, plan, task);
107
+ count++;
108
+ },
109
+ title: plan.action === 'create'
110
+ ? `Create project "${plan.project.name}" on the API`
111
+ : `Link project "${plan.project.name}" to the API`,
112
+ })), { concurrent: false, exitOnError: false }).run();
113
+ return count;
114
+ }
115
+ collectEnvsNeedingWork(projectPlans, envPlansByProject) {
116
+ return projectPlans
117
+ .filter((plan) => Boolean(plan.apiProjectId))
118
+ .flatMap((plan) => (envPlansByProject.get(plan.project.id) ?? [])
119
+ .filter((envPlan) => envPlan.action !== 'synced')
120
+ .map((envPlan) => ({ apiProjectId: plan.apiProjectId, envPlan, projectName: plan.project.name })));
121
+ }
136
122
  // Linking to the existing match is the safe default: outside a TTY (or with --yes) it is
137
123
  // taken without prompting, and logged, so CI runs never mint duplicate projects.
138
124
  async confirmLink(message) {
@@ -151,6 +137,25 @@ export default class Push extends Command {
151
137
  return [];
152
138
  }
153
139
  }
140
+ async planAll(projects, apiProjects) {
141
+ const claimedProjectIds = new Set();
142
+ const envPlansByProject = new Map();
143
+ const projectPlans = [];
144
+ for (const project of projects) {
145
+ const plan = await this.planProject(project, apiProjects, claimedProjectIds);
146
+ projectPlans.push(plan);
147
+ const apiProject = plan.apiProjectId ? apiProjects.find((candidate) => candidate.id === plan.apiProjectId) : undefined;
148
+ const claimedEnvironmentIds = new Set();
149
+ const envPlans = [];
150
+ for (const env of configManager.listEnvironments(project.id)) {
151
+ envPlans.push(plan.action === 'create'
152
+ ? { action: 'create', env, projectId: project.id }
153
+ : await this.planEnvironment(env, project.id, apiProject, claimedEnvironmentIds));
154
+ }
155
+ envPlansByProject.set(project.id, envPlans);
156
+ }
157
+ return { envPlansByProject, projectPlans };
158
+ }
154
159
  async planEnvironment(env, projectId, apiProject, claimedEnvironmentIds) {
155
160
  if (env.apiEnvironmentId) {
156
161
  claimedEnvironmentIds.add(env.apiEnvironmentId);
@@ -158,8 +163,8 @@ export default class Push extends Command {
158
163
  }
159
164
  const match = apiProject?.environments.find((candidate) => candidate.name === env.name && !claimedEnvironmentIds.has(candidate.id));
160
165
  if (match) {
161
- const link = await this.confirmLink(`Environment "${env.name}" already exists on "${apiProject?.name}". Link to it instead of creating a new one?`);
162
- if (link) {
166
+ const isLink = await this.confirmLink(`Environment "${env.name}" already exists on "${apiProject?.name}". Link to it instead of creating a new one?`);
167
+ if (isLink) {
163
168
  claimedEnvironmentIds.add(match.id);
164
169
  return { action: 'link', apiEnvironmentId: match.id, env, projectId };
165
170
  }
@@ -174,14 +179,30 @@ export default class Push extends Command {
174
179
  const slug = toSlug(project.name);
175
180
  const match = apiProjects.find((candidate) => candidate.slug === slug && !claimedProjectIds.has(candidate.id));
176
181
  if (match) {
177
- const link = await this.confirmLink(`A project named "${project.name}" already exists on your account. Link to it instead of creating a new one?`);
178
- if (link) {
182
+ const isLink = await this.confirmLink(`A project named "${project.name}" already exists on your account. Link to it instead of creating a new one?`);
183
+ if (isLink) {
179
184
  claimedProjectIds.add(match.id);
180
185
  return { action: 'link', apiProjectId: match.id, project };
181
186
  }
182
187
  }
183
188
  return { action: 'create', project };
184
189
  }
190
+ async pushAllCredentials(api, projectPlans, envPlansByProject) {
191
+ for (const plan of projectPlans) {
192
+ if (!plan.apiProjectId)
193
+ continue;
194
+ for (const envPlan of envPlansByProject.get(plan.project.id) ?? []) {
195
+ if (!envPlan.apiEnvironmentId || !envPlan.env.token)
196
+ continue;
197
+ try {
198
+ await this.pushCredentials(api, plan.apiProjectId, envPlan.apiEnvironmentId, envPlan.env);
199
+ }
200
+ catch (error) {
201
+ this.warn(`Failed to push "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
202
+ }
203
+ }
204
+ }
205
+ }
185
206
  async pushCredentials(api, apiProjectId, apiEnvironmentId, env) {
186
207
  if (!env.token)
187
208
  return;
@@ -28,7 +28,7 @@ export default class Switch extends Command {
28
28
  return { envName: env.name, projectId: project.id, projectName: project.name };
29
29
  }
30
30
  const choices = groups.flatMap(({ envs, project }) => [
31
- new Separator(project.isCurrent ? c('green', `─── ${project.name} ───`) : c('dim', `─── ${project.name} ───`)),
31
+ new Separator(c(project.isCurrent ? 'green' : 'dim', `─── ${project.name} ───`)),
32
32
  ...envs.map((env) => ({
33
33
  name: `${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
34
34
  value: `${project.id}::${env.name}`,
@@ -40,7 +40,7 @@ export default class Switch extends Command {
40
40
  default: current && `${current.project.id}::${current.env.name}`,
41
41
  message: 'Select project / environment',
42
42
  });
43
- const [projectId, envName] = chosen.split('::');
43
+ const [projectId, envName] = chosen.split('::', 2);
44
44
  const { project } = groups.find((group) => group.project.id === projectId);
45
45
  return { envName, projectId, projectName: project.name };
46
46
  }
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Promote extends Command {
3
+ static args: {
4
+ from: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ to: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static examples: string[];
9
+ static flags: {
10
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ private confirmPromotion;
15
+ private delegateArgv;
16
+ private requireEnvironment;
17
+ private resolveProject;
18
+ }
@@ -0,0 +1,90 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Command } from '@oclif/core';
3
+ import { configManager } from '../config/project-config.manager.js';
4
+ import { LoopressCommand } from '../lib/base.js';
5
+ import { isInteractive } from '../lib/interactive.js';
6
+ import { readLocalConfig } from '../utils/loopress-config.js';
7
+ export default class Promote extends Command {
8
+ static args = {
9
+ from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
10
+ to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
11
+ };
12
+ static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process.';
13
+ static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
14
+ static flags = {
15
+ ...LoopressCommand.dryRunFlag,
16
+ ...LoopressCommand.yesFlag,
17
+ };
18
+ async run() {
19
+ const { args, flags } = await this.parse(Promote);
20
+ const { yes } = flags;
21
+ const dryRun = flags['dry-run'];
22
+ const { environments, projectName } = await this.resolveProject();
23
+ const from = this.requireEnvironment(environments, projectName, args.from);
24
+ const to = this.requireEnvironment(environments, projectName, args.to);
25
+ if (from.name === to.name) {
26
+ this.error('<from> and <to> must be different environments.');
27
+ }
28
+ if (!dryRun)
29
+ await this.confirmPromotion(from.name, to.name, to.url, yes);
30
+ this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
31
+ try {
32
+ await this.config.runCommand('pull', this.delegateArgv(from.name, dryRun));
33
+ }
34
+ catch (error) {
35
+ // A partial pull must never be pushed onward: stop before touching <to>.
36
+ this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
37
+ }
38
+ this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
39
+ await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
40
+ this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
41
+ }
42
+ // Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
43
+ // target, not just production, so it needs its own confirmation regardless of the name. That
44
+ // prompt also calls out production when relevant, making a second production-only guard
45
+ // redundant.
46
+ async confirmPromotion(from, to, toUrl, yes) {
47
+ if (yes)
48
+ return;
49
+ const isProduction = to.toLowerCase() === 'production';
50
+ const warning = isProduction ? ` "${to}" is a production environment.` : '';
51
+ if (!isInteractive()) {
52
+ this.error(`This overwrites local tracked files with ${from} and pushes them to ${to} (${toUrl}).${warning} Pass --yes to confirm.`);
53
+ }
54
+ const isProceed = await confirm({
55
+ default: !isProduction,
56
+ message: `Promote ${from} to ${to} (${toUrl})? This overwrites local tracked files with ${from}.${warning}`,
57
+ });
58
+ if (!isProceed)
59
+ this.error('Aborted.');
60
+ }
61
+ // `--yes` is always forwarded: confirmPromotion above already gathered intent once, so the
62
+ // delegated `pull` and `push` must not prompt again (push would otherwise re-run its own
63
+ // production guard, and pull its orphan-deletion prompt).
64
+ delegateArgv(env, dryRun) {
65
+ const argv = ['--env', env, '--yes'];
66
+ if (dryRun)
67
+ argv.push('--dry-run');
68
+ return argv;
69
+ }
70
+ requireEnvironment(environments, projectName, name) {
71
+ const env = environments[name];
72
+ if (!env) {
73
+ this.error(`Environment "${name}" not found in project "${projectName}". Available: ${Object.keys(environments).join(', ')}`);
74
+ }
75
+ return env;
76
+ }
77
+ // Same project resolution as base.ts / status.ts: the pinned project from loopress.json, or
78
+ // the globally active one.
79
+ async resolveProject() {
80
+ const { projectId } = await readLocalConfig();
81
+ const project = projectId ? configManager.getProject(projectId) : configManager.getCurrentProject();
82
+ if (!project) {
83
+ this.error('No project configured. Run `lps project config` first.');
84
+ }
85
+ if (Object.keys(project.environments).length === 0) {
86
+ this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
87
+ }
88
+ return { environments: project.environments, projectName: project.name };
89
+ }
90
+ }
@@ -0,0 +1,12 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ private failedCount;
10
+ run(): Promise<void>;
11
+ private buildArgv;
12
+ }
@@ -0,0 +1,51 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ import { pluralize } from '../utils/pluralize.js';
3
+ // composer before plugins: `plugin:pull` reads the local composer.json to skip
4
+ // Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
5
+ // directories and their order does not matter, it mirrors `lps push` for familiarity.
6
+ const PULL_TARGETS = [
7
+ { commandId: 'composer:pull', label: 'composer' },
8
+ { commandId: 'plugin:pull', label: 'plugins' },
9
+ { commandId: 'acf:pull', label: 'ACF', supportsYes: true },
10
+ { commandId: 'api:pull', label: 'API routes', supportsYes: true },
11
+ { commandId: 'form:pull', label: 'forms', supportsYes: true },
12
+ { commandId: 'page:pull', label: 'pages', supportsYes: true },
13
+ { commandId: 'seo:pull', label: 'SEO', supportsYes: true },
14
+ { commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
15
+ ];
16
+ export default class Pull extends LoopressCommand {
17
+ static description = 'Pull all content, plugins, composer dependencies, ACF, API routes, forms, pages, SEO, and snippets, from WordPress';
18
+ static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
19
+ static flags = {
20
+ ...LoopressCommand.dryRunFlag,
21
+ ...LoopressCommand.yesFlag,
22
+ };
23
+ failedCount = 0;
24
+ async run() {
25
+ for (const target of PULL_TARGETS) {
26
+ this.log(`\n→ Pulling ${target.label}...`);
27
+ try {
28
+ await this.config.runCommand(target.commandId, this.buildArgv(target));
29
+ this.log(`✓ ${target.label} pulled`);
30
+ }
31
+ catch (error) {
32
+ this.failedCount++;
33
+ this.log(`✗ ${target.label} failed: ${error.message}`);
34
+ }
35
+ }
36
+ if (this.failedCount > 0) {
37
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to pull.`);
38
+ }
39
+ this.log('\nAll resources pulled.');
40
+ }
41
+ // `--yes` is forwarded only when the user passed it and the target accepts it: unlike
42
+ // `lps push`, a bare `lps pull` should still prompt before removing local files.
43
+ buildArgv(target) {
44
+ const argv = ['--env', this.siteConfig.name];
45
+ if (this.dryRun)
46
+ argv.push('--dry-run');
47
+ if (this.yes && target.supportsYes)
48
+ argv.push('--yes');
49
+ return argv;
50
+ }
51
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ export default class Push extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ private failedCount;
10
+ run(): Promise<void>;
11
+ private buildArgv;
12
+ private guardProductionPush;
13
+ }
@@ -0,0 +1,57 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ import { guardProductionPush } from '../lib/guard-production-push.js';
3
+ import { pluralize } from '../utils/pluralize.js';
4
+ // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
5
+ // relies on, then content itself.
6
+ const PUSH_TARGETS = [
7
+ { commandId: 'plugin:push', label: 'plugins' },
8
+ { commandId: 'composer:push', label: 'composer' },
9
+ { commandId: 'acf:push', label: 'ACF' },
10
+ { commandId: 'api:push', label: 'API routes' },
11
+ { commandId: 'form:push', label: 'forms' },
12
+ { commandId: 'page:push', label: 'pages' },
13
+ { commandId: 'seo:push', label: 'SEO' },
14
+ { commandId: 'snippet:push', label: 'snippets' },
15
+ ];
16
+ export default class Push extends LoopressCommand {
17
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, forms, pages, SEO, and snippets, to WordPress';
18
+ static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
19
+ static flags = {
20
+ ...LoopressCommand.dryRunFlag,
21
+ ...LoopressCommand.yesFlag,
22
+ };
23
+ failedCount = 0;
24
+ async run() {
25
+ await this.guardProductionPush();
26
+ const argv = this.buildArgv();
27
+ for (const target of PUSH_TARGETS) {
28
+ this.log(`\n→ Pushing ${target.label}...`);
29
+ try {
30
+ await this.config.runCommand(target.commandId, argv);
31
+ this.log(`✓ ${target.label} pushed`);
32
+ }
33
+ catch (error) {
34
+ this.failedCount++;
35
+ this.log(`✗ ${target.label} failed: ${error.message}`);
36
+ }
37
+ }
38
+ if (this.failedCount > 0) {
39
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
40
+ }
41
+ this.log('\nAll resources pushed.');
42
+ }
43
+ // Every delegated push always gets --yes: either this command's own guard already confirmed
44
+ // production once above, or the target isn't production and there's nothing to confirm.
45
+ // Without this, each of the 8 delegated commands would re-run its own production guard.
46
+ buildArgv() {
47
+ const argv = ['--env', this.siteConfig.name, '--yes'];
48
+ if (this.dryRun)
49
+ argv.push('--dry-run');
50
+ return argv;
51
+ }
52
+ // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
53
+ // delegated command.
54
+ async guardProductionPush() {
55
+ await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
56
+ }
57
+ }
@@ -1,11 +1,18 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type SeoPostMeta, type SeoRedirect } from '../../utils/seo-format.js';
3
+ type ListResult = {
4
+ postMeta: Record<string, SeoPostMeta[]>;
5
+ redirects: SeoRedirect[] | undefined;
6
+ redirectsUnsupported: string | undefined;
7
+ };
2
8
  export default class List extends LoopressCommand {
3
9
  static description: string;
10
+ static enableJsonFlag: boolean;
4
11
  static examples: string[];
5
12
  static flags: {
6
- json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
13
  'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
14
  };
9
- run(): Promise<void>;
15
+ run(): Promise<ListResult>;
10
16
  private fetchRedirects;
11
17
  }
18
+ export {};
@@ -3,9 +3,9 @@ import { LoopressCommand } from '../../lib/base.js';
3
3
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, seoPostMetaEndpoint } from '../../utils/seo-format.js';
4
4
  export default class List extends LoopressCommand {
5
5
  static description = 'List posts with SEO meta, and redirects if supported by the active SEO plugin, on WordPress';
6
+ static enableJsonFlag = true;
6
7
  static examples = ['$ lps seo list', '$ lps seo list --post-type post'];
7
8
  static flags = {
8
- json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
9
9
  'post-type': Flags.string({ description: 'Limit to specific post types', multiple: true }),
10
10
  };
11
11
  async run() {
@@ -16,10 +16,6 @@ export default class List extends LoopressCommand {
16
16
  byType[postType] = await this.wp.get(seoPostMetaEndpoint(postType));
17
17
  }
18
18
  const { redirects, unsupportedReason } = await this.fetchRedirects();
19
- if (flags.json) {
20
- this.log(JSON.stringify({ postMeta: byType, redirects: redirects ?? undefined, redirectsUnsupported: unsupportedReason }, null, 2));
21
- return;
22
- }
23
19
  for (const postType of postTypes) {
24
20
  const posts = byType[postType];
25
21
  this.log(`${postType} (${posts.length}):`);
@@ -35,16 +31,18 @@ export default class List extends LoopressCommand {
35
31
  }
36
32
  if (unsupportedReason) {
37
33
  this.log(`redirects: ${unsupportedReason}`);
38
- return;
34
+ return { postMeta: byType, redirects: undefined, redirectsUnsupported: unsupportedReason };
39
35
  }
40
36
  this.log(`redirects (${redirects.length}):`);
41
- if (redirects.length === 0) {
42
- this.log(' (none)');
43
- return;
37
+ if (redirects.length > 0) {
38
+ for (const redirect of redirects) {
39
+ this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
40
+ }
44
41
  }
45
- for (const redirect of redirects) {
46
- this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
42
+ else {
43
+ this.log(' (none)');
47
44
  }
45
+ return { postMeta: byType, redirects: redirects, redirectsUnsupported: undefined };
48
46
  }
49
47
  // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast); reported
50
48
  // as a line in the listing rather than failing the whole command, since post meta above may
@@ -1,5 +1,4 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
4
  import { LoopressCommand } from '../../lib/base.js';
@@ -36,24 +35,13 @@ export default class Pull extends LoopressCommand {
36
35
  extensions: ['.json'],
37
36
  key: basenameKey,
38
37
  });
39
- if (this.dryRun) {
40
- this.log(`[dry-run] Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`);
41
- if (orphans.length > 0) {
42
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
43
- }
44
- return;
45
- }
46
- if (remote.length > 0)
47
- await mkdir(dir, { recursive: true });
48
- await new Listr(remote.map((post) => ({
49
- async task(_ctx, task) {
50
- await writeFile(join(dir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n');
51
- task.output = `Pulled: ${post.slug}`;
52
- },
53
- title: `Pull ${post.slug}`,
54
- }))).run();
55
- await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
56
- this.log(`Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`);
38
+ await this.pullDirectory(dir, remote, orphans, {
39
+ dryRunMessage: `Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`,
40
+ orphanReason: `in ${dir} no longer present on WordPress`,
41
+ pulledMessage: `Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`,
42
+ title: (post) => post.slug,
43
+ write: async (post, writeDir) => writeFile(join(writeDir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n'),
44
+ });
57
45
  }
58
46
  // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
59
47
  // push (which must fail loudly if the user has local redirect files that can't be synced),
@@ -73,24 +61,13 @@ export default class Pull extends LoopressCommand {
73
61
  extensions: ['.json'],
74
62
  key: numericPrefixKey,
75
63
  });
76
- if (this.dryRun) {
77
- this.log(`[dry-run] Would pull ${remote.length} redirect(s) to ${dir}`);
78
- if (orphans.length > 0) {
79
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
80
- }
81
- return;
82
- }
83
- if (remote.length > 0)
84
- await mkdir(dir, { recursive: true });
85
- await new Listr(remote.map((redirect) => ({
86
- async task(_ctx, task) {
87
- await writeFile(join(dir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n');
88
- task.output = `Pulled: redirect #${redirect.id}`;
89
- },
90
- title: `Pull redirect #${redirect.id}`,
91
- }))).run();
92
- await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
93
- this.log(`Pulled ${remote.length} redirect(s) to ${dir}`);
64
+ await this.pullDirectory(dir, remote, orphans, {
65
+ dryRunMessage: `Would pull ${remote.length} redirect(s) to ${dir}`,
66
+ orphanReason: `in ${dir} no longer present on WordPress`,
67
+ pulledMessage: `Pulled ${remote.length} redirect(s) to ${dir}`,
68
+ title: (redirect) => `redirect #${redirect.id}`,
69
+ write: async (redirect, writeDir) => writeFile(join(writeDir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n'),
70
+ });
94
71
  }
95
72
  async pullSettings(basePath) {
96
73
  const file = join(basePath, 'settings.json');