@loopress/cli 0.18.0 → 0.20.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 +260 -75
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +12 -25
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +18 -47
- package/dist/commands/api/publish.d.ts +9 -0
- package/dist/commands/api/publish.js +60 -0
- package/dist/commands/api/pull.d.ts +1 -1
- package/dist/commands/api/pull.js +11 -26
- package/dist/commands/api/push.d.ts +2 -1
- package/dist/commands/api/push.js +40 -27
- package/dist/commands/composer/init.js +11 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +17 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/form/pull.d.ts +1 -2
- package/dist/commands/form/pull.js +13 -32
- package/dist/commands/form/push.d.ts +1 -1
- package/dist/commands/form/push.js +6 -14
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +65 -33
- package/dist/commands/page/list.d.ts +9 -0
- package/dist/commands/page/list.js +26 -0
- package/dist/commands/page/pull.d.ts +13 -0
- package/dist/commands/page/pull.js +65 -0
- package/dist/commands/page/push.d.ts +16 -0
- package/dist/commands/page/push.js +145 -0
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.d.ts +1 -0
- package/dist/commands/project/config.js +32 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- package/dist/commands/project/rotate.d.ts +7 -0
- package/dist/commands/project/rotate.js +20 -0
- package/dist/commands/seo/pull.d.ts +1 -3
- package/dist/commands/seo/pull.js +15 -43
- package/dist/commands/seo/push.d.ts +1 -1
- package/dist/commands/seo/push.js +4 -17
- package/dist/commands/snippet/publish.js +2 -2
- package/dist/commands/snippet/pull.d.ts +1 -4
- package/dist/commands/snippet/pull.js +12 -60
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +4 -13
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +25 -4
- package/dist/config/project-config.manager.js +2 -2
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/api-client.js +10 -3
- package/dist/lib/base.d.ts +12 -0
- package/dist/lib/base.js +100 -9
- package/dist/lib/bootstrap-full-install.d.ts +10 -0
- package/dist/lib/bootstrap-full-install.js +66 -0
- package/dist/lib/browser-launch.d.ts +8 -0
- package/dist/lib/browser-launch.js +20 -0
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -0
- package/dist/lib/github-release.d.ts +11 -0
- package/dist/lib/github-release.js +37 -0
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +7 -0
- package/dist/lib/load-files.d.ts +5 -0
- package/dist/lib/load-files.js +31 -0
- package/dist/lib/plugin-detection.d.ts +8 -0
- package/dist/lib/plugin-detection.js +11 -0
- package/dist/lib/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/rotate-app-password.d.ts +6 -0
- package/dist/lib/rotate-app-password.js +33 -0
- package/dist/lib/temp-admin.d.ts +19 -0
- package/dist/lib/temp-admin.js +46 -0
- package/dist/lib/wp-client.d.ts +10 -4
- package/dist/lib/wp-client.js +29 -12
- package/dist/types/project-config.generated.d.ts +4 -0
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/page-format.d.ts +7 -0
- package/dist/utils/page-format.js +78 -0
- package/dist/utils/seo-format.d.ts +1 -0
- package/dist/utils/seo-format.js +5 -0
- package/dist/utils/snippet-format.d.ts +2 -0
- package/dist/utils/snippet-format.js +27 -1
- package/dist/utils/to-slug.d.ts +1 -0
- package/dist/utils/to-slug.js +7 -0
- package/oclif.manifest.json +688 -29
- package/package.json +16 -9
- package/schemas/project-config.schema.json +5 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { confirm, input, password as passwordPrompt, select } from '@inquirer/prompts';
|
|
2
2
|
import { Command } from '@oclif/core';
|
|
3
3
|
import { configManager } from '../../config/project-config.manager.js';
|
|
4
|
+
import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
|
|
5
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
6
|
+
import { isLoopressFullActive } from '../../lib/plugin-detection.js';
|
|
4
7
|
import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
|
|
8
|
+
import { WpClient } from '../../lib/wp-client.js';
|
|
5
9
|
import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
|
|
6
10
|
const NEW_PROJECT = '__new__';
|
|
7
11
|
const AUTH_BROWSER = 'browser';
|
|
@@ -11,6 +15,9 @@ export default class Config extends Command {
|
|
|
11
15
|
static examples = ['$ lps project config'];
|
|
12
16
|
async run() {
|
|
13
17
|
await this.parse(Config);
|
|
18
|
+
if (!isInteractive()) {
|
|
19
|
+
this.error('lps project config asks its questions interactively and needs a terminal. Configure the project on your machine, then target environments in CI with --env.');
|
|
20
|
+
}
|
|
14
21
|
const { projectId, projectName } = await this.resolveProject();
|
|
15
22
|
const envChoice = await select({
|
|
16
23
|
choices: [
|
|
@@ -74,8 +81,33 @@ export default class Config extends Command {
|
|
|
74
81
|
configManager.setProject(projectId, project);
|
|
75
82
|
}
|
|
76
83
|
this.log(`✓ "${projectName}/${envName}" configured`);
|
|
84
|
+
await this.maybeBootstrapLoopressFull(env);
|
|
77
85
|
this.log('→ Run `lps project switch` to change the active project or environment');
|
|
78
86
|
}
|
|
87
|
+
async maybeBootstrapLoopressFull(env) {
|
|
88
|
+
const { token, url } = env;
|
|
89
|
+
if (!token)
|
|
90
|
+
return; // env was just built with one a few lines up; guard only narrows the type
|
|
91
|
+
const wp = new WpClient(url, token);
|
|
92
|
+
// A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
|
|
93
|
+
// there" rather than surprising the user with an account-creating prompt they didn't ask
|
|
94
|
+
// for; `lps project config` can simply be re-run if it genuinely isn't installed.
|
|
95
|
+
const alreadyActive = await isLoopressFullActive(wp).catch(() => true);
|
|
96
|
+
if (alreadyActive)
|
|
97
|
+
return;
|
|
98
|
+
const proceed = await confirm({
|
|
99
|
+
default: true,
|
|
100
|
+
message: 'Loopress Full was not detected on this site. Install it now? (creates a temporary admin account, removed automatically afterward)',
|
|
101
|
+
});
|
|
102
|
+
if (!proceed)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
await bootstrapLoopressFull(wp, url, (message) => this.log(message));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
this.warn(error.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
79
111
|
async promptManualCredentials() {
|
|
80
112
|
const user = await input({
|
|
81
113
|
message: 'Username',
|
|
@@ -2,9 +2,14 @@ import { Command } from '@oclif/core';
|
|
|
2
2
|
export default class Push extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
private yes;
|
|
5
9
|
run(): Promise<void>;
|
|
6
10
|
private applyEnvironment;
|
|
7
11
|
private applyProject;
|
|
12
|
+
private confirmLink;
|
|
8
13
|
private fetchApiProjects;
|
|
9
14
|
private planEnvironment;
|
|
10
15
|
private planProject;
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Command } from '@oclif/core';
|
|
3
3
|
import { Listr } from 'listr2';
|
|
4
|
-
import slugify from 'slugify';
|
|
5
4
|
import { authManager } from '../../config/auth.manager.js';
|
|
6
5
|
import { configManager } from '../../config/project-config.manager.js';
|
|
7
6
|
import { ApiClient } from '../../lib/api-client.js';
|
|
7
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
8
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
9
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
8
10
|
export default class Push extends Command {
|
|
9
11
|
static description = 'Push locally configured projects, environments and credentials to your Loopress account';
|
|
10
12
|
static examples = ['$ lps project push'];
|
|
13
|
+
static flags = {
|
|
14
|
+
...LoopressCommand.yesFlag,
|
|
15
|
+
};
|
|
16
|
+
yes = false;
|
|
11
17
|
async run() {
|
|
12
|
-
await this.parse(Push);
|
|
18
|
+
const { flags } = await this.parse(Push);
|
|
19
|
+
this.yes = Boolean(flags.yes);
|
|
13
20
|
const token = authManager.getAuth()?.token;
|
|
14
21
|
if (!token) {
|
|
15
22
|
this.error('Not logged in. Run `lps login` first.');
|
|
@@ -126,6 +133,15 @@ export default class Push extends Command {
|
|
|
126
133
|
throw error;
|
|
127
134
|
}
|
|
128
135
|
}
|
|
136
|
+
// Linking to the existing match is the safe default: outside a TTY (or with --yes) it is
|
|
137
|
+
// taken without prompting, and logged, so CI runs never mint duplicate projects.
|
|
138
|
+
async confirmLink(message) {
|
|
139
|
+
if (this.yes || !isInteractive()) {
|
|
140
|
+
this.log(`${message} Assuming yes (link).`);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return confirm({ default: true, message });
|
|
144
|
+
}
|
|
129
145
|
async fetchApiProjects(api) {
|
|
130
146
|
try {
|
|
131
147
|
return await api.get('projects');
|
|
@@ -142,10 +158,7 @@ export default class Push extends Command {
|
|
|
142
158
|
}
|
|
143
159
|
const match = apiProject?.environments.find((candidate) => candidate.name === env.name && !claimedEnvironmentIds.has(candidate.id));
|
|
144
160
|
if (match) {
|
|
145
|
-
const link = await
|
|
146
|
-
default: true,
|
|
147
|
-
message: `Environment "${env.name}" already exists on "${apiProject?.name}". Link to it instead of creating a new one?`,
|
|
148
|
-
});
|
|
161
|
+
const link = await this.confirmLink(`Environment "${env.name}" already exists on "${apiProject?.name}". Link to it instead of creating a new one?`);
|
|
149
162
|
if (link) {
|
|
150
163
|
claimedEnvironmentIds.add(match.id);
|
|
151
164
|
return { action: 'link', apiEnvironmentId: match.id, env, projectId };
|
|
@@ -158,13 +171,10 @@ export default class Push extends Command {
|
|
|
158
171
|
claimedProjectIds.add(project.apiProjectId);
|
|
159
172
|
return { action: 'synced', apiProjectId: project.apiProjectId, project };
|
|
160
173
|
}
|
|
161
|
-
const slug =
|
|
174
|
+
const slug = toSlug(project.name);
|
|
162
175
|
const match = apiProjects.find((candidate) => candidate.slug === slug && !claimedProjectIds.has(candidate.id));
|
|
163
176
|
if (match) {
|
|
164
|
-
const link = await
|
|
165
|
-
default: true,
|
|
166
|
-
message: `A project named "${project.name}" already exists on your account. Link to it instead of creating a new one?`,
|
|
167
|
-
});
|
|
177
|
+
const link = await this.confirmLink(`A project named "${project.name}" already exists on your account. Link to it instead of creating a new one?`);
|
|
168
178
|
if (link) {
|
|
169
179
|
claimedProjectIds.add(match.id);
|
|
170
180
|
return { action: 'link', apiProjectId: match.id, project };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { configManager } from '../../config/project-config.manager.js';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { rotateAppPassword } from '../../lib/rotate-app-password.js';
|
|
4
|
+
export default class Rotate extends LoopressCommand {
|
|
5
|
+
static description = 'Rotate the WordPress application password for the current (or --env) environment';
|
|
6
|
+
static examples = ['$ lps project rotate', '$ lps project rotate --env staging'];
|
|
7
|
+
// The rotation above always runs, regardless of age; the background age-check in
|
|
8
|
+
// LoopressCommand would just redo the same work a second time right after.
|
|
9
|
+
async maybeAutoRotate() { }
|
|
10
|
+
async run() {
|
|
11
|
+
const { name, token, url } = this.siteConfig;
|
|
12
|
+
if (!token) {
|
|
13
|
+
this.error(`No credentials configured for ${url}. Run \`lps project config\` first.`);
|
|
14
|
+
}
|
|
15
|
+
this.log(`Rotating application password for ${url}...`);
|
|
16
|
+
const rotated = await rotateAppPassword({ ...this.siteConfig, token });
|
|
17
|
+
configManager.setEnvironment(this.projectId, name, rotated);
|
|
18
|
+
this.log('✓ New application password created and verified, previous one revoked.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
-
import { SeoRedirect } from '../../utils/seo-format.js';
|
|
3
2
|
export default class Pull extends LoopressCommand {
|
|
4
3
|
static args: {
|
|
5
4
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -8,12 +7,11 @@ export default class Pull extends LoopressCommand {
|
|
|
8
7
|
static examples: string[];
|
|
9
8
|
static flags: {
|
|
10
9
|
'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
12
|
};
|
|
13
13
|
run(): Promise<void>;
|
|
14
|
-
private findOrphanedFiles;
|
|
15
14
|
private pullPostMeta;
|
|
16
15
|
private pullRedirects;
|
|
17
16
|
private pullSettings;
|
|
18
17
|
}
|
|
19
|
-
export declare function redirectFileBase(redirect: SeoRedirect): string;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import { mkdir,
|
|
4
|
-
import {
|
|
5
|
-
import slugify from 'slugify';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
-
import {
|
|
6
|
+
import { basenameKey, findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
7
|
+
import { DEFAULT_POST_TYPES, redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../../utils/seo-format.js';
|
|
8
8
|
export default class Pull extends LoopressCommand {
|
|
9
9
|
static args = {
|
|
10
10
|
path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
|
|
@@ -13,6 +13,7 @@ export default class Pull extends LoopressCommand {
|
|
|
13
13
|
static examples = ['$ lps seo pull', '$ lps seo pull --post-type post --post-type page'];
|
|
14
14
|
static flags = {
|
|
15
15
|
...LoopressCommand.dryRunFlag,
|
|
16
|
+
...LoopressCommand.yesFlag,
|
|
16
17
|
'post-type': Flags.string({ description: 'Limit post meta to specific post types', multiple: true }),
|
|
17
18
|
};
|
|
18
19
|
async run() {
|
|
@@ -28,33 +29,13 @@ export default class Pull extends LoopressCommand {
|
|
|
28
29
|
}
|
|
29
30
|
await this.pullRedirects(path);
|
|
30
31
|
}
|
|
31
|
-
// Shared by post-meta (`<slug>.json`) and redirects (`<id>-<slug>.json`) directories: a local
|
|
32
|
-
// file whose identity is no longer in the current remote list belongs to something deleted on
|
|
33
|
-
// WordPress. Left on disk, it would silently come back to life on the next `seo push`.
|
|
34
|
-
async findOrphanedFiles(dir, keepKeys, numericIdPrefix) {
|
|
35
|
-
let files;
|
|
36
|
-
try {
|
|
37
|
-
files = await readdir(dir);
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
if (error.code === 'ENOENT')
|
|
41
|
-
return [];
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
return files.filter((file) => {
|
|
45
|
-
if (extname(file) !== '.json')
|
|
46
|
-
return false;
|
|
47
|
-
if (numericIdPrefix) {
|
|
48
|
-
const match = /^(\d+)-/.exec(file);
|
|
49
|
-
return match !== null && !keepKeys.has(match[1]);
|
|
50
|
-
}
|
|
51
|
-
return !keepKeys.has(file.slice(0, -'.json'.length));
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
32
|
async pullPostMeta(postType, basePath) {
|
|
55
33
|
const dir = join(basePath, 'post-meta', postType);
|
|
56
34
|
const remote = await this.wp.get(seoPostMetaEndpoint(postType));
|
|
57
|
-
const orphans = await
|
|
35
|
+
const orphans = await findOrphanedFiles(dir, new Set(remote.map((post) => post.slug)), {
|
|
36
|
+
extensions: ['.json'],
|
|
37
|
+
key: basenameKey,
|
|
38
|
+
});
|
|
58
39
|
if (this.dryRun) {
|
|
59
40
|
this.log(`[dry-run] Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`);
|
|
60
41
|
if (orphans.length > 0) {
|
|
@@ -71,11 +52,7 @@ export default class Pull extends LoopressCommand {
|
|
|
71
52
|
},
|
|
72
53
|
title: `Pull ${post.slug}`,
|
|
73
54
|
}))).run();
|
|
74
|
-
|
|
75
|
-
await rm(join(dir, file), { force: true });
|
|
76
|
-
if (orphans.length > 0) {
|
|
77
|
-
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
78
|
-
}
|
|
55
|
+
await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
|
|
79
56
|
this.log(`Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`);
|
|
80
57
|
}
|
|
81
58
|
// Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
|
|
@@ -92,7 +69,10 @@ export default class Pull extends LoopressCommand {
|
|
|
92
69
|
this.warn(`Skipping redirects: ${error.message}`);
|
|
93
70
|
return;
|
|
94
71
|
}
|
|
95
|
-
const orphans = await
|
|
72
|
+
const orphans = await findOrphanedFiles(dir, new Set(remote.map((redirect) => String(redirect.id))), {
|
|
73
|
+
extensions: ['.json'],
|
|
74
|
+
key: numericPrefixKey,
|
|
75
|
+
});
|
|
96
76
|
if (this.dryRun) {
|
|
97
77
|
this.log(`[dry-run] Would pull ${remote.length} redirect(s) to ${dir}`);
|
|
98
78
|
if (orphans.length > 0) {
|
|
@@ -109,11 +89,7 @@ export default class Pull extends LoopressCommand {
|
|
|
109
89
|
},
|
|
110
90
|
title: `Pull redirect #${redirect.id}`,
|
|
111
91
|
}))).run();
|
|
112
|
-
|
|
113
|
-
await rm(join(dir, file), { force: true });
|
|
114
|
-
if (orphans.length > 0) {
|
|
115
|
-
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
116
|
-
}
|
|
92
|
+
await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
|
|
117
93
|
this.log(`Pulled ${remote.length} redirect(s) to ${dir}`);
|
|
118
94
|
}
|
|
119
95
|
async pullSettings(basePath) {
|
|
@@ -128,7 +104,3 @@ export default class Pull extends LoopressCommand {
|
|
|
128
104
|
this.log(`Pulled settings to ${file}`);
|
|
129
105
|
}
|
|
130
106
|
}
|
|
131
|
-
export function redirectFileBase(redirect) {
|
|
132
|
-
const slug = slugify(redirect.urlTo || 'redirect', { lower: true, strict: true });
|
|
133
|
-
return `${redirect.id}-${slug || 'redirect'}`;
|
|
134
|
-
}
|
|
@@ -6,9 +6,9 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
};
|
|
11
|
-
private failedCount;
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
private jsonFilesIn;
|
|
14
14
|
private pushPostMeta;
|
|
@@ -4,8 +4,7 @@ import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, extname, join } from 'node:path';
|
|
5
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
6
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
-
import { SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
|
|
8
|
-
import { redirectFileBase } from './pull.js';
|
|
7
|
+
import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
|
|
9
8
|
export default class Push extends PushCommand {
|
|
10
9
|
static args = {
|
|
11
10
|
path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
|
|
@@ -14,8 +13,8 @@ export default class Push extends PushCommand {
|
|
|
14
13
|
static examples = ['$ lps seo push'];
|
|
15
14
|
static flags = {
|
|
16
15
|
...PushCommand.dryRunFlag,
|
|
16
|
+
...PushCommand.yesFlag,
|
|
17
17
|
};
|
|
18
|
-
failedCount = 0;
|
|
19
18
|
async run() {
|
|
20
19
|
const { args } = await this.parse(Push);
|
|
21
20
|
const { url } = this.siteConfig;
|
|
@@ -79,13 +78,7 @@ export default class Push extends PushCommand {
|
|
|
79
78
|
task.output = `Pushed: ${post.slug}`;
|
|
80
79
|
}
|
|
81
80
|
catch (error) {
|
|
82
|
-
|
|
83
|
-
if (task)
|
|
84
|
-
task.output = message;
|
|
85
|
-
else
|
|
86
|
-
this.warn(` ${message}`);
|
|
87
|
-
this.failedCount++;
|
|
88
|
-
throw error;
|
|
81
|
+
this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
|
|
89
82
|
}
|
|
90
83
|
}
|
|
91
84
|
async pushRedirectFile(filePath, task) {
|
|
@@ -117,13 +110,7 @@ export default class Push extends PushCommand {
|
|
|
117
110
|
task.output = `Pushed: redirect #${created.id}`;
|
|
118
111
|
}
|
|
119
112
|
catch (error) {
|
|
120
|
-
|
|
121
|
-
if (task)
|
|
122
|
-
task.output = message;
|
|
123
|
-
else
|
|
124
|
-
this.warn(` ${message}`);
|
|
125
|
-
this.failedCount++;
|
|
126
|
-
throw error;
|
|
113
|
+
this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
|
|
127
114
|
}
|
|
128
115
|
}
|
|
129
116
|
async pushRedirects(basePath) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Args, Command } from '@oclif/core';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import slugify from 'slugify';
|
|
4
3
|
import { authManager } from '../../config/auth.manager.js';
|
|
5
4
|
import { configManager } from '../../config/project-config.manager.js';
|
|
6
5
|
import { ApiClient } from '../../lib/api-client.js';
|
|
7
6
|
import { loadSnippets } from '../../lib/load-snippets.js';
|
|
8
7
|
import { readLocalConfig } from '../../utils/loopress-config.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
9
9
|
// Publishes to the Loopress api (not a WordPress site), so this does not extend
|
|
10
10
|
// `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
|
|
11
11
|
// publishing its snippets for sharing doesn't need one, a pure "library" project
|
|
@@ -66,7 +66,7 @@ export default class Publish extends Command {
|
|
|
66
66
|
// Derived from the snippet's name rather than its on-disk filename: the same
|
|
67
67
|
// slugification `push`/`pull` already use for the canonical `<id>-<slug>` filename
|
|
68
68
|
// convention, so it stays stable even for a not-yet-pushed file with an arbitrary name.
|
|
69
|
-
slug:
|
|
69
|
+
slug: toSlug(snippet.name),
|
|
70
70
|
tags: snippet.tags,
|
|
71
71
|
type: snippet.type,
|
|
72
72
|
};
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
-
import { NormalizedSnippet } from '../../utils/snippet-format.js';
|
|
3
|
-
export declare function buildSnippetFile(snippet: NormalizedSnippet): string;
|
|
4
|
-
export declare function buildMetaFile(snippet: NormalizedSnippet): string;
|
|
5
2
|
export default class Pull extends LoopressCommand {
|
|
6
3
|
static args: {
|
|
7
4
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -9,8 +6,8 @@ export default class Pull extends LoopressCommand {
|
|
|
9
6
|
static description: string;
|
|
10
7
|
static examples: string[];
|
|
11
8
|
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
11
|
};
|
|
14
12
|
run(): Promise<void>;
|
|
15
|
-
private findOrphanedFiles;
|
|
16
13
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import { mkdir,
|
|
4
|
-
import {
|
|
5
|
-
import slugify from 'slugify';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
7
|
-
import {
|
|
6
|
+
import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
7
|
+
import { buildMetaFile, buildSnippetFile, normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
8
9
|
const EXTENSIONS = {
|
|
9
10
|
css: 'css',
|
|
10
11
|
html: 'html',
|
|
@@ -12,32 +13,6 @@ const EXTENSIONS = {
|
|
|
12
13
|
php: 'php',
|
|
13
14
|
text: 'txt',
|
|
14
15
|
};
|
|
15
|
-
export function buildSnippetFile(snippet) {
|
|
16
|
-
if (snippet.type === 'php' && !snippet.code.trimStart().startsWith('<?')) {
|
|
17
|
-
return `<?php\n\n${snippet.code}`;
|
|
18
|
-
}
|
|
19
|
-
return snippet.code;
|
|
20
|
-
}
|
|
21
|
-
export function buildMetaFile(snippet) {
|
|
22
|
-
const meta = {
|
|
23
|
-
id: snippet.id,
|
|
24
|
-
name: snippet.name,
|
|
25
|
-
type: snippet.type,
|
|
26
|
-
active: snippet.active,
|
|
27
|
-
location: snippet.location,
|
|
28
|
-
};
|
|
29
|
-
if (snippet.description)
|
|
30
|
-
meta.description = snippet.description;
|
|
31
|
-
if (snippet.tags.length > 0)
|
|
32
|
-
meta.tags = snippet.tags;
|
|
33
|
-
if (snippet.insertMethod === 'shortcode')
|
|
34
|
-
meta.insertMethod = snippet.insertMethod;
|
|
35
|
-
if (snippet.priority !== 10)
|
|
36
|
-
meta.priority = snippet.priority;
|
|
37
|
-
if (snippet.shortcodeAttributes.length > 0)
|
|
38
|
-
meta.shortcodeAttributes = snippet.shortcodeAttributes;
|
|
39
|
-
return JSON.stringify(meta, null, 2) + '\n';
|
|
40
|
-
}
|
|
41
16
|
export default class Pull extends LoopressCommand {
|
|
42
17
|
static args = {
|
|
43
18
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -46,6 +21,7 @@ export default class Pull extends LoopressCommand {
|
|
|
46
21
|
static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets'];
|
|
47
22
|
static flags = {
|
|
48
23
|
...LoopressCommand.dryRunFlag,
|
|
24
|
+
...LoopressCommand.yesFlag,
|
|
49
25
|
};
|
|
50
26
|
async run() {
|
|
51
27
|
const { args } = await this.parse(Pull);
|
|
@@ -60,7 +36,10 @@ export default class Pull extends LoopressCommand {
|
|
|
60
36
|
// Files following the `<id>-<slug>` convention whose id is no longer in the current
|
|
61
37
|
// remote list belong to a snippet that was deleted on WordPress. Left on disk, they'd
|
|
62
38
|
// silently come back to life the next time `snippet push` runs.
|
|
63
|
-
const orphans = await
|
|
39
|
+
const orphans = await findOrphanedFiles(path, new Set(pullable.map((snippet) => String(snippet.id))), {
|
|
40
|
+
extensions: ['.json', ...Object.values(EXTENSIONS).map((ext) => `.${ext}`)],
|
|
41
|
+
key: numericPrefixKey,
|
|
42
|
+
});
|
|
64
43
|
if (this.dryRun) {
|
|
65
44
|
this.log(`[dry-run] Would pull ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to ${path}`);
|
|
66
45
|
if (orphans.length > 0) {
|
|
@@ -72,44 +51,17 @@ export default class Pull extends LoopressCommand {
|
|
|
72
51
|
await new Listr(pullable.map((snippet) => ({
|
|
73
52
|
async task(_ctx, task) {
|
|
74
53
|
const ext = EXTENSIONS[snippet.type];
|
|
75
|
-
const
|
|
76
|
-
const base = `${snippet.id}-${slug}`;
|
|
54
|
+
const base = `${snippet.id}-${toSlug(snippet.name)}`;
|
|
77
55
|
await writeFile(join(path, `${base}.${ext}`), buildSnippetFile(snippet));
|
|
78
56
|
await writeFile(join(path, `${base}.json`), buildMetaFile(snippet));
|
|
79
57
|
task.output = `Pulled: ${snippet.name}`;
|
|
80
58
|
},
|
|
81
59
|
title: `Pull ${snippet.name}`,
|
|
82
60
|
}))).run();
|
|
83
|
-
|
|
84
|
-
await rm(join(path, file), { force: true });
|
|
85
|
-
if (orphans.length > 0) {
|
|
86
|
-
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
|
|
87
|
-
}
|
|
61
|
+
await this.removeOrphanedFiles(path, orphans, 'whose snippet no longer exists on WordPress');
|
|
88
62
|
this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
|
|
89
63
|
if (skipped > 0) {
|
|
90
64
|
this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
|
|
91
65
|
}
|
|
92
66
|
}
|
|
93
|
-
// Only ever matches files already following the `<id>-<slug>` convention that `snippet
|
|
94
|
-
// pull`/`push` themselves produce, so a hand-created file without a numeric prefix is
|
|
95
|
-
// never at risk of being picked up here.
|
|
96
|
-
async findOrphanedFiles(path, keepIds) {
|
|
97
|
-
let files;
|
|
98
|
-
try {
|
|
99
|
-
files = await readdir(path);
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
if (error.code === 'ENOENT')
|
|
103
|
-
return [];
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
const knownExtensions = new Set(['json', ...Object.values(EXTENSIONS)]);
|
|
107
|
-
return files.filter((file) => {
|
|
108
|
-
const ext = extname(file).slice(1);
|
|
109
|
-
if (!knownExtensions.has(ext))
|
|
110
|
-
return false;
|
|
111
|
-
const match = /^(\d+)-/.exec(file);
|
|
112
|
-
return match !== null && !keepIds.has(Number(match[1]));
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
67
|
}
|
|
@@ -6,9 +6,9 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
};
|
|
11
|
-
private failedCount;
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
private ensureCanonicalFilename;
|
|
14
14
|
private loadSnippets;
|
|
@@ -2,11 +2,11 @@ import { Args } from '@oclif/core';
|
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
3
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
|
-
import slugify from 'slugify';
|
|
6
5
|
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
7
6
|
import { PushCommand } from '../../lib/push-command.js';
|
|
8
7
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
9
8
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
9
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
10
10
|
export default class Push extends PushCommand {
|
|
11
11
|
static args = {
|
|
12
12
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -15,8 +15,8 @@ export default class Push extends PushCommand {
|
|
|
15
15
|
static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets'];
|
|
16
16
|
static flags = {
|
|
17
17
|
...PushCommand.dryRunFlag,
|
|
18
|
+
...PushCommand.yesFlag,
|
|
18
19
|
};
|
|
19
|
-
failedCount = 0;
|
|
20
20
|
async run() {
|
|
21
21
|
const { args } = await this.parse(Push);
|
|
22
22
|
const { url } = this.siteConfig;
|
|
@@ -44,7 +44,7 @@ export default class Push extends PushCommand {
|
|
|
44
44
|
const dir = dirname(snippet.path);
|
|
45
45
|
const ext = extname(snippet.path);
|
|
46
46
|
const currentBase = basename(snippet.path, ext);
|
|
47
|
-
const canonicalBase = `${id}-${
|
|
47
|
+
const canonicalBase = `${id}-${toSlug(name)}`;
|
|
48
48
|
const oldMetaPath = join(dir, `${currentBase}.json`);
|
|
49
49
|
let meta = {};
|
|
50
50
|
try {
|
|
@@ -79,9 +79,6 @@ export default class Push extends PushCommand {
|
|
|
79
79
|
this.error(error.message);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
83
|
-
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
84
|
-
// sibling snippets push regardless.
|
|
85
82
|
async pushSnippet(snippet, task) {
|
|
86
83
|
if (this.dryRun) {
|
|
87
84
|
if (task)
|
|
@@ -114,13 +111,7 @@ export default class Push extends PushCommand {
|
|
|
114
111
|
task.output = `Pushed: ${snippet.name}`;
|
|
115
112
|
}
|
|
116
113
|
catch (error) {
|
|
117
|
-
|
|
118
|
-
if (task)
|
|
119
|
-
task.output = message;
|
|
120
|
-
else
|
|
121
|
-
this.warn(` ${message}`);
|
|
122
|
-
this.failedCount++;
|
|
123
|
-
throw error;
|
|
114
|
+
this.reportTaskFailure(`Failed to push ${snippet.name}: ${error.message}`, error, task);
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
toPayload(snippet) {
|
|
@@ -2,7 +2,11 @@ import { Command } from '@oclif/core';
|
|
|
2
2
|
export default class Status extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
5
8
|
run(): Promise<void>;
|
|
6
9
|
private reportActiveProject;
|
|
10
|
+
private reportEnvOverride;
|
|
7
11
|
private reportPinnedProject;
|
|
8
12
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { Command, ux } from '@oclif/core';
|
|
1
|
+
import { Command, Flags, ux } from '@oclif/core';
|
|
2
2
|
import { configManager } from '../config/project-config.manager.js';
|
|
3
3
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
4
4
|
const c = ux.colorize;
|
|
5
5
|
export default class Status extends Command {
|
|
6
6
|
static description = 'Show which WordPress project and environment commands will target';
|
|
7
|
-
static examples = ['$ lps status'];
|
|
7
|
+
static examples = ['$ lps status', '$ lps status --env staging'];
|
|
8
|
+
static flags = {
|
|
9
|
+
env: Flags.string({ description: 'Show what would be targeted with this environment, as other commands do with --env' }),
|
|
10
|
+
};
|
|
8
11
|
async run() {
|
|
9
|
-
await this.parse(Status);
|
|
12
|
+
const { flags } = await this.parse(Status);
|
|
10
13
|
const localConfig = await readLocalConfig();
|
|
11
|
-
if (
|
|
14
|
+
if (flags.env) {
|
|
15
|
+
this.reportEnvOverride(localConfig.projectId, flags.env);
|
|
16
|
+
}
|
|
17
|
+
else if (localConfig.projectId) {
|
|
12
18
|
this.reportPinnedProject(localConfig.projectId);
|
|
13
19
|
}
|
|
14
20
|
else {
|
|
@@ -32,6 +38,21 @@ export default class Status extends Command {
|
|
|
32
38
|
this.log(`Project: ${project.name} (${env.name})`);
|
|
33
39
|
this.log(`URL: ${env.url}`);
|
|
34
40
|
}
|
|
41
|
+
// Mirrors base.ts:resolveEnvironment with --env: the targeted project comes from
|
|
42
|
+
// loopress.json when pinned, from the globally active project otherwise.
|
|
43
|
+
reportEnvOverride(pinnedProjectId, envName) {
|
|
44
|
+
const project = pinnedProjectId ? configManager.getProject(pinnedProjectId) : configManager.getCurrentProject();
|
|
45
|
+
if (!project) {
|
|
46
|
+
this.log('No project configured. Run `lps project config` first.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const env = project.environments[envName];
|
|
50
|
+
if (!env) {
|
|
51
|
+
this.error(`Environment "${envName}" not found in project "${project.name}". Available: ${Object.keys(project.environments).join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
this.log(`Project: ${project.name} (${env.name}, via --env)`);
|
|
54
|
+
this.log(`URL: ${env.url}`);
|
|
55
|
+
}
|
|
35
56
|
reportPinnedProject(projectId) {
|
|
36
57
|
const project = configManager.getProject(projectId);
|
|
37
58
|
if (!project) {
|