niranzwp 0.1.0 → 0.6.1

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/bin/niranzwp.js CHANGED
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { loginWithAppPassword } from '../lib/auth.js';
3
- import { saveProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
4
- import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, WpError } from '../lib/wp.js';
3
+ import { saveProfile, saveOAuthProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
4
+ import { discover, registerClient, startDeviceFlow, pollForToken } from '../lib/oauth.js';
5
+ import { CliError } from '../lib/errors.js';
6
+ import * as cache from '../lib/cache.js';
7
+ import { listServers, pickEndpoint, McpClient, textOf } from '../lib/mcp.js';
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';
5
10
  import { readFileSync } from 'node:fs';
6
11
 
7
- const USAGE = `niranzwp -- a CLI for WordPress
12
+ const VERSION = '0.6.0';
8
13
 
9
- niranzwp auth login <url> [--name <profile>] [--no-open]
14
+ const USAGE = `NiranzWP CLI v${VERSION} -- a CLI for WordPress
15
+ Built by Niranjan -- https://niranz.dev
16
+
17
+ niranzwp auth login <url> [--name <profile>] [--no-open] [--app-password]
10
18
  niranzwp auth status [--site <profile>]
11
19
  niranzwp auth logout <profile>
12
20
 
@@ -14,11 +22,34 @@ const USAGE = `niranzwp -- a CLI for WordPress
14
22
  niranzwp discover [--site <profile>] list abilities this site exposes
15
23
 
16
24
  niranzwp post list [--status draft] [--search x] [--limit 10] [--page 1]
25
+
26
+ niranzwp seo audit site-wide SEO gaps, ranked by severity
27
+ niranzwp seo missing <field> list posts missing description|title|focus|thumbnail|alt
28
+ niranzwp geo check AI crawler access, llms.txt, sitemap
29
+ niranzwp geo llms-txt [--write] generate llms.txt for AI answer engines
30
+
17
31
  niranzwp run <ability> [--input '<json>' | --file <path>]
18
32
 
33
+ seo and geo commands need the NiranzWP plugin on the site.
34
+
35
+ niranzwp describe <ability> show an ability's schema and annotations
36
+
37
+ niranzwp sites list connected sites
38
+ niranzwp doctor [--offline] check this install and every connection
39
+ niranzwp cache clear drop cached ability metadata
40
+
41
+ niranzwp mcp servers MCP servers this site exposes
42
+ niranzwp mcp tools tools available over MCP
43
+ niranzwp mcp call <tool> [--input] call a tool over MCP
44
+
19
45
  Options
20
- --site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
21
- --json raw JSON output
46
+ --all run across every connected site (seo, geo, discover)
47
+ --site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
48
+ --json raw JSON output
49
+ --yes approve a write ability (required for anything not read-only)
50
+ --timeout <ms> request timeout (default 30000)
51
+ --max-output <bytes> response budget (default 1048576)
52
+ --version print version
22
53
  `;
23
54
 
24
55
  function parseArgs(argv) {
@@ -56,6 +87,21 @@ function resolveProfile(flags) {
56
87
  return p;
57
88
  }
58
89
 
90
+ // --all turns any site-scoped command into a fleet operation. Nothing else
91
+ // in the WordPress CLI space does this, and it is the whole point of the tool
92
+ // for anyone running more than one site.
93
+ function targets(flags) {
94
+ if (flags.all !== true) return [resolveProfile(flags)];
95
+ const all = listProfiles().map((p) => getProfile(p.name)).filter(Boolean);
96
+ if (!all.length) die('no sites connected. Run: niranzwp auth login <url>');
97
+ return all;
98
+ }
99
+
100
+ const reqOpts = (flags) => ({
101
+ timeout: Number(flags.timeout || DEFAULT_TIMEOUT_MS),
102
+ maxOutput: Number(flags['max-output'] || DEFAULT_MAX_OUTPUT),
103
+ });
104
+
59
105
  const out = (flags, value, pretty) => {
60
106
  if (flags.json || !pretty) console.log(JSON.stringify(value, null, 2));
61
107
  else pretty(value);
@@ -65,12 +111,45 @@ async function main() {
65
111
  const { _: pos, flags } = parseArgs(process.argv.slice(2));
66
112
  const [cmd, sub, ...rest] = pos;
67
113
 
114
+ if (flags.version || cmd === 'version') {
115
+ console.log(`NiranzWP CLI v${VERSION}`);
116
+ console.log('Built by Niranjan -- https://niranz.dev');
117
+ return;
118
+ }
119
+
68
120
  if (!cmd || flags.help || cmd === 'help') { console.log(USAGE); return; }
69
121
 
70
122
  if (cmd === 'auth' && sub === 'login') {
71
123
  const url = rest[0];
72
124
  if (!url) die('usage: niranzwp auth login <url>');
73
125
  const site = normalizeSite(url);
126
+
127
+ // Prefer OAuth where the site offers it: nothing is stored that can be
128
+ // replayed, and access can be revoked server-side. --app-password
129
+ // forces the older path.
130
+ const meta = flags['app-password'] === true ? null : await discover(site);
131
+
132
+ if (meta?.device_authorization_endpoint) {
133
+ const name = flags.name || new URL(site).hostname.replace(/^www\./, '');
134
+ const clientId = await registerClient(meta);
135
+ const device = await startDeviceFlow(meta, clientId);
136
+
137
+ console.error('Open this page and enter the code:');
138
+ console.error(` ${device.verificationUri}`);
139
+ console.error(` code: ${device.userCode}`);
140
+ console.error(`\nExpires in ${Math.round(device.expiresIn / 60)} minutes. Waiting...`);
141
+
142
+ const tokens = await pollForToken(meta, clientId, device, {
143
+ timeoutMs: Number(flags.timeout || device.expiresIn * 1000),
144
+ });
145
+
146
+ saveOAuthProfile(name, { siteUrl: site, clientId, tokens });
147
+ console.log(`Connected "${name}" -> ${site} via OAuth`);
148
+ console.log(`Tokens stored in ${storageKind()}; they refresh automatically.`);
149
+ console.log('Try: niranzwp discover');
150
+ return;
151
+ }
152
+
74
153
  const creds = await loginWithAppPassword(site, { open: flags.open !== false });
75
154
  const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
76
155
  saveProfile(name, creds);
@@ -156,14 +235,266 @@ async function main() {
156
235
  return;
157
236
  }
158
237
 
238
+ if (cmd === 'mcp') {
239
+ const p = resolveProfile(flags);
240
+ const servers = await listServers(p.siteUrl);
241
+ if (!servers.length) die(`${p.siteUrl} exposes no MCP servers.`);
242
+
243
+ if (sub === 'servers') {
244
+ out(flags, servers, (rows) => {
245
+ for (const s of rows) console.log(` ${s.name.padEnd(30)}${s.url}`);
246
+ });
247
+ return;
248
+ }
249
+
250
+ const endpoint = flags.endpoint || pickEndpoint(servers, { oauth: p.auth === 'oauth' });
251
+ const client = new McpClient(endpoint, {
252
+ token: p.auth === 'oauth' ? p.tokens.accessToken : undefined,
253
+ basic: p.auth !== 'oauth' && p.password
254
+ ? Buffer.from(`${p.user}:${p.password.replace(/\s+/g, '')}`, 'utf8').toString('base64')
255
+ : undefined,
256
+ timeout: Number(flags.timeout || DEFAULT_TIMEOUT_MS),
257
+ });
258
+ await client.initialize();
259
+
260
+ if (sub === 'tools') {
261
+ const tools = await client.listTools();
262
+ out(flags, tools, (rows) => {
263
+ console.log(`${rows.length} tools on ${endpoint}`);
264
+ for (const t of rows) console.log(` ${t.name}${t.description ? ` -- ${String(t.description).split('\n')[0].slice(0, 70)}` : ''}`);
265
+ });
266
+ return;
267
+ }
268
+
269
+ if (sub === 'call') {
270
+ const tool = rest[0];
271
+ if (!tool) die('usage: niranzwp mcp call <tool> [--input <json>|--file <path>]');
272
+ let args = {};
273
+ if (flags.file) args = JSON.parse(readFileSync(flags.file, 'utf8'));
274
+ else if (typeof flags.input === 'string') args = JSON.parse(flags.input);
275
+ const r = await client.callTool(tool, args);
276
+ console.log(textOf(r));
277
+ return;
278
+ }
279
+
280
+ die('usage: niranzwp mcp servers|tools|call <tool>');
281
+ }
282
+
283
+ if (cmd === 'cache' && sub === 'clear') {
284
+ cache.clear();
285
+ console.log(`Cleared ${cache.dir()}`);
286
+ return;
287
+ }
288
+
289
+ if (cmd === 'sites') {
290
+ const all = listProfiles();
291
+ if (!all.length) { console.log('No sites connected. Run: niranzwp auth login <url>'); return; }
292
+ out(flags, all, (rows) => {
293
+ for (const r of rows) {
294
+ const has = Boolean(getProfile(r.name));
295
+ console.log(` ${has ? ' ' : '!'} ${r.name.padEnd(28)}${r.siteUrl}${has ? '' : ' (credential missing)'}`);
296
+ }
297
+ console.log(`\n${rows.length} site${rows.length === 1 ? '' : 's'} -- storage: ${storageKind()}`);
298
+ });
299
+ return;
300
+ }
301
+
302
+ if (cmd === 'doctor') {
303
+ const checks = [];
304
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
305
+ checks.push({
306
+ ok: nodeMajor >= 22,
307
+ label: 'Node 22+',
308
+ detail: `running ${process.versions.node}`,
309
+ });
310
+ checks.push({
311
+ ok: storageKind().includes('Keychain'),
312
+ warn: !storageKind().includes('Keychain'),
313
+ label: 'Credential storage',
314
+ detail: storageKind(),
315
+ });
316
+
317
+ const all = listProfiles();
318
+ checks.push({ ok: all.length > 0, label: 'Sites connected', detail: `${all.length}` });
319
+
320
+ if (flags.offline !== true) {
321
+ for (const prof of all) {
322
+ const info = await contract(prof.siteUrl);
323
+ if (!info) continue;
324
+ const problems = checkCompat(info);
325
+ checks.push({
326
+ ok: problems.length === 0,
327
+ label: `${prof.name} contract`,
328
+ detail: problems.length
329
+ ? problems.join('; ')
330
+ : `WordPress ${info.wordpress_version}, REST contract v${info.rest_api_version}`,
331
+ });
332
+ }
333
+ }
334
+
335
+ if (flags.offline !== true) {
336
+ for (const prof of all) {
337
+ const full = getProfile(prof.name);
338
+ if (!full) {
339
+ checks.push({ ok: false, label: prof.name, detail: 'credential missing from keychain' });
340
+ continue;
341
+ }
342
+ try {
343
+ const me = await whoami(full);
344
+ let abilities = 0;
345
+ try {
346
+ const list = await listAbilities(full);
347
+ abilities = (Array.isArray(list) ? list : list?.abilities || []).length;
348
+ } catch { /* tier 1 only */ }
349
+ checks.push({
350
+ ok: true,
351
+ label: prof.name,
352
+ detail: `${me.name} (id ${me.id}) -- ${abilities} abilities`,
353
+ });
354
+ } catch (e) {
355
+ checks.push({ ok: false, label: prof.name, detail: e.message });
356
+ }
357
+ }
358
+ }
359
+
360
+ out(flags, checks, (rows) => {
361
+ for (const c of rows) {
362
+ const icon = c.ok ? 'ok ' : (c.warn ? 'warn' : 'FAIL');
363
+ console.log(` [${icon}] ${c.label.padEnd(24)}${c.detail}`);
364
+ }
365
+ const bad = rows.filter((c) => !c.ok && !c.warn).length;
366
+ console.log(`\n${bad === 0 ? 'All checks passed.' : `${bad} check(s) failed.`}`);
367
+ });
368
+ if (checks.some((c) => !c.ok && !c.warn)) process.exitCode = 1;
369
+ return;
370
+ }
371
+
372
+ // --- SEO ---------------------------------------------------------------
373
+
374
+ if (cmd === 'seo' && sub === 'audit') {
375
+ for (const p of targets(flags)) {
376
+ const r = await runAbility(p, 'niranzwp/seo-audit', {
377
+ post_type: flags['post-type'] || 'post',
378
+ }, { ...reqOpts(flags), ability: await describeAbility(p, 'niranzwp/seo-audit', reqOpts(flags)).catch(() => null) })
379
+ .catch((e) => ({ _error: e.message }));
380
+ if (r._error) { console.error(`${p.name}: ${r._error}`); continue; }
381
+ out(flags, r, (a) => {
382
+ console.log(`${p.siteUrl} -- ${a.published} published ${a.post_type}`);
383
+ console.log(`SEO plugin: ${a.seo_plugin || 'none detected'}\n`);
384
+ if (!a.issues?.length) { console.log(' No issues found.'); return; }
385
+ const mark = { high: '!!', medium: ' !', warning: ' ?', low: ' ' };
386
+ for (const i of a.issues) {
387
+ console.log(`${mark[i.severity] ?? ' '} [${i.severity}] ${i.message}`);
388
+ }
389
+ console.log(`\n${a.issue_count} issue${a.issue_count === 1 ? '' : 's'}.\n`);
390
+ });
391
+ }
392
+ return;
393
+ }
394
+
395
+ if (cmd === 'seo' && sub === 'missing') {
396
+ const p = resolveProfile(flags);
397
+ const listMeta = await describeAbility(p, 'niranzwp/seo-list-missing', reqOpts(flags)).catch(() => null);
398
+ const r = await runAbility(p, 'niranzwp/seo-list-missing', {
399
+ field: rest[0] || flags.field || 'description',
400
+ post_type: flags['post-type'] || 'post',
401
+ limit: Number(flags.limit || 20),
402
+ offset: Number(flags.offset || 0),
403
+ }, { ...reqOpts(flags), ability: listMeta });
404
+ out(flags, r, (d) => {
405
+ console.log(`${d.count} missing "${d.field}" (offset ${d.offset})`);
406
+ for (const i of d.items) {
407
+ console.log(` ${String(i.id).padEnd(8)}${(i.date ?? '').padEnd(12)}${(i.title || '').slice(0, 58)}`);
408
+ }
409
+ });
410
+ return;
411
+ }
412
+
413
+ // --- GEO ---------------------------------------------------------------
414
+
415
+ if (cmd === 'geo' && sub === 'check') {
416
+ for (const p of targets(flags)) {
417
+ const r = await runAbility(p, 'niranzwp/geo-check', {}, {
418
+ ...reqOpts(flags),
419
+ ability: await describeAbility(p, 'niranzwp/geo-check', reqOpts(flags)).catch(() => null),
420
+ }).catch((e) => ({ _error: e.message }));
421
+ if (r._error) { console.error(`${p.name}: ${r._error}`); continue; }
422
+ out(flags, r, (g) => {
423
+ console.log(`${g.site}\n`);
424
+ console.log(` robots.txt ${g.robots_txt ? 'yes' : 'NO'}`);
425
+ console.log(` llms.txt ${g.llms_txt ? 'yes' : 'NO'}`);
426
+ console.log(` sitemap in robots ${g.sitemap_in_robots ? 'yes' : 'NO'}\n`);
427
+ console.log(' AI crawlers:');
428
+ for (const [agent, info] of Object.entries(g.ai_crawlers || {})) {
429
+ console.log(` ${info.blocked ? 'BLOCKED' : 'allowed'} ${agent.padEnd(20)} ${info.owner}`);
430
+ }
431
+ if (g.issues?.length) {
432
+ console.log('');
433
+ for (const i of g.issues) console.log(` [${i.severity}] ${i.message}`);
434
+ }
435
+ console.log('');
436
+ });
437
+ }
438
+ return;
439
+ }
440
+
441
+ if (cmd === 'geo' && sub === 'llms-txt') {
442
+ const p = resolveProfile(flags);
443
+ const write = flags.write === true;
444
+ const llmsMeta = await describeAbility(p, 'niranzwp/geo-llms-txt', reqOpts(flags)).catch(() => null);
445
+ const r = await runAbility(p, 'niranzwp/geo-llms-txt', {
446
+ write,
447
+ limit: Number(flags.limit || 30),
448
+ }, { ...reqOpts(flags), ability: llmsMeta });
449
+ if (flags.json) { console.log(JSON.stringify(r, null, 2)); return; }
450
+ console.log(r.content);
451
+ console.error(`\n${r.bytes} bytes -- ${r.written ? `written to ${r.url}` : 'not written (pass --write)'}`);
452
+ return;
453
+ }
454
+
455
+ if (cmd === 'describe') {
456
+ const p = resolveProfile(flags);
457
+ if (!sub) die('usage: niranzwp describe <ability>');
458
+ const a = await describeAbility(p, sub, reqOpts(flags));
459
+ out(flags, a, (d) => {
460
+ console.log(`${d.name}\n ${d.description || ''}`);
461
+ console.log(` type: ${isReadOnly(d) ? 'read-only' : 'WRITE -- needs --yes'}`);
462
+ if (d.input_schema) console.log(` input: ${JSON.stringify(d.input_schema)}`);
463
+ });
464
+ return;
465
+ }
466
+
159
467
  if (cmd === 'run') {
160
468
  const p = resolveProfile(flags);
161
469
  const ability = sub;
162
470
  if (!ability) die('usage: niranzwp run <ability> [--input <json>|--file <path>]');
471
+
163
472
  let input = {};
164
473
  if (flags.file) input = JSON.parse(readFileSync(flags.file, 'utf8'));
165
474
  else if (typeof flags.input === 'string') input = JSON.parse(flags.input);
166
- const result = await runAbility(p, ability, input);
475
+
476
+ // One describe serves two purposes: the write gate, and validating the
477
+ // input locally so a bad argument never reaches the site.
478
+ let meta = null;
479
+ try {
480
+ meta = await describeAbility(p, ability, reqOpts(flags));
481
+ } catch { /* fall through; run will report the real error */ }
482
+
483
+ if (meta && !isReadOnly(meta) && flags.yes !== true) {
484
+ throw new CliError('approval_required', `"${ability}" is a write ability.`, {
485
+ hint: 'Re-run with --yes to approve it.',
486
+ });
487
+ }
488
+
489
+ if (meta?.input_schema) {
490
+ input = applyDefaults(input, meta.input_schema);
491
+ const errors = validate(input, meta.input_schema);
492
+ if (errors.length) {
493
+ throw new CliError('usage_error', `Invalid input for ${ability}:\n - ${errors.join('\n - ')}`);
494
+ }
495
+ }
496
+
497
+ const result = await runAbility(p, ability, input, { ...reqOpts(flags), ability: meta });
167
498
  console.log(JSON.stringify(result, null, 2));
168
499
  return;
169
500
  }
@@ -173,6 +504,11 @@ async function main() {
173
504
  }
174
505
 
175
506
  main().catch((e) => {
507
+ if (e instanceof CliError) {
508
+ console.error(`error [${e.code}]: ${e.message}`);
509
+ if (e.hint) console.error(`hint: ${e.hint}`);
510
+ process.exit(e.exitCode);
511
+ }
176
512
  if (e instanceof WpError) {
177
513
  die(`${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`);
178
514
  }
package/lib/cache.js ADDED
@@ -0,0 +1,122 @@
1
+ // Ability metadata cache.
2
+ //
3
+ // Ability schemas change only when a site's plugins change, but the --yes gate
4
+ // needs them on every run. Caching removes that round trip.
5
+ //
6
+ // Records are keyed by a SHA-256 of origin + profile + ability name, so two
7
+ // sites can never read each other's entries, and a key is never a filename
8
+ // derived from untrusted input. Reads fail closed: anything corrupt, expired
9
+ // or oversized is deleted rather than returned.
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const DIR = join(homedir(), '.config', 'niranzwp', 'cache', 'abilities', 'v1');
17
+ const TTL_MS = 5 * 60 * 1000; // metadata is cheap to refetch; keep it fresh
18
+ const MAX_ENTRY = 256 * 1024; // one oversized record must not fill the disk
19
+ const MAX_TOTAL = 10 * 1024 * 1024;
20
+
21
+ // Bumping this invalidates every entry, for when our own record shape changes.
22
+ const RECORD_VERSION = 1;
23
+
24
+ function keyFor(origin, profile, name) {
25
+ return createHash('sha256').update([origin, profile, name].join('\0')).digest('hex');
26
+ }
27
+
28
+ function pathFor(origin, profile, name) {
29
+ return join(DIR, `${keyFor(origin, profile, name)}.json`);
30
+ }
31
+
32
+ export function get(origin, profile, name) {
33
+ const file = pathFor(origin, profile, name);
34
+ if (!existsSync(file)) return null;
35
+
36
+ try {
37
+ const rec = JSON.parse(readFileSync(file, 'utf8'));
38
+
39
+ // The record repeats its own identity so a stale or moved file cannot
40
+ // be served for the wrong site.
41
+ if (
42
+ rec.v !== RECORD_VERSION ||
43
+ rec.origin !== origin ||
44
+ rec.profile !== profile ||
45
+ rec.name !== name
46
+ ) {
47
+ rmSync(file, { force: true });
48
+ return null;
49
+ }
50
+ if (Date.now() - rec.at > TTL_MS) {
51
+ rmSync(file, { force: true });
52
+ return null;
53
+ }
54
+ return rec.data;
55
+ } catch {
56
+ rmSync(file, { force: true });
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function put(origin, profile, name, data) {
62
+ const body = JSON.stringify({
63
+ v: RECORD_VERSION,
64
+ origin,
65
+ profile,
66
+ name,
67
+ at: Date.now(),
68
+ data,
69
+ });
70
+
71
+ if (body.length > MAX_ENTRY) return;
72
+
73
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
74
+ writeFileSync(pathFor(origin, profile, name), body, { mode: 0o600 });
75
+ sweep();
76
+ }
77
+
78
+ /** Drop expired entries, then oldest-first until the directory fits. */
79
+ function sweep() {
80
+ if (!existsSync(DIR)) return;
81
+
82
+ let files;
83
+ try {
84
+ files = readdirSync(DIR).map((f) => {
85
+ const full = join(DIR, f);
86
+ const st = statSync(full);
87
+ return { full, size: st.size, mtime: st.mtimeMs };
88
+ });
89
+ } catch {
90
+ return;
91
+ }
92
+
93
+ const now = Date.now();
94
+ let total = 0;
95
+ const live = [];
96
+
97
+ for (const f of files) {
98
+ if (now - f.mtime > TTL_MS) {
99
+ rmSync(f.full, { force: true });
100
+ continue;
101
+ }
102
+ total += f.size;
103
+ live.push(f);
104
+ }
105
+
106
+ if (total <= MAX_TOTAL) return;
107
+
108
+ live.sort((a, b) => a.mtime - b.mtime);
109
+ for (const f of live) {
110
+ if (total <= MAX_TOTAL) break;
111
+ rmSync(f.full, { force: true });
112
+ total -= f.size;
113
+ }
114
+ }
115
+
116
+ export function clear() {
117
+ rmSync(DIR, { recursive: true, force: true });
118
+ }
119
+
120
+ export function dir() {
121
+ return DIR;
122
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,78 @@
1
+ // Typed errors with stable exit codes, so scripts can branch on failure kind
2
+ // instead of grepping messages.
3
+ //
4
+ // 1 generic / usage
5
+ // 2 the site is not usable (unreachable, not WordPress, unsupported)
6
+ // 3 authorization (missing, expired, denied, insufficient scope)
7
+ // 4 the request was refused (budget, timeout, batch too large)
8
+
9
+ export const EXIT = {
10
+ GENERIC: 1,
11
+ SERVER: 2,
12
+ AUTH: 3,
13
+ REFUSED: 4,
14
+ };
15
+
16
+ export const CODES = {
17
+ usage_error: EXIT.GENERIC,
18
+ not_found: EXIT.GENERIC,
19
+
20
+ server_unreachable: EXIT.SERVER,
21
+ not_wordpress: EXIT.SERVER,
22
+ server_unsupported: EXIT.SERVER,
23
+ contract_mismatch: EXIT.SERVER,
24
+
25
+ auth_required: EXIT.AUTH,
26
+ auth_expired: EXIT.AUTH,
27
+ auth_denied: EXIT.AUTH,
28
+ insufficient_scope: EXIT.AUTH,
29
+
30
+ output_budget: EXIT.REFUSED,
31
+ timeout: EXIT.REFUSED,
32
+ approval_required: EXIT.REFUSED,
33
+ batch_too_large: EXIT.REFUSED,
34
+ };
35
+
36
+ export class CliError extends Error {
37
+ constructor(code, message, { hint, cause } = {}) {
38
+ super(message);
39
+ this.name = 'CliError';
40
+ this.code = code;
41
+ this.hint = hint;
42
+ this.cause = cause;
43
+ this.exitCode = CODES[code] ?? EXIT.GENERIC;
44
+ }
45
+
46
+ toJSON() {
47
+ return { error: this.code, message: this.message, hint: this.hint ?? null };
48
+ }
49
+ }
50
+
51
+ /** Map a WordPress REST failure onto our taxonomy. */
52
+ export function fromRest(status, body, fallback = 'usage_error') {
53
+ const wpCode = body && typeof body === 'object' ? body.code : undefined;
54
+
55
+ if (401 === status || 'rest_oauth_required' === wpCode) {
56
+ return new CliError('auth_required', body?.message || 'Authorization required.', {
57
+ hint: 'Run: niranzwp auth login <url>',
58
+ });
59
+ }
60
+ if (403 === status) {
61
+ // A token can be valid but scoped to a narrower set of routes than the
62
+ // one being called -- that reads very differently from a plain
63
+ // capability failure, so keep the two hints apart.
64
+ const scoped = /route|scope/i.test(String(body?.message ?? ''));
65
+ return new CliError('insufficient_scope', body?.message || 'Not permitted.', {
66
+ hint: scoped
67
+ ? 'This token is scoped to a different endpoint. Novamira OAuth tokens work against its MCP endpoint, not the core abilities run route.'
68
+ : 'This credential belongs to a user without the required capability.',
69
+ });
70
+ }
71
+ if (404 === status) {
72
+ return new CliError('not_found', body?.message || 'Not found.');
73
+ }
74
+ if (status >= 500) {
75
+ return new CliError('server_unsupported', body?.message || `Server error (HTTP ${status}).`);
76
+ }
77
+ return new CliError(fallback, body?.message || `HTTP ${status}`);
78
+ }