@loopress/cli 0.21.0 → 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 (65) hide show
  1. package/README.md +169 -56
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/dev.js +1 -1
  11. package/dist/commands/doctor.js +2 -1
  12. package/dist/commands/form/list.d.ts +2 -4
  13. package/dist/commands/form/list.js +3 -10
  14. package/dist/commands/form/pull.js +15 -24
  15. package/dist/commands/form/push.js +17 -42
  16. package/dist/commands/login.js +6 -2
  17. package/dist/commands/logout.js +2 -1
  18. package/dist/commands/page/pull.js +17 -28
  19. package/dist/commands/page/push.js +27 -52
  20. package/dist/commands/plugin/pull.d.ts +1 -0
  21. package/dist/commands/plugin/pull.js +8 -10
  22. package/dist/commands/plugin/push.d.ts +3 -0
  23. package/dist/commands/plugin/push.js +32 -22
  24. package/dist/commands/project/config.js +4 -1
  25. package/dist/commands/project/list.d.ts +16 -1
  26. package/dist/commands/project/list.js +22 -16
  27. package/dist/commands/project/pull.js +3 -2
  28. package/dist/commands/project/push.d.ts +5 -0
  29. package/dist/commands/project/push.js +79 -58
  30. package/dist/commands/promote.d.ts +18 -0
  31. package/dist/commands/promote.js +90 -0
  32. package/dist/commands/pull.d.ts +12 -0
  33. package/dist/commands/pull.js +51 -0
  34. package/dist/commands/push.d.ts +13 -0
  35. package/dist/commands/push.js +57 -0
  36. package/dist/commands/seo/list.d.ts +9 -2
  37. package/dist/commands/seo/list.js +9 -11
  38. package/dist/commands/seo/pull.js +14 -37
  39. package/dist/commands/seo/push.js +24 -48
  40. package/dist/commands/snippet/list.js +2 -1
  41. package/dist/commands/snippet/publish.js +5 -21
  42. package/dist/commands/snippet/pull.js +15 -20
  43. package/dist/commands/snippet/push.js +21 -30
  44. package/dist/commands/validate.d.ts +8 -0
  45. package/dist/commands/validate.js +24 -0
  46. package/dist/lib/base.d.ts +9 -0
  47. package/dist/lib/base.js +51 -22
  48. package/dist/lib/guard-production-push.d.ts +12 -0
  49. package/dist/lib/guard-production-push.js +20 -0
  50. package/dist/lib/load-snippets.js +65 -60
  51. package/dist/lib/local-callback-server.js +9 -4
  52. package/dist/lib/push-command.d.ts +3 -0
  53. package/dist/lib/push-command.js +22 -15
  54. package/dist/lib/put-or-create.d.ts +13 -0
  55. package/dist/lib/put-or-create.js +19 -0
  56. package/dist/lib/readdir-tolerant.d.ts +5 -0
  57. package/dist/lib/readdir-tolerant.js +11 -0
  58. package/dist/lib/resolve-linked-project.d.ts +8 -0
  59. package/dist/lib/resolve-linked-project.js +27 -0
  60. package/dist/lib/validate-local.d.ts +10 -0
  61. package/dist/lib/validate-local.js +183 -0
  62. package/dist/utils/pluralize.d.ts +1 -0
  63. package/dist/utils/pluralize.js +5 -0
  64. package/oclif.manifest.json +242 -27
  65. package/package.json +16 -15
@@ -17,21 +17,14 @@ export default class Pull extends LoopressCommand {
17
17
  const composerJson = await readComposerJson();
18
18
  const composerSlugs = composerJson ? getComposerManagedSlugs(composerJson) : [];
19
19
  const incoming = Object.fromEntries(installed.filter((p) => !composerSlugs.includes(p.slug)).map((p) => [p.slug, 'latest']));
20
- if (composerSlugs.length > 0) {
21
- const found = installed.filter((p) => composerSlugs.includes(p.slug)).map((p) => p.slug);
22
- if (found.length > 0) {
23
- this.log(`Skipping ${found.length} Composer-managed ${found.length === 1 ? 'plugin' : 'plugins'}: ${found.join(', ')}`);
24
- }
25
- }
20
+ this.logSkippedComposerManaged(installed.filter((p) => composerSlugs.includes(p.slug)).map((p) => p.slug));
26
21
  const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, incoming);
