niranzwp 0.8.4 → 0.8.5

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
@@ -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),
@@ -425,6 +468,120 @@ async function main() {
425
468
  die(`usage: niranzwp ${cmd} list|get|create|update|delete`);
426
469
  }
427
470
 
471
+ /*
472
+ * The filesystem group.
473
+ *
474
+ * The plugin has eight filesystem abilities and the CLI had shortcuts for
475
+ * none of them, so every one of them had to be reached through `run` with a
476
+ * hand-written JSON blob. That is fine for a program and miserable for a
477
+ * person, and these are the abilities someone reaches for while something
478
+ * is broken.
479
+ *
480
+ * Writes keep the ability's own dry_run default: --yes approves the call,
481
+ * --apply is what actually changes the file. Two gestures, because the
482
+ * first is "I meant to run this" and the second is "I have read the
483
+ * preview".
484
+ */
485
+ if (cmd === 'file') {
486
+ const p = resolveProfile(flags);
487
+ /*
488
+ * The abilities API routes read-only abilities over GET and refuses
489
+ * them over POST. runAbility works that out from ability metadata it
490
+ * does not have here, and fetching the metadata first would be a round
491
+ * trip to learn something already known - these two are read-only and
492
+ * always will be.
493
+ */
494
+ const READS = new Set(['read-file', 'list-directory']);
495
+ const call = (ability, input) => runAbility(
496
+ p,
497
+ `niranzwp/${ability}`,
498
+ input,
499
+ { ...reqOpts(flags), method: READS.has(ability) ? 'GET' : 'POST' }
500
+ );
501
+ const apply = flags.apply === true;
502
+
503
+ if (sub === 'read') {
504
+ const path = rest[0];
505
+ if (!path) die('usage: niranzwp file read <path> [--offset <n>] [--limit <n>]');
506
+ const input = { path };
507
+ if (flags.offset !== undefined) input.offset = Number(flags.offset);
508
+ if (flags.limit !== undefined) input.limit = Number(flags.limit);
509
+ const r = await call('read-file', input);
510
+ out(flags, r, (d) => {
511
+ process.stdout.write(d.content ?? '');
512
+ if (!d.eof) {
513
+ console.error(`\n-- ${d.bytes_returned} of ${d.bytes} bytes. Continue with --offset ${d.next_offset}`);
514
+ }
515
+ });
516
+ return;
517
+ }
518
+
519
+ if (sub === 'list') {
520
+ const input = { path: rest[0] ?? '' };
521
+ if (flags.recursive === true) input.recursive = true;
522
+ if (flags.pattern) input.pattern = flags.pattern;
523
+ if (flags.limit !== undefined) input.limit = Number(flags.limit);
524
+ if (flags['include-hidden'] === true) input.include_hidden = true;
525
+ const r = await call('list-directory', input);
526
+ out(flags, r, (d) => {
527
+ console.log(`${d.total} entries in ${d.path}${d.truncated ? ' (truncated)' : ''}`);
528
+ for (const e of d.entries) {
529
+ console.log(` ${(e.type === 'directory' ? 'dir ' : ' ')}${String(e.size).padStart(9)} ${e.name}`);
530
+ }
531
+ });
532
+ return;
533
+ }
534
+
535
+ if (sub === 'write') {
536
+ const path = rest[0];
537
+ if (!path) die('usage: niranzwp file write <path> <content|-> [--append] [--apply] --yes');
538
+ // A dash means stdin, which is how a generated file gets here
539
+ // without going through the shell's quoting.
540
+ const rawArg = rest.slice(1).join(' ');
541
+ const content = rawArg === '-' ? await readStdin() : rawArg;
542
+ const input = { path, content, dry_run: !apply };
543
+ if (flags.append === true) input.mode = 'append';
544
+ if (flags['create-dirs'] === true) input.create_directories = true;
545
+ out(flags, await call('write-file', input));
546
+ return;
547
+ }
548
+
549
+ if (sub === 'edit') {
550
+ const path = rest[0];
551
+ if (!path || flags.old === undefined || flags.new === undefined) {
552
+ die('usage: niranzwp file edit <path> --old "<text>" --new "<text>" [--all] [--apply] --yes');
553
+ }
554
+ out(flags, await call('edit-file', {
555
+ path,
556
+ old_string: String(flags.old),
557
+ new_string: String(flags.new),
558
+ replace_all: flags.all === true,
559
+ dry_run: !apply,
560
+ }));
561
+ return;
562
+ }
563
+
564
+ if (sub === 'delete') {
565
+ const path = rest[0];
566
+ if (!path) die('usage: niranzwp file delete <path> [--recursive] [--apply] --yes');
567
+ out(flags, await call('delete-file', {
568
+ path,
569
+ recursive: flags.recursive === true,
570
+ dry_run: !apply,
571
+ }));
572
+ return;
573
+ }
574
+
575
+ if (sub === 'disable' || sub === 'enable') {
576
+ const path = rest[0];
577
+ if (!path) die(`usage: niranzwp file ${sub} <path> [--apply] --yes`);
578
+ out(flags, await call(`${sub}-file`, { path, dry_run: !apply }));
579
+ return;
580
+ }
581
+
582
+ die('usage: niranzwp file read|list|write|edit|delete|disable|enable');
583
+ }
584
+
428
585
  if (cmd === 'media') {
429
586
  const p = resolveProfile(flags);
430
587
 
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
 
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.5",
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": {