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 +343 -7
- package/lib/cache.js +122 -0
- package/lib/errors.js +78 -0
- package/lib/mcp.js +185 -0
- package/lib/oauth.js +196 -0
- package/lib/schema.js +94 -0
- package/lib/store.js +49 -11
- package/lib/wp.js +215 -18
- package/package.json +23 -1
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 {
|
|
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
|
|
12
|
+
const VERSION = '0.6.0';
|
|
8
13
|
|
|
9
|
-
|
|
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
|
-
--
|
|
21
|
-
--
|
|
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
|
-
|
|
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
|
+
}
|