niranzwp 0.6.1 → 0.7.3

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 CHANGED
@@ -36,8 +36,12 @@ UAE Stories -- UAE's first people centric magazine
36
36
  https://uaestories.com
37
37
  Tier 1 (app passwords): yes
38
38
  Tier 2 (abilities): yes
39
- MCP endpoint: yes
40
- OAuth server: yes
39
+ namespaces: 38
40
+
41
+ $ niranzwp probe niranz.dev
42
+ error [server_unreachable]: https://niranz.dev did not return a WordPress
43
+ REST API (HTTP 200, got HTML). Either it is not WordPress, or /wp-json/
44
+ is disabled or blocked.
41
45
  ```
42
46
 
43
47
  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, WpError, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT } from '../lib/wp.js';
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
- niranzwp post list [--status draft] [--search x] [--limit 10] [--page 1]
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(`Removed "${name}" locally.`);
189
- console.log('Revoke the password itself under Users -> Profile -> Application Passwords in WordPress.');
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
- if (cmd === 'post' && sub === 'list') {
270
+ // --- Tier 1: core REST, no plugin -------------------------------------
271
+
272
+ if (['post', 'page'].includes(cmd)) {
221
273
  const p = resolveProfile(flags);
222
- const { data, headers } = await listPosts(p, {
223
- status: flags.status || 'publish',
224
- perPage: Number(flags.limit || 10),
225
- page: Number(flags.page || 1),
226
- search: flags.search === true ? undefined : flags.search,
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
- const total = headers.get('x-wp-total');
230
- console.log(`${rows.length} shown of ${total ?? '?'} (${flags.status || 'publish'})`);
231
- for (const r of rows) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niranzwp",
3
- "version": "0.6.1",
3
+ "version": "0.7.3",
4
4
  "description": "A CLI for WordPress. Works on any site via Application Passwords, and unlocks Abilities where a site provides them.",
5
5
  "type": "module",
6
6
  "bin": {