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/lib/wp.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { CliError, fromRest } from './errors.js';
|
|
2
|
+
import { discover, refresh as refreshTokens, isExpired } from './oauth.js';
|
|
3
|
+
import { updateTokens } from './store.js';
|
|
4
|
+
import * as cache from './cache.js';
|
|
1
5
|
// Thin WordPress REST client. No dependencies -- Node's fetch is enough.
|
|
2
6
|
|
|
3
7
|
export class WpError extends Error {
|
|
@@ -17,14 +21,39 @@ export function normalizeSite(url) {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
function authHeader(profile) {
|
|
24
|
+
if ('oauth' === profile.auth) {
|
|
25
|
+
return `Bearer ${profile.tokens.accessToken}`;
|
|
26
|
+
}
|
|
20
27
|
// Application Passwords are sent as HTTP Basic. WordPress strips the
|
|
21
28
|
// spaces WordPress itself put in the generated password.
|
|
22
29
|
const raw = `${profile.user}:${profile.password.replace(/\s+/g, '')}`;
|
|
23
30
|
return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Refresh an OAuth profile in place when its access token is about to expire.
|
|
35
|
+
* The rotated pair is persisted before it is used, because the old refresh
|
|
36
|
+
* token is consumed by the exchange.
|
|
37
|
+
*/
|
|
38
|
+
export async function ensureFresh(profile) {
|
|
39
|
+
if ('oauth' !== profile.auth || !profile.tokens?.refreshToken) return profile;
|
|
40
|
+
if (!isExpired(profile.tokens)) return profile;
|
|
41
|
+
|
|
42
|
+
const meta = await discover(profile.siteUrl);
|
|
43
|
+
if (!meta) throw new CliError('server_unsupported', 'This site no longer advertises an OAuth server.');
|
|
44
|
+
|
|
45
|
+
const tokens = await refreshTokens(meta, profile.clientId, profile.tokens.refreshToken);
|
|
46
|
+
updateTokens(profile.name, tokens);
|
|
47
|
+
return { ...profile, tokens };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function parse(res, maxOutput = DEFAULT_MAX_OUTPUT) {
|
|
27
51
|
const text = await res.text();
|
|
52
|
+
if (text.length > maxOutput) {
|
|
53
|
+
throw new CliError('output_budget', `Response body is ${text.length} bytes, over the ${maxOutput} byte budget.`, {
|
|
54
|
+
hint: 'Narrow the request or raise --max-output.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
28
57
|
if (!text) return null;
|
|
29
58
|
try {
|
|
30
59
|
return JSON.parse(text);
|
|
@@ -33,28 +62,62 @@ async function parse(res) {
|
|
|
33
62
|
}
|
|
34
63
|
}
|
|
35
64
|
|
|
36
|
-
|
|
65
|
+
// Guardrails, matching what a careful CLI should do rather than trusting the
|
|
66
|
+
// server to be well behaved: every request is bounded in time and in size.
|
|
67
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
68
|
+
export const DEFAULT_MAX_OUTPUT = 1_048_576; // 1 MiB
|
|
69
|
+
|
|
70
|
+
export async function request(profile, path, {
|
|
71
|
+
method = 'GET',
|
|
72
|
+
body,
|
|
73
|
+
query,
|
|
74
|
+
raw = false,
|
|
75
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
76
|
+
maxOutput = DEFAULT_MAX_OUTPUT,
|
|
77
|
+
} = {}) {
|
|
37
78
|
const url = new URL(path.startsWith('http') ? path : `${profile.siteUrl}/wp-json${path}`);
|
|
38
79
|
for (const [k, v] of Object.entries(query || {})) {
|
|
39
80
|
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
40
81
|
}
|
|
41
82
|
|
|
42
83
|
const headers = { Accept: 'application/json', 'User-Agent': 'niranzwp' };
|
|
43
|
-
if (profile.password) headers.Authorization = authHeader(profile);
|
|
84
|
+
if (profile.password || profile.tokens) headers.Authorization = authHeader(profile);
|
|
44
85
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
45
86
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
headers,
|
|
49
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
|
-
});
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
51
89
|
|
|
52
|
-
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetch(url, {
|
|
93
|
+
method,
|
|
94
|
+
headers,
|
|
95
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
if (e.name === 'AbortError') {
|
|
101
|
+
throw new CliError('timeout', `Request timed out after ${timeout}ms.`, { hint: 'Raise it with --timeout <ms>.' });
|
|
102
|
+
}
|
|
103
|
+
throw reachError(profile.siteUrl, e);
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A site can return an unbounded response. Refuse to buffer it rather than
|
|
109
|
+
// letting one call exhaust memory or flood the caller.
|
|
110
|
+
const declared = Number(res.headers.get('content-length') || 0);
|
|
111
|
+
if (declared > maxOutput) {
|
|
112
|
+
throw new CliError('output_budget', `Response is ${declared} bytes, over the ${maxOutput} byte budget.`, {
|
|
113
|
+
hint: 'Narrow the request or raise --max-output.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = await parse(res, maxOutput);
|
|
53
118
|
|
|
54
119
|
if (!res.ok) {
|
|
55
|
-
|
|
56
|
-
const msg = (data && typeof data === 'object' && data.message) || `HTTP ${res.status}`;
|
|
57
|
-
throw new WpError(msg, { status: res.status, code, body: data });
|
|
120
|
+
throw fromRest(res.status, data);
|
|
58
121
|
}
|
|
59
122
|
|
|
60
123
|
return raw ? { data, headers: res.headers } : data;
|
|
@@ -68,7 +131,7 @@ export async function probe(siteUrl) {
|
|
|
68
131
|
headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
|
|
69
132
|
});
|
|
70
133
|
} catch (e) {
|
|
71
|
-
throw
|
|
134
|
+
throw reachError(siteUrl, e);
|
|
72
135
|
}
|
|
73
136
|
|
|
74
137
|
const text = await res.text();
|
|
@@ -141,9 +204,143 @@ export async function listAbilities(profile) {
|
|
|
141
204
|
return request(profile, '/wp-abilities/v1/abilities');
|
|
142
205
|
}
|
|
143
206
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Core dictates the HTTP method from the ability's own annotations, and always
|
|
209
|
+
* carries the payload under an "input" key -- as a query parameter for the
|
|
210
|
+
* methods that have no body, and in the JSON body otherwise.
|
|
211
|
+
*/
|
|
212
|
+
export function methodFor(ability) {
|
|
213
|
+
const ann = ability?.meta?.annotations ?? ability?.annotations ?? {};
|
|
214
|
+
if (ann.readonly) return 'GET';
|
|
215
|
+
if (ann.destructive && ann.idempotent) return 'DELETE';
|
|
216
|
+
return 'POST';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Flatten a value into PHP-style bracketed query keys:
|
|
221
|
+
* { a: 1, b: { c: 2 }, d: [3] } -> input[a]=1, input[b][c]=2, input[d][0]=3
|
|
222
|
+
*/
|
|
223
|
+
function bracketed(prefix, value, out = {}) {
|
|
224
|
+
if (value === null || value === undefined) return out;
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
value.forEach((v, i) => bracketed(`${prefix}[${i}]`, v, out));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
if ('object' === typeof value) {
|
|
231
|
+
const entries = Object.entries(value);
|
|
232
|
+
// An empty object must still be sent, or core sees no input at all.
|
|
233
|
+
if (!entries.length) { out[`${prefix}`] = ''; return out; }
|
|
234
|
+
for (const [k, v] of entries) bracketed(`${prefix}[${k}]`, v, out);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
out[prefix] = 'boolean' === typeof value ? (value ? '1' : '0') : String(value);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function runAbility(profile, name, input, opts = {}) {
|
|
243
|
+
const { ability, ...rest } = opts;
|
|
244
|
+
const method = opts.method ?? methodFor(ability);
|
|
245
|
+
const path = `/wp-abilities/v1/abilities/${name}/run`;
|
|
246
|
+
const payload = input ?? {};
|
|
247
|
+
|
|
248
|
+
if ('GET' === method || 'DELETE' === method) {
|
|
249
|
+
// Core reads $request->get_query_params()['input'] and expects a real
|
|
250
|
+
// array, so the payload has to be bracket-encoded rather than sent as
|
|
251
|
+
// a JSON string.
|
|
252
|
+
return request(profile, path, { ...rest, method, query: bracketed('input', payload) });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return request(profile, path, { ...rest, method, body: { input: payload } });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function describeAbility(profile, name, opts = {}) {
|
|
259
|
+
const hit = cache.get(profile.siteUrl, profile.name, name);
|
|
260
|
+
if (hit) return hit;
|
|
261
|
+
|
|
262
|
+
const data = await request(profile, `/wp-abilities/v1/abilities/${name}`, opts);
|
|
263
|
+
cache.put(profile.siteUrl, profile.name, name, data);
|
|
264
|
+
return data;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Is this ability declared read-only by the site? Used to decide whether a
|
|
268
|
+
// run needs explicit approval.
|
|
269
|
+
export function isReadOnly(ability) {
|
|
270
|
+
const ann = ability?.meta?.annotations ?? ability?.annotations ?? {};
|
|
271
|
+
return ann.readonly === true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Read the site's Novamira/abilities compatibility metadata, if it publishes
|
|
277
|
+
* any. Used to fail early with a clear message rather than midway through a
|
|
278
|
+
* command, when a site is too old to serve what we are about to ask for.
|
|
279
|
+
*/
|
|
280
|
+
export async function contract(siteUrl, endpoint = '/wp-json/mcp/novamira-oauth') {
|
|
281
|
+
try {
|
|
282
|
+
const res = await fetch(`${siteUrl}/.well-known/oauth-protected-resource${endpoint}`, {
|
|
283
|
+
headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
|
|
284
|
+
});
|
|
285
|
+
if (!res.ok) return null;
|
|
286
|
+
const body = await res.json();
|
|
287
|
+
return body?.novamira ?? null;
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const MIN_WORDPRESS = '6.9';
|
|
294
|
+
|
|
295
|
+
export function checkCompat(info) {
|
|
296
|
+
const problems = [];
|
|
297
|
+
if (!info) return problems;
|
|
298
|
+
if (info.wordpress_version && compareVersions(info.wordpress_version, MIN_WORDPRESS) < 0) {
|
|
299
|
+
problems.push(`site runs WordPress ${info.wordpress_version}; the Abilities API needs ${MIN_WORDPRESS}`);
|
|
300
|
+
}
|
|
301
|
+
if (info.rest_api_version && Number(info.rest_api_version) > 1) {
|
|
302
|
+
problems.push(`site advertises REST contract v${info.rest_api_version}; this CLI speaks v1`);
|
|
303
|
+
}
|
|
304
|
+
return problems;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function compareVersions(a, b) {
|
|
308
|
+
const pa = String(a).split('.').map(Number);
|
|
309
|
+
const pb = String(b).split('.').map(Number);
|
|
310
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
311
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
312
|
+
if (d) return d < 0 ? -1 : 1;
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Turn a fetch failure into something actionable. Node ships its own CA list,
|
|
320
|
+
* so a certificate the OS trusts -- a LocalWP or mkcert development cert, or a
|
|
321
|
+
* corporate proxy's -- is still rejected here, and the bare "fetch failed"
|
|
322
|
+
* gives no clue why.
|
|
323
|
+
*/
|
|
324
|
+
function reachError(siteUrl, e) {
|
|
325
|
+
const chain = [];
|
|
326
|
+
for (let c = e; c; c = c.cause) chain.push(c.code || c.message || '');
|
|
327
|
+
const codes = chain.join(' ');
|
|
328
|
+
|
|
329
|
+
const tls = /SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO|CERT_|ERR_TLS/i.test(codes);
|
|
330
|
+
if (tls) {
|
|
331
|
+
return new CliError('server_unreachable', `TLS certificate for ${siteUrl} is not trusted by Node.`, {
|
|
332
|
+
hint: 'Node uses its own CA list. For a development certificate run: NODE_OPTIONS=--use-system-ca niranzwp ... (after trusting it in the OS), or use the http:// URL for a local site.',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(codes)) {
|
|
336
|
+
return new CliError('server_unreachable', `Cannot resolve ${siteUrl}.`, {
|
|
337
|
+
hint: 'Check the hostname, and that the site is running.',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (/ECONNREFUSED/i.test(codes)) {
|
|
341
|
+
return new CliError('server_unreachable', `Connection refused by ${siteUrl}.`, {
|
|
342
|
+
hint: 'The host resolved but nothing is listening. Is the site started?',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return new CliError('server_unreachable', `Cannot reach ${siteUrl}: ${e.message}`);
|
|
149
346
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niranzwp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.6.1",
|
|
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": {
|
|
7
7
|
"niranzwp": "bin/niranzwp.js"
|
|
8
8
|
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Niranjan",
|
|
11
|
+
"url": "https://niranz.dev"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://niranz.dev",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/niranz-dev/niranzwp.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/niranz-dev/niranzwp/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"wordpress",
|
|
23
|
+
"cli",
|
|
24
|
+
"wp-cli",
|
|
25
|
+
"rest-api",
|
|
26
|
+
"application-passwords",
|
|
27
|
+
"abilities-api",
|
|
28
|
+
"seo",
|
|
29
|
+
"site-management"
|
|
30
|
+
],
|
|
9
31
|
"engines": {
|
|
10
32
|
"node": ">=22"
|
|
11
33
|
},
|