@loopress/cli 0.21.0 → 0.23.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 +457 -67
- package/dist/commands/acf/list.d.ts +2 -2
- package/dist/commands/acf/list.js +2 -5
- package/dist/commands/acf/pull.js +9 -22
- package/dist/commands/acf/push.js +3 -6
- package/dist/commands/api/list.js +2 -1
- package/dist/commands/api/publish.js +6 -22
- package/dist/commands/api/pull.js +12 -17
- package/dist/commands/api/push.js +7 -10
- package/dist/commands/app/list.d.ts +17 -0
- package/dist/commands/app/list.js +35 -0
- package/dist/commands/app/pull.d.ts +27 -0
- package/dist/commands/app/pull.js +94 -0
- package/dist/commands/app/push.d.ts +28 -0
- package/dist/commands/app/push.js +130 -0
- package/dist/commands/app/remove.d.ts +20 -0
- package/dist/commands/app/remove.js +42 -0
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +43 -19
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +2 -1
- 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 +1 -0
- package/dist/commands/login.js +6 -2
- package/dist/commands/logout.js +2 -1
- package/dist/commands/page/pull.js +17 -28
- package/dist/commands/page/push.js +27 -52
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +18 -8
- package/dist/commands/plugin/audit.d.ts +30 -0
- package/dist/commands/plugin/audit.js +138 -0
- package/dist/commands/plugin/pull.d.ts +1 -1
- package/dist/commands/plugin/pull.js +13 -15
- package/dist/commands/plugin/push.d.ts +14 -5
- package/dist/commands/plugin/push.js +171 -60
- package/dist/commands/plugin/status.d.ts +22 -0
- package/dist/commands/plugin/status.js +65 -0
- package/dist/commands/project/config.js +4 -1
- 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 +79 -58
- 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.js +2 -1
- package/dist/commands/snippet/publish.js +5 -21
- package/dist/commands/snippet/pull.js +15 -20
- package/dist/commands/snippet/push.js +21 -30
- package/dist/commands/theme/add.d.ts +13 -0
- package/dist/commands/theme/add.js +35 -0
- package/dist/commands/theme/pull.d.ts +21 -0
- package/dist/commands/theme/pull.js +37 -0
- package/dist/commands/theme/push.d.ts +23 -0
- package/dist/commands/theme/push.js +130 -0
- package/dist/commands/theme/status.d.ts +20 -0
- package/dist/commands/theme/status.js +51 -0
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +24 -0
- package/dist/lib/app-manifest.d.ts +36 -0
- package/dist/lib/app-manifest.js +162 -0
- package/dist/lib/base.d.ts +10 -0
- package/dist/lib/base.js +56 -22
- package/dist/lib/guard-production-push.d.ts +12 -0
- package/dist/lib/guard-production-push.js +20 -0
- package/dist/lib/load-snippets.js +65 -60
- package/dist/lib/local-callback-server.js +9 -4
- 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/validate-local.d.ts +10 -0
- package/dist/lib/validate-local.js +183 -0
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/project-config.generated.d.ts +11 -1
- package/dist/utils/plugin-sync.d.ts +26 -0
- package/dist/utils/plugin-sync.js +31 -0
- package/dist/utils/plugins.d.ts +15 -2
- package/dist/utils/plugins.js +57 -12
- package/dist/utils/pluralize.d.ts +1 -0
- package/dist/utils/pluralize.js +5 -0
- package/dist/utils/themes.d.ts +30 -0
- package/dist/utils/themes.js +29 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/oclif.manifest.json +986 -125
- package/package.json +25 -20
- package/schemas/project-config.schema.json +22 -4
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
4
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
5
|
+
import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
|
|
6
|
+
export default class Status extends LoopressCommand {
|
|
7
|
+
static description = 'Compare the plugins on WordPress against loopress.json and report drift';
|
|
8
|
+
static enableJsonFlag = true;
|
|
9
|
+
static examples = ['$ lps plugin status'];
|
|
10
|
+
static flags = {};
|
|
11
|
+
async run() {
|
|
12
|
+
const { url } = this.siteConfig;
|
|
13
|
+
if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
|
|
14
|
+
this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer` commands instead.');
|
|
15
|
+
return { drift: false, inactive: [], missing: [], status: 'composer-managed', untrackedActive: [], wrongVersion: [] };
|
|
16
|
+
}
|
|
17
|
+
const manifest = this.localConfig.plugins ?? {};
|
|
18
|
+
const raw = await this.wp.get('wp/v2/plugins');
|
|
19
|
+
const installed = parseInstalledPlugins(raw);
|
|
20
|
+
const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'plugin');
|
|
21
|
+
const diff = diffPlugins(manifest, installed, managed);
|
|
22
|
+
const missing = diff.toInstall.map((a) => a.slug);
|
|
23
|
+
const wrongVersion = diff.toPin.map((p) => ({ live: p.from, pinned: p.to, slug: p.slug }));
|
|
24
|
+
const inactive = diff.toActivate.map((a) => a.slug);
|
|
25
|
+
const { untrackedActive } = diff;
|
|
26
|
+
// `collisions` are also drift (Loopress doesn't own them yet); surface them under missing's
|
|
27
|
+
// banner so `plugin push --force` is the obvious next step.
|
|
28
|
+
const collisions = diff.collisions.map((c) => c.slug);
|
|
29
|
+
const drift = missing.length > 0 ||
|
|
30
|
+
wrongVersion.length > 0 ||
|
|
31
|
+
inactive.length > 0 ||
|
|
32
|
+
diff.toRemove.length > 0 ||
|
|
33
|
+
collisions.length > 0;
|
|
34
|
+
if (!drift && untrackedActive.length === 0) {
|
|
35
|
+
this.log(`In sync with loopress.json (${url})`);
|
|
36
|
+
return { drift: false, inactive: [], missing: [], status: 'in-sync', untrackedActive: [], wrongVersion: [] };
|
|
37
|
+
}
|
|
38
|
+
if (missing.length > 0)
|
|
39
|
+
this.log(`Not installed: ${missing.join(', ')}`);
|
|
40
|
+
if (collisions.length > 0)
|
|
41
|
+
this.log(`Installed outside Loopress (run \`plugin push --force\`): ${collisions.join(', ')}`);
|
|
42
|
+
for (const w of wrongVersion)
|
|
43
|
+
this.log(`Version drift: ${w.slug} is ${w.live}, loopress.json pins ${w.pinned}`);
|
|
44
|
+
if (inactive.length > 0)
|
|
45
|
+
this.log(`Pinned but inactive: ${inactive.join(', ')}`);
|
|
46
|
+
if (diff.toRemove.length > 0)
|
|
47
|
+
this.log(`Managed by Loopress but dropped from loopress.json: ${diff.toRemove.join(', ')}`);
|
|
48
|
+
if (untrackedActive.length > 0)
|
|
49
|
+
this.log(`Active but untracked: ${untrackedActive.join(', ')}`);
|
|
50
|
+
if (drift)
|
|
51
|
+
this.exit(1);
|
|
52
|
+
return { drift, inactive, missing, status: drift ? 'drift' : 'in-sync', untrackedActive, wrongVersion };
|
|
53
|
+
}
|
|
54
|
+
async fetchInstanceLock() {
|
|
55
|
+
try {
|
|
56
|
+
const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
|
|
57
|
+
return composerLock;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (isNotFoundError(error))
|
|
61
|
+
return null;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -60,7 +60,10 @@ export default class Config extends Command {
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
});
|
|
63
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
@@ -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
|
+
}
|
|
@@ -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 {};
|