niranzwp 0.8.4 → 0.8.6

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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { loginWithAppPassword, loginManual } from '../lib/auth.js';
2
+ import { loginWithAppPassword, loginManual, credentialsFromEnv } from '../lib/auth.js';
3
3
  import { saveProfile, saveOAuthProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
4
4
  import { discover, registerClient, startDeviceFlow, pollForToken } from '../lib/oauth.js';
5
5
  import { CliError } from '../lib/errors.js';
@@ -74,6 +74,13 @@ Built by Niranjan -- https://niranz.dev
74
74
  niranzwp media list [--missing-alt]
75
75
  niranzwp media set-alt <id> "<text>"
76
76
  niranzwp user list
77
+
78
+ niranzwp file read <path> [--offset <n>] [--limit <n>]
79
+ niranzwp file list [<path>] [--recursive] [--pattern '*.php']
80
+ niranzwp file write <path> <content|-> [--append] [--create-dirs] [--apply]
81
+ niranzwp file edit <path> --old '<text>' --new '<text>' [--all] [--apply]
82
+ niranzwp file delete|disable|enable <path> [--recursive] [--apply]
83
+ writes preview by default -- add --apply to change the file
77
84
  niranzwp settings get|set <key> <value>
78
85
 
79
86
  niranzwp seo audit site-wide SEO gaps, ranked by severity
@@ -105,6 +112,25 @@ Options
105
112
  --version print version
106
113
  `;
107
114
 
115
+ /*
116
+ * Flags that take a value, listed rather than guessed.
117
+ *
118
+ * The parser used to decide by looking ahead: a flag swallowed the next token
119
+ * unless it began with --. That is right for --site local and wrong for every
120
+ * boolean, and it broke silently rather than loudly. `--site local --yes run
121
+ * <ability>` parsed as yes="run", left the ability as the command, and printed
122
+ * usage -- so putting the global flags first, which works for every other
123
+ * command, quietly did nothing here.
124
+ *
125
+ * Anything not named here is a boolean, so a new switch needs no change and
126
+ * cannot eat the command after it.
127
+ */
128
+ const VALUE_FLAGS = new Set([
129
+ 'site', 'name', 'input', 'file', 'timeout', 'max-output', 'endpoint',
130
+ 'title', 'content', 'excerpt', 'status', 'field', 'search',
131
+ 'page', 'limit', 'offset', 'post-type', 'old', 'new', 'pattern',
132
+ ]);
133
+
108
134
  function parseArgs(argv) {
109
135
  const args = { _: [], flags: {} };
110
136
  for (let i = 0; i < argv.length; i++) {
@@ -112,9 +138,13 @@ function parseArgs(argv) {
112
138
  if (a.startsWith('--')) {
113
139
  const key = a.slice(2);
114
140
  if (key.startsWith('no-')) { args.flags[key.slice(3)] = false; continue; }
141
+ if (!VALUE_FLAGS.has(key)) { args.flags[key] = true; continue; }
115
142
  const next = argv[i + 1];
116
- if (next === undefined || next.startsWith('--')) { args.flags[key] = true; }
117
- else { args.flags[key] = next; i++; }
143
+ if (next === undefined || next.startsWith('--')) {
144
+ args.flags[key] = true;
145
+ } else {
146
+ args.flags[key] = next; i++;
147
+ }
118
148
  } else {
119
149
  args._.push(a);
120
150
  }
@@ -152,6 +182,19 @@ function targets(flags) {
152
182
  return all;
153
183
  }
154
184
 
185
+ /**
186
+ * Read the whole of stdin.
187
+ *
188
+ * `file write <path> -` is how a generated file gets here without going
189
+ * through the shell's quoting, which mangles anything with a newline or a
190
+ * quote in it - which is most files worth writing.
191
+ */
192
+ async function readStdin() {
193
+ const chunks = [];
194
+ for await (const chunk of process.stdin) chunks.push(chunk);
195
+ return Buffer.concat(chunks).toString('utf8');
196
+ }
197
+
155
198
  const reqOpts = (flags) => ({
156
199
  timeout: Number(flags.timeout || DEFAULT_TIMEOUT_MS),
157
200
  maxOutput: Number(flags['max-output'] || DEFAULT_MAX_OUTPUT),
@@ -214,9 +257,31 @@ async function main() {
214
257
  // password on its own success page and the user carries it here. The
215
258
  // only route that works when the browser cannot reach this machine --
216
259
  // SSH, a VPS, a phone browser against a CLI running elsewhere.
217
- const creds = flags.manual === true
218
- ? await loginManual(site, { open: flags.open !== false })
219
- : await loginWithAppPassword(site, { open: flags.open !== false });
260
+ /*
261
+ * The environment first, and a refusal rather than a hang.
262
+ *
263
+ * Both routes below need a person -- one to approve in a browser, one
264
+ * to type at a prompt -- and neither can tell you that. Given the
265
+ * variables, this skips them entirely; without them, and with no
266
+ * terminal to prompt at, it says so instead of waiting.
267
+ */
268
+ const fromEnv = credentialsFromEnv();
269
+ let creds;
270
+
271
+ if (fromEnv) {
272
+ const info = await probe(site);
273
+ creds = { siteUrl: site.replace(/\/+$/, ''), ...fromEnv, info };
274
+ } else if (process.stdin.isTTY !== true && flags.manual !== true) {
275
+ throw new CliError(
276
+ 'no_terminal',
277
+ 'There is no terminal here to approve or type at, and no credentials in the environment.',
278
+ { hint: 'Set NIRANZWP_USER and NIRANZWP_APP_PASSWORD, or pipe the two answers into --manual.' }
279
+ );
280
+ } else {
281
+ creds = flags.manual === true
282
+ ? await loginManual(site, { open: flags.open !== false })
283
+ : await loginWithAppPassword(site, { open: flags.open !== false });
284
+ }
220
285
 
221
286
  // A pasted credential can be mistyped, so prove it works before
222
287
  // storing it. The loopback flow's credential came from WordPress
@@ -425,6 +490,120 @@ async function main() {
425
490
  die(`usage: niranzwp ${cmd} list|get|create|update|delete`);
426
491
  }
427
492
 
493
+ /*
494
+ * The filesystem group.
495
+ *
496
+ * The plugin has eight filesystem abilities and the CLI had shortcuts for
497
+ * none of them, so every one of them had to be reached through `run` with a
498
+ * hand-written JSON blob. That is fine for a program and miserable for a
499
+ * person, and these are the abilities someone reaches for while something
500
+ * is broken.
501
+ *
502
+ * Writes keep the ability's own dry_run default: --yes approves the call,
503
+ * --apply is what actually changes the file. Two gestures, because the
504
+ * first is "I meant to run this" and the second is "I have read the
505
+ * preview".
506
+ */
507
+ if (cmd === 'file') {
508
+ const p = resolveProfile(flags);
509
+ /*
510
+ * The abilities API routes read-only abilities over GET and refuses
511
+ * them over POST. runAbility works that out from ability metadata it
512
+ * does not have here, and fetching the metadata first would be a round
513
+ * trip to learn something already known - these two are read-only and
514
+ * always will be.
515
+ */
516
+ const READS = new Set(['read-file', 'list-directory']);
517
+ const call = (ability, input) => runAbility(
518
+ p,
519
+ `niranzwp/${ability}`,
520
+ input,
521
+ { ...reqOpts(flags), method: READS.has(ability) ? 'GET' : 'POST' }
522
+ );
523
+ const apply = flags.apply === true;
524
+
525
+ if (sub === 'read') {
526
+ const path = rest[0];
527
+ if (!path) die('usage: niranzwp file read <path> [--offset <n>] [--limit <n>]');
528
+ const input = { path };
529
+ if (flags.offset !== undefined) input.offset = Number(flags.offset);
530
+ if (flags.limit !== undefined) input.limit = Number(flags.limit);
531
+ const r = await call('read-file', input);
532
+ out(flags, r, (d) => {
533
+ process.stdout.write(d.content ?? '');
534
+ if (!d.eof) {
535
+ console.error(`\n-- ${d.bytes_returned} of ${d.bytes} bytes. Continue with --offset ${d.next_offset}`);
536
+ }
537
+ });
538
+ return;
539
+ }
540
+
541
+ if (sub === 'list') {
542
+ const input = { path: rest[0] ?? '' };
543
+ if (flags.recursive === true) input.recursive = true;
544
+ if (flags.pattern) input.pattern = flags.pattern;
545
+ if (flags.limit !== undefined) input.limit = Number(flags.limit);
546
+ if (flags['include-hidden'] === true) input.include_hidden = true;
547
+ const r = await call('list-directory', input);
548
+ out(flags, r, (d) => {
549
+ console.log(`${d.total} entries in ${d.path}${d.truncated ? ' (truncated)' : ''}`);
550
+ for (const e of d.entries) {
551
+ console.log(` ${(e.type === 'directory' ? 'dir ' : ' ')}${String(e.size).padStart(9)} ${e.name}`);
552
+ }
553
+ });
554
+ return;
555
+ }
556
+
557
+ if (sub === 'write') {
558
+ const path = rest[0];
559
+ if (!path) die('usage: niranzwp file write <path> <content|-> [--append] [--apply] --yes');
560
+ // A dash means stdin, which is how a generated file gets here
561
+ // without going through the shell's quoting.
562
+ const rawArg = rest.slice(1).join(' ');
563
+ const content = rawArg === '-' ? await readStdin() : rawArg;
564
+ const input = { path, content, dry_run: !apply };
565
+ if (flags.append === true) input.mode = 'append';
566
+ if (flags['create-dirs'] === true) input.create_directories = true;
567
+ out(flags, await call('write-file', input));
568
+ return;
569
+ }
570
+
571
+ if (sub === 'edit') {
572
+ const path = rest[0];
573
+ if (!path || flags.old === undefined || flags.new === undefined) {
574
+ die('usage: niranzwp file edit <path> --old "<text>" --new "<text>" [--all] [--apply] --yes');
575
+ }
576
+ out(flags, await call('edit-file', {
577
+ path,
578
+ old_string: String(flags.old),
579
+ new_string: String(flags.new),
580
+ replace_all: flags.all === true,
581
+ dry_run: !apply,
582
+ }));
583
+ return;
584
+ }
585
+
586
+ if (sub === 'delete') {
587
+ const path = rest[0];
588
+ if (!path) die('usage: niranzwp file delete <path> [--recursive] [--apply] --yes');
589
+ out(flags, await call('delete-file', {
590
+ path,
591
+ recursive: flags.recursive === true,
592
+ dry_run: !apply,
593
+ }));
594
+ return;
595
+ }
596
+
597
+ if (sub === 'disable' || sub === 'enable') {
598
+ const path = rest[0];
599
+ if (!path) die(`usage: niranzwp file ${sub} <path> [--apply] --yes`);
600
+ out(flags, await call(`${sub}-file`, { path, dry_run: !apply }));
601
+ return;
602
+ }
603
+
604
+ die('usage: niranzwp file read|list|write|edit|delete|disable|enable');
605
+ }
606
+
428
607
  if (cmd === 'media') {
429
608
  const p = resolveProfile(flags);
430
609
 
package/lib/auth.js CHANGED
@@ -66,16 +66,35 @@ export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI',
66
66
  resolve({ siteUrl: site.replace(/\/+$/, ''), user, password, info });
67
67
  });
68
68
 
69
+ /*
70
+ * This listener cannot see what the browser saw. If WordPress refused
71
+ * the request - it answers "Invalid URL format" when something between
72
+ * the browser and PHP rewrites the success_url, which a WAF will do to
73
+ * anything carrying http://127.0.0.1 - the person is looking at an
74
+ * error page while this sits here saying nothing for five minutes.
75
+ *
76
+ * So it stops being silent. Nothing is cancelled: an approval arriving
77
+ * at minute four still works.
78
+ */
79
+ const nudge = setTimeout(() => {
80
+ console.error('Still waiting.');
81
+ console.error('If the browser showed an error instead of an approval screen, or is not on');
82
+ console.error('this machine, press Ctrl-C and run again with --manual.');
83
+ console.error('');
84
+ }, Math.min(45_000, timeoutMs / 2));
85
+
69
86
  const timer = setTimeout(() => {
70
87
  cleanup();
71
88
  reject(new Error(
72
- 'Timed out waiting for authorization. If the browser is not on this machine ' +
73
- '(SSH, a VPS, a container), it cannot reach this listener -- run again with --manual.'
89
+ 'Timed out waiting for authorization. Either nobody approved it, or the browser ' +
90
+ 'could not reach this listener (SSH, a VPS, a container) or was refused by ' +
91
+ 'WordPress -- run again with --manual, which sends no callback URL at all.'
74
92
  ));
75
93
  }, timeoutMs);
76
94
 
77
95
  function cleanup() {
78
96
  clearTimeout(timer);
97
+ clearTimeout(nudge);
79
98
  server.close();
80
99
  }
81
100
 
@@ -92,6 +111,27 @@ export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI',
92
111
  console.error('Approve this connection in your browser:');
93
112
  console.error(` ${authorize}`);
94
113
  console.error('');
114
+ /*
115
+ * Said before the wait, not after it.
116
+ *
117
+ * The callback is http://127.0.0.1, and WordPress accepts an
118
+ * http:// redirect only when the site's own environment type is
119
+ * "local" -- wp_is_authorize_application_redirect_url_valid()
120
+ * exempts nothing by host, not loopback, not private ranges. On a
121
+ * live site the browser is shown "The URL must be served over a
122
+ * secure connection." and this listener, which cannot see that
123
+ * page, waits out its five minutes in silence.
124
+ *
125
+ * Whether a given site will refuse is not knowable from here --
126
+ * the environment type is not in the REST root, and a public site
127
+ * can still be set to "local", wrongly but effectively. So this
128
+ * does not guess. It says what that error means and what to do
129
+ * about it, where it will be read.
130
+ */
131
+ console.error('If the browser says "The URL must be served over a secure connection",');
132
+ console.error('this site will not hand the password back here. Stop and run:');
133
+ console.error(` niranzwp auth login ${siteUrl} --manual`);
134
+ console.error('');
95
135
  if (open) openBrowser(authorize.toString());
96
136
  });
97
137
  });
@@ -137,6 +177,30 @@ export async function loginManual(siteUrl, { appName = 'NiranzWP CLI', open = tr
137
177
  return { siteUrl: siteUrl.replace(/\/+$/, ''), user: user.trim(), password: password.trim(), info };
138
178
  }
139
179
 
180
+ /**
181
+ * Credentials from the environment, for anything with nobody sitting at it.
182
+ *
183
+ * Every other route here needs a person: a browser to approve in, or a
184
+ * terminal to type into. A script, a CI job or an agent has neither, and used
185
+ * to discover that by waiting five minutes for a callback that was never
186
+ * coming. Two variables connect directly:
187
+ *
188
+ * NIRANZWP_USER=editor NIRANZWP_APP_PASSWORD='abcd efgh ...' \
189
+ * niranzwp auth login https://example.com
190
+ *
191
+ * Deliberately not flags. A password given on the command line is written
192
+ * into shell history and shows up in `ps` to every other user on the machine.
193
+ * An environment variable does neither.
194
+ *
195
+ * @returns {{user: string, password: string} | null}
196
+ */
197
+ export function credentialsFromEnv() {
198
+ const user = (process.env.NIRANZWP_USER || '').trim();
199
+ const password = (process.env.NIRANZWP_APP_PASSWORD || '').trim();
200
+
201
+ return user && password ? { user, password } : null;
202
+ }
203
+
140
204
  /**
141
205
  * Both prompts on ONE readline interface.
142
206
  *
package/lib/oauth.js CHANGED
@@ -70,8 +70,48 @@ async function postJson(url, body, { timeout = 30_000 } = {}) {
70
70
  }
71
71
  }
72
72
 
73
- /** Fetch the authorization server metadata, or null if the site has none. */
73
+ /**
74
+ * Find the authorization server, preferring the plugin's own.
75
+ *
76
+ * /.well-known/oauth-authorization-server is a single path and any plugin can
77
+ * claim it; whichever hooks parse_request first and exits wins. On a site
78
+ * running more than one MCP plugin the document there may describe somebody
79
+ * else's server entirely, and a token minted against it dies the moment that
80
+ * plugin is removed. So ask the plugin's own namespace first - it can only
81
+ * ever answer for itself - and fall back to the well-known document for sites
82
+ * that have an authorization server but not this plugin.
83
+ */
74
84
  export async function discover(siteUrl) {
85
+ return (await discoverOwn(siteUrl)) || (await discoverWellKnown(siteUrl));
86
+ }
87
+
88
+ /** The REST namespace index lists the routes actually registered, and nothing else's. */
89
+ async function discoverOwn(siteUrl) {
90
+ try {
91
+ const res = await fetchWithTimeout(`${siteUrl}/wp-json/niranzwp/v1`, {
92
+ headers: { Accept: 'application/json', 'User-Agent': UA },
93
+ });
94
+ if (!res.ok) return null;
95
+ const index = await res.json();
96
+ const routes = Object.keys(index?.routes || {});
97
+ const has = (r) => routes.includes(`/niranzwp/v1/oauth/${r}`);
98
+ if (!has('token') || !has('device') || !has('register')) return null;
99
+
100
+ return {
101
+ issuer: siteUrl,
102
+ token_endpoint: `${siteUrl}/wp-json/niranzwp/v1/oauth/token`,
103
+ device_authorization_endpoint: `${siteUrl}/wp-json/niranzwp/v1/oauth/device`,
104
+ registration_endpoint: `${siteUrl}/wp-json/niranzwp/v1/oauth/register`,
105
+ grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
106
+ token_endpoint_auth_methods_supported: ['none'],
107
+ scopes_supported: ['abilities'],
108
+ };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async function discoverWellKnown(siteUrl) {
75
115
  try {
76
116
  const res = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-authorization-server`, {
77
117
  headers: { Accept: 'application/json', 'User-Agent': UA },
package/lib/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, rmSync, existsSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
@@ -23,9 +23,33 @@ function readProfiles() {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Write a file so a concurrent reader never sees half of it.
28
+ *
29
+ * writeFileSync truncates and then fills, so anything reading in that window
30
+ * gets a partial file - and both of these hold JSON, where partial means
31
+ * unparseable. Two CLI processes at once is ordinary: a script running
32
+ * commands in parallel, or a save landing while another call is resolving its
33
+ * profile. rename() is atomic on POSIX, so a reader sees either the old file
34
+ * or the new one.
35
+ *
36
+ * The temp file carries the same 0600 mode, because for a moment it holds the
37
+ * same secrets.
38
+ */
39
+ function writeAtomic(path, contents) {
40
+ const tmp = `${path}.${process.pid}.tmp`;
41
+ try {
42
+ writeFileSync(tmp, contents, { mode: 0o600 });
43
+ renameSync(tmp, path);
44
+ } catch (e) {
45
+ try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
46
+ throw e;
47
+ }
48
+ }
49
+
26
50
  function writeProfiles(all) {
27
51
  mkdirSync(DIR, { recursive: true, mode: 0o700 });
28
- writeFileSync(PROFILES, JSON.stringify(all, null, 2), { mode: 0o600 });
52
+ writeAtomic(PROFILES, JSON.stringify(all, null, 2));
29
53
  }
30
54
 
31
55
  const account = (name) => `${SERVICE}:${name}`;
@@ -200,7 +224,7 @@ function fileBlobs() {
200
224
 
201
225
  function writeBlobs(all) {
202
226
  mkdirSync(DIR, { recursive: true, mode: 0o700 });
203
- writeFileSync(secretsPath(), JSON.stringify(all), { mode: 0o600 });
227
+ writeAtomic(secretsPath(), JSON.stringify(all));
204
228
  }
205
229
 
206
230
  /** Last resort: a 0600 file. Weaker than any keychain, and `storageKind` says so. */
package/lib/wp.js CHANGED
@@ -40,12 +40,23 @@ export async function ensureFresh(profile) {
40
40
  if ('oauth' !== profile.auth || !profile.tokens?.refreshToken) return profile;
41
41
  if (!isExpired(profile.tokens)) return profile;
42
42
 
43
- const meta = await discover(profile.siteUrl);
44
- if (!meta) throw new CliError('server_unsupported', 'This site no longer advertises an OAuth server.');
43
+ // Discovery can fail for reasons that have nothing to do with the token --
44
+ // a WAF that started blocking /.well-known/ is the one that actually
45
+ // happened. The token may still be perfectly good, so fall through and
46
+ // let the request try it; a real 401 is handled below with one retry.
47
+ let meta = null;
48
+ try {
49
+ meta = await discover(profile.siteUrl);
50
+ } catch { /* treated the same as no metadata */ }
51
+ if (!meta) return profile;
45
52
 
46
- const tokens = await refreshTokens(meta, profile.clientId, profile.tokens.refreshToken);
47
- updateTokens(profile.name, tokens);
48
- return { ...profile, tokens };
53
+ try {
54
+ const tokens = await refreshTokens(meta, profile.clientId, profile.tokens.refreshToken);
55
+ updateTokens(profile.name, tokens);
56
+ return { ...profile, tokens };
57
+ } catch {
58
+ return profile;
59
+ }
49
60
  }
50
61
 
51
62
  async function parse(res, maxOutput = DEFAULT_MAX_OUTPUT) {
@@ -65,6 +76,7 @@ async function parse(res, maxOutput = DEFAULT_MAX_OUTPUT) {
65
76
 
66
77
  // Guardrails, matching what a careful CLI should do rather than trusting the
67
78
  // server to be well behaved: every request is bounded in time and in size.
79
+ export const USER_AGENT = 'Mozilla/5.0 (compatible; niranzwp/1.0; +https://niranz.dev)';
68
80
  export const DEFAULT_TIMEOUT_MS = 30_000;
69
81
  export const DEFAULT_MAX_OUTPUT = 1_048_576; // 1 MiB
70
82
 
@@ -87,7 +99,11 @@ export async function request(profile, path, {
87
99
  if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
88
100
  }
89
101
 
90
- const headers = { Accept: 'application/json', 'User-Agent': 'niranzwp' };
102
+ // A bare tool-name UA gets classified as a bot by some hosts' WAF rules
103
+ // (Hostinger's ModSecurity started 403-ing "niranzwp" + Accept:json on
104
+ // 15 Aug). Identify honestly, but in the shape browsers use, which is what
105
+ // those rules are written to allow.
106
+ const headers = { Accept: 'application/json', 'User-Agent': USER_AGENT };
91
107
  if (profile.password || profile.tokens) headers.Authorization = authHeader(profile);
92
108
  if (body !== undefined) headers['Content-Type'] = 'application/json';
93
109
 
@@ -124,6 +140,15 @@ export async function request(profile, path, {
124
140
  const data = await parse(res, maxOutput);
125
141
 
126
142
  if (!res.ok) {
143
+ // A 403 whose body is an HTML page did not come from WordPress -- WP
144
+ // answers REST with JSON. It is a WAF or the host in front. Say so,
145
+ // rather than reporting a capability problem the user cannot fix.
146
+ if (403 === res.status && typeof data === 'string' && /<html/i.test(data)) {
147
+ throw new CliError('server_unsupported',
148
+ `${profile.siteUrl} refused this request at the web server, before WordPress saw it (HTTP 403, HTML body).`, {
149
+ hint: 'A firewall (ModSecurity, Cloudflare, host security) is blocking REST calls. Check the host\'s security or WAF settings; nothing in WordPress will change this.',
150
+ });
151
+ }
127
152
  // A token can be revoked server-side before it expires on the clock, so
128
153
  // isExpired() will not have caught it. Refresh once and retry.
129
154
  if (401 === res.status && 'oauth' === profile.auth && profile.tokens?.refreshToken && !_retried) {
@@ -145,7 +170,7 @@ export async function probe(siteUrl, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
145
170
  let res;
146
171
  try {
147
172
  res = await fetchWithTimeout(`${siteUrl}/wp-json/`, {
148
- headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
173
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
149
174
  }, timeout);
150
175
  } catch (e) {
151
176
  throw reachError(siteUrl, e);
@@ -180,7 +205,7 @@ export async function probe(siteUrl, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
180
205
  let oauth = null;
181
206
  try {
182
207
  const r = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-authorization-server`, {
183
- headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
208
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
184
209
  }, timeout);
185
210
  if (r.ok) oauth = await r.json();
186
211
  } catch { /* not every site has one */ }
@@ -217,8 +242,32 @@ export async function listPosts(profile, { status = 'publish', perPage = 10, pag
217
242
  });
218
243
  }
219
244
 
245
+ /**
246
+ * Every ability the site exposes, across every page.
247
+ *
248
+ * The endpoint paginates and defaults to fifty per page. Asking without a page
249
+ * size therefore returned a silently truncated list: on a site with two MCP
250
+ * plugins installed, ninety-six abilities came back as fifty, and the ones that
251
+ * fell off simply looked as though they did not exist. Follow the page count
252
+ * the response reports rather than trusting one request to be the whole answer.
253
+ */
220
254
  export async function listAbilities(profile) {
221
- return request(profile, '/wp-abilities/v1/abilities');
255
+ const first = await request(profile, '/wp-abilities/v1/abilities', {
256
+ raw: true,
257
+ query: { per_page: 100, page: 1 },
258
+ });
259
+
260
+ const all = Array.isArray(first.data) ? [...first.data] : first.data;
261
+ const pages = Number(first.headers?.get?.('x-wp-totalpages') || 1);
262
+ if (!Array.isArray(all) || pages <= 1) return all;
263
+
264
+ for (let page = 2; page <= pages; page++) {
265
+ const next = await request(profile, '/wp-abilities/v1/abilities', {
266
+ query: { per_page: 100, page },
267
+ });
268
+ if (Array.isArray(next)) all.push(...next);
269
+ }
270
+ return all;
222
271
  }
223
272
 
224
273
  /**
@@ -297,7 +346,7 @@ export function isReadOnly(ability) {
297
346
  export async function contract(siteUrl, endpoint = '/wp-json/mcp/novamira-oauth') {
298
347
  try {
299
348
  const res = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-protected-resource${endpoint}`, {
300
- headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
349
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
301
350
  });
302
351
  if (!res.ok) return null;
303
352
  const body = await res.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niranzwp",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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": {