27
22
  if (this.dryRun) {
28
23
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
29
24
  if (added.length > 0)
30
25
  this.log(` + ${added.join(', ')}`);
31
- if (updated.length > 0) {
32
- for (const u of updated)
33
- this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
34
- }
26
+ for (const u of updated)
27
+ this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
35
28
  return { added, merged, status: 'dry-run', updated };
36
29
  }
37
30
  await writeLocalConfig({ ...this.localConfig, plugins: merged });
@@ -42,4 +35,9 @@ export default class Pull extends LoopressCommand {
42
35
  this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
43
36
  return { added, merged, status: 'success', updated };
44
37
  }
38
+ logSkippedComposerManaged(found) {
39
+ if (found.length > 0) {
40
+ this.log(`Skipping ${found.length} Composer-managed ${found.length === 1 ? 'plugin' : 'plugins'}: ${found.join(', ')}`);
41
+ }
42
+ }
45
43
  }
@@ -15,7 +15,10 @@ export default class Push extends PushCommand {
15
15
  };
16
16
  run(): Promise<PushResult>;
17
17
  private activatePlugin;
18
+ private applyPluginChanges;
18
19
  private installPlugin;
20
+ private logPlannedChanges;
21
+ private logSkippedComposerManaged;
19
22
  private performPluginAction;
20
23
  }
21
24
  export {};
@@ -2,6 +2,7 @@ import { Listr } from 'listr2';
2
2
  import { PushCommand } from '../../lib/push-command.js';
3
3
  import { getComposerManagedSlugs, readComposerJson } from '../../utils/composer.js';
4
4
  import { diffPlugins, parseInstalledPlugins } from '../../utils/plugins.js';
5
+ import { pluralize } from '../../utils/pluralize.js';
5
6
  export default class Push extends PushCommand {
6
7
  static description = 'Push plugins to WordPress to match loopress.json';
7
8
  static enableJsonFlag = true;
@@ -20,10 +21,7 @@ export default class Push extends PushCommand {
20
21
  const composerSlugs = composerJson ? getComposerManagedSlugs(composerJson) : [];
21
22
  const filteredManifest = Object.fromEntries(Object.entries(manifest).filter(([slug]) => !composerSlugs.includes(slug)));
22
23
  const skippedComposerManaged = composerSlugs.filter((slug) => Object.hasOwn(manifest, slug));
23
- if (skippedComposerManaged.length > 0) {
24
- this.log(`Skipping ${skippedComposerManaged.length} Composer-managed ${skippedComposerManaged.length === 1 ? 'plugin' : 'plugins'}: ${skippedComposerManaged.join(', ')}`);
25
- this.log('Run `lps composer push` to deploy them.');
26
- }
24
+ this.logSkippedComposerManaged(skippedComposerManaged);
27
25
  this.log(`Pushing plugins to ${url}`);
28
26
  const raw = await this.wp.get('wp/v2/plugins');
29
27
  const installed = parseInstalledPlugins(raw);
@@ -32,22 +30,24 @@ export default class Push extends PushCommand {
32
30
  this.log('Everything is already in sync.');
33
31
  return { activated: [], installed: [], skippedComposerManaged, status: 'in-sync' };
34
32
  }
35
- if (toInstall.length > 0) {
36
- this.log(`\nTo install (${toInstall.length}):`);
37
- for (const a of toInstall)
38
- this.log(` + ${a.slug}`);
39
- }
40
- if (toActivate.length > 0) {
41
- this.log(`\nTo activate (${toActivate.length}):`);
42
- for (const a of toActivate)
43
- this.log(` ↑ ${a.slug}`);
44
- }
33
+ this.logPlannedChanges(toInstall, toActivate);
45
34
  const installedSlugs = toInstall.map((a) => a.slug);
46
35
  const activatedSlugs = toActivate.map((a) => a.slug);
47
36
  if (this.dryRun)
48
37
  return { activated: activatedSlugs, installed: installedSlugs, skippedComposerManaged, status: 'dry-run' };
49
38
  // Installing with `status: active` activates in the same call, so installs never need a
50
39
  // separate activation step the way the old custom endpoint's two-step flow did.
40
+ await this.applyPluginChanges(toInstall, toActivate);
41
+ if (this.failedCount > 0) {
42
+ this.error(`${pluralize(this.failedCount, 'plugin')} failed to install or activate.`);
43
+ }
44
+ await this.recordSuccess();
45
+ return { activated: activatedSlugs, installed: installedSlugs, skippedComposerManaged, status: 'success' };
46
+ }
47
+ async activatePlugin(file, slug, task) {
48
+ await this.performPluginAction('activate', slug, async () => this.wp.put(`wp/v2/plugins/${file}`, { status: 'active' }), task);
49
+ }
50
+ async applyPluginChanges(toInstall, toActivate) {
51
51
  if (toInstall.length > 0) {
52
52
  await new Listr(toInstall.map((action) => ({
53
53
  task: async (_ctx, task) => this.installPlugin(action.slug, task),
@@ -60,18 +60,28 @@ export default class Push extends PushCommand {
60
60
  title: `Activate ${action.slug}`,
61
61
  })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
62
62
  }
63
- if (this.failedCount > 0) {
64
- this.error(`${this.failedCount} plugin${this.failedCount === 1 ? '' : 's'} failed to install or activate.`);
65
- }
66
- await this.recordSuccess();
67
- return { activated: activatedSlugs, installed: installedSlugs, skippedComposerManaged, status: 'success' };
68
- }
69
- async activatePlugin(file, slug, task) {
70
- await this.performPluginAction('activate', slug, async () => this.wp.put(`wp/v2/plugins/${file}`, { status: 'active' }), task);
71
63
  }
72
64
  async installPlugin(slug, task) {
73
65
  await this.performPluginAction('install', slug, async () => this.wp.post('wp/v2/plugins', { slug, status: 'active' }), task);
74
66
  }
67
+ logPlannedChanges(toInstall, toActivate) {
68
+ if (toInstall.length > 0) {
69
+ this.log(`\nTo install (${toInstall.length}):`);
70
+ for (const a of toInstall)
71
+ this.log(` + ${a.slug}`);
72
+ }
73
+ if (toActivate.length > 0) {
74
+ this.log(`\nTo activate (${toActivate.length}):`);
75
+ for (const a of toActivate)
76
+ this.log(` ↑ ${a.slug}`);
77
+ }
78
+ }
79
+ logSkippedComposerManaged(skippedComposerManaged) {
80
+ if (skippedComposerManaged.length === 0)
81
+ return;
82
+ this.log(`Skipping ${skippedComposerManaged.length} Composer-managed ${skippedComposerManaged.length === 1 ? 'plugin' : 'plugins'}: ${skippedComposerManaged.join(', ')}`);
83
+ this.log('Run `lps composer push` to deploy them.');
84
+ }
75
85
  async performPluginAction(verb, slug, request, task) {
76
86
  try {
77
87
  await request();
@@ -60,7 +60,10 @@ export default class Config extends Command {
60
60
  }
61
61
  },
62
62
  });
63
- const url = rawUrl.replace(/\/+$/, '');
63
+ let url = rawUrl;
64
+ while (url.endsWith('/')) {
65
+ url = url.slice(0, -1);
66
+ }
64
67
  const { appPassword, user } = await this.resolveCredentials(url);
65
68
  const token = `${user}:${appPassword}`;
66
69
  const env = {
@@ -1,6 +1,21 @@
1
1
  import { Command } from '@oclif/core';
2
+ type EnvironmentInfo = {
3
+ isCurrent: boolean;
4
+ name: string;
5
+ url: string;
6
+ };
7
+ type ProjectInfo = {
8
+ environments: EnvironmentInfo[];
9
+ id: string;
10
+ isCurrent: boolean;
11
+ name: string;
12
+ };
2
13
  export default class List extends Command {
3
14
  static description: string;
15
+ static enableJsonFlag: boolean;
4
16
  static examples: string[];
5
- run(): Promise<void>;
17
+ run(): Promise<ProjectInfo[]>;
18
+ private logEnvironment;
19
+ private logProject;
6
20
  }
21
+ export {};
@@ -3,28 +3,34 @@ import { configManager } from '../../config/project-config.manager.js';
3
3
  const c = ux.colorize;
4
4
  export default class List extends Command {
5
5
  static description = 'List configured WordPress projects';
6
+ static enableJsonFlag = true;
6
7
  static examples = ['$ lps project list'];
7
8
  async run() {
8
9
  await this.parse(List);
9
10
  const projects = configManager.listProjects();
10
11
  if (projects.length === 0) {
11
12
  this.log('No projects configured. Run `lps project config` first.');
12
- return;
13
- }
14
- for (const project of projects) {
15
- const envs = configManager.listEnvironments(project.id);
16
- const marker = project.isCurrent ? c('green', '●') : c('dim', '○');
17
- const name = project.isCurrent ? c('green', project.name) : project.name;
18
- const currentTag = project.isCurrent ? ` ${c('green', '[current]')}` : '';
19
- this.log(`${marker} ${name}${currentTag}`);
20
- for (const env of envs) {
21
- const envMarker = c(env.isCurrent ? 'cyan' : 'dim', '·');
22
- const envName = c(env.isCurrent ? 'cyan' : 'dim', env.name.padEnd(15));
23
- const envUrl = c('dim', env.url);
24
- const activeTag = env.isCurrent ? ` ${c('cyan', '←')}` : '';
25
- this.log(` ${envMarker} ${envName} ${envUrl}${activeTag}`);
26
- }
27
- this.log('');
13
+ return [];
28
14
  }
15
+ return projects.map((project) => this.logProject(project));
16
+ }
17
+ // Only name/url/isCurrent are surfaced, deliberately: EnvironmentConfig also carries the
18
+ // WordPress application password token, which must never end up in --json output.
19
+ logEnvironment(env) {
20
+ const envMarker = c(env.isCurrent ? 'cyan' : 'dim', '·');
21
+ const envName = c(env.isCurrent ? 'cyan' : 'dim', env.name.padEnd(15));
22
+ const envUrl = c('dim', env.url);
23
+ const activeTag = env.isCurrent ? ` ${c('cyan', '←')}` : '';
24
+ this.log(` ${envMarker} ${envName} ${envUrl}${activeTag}`);
25
+ return { isCurrent: env.isCurrent, name: env.name, url: env.url };
26
+ }
27
+ logProject(project) {
28
+ const marker = project.isCurrent ? c('green', '●') : c('dim', '○');
29
+ const name = project.isCurrent ? c('green', project.name) : project.name;
30
+ const currentTag = project.isCurrent ? ` ${c('green', '[current]')}` : '';
31
+ this.log(`${marker} ${name}${currentTag}`);
32
+ const environments = configManager.listEnvironments(project.id).map((env) => this.logEnvironment(env));
33
+ this.log('');
34
+ return { environments, id: project.id, isCurrent: project.isCurrent, name: project.name };
29
35
  }
30
36
  }
@@ -3,6 +3,7 @@ import { Listr } from 'listr2';
3
3
  import { authManager } from '../../config/auth.manager.js';
4
4
  import { configManager } from '../../config/project-config.manager.js';
5
5
  import { ApiClient } from '../../lib/api-client.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
6
7
  export default class Pull extends Command {
7
8
  static description = 'Pull projects and environments from your Loopress account that are not configured locally yet';
8
9
  static examples = ['$ lps project pull'];
@@ -31,7 +32,7 @@ export default class Pull extends Command {
31
32
  title: `Pull project "${apiProject.name}" from the API`,
32
33
  })), { concurrent: false, exitOnError: false }).run();
33
34
  }
34
- this.log(`\n✓ Pulled ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} from your Loopress account`);
35
+ this.log(`\n✓ Pulled ${pluralize(projectCount, 'project')}, ${pluralize(environmentCount, 'environment')} from your Loopress account`);
35
36
  }
36
37
  async fetchApiProjects(api) {
37
38
  try {
@@ -64,6 +65,6 @@ export default class Pull extends Command {
64
65
  });
65
66
  const envCount = apiProject.environments.length;
66
67
  if (task)
67
- task.output = `Pulled with ${envCount} environment${envCount === 1 ? '' : 's'}`;
68
+ task.output = `Pulled with ${pluralize(envCount, 'environment')}`;
68
69
  }
69
70
  }
@@ -8,10 +8,15 @@ export default class Push extends Command {
8
8
  private yes;
9
9
  run(): Promise<void>;
10
10
  private applyEnvironment;
11
+ private applyEnvironmentPlans;
11
12
  private applyProject;
13
+ private applyProjectPlans;
14
+ private collectEnvsNeedingWork;
12
15
  private confirmLink;
13
16
  private fetchApiProjects;
17
+ private planAll;
14
18
  private planEnvironment;
15
19
  private planProject;
20
+ private pushAllCredentials;
16
21
  private pushCredentials;
17
22
  }
@@ -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';
@@ -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);
@@ -182,6 +187,22 @@ export default class Push extends Command {
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;
@@ -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
+ }