@loopress/cli 0.11.0 → 0.13.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 +45 -20
- package/dist/commands/login.d.ts +0 -1
- package/dist/commands/login.js +21 -90
- package/dist/commands/project/config.d.ts +2 -0
- package/dist/commands/project/config.js +45 -11
- package/dist/commands/project/sync.js +15 -5
- package/dist/commands/snippet/publish.d.ts +10 -0
- package/dist/commands/snippet/publish.js +74 -0
- package/dist/commands/snippet/pull.d.ts +1 -0
- package/dist/commands/snippet/pull.js +38 -4
- package/dist/commands/snippet/push.js +5 -61
- package/dist/config/project-config.manager.d.ts +3 -0
- package/dist/config/project-config.manager.js +12 -0
- package/dist/lib/load-snippets.d.ts +2 -0
- package/dist/lib/load-snippets.js +84 -0
- package/dist/lib/local-callback-server.d.ts +25 -0
- package/dist/lib/local-callback-server.js +93 -0
- package/dist/lib/open-browser.d.ts +2 -0
- package/dist/lib/open-browser.js +12 -0
- package/dist/lib/wp-authorize-flow.d.ts +10 -0
- package/dist/lib/wp-authorize-flow.js +53 -0
- package/dist/lib/wp-client.js +18 -1
- package/dist/lib/wp-site-diagnostic.d.ts +13 -0
- package/dist/lib/wp-site-diagnostic.js +46 -0
- package/oclif.manifest.json +138 -108
- package/package.json +2 -1
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
5
|
import slugify from 'slugify';
|
|
6
|
+
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
6
7
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
8
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
-
import {
|
|
9
|
-
const TYPE_BY_EXTENSION = {
|
|
10
|
-
'.css': 'css',
|
|
11
|
-
'.html': 'html',
|
|
12
|
-
'.js': 'js',
|
|
13
|
-
'.php': 'php',
|
|
14
|
-
'.txt': 'text',
|
|
15
|
-
};
|
|
9
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
16
10
|
export default class Push extends PushCommand {
|
|
17
11
|
static args = {
|
|
18
12
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -78,62 +72,12 @@ export default class Push extends PushCommand {
|
|
|
78
72
|
await rm(oldMetaPath, { force: true });
|
|
79
73
|
}
|
|
80
74
|
async loadSnippets(path) {
|
|
81
|
-
const snippets = [];
|
|
82
75
|
try {
|
|
83
|
-
|
|
84
|
-
for (const file of files) {
|
|
85
|
-
const ext = extname(file);
|
|
86
|
-
if (!(ext in TYPE_BY_EXTENSION))
|
|
87
|
-
continue;
|
|
88
|
-
const filePath = join(path, file);
|
|
89
|
-
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
90
|
-
const content = await readFile(filePath, 'utf8');
|
|
91
|
-
let id;
|
|
92
|
-
let name;
|
|
93
|
-
let type;
|
|
94
|
-
let active = false;
|
|
95
|
-
let tags = [];
|
|
96
|
-
let location = null;
|
|
97
|
-
let insertMethod = null;
|
|
98
|
-
let priority = 10;
|
|
99
|
-
let shortcodeAttributes = [];
|
|
100
|
-
try {
|
|
101
|
-
const metaContent = await readFile(metaPath, 'utf8');
|
|
102
|
-
const meta = JSON.parse(metaContent);
|
|
103
|
-
id = meta.id ? Number(meta.id) : undefined;
|
|
104
|
-
name = meta.name ? String(meta.name) : undefined;
|
|
105
|
-
type = parseType(meta.type) ?? undefined;
|
|
106
|
-
active = Boolean(meta.active);
|
|
107
|
-
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
108
|
-
location = parseLocation(meta.location);
|
|
109
|
-
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
110
|
-
priority = meta.priority === undefined ? 10 : Number(meta.priority);
|
|
111
|
-
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
if (error.code !== 'ENOENT')
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
|
-
const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
118
|
-
snippets.push({
|
|
119
|
-
active,
|
|
120
|
-
code: content,
|
|
121
|
-
id,
|
|
122
|
-
insertMethod: insertMethod ?? 'auto',
|
|
123
|
-
location: location ?? defaultLocationForType(resolvedType),
|
|
124
|
-
name: name ?? basename(file, ext),
|
|
125
|
-
path: filePath,
|
|
126
|
-
priority,
|
|
127
|
-
shortcodeAttributes,
|
|
128
|
-
tags,
|
|
129
|
-
type: resolvedType,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
76
|
+
return await loadSnippetsFromDisk(path, (message) => this.warn(message));
|
|
132
77
|
}
|
|
133
78
|
catch (error) {
|
|
134
|
-
this.error(
|
|
79
|
+
this.error(error.message);
|
|
135
80
|
}
|
|
136
|
-
return snippets;
|
|
137
81
|
}
|
|
138
82
|
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
139
83
|
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
@@ -4,6 +4,9 @@ export declare class ProjectConfigManager {
|
|
|
4
4
|
constructor(homeDir?: string);
|
|
5
5
|
createProjectId(name: string): string;
|
|
6
6
|
ensureConfigDir(): void;
|
|
7
|
+
findProjectByApiId(apiProjectId: string): null | (ProjectConfig & {
|
|
8
|
+
id: string;
|
|
9
|
+
});
|
|
7
10
|
getConfigFilePath(): string;
|
|
8
11
|
getCurrentEnv(): EnvironmentConfig | null;
|
|
9
12
|
getCurrentProject(): null | (ProjectConfig & {
|
|
@@ -25,6 +25,18 @@ export class ProjectConfigManager {
|
|
|
25
25
|
mkdirSync(dir, { recursive: true });
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
// Looks up a local project already linked to a given API project, regardless of whether
|
|
29
|
+
// it was "claimed" in the current sync run. Used by `project sync` to avoid minting a new
|
|
30
|
+
// local project every time it pulls an API project whose local link was lost (e.g. after
|
|
31
|
+
// a reset or partial config corruption), which otherwise accumulates duplicate entries.
|
|
32
|
+
findProjectByApiId(apiProjectId) {
|
|
33
|
+
const config = this.readConfig();
|
|
34
|
+
for (const [id, project] of Object.entries(config.projects)) {
|
|
35
|
+
if (project.apiProjectId === apiProjectId)
|
|
36
|
+
return { ...project, id };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
28
40
|
getConfigFilePath() {
|
|
29
41
|
return join(this.homeDir, '.loopress', 'config.json');
|
|
30
42
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
|
|
4
|
+
const TYPE_BY_EXTENSION = {
|
|
5
|
+
'.css': 'css',
|
|
6
|
+
'.html': 'html',
|
|
7
|
+
'.js': 'js',
|
|
8
|
+
'.php': 'php',
|
|
9
|
+
'.txt': 'text',
|
|
10
|
+
};
|
|
11
|
+
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
12
|
+
// (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
|
|
13
|
+
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
14
|
+
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
15
|
+
export async function loadSnippets(path, onSkip) {
|
|
16
|
+
const snippets = [];
|
|
17
|
+
let files;
|
|
18
|
+
try {
|
|
19
|
+
files = await readdir(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`Error loading snippets: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const ext = extname(file);
|
|
26
|
+
if (!(ext in TYPE_BY_EXTENSION))
|
|
27
|
+
continue;
|
|
28
|
+
const filePath = join(path, file);
|
|
29
|
+
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
30
|
+
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
31
|
+
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
32
|
+
let content;
|
|
33
|
+
try {
|
|
34
|
+
content = await readFile(filePath, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let id;
|
|
41
|
+
let name;
|
|
42
|
+
let type;
|
|
43
|
+
let active = false;
|
|
44
|
+
let tags = [];
|
|
45
|
+
let location = null;
|
|
46
|
+
let insertMethod = null;
|
|
47
|
+
let priority = 10;
|
|
48
|
+
let shortcodeAttributes = [];
|
|
49
|
+
try {
|
|
50
|
+
const metaContent = await readFile(metaPath, 'utf8');
|
|
51
|
+
const meta = JSON.parse(metaContent);
|
|
52
|
+
id = meta.id === undefined ? undefined : Number(meta.id);
|
|
53
|
+
name = meta.name ? String(meta.name) : undefined;
|
|
54
|
+
type = parseType(meta.type) ?? undefined;
|
|
55
|
+
active = Boolean(meta.active);
|
|
56
|
+
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
57
|
+
location = parseLocation(meta.location);
|
|
58
|
+
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
59
|
+
priority = meta.priority === undefined ? 10 : Number(meta.priority);
|
|
60
|
+
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code !== 'ENOENT') {
|
|
64
|
+
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
69
|
+
snippets.push({
|
|
70
|
+
active,
|
|
71
|
+
code: content,
|
|
72
|
+
id,
|
|
73
|
+
insertMethod: insertMethod ?? 'auto',
|
|
74
|
+
location: location ?? defaultLocationForType(resolvedType),
|
|
75
|
+
name: name ?? basename(file, ext),
|
|
76
|
+
path: filePath,
|
|
77
|
+
priority,
|
|
78
|
+
shortcodeAttributes,
|
|
79
|
+
tags,
|
|
80
|
+
type: resolvedType,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return snippets;
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CallbackHelpers<T> = {
|
|
2
|
+
rejectWithPage: (page: string, error: Error) => void;
|
|
3
|
+
resolveWithPage: (page: string, value: T) => void;
|
|
4
|
+
respondBadRequest: (message: string) => void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
|
|
8
|
+
* catch the resulting redirect on a short-lived local server; this factors out the server setup,
|
|
9
|
+
* timeout, and browser-opening boilerplate they'd otherwise duplicate.
|
|
10
|
+
*/
|
|
11
|
+
export declare function waitForLocalCallback<T>(options: {
|
|
12
|
+
buildUrl: (callbackBaseUrl: string) => string;
|
|
13
|
+
handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
|
|
14
|
+
log: (message: string) => void;
|
|
15
|
+
openingMessage: string;
|
|
16
|
+
timeoutMessage: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}): Promise<T>;
|
|
19
|
+
export declare function renderResultPage(options: {
|
|
20
|
+
background: string;
|
|
21
|
+
heading: string;
|
|
22
|
+
headingColor: string;
|
|
23
|
+
icon: string;
|
|
24
|
+
tabTitle: string;
|
|
25
|
+
}): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { openBrowser } from './open-browser.js';
|
|
3
|
+
/**
|
|
4
|
+
* Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
|
|
5
|
+
* catch the resulting redirect on a short-lived local server; this factors out the server setup,
|
|
6
|
+
* timeout, and browser-opening boilerplate they'd otherwise duplicate.
|
|
7
|
+
*/
|
|
8
|
+
export function waitForLocalCallback(options) {
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
function finish(res, page, settle) {
|
|
12
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
13
|
+
res.end(page);
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
server.close();
|
|
16
|
+
settle();
|
|
17
|
+
}
|
|
18
|
+
const server = createServer((req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
21
|
+
options.handleRequest(url, {
|
|
22
|
+
rejectWithPage: (page, error) => finish(res, page, () => reject(error)),
|
|
23
|
+
resolveWithPage: (page, value) => finish(res, page, () => resolve(value)),
|
|
24
|
+
respondBadRequest(message) {
|
|
25
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
26
|
+
res.end(message);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
res.writeHead(500);
|
|
32
|
+
res.end('Internal error');
|
|
33
|
+
server.close();
|
|
34
|
+
reject(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
server.on('error', (err) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(err);
|
|
40
|
+
});
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
server.close();
|
|
43
|
+
reject(new Error(options.timeoutMessage));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
server.listen(0, '127.0.0.1', () => {
|
|
46
|
+
const { port } = server.address();
|
|
47
|
+
const targetUrl = options.buildUrl(`http://localhost:${port}`);
|
|
48
|
+
options.log(options.openingMessage);
|
|
49
|
+
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
50
|
+
openBrowser(targetUrl);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function renderResultPage(options) {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title>Loopress: ${options.tabTitle}</title>
|
|
61
|
+
<style>
|
|
62
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
63
|
+
body {
|
|
64
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
65
|
+
background: ${options.background};
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
min-height: 100dvh;
|
|
70
|
+
}
|
|
71
|
+
.card {
|
|
72
|
+
background: #fff;
|
|
73
|
+
border-radius: 16px;
|
|
74
|
+
padding: 2.5rem 3rem;
|
|
75
|
+
text-align: center;
|
|
76
|
+
box-shadow: 0 4px 32px rgba(0, 0, 0, .08);
|
|
77
|
+
max-width: 420px;
|
|
78
|
+
width: 90%;
|
|
79
|
+
}
|
|
80
|
+
.icon { font-size: 3rem; margin-bottom: 1rem; }
|
|
81
|
+
h1 { color: ${options.headingColor}; font-size: 1.5rem; margin-bottom: .5rem; }
|
|
82
|
+
p { color: #6b7280; font-size: .95rem; line-height: 1.5; }
|
|
83
|
+
</style>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="icon">${options.icon}</div>
|
|
88
|
+
<h1>${options.heading}</h1>
|
|
89
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
90
|
+
</div>
|
|
91
|
+
</body>
|
|
92
|
+
</html>`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/** Opens `url` in the user's default browser, best-effort (no-op on unknown platforms). */
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const commands = {
|
|
5
|
+
darwin: ['open', [url]],
|
|
6
|
+
linux: ['xdg-open', [url]],
|
|
7
|
+
win32: ['cmd', ['/c', 'start', '', url]],
|
|
8
|
+
};
|
|
9
|
+
const command = commands[process.platform];
|
|
10
|
+
if (command)
|
|
11
|
+
execFile(...command);
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type AuthorizeResult = {
|
|
2
|
+
password: string;
|
|
3
|
+
userLogin: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Runs WordPress's native "authorize application" flow: opens a local callback server,
|
|
7
|
+
* sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
|
|
8
|
+
* generated Application Password once WordPress redirects back.
|
|
9
|
+
*/
|
|
10
|
+
export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
|
|
3
|
+
const APP_NAME = 'Loopress';
|
|
4
|
+
/**
|
|
5
|
+
* Runs WordPress's native "authorize application" flow: opens a local callback server,
|
|
6
|
+
* sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
|
|
7
|
+
* generated Application Password once WordPress redirects back.
|
|
8
|
+
*/
|
|
9
|
+
export function authorizeWithBrowser(siteUrl, log) {
|
|
10
|
+
const state = randomBytes(32).toString('hex');
|
|
11
|
+
return waitForLocalCallback({
|
|
12
|
+
buildUrl(callbackBaseUrl) {
|
|
13
|
+
const successUrl = `${callbackBaseUrl}/callback?state=${state}`;
|
|
14
|
+
const rejectUrl = `${callbackBaseUrl}/reject?state=${state}`;
|
|
15
|
+
return (`${siteUrl}/wp-admin/authorize-application.php?app_name=${encodeURIComponent(APP_NAME)}` +
|
|
16
|
+
`&success_url=${encodeURIComponent(successUrl)}&reject_url=${encodeURIComponent(rejectUrl)}`);
|
|
17
|
+
},
|
|
18
|
+
handleRequest(url, { rejectWithPage, resolveWithPage, respondBadRequest }) {
|
|
19
|
+
if (url.searchParams.get('state') !== state) {
|
|
20
|
+
respondBadRequest('Invalid or missing state');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (url.pathname === '/reject') {
|
|
24
|
+
rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress. You can enter credentials manually instead.'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const userLogin = url.searchParams.get('user_login');
|
|
28
|
+
const password = url.searchParams.get('password');
|
|
29
|
+
if (!userLogin || !password) {
|
|
30
|
+
respondBadRequest('Missing user_login or password');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolveWithPage(SUCCESS_PAGE, { password, userLogin });
|
|
34
|
+
},
|
|
35
|
+
log,
|
|
36
|
+
openingMessage: 'Opening WordPress in your browser to authorize Loopress...',
|
|
37
|
+
timeoutMessage: 'Authorization timed out after 5 minutes. You can enter credentials manually instead.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const SUCCESS_PAGE = renderResultPage({
|
|
41
|
+
background: '#f0fdf4',
|
|
42
|
+
heading: 'Authorization successful!',
|
|
43
|
+
headingColor: '#15803d',
|
|
44
|
+
icon: '✅',
|
|
45
|
+
tabTitle: 'Authorized',
|
|
46
|
+
});
|
|
47
|
+
const REJECTED_PAGE = renderResultPage({
|
|
48
|
+
background: '#fef2f2',
|
|
49
|
+
heading: 'Authorization rejected',
|
|
50
|
+
headingColor: '#b91c1c',
|
|
51
|
+
icon: '✕',
|
|
52
|
+
tabTitle: 'Authorization rejected',
|
|
53
|
+
});
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -48,10 +48,27 @@ export function formatWpError(error, url) {
|
|
|
48
48
|
return `Endpoint not found (404) on ${url}. Is the required plugin installed and up to date on the site?`;
|
|
49
49
|
}
|
|
50
50
|
if (status !== undefined) {
|
|
51
|
-
|
|
51
|
+
const reason = extractServerErrorMessage(err.response?.body);
|
|
52
|
+
return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
|
|
52
53
|
}
|
|
53
54
|
if (err.name === 'TimeoutError') {
|
|
54
55
|
return `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s on ${url}. Is the site reachable?`;
|
|
55
56
|
}
|
|
56
57
|
return `Request to ${url} failed: ${err.message ?? String(error)}`;
|
|
57
58
|
}
|
|
59
|
+
// The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
|
|
60
|
+
// core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
|
|
61
|
+
// Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
|
|
62
|
+
// plugins are active...") actually reach the user instead of a bare, unhelpful status code.
|
|
63
|
+
function extractServerErrorMessage(body) {
|
|
64
|
+
if (!body)
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(body);
|
|
68
|
+
const reason = parsed.error ?? parsed.message;
|
|
69
|
+
return typeof reason === 'string' && reason.trim() ? reason : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const REQUEST_TIMEOUT_MS = 10000;
|
|
2
|
+
export type DiagnosticResult = {
|
|
3
|
+
ok: false;
|
|
4
|
+
reason: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: true;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Pre-flight checks run before starting the browser authorization flow, so failures
|
|
10
|
+
* (unreachable site, blocked REST API, WordPress too old) surface as an actionable
|
|
11
|
+
* message instead of a confusing timeout once the browser is already open.
|
|
12
|
+
*/
|
|
13
|
+
export declare function diagnoseWpSite(siteUrl: string): Promise<DiagnosticResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
/**
|
|
4
|
+
* Pre-flight checks run before starting the browser authorization flow, so failures
|
|
5
|
+
* (unreachable site, blocked REST API, WordPress too old) surface as an actionable
|
|
6
|
+
* message instead of a confusing timeout once the browser is already open.
|
|
7
|
+
*/
|
|
8
|
+
export async function diagnoseWpSite(siteUrl) {
|
|
9
|
+
try {
|
|
10
|
+
await got.get(`${siteUrl}/wp-json/`, { timeout: { request: REQUEST_TIMEOUT_MS } });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${describe(error)})`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const response = await got.head(`${siteUrl}/wp-admin/authorize-application.php`, {
|
|
20
|
+
throwHttpErrors: false,
|
|
21
|
+
timeout: { request: REQUEST_TIMEOUT_MS },
|
|
22
|
+
});
|
|
23
|
+
if (response.statusCode === 404) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: `The application authorization page was not found on ${siteUrl}. This WordPress site may be older than 5.6, or the feature may be disabled by a plugin.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (response.statusCode >= 400) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: `The application authorization page at ${siteUrl}/wp-admin/authorize-application.php returned an error (HTTP ${response.statusCode}).`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `Could not reach ${siteUrl}/wp-admin/authorize-application.php. (${describe(error)})`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
function describe(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|