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/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
- async function parse(res) {
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
- export async function request(profile, path, { method = 'GET', body, query, raw = false } = {}) {
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 res = await fetch(url, {
47
- method,
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
- const data = await parse(res);
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
- const code = data && typeof data === 'object' ? data.code : undefined;
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 new WpError(`cannot reach ${siteUrl}: ${e.message}`);
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
- export async function runAbility(profile, name, input) {
145
- return request(profile, `/wp-abilities/v1/abilities/${name}/run`, {
146
- method: 'POST',
147
- body: input ?? {},
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.0",
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
  },