@loopress/cli 0.17.0 → 0.19.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 +266 -59
- 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/list.d.ts +9 -0
- package/dist/commands/api/list.js +26 -0
- package/dist/commands/api/pull.d.ts +13 -0
- package/dist/commands/api/pull.js +49 -0
- package/dist/commands/api/push.d.ts +15 -0
- package/dist/commands/api/push.js +70 -0
- 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/list.d.ts +9 -0
- package/dist/commands/form/list.js +26 -0
- package/dist/commands/form/pull.d.ts +13 -0
- package/dist/commands/form/pull.js +60 -0
- package/dist/commands/form/push.d.ts +16 -0
- package/dist/commands/form/push.js +122 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +64 -33
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.js +4 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- 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 +11 -0
- package/dist/lib/base.js +71 -2
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -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/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/wp-client.d.ts +9 -4
- package/dist/lib/wp-client.js +26 -12
- package/dist/types/project-config.generated.d.ts +8 -0
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/form-format.d.ts +3 -0
- package/dist/utils/form-format.js +17 -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 +748 -96
- package/package.json +26 -17
- package/schemas/project-config.schema.json +10 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
6
|
+
import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
7
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
9
|
+
export default class Pull extends LoopressCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
12
|
+
};
|
|
13
|
+
static description = 'Pull forms from WordPress';
|
|
14
|
+
static examples = ['$ lps form pull'];
|
|
15
|
+
static flags = {
|
|
16
|
+
...LoopressCommand.dryRunFlag,
|
|
17
|
+
...LoopressCommand.yesFlag,
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { args } = await this.parse(Pull);
|
|
21
|
+
const { url } = this.siteConfig;
|
|
22
|
+
const path = this.resolveFormPath(args.path);
|
|
23
|
+
this.log(`Pulling forms from ${url}`);
|
|
24
|
+
this.log(`Forms path: ${path}`);
|
|
25
|
+
const remoteList = await this.wp.get(FORM_ENDPOINT);
|
|
26
|
+
const withId = remoteList.filter((form) => getFormId(form) !== null);
|
|
27
|
+
const skipped = remoteList.length - withId.length;
|
|
28
|
+
// Only matches files following the `<id>-<slug>.json` convention pull/push themselves
|
|
29
|
+
// produce, same principle as `snippet pull`.
|
|
30
|
+
const orphans = await findOrphanedFiles(path, new Set(withId.map((form) => String(getFormId(form)))), {
|
|
31
|
+
extensions: ['.json'],
|
|
32
|
+
key: numericPrefixKey,
|
|
33
|
+
});
|
|
34
|
+
if (this.dryRun) {
|
|
35
|
+
this.log(`[dry-run] Would pull ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
36
|
+
if (orphans.length > 0) {
|
|
37
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (withId.length > 0)
|
|
42
|
+
await mkdir(path, { recursive: true });
|
|
43
|
+
await new Listr(withId.map((form) => {
|
|
44
|
+
const id = getFormId(form);
|
|
45
|
+
const title = getFormTitle(form);
|
|
46
|
+
return {
|
|
47
|
+
async task(_ctx, task) {
|
|
48
|
+
await writeFile(join(path, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(form, null, 2) + '\n');
|
|
49
|
+
task.output = `Pulled: ${title}`;
|
|
50
|
+
},
|
|
51
|
+
title: `Pull ${title}`,
|
|
52
|
+
};
|
|
53
|
+
})).run();
|
|
54
|
+
await this.removeOrphanedFiles(path, orphans, `in ${path} no longer present on WordPress`);
|
|
55
|
+
this.log(`Pulled ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
56
|
+
if (skipped > 0) {
|
|
57
|
+
this.warn(`${skipped} form${skipped === 1 ? '' : 's'} skipped because they have no id`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private ensureCanonicalFilename;
|
|
14
|
+
private loadFiles;
|
|
15
|
+
private pushForm;
|
|
16
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { readdir, readFile, rename } from 'node:fs/promises';
|
|
4
|
+
import { dirname, extname, join } from 'node:path';
|
|
5
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
6
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
9
|
+
export default class Push extends PushCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
12
|
+
};
|
|
13
|
+
static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
|
|
14
|
+
static examples = ['$ lps form push'];
|
|
15
|
+
static flags = {
|
|
16
|
+
...PushCommand.dryRunFlag,
|
|
17
|
+
...PushCommand.yesFlag,
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { args } = await this.parse(Push);
|
|
21
|
+
const { url } = this.siteConfig;
|
|
22
|
+
const path = this.resolveFormPath(args.path);
|
|
23
|
+
this.log(`Pushing forms to ${url}`);
|
|
24
|
+
this.log(`Forms path: ${path}`);
|
|
25
|
+
const files = await this.loadFiles(path);
|
|
26
|
+
this.log(`Found ${files.length} form${files.length === 1 ? '' : 's'} to push`);
|
|
27
|
+
await new Listr(files.map(({ data, filePath }) => ({
|
|
28
|
+
task: async (_ctx, task) => this.pushForm(filePath, data, task),
|
|
29
|
+
title: `Push ${getFormTitle(data)}`,
|
|
30
|
+
})), { concurrent: false, exitOnError: false }).run();
|
|
31
|
+
if (this.failedCount > 0) {
|
|
32
|
+
this.error(`${this.failedCount} form${this.failedCount === 1 ? '' : 's'} failed to push.`);
|
|
33
|
+
}
|
|
34
|
+
if (this.dryRun)
|
|
35
|
+
return;
|
|
36
|
+
await this.recordSuccess();
|
|
37
|
+
this.log('All forms pushed.');
|
|
38
|
+
}
|
|
39
|
+
// Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
|
|
40
|
+
// whenever it doesn't already match (a hand-created file with no id, or a stale slug after
|
|
41
|
+
// a title change in the WordPress admin), same principle as ensureCanonicalFilename in
|
|
42
|
+
// commands/snippet/push.ts.
|
|
43
|
+
async ensureCanonicalFilename(filePath, id, title) {
|
|
44
|
+
const canonicalPath = join(dirname(filePath), `${id}-${toSlug(title, 'untitled')}.json`);
|
|
45
|
+
if (filePath !== canonicalPath)
|
|
46
|
+
await rename(filePath, canonicalPath);
|
|
47
|
+
}
|
|
48
|
+
// One file is read in isolation: a corrupted or hand-broken JSON file must only skip that
|
|
49
|
+
// form, not abort loading the rest of the directory, same principle as loadObjects() in
|
|
50
|
+
// commands/acf/push.ts.
|
|
51
|
+
async loadFiles(dir) {
|
|
52
|
+
let files;
|
|
53
|
+
try {
|
|
54
|
+
files = await readdir(dir);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT')
|
|
58
|
+
return [];
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const forms = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
if (extname(file) !== '.json')
|
|
64
|
+
continue;
|
|
65
|
+
const filePath = join(dir, file);
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
this.warn(`Skipping "${filePath}": ${error.message}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
75
|
+
this.warn(`Skipping "${filePath}": not a JSON object`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
forms.push({ data: parsed, filePath });
|
|
79
|
+
}
|
|
80
|
+
return forms;
|
|
81
|
+
}
|
|
82
|
+
// PUT-then-404-fallback-POST is the same dance as commands/snippet/push.ts, forms are
|
|
83
|
+
// id-based like snippets, not key-based like ACF.
|
|
84
|
+
async pushForm(filePath, data, task) {
|
|
85
|
+
const title = getFormTitle(data);
|
|
86
|
+
if (this.dryRun) {
|
|
87
|
+
if (task)
|
|
88
|
+
task.output = `[dry-run] Would push: ${title}`;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const id = getFormId(data);
|
|
93
|
+
if (id === null) {
|
|
94
|
+
const created = await this.wp.post(FORM_ENDPOINT, data);
|
|
95
|
+
const newId = getFormId(created);
|
|
96
|
+
if (newId !== null)
|
|
97
|
+
await this.ensureCanonicalFilename(filePath, newId, title);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
await this.wp.put(`${FORM_ENDPOINT}/${id}`, data);
|
|
102
|
+
await this.ensureCanonicalFilename(filePath, id, title);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// The id recorded locally doesn't exist on this site (e.g. a fresh install): create
|
|
106
|
+
// it instead of failing, and adopt whatever id the site assigns.
|
|
107
|
+
if (!isNotFoundError(error))
|
|
108
|
+
throw error;
|
|
109
|
+
const created = await this.wp.post(FORM_ENDPOINT, data);
|
|
110
|
+
const newId = getFormId(created);
|
|
111
|
+
if (newId !== null)
|
|
112
|
+
await this.ensureCanonicalFilename(filePath, newId, title);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (task)
|
|
116
|
+
task.output = `Pushed: ${title}`;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.reportTaskFailure(`Failed to push ${title}: ${error.message}`, error, task);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/commands/init.d.ts
CHANGED
package/dist/commands/init.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { confirm, input, select } from '@inquirer/prompts';
|
|
1
|
+
import { checkbox, confirm, input, select } from '@inquirer/prompts';
|
|
2
2
|
import { Command } from '@oclif/core';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { configManager } from '../config/project-config.manager.js';
|
|
6
|
+
import { isInteractive } from '../lib/interactive.js';
|
|
6
7
|
import { writeLocalConfig } from '../utils/loopress-config.js';
|
|
7
8
|
// WordPress.org slugs for the two snippet plugins the Loopress WordPress plugin supports
|
|
8
9
|
// (see SnippetModule.php, which wires up both providers and auto-detects the active one).
|
|
@@ -10,11 +11,21 @@ const SNIPPET_PROVIDERS = [
|
|
|
10
11
|
{ name: 'Code Snippets', slug: 'code-snippets' },
|
|
11
12
|
{ name: 'WPCode', slug: 'insert-headers-and-footers' },
|
|
12
13
|
];
|
|
14
|
+
// Default directory per optional feature, matching the resolve*Path defaults in lib/base.ts.
|
|
15
|
+
const FEATURES = [
|
|
16
|
+
{ dir: 'acf', key: 'acfDir', label: 'ACF' },
|
|
17
|
+
{ dir: 'seo', key: 'seoDir', label: 'SEO' },
|
|
18
|
+
{ dir: 'forms', key: 'formDir', label: 'Forms' },
|
|
19
|
+
{ dir: 'api', key: 'apiDir', label: 'Custom API routes' },
|
|
20
|
+
];
|
|
13
21
|
export default class Init extends Command {
|
|
14
22
|
static description = 'Initialize a loopress.json config file in the current directory';
|
|
15
23
|
static examples = ['$ lps init'];
|
|
16
24
|
async run() {
|
|
17
25
|
await this.parse(Init);
|
|
26
|
+
if (!isInteractive()) {
|
|
27
|
+
this.error('lps init asks its questions interactively and needs a terminal. In CI or scripts, commit a loopress.json instead (see the Getting Started documentation for the file format).');
|
|
28
|
+
}
|
|
18
29
|
const configPath = join(process.cwd(), 'loopress.json');
|
|
19
30
|
if (existsSync(configPath)) {
|
|
20
31
|
const overwrite = await confirm({
|
|
@@ -26,38 +37,7 @@ export default class Init extends Command {
|
|
|
26
37
|
return;
|
|
27
38
|
}
|
|
28
39
|
}
|
|
29
|
-
const
|
|
30
|
-
let projectId;
|
|
31
|
-
let projectLabel;
|
|
32
|
-
if (projects.length > 0) {
|
|
33
|
-
const choices = [
|
|
34
|
-
...projects.map((p) => ({ name: p.name, value: p.id })),
|
|
35
|
-
{ name: 'Enter a project ID manually', value: '__manual__' },
|
|
36
|
-
];
|
|
37
|
-
const choice = await select({
|
|
38
|
-
choices,
|
|
39
|
-
message: 'WordPress project',
|
|
40
|
-
});
|
|
41
|
-
if (choice === '__manual__') {
|
|
42
|
-
projectId = await input({
|
|
43
|
-
message: 'Project ID',
|
|
44
|
-
validate: (value) => (value.trim().length > 0 ? true : 'Project ID cannot be empty'),
|
|
45
|
-
});
|
|
46
|
-
projectLabel = projectId;
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
projectId = choice;
|
|
50
|
-
projectLabel = projects.find((p) => p.id === choice).name;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
this.log('No projects configured yet. Run `lps project config` to add one first.');
|
|
55
|
-
projectId = await input({
|
|
56
|
-
message: 'Project ID',
|
|
57
|
-
validate: (value) => (value.trim().length > 0 ? true : 'Project ID cannot be empty'),
|
|
58
|
-
});
|
|
59
|
-
projectLabel = projectId;
|
|
60
|
-
}
|
|
40
|
+
const { projectId, projectLabel } = await this.resolveProjectChoice();
|
|
61
41
|
const rootDir = await input({
|
|
62
42
|
default: '.',
|
|
63
43
|
message: 'Root directory',
|
|
@@ -66,6 +46,10 @@ export default class Init extends Command {
|
|
|
66
46
|
default: 'snippets',
|
|
67
47
|
message: 'Snippets directory (relative to root)',
|
|
68
48
|
});
|
|
49
|
+
const features = await checkbox({
|
|
50
|
+
choices: FEATURES.map((feature) => ({ name: `${feature.label} (${feature.dir}/)`, value: feature.key })),
|
|
51
|
+
message: 'Other features to configure directories for (optional)',
|
|
52
|
+
});
|
|
69
53
|
const providerChoice = await select({
|
|
70
54
|
choices: [...SNIPPET_PROVIDERS.map((p) => ({ name: p.name, value: p.slug })), { name: 'None / already installed', value: '__none__' }],
|
|
71
55
|
message: 'Snippet provider',
|
|
@@ -75,6 +59,10 @@ export default class Init extends Command {
|
|
|
75
59
|
rootDir,
|
|
76
60
|
snippetsDir,
|
|
77
61
|
};
|
|
62
|
+
for (const feature of FEATURES) {
|
|
63
|
+
if (features.includes(feature.key))
|
|
64
|
+
config[feature.key] = feature.dir;
|
|
65
|
+
}
|
|
78
66
|
await writeLocalConfig(config);
|
|
79
67
|
let providerAdded = false;
|
|
80
68
|
if (providerChoice !== '__none__') {
|
|
@@ -89,8 +77,51 @@ export default class Init extends Command {
|
|
|
89
77
|
this.log(`\n✓ loopress.json created`);
|
|
90
78
|
this.log(` Project: ${projectLabel}`);
|
|
91
79
|
this.log(` Snippets: ${join(rootDir, snippetsDir)}`);
|
|
80
|
+
for (const feature of FEATURES) {
|
|
81
|
+
if (features.includes(feature.key)) {
|
|
82
|
+
this.log(` ${feature.label}:${' '.repeat(Math.max(1, 9 - feature.label.length))}${join(rootDir, feature.dir)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
92
85
|
if (providerAdded) {
|
|
93
86
|
this.log(` Plugin: ${providerChoice}`);
|
|
94
87
|
}
|
|
88
|
+
this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
|
|
89
|
+
}
|
|
90
|
+
// When nothing is configured yet, the useful path is configuring a project right here, not
|
|
91
|
+
// typing a project ID that points at nothing. Manual entry stays available as an explicit
|
|
92
|
+
// choice, and as the fallback when the inline `project config` is declined or aborted.
|
|
93
|
+
async promptManualProjectId() {
|
|
94
|
+
const projectId = await input({
|
|
95
|
+
message: 'Project ID',
|
|
96
|
+
validate: (value) => (value.trim().length > 0 ? true : 'Project ID cannot be empty'),
|
|
97
|
+
});
|
|
98
|
+
return { projectId, projectLabel: projectId };
|
|
99
|
+
}
|
|
100
|
+
async resolveProjectChoice() {
|
|
101
|
+
let projects = configManager.listProjects();
|
|
102
|
+
if (projects.length === 0) {
|
|
103
|
+
this.log('No projects configured yet.');
|
|
104
|
+
const runConfig = await confirm({ default: true, message: 'Run `lps project config` now to add one?' });
|
|
105
|
+
if (!runConfig) {
|
|
106
|
+
return this.promptManualProjectId();
|
|
107
|
+
}
|
|
108
|
+
await this.config.runCommand('project:config');
|
|
109
|
+
projects = configManager.listProjects();
|
|
110
|
+
if (projects.length === 0) {
|
|
111
|
+
this.log('Still no project configured, falling back to manual entry.');
|
|
112
|
+
return this.promptManualProjectId();
|
|
113
|
+
}
|
|
114
|
+
if (projects.length === 1) {
|
|
115
|
+
return { projectId: projects[0].id, projectLabel: projects[0].name };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const choice = await select({
|
|
119
|
+
choices: [...projects.map((p) => ({ name: p.name, value: p.id })), { name: 'Enter a project ID manually', value: '__manual__' }],
|
|
120
|
+
message: 'WordPress project',
|
|
121
|
+
});
|
|
122
|
+
if (choice === '__manual__') {
|
|
123
|
+
return this.promptManualProjectId();
|
|
124
|
+
}
|
|
125
|
+
return { projectId: choice, projectLabel: projects.find((p) => p.id === choice).name };
|
|
95
126
|
}
|
|
96
127
|
}
|
|
@@ -3,9 +3,9 @@ export default class Push extends PushCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
};
|
|
8
|
-
private failedCount;
|
|
9
9
|
run(): Promise<void>;
|
|
10
10
|
private activatePlugin;
|
|
11
11
|
private installPlugin;
|
|
@@ -7,8 +7,8 @@ export default class Push extends PushCommand {
|
|
|
7
7
|
static examples = ['$ lps plugin push', '$ lps plugin push --dry-run'];
|
|
8
8
|
static flags = {
|
|
9
9
|
...PushCommand.dryRunFlag,
|
|
10
|
+
...PushCommand.yesFlag,
|
|
10
11
|
};
|
|
11
|
-
failedCount = 0;
|
|
12
12
|
async run() {
|
|
13
13
|
const { url } = this.siteConfig;
|
|
14
14
|
const manifest = this.localConfig.plugins;
|
|
@@ -62,12 +62,6 @@ export default class Push extends PushCommand {
|
|
|
62
62
|
}
|
|
63
63
|
await this.recordSuccess();
|
|
64
64
|
}
|
|
65
|
-
// `task` is only passed when called from within a running Listr task list (see `run()`); it lets
|
|
66
|
-
// status lines go through `task.output` instead of `this.log`/`this.warn`, which would otherwise
|
|
67
|
-
// race with the renderer repainting the terminal. Called without `task` (e.g. directly in tests),
|
|
68
|
-
// it falls back to plain logging. Rethrowing on failure (rather than swallowing) is what lets Listr
|
|
69
|
-
// mark the task as failed (red cross) instead of completed, even though `exitOnError: false` stops
|
|
70
|
-
// that failure from aborting sibling tasks in the same list.
|
|
71
65
|
async activatePlugin(file, slug, task) {
|
|
72
66
|
await this.performPluginAction('activate', slug, () => this.wp.put(`wp/v2/plugins/${file}`, { status: 'active' }), task);
|
|
73
67
|
}
|
|
@@ -84,13 +78,7 @@ export default class Push extends PushCommand {
|
|
|
84
78
|
this.log(` ${message}`);
|
|
85
79
|
}
|
|
86
80
|
catch (error) {
|
|
87
|
-
|
|
88
|
-
if (task)
|
|
89
|
-
task.output = message;
|
|
90
|
-
else
|
|
91
|
-
this.warn(` ${message}`);
|
|
92
|
-
this.failedCount++;
|
|
93
|
-
throw error;
|
|
81
|
+
this.reportTaskFailure(`Failed to ${verb} ${slug}: ${error.message}`, error, task);
|
|
94
82
|
}
|
|
95
83
|
}
|
|
96
84
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { isInteractive } from '../../lib/interactive.js';
|
|
4
5
|
import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
|
|
5
6
|
import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
|
|
6
7
|
const NEW_PROJECT = '__new__';
|
|
@@ -11,6 +12,9 @@ export default class Config extends Command {
|
|
|
11
12
|
static examples = ['$ lps project config'];
|
|
12
13
|
async run() {
|
|
13
14
|
await this.parse(Config);
|
|
15
|
+
if (!isInteractive()) {
|
|
16
|
+
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.');
|
|
17
|
+
}
|
|
14
18
|
const { projectId, projectName } = await this.resolveProject();
|
|
15
19
|
const envChoice = await select({
|
|
16
20
|
choices: [
|
|
@@ -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 };
|
|
@@ -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;
|