@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.
- package/README.md +254 -75
- package/dist/commands/acf/list.d.ts +2 -2
- package/dist/commands/acf/list.js +4 -6
- package/dist/commands/acf/pull.js +9 -22
- package/dist/commands/acf/push.js +4 -7
- package/dist/commands/api/list.d.ts +7 -4
- package/dist/commands/api/list.js +5 -11
- package/dist/commands/api/publish.js +7 -23
- package/dist/commands/api/pull.d.ts +8 -1
- package/dist/commands/api/pull.js +22 -18
- package/dist/commands/api/push.d.ts +7 -1
- package/dist/commands/api/push.js +50 -23
- package/dist/commands/composer/init.js +6 -6
- package/dist/commands/composer/pull.d.ts +7 -1
- package/dist/commands/composer/pull.js +4 -2
- package/dist/commands/composer/push.d.ts +8 -1
- package/dist/commands/composer/push.js +3 -1
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.js +97 -0
- package/dist/commands/doctor.js +6 -5
- package/dist/commands/form/list.d.ts +2 -4
- package/dist/commands/form/list.js +3 -10
- package/dist/commands/form/pull.js +15 -24
- package/dist/commands/form/push.js +17 -42
- package/dist/commands/init.js +7 -7
- package/dist/commands/login.js +9 -5
- package/dist/commands/logout.js +2 -1
- package/dist/commands/page/list.d.ts +2 -4
- package/dist/commands/page/list.js +3 -10
- package/dist/commands/page/pull.d.ts +13 -1
- package/dist/commands/page/pull.js +21 -29
- package/dist/commands/page/push.d.ts +11 -1
- package/dist/commands/page/push.js +31 -49
- package/dist/commands/plugin/add.js +3 -3
- package/dist/commands/plugin/pull.d.ts +8 -1
- package/dist/commands/plugin/pull.js +11 -11
- package/dist/commands/plugin/push.d.ts +12 -1
- package/dist/commands/plugin/push.js +41 -27
- package/dist/commands/project/config.js +12 -9
- package/dist/commands/project/list.d.ts +16 -1
- package/dist/commands/project/list.js +22 -16
- package/dist/commands/project/pull.js +3 -2
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +84 -63
- package/dist/commands/project/switch.js +2 -2
- package/dist/commands/promote.d.ts +18 -0
- package/dist/commands/promote.js +90 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.js +51 -0
- package/dist/commands/push.d.ts +13 -0
- package/dist/commands/push.js +57 -0
- package/dist/commands/seo/list.d.ts +9 -2
- package/dist/commands/seo/list.js +9 -11
- package/dist/commands/seo/pull.js +14 -37
- package/dist/commands/seo/push.js +24 -48
- package/dist/commands/snippet/list.d.ts +3 -4
- package/dist/commands/snippet/list.js +5 -11
- package/dist/commands/snippet/publish.js +5 -21
- package/dist/commands/snippet/pull.d.ts +13 -1
- package/dist/commands/snippet/pull.js +18 -20
- package/dist/commands/snippet/push.d.ts +11 -1
- package/dist/commands/snippet/push.js +29 -28
- package/dist/commands/status.d.ts +13 -1
- package/dist/commands/status.js +25 -18
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +24 -0
- package/dist/config/auth.manager.d.ts +2 -2
- package/dist/config/project-config.manager.d.ts +1 -1
- package/dist/config/project-config.manager.js +5 -6
- package/dist/lib/base.d.ts +11 -2
- package/dist/lib/base.js +54 -24
- package/dist/lib/bootstrap-full-install.d.ts +1 -1
- package/dist/lib/dev-watch.d.ts +16 -0
- package/dist/lib/dev-watch.js +71 -0
- package/dist/lib/find-orphaned-files.d.ts +3 -2
- package/dist/lib/find-orphaned-files.js +40 -4
- package/dist/lib/github-release.js +1 -1
- package/dist/lib/guard-production-push.d.ts +12 -0
- package/dist/lib/guard-production-push.js +20 -0
- package/dist/lib/interactive.js +1 -1
- package/dist/lib/load-files.d.ts +1 -0
- package/dist/lib/load-files.js +40 -11
- package/dist/lib/load-snippets.d.ts +1 -1
- package/dist/lib/load-snippets.js +66 -59
- package/dist/lib/local-callback-server.js +15 -8
- package/dist/lib/plugin-detection.d.ts +1 -1
- package/dist/lib/push-command.d.ts +3 -0
- package/dist/lib/push-command.js +22 -15
- package/dist/lib/put-or-create.d.ts +13 -0
- package/dist/lib/put-or-create.js +19 -0
- package/dist/lib/readdir-tolerant.d.ts +5 -0
- package/dist/lib/readdir-tolerant.js +11 -0
- package/dist/lib/resolve-linked-project.d.ts +8 -0
- package/dist/lib/resolve-linked-project.js +27 -0
- package/dist/lib/rotate-app-password.d.ts +1 -1
- package/dist/lib/rotate-app-password.js +1 -1
- package/dist/lib/temp-admin.d.ts +3 -3
- package/dist/lib/temp-admin.js +2 -2
- package/dist/lib/validate-local.d.ts +10 -0
- package/dist/lib/validate-local.js +183 -0
- package/dist/lib/wp-authorize-flow.js +5 -1
- package/dist/lib/wp-client.d.ts +2 -2
- package/dist/lib/wp-client.js +7 -0
- package/dist/types/plugin.d.ts +4 -4
- package/dist/types/snippet.d.ts +3 -3
- package/dist/utils/composer.d.ts +2 -2
- package/dist/utils/form-format.js +1 -1
- package/dist/utils/loopress-config.d.ts +1 -1
- package/dist/utils/page-format.js +2 -2
- package/dist/utils/plugins.d.ts +5 -5
- package/dist/utils/plugins.js +3 -3
- package/dist/utils/pluralize.d.ts +1 -0
- package/dist/utils/pluralize.js +5 -0
- package/dist/utils/seo-format.d.ts +6 -6
- package/dist/utils/snippet-format.d.ts +2 -2
- package/dist/utils/snippet-format.js +10 -5
- package/oclif.manifest.json +394 -57
- 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 =
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
162
|
-
if (
|
|
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
|
|
178
|
-
if (
|
|
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 ?
|
|
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<
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
this.log(
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
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');
|