@loopress/cli 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -56
- 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/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/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/pull.d.ts +1 -0
- package/dist/commands/plugin/pull.js +8 -10
- package/dist/commands/plugin/push.d.ts +3 -0
- package/dist/commands/plugin/push.js +32 -22
- 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/validate.d.ts +8 -0
- package/dist/commands/validate.js +24 -0
- package/dist/lib/base.d.ts +9 -0
- package/dist/lib/base.js +51 -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/utils/pluralize.d.ts +1 -0
- package/dist/utils/pluralize.js +5 -0
- package/oclif.manifest.json +242 -27
- package/package.json +16 -15
|
@@ -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');
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
|
-
import {
|
|
3
|
-
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { dirname, extname, join } from 'node:path';
|
|
5
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
|
-
import {
|
|
5
|
+
import { putOrCreate } from '../../lib/put-or-create.js';
|
|
6
|
+
import { readdirTolerant } from '../../lib/readdir-tolerant.js';
|
|
7
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
7
8
|
import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
|
|
8
9
|
export default class Push extends PushCommand {
|
|
9
10
|
static args = {
|
|
@@ -25,7 +26,7 @@ export default class Push extends PushCommand {
|
|
|
25
26
|
await this.pushPostMeta(path);
|
|
26
27
|
await this.pushRedirects(path);
|
|
27
28
|
if (this.failedCount > 0) {
|
|
28
|
-
this.error(`${this.failedCount
|
|
29
|
+
this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
|
|
29
30
|
}
|
|
30
31
|
if (this.dryRun)
|
|
31
32
|
return;
|
|
@@ -33,36 +34,20 @@ export default class Push extends PushCommand {
|
|
|
33
34
|
this.log('All SEO configuration pushed.');
|
|
34
35
|
}
|
|
35
36
|
async jsonFilesIn(dir) {
|
|
36
|
-
|
|
37
|
-
return (await readdir(dir)).filter((file) => extname(file) === '.json');
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
if (error.code === 'ENOENT')
|
|
41
|
-
return [];
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
37
|
+
return (await readdirTolerant(dir)).filter((file) => extname(file) === '.json');
|
|
44
38
|
}
|
|
45
39
|
async pushPostMeta(basePath) {
|
|
46
40
|
const root = join(basePath, 'post-meta');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
if (error.code === 'ENOENT')
|
|
53
|
-
return;
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
41
|
+
const postTypeDirs = (await readdirTolerant(root, { withFileTypes: true }))
|
|
42
|
+
.filter((entry) => entry.isDirectory())
|
|
43
|
+
.map((entry) => entry.name);
|
|
56
44
|
for (const postType of postTypeDirs) {
|
|
57
45
|
const dir = join(root, postType);
|
|
58
46
|
const files = await this.jsonFilesIn(dir);
|
|
59
47
|
if (files.length === 0)
|
|
60
48
|
continue;
|
|
61
|
-
this.log(`Found ${files.length
|
|
62
|
-
await
|
|
63
|
-
task: async (_ctx, task) => this.pushPostMetaFile(postType, join(dir, file), task),
|
|
64
|
-
title: `Push ${file}`,
|
|
65
|
-
})), { concurrent: false, exitOnError: false }).run();
|
|
49
|
+
this.log(`Found ${pluralize(files.length, postType + ' post-meta file')} to push`);
|
|
50
|
+
await this.runPushTasks(files, (file) => file, async (file, task) => this.pushPostMetaFile(postType, join(dir, file), task));
|
|
66
51
|
}
|
|
67
52
|
}
|
|
68
53
|
async pushPostMetaFile(postType, filePath, task) {
|
|
@@ -90,24 +75,18 @@ export default class Push extends PushCommand {
|
|
|
90
75
|
try {
|
|
91
76
|
const redirect = JSON.parse(await readFile(filePath, 'utf8'));
|
|
92
77
|
const payload = { headerCode: redirect.headerCode, sources: redirect.sources, status: redirect.status, urlTo: redirect.urlTo };
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!isNotFoundError(error))
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const created = await this.wp.post(SEO_REDIRECTS_ENDPOINT, payload);
|
|
108
|
-
await this.renameToCanonical(filePath, created);
|
|
78
|
+
// The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
|
|
79
|
+
// falls back to POST instead of failing, adopting whatever id the site assigns.
|
|
80
|
+
const { body, created } = await putOrCreate(this.wp, {
|
|
81
|
+
id: redirect.id ?? null,
|
|
82
|
+
payload,
|
|
83
|
+
postEndpoint: SEO_REDIRECTS_ENDPOINT,
|
|
84
|
+
putEndpoint: (id) => seoRedirectEndpoint(id),
|
|
85
|
+
});
|
|
86
|
+
if (created)
|
|
87
|
+
await this.renameToCanonical(filePath, body);
|
|
109
88
|
if (task)
|
|
110
|
-
task.output = `Pushed: redirect #${created.id}`;
|
|
89
|
+
task.output = `Pushed: redirect #${created ? body.id : redirect.id}`;
|
|
111
90
|
}
|
|
112
91
|
catch (error) {
|
|
113
92
|
this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
|
|
@@ -118,11 +97,8 @@ export default class Push extends PushCommand {
|
|
|
118
97
|
const files = await this.jsonFilesIn(dir);
|
|
119
98
|
if (files.length === 0)
|
|
120
99
|
return;
|
|
121
|
-
this.log(`Found ${files.length
|
|
122
|
-
await
|
|
123
|
-
task: async (_ctx, task) => this.pushRedirectFile(join(dir, file), task),
|
|
124
|
-
title: `Push ${file}`,
|
|
125
|
-
})), { concurrent: false, exitOnError: false }).run();
|
|
100
|
+
this.log(`Found ${pluralize(files.length, 'redirect')} to push`);
|
|
101
|
+
await this.runPushTasks(files, (file) => file, async (file, task) => this.pushRedirectFile(join(dir, file), task));
|
|
126
102
|
}
|
|
127
103
|
async pushSettings(basePath) {
|
|
128
104
|
const file = join(basePath, 'settings.json');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
2
3
|
import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
3
4
|
export default class List extends LoopressCommand {
|
|
4
5
|
static description = 'List snippets from WordPress';
|
|
@@ -11,7 +12,7 @@ export default class List extends LoopressCommand {
|
|
|
11
12
|
this.log('No snippets found');
|
|
12
13
|
return snippets;
|
|
13
14
|
}
|
|
14
|
-
this.log(`Found ${snippets.length
|
|
15
|
+
this.log(`Found ${pluralize(snippets.length, 'snippet')}:`);
|
|
15
16
|
this.log('');
|
|
16
17
|
for (const snippet of snippets) {
|
|
17
18
|
this.log(` ${snippet.id}. ${snippet.name}`);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Args, Command } from '@oclif/core';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { authManager } from '../../config/auth.manager.js';
|
|
4
|
-
import { configManager } from '../../config/project-config.manager.js';
|
|
5
3
|
import { ApiClient } from '../../lib/api-client.js';
|
|
6
4
|
import { loadSnippets } from '../../lib/load-snippets.js';
|
|
7
|
-
import {
|
|
5
|
+
import { resolveLinkedProject } from '../../lib/resolve-linked-project.js';
|
|
6
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
8
7
|
import { toSlug } from '../../utils/to-slug.js';
|
|
9
8
|
// Publishes to the Loopress api (not a WordPress site), so this does not extend
|
|
10
9
|
// `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
|
|
@@ -18,22 +17,7 @@ export default class Publish extends Command {
|
|
|
18
17
|
static examples = ['$ lps snippet publish', '$ lps snippet publish --path ./snippets'];
|
|
19
18
|
async run() {
|
|
20
19
|
const { args } = await this.parse(Publish);
|
|
21
|
-
const token =
|
|
22
|
-
if (!token) {
|
|
23
|
-
this.error('Not logged in. Run `lps login` first.');
|
|
24
|
-
}
|
|
25
|
-
const localConfig = await readLocalConfig();
|
|
26
|
-
const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
|
|
27
|
-
if (!projectId) {
|
|
28
|
-
this.error('No project configured. Run `lps project config` first.');
|
|
29
|
-
}
|
|
30
|
-
const project = configManager.getProject(projectId);
|
|
31
|
-
if (!project) {
|
|
32
|
-
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
33
|
-
}
|
|
34
|
-
if (!project.apiProjectId) {
|
|
35
|
-
this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project push\` first.`);
|
|
36
|
-
}
|
|
20
|
+
const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
|
|
37
21
|
const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
|
|
38
22
|
this.log(`Publishing snippets from ${path}`);
|
|
39
23
|
let snippets;
|
|
@@ -45,14 +29,14 @@ export default class Publish extends Command {
|
|
|
45
29
|
}
|
|
46
30
|
const api = new ApiClient(token);
|
|
47
31
|
try {
|
|
48
|
-
await api.post(`projects/${
|
|
32
|
+
await api.post(`projects/${apiProjectId}/snippets/publish`, {
|
|
49
33
|
snippets: snippets.map((snippet) => this.toPayload(snippet)),
|
|
50
34
|
});
|
|
51
35
|
}
|
|
52
36
|
catch (error) {
|
|
53
37
|
this.error(error.message);
|
|
54
38
|
}
|
|
55
|
-
this.log(`Published ${snippets.length
|
|
39
|
+
this.log(`Published ${pluralize(snippets.length, 'snippet')} to your Loopress account.`);
|
|
56
40
|
}
|
|
57
41
|
toPayload(snippet) {
|
|
58
42
|
return {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
|
-
import {
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { LoopressCommand } from '../../lib/base.js';
|
|
6
5
|
import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
6
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
7
7
|
import { buildMetaFile, buildSnippetFile, normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
|
|
8
8
|
import { toSlug } from '../../utils/to-slug.js';
|
|
9
9
|
const EXTENSIONS = {
|
|
@@ -42,28 +42,23 @@ export default class Pull extends LoopressCommand {
|
|
|
42
42
|
key: numericPrefixKey,
|
|
43
43
|
});
|
|
44
44
|
const pulled = pullable.map((snippet) => ({ id: snippet.id, name: snippet.name }));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await mkdir(path, { recursive: true });
|
|
53
|
-
await new Listr(pullable.map((snippet) => ({
|
|
54
|
-
async task(_ctx, task) {
|
|
45
|
+
await this.pullDirectory(path, pullable, orphans, {
|
|
46
|
+
alwaysCreateDir: true,
|
|
47
|
+
dryRunMessage: `Would pull ${pluralize(snippets.length, 'snippet')} to ${path}`,
|
|
48
|
+
orphanReason: 'whose snippet no longer exists on WordPress',
|
|
49
|
+
pulledMessage: `Pulled ${pluralize(pullable.length, 'snippet')} to ${path}`,
|
|
50
|
+
title: (snippet) => snippet.name,
|
|
51
|
+
async write(snippet, writeDir) {
|
|
55
52
|
const ext = EXTENSIONS[snippet.type];
|
|
56
53
|
const base = `${snippet.id}-${toSlug(snippet.name)}`;
|
|
57
|
-
await writeFile(join(
|
|
58
|
-
await writeFile(join(
|
|
59
|
-
task.output = `Pulled: ${snippet.name}`;
|
|
54
|
+
await writeFile(join(writeDir, `${base}.${ext}`), buildSnippetFile(snippet));
|
|
55
|
+
await writeFile(join(writeDir, `${base}.json`), buildMetaFile(snippet));
|
|
60
56
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
|
|
57
|
+
});
|
|
58
|
+
if (this.dryRun)
|
|
59
|
+
return { orphans, pulled, skipped, status: 'dry-run' };
|
|
65
60
|
if (skipped > 0) {
|
|
66
|
-
this.warn(`${skipped
|
|
61
|
+
this.warn(`${pluralize(skipped, 'snippet')} skipped because they have no name`);
|
|
67
62
|
}
|
|
68
63
|
return { orphans, pulled, skipped, status: 'success' };
|
|
69
64
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
|
-
import { Listr } from 'listr2';
|
|
3
2
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
4
|
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
6
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
|
-
import {
|
|
6
|
+
import { putOrCreate } from '../../lib/put-or-create.js';
|
|
7
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
8
8
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
9
9
|
import { toSlug } from '../../utils/to-slug.js';
|
|
10
10
|
export default class Push extends PushCommand {
|
|
@@ -25,17 +25,14 @@ export default class Push extends PushCommand {
|
|
|
25
25
|
this.log(`Pushing snippets to ${url}`);
|
|
26
26
|
this.log(`Snippets path: ${path}`);
|
|
27
27
|
const snippets = await this.loadSnippets(path);
|
|
28
|
-
this.log(`Found ${snippets.length
|
|
28
|
+
this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
|
|
29
29
|
const pushed = [];
|
|
30
|
-
await
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
title: `Push ${snippet.name}`,
|
|
36
|
-
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
30
|
+
await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
|
|
31
|
+
const id = await this.pushSnippet(snippet, task);
|
|
32
|
+
pushed.push({ id, name: snippet.name });
|
|
33
|
+
});
|
|
37
34
|
if (this.failedCount > 0) {
|
|
38
|
-
this.error(`${this.failedCount
|
|
35
|
+
this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
|
|
39
36
|
}
|
|
40
37
|
if (this.dryRun)
|
|
41
38
|
return { pushed, status: 'dry-run' };
|
|
@@ -93,28 +90,22 @@ export default class Push extends PushCommand {
|
|
|
93
90
|
}
|
|
94
91
|
try {
|
|
95
92
|
const payload = this.toPayload(snippet);
|
|
93
|
+
// The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
|
|
94
|
+
// falls back to POST instead of failing, adopting whatever id the site assigns.
|
|
95
|
+
const { body, created } = await putOrCreate(this.wp, {
|
|
96
|
+
id: snippet.id ?? null,
|
|
97
|
+
payload,
|
|
98
|
+
postEndpoint: SNIPPETS_ENDPOINT,
|
|
99
|
+
putEndpoint: (id) => `${SNIPPETS_ENDPOINT}/${id}`,
|
|
100
|
+
});
|
|
96
101
|
let { id } = snippet;
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
// The id recorded locally doesn't exist on this site (e.g. a fresh install): create it
|
|
104
|
-
// instead of failing, and adopt whatever id the site assigns.
|
|
105
|
-
if (!isNotFoundError(error))
|
|
106
|
-
throw error;
|
|
107
|
-
const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
|
|
108
|
-
const created = normalizeSnippet(response);
|
|
109
|
-
id = created.id;
|
|
110
|
-
await this.ensureCanonicalFilename(snippet, created.id, created.name);
|
|
111
|
-
}
|
|
102
|
+
if (created) {
|
|
103
|
+
const createdSnippet = normalizeSnippet(body);
|
|
104
|
+
id = createdSnippet.id;
|
|
105
|
+
await this.ensureCanonicalFilename(snippet, createdSnippet.id, createdSnippet.name);
|
|
112
106
|
}
|
|
113
107
|
else {
|
|
114
|
-
|
|
115
|
-
const created = normalizeSnippet(response);
|
|
116
|
-
id = created.id;
|
|
117
|
-
await this.ensureCanonicalFilename(snippet, created.id, created.name);
|
|
108
|
+
await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
|
|
118
109
|
}
|
|
119
110
|
if (task)
|
|
120
111
|
task.output = `Pushed: ${snippet.name}`;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { type ValidateResult } from '../lib/validate-local.js';
|
|
3
|
+
export default class Validate extends Command {
|
|
4
|
+
static description: string;
|
|
5
|
+
static enableJsonFlag: boolean;
|
|
6
|
+
static examples: string[];
|
|
7
|
+
run(): Promise<ValidateResult>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { validateLocal } from '../lib/validate-local.js';
|
|
3
|
+
export default class Validate extends Command {
|
|
4
|
+
static description = 'Check local tracked files are well formed and push-ready, without contacting WordPress';
|
|
5
|
+
static enableJsonFlag = true;
|
|
6
|
+
static examples = ['$ lps validate'];
|
|
7
|
+
async run() {
|
|
8
|
+
await this.parse(Validate);
|
|
9
|
+
const result = await validateLocal(process.cwd());
|
|
10
|
+
if (!this.jsonEnabled()) {
|
|
11
|
+
for (const problem of result.problems) {
|
|
12
|
+
this.log(` ✗ ${problem.file}: ${problem.message}`);
|
|
13
|
+
}
|
|
14
|
+
this.log('');
|
|
15
|
+
this.log(result.valid
|
|
16
|
+
? `${result.checked} file(s) checked, no problems found.`
|
|
17
|
+
: `${result.checked} file(s) checked, ${result.problems.length} problem(s) found.`);
|
|
18
|
+
}
|
|
19
|
+
// Non-zero exit for CI without throwing, so `--json` still prints the full report.
|
|
20
|
+
if (!result.valid)
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
22
22
|
protected get wp(): WpClient;
|
|
23
23
|
init(): Promise<void>;
|
|
24
24
|
protected maybeAutoRotate(): Promise<void>;
|
|
25
|
+
protected pullDirectory<T>(dir: string, items: T[], orphans: string[], options: {
|
|
26
|
+
alwaysCreateDir?: boolean;
|
|
27
|
+
dryRunMessage: string;
|
|
28
|
+
orphanReason: string;
|
|
29
|
+
pulledMessage: string;
|
|
30
|
+
title: (item: T) => string;
|
|
31
|
+
write: (item: T, dir: string) => Promise<void>;
|
|
32
|
+
}): Promise<void>;
|
|
25
33
|
protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
|
|
26
34
|
protected resolveAcfPath(override?: string): string;
|
|
27
35
|
protected resolveApiPath(override?: string): string;
|
|
@@ -31,4 +39,5 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
31
39
|
protected resolveSnippetsPath(override?: string): string;
|
|
32
40
|
private pickEnvironment;
|
|
33
41
|
private resolveEnvironment;
|
|
42
|
+
private resolveEnvironmentForConfiguredProject;
|
|
34
43
|
}
|