niranzwp 0.6.1 → 0.7.2
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 +5 -5
- package/bin/niranzwp.js +207 -18
- package/lib/wp.js +84 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,13 +31,13 @@ client sites, you usually do not have that.
|
|
|
31
31
|
`niranzwp probe` reports which tiers a site offers before you connect.
|
|
32
32
|
|
|
33
33
|
```
|
|
34
|
-
$ niranzwp probe
|
|
35
|
-
|
|
36
|
-
https://
|
|
34
|
+
$ niranzwp probe example.com
|
|
35
|
+
Example Magazine -- a WordPress site
|
|
36
|
+
https://example.com
|
|
37
37
|
Tier 1 (app passwords): yes
|
|
38
38
|
Tier 2 (abilities): yes
|
|
39
|
-
MCP endpoint:
|
|
40
|
-
OAuth server:
|
|
39
|
+
MCP endpoint: no
|
|
40
|
+
OAuth server: no
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
|
package/bin/niranzwp.js
CHANGED
|
@@ -6,7 +6,9 @@ import { CliError } from '../lib/errors.js';
|
|
|
6
6
|
import * as cache from '../lib/cache.js';
|
|
7
7
|
import { listServers, pickEndpoint, McpClient, textOf } from '../lib/mcp.js';
|
|
8
8
|
import { validate, applyDefaults } from '../lib/schema.js';
|
|
9
|
-
import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, describeAbility, isReadOnly, methodFor, contract, checkCompat,
|
|
9
|
+
import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, describeAbility, isReadOnly, methodFor, contract, checkCompat,
|
|
10
|
+
listItems, getItem, createItem, updateItem, deleteItem, getSettings, updateSettings, totalOf,
|
|
11
|
+
introspectAppPassword, revokeAppPassword, WpError, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT } from '../lib/wp.js';
|
|
10
12
|
import { readFileSync } from 'node:fs';
|
|
11
13
|
|
|
12
14
|
const VERSION = '0.6.0';
|
|
@@ -16,12 +18,18 @@ Built by Niranjan -- https://niranz.dev
|
|
|
16
18
|
|
|
17
19
|
niranzwp auth login <url> [--name <profile>] [--no-open] [--app-password]
|
|
18
20
|
niranzwp auth status [--site <profile>]
|
|
19
|
-
niranzwp auth logout <profile>
|
|
21
|
+
niranzwp auth logout <profile> [--local] revoke on the site too, unless --local
|
|
20
22
|
|
|
21
23
|
niranzwp probe <url> what does this site support? (no auth)
|
|
22
24
|
niranzwp discover [--site <profile>] list abilities this site exposes
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
--- no plugin needed, works on any WordPress 5.6+ ---
|
|
27
|
+
niranzwp post list|get|create|update|delete
|
|
28
|
+
niranzwp page list|get|create|update|delete
|
|
29
|
+
niranzwp media list [--missing-alt]
|
|
30
|
+
niranzwp media set-alt <id> "<text>"
|
|
31
|
+
niranzwp user list
|
|
32
|
+
niranzwp settings get|set <key> <value>
|
|
25
33
|
|
|
26
34
|
niranzwp seo audit site-wide SEO gaps, ranked by severity
|
|
27
35
|
niranzwp seo missing <field> list posts missing description|title|focus|thumbnail|alt
|
|
@@ -182,11 +190,53 @@ async function main() {
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
if (cmd === 'auth' && sub === 'logout') {
|
|
185
|
-
const name = rest[0];
|
|
186
|
-
if (!name) die('usage: niranzwp auth logout <profile>');
|
|
193
|
+
const name = rest[0] || flags.site;
|
|
194
|
+
if (!name) die('usage: niranzwp auth logout <profile> [--local]');
|
|
195
|
+
|
|
196
|
+
const prof = getProfile(name);
|
|
197
|
+
if (!prof) {
|
|
198
|
+
deleteProfile(name);
|
|
199
|
+
console.log(`Removed "${name}" locally (no stored credential to revoke).`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Removing the local copy is not disconnecting: the credential stays
|
|
204
|
+
// valid on the site until it is revoked there. Do both by default.
|
|
205
|
+
let revoked = null;
|
|
206
|
+
if (flags.local !== true) {
|
|
207
|
+
try {
|
|
208
|
+
if ('oauth' === prof.auth) {
|
|
209
|
+
const meta = await discover(prof.siteUrl);
|
|
210
|
+
if (meta?.revocation_endpoint) {
|
|
211
|
+
await fetch(meta.revocation_endpoint, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
214
|
+
body: new URLSearchParams({
|
|
215
|
+
token: prof.tokens.refreshToken || prof.tokens.accessToken,
|
|
216
|
+
client_id: prof.clientId,
|
|
217
|
+
}).toString(),
|
|
218
|
+
});
|
|
219
|
+
revoked = 'oauth grant';
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
const me = await introspectAppPassword(prof, reqOpts(flags));
|
|
223
|
+
if (me?.uuid) {
|
|
224
|
+
await revokeAppPassword(prof, me.uuid, reqOpts(flags));
|
|
225
|
+
revoked = `application password "${me.name || name}"`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`warning: could not revoke on the site (${e.message})`);
|
|
230
|
+
console.error('The local credential is still being removed. Revoke it under Users -> Profile -> Application Passwords.');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
187
234
|
deleteProfile(name);
|
|
188
|
-
console.log(`
|
|
189
|
-
console.log(
|
|
235
|
+
console.log(`Disconnected "${name}" from ${prof.siteUrl}.`);
|
|
236
|
+
console.log(revoked ? `Revoked ${revoked} on the site.` : 'Local credential removed.');
|
|
237
|
+
if (flags.local === true) {
|
|
238
|
+
console.log('--local was passed, so nothing was revoked on the site.');
|
|
239
|
+
}
|
|
190
240
|
return;
|
|
191
241
|
}
|
|
192
242
|
|
|
@@ -217,24 +267,163 @@ async function main() {
|
|
|
217
267
|
return;
|
|
218
268
|
}
|
|
219
269
|
|
|
220
|
-
|
|
270
|
+
// --- Tier 1: core REST, no plugin -------------------------------------
|
|
271
|
+
|
|
272
|
+
if (['post', 'page'].includes(cmd)) {
|
|
221
273
|
const p = resolveProfile(flags);
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
274
|
+
const title = (r) => r.title?.raw ?? r.title?.rendered ?? '';
|
|
275
|
+
|
|
276
|
+
if (sub === 'list' || sub === undefined) {
|
|
277
|
+
const { data, headers } = await listItems(p, cmd, {
|
|
278
|
+
status: flags.status || 'publish',
|
|
279
|
+
perPage: Number(flags.limit || 20),
|
|
280
|
+
page: Number(flags.page || 1),
|
|
281
|
+
search: flags.search === true ? undefined : flags.search,
|
|
282
|
+
fields: 'id,date,status,link,title',
|
|
283
|
+
}, reqOpts(flags));
|
|
284
|
+
out(flags, data, (rows) => {
|
|
285
|
+
console.log(`${rows.length} of ${totalOf(headers) ?? '?'} ${cmd}s (${flags.status || 'publish'})`);
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
console.log(` ${String(r.id).padEnd(8)}${r.date.slice(0, 10)} ${title(r).slice(0, 58)}`);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (sub === 'get') {
|
|
294
|
+
if (!rest[0]) die(`usage: niranzwp ${cmd} get <id>`);
|
|
295
|
+
out(flags, await getItem(p, cmd, rest[0], reqOpts(flags)), null);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (sub === 'create') {
|
|
300
|
+
const body = {
|
|
301
|
+
title: flags.title === true ? undefined : flags.title,
|
|
302
|
+
content: flags.content === true ? undefined : flags.content,
|
|
303
|
+
excerpt: flags.excerpt === true ? undefined : flags.excerpt,
|
|
304
|
+
// Draft unless publishing is asked for explicitly.
|
|
305
|
+
status: flags.status || 'draft',
|
|
306
|
+
};
|
|
307
|
+
if (!body.title) die('--title is required');
|
|
308
|
+
const r = await createItem(p, cmd, body, reqOpts(flags));
|
|
309
|
+
console.log(`created ${cmd} ${r.id} (${r.status}) -- ${r.link}`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (sub === 'update') {
|
|
314
|
+
const id = rest[0];
|
|
315
|
+
if (!id) die(`usage: niranzwp ${cmd} update <id> [--title x] [--content y] [--status z]`);
|
|
316
|
+
const body = {};
|
|
317
|
+
for (const k of ['title', 'content', 'excerpt', 'status', 'slug']) {
|
|
318
|
+
if (typeof flags[k] === 'string') body[k] = flags[k];
|
|
319
|
+
}
|
|
320
|
+
if (!Object.keys(body).length) die('nothing to update -- pass --title, --content, --excerpt, --status or --slug');
|
|
321
|
+
if (flags.yes !== true) {
|
|
322
|
+
throw new CliError('approval_required', `This edits ${cmd} ${id} on ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
323
|
+
}
|
|
324
|
+
const r = await updateItem(p, cmd, id, body, reqOpts(flags));
|
|
325
|
+
console.log(`updated ${cmd} ${r.id} (${r.status})`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (sub === 'delete') {
|
|
330
|
+
const id = rest[0];
|
|
331
|
+
if (!id) die(`usage: niranzwp ${cmd} delete <id> [--force]`);
|
|
332
|
+
if (flags.yes !== true) {
|
|
333
|
+
throw new CliError('approval_required',
|
|
334
|
+
flags.force === true
|
|
335
|
+
? `This PERMANENTLY deletes ${cmd} ${id} on ${p.siteUrl}.`
|
|
336
|
+
: `This moves ${cmd} ${id} to trash on ${p.siteUrl}.`,
|
|
337
|
+
{ hint: 'Re-run with --yes.' });
|
|
338
|
+
}
|
|
339
|
+
const r = await deleteItem(p, cmd, id, { force: flags.force === true }, reqOpts(flags));
|
|
340
|
+
console.log(flags.force === true ? `deleted ${cmd} ${id}` : `trashed ${cmd} ${id} (restorable)`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
die(`usage: niranzwp ${cmd} list|get|create|update|delete`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (cmd === 'media') {
|
|
348
|
+
const p = resolveProfile(flags);
|
|
349
|
+
|
|
350
|
+
if (sub === 'list' || sub === undefined) {
|
|
351
|
+
const { data, headers } = await listItems(p, 'media', {
|
|
352
|
+
perPage: Number(flags.limit || 20),
|
|
353
|
+
page: Number(flags.page || 1),
|
|
354
|
+
search: flags.search === true ? undefined : flags.search,
|
|
355
|
+
fields: 'id,date,alt_text,mime_type,source_url,title',
|
|
356
|
+
extra: { media_type: 'image' },
|
|
357
|
+
}, reqOpts(flags));
|
|
358
|
+
const rows = flags['missing-alt'] === true ? data.filter((m) => !m.alt_text) : data;
|
|
359
|
+
out(flags, rows, (list) => {
|
|
360
|
+
console.log(`${list.length} of ${totalOf(headers) ?? '?'} images${flags['missing-alt'] === true ? ' (missing alt on this page)' : ''}`);
|
|
361
|
+
for (const m of list) {
|
|
362
|
+
console.log(` ${String(m.id).padEnd(8)}${(m.alt_text ? 'alt' : '---').padEnd(5)}${(m.source_url || '').split('/').pop().slice(0, 52)}`);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (sub === 'set-alt') {
|
|
369
|
+
const [id, ...words] = rest;
|
|
370
|
+
const alt = words.join(' ');
|
|
371
|
+
if (!id || !alt) die('usage: niranzwp media set-alt <id> "<alt text>"');
|
|
372
|
+
if (flags.yes !== true) {
|
|
373
|
+
throw new CliError('approval_required', `This sets alt text on media ${id} at ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
374
|
+
}
|
|
375
|
+
const r = await updateItem(p, 'media', id, { alt_text: alt }, reqOpts(flags));
|
|
376
|
+
console.log(`media ${r.id} alt set to: ${r.alt_text}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
die('usage: niranzwp media list|set-alt');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (cmd === 'user' && (sub === 'list' || sub === undefined)) {
|
|
384
|
+
const p = resolveProfile(flags);
|
|
385
|
+
const { data, headers } = await listItems(p, 'user', {
|
|
386
|
+
perPage: Number(flags.limit || 20),
|
|
387
|
+
fields: 'id,name,slug,roles,email',
|
|
388
|
+
}, reqOpts(flags));
|
|
228
389
|
out(flags, data, (rows) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
console.log(` ${String(r.id).padEnd(8)}${r.date.slice(0, 10)} ${(r.title?.raw ?? r.title?.rendered ?? '').slice(0, 60)}`);
|
|
390
|
+
console.log(`${rows.length} of ${totalOf(headers) ?? '?'} users`);
|
|
391
|
+
for (const u of rows) {
|
|
392
|
+
console.log(` ${String(u.id).padEnd(6)}${(u.name || '').padEnd(26)}${(u.roles || []).join(',')}`);
|
|
233
393
|
}
|
|
234
394
|
});
|
|
235
395
|
return;
|
|
236
396
|
}
|
|
237
397
|
|
|
398
|
+
if (cmd === 'settings') {
|
|
399
|
+
const p = resolveProfile(flags);
|
|
400
|
+
|
|
401
|
+
if (sub === 'get' || sub === undefined) {
|
|
402
|
+
const s = await getSettings(p, reqOpts(flags));
|
|
403
|
+
out(flags, s, (o) => {
|
|
404
|
+
for (const [k, v] of Object.entries(o)) {
|
|
405
|
+
console.log(` ${k.padEnd(28)}${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (sub === 'set') {
|
|
412
|
+
const [key, ...words] = rest;
|
|
413
|
+
const value = words.join(' ');
|
|
414
|
+
if (!key || !value) die('usage: niranzwp settings set <key> <value>');
|
|
415
|
+
if (flags.yes !== true) {
|
|
416
|
+
throw new CliError('approval_required', `This changes "${key}" on ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
|
|
417
|
+
}
|
|
418
|
+
const before = (await getSettings(p, reqOpts(flags)))[key];
|
|
419
|
+
const after = await updateSettings(p, { [key]: value }, reqOpts(flags));
|
|
420
|
+
console.log(`${key}\n before: ${JSON.stringify(before)}\n after: ${JSON.stringify(after[key])}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
die('usage: niranzwp settings get|set <key> <value>');
|
|
425
|
+
}
|
|
426
|
+
|
|
238
427
|
if (cmd === 'mcp') {
|
|
239
428
|
const p = resolveProfile(flags);
|
|
240
429
|
const servers = await listServers(p.siteUrl);
|
package/lib/wp.js
CHANGED
|
@@ -344,3 +344,87 @@ function reachError(siteUrl, e) {
|
|
|
344
344
|
}
|
|
345
345
|
return new CliError('server_unreachable', `Cannot reach ${siteUrl}: ${e.message}`);
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Tier 1: core REST only. Everything below works on any WordPress 5.6+ site
|
|
351
|
+
// with no plugin installed, because wp/v2 and Application Passwords ship with
|
|
352
|
+
// WordPress itself.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
const TYPE_ROUTE = { post: 'posts', page: 'pages', media: 'media', user: 'users', comment: 'comments' };
|
|
356
|
+
|
|
357
|
+
export function routeFor(type) {
|
|
358
|
+
return TYPE_ROUTE[type] ?? `${type}s`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function listItems(profile, type, { perPage = 20, page = 1, status, search, fields, extra = {} } = {}, opts = {}) {
|
|
362
|
+
return request(profile, `/wp/v2/${routeFor(type)}`, {
|
|
363
|
+
...opts,
|
|
364
|
+
raw: true,
|
|
365
|
+
query: {
|
|
366
|
+
per_page: perPage,
|
|
367
|
+
page,
|
|
368
|
+
status,
|
|
369
|
+
search,
|
|
370
|
+
context: 'edit',
|
|
371
|
+
_fields: fields,
|
|
372
|
+
...extra,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function getItem(profile, type, id, opts = {}) {
|
|
378
|
+
return request(profile, `/wp/v2/${routeFor(type)}/${id}`, { ...opts, query: { context: 'edit' } });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function createItem(profile, type, body, opts = {}) {
|
|
382
|
+
return request(profile, `/wp/v2/${routeFor(type)}`, { ...opts, method: 'POST', body });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function updateItem(profile, type, id, body, opts = {}) {
|
|
386
|
+
return request(profile, `/wp/v2/${routeFor(type)}/${id}`, { ...opts, method: 'POST', body });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* WordPress trashes rather than deletes unless force is set, which is the
|
|
391
|
+
* safer default -- a trashed post can be restored, a deleted one cannot.
|
|
392
|
+
*/
|
|
393
|
+
export async function deleteItem(profile, type, id, { force = false } = {}, opts = {}) {
|
|
394
|
+
return request(profile, `/wp/v2/${routeFor(type)}/${id}`, {
|
|
395
|
+
...opts,
|
|
396
|
+
method: 'DELETE',
|
|
397
|
+
query: force ? { force: 'true' } : undefined,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function getSettings(profile, opts = {}) {
|
|
402
|
+
return request(profile, '/wp/v2/settings', opts);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function updateSettings(profile, body, opts = {}) {
|
|
406
|
+
return request(profile, '/wp/v2/settings', { ...opts, method: 'POST', body });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Total count from the X-WP-Total header, which every list route sets. */
|
|
410
|
+
export function totalOf(headers) {
|
|
411
|
+
const n = Number(headers?.get?.('x-wp-total'));
|
|
412
|
+
return Number.isFinite(n) ? n : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The application password actually being used for this request. WordPress
|
|
418
|
+
* exposes it so a client can identify -- and revoke -- its own credential
|
|
419
|
+
* without the user hunting for it in wp-admin.
|
|
420
|
+
*/
|
|
421
|
+
export async function introspectAppPassword(profile, opts = {}) {
|
|
422
|
+
return request(profile, '/wp/v2/users/me/application-passwords/introspect', opts);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function revokeAppPassword(profile, uuid, opts = {}) {
|
|
426
|
+
return request(profile, `/wp/v2/users/me/application-passwords/${uuid}`, {
|
|
427
|
+
...opts,
|
|
428
|
+
method: 'DELETE',
|
|
429
|
+
});
|
|
430
|
+
}
|