@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,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';
|
|
6
|
+
import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
|
|
7
7
|
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
8
9
|
export default class Pull extends LoopressCommand {
|
|
9
10
|
static args = {
|
|
10
11
|
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
@@ -13,6 +14,7 @@ export default class Pull extends LoopressCommand {
|
|
|
13
14
|
static examples = ['$ lps form pull'];
|
|
14
15
|
static flags = {
|
|
15
16
|
...LoopressCommand.dryRunFlag,
|
|
17
|
+
...LoopressCommand.yesFlag,
|
|
16
18
|
};
|
|
17
19
|
async run() {
|
|
18
20
|
const { args } = await this.parse(Pull);
|
|
@@ -23,7 +25,12 @@ export default class Pull extends LoopressCommand {
|
|
|
23
25
|
const remoteList = await this.wp.get(FORM_ENDPOINT);
|
|
24
26
|
const withId = remoteList.filter((form) => getFormId(form) !== null);
|
|
25
27
|
const skipped = remoteList.length - withId.length;
|
|
26
|
-
|
|
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
|
+
});
|
|
27
34
|
if (this.dryRun) {
|
|
28
35
|
this.log(`[dry-run] Would pull ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
29
36
|
if (orphans.length > 0) {
|
|
@@ -38,42 +45,16 @@ export default class Pull extends LoopressCommand {
|
|
|
38
45
|
const title = getFormTitle(form);
|
|
39
46
|
return {
|
|
40
47
|
async task(_ctx, task) {
|
|
41
|
-
await writeFile(join(path, `${id}-${
|
|
48
|
+
await writeFile(join(path, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(form, null, 2) + '\n');
|
|
42
49
|
task.output = `Pulled: ${title}`;
|
|
43
50
|
},
|
|
44
51
|
title: `Pull ${title}`,
|
|
45
52
|
};
|
|
46
53
|
})).run();
|
|
47
|
-
|
|
48
|
-
await rm(join(path, file), { force: true });
|
|
49
|
-
if (orphans.length > 0) {
|
|
50
|
-
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
51
|
-
}
|
|
54
|
+
await this.removeOrphanedFiles(path, orphans, `in ${path} no longer present on WordPress`);
|
|
52
55
|
this.log(`Pulled ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
53
56
|
if (skipped > 0) {
|
|
54
57
|
this.warn(`${skipped} form${skipped === 1 ? '' : 's'} skipped because they have no id`);
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
|
-
// Only matches files following the `<id>-<slug>.json` convention pull/push themselves
|
|
58
|
-
// produce, same principle as findOrphanedFiles in commands/snippet/pull.ts.
|
|
59
|
-
async findOrphanedFiles(dir, keepIds) {
|
|
60
|
-
let files;
|
|
61
|
-
try {
|
|
62
|
-
files = await readdir(dir);
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
if (error.code === 'ENOENT')
|
|
66
|
-
return [];
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
return files.filter((file) => {
|
|
70
|
-
if (extname(file) !== '.json')
|
|
71
|
-
return false;
|
|
72
|
-
const match = /^(\d+)-/.exec(file);
|
|
73
|
-
return match !== null && !keepIds.has(Number(match[1]));
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
export function slug(title) {
|
|
78
|
-
return slugify(title, { lower: true, strict: true }) || 'untitled';
|
|
79
60
|
}
|
|
@@ -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 loadFiles;
|
|
@@ -5,7 +5,7 @@ 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
7
|
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
8
|
-
import {
|
|
8
|
+
import { toSlug } from '../../utils/to-slug.js';
|
|
9
9
|
export default class Push extends PushCommand {
|
|
10
10
|
static args = {
|
|
11
11
|
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
@@ -14,8 +14,8 @@ export default class Push extends PushCommand {
|
|
|
14
14
|
static examples = ['$ lps form push'];
|
|
15
15
|
static flags = {
|
|
16
16
|
...PushCommand.dryRunFlag,
|
|
17
|
+
...PushCommand.yesFlag,
|
|
17
18
|
};
|
|
18
|
-
failedCount = 0;
|
|
19
19
|
async run() {
|
|
20
20
|
const { args } = await this.parse(Push);
|
|
21
21
|
const { url } = this.siteConfig;
|
|
@@ -41,7 +41,7 @@ export default class Push extends PushCommand {
|
|
|
41
41
|
// a title change in the WordPress admin), same principle as ensureCanonicalFilename in
|
|
42
42
|
// commands/snippet/push.ts.
|
|
43
43
|
async ensureCanonicalFilename(filePath, id, title) {
|
|
44
|
-
const canonicalPath = join(dirname(filePath), `${id}-${
|
|
44
|
+
const canonicalPath = join(dirname(filePath), `${id}-${toSlug(title, 'untitled')}.json`);
|
|
45
45
|
if (filePath !== canonicalPath)
|
|
46
46
|
await rename(filePath, canonicalPath);
|
|
47
47
|
}
|
|
@@ -79,10 +79,8 @@ export default class Push extends PushCommand {
|
|
|
79
79
|
}
|
|
80
80
|
return forms;
|
|
81
81
|
}
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// sibling forms push regardless. PUT-then-404-fallback-POST is the same dance as
|
|
85
|
-
// commands/snippet/push.ts, forms are id-based like snippets, not key-based like ACF.
|
|
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.
|
|
86
84
|
async pushForm(filePath, data, task) {
|
|
87
85
|
const title = getFormTitle(data);
|
|
88
86
|
if (this.dryRun) {
|
|
@@ -118,13 +116,7 @@ export default class Push extends PushCommand {
|
|
|
118
116
|
task.output = `Pushed: ${title}`;
|
|
119
117
|
}
|
|
120
118
|
catch (error) {
|
|
121
|
-
|
|
122
|
-
if (task)
|
|
123
|
-
task.output = message;
|
|
124
|
-
else
|
|
125
|
-
this.warn(` ${message}`);
|
|
126
|
-
this.failedCount++;
|
|
127
|
-
throw error;
|
|
119
|
+
this.reportTaskFailure(`Failed to push ${title}: ${error.message}`, error, task);
|
|
128
120
|
}
|
|
129
121
|
}
|
|
130
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,22 @@ 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: 'pages', key: 'pageDir', label: 'Pages' },
|
|
20
|
+
{ dir: 'api', key: 'apiDir', label: 'Custom API routes' },
|
|
21
|
+
];
|
|
13
22
|
export default class Init extends Command {
|
|
14
23
|
static description = 'Initialize a loopress.json config file in the current directory';
|
|
15
24
|
static examples = ['$ lps init'];
|
|
16
25
|
async run() {
|
|
17
26
|
await this.parse(Init);
|
|
27
|
+
if (!isInteractive()) {
|
|
28
|
+
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).');
|
|
29
|
+
}
|
|
18
30
|
const configPath = join(process.cwd(), 'loopress.json');
|
|
19
31
|
if (existsSync(configPath)) {
|
|
20
32
|
const overwrite = await confirm({
|
|
@@ -26,38 +38,7 @@ export default class Init extends Command {
|
|
|
26
38
|
return;
|
|
27
39
|
}
|
|
28
40
|
}
|
|
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
|
-
}
|
|
41
|
+
const { projectId, projectLabel } = await this.resolveProjectChoice();
|
|
61
42
|
const rootDir = await input({
|
|
62
43
|
default: '.',
|
|
63
44
|
message: 'Root directory',
|
|
@@ -66,6 +47,10 @@ export default class Init extends Command {
|
|
|
66
47
|
default: 'snippets',
|
|
67
48
|
message: 'Snippets directory (relative to root)',
|
|
68
49
|
});
|
|
50
|
+
const features = await checkbox({
|
|
51
|
+
choices: FEATURES.map((feature) => ({ name: `${feature.label} (${feature.dir}/)`, value: feature.key })),
|
|
52
|
+
message: 'Other features to configure directories for (optional)',
|
|
53
|
+
});
|
|
69
54
|
const providerChoice = await select({
|
|
70
55
|
choices: [...SNIPPET_PROVIDERS.map((p) => ({ name: p.name, value: p.slug })), { name: 'None / already installed', value: '__none__' }],
|
|
71
56
|
message: 'Snippet provider',
|
|
@@ -75,6 +60,10 @@ export default class Init extends Command {
|
|
|
75
60
|
rootDir,
|
|
76
61
|
snippetsDir,
|
|
77
62
|
};
|
|
63
|
+
for (const feature of FEATURES) {
|
|
64
|
+
if (features.includes(feature.key))
|
|
65
|
+
config[feature.key] = feature.dir;
|
|
66
|
+
}
|
|
78
67
|
await writeLocalConfig(config);
|
|
79
68
|
let providerAdded = false;
|
|
80
69
|
if (providerChoice !== '__none__') {
|
|
@@ -89,8 +78,51 @@ export default class Init extends Command {
|
|
|
89
78
|
this.log(`\n✓ loopress.json created`);
|
|
90
79
|
this.log(` Project: ${projectLabel}`);
|
|
91
80
|
this.log(` Snippets: ${join(rootDir, snippetsDir)}`);
|
|
81
|
+
for (const feature of FEATURES) {
|
|
82
|
+
if (features.includes(feature.key)) {
|
|
83
|
+
this.log(` ${feature.label}:${' '.repeat(Math.max(1, 9 - feature.label.length))}${join(rootDir, feature.dir)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
92
86
|
if (providerAdded) {
|
|
93
87
|
this.log(` Plugin: ${providerChoice}`);
|
|
94
88
|
}
|
|
89
|
+
this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
|
|
90
|
+
}
|
|
91
|
+
// When nothing is configured yet, the useful path is configuring a project right here, not
|
|
92
|
+
// typing a project ID that points at nothing. Manual entry stays available as an explicit
|
|
93
|
+
// choice, and as the fallback when the inline `project config` is declined or aborted.
|
|
94
|
+
async promptManualProjectId() {
|
|
95
|
+
const projectId = await input({
|
|
96
|
+
message: 'Project ID',
|
|
97
|
+
validate: (value) => (value.trim().length > 0 ? true : 'Project ID cannot be empty'),
|
|
98
|
+
});
|
|
99
|
+
return { projectId, projectLabel: projectId };
|
|
100
|
+
}
|
|
101
|
+
async resolveProjectChoice() {
|
|
102
|
+
let projects = configManager.listProjects();
|
|
103
|
+
if (projects.length === 0) {
|
|
104
|
+
this.log('No projects configured yet.');
|
|
105
|
+
const runConfig = await confirm({ default: true, message: 'Run `lps project config` now to add one?' });
|
|
106
|
+
if (!runConfig) {
|
|
107
|
+
return this.promptManualProjectId();
|
|
108
|
+
}
|
|
109
|
+
await this.config.runCommand('project:config');
|
|
110
|
+
projects = configManager.listProjects();
|
|
111
|
+
if (projects.length === 0) {
|
|
112
|
+
this.log('Still no project configured, falling back to manual entry.');
|
|
113
|
+
return this.promptManualProjectId();
|
|
114
|
+
}
|
|
115
|
+
if (projects.length === 1) {
|
|
116
|
+
return { projectId: projects[0].id, projectLabel: projects[0].name };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const choice = await select({
|
|
120
|
+
choices: [...projects.map((p) => ({ name: p.name, value: p.id })), { name: 'Enter a project ID manually', value: '__manual__' }],
|
|
121
|
+
message: 'WordPress project',
|
|
122
|
+
});
|
|
123
|
+
if (choice === '__manual__') {
|
|
124
|
+
return this.promptManualProjectId();
|
|
125
|
+
}
|
|
126
|
+
return { projectId: choice, projectLabel: projects.find((p) => p.id === choice).name };
|
|
95
127
|
}
|
|
96
128
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class List extends LoopressCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { getPageId, getPageTitle, PAGE_ENDPOINT, PAGE_LIST_QUERY } from '../../utils/page-format.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List pages from WordPress';
|
|
6
|
+
static examples = ['$ lps page list'];
|
|
7
|
+
static flags = {
|
|
8
|
+
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
9
|
+
};
|
|
10
|
+
async run() {
|
|
11
|
+
const { flags } = await this.parse(List);
|
|
12
|
+
const pages = await this.wp.get(`${PAGE_ENDPOINT}?${PAGE_LIST_QUERY}`);
|
|
13
|
+
if (flags.json) {
|
|
14
|
+
this.log(JSON.stringify(pages, null, 2));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.log(`Pages (${pages.length}):`);
|
|
18
|
+
if (pages.length === 0) {
|
|
19
|
+
this.log(' (none)');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const page of pages) {
|
|
23
|
+
this.log(` ${getPageId(page) ?? '(no id)'}. ${getPageTitle(page)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { getPageContent, getPageId, getPageTitle, PAGE_ENDPOINT, PAGE_LIST_QUERY, pageFileBase, pickPageMeta } from '../../utils/page-format.js';
|
|
8
|
+
export default class Pull extends LoopressCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
path: Args.string({ description: 'Path to pages directory (overrides project config)' }),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Pull pages from WordPress';
|
|
13
|
+
static examples = ['$ lps page pull'];
|
|
14
|
+
static flags = {
|
|
15
|
+
...LoopressCommand.dryRunFlag,
|
|
16
|
+
...LoopressCommand.yesFlag,
|
|
17
|
+
};
|
|
18
|
+
async run() {
|
|
19
|
+
const { args } = await this.parse(Pull);
|
|
20
|
+
const { url } = this.siteConfig;
|
|
21
|
+
const path = this.resolvePagePath(args.path);
|
|
22
|
+
this.log(`Pulling pages from ${url}`);
|
|
23
|
+
this.log(`Pages path: ${path}`);
|
|
24
|
+
// context=edit returns title/content/excerpt as raw editable source instead of rendered
|
|
25
|
+
// HTML, needed for the file to be a faithful, re-pushable copy (requires edit_pages).
|
|
26
|
+
const remoteList = await this.wp.get(`${PAGE_ENDPOINT}?${PAGE_LIST_QUERY}&context=edit`);
|
|
27
|
+
const withId = remoteList.filter((page) => getPageId(page) !== null);
|
|
28
|
+
const skipped = remoteList.length - withId.length;
|
|
29
|
+
// Every page writes an `.html` (content) and `.json` (everything else) pair; both
|
|
30
|
+
// extensions are candidates for orphan cleanup, same principle as `snippet pull`.
|
|
31
|
+
const orphans = await findOrphanedFiles(path, new Set(withId.map((page) => String(getPageId(page)))), {
|
|
32
|
+
extensions: ['.json', '.html'],
|
|
33
|
+
key: numericPrefixKey,
|
|
34
|
+
});
|
|
35
|
+
if (this.dryRun) {
|
|
36
|
+
this.log(`[dry-run] Would pull ${withId.length} page${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
37
|
+
if (orphans.length > 0) {
|
|
38
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (withId.length > 0)
|
|
43
|
+
await mkdir(path, { recursive: true });
|
|
44
|
+
await new Listr(withId.map((page) => {
|
|
45
|
+
const id = getPageId(page);
|
|
46
|
+
const title = getPageTitle(page);
|
|
47
|
+
return {
|
|
48
|
+
async task(_ctx, task) {
|
|
49
|
+
const base = pageFileBase(id, title);
|
|
50
|
+
// `content` lives entirely in the `.html` file; the `.json` sidecar keeps only the
|
|
51
|
+
// fields WordPress actually accepts back on write, see pickPageMeta.
|
|
52
|
+
await writeFile(join(path, `${base}.html`), getPageContent(page));
|
|
53
|
+
await writeFile(join(path, `${base}.json`), JSON.stringify(pickPageMeta(page), null, 2) + '\n');
|
|
54
|
+
task.output = `Pulled: ${title}`;
|
|
55
|
+
},
|
|
56
|
+
title: `Pull ${title}`,
|
|
57
|
+
};
|
|
58
|
+
})).run();
|
|
59
|
+
await this.removeOrphanedFiles(path, orphans, `in ${path} no longer present on WordPress`);
|
|
60
|
+
this.log(`Pulled ${withId.length} page${withId.length === 1 ? '' : 's'} to ${path}`);
|
|
61
|
+
if (skipped > 0) {
|
|
62
|
+
this.warn(`${skipped} page${skipped === 1 ? '' : 's'} skipped because they have no id`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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 pushPage;
|
|
16
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
5
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
6
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
+
import { getPageId, getPageTitle, PAGE_ENDPOINT, pageFileBase } from '../../utils/page-format.js';
|
|
8
|
+
export default class Push extends PushCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
path: Args.string({ description: 'Path to pages directory (overrides project config)' }),
|
|
11
|
+
};
|
|
12
|
+
static description = 'Push pages to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
|
|
13
|
+
static examples = ['$ lps page push'];
|
|
14
|
+
static flags = {
|
|
15
|
+
...PushCommand.dryRunFlag,
|
|
16
|
+
...PushCommand.yesFlag,
|
|
17
|
+
};
|
|
18
|
+
async run() {
|
|
19
|
+
const { args } = await this.parse(Push);
|
|
20
|
+
const { url } = this.siteConfig;
|
|
21
|
+
const path = this.resolvePagePath(args.path);
|
|
22
|
+
this.log(`Pushing pages to ${url}`);
|
|
23
|
+
this.log(`Pages path: ${path}`);
|
|
24
|
+
const pages = await this.loadFiles(path);
|
|
25
|
+
this.log(`Found ${pages.length} page${pages.length === 1 ? '' : 's'} to push`);
|
|
26
|
+
await new Listr(pages.map((page) => ({
|
|
27
|
+
task: async (_ctx, task) => this.pushPage(page, task),
|
|
28
|
+
title: `Push ${getPageTitle(page.meta)}`,
|
|
29
|
+
})), { concurrent: false, exitOnError: false }).run();
|
|
30
|
+
if (this.failedCount > 0) {
|
|
31
|
+
this.error(`${this.failedCount} page${this.failedCount === 1 ? '' : 's'} failed to push.`);
|
|
32
|
+
}
|
|
33
|
+
if (this.dryRun)
|
|
34
|
+
return;
|
|
35
|
+
await this.recordSuccess();
|
|
36
|
+
this.log('All pages pushed.');
|
|
37
|
+
}
|
|
38
|
+
// Renames the local file pair to the `<id>-<slug>` convention used by `page pull` whenever it
|
|
39
|
+
// doesn't already match, same principle as ensureCanonicalFilename in commands/snippet/push.ts:
|
|
40
|
+
// the id is persisted into the sidecar before any rename, so a crash mid-rename still leaves a
|
|
41
|
+
// valid, matchable pair on disk instead of an orphaned one that would be recreated as a duplicate.
|
|
42
|
+
async ensureCanonicalFilename(page, id, title) {
|
|
43
|
+
const dir = dirname(page.contentPath);
|
|
44
|
+
const canonicalBase = pageFileBase(id, title);
|
|
45
|
+
const currentBase = basename(page.contentPath, '.html');
|
|
46
|
+
const meta = { ...page.meta, id };
|
|
47
|
+
await writeFile(page.metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
48
|
+
if (currentBase === canonicalBase)
|
|
49
|
+
return;
|
|
50
|
+
await rename(page.contentPath, join(dir, `${canonicalBase}.html`));
|
|
51
|
+
await rename(page.metaPath, join(dir, `${canonicalBase}.json`));
|
|
52
|
+
}
|
|
53
|
+
// A page is its `.html` content file plus an optional `.json` sidecar for everything else
|
|
54
|
+
// (a fresh hand-created page may have no sidecar yet). One page is read in isolation: a
|
|
55
|
+
// corrupted or hand-broken sidecar must only skip that page, not abort loading the rest of
|
|
56
|
+
// the directory, same principle as commands/form/push.ts.
|
|
57
|
+
async loadFiles(dir) {
|
|
58
|
+
let files;
|
|
59
|
+
try {
|
|
60
|
+
files = await readdir(dir);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === 'ENOENT')
|
|
64
|
+
return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const pages = [];
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
if (extname(file) !== '.html')
|
|
70
|
+
continue;
|
|
71
|
+
const contentPath = join(dir, file);
|
|
72
|
+
const metaPath = join(dir, `${basename(file, '.html')}.json`);
|
|
73
|
+
let meta = {};
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
76
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
77
|
+
this.warn(`Skipping "${metaPath}": not a JSON object`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
meta = parsed;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== 'ENOENT') {
|
|
84
|
+
this.warn(`Skipping "${metaPath}": ${error.message}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let content;
|
|
89
|
+
try {
|
|
90
|
+
content = await readFile(contentPath, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
this.warn(`Skipping "${contentPath}": ${error.message}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
pages.push({ content, contentPath, meta, metaPath });
|
|
97
|
+
}
|
|
98
|
+
return pages;
|
|
99
|
+
}
|
|
100
|
+
// PUT-then-404-fallback-POST, same dance as commands/form/push.ts: pages are id-based.
|
|
101
|
+
//
|
|
102
|
+
// ponytail: `parent` round-trips as the source site's numeric page id untouched, so pushing
|
|
103
|
+
// to a site where that id belongs to a different page (or nothing) silently mis-parents or
|
|
104
|
+
// orphans the page. No id-remapping across sites here; fine for same-hierarchy environments,
|
|
105
|
+
// revisit if pushing across sites with divergent page trees becomes a real workflow.
|
|
106
|
+
async pushPage(page, task) {
|
|
107
|
+
const title = getPageTitle(page.meta);
|
|
108
|
+
if (this.dryRun) {
|
|
109
|
+
if (task)
|
|
110
|
+
task.output = `[dry-run] Would push: ${title}`;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const id = getPageId(page.meta);
|
|
115
|
+
const payload = { ...page.meta, content: page.content };
|
|
116
|
+
if (id === null) {
|
|
117
|
+
const created = await this.wp.post(PAGE_ENDPOINT, payload);
|
|
118
|
+
const newId = getPageId(created);
|
|
119
|
+
if (newId !== null)
|
|
120
|
+
await this.ensureCanonicalFilename(page, newId, title);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
try {
|
|
124
|
+
await this.wp.put(`${PAGE_ENDPOINT}/${id}`, payload);
|
|
125
|
+
await this.ensureCanonicalFilename(page, id, title);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// The id recorded locally doesn't exist on this site (e.g. a fresh install): create
|
|
129
|
+
// it instead of failing, and adopt whatever id the site assigns.
|
|
130
|
+
if (!isNotFoundError(error))
|
|
131
|
+
throw error;
|
|
132
|
+
const created = await this.wp.post(PAGE_ENDPOINT, payload);
|
|
133
|
+
const newId = getPageId(created);
|
|
134
|
+
if (newId !== null)
|
|
135
|
+
await this.ensureCanonicalFilename(page, newId, title);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (task)
|
|
139
|
+
task.output = `Pushed: ${title}`;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.reportTaskFailure(`Failed to push ${title}: ${error.message}`, error, task);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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
|
}
|