wpfleet 1.0.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/CHANGELOG.md +30 -0
- package/README.md +143 -0
- package/bin/wpfleet.js +2 -0
- package/package.json +45 -0
- package/src/ai/client.js +149 -0
- package/src/ai/seo-prompts.js +117 -0
- package/src/commands/ai-optimize.js +242 -0
- package/src/commands/ai-write.js +119 -0
- package/src/commands/health.js +100 -0
- package/src/commands/interlink.js +208 -0
- package/src/commands/publish.js +136 -0
- package/src/commands/report.js +108 -0
- package/src/commands/security.js +158 -0
- package/src/commands/sites.js +98 -0
- package/src/commands/stats.js +48 -0
- package/src/commands/traffic.js +87 -0
- package/src/commands/update.js +112 -0
- package/src/config.js +51 -0
- package/src/index.js +231 -0
- package/src/utils/display.js +41 -0
- package/src/utils/http.js +40 -0
- package/src/utils/wp-api.js +75 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import { marked } from 'marked';
|
|
6
|
+
import matter from 'gray-matter';
|
|
7
|
+
import { getSite, getSites } from '../config.js';
|
|
8
|
+
import { makeTable } from '../utils/display.js';
|
|
9
|
+
|
|
10
|
+
function makeClient(site) {
|
|
11
|
+
const baseURL = site.url.replace(/\/$/, '') + '/wp-json';
|
|
12
|
+
const token = Buffer.from(`${site.wp_user}:${site.wp_app_password}`).toString('base64');
|
|
13
|
+
return axios.create({
|
|
14
|
+
baseURL,
|
|
15
|
+
timeout: 15000,
|
|
16
|
+
validateStatus: () => true,
|
|
17
|
+
headers: { Authorization: `Basic ${token}` },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resolveTerms(client, taxonomy, names) {
|
|
22
|
+
const ids = [];
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const search = await client.get(`/wp/v2/${taxonomy}`, { params: { search: name, per_page: 10 } });
|
|
25
|
+
if (search.status === 200 && Array.isArray(search.data) && search.data.length > 0) {
|
|
26
|
+
const match = search.data.find(t => t.name.toLowerCase() === String(name).toLowerCase());
|
|
27
|
+
if (match) { ids.push(match.id); continue; }
|
|
28
|
+
}
|
|
29
|
+
const create = await client.post(`/wp/v2/${taxonomy}`, { name: String(name) });
|
|
30
|
+
if (create.status === 201) ids.push(create.data.id);
|
|
31
|
+
}
|
|
32
|
+
return ids;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function publishPost(site, filePath, options) {
|
|
36
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
37
|
+
const { data: fm, content } = matter(raw);
|
|
38
|
+
const html = marked(content);
|
|
39
|
+
|
|
40
|
+
const title = fm.title || path.basename(filePath, '.md');
|
|
41
|
+
const excerpt = fm.excerpt || '';
|
|
42
|
+
let status = fm.status || 'publish';
|
|
43
|
+
if (options.draft) status = 'draft';
|
|
44
|
+
|
|
45
|
+
const payload = { title, content: html, status, excerpt };
|
|
46
|
+
|
|
47
|
+
if (options.schedule) {
|
|
48
|
+
payload.date = new Date(options.schedule).toISOString();
|
|
49
|
+
payload.status = 'future';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const client = makeClient(site);
|
|
53
|
+
|
|
54
|
+
const categories = Array.isArray(fm.categories) ? fm.categories : (fm.categories ? [fm.categories] : []);
|
|
55
|
+
const tags = Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []);
|
|
56
|
+
|
|
57
|
+
if (categories.length > 0) payload.categories = await resolveTerms(client, 'categories', categories);
|
|
58
|
+
if (tags.length > 0) payload.tags = await resolveTerms(client, 'tags', tags);
|
|
59
|
+
|
|
60
|
+
const res = await client.post('/wp/v2/posts', payload);
|
|
61
|
+
if (res.status !== 201) {
|
|
62
|
+
throw new Error(`HTTP ${res.status}: ${JSON.stringify(res.data?.message || res.data)}`);
|
|
63
|
+
}
|
|
64
|
+
return res.data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function publishCommand(filePath, options) {
|
|
68
|
+
if (!options.site) {
|
|
69
|
+
console.error(chalk.red('Error: --site <name> is required'));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const site = getSite(options.site);
|
|
73
|
+
if (!site) { console.error(chalk.red(`Site not found: ${options.site}`)); process.exit(1); }
|
|
74
|
+
if (!site.wp_user || !site.wp_app_password) {
|
|
75
|
+
console.error(chalk.red(`Site "${site.name}" requires wp_user and wp_app_password`));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (!fs.existsSync(filePath)) {
|
|
79
|
+
console.error(chalk.red(`File not found: ${filePath}`)); process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(chalk.dim(`Publishing ${path.basename(filePath)} to ${site.name}...`));
|
|
83
|
+
const post = await publishPost(site, filePath, options);
|
|
84
|
+
console.log(chalk.green(`Published: ${post.link}`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function bulkPublishCommand(directory, options) {
|
|
88
|
+
if (!fs.existsSync(directory)) {
|
|
89
|
+
console.error(chalk.red(`Directory not found: ${directory}`)); process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const files = fs.readdirSync(directory)
|
|
93
|
+
.filter(f => f.endsWith('.md'))
|
|
94
|
+
.map(f => path.join(directory, f));
|
|
95
|
+
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
console.log(chalk.yellow('No .md files found in directory'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let sites = [];
|
|
102
|
+
if (options.distribute) {
|
|
103
|
+
sites = getSites();
|
|
104
|
+
if (sites.length === 0) { console.error(chalk.red('No sites configured')); process.exit(1); }
|
|
105
|
+
} else if (options.site) {
|
|
106
|
+
const site = getSite(options.site);
|
|
107
|
+
if (!site) { console.error(chalk.red(`Site not found: ${options.site}`)); process.exit(1); }
|
|
108
|
+
sites = [site];
|
|
109
|
+
} else {
|
|
110
|
+
console.error(chalk.red('Either --site <name> or --distribute is required'));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const table = makeTable(['File', 'Site', 'Status'], [30, 20, 30]);
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < files.length; i++) {
|
|
117
|
+
const file = files[i];
|
|
118
|
+
const site = sites[i % sites.length];
|
|
119
|
+
const fileName = path.basename(file);
|
|
120
|
+
|
|
121
|
+
if (options.dryRun) {
|
|
122
|
+
table.push([fileName, site.name, chalk.cyan('dry-run')]);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const post = await publishPost(site, file, options);
|
|
128
|
+
table.push([fileName, site.name, chalk.green(`published`)]);
|
|
129
|
+
console.log(chalk.dim(` ${fileName} → ${post.link}`));
|
|
130
|
+
} catch (err) {
|
|
131
|
+
table.push([fileName, site.name, chalk.red(`error: ${err.message}`.slice(0, 28))]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(table.toString());
|
|
136
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
import { getSites, getSite } from '../config.js';
|
|
6
|
+
import { getPostCount, getPageCount, getLastPublished, getWpInfo, getPlugins } from '../utils/wp-api.js';
|
|
7
|
+
import { getResponseTime, getSslExpiry } from '../utils/http.js';
|
|
8
|
+
|
|
9
|
+
async function fetchSiteData(site) {
|
|
10
|
+
const { hostname } = new URL(site.url);
|
|
11
|
+
const [posts, pages, lastDate, wpInfo, timing, sslExpiry, plugins] = await Promise.all([
|
|
12
|
+
getPostCount(site),
|
|
13
|
+
getPageCount(site),
|
|
14
|
+
getLastPublished(site),
|
|
15
|
+
getWpInfo(site),
|
|
16
|
+
getResponseTime(site.url),
|
|
17
|
+
getSslExpiry(hostname),
|
|
18
|
+
getPlugins(site),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
let wpVersion = null;
|
|
22
|
+
if (wpInfo && wpInfo.generator) {
|
|
23
|
+
const match = wpInfo.generator.match(/v=([\d.]+)/);
|
|
24
|
+
if (match) wpVersion = match[1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let sslDays = null;
|
|
28
|
+
if (sslExpiry) {
|
|
29
|
+
sslDays = Math.floor((sslExpiry - Date.now()) / (1000 * 60 * 60 * 24));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pluginCount = plugins ? plugins.length : null;
|
|
33
|
+
const pluginUpdates = plugins ? plugins.filter(p => p.update && p.update !== 'none').length : null;
|
|
34
|
+
|
|
35
|
+
return { posts, pages, lastDate, wpVersion, responseTime: timing.ms, sslDays, pluginCount, pluginUpdates };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildMarkdownReport(site, data, month) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
const dateStr = month || new Date().toISOString().slice(0, 7);
|
|
41
|
+
lines.push(`# WP Fleet Report — ${site.name}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(`**Month:** ${dateStr} `);
|
|
44
|
+
lines.push(`**Generated:** ${new Date().toISOString().slice(0, 10)} `);
|
|
45
|
+
lines.push(`**URL:** ${site.url}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('## Site Overview');
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(`| Field | Value |`);
|
|
50
|
+
lines.push(`|-------|-------|`);
|
|
51
|
+
lines.push(`| WordPress Version | ${data.wpVersion || 'n/a'} |`);
|
|
52
|
+
lines.push(`| SSL Days Remaining | ${data.sslDays !== null ? data.sslDays + 'd' : 'n/a'} |`);
|
|
53
|
+
lines.push(`| Response Time | ${data.responseTime !== null ? data.responseTime + 'ms' : 'n/a'} |`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push('## Content Stats');
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push(`| Metric | Count |`);
|
|
58
|
+
lines.push(`|--------|-------|`);
|
|
59
|
+
lines.push(`| Published Posts | ${data.posts ?? 'n/a'} |`);
|
|
60
|
+
lines.push(`| Pages | ${data.pages ?? 'n/a'} |`);
|
|
61
|
+
lines.push(`| Last Published | ${data.lastDate ? new Date(data.lastDate).toISOString().slice(0, 10) : 'n/a'} |`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('## Health Metrics');
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push(`| Metric | Status |`);
|
|
66
|
+
lines.push(`|--------|--------|`);
|
|
67
|
+
lines.push(`| Plugins Installed | ${data.pluginCount ?? 'n/a'} |`);
|
|
68
|
+
lines.push(`| Plugins Needing Update | ${data.pluginUpdates ?? 'n/a'} |`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function reportCommand(options) {
|
|
74
|
+
const siteName = options.site;
|
|
75
|
+
const month = options.month;
|
|
76
|
+
const format = options.format || 'md';
|
|
77
|
+
const outputFile = options.output;
|
|
78
|
+
|
|
79
|
+
const sites = siteName
|
|
80
|
+
? [getSite(siteName)].filter(Boolean)
|
|
81
|
+
: getSites();
|
|
82
|
+
|
|
83
|
+
if (sites.length === 0) {
|
|
84
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let fullReport = '';
|
|
89
|
+
|
|
90
|
+
for (const site of sites) {
|
|
91
|
+
if (!outputFile) console.log(chalk.dim(`Generating report for ${site.name}...`));
|
|
92
|
+
const data = await fetchSiteData(site);
|
|
93
|
+
const md = buildMarkdownReport(site, data, month);
|
|
94
|
+
|
|
95
|
+
if (format === 'html') {
|
|
96
|
+
fullReport += `<!DOCTYPE html><html><body>\n${marked(md)}\n</body></html>\n\n`;
|
|
97
|
+
} else {
|
|
98
|
+
fullReport += md + '\n---\n\n';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (outputFile) {
|
|
103
|
+
fs.writeFileSync(outputFile, fullReport, 'utf8');
|
|
104
|
+
console.log(chalk.green(`Report saved to ${outputFile}`));
|
|
105
|
+
} else {
|
|
106
|
+
process.stdout.write(fullReport);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import { getSites, getSite } from '../config.js';
|
|
6
|
+
|
|
7
|
+
async function checkHeaders(url) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true });
|
|
10
|
+
const h = res.headers;
|
|
11
|
+
return {
|
|
12
|
+
xFrameOptions: h['x-frame-options'] || null,
|
|
13
|
+
csp: h['content-security-policy'] || null,
|
|
14
|
+
hsts: h['strict-transport-security'] || null,
|
|
15
|
+
xContentType: h['x-content-type-options'] || null,
|
|
16
|
+
xXssProtection: h['x-xss-protection'] || null,
|
|
17
|
+
};
|
|
18
|
+
} catch {
|
|
19
|
+
return { xFrameOptions: null, csp: null, hsts: null, xContentType: null, xXssProtection: null };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function checkSsl(siteUrl) {
|
|
24
|
+
const parsed = new URL(siteUrl);
|
|
25
|
+
if (parsed.protocol !== 'https:') return { ok: false, reason: 'Not HTTPS', days: null };
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const options = { host: parsed.hostname, port: 443, method: 'HEAD', rejectUnauthorized: false };
|
|
29
|
+
const req = https.request(options, (res) => {
|
|
30
|
+
const cert = res.socket.getPeerCertificate();
|
|
31
|
+
if (!cert || !cert.valid_to) { resolve({ ok: false, reason: 'No certificate', days: null }); return; }
|
|
32
|
+
const days = Math.floor((new Date(cert.valid_to) - Date.now()) / (1000 * 60 * 60 * 24));
|
|
33
|
+
resolve({ ok: res.socket.authorized !== false && days > 0, days, reason: null });
|
|
34
|
+
});
|
|
35
|
+
req.on('error', () => resolve({ ok: false, reason: 'Connection error', days: null }));
|
|
36
|
+
req.setTimeout(8000, () => { req.destroy(); resolve({ ok: false, reason: 'Timeout', days: null }); });
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function checkExposedVersion(site) {
|
|
42
|
+
try {
|
|
43
|
+
const res = await axios.get(site.url.replace(/\/$/, '') + '/wp-json/', {
|
|
44
|
+
timeout: 8000, validateStatus: () => true,
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 200 && res.data?.generator) {
|
|
47
|
+
const match = res.data.generator.match(/v=([\d.]+)/);
|
|
48
|
+
return match ? match[1] : null;
|
|
49
|
+
}
|
|
50
|
+
} catch { /* ignore */ }
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function checkXmlRpc(siteUrl) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await axios.post(siteUrl.replace(/\/$/, '') + '/xmlrpc.php',
|
|
57
|
+
'<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>',
|
|
58
|
+
{ timeout: 8000, validateStatus: () => true, headers: { 'Content-Type': 'text/xml' } }
|
|
59
|
+
);
|
|
60
|
+
return res.status === 200 && typeof res.data === 'string' && res.data.includes('methodResponse');
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function checkDirectoryListing(siteUrl) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await axios.get(siteUrl.replace(/\/$/, '') + '/wp-content/uploads/', {
|
|
69
|
+
timeout: 8000, validateStatus: () => true,
|
|
70
|
+
});
|
|
71
|
+
return res.status === 200 &&
|
|
72
|
+
typeof res.data === 'string' &&
|
|
73
|
+
res.data.toLowerCase().includes('index of');
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function row(ok, label, detail) {
|
|
80
|
+
const icon = ok ? chalk.green('✓') : chalk.red('✗');
|
|
81
|
+
const detailStr = detail ? chalk.dim(` (${detail})`) : '';
|
|
82
|
+
return ` ${icon} ${label}${detailStr}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function grade(pct) {
|
|
86
|
+
if (pct >= 90) return chalk.green('A');
|
|
87
|
+
if (pct >= 75) return chalk.green('B');
|
|
88
|
+
if (pct >= 60) return chalk.yellow('C');
|
|
89
|
+
if (pct >= 40) return chalk.yellow('D');
|
|
90
|
+
return chalk.red('F');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function securityCommand(options) {
|
|
94
|
+
const siteName = options.site;
|
|
95
|
+
const sites = siteName
|
|
96
|
+
? [getSite(siteName)].filter(Boolean)
|
|
97
|
+
: getSites();
|
|
98
|
+
|
|
99
|
+
if (sites.length === 0) {
|
|
100
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const site of sites) {
|
|
105
|
+
console.log(chalk.bold(`\n=== ${site.name} ===`));
|
|
106
|
+
console.log(chalk.dim(`Scanning ${site.url}...`));
|
|
107
|
+
|
|
108
|
+
const [headers, ssl, wpVersion, xmlRpc, dirListing] = await Promise.all([
|
|
109
|
+
checkHeaders(site.url),
|
|
110
|
+
checkSsl(site.url),
|
|
111
|
+
checkExposedVersion(site),
|
|
112
|
+
checkXmlRpc(site.url),
|
|
113
|
+
checkDirectoryListing(site.url),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
let score = 0;
|
|
117
|
+
|
|
118
|
+
// SSL (20 pts)
|
|
119
|
+
console.log(chalk.bold('\nSSL:'));
|
|
120
|
+
if (ssl.ok) {
|
|
121
|
+
const daysColor = ssl.days > 30 ? chalk.green : ssl.days > 14 ? chalk.yellow : chalk.red;
|
|
122
|
+
console.log(row(true, 'SSL valid', `${daysColor(ssl.days + 'd')} remaining`));
|
|
123
|
+
score += ssl.days > 30 ? 20 : ssl.days > 14 ? 15 : 10;
|
|
124
|
+
} else {
|
|
125
|
+
console.log(row(false, 'SSL invalid or missing', ssl.reason));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Security Headers (40 pts)
|
|
129
|
+
console.log(chalk.bold('\nSecurity Headers:'));
|
|
130
|
+
const xfo = !!headers.xFrameOptions;
|
|
131
|
+
const csp = !!headers.csp;
|
|
132
|
+
const hsts = !!headers.hsts;
|
|
133
|
+
const xct = !!headers.xContentType;
|
|
134
|
+
console.log(row(xfo, 'X-Frame-Options', xfo ? headers.xFrameOptions : null));
|
|
135
|
+
console.log(row(csp, 'Content-Security-Policy', csp ? 'present' : null));
|
|
136
|
+
console.log(row(hsts, 'Strict-Transport-Security (HSTS)', hsts ? 'present' : null));
|
|
137
|
+
console.log(row(xct, 'X-Content-Type-Options', xct ? headers.xContentType : null));
|
|
138
|
+
if (xfo) score += 10;
|
|
139
|
+
if (csp) score += 15;
|
|
140
|
+
if (hsts) score += 10;
|
|
141
|
+
if (xct) score += 5;
|
|
142
|
+
|
|
143
|
+
// WordPress hardening (40 pts)
|
|
144
|
+
console.log(chalk.bold('\nWordPress Hardening:'));
|
|
145
|
+
const versionHidden = !wpVersion;
|
|
146
|
+
const xmlRpcDisabled = !xmlRpc;
|
|
147
|
+
const dirDisabled = !dirListing;
|
|
148
|
+
console.log(row(versionHidden, 'WP version hidden', wpVersion ? `exposed: ${wpVersion}` : null));
|
|
149
|
+
console.log(row(xmlRpcDisabled, 'XML-RPC disabled', xmlRpc ? 'enabled — potential attack surface' : null));
|
|
150
|
+
console.log(row(dirDisabled, 'Directory listing disabled', dirListing ? 'enabled!' : null));
|
|
151
|
+
if (versionHidden) score += 10;
|
|
152
|
+
if (xmlRpcDisabled) score += 15;
|
|
153
|
+
if (dirDisabled) score += 15;
|
|
154
|
+
|
|
155
|
+
const pct = score;
|
|
156
|
+
console.log(chalk.bold(`\nSecurity Score: ${grade(pct)} ${chalk.dim(`(${pct}/100)`)}`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadConfig, saveConfig, getSites } from '../config.js';
|
|
4
|
+
import { headCheck } from '../utils/http.js';
|
|
5
|
+
import { validateWpApi } from '../utils/wp-api.js';
|
|
6
|
+
import { makeTable, statusBadge } from '../utils/display.js';
|
|
7
|
+
|
|
8
|
+
export async function sitesList() {
|
|
9
|
+
const sites = getSites();
|
|
10
|
+
if (sites.length === 0) {
|
|
11
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(chalk.dim('Checking site status...'));
|
|
16
|
+
const table = makeTable(['Name', 'URL', 'Status'], [20, 40, 12]);
|
|
17
|
+
|
|
18
|
+
const checks = await Promise.all(sites.map(s => headCheck(s.url)));
|
|
19
|
+
for (let i = 0; i < sites.length; i++) {
|
|
20
|
+
const s = sites[i];
|
|
21
|
+
const { online } = checks[i];
|
|
22
|
+
table.push([s.name, s.url, statusBadge(online)]);
|
|
23
|
+
}
|
|
24
|
+
console.log(table.toString());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function sitesAdd() {
|
|
28
|
+
const answers = await inquirer.prompt([
|
|
29
|
+
{
|
|
30
|
+
type: 'input',
|
|
31
|
+
name: 'name',
|
|
32
|
+
message: 'Site name (slug):',
|
|
33
|
+
validate: v => v.trim() ? true : 'Name is required',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'input',
|
|
37
|
+
name: 'url',
|
|
38
|
+
message: 'Site URL (https://example.com):',
|
|
39
|
+
validate: v => v.startsWith('http') ? true : 'URL must start with http',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: 'input',
|
|
43
|
+
name: 'wp_user',
|
|
44
|
+
message: 'WordPress username:',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'password',
|
|
48
|
+
name: 'wp_app_password',
|
|
49
|
+
message: 'WordPress application password:',
|
|
50
|
+
mask: '*',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'input',
|
|
54
|
+
name: 'ga4_property',
|
|
55
|
+
message: 'GA4 property ID (optional, e.g. properties/123456789):',
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const url = answers.url.replace(/\/$/, '');
|
|
60
|
+
process.stdout.write(chalk.dim('Validating WordPress REST API... '));
|
|
61
|
+
const valid = await validateWpApi(url);
|
|
62
|
+
if (!valid) {
|
|
63
|
+
console.log(chalk.red('failed'));
|
|
64
|
+
console.log(chalk.yellow('Warning: WordPress REST API not accessible. Site added anyway.'));
|
|
65
|
+
} else {
|
|
66
|
+
console.log(chalk.green('OK'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const existing = config.sites.findIndex(s => s.name === answers.name);
|
|
71
|
+
|
|
72
|
+
const site = { name: answers.name.trim(), url };
|
|
73
|
+
if (answers.wp_user) site.wp_user = answers.wp_user;
|
|
74
|
+
if (answers.wp_app_password) site.wp_app_password = answers.wp_app_password;
|
|
75
|
+
if (answers.ga4_property) site.ga4_property = answers.ga4_property;
|
|
76
|
+
|
|
77
|
+
if (existing >= 0) {
|
|
78
|
+
config.sites[existing] = site;
|
|
79
|
+
console.log(chalk.green(`Updated site: ${site.name}`));
|
|
80
|
+
} else {
|
|
81
|
+
config.sites.push(site);
|
|
82
|
+
console.log(chalk.green(`Added site: ${site.name}`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
saveConfig(config);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sitesRemove(name) {
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
const before = config.sites.length;
|
|
91
|
+
config.sites = config.sites.filter(s => s.name !== name);
|
|
92
|
+
if (config.sites.length === before) {
|
|
93
|
+
console.error(chalk.red(`Site not found: ${name}`));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
saveConfig(config);
|
|
97
|
+
console.log(chalk.green(`Removed site: ${name}`));
|
|
98
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getSites, getSite } from '../config.js';
|
|
3
|
+
import { getPostCount, getPageCount, getLastPublished } from '../utils/wp-api.js';
|
|
4
|
+
import { makeTable, formatDate, na } from '../utils/display.js';
|
|
5
|
+
|
|
6
|
+
async function fetchStats(site) {
|
|
7
|
+
const [posts, pages, lastDate] = await Promise.all([
|
|
8
|
+
getPostCount(site),
|
|
9
|
+
getPageCount(site),
|
|
10
|
+
getLastPublished(site),
|
|
11
|
+
]);
|
|
12
|
+
return { posts, pages, lastDate };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderRow(site, stats) {
|
|
16
|
+
const posts = stats.posts !== null ? String(stats.posts) : na();
|
|
17
|
+
const pages = stats.pages !== null ? String(stats.pages) : na();
|
|
18
|
+
const last = formatDate(stats.lastDate);
|
|
19
|
+
return [site.name, site.url, posts, pages, last];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function statsCommand(siteName) {
|
|
23
|
+
const table = makeTable(['Name', 'URL', 'Posts', 'Pages', 'Last Published'], [18, 36, 8, 8, 14]);
|
|
24
|
+
|
|
25
|
+
if (siteName) {
|
|
26
|
+
const site = getSite(siteName);
|
|
27
|
+
if (!site) {
|
|
28
|
+
console.error(chalk.red(`Site not found: ${siteName}`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
console.log(chalk.dim(`Fetching stats for ${site.name}...`));
|
|
32
|
+
const stats = await fetchStats(site);
|
|
33
|
+
table.push(renderRow(site, stats));
|
|
34
|
+
} else {
|
|
35
|
+
const sites = getSites();
|
|
36
|
+
if (sites.length === 0) {
|
|
37
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.log(chalk.dim('Fetching stats for all sites...'));
|
|
41
|
+
const allStats = await Promise.all(sites.map(fetchStats));
|
|
42
|
+
for (let i = 0; i < sites.length; i++) {
|
|
43
|
+
table.push(renderRow(sites[i], allStats[i]));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(table.toString());
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { getSites, getSite } from '../config.js';
|
|
4
|
+
import { makeTable } from '../utils/display.js';
|
|
5
|
+
|
|
6
|
+
function makeClient(site) {
|
|
7
|
+
const baseURL = site.url.replace(/\/$/, '') + '/wp-json';
|
|
8
|
+
const config = { baseURL, timeout: 12000, validateStatus: () => true };
|
|
9
|
+
if (site.wp_user && site.wp_app_password) {
|
|
10
|
+
const token = Buffer.from(`${site.wp_user}:${site.wp_app_password}`).toString('base64');
|
|
11
|
+
config.headers = { Authorization: `Basic ${token}` };
|
|
12
|
+
}
|
|
13
|
+
return axios.create(config);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchJetpackStats(site) {
|
|
17
|
+
const client = makeClient(site);
|
|
18
|
+
try {
|
|
19
|
+
const res = await client.get('/jetpack/v4/stats/visitors');
|
|
20
|
+
if (res.status === 200 && res.data && typeof res.data === 'object') return res.data;
|
|
21
|
+
} catch { /* not available */ }
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchWpComStats(site) {
|
|
26
|
+
const client = makeClient(site);
|
|
27
|
+
try {
|
|
28
|
+
const res = await client.get('/wpcom/v2/stats/visits');
|
|
29
|
+
if (res.status === 200 && res.data) return res.data;
|
|
30
|
+
} catch { /* not available */ }
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function trafficCommand(options) {
|
|
35
|
+
const siteName = options.site;
|
|
36
|
+
const sites = siteName
|
|
37
|
+
? [getSite(siteName)].filter(Boolean)
|
|
38
|
+
: getSites();
|
|
39
|
+
|
|
40
|
+
if (sites.length === 0) {
|
|
41
|
+
console.log(chalk.yellow('No sites configured. Run: wpfleet sites add'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const site of sites) {
|
|
46
|
+
console.log(chalk.bold(`\n=== ${site.name} ===`));
|
|
47
|
+
console.log(chalk.dim(site.url));
|
|
48
|
+
|
|
49
|
+
if (site.ga4_property) {
|
|
50
|
+
console.log(chalk.cyan(`GA4 property configured: ${site.ga4_property}`));
|
|
51
|
+
console.log(chalk.dim('Full GA4 traffic data requires additional auth setup:'));
|
|
52
|
+
console.log(chalk.dim(' 1. Create a service account in Google Cloud Console'));
|
|
53
|
+
console.log(chalk.dim(' 2. Grant it "Viewer" access to your GA4 property'));
|
|
54
|
+
console.log(chalk.dim(' 3. Set GOOGLE_APPLICATION_CREDENTIALS env var'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Try Jetpack stats
|
|
58
|
+
const jetpack = await fetchJetpackStats(site);
|
|
59
|
+
if (jetpack) {
|
|
60
|
+
console.log(chalk.green('\nJetpack Stats:'));
|
|
61
|
+
const table = makeTable(['Metric', 'Value'], [25, 20]);
|
|
62
|
+
if (jetpack.visitors !== undefined) table.push(['Visitors', String(jetpack.visitors)]);
|
|
63
|
+
if (jetpack.views !== undefined) table.push(['Views', String(jetpack.views)]);
|
|
64
|
+
if (jetpack.period !== undefined) table.push(['Period', String(jetpack.period)]);
|
|
65
|
+
console.log(table.toString());
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try WP.com stats
|
|
70
|
+
const wpcom = await fetchWpComStats(site);
|
|
71
|
+
if (wpcom) {
|
|
72
|
+
console.log(chalk.green('\nWP.com Stats:'));
|
|
73
|
+
const table = makeTable(['Metric', 'Value'], [25, 20]);
|
|
74
|
+
if (wpcom.views !== undefined) table.push(['Views', String(wpcom.views)]);
|
|
75
|
+
if (wpcom.visitors !== undefined) table.push(['Visitors', String(wpcom.visitors)]);
|
|
76
|
+
console.log(table.toString());
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No traffic source available
|
|
81
|
+
console.log(chalk.dim('\nNo traffic data available for this site.'));
|
|
82
|
+
const table = makeTable(['Option', 'How to enable'], [20, 50]);
|
|
83
|
+
table.push(['GA4', 'Add ga4_property to site config + set up Google auth']);
|
|
84
|
+
table.push(['Jetpack', 'Install Jetpack plugin + connect to WordPress.com']);
|
|
85
|
+
console.log(table.toString());
|
|
86
|
+
}
|
|
87
|
+
}
|