niranzwp 0.7.4 → 0.8.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/README.md CHANGED
@@ -40,17 +40,30 @@ UAE Stories -- UAE's first people centric magazine
40
40
  OAuth server: yes
41
41
  namespaces: 38
42
42
 
43
- $ niranzwp probe niranz.dev
44
- error [server_unreachable]: https://niranz.dev did not return a WordPress
45
- REST API (HTTP 200, got HTML). Either it is not WordPress, or /wp-json/
46
- is disabled or blocked.
43
+ $ niranzwp probe yoursite.com
44
+ Your Site -- just another WordPress site
45
+ https://yoursite.com
46
+ Tier 1 (app passwords): yes
47
+ Tier 2 (abilities): no
48
+ MCP endpoint: no
49
+ OAuth server: no
50
+ namespaces: 12
47
51
  ```
48
52
 
53
+ A site with Tier 1 only still gives you posts, pages, media, users and
54
+ settings. Tier 2 is what an abilities provider adds on top.
55
+
49
56
  Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
50
57
  abilities through the core API works — Novamira, your own plugin, anything else.
51
58
 
52
59
  ## Install
53
60
 
61
+ ```sh
62
+ curl -fsSL https://niranz.dev/install.sh | sh
63
+ ```
64
+
65
+ or, if you would rather not pipe a script to a shell:
66
+
54
67
  ```sh
55
68
  npm install -g niranzwp
56
69
  ```
@@ -64,16 +77,68 @@ Requires Node 22+. No runtime dependencies.
64
77
  WordPress redirects back to a temporary loopback listener with the credential.
65
78
  Nothing is typed or pasted, and no password crosses a third party.
66
79
 
67
- Credentials go to the **macOS Keychain** where available, otherwise to a
68
- `0600` file under `~/.config/niranzwp`. Profile metadata (site URL, username)
69
- lives in `~/.config/niranzwp/profiles.json`; passwords never do.
80
+ Where a site runs an OAuth server, `niranzwp` uses it instead — dynamic client
81
+ registration, PKCE, the device grant, and rotating refresh tokens.
82
+
83
+ Credentials go to the strongest store the machine offers:
70
84
 
71
- `niranzwp auth logout <profile>` removes the local copy. Revoke the password
72
- itself under **Users → Profile → Application Passwords** in WordPress.
85
+ | | |
86
+ |---|---|
87
+ | macOS | Keychain |
88
+ | Linux | libsecret — GNOME Keyring or KWallet, via `secret-tool` |
89
+ | Windows | DPAPI, scoped to your Windows account |
90
+ | otherwise | a `0600` file, and `niranzwp sites` tells you so |
91
+
92
+ No secret is ever passed as a command-line argument, because process arguments
93
+ are visible to `ps`.
94
+
95
+ Profile metadata (site URL, username) lives in
96
+ `~/.config/niranzwp/profiles.json`; secrets never do.
97
+
98
+ `niranzwp auth logout <profile>` revokes the credential on the site and removes
99
+ the local copy. Pass `--local` to keep it on the site.
100
+
101
+ ## Commands
102
+
103
+ ```
104
+ auth login|status|logout connect, inspect, disconnect
105
+ probe <url> what a site supports, before connecting
106
+ doctor check every connected site
107
+
108
+ post|page list|get|create|update|delete
109
+ media list [--missing-alt] | set-alt <id> "<text>"
110
+ user list
111
+ settings get|set <key> <value>
112
+
113
+ seo audit | missing <field>
114
+ geo check | llms-txt [--write]
115
+
116
+ discover | describe <ability> | run <ability> [--input '<json>']
117
+ mcp servers|tools|call
118
+ ```
119
+
120
+ Anything that writes needs `--yes`. Anything that reads does not.
121
+
122
+ `--json` on any command gives machine-readable output — including on failure,
123
+ so a pipeline can branch on the error rather than parse a message.
124
+
125
+ ## Tests
126
+
127
+ ```sh
128
+ NIRANZWP_TEST_SITE=<profile> npm test
129
+ ```
130
+
131
+ 51 tests. They run the real binary as a subprocess against a real WordPress
132
+ site and assert on exit codes and printed output, because that is where the
133
+ bugs were. Point them at a throwaway install: the write tests create and delete
134
+ their own content and restore any setting they touch.
73
135
 
74
136
  ## Status
75
137
 
76
- Early. Tier 1 auth, `probe`, `post list`, `discover`, and `run` work today.
138
+ Usable. Everything in the command list above works and is covered by tests.
139
+
140
+ Not done yet: no OAuth *server* — that lives in a companion plugin and is not
141
+ written, so MCP clients still authenticate with an application password.
77
142
 
78
143
  ## License
79
144
 
package/bin/niranzwp.js CHANGED
@@ -10,8 +10,53 @@ import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, des
10
10
  listItems, getItem, createItem, updateItem, deleteItem, getSettings, updateSettings, totalOf,
11
11
  introspectAppPassword, revokeAppPassword, WpError, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT } from '../lib/wp.js';
12
12
  import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ // Read the version from package.json rather than restating it here, where it
16
+ // had already drifted a release behind what npm was publishing.
17
+ const VERSION = JSON.parse(
18
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')
19
+ ).version;
20
+
21
+ /**
22
+ * Coerce a command-line string to the type the site already stores for that
23
+ * key. Everything arrives as a string from argv, and WordPress rejects a
24
+ * string where its schema wants an integer or a boolean.
25
+ */
26
+ function coerceLike(raw, sample) {
27
+ if (typeof sample === 'number') {
28
+ const n = Number(raw);
29
+ if (!Number.isFinite(n)) {
30
+ throw new CliError('invalid_input', `"${raw}" is not a number, and this setting holds one.`);
31
+ }
32
+ return n;
33
+ }
34
+ if (typeof sample === 'boolean') {
35
+ if (/^(true|1|yes|on)$/i.test(raw)) return true;
36
+ if (/^(false|0|no|off)$/i.test(raw)) return false;
37
+ throw new CliError('invalid_input', `"${raw}" is not a boolean. Use true or false.`);
38
+ }
39
+ return raw;
40
+ }
13
41
 
14
- const VERSION = '0.6.0';
42
+ /**
43
+ * WordPress stores option strings HTML-encoded, so a value written as
44
+ * "UAE's" reads back as "UAE&#039;s". Compare decoded, or every write looks
45
+ * like it was rejected and every re-write of the same value looks like a
46
+ * no-op failure.
47
+ */
48
+ function decodeEntities(v) {
49
+ if (typeof v !== 'string') return v;
50
+ return v
51
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
52
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
53
+ .replace(/&quot;/g, '"')
54
+ .replace(/&lt;/g, '<')
55
+ .replace(/&gt;/g, '>')
56
+ .replace(/&amp;/g, '&');
57
+ }
58
+
59
+ const same = (a, b) => JSON.stringify(decodeEntities(a)) === JSON.stringify(decodeEntities(b));
15
60
 
16
61
  const USAGE = `NiranzWP CLI v${VERSION} -- a CLI for WordPress
17
62
  Built by Niranjan -- https://niranz.dev
@@ -78,7 +123,9 @@ function parseArgs(argv) {
78
123
  }
79
124
 
80
125
  function die(msg, code = 1) {
81
- console.error(`error: ${msg}`);
126
+ // Usage errors go through here too, and they must respect --json for the
127
+ // same reason the top-level handler does.
128
+ console.error(process.argv.includes('--json') ? JSON.stringify({ error: 'usage_error', message: msg, hint: null }) : `error: ${msg}`);
82
129
  process.exit(code);
83
130
  }
84
131
 
@@ -170,8 +217,19 @@ async function main() {
170
217
  }
171
218
 
172
219
  if (cmd === 'auth' && sub === 'status') {
173
- const all = listProfiles();
220
+ let all = listProfiles();
174
221
  if (!all.length) { console.log('No sites connected.'); return; }
222
+ // --site was accepted and then ignored here, unlike every other
223
+ // command, so "auth status --site x" quietly reported on all of them.
224
+ if (typeof flags.site === 'string') {
225
+ const only = all.filter((p) => p.name === flags.site);
226
+ if (!only.length) {
227
+ throw new CliError('not_found', `No connected site named "${flags.site}".`, {
228
+ hint: `Known: ${all.map((p) => p.name).join(', ')}`,
229
+ });
230
+ }
231
+ all = only;
232
+ }
175
233
  for (const p of all) {
176
234
  const full = getProfile(p.name);
177
235
  let who = 'credential missing';
@@ -243,7 +301,7 @@ async function main() {
243
301
  if (cmd === 'probe') {
244
302
  const url = sub;
245
303
  if (!url) die('usage: niranzwp probe <url>');
246
- const info = await probe(normalizeSite(url));
304
+ const info = await probe(normalizeSite(url), { timeout: Number(flags.timeout) || undefined });
247
305
  out(flags, info, (i) => {
248
306
  console.log(`${i.name} -- ${i.description || ''}`);
249
307
  console.log(` ${i.home}`);
@@ -367,13 +425,20 @@ async function main() {
367
425
 
368
426
  if (sub === 'set-alt') {
369
427
  const [id, ...words] = rest;
428
+ // Same as settings: "" clears wrong alt text, which the old
429
+ // truthiness check made unreachable.
430
+ if (!id || words.length === 0) die('usage: niranzwp media set-alt <id> "<alt text>" (pass "" to clear)');
370
431
  const alt = words.join(' ');
371
- if (!id || !alt) die('usage: niranzwp media set-alt <id> "<alt text>"');
372
432
  if (flags.yes !== true) {
373
433
  throw new CliError('approval_required', `This sets alt text on media ${id} at ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
374
434
  }
375
435
  const r = await updateItem(p, 'media', id, { alt_text: alt }, reqOpts(flags));
376
- console.log(`media ${r.id} alt set to: ${r.alt_text}`);
436
+ if ((r.alt_text ?? '') !== alt) {
437
+ throw new CliError('write_rejected', `${p.siteUrl} accepted the request but media ${id} alt is ${JSON.stringify(r.alt_text ?? '')}.`, {
438
+ hint: 'An SEO or media plugin may be filtering alt text on save.',
439
+ });
440
+ }
441
+ console.log(alt === '' ? `media ${r.id} alt cleared` : `media ${r.id} alt set to: ${r.alt_text}`);
377
442
  return;
378
443
  }
379
444
 
@@ -410,14 +475,39 @@ async function main() {
410
475
 
411
476
  if (sub === 'set') {
412
477
  const [key, ...words] = rest;
413
- const value = words.join(' ');
414
- if (!key || !value) die('usage: niranzwp settings set <key> <value>');
478
+ // words.length rather than a truthiness check: "" is a legitimate
479
+ // value, and refusing it made it impossible to clear a setting.
480
+ if (!key || words.length === 0) die('usage: niranzwp settings set <key> <value> (pass "" to clear)');
481
+
482
+ const current = await getSettings(p, reqOpts(flags));
483
+ if (!(key in current)) {
484
+ const near = Object.keys(current).filter((k) => k.includes(key) || key.includes(k)).slice(0, 3);
485
+ throw new CliError('invalid_input', `"${key}" is not a setting exposed by ${p.siteUrl}.`, {
486
+ hint: near.length
487
+ ? `Did you mean ${near.map((k) => `"${k}"`).join(' or ')}? Run "niranzwp settings get" for the full list.`
488
+ : 'Run "niranzwp settings get" for the writable keys. WordPress ignores unknown keys silently, so this would otherwise have looked like it worked.',
489
+ });
490
+ }
491
+
415
492
  if (flags.yes !== true) {
416
493
  throw new CliError('approval_required', `This changes "${key}" on ${p.siteUrl}.`, { hint: 'Re-run with --yes.' });
417
494
  }
418
- const before = (await getSettings(p, reqOpts(flags)))[key];
419
- const after = await updateSettings(p, { [key]: value }, reqOpts(flags));
420
- console.log(`${key}\n before: ${JSON.stringify(before)}\n after: ${JSON.stringify(after[key])}`);
495
+
496
+ const before = current[key];
497
+ const wanted = coerceLike(words.join(' '), before);
498
+ const after = (await updateSettings(p, { [key]: wanted }, reqOpts(flags)))[key];
499
+
500
+ // A 200 does not mean the value landed. What matters is whether it
501
+ // moved at all -- WordPress sanitises on save (an apostrophe comes
502
+ // back as &#039;), so an exact match is the wrong test.
503
+ if (same(after, before) && !same(wanted, before)) {
504
+ throw new CliError('write_rejected', `${p.siteUrl} accepted the request but "${key}" is still ${JSON.stringify(after)}.`, {
505
+ hint: 'The setting may be filtered, read-only, or locked by a plugin or wp-config constant.',
506
+ });
507
+ }
508
+
509
+ console.log(`${key}\n before: ${JSON.stringify(before)}\n after: ${JSON.stringify(after)}`);
510
+ if (!same(after, wanted)) console.log(' note: the site sanitised the value on save');
421
511
  return;
422
512
  }
423
513
 
@@ -692,14 +782,34 @@ async function main() {
692
782
  process.exit(1);
693
783
  }
694
784
 
785
+ // --json is a contract: everything this process writes must be parseable,
786
+ // failures included. Printing "error [x]: ..." under --json broke any script
787
+ // that piped the output to jq.
788
+ const wantsJson = process.argv.includes('--json');
789
+
695
790
  main().catch((e) => {
696
791
  if (e instanceof CliError) {
697
- console.error(`error [${e.code}]: ${e.message}`);
698
- if (e.hint) console.error(`hint: ${e.hint}`);
792
+ if (wantsJson) {
793
+ console.error(JSON.stringify(e.toJSON()));
794
+ } else {
795
+ console.error(`error [${e.code}]: ${e.message}`);
796
+ if (e.hint) console.error(`hint: ${e.hint}`);
797
+ }
699
798
  process.exit(e.exitCode);
700
799
  }
800
+
701
801
  if (e instanceof WpError) {
702
- die(`${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`);
802
+ const message = `${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`;
803
+ if (wantsJson) {
804
+ console.error(JSON.stringify({ error: e.code ?? 'wp_error', message: e.message, status: e.status ?? null }));
805
+ process.exit(2);
806
+ }
807
+ die(message);
808
+ }
809
+
810
+ if (wantsJson) {
811
+ console.error(JSON.stringify({ error: 'unexpected', message: e.message, hint: null }));
812
+ process.exit(1);
703
813
  }
704
814
  die(e.message);
705
815
  });
package/lib/errors.js CHANGED
@@ -16,6 +16,11 @@ export const EXIT = {
16
16
  export const CODES = {
17
17
  usage_error: EXIT.GENERIC,
18
18
  not_found: EXIT.GENERIC,
19
+ invalid_input: EXIT.GENERIC,
20
+ // The site returned 200 but the change did not stick. WordPress drops
21
+ // unknown or unwritable keys without complaining, so this has to be caught
22
+ // by reading the value back rather than by trusting the status code.
23
+ write_rejected: EXIT.GENERIC,
19
24
 
20
25
  server_unreachable: EXIT.SERVER,
21
26
  not_wordpress: EXIT.SERVER,
package/lib/http.js ADDED
@@ -0,0 +1,27 @@
1
+ import { CliError } from './errors.js';
2
+
3
+ export const DEFAULT_TIMEOUT_MS = 30_000;
4
+
5
+ /**
6
+ * fetch() with a deadline.
7
+ *
8
+ * Node's fetch has no default timeout, so a site that accepts the connection
9
+ * and then stops responding hangs the CLI forever. Every network call goes
10
+ * through here.
11
+ */
12
+ export async function fetchWithTimeout(url, options = {}, timeout = DEFAULT_TIMEOUT_MS) {
13
+ const controller = new AbortController();
14
+ const timer = setTimeout(() => controller.abort(), timeout);
15
+ try {
16
+ return await fetch(url, { ...options, signal: controller.signal });
17
+ } catch (e) {
18
+ if ('AbortError' === e.name) {
19
+ throw new CliError('timeout', `${url} did not respond within ${timeout}ms.`, {
20
+ hint: 'Raise it with --timeout <ms>, or check whether the host is up.',
21
+ });
22
+ }
23
+ throw e;
24
+ } finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
package/lib/mcp.js CHANGED
@@ -8,13 +8,14 @@
8
8
  // scoped to an MCP endpoint rather than the core REST run route.
9
9
 
10
10
  import { CliError } from './errors.js';
11
+ import { fetchWithTimeout } from './http.js';
11
12
 
12
13
  const PROTOCOL_VERSION = '2025-06-18';
13
14
  const UA = 'niranzwp';
14
15
 
15
16
  /** List MCP servers a WordPress site exposes. Unauthenticated. */
16
17
  export async function listServers(siteUrl) {
17
- const res = await fetch(`${siteUrl}/wp-json/mcp`, {
18
+ const res = await fetchWithTimeout(`${siteUrl}/wp-json/mcp`, {
18
19
  headers: { Accept: 'application/json', 'User-Agent': UA },
19
20
  });
20
21
  if (!res.ok) return [];
package/lib/oauth.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { createHash, randomBytes } from 'node:crypto';
12
12
  import { WpError } from './wp.js';
13
+ import { fetchWithTimeout } from './http.js';
13
14
 
14
15
  const UA = 'niranzwp';
15
16
 
@@ -31,7 +32,7 @@ async function postForm(url, params, { timeout = 30_000 } = {}) {
31
32
  const controller = new AbortController();
32
33
  const timer = setTimeout(() => controller.abort(), timeout);
33
34
  try {
34
- const res = await fetch(url, {
35
+ const res = await fetchWithTimeout(url, {
35
36
  method: 'POST',
36
37
  headers: {
37
38
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -54,7 +55,7 @@ async function postJson(url, body, { timeout = 30_000 } = {}) {
54
55
  const controller = new AbortController();
55
56
  const timer = setTimeout(() => controller.abort(), timeout);
56
57
  try {
57
- const res = await fetch(url, {
58
+ const res = await fetchWithTimeout(url, {
58
59
  method: 'POST',
59
60
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA },
60
61
  body: JSON.stringify(body),
@@ -72,7 +73,7 @@ async function postJson(url, body, { timeout = 30_000 } = {}) {
72
73
  /** Fetch the authorization server metadata, or null if the site has none. */
73
74
  export async function discover(siteUrl) {
74
75
  try {
75
- const res = await fetch(`${siteUrl}/.well-known/oauth-authorization-server`, {
76
+ const res = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-authorization-server`, {
76
77
  headers: { Accept: 'application/json', 'User-Agent': UA },
77
78
  });
78
79
  if (!res.ok) return null;
package/lib/store.js CHANGED
@@ -4,7 +4,11 @@ import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  // Profiles (which sites exist, who we are on each) live in a plain JSON file.
7
- // Passwords never do -- they go to the OS keychain where available.
7
+ // Secrets never do -- they go to the OS credential store where there is one.
8
+ //
9
+ // No secret is ever passed as a command-line argument. Process arguments are
10
+ // visible to `ps`, so a password handed to the keychain helper on argv is
11
+ // readable by anything that looks at the process list while it runs.
8
12
 
9
13
  const DIR = join(homedir(), '.config', 'niranzwp');
10
14
  const PROFILES = join(DIR, 'profiles.json');
@@ -24,40 +28,168 @@ function writeProfiles(all) {
24
28
  writeFileSync(PROFILES, JSON.stringify(all, null, 2), { mode: 0o600 });
25
29
  }
26
30
 
27
- const useKeychain = () => platform() === 'darwin';
28
-
29
- function keychainSet(account, secret) {
30
- execFileSync('security', [
31
- 'add-generic-password',
32
- '-a', account,
33
- '-s', SERVICE,
34
- '-w', secret,
35
- '-U', // update if it already exists
36
- ]);
37
- }
31
+ const account = (name) => `${SERVICE}:${name}`;
32
+ const secretsPath = () => join(DIR, 'secrets.json');
38
33
 
39
- function keychainGet(account) {
34
+ function has(bin, args = ['--version']) {
40
35
  try {
41
- return execFileSync('security', [
42
- 'find-generic-password', '-a', account, '-s', SERVICE, '-w',
43
- ], { encoding: 'utf8' }).trim();
44
- } catch {
45
- return null;
36
+ execFileSync(bin, args, { stdio: 'ignore' });
37
+ return true;
38
+ } catch (e) {
39
+ // A binary that exists but exits non-zero still counts as present;
40
+ // only "not found" means the backend is unavailable.
41
+ return 'ENOENT' !== e.code;
46
42
  }
47
43
  }
48
44
 
49
- function keychainDelete(account) {
50
- try {
51
- execFileSync('security', [
52
- 'delete-generic-password', '-a', account, '-s', SERVICE,
53
- ], { stdio: 'ignore' });
54
- } catch { /* nothing stored */ }
55
- }
45
+ /* ------------------------------------------------------------------ macOS */
56
46
 
57
- // Fallback for non-macOS: a 0600 file. Less safe than a keychain, so we say so.
58
- const secretsPath = () => join(DIR, 'secrets.json');
47
+ // `security -w` prints a hex dump instead of the value whenever the stored
48
+ // password is not plain ASCII, which silently corrupted any secret containing
49
+ // non-Latin characters. Storing base64 keeps what goes in ASCII, so what comes
50
+ // back is always the value. The tag distinguishes new entries from ones
51
+ // written before this, which are still plain.
52
+ const B64 = 'b64:';
53
+
54
+ /**
55
+ * `security -i` reads its commands from stdin, which is the only way to hand
56
+ * it a password without putting the password in argv.
57
+ */
58
+ const macKeychain = {
59
+ name: 'macOS Keychain',
60
+ available: () => 'darwin' === platform() && has('security', ['-h']),
61
+
62
+ set(acct, secret) {
63
+ const payload = B64 + Buffer.from(String(secret), 'utf8').toString('base64');
64
+ execFileSync('security', ['-i'], {
65
+ input: `add-generic-password -a "${acct}" -s "${SERVICE}" -w "${payload}" -U\n`,
66
+ stdio: ['pipe', 'ignore', 'pipe'],
67
+ });
68
+ },
69
+
70
+ get(acct) {
71
+ let raw;
72
+ try {
73
+ raw = execFileSync('security', ['find-generic-password', '-a', acct, '-s', SERVICE, '-w'], {
74
+ encoding: 'utf8',
75
+ }).trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ if (raw.startsWith(B64)) {
80
+ return Buffer.from(raw.slice(B64.length), 'base64').toString('utf8');
81
+ }
82
+ // Written before the base64 tag. If security hex-dumped it, decode
83
+ // that; otherwise it is the value as stored.
84
+ if (/^([0-9a-f]{2})+$/.test(raw) && raw.length >= 8) {
85
+ return Buffer.from(raw, 'hex').toString('utf8');
86
+ }
87
+ return raw;
88
+ },
89
+
90
+ del(acct) {
91
+ try {
92
+ execFileSync('security', ['delete-generic-password', '-a', acct, '-s', SERVICE], { stdio: 'ignore' });
93
+ } catch { /* nothing stored */ }
94
+ },
95
+ };
96
+
97
+ /* ------------------------------------------------------------------ Linux */
98
+
99
+ /**
100
+ * libsecret's secret-tool, which fronts GNOME Keyring and KWallet. It reads
101
+ * the secret from stdin by design, so nothing lands in argv.
102
+ */
103
+ const libsecret = {
104
+ name: 'libsecret (GNOME Keyring / KWallet)',
105
+ available: () => 'linux' === platform() && has('secret-tool', ['--version']),
106
+
107
+ set(acct, secret) {
108
+ execFileSync('secret-tool', ['store', '--label', `niranzwp: ${acct}`, 'service', SERVICE, 'account', acct], {
109
+ input: secret,
110
+ stdio: ['pipe', 'ignore', 'pipe'],
111
+ });
112
+ },
113
+
114
+ get(acct) {
115
+ try {
116
+ const out = execFileSync('secret-tool', ['lookup', 'service', SERVICE, 'account', acct], { encoding: 'utf8' });
117
+ // secret-tool returns the value with no trailing newline of its
118
+ // own, but a stored value may legitimately end in whitespace.
119
+ return '' === out ? null : out;
120
+ } catch {
121
+ return null;
122
+ }
123
+ },
59
124
 
60
- function fileSecrets() {
125
+ del(acct) {
126
+ try {
127
+ execFileSync('secret-tool', ['clear', 'service', SERVICE, 'account', acct], { stdio: 'ignore' });
128
+ } catch { /* nothing stored */ }
129
+ },
130
+ };
131
+
132
+ /* ---------------------------------------------------------------- Windows */
133
+
134
+ /**
135
+ * DPAPI through PowerShell. The ciphertext is bound to the Windows user
136
+ * account, so the file alone is useless to anyone else on the machine.
137
+ *
138
+ * Windows Credential Manager would be the closer analogue, but `cmdkey`
139
+ * cannot read a stored password back out, and anything that can needs a
140
+ * PowerShell module that is not installed by default.
141
+ */
142
+ const dpapi = {
143
+ name: 'Windows DPAPI (user-scoped)',
144
+ available: () => 'win32' === platform() && has('powershell', ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major']),
145
+
146
+ // The secret travels through an environment variable rather than argv,
147
+ // for the same reason macOS uses stdin.
148
+ run(script, secret) {
149
+ return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
150
+ encoding: 'utf8',
151
+ env: { ...process.env, NIRANZWP_SECRET: secret ?? '' },
152
+ }).trim();
153
+ },
154
+
155
+ set(acct, secret) {
156
+ const b64 = this.run(
157
+ 'Add-Type -AssemblyName System.Security;' +
158
+ '$b=[Text.Encoding]::UTF8.GetBytes($env:NIRANZWP_SECRET);' +
159
+ "$e=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser');" +
160
+ '[Convert]::ToBase64String($e)',
161
+ secret
162
+ );
163
+ const all = fileBlobs();
164
+ all[acct] = b64;
165
+ writeBlobs(all);
166
+ },
167
+
168
+ get(acct) {
169
+ const b64 = fileBlobs()[acct];
170
+ if (!b64) return null;
171
+ try {
172
+ return this.run(
173
+ 'Add-Type -AssemblyName System.Security;' +
174
+ `$e=[Convert]::FromBase64String('${b64.replace(/'/g, "''")}');` +
175
+ "$b=[Security.Cryptography.ProtectedData]::Unprotect($e,$null,'CurrentUser');" +
176
+ '[Text.Encoding]::UTF8.GetString($b)'
177
+ );
178
+ } catch {
179
+ return null;
180
+ }
181
+ },
182
+
183
+ del(acct) {
184
+ const all = fileBlobs();
185
+ delete all[acct];
186
+ writeBlobs(all);
187
+ },
188
+ };
189
+
190
+ /* --------------------------------------------------------- plain fallback */
191
+
192
+ function fileBlobs() {
61
193
  if (!existsSync(secretsPath())) return {};
62
194
  try {
63
195
  return JSON.parse(readFileSync(secretsPath(), 'utf8'));
@@ -66,26 +198,54 @@ function fileSecrets() {
66
198
  }
67
199
  }
68
200
 
69
- function account(name) {
70
- return `${SERVICE}:${name}`;
201
+ function writeBlobs(all) {
202
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
203
+ writeFileSync(secretsPath(), JSON.stringify(all), { mode: 0o600 });
71
204
  }
72
205
 
73
- function putSecret(name, value) {
74
- if (useKeychain()) {
75
- keychainSet(account(name), value);
76
- return;
206
+ /** Last resort: a 0600 file. Weaker than any keychain, and `storageKind` says so. */
207
+ const plainFile = {
208
+ name: `file (${secretsPath()}, mode 0600)`,
209
+ available: () => true,
210
+ set(acct, secret) {
211
+ const all = fileBlobs();
212
+ all[acct] = secret;
213
+ writeBlobs(all);
214
+ },
215
+ get(acct) {
216
+ return fileBlobs()[acct] ?? null;
217
+ },
218
+ del(acct) {
219
+ const all = fileBlobs();
220
+ delete all[acct];
221
+ writeBlobs(all);
222
+ },
223
+ };
224
+
225
+ let cached = null;
226
+
227
+ /** Pick the strongest backend this machine actually offers. */
228
+ function backend() {
229
+ if (cached) return cached;
230
+ for (const b of [macKeychain, libsecret, dpapi, plainFile]) {
231
+ try {
232
+ if (b.available()) {
233
+ cached = b;
234
+ return b;
235
+ }
236
+ } catch { /* try the next one */ }
77
237
  }
78
- const s = fileSecrets();
79
- s[name] = value;
80
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
81
- writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
238
+ cached = plainFile;
239
+ return cached;
82
240
  }
83
241
 
242
+ /* -------------------------------------------------------------- profiles */
243
+
84
244
  export function saveProfile(name, { siteUrl, user, password }) {
85
245
  const all = readProfiles();
86
246
  all[name] = { siteUrl, user, auth: 'app-password', createdAt: new Date().toISOString() };
87
247
  writeProfiles(all);
88
- putSecret(name, password);
248
+ backend().set(account(name), password);
89
249
  }
90
250
 
91
251
  /**
@@ -104,7 +264,7 @@ export function saveOAuthProfile(name, { siteUrl, user, clientId, tokens }) {
104
264
  createdAt: all[name]?.createdAt ?? new Date().toISOString(),
105
265
  };
106
266
  writeProfiles(all);
107
- putSecret(name, JSON.stringify(tokens));
267
+ backend().set(account(name), JSON.stringify(tokens));
108
268
  }
109
269
 
110
270
  /** Replace just the token pair after a refresh, keeping profile metadata. */
@@ -114,19 +274,29 @@ export function updateTokens(name, tokens) {
114
274
  all[name].expiresAt = tokens.expiresAt;
115
275
  writeProfiles(all);
116
276
  }
117
- putSecret(name, JSON.stringify(tokens));
277
+ backend().set(account(name), JSON.stringify(tokens));
118
278
  }
119
279
 
120
280
  export function getProfile(name) {
121
281
  const p = readProfiles()[name];
122
282
  if (!p) return null;
123
- const secret = useKeychain() ? keychainGet(account(name)) : fileSecrets()[name];
124
- if (!secret) return null;
283
+
284
+ let secret = backend().get(account(name));
285
+
286
+ // Profiles written before the backend was pluggable keyed the fallback
287
+ // file by bare profile name. Read those, and migrate on the next write.
288
+ if (null === secret) {
289
+ const legacy = fileBlobs()[name];
290
+ if (undefined === legacy) return null;
291
+ secret = legacy;
292
+ }
125
293
 
126
294
  if ('oauth' === p.auth) {
127
- let tokens = null;
128
- try { tokens = JSON.parse(secret); } catch { return null; }
129
- return { name, ...p, tokens };
295
+ try {
296
+ return { name, ...p, tokens: JSON.parse(secret) };
297
+ } catch {
298
+ return null;
299
+ }
130
300
  }
131
301
  return { name, ...p, password: secret };
132
302
  }
@@ -140,17 +310,18 @@ export function deleteProfile(name) {
140
310
  delete all[name];
141
311
  writeProfiles(all);
142
312
 
143
- if (useKeychain()) {
144
- keychainDelete(account(name));
145
- } else {
146
- const s = fileSecrets();
147
- delete s[name];
148
- if (existsSync(secretsPath())) writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
313
+ backend().del(account(name));
314
+
315
+ // Clear any legacy entry too, so logout really removes the credential.
316
+ const blobs = fileBlobs();
317
+ if (name in blobs) {
318
+ delete blobs[name];
319
+ writeBlobs(blobs);
149
320
  }
150
321
  }
151
322
 
152
323
  export function storageKind() {
153
- return useKeychain() ? 'macOS Keychain' : `file (${secretsPath()}, mode 0600)`;
324
+ return backend().name;
154
325
  }
155
326
 
156
327
  export function configDir() {
package/lib/wp.js CHANGED
@@ -1,4 +1,5 @@
1
- import { CliError, fromRest } from './errors.js';
1
+ import { CliError, fromRest } from "./errors.js";
2
+ import { fetchWithTimeout } from "./http.js";
2
3
  import { discover, refresh as refreshTokens, isExpired } from './oauth.js';
3
4
  import { updateTokens } from './store.js';
4
5
  import * as cache from './cache.js';
@@ -74,7 +75,13 @@ export async function request(profile, path, {
74
75
  raw = false,
75
76
  timeout = DEFAULT_TIMEOUT_MS,
76
77
  maxOutput = DEFAULT_MAX_OUTPUT,
78
+ _retried = false,
77
79
  } = {}) {
80
+ // Rotate an expiring OAuth token before spending it. This used to be
81
+ // written and never called, so every OAuth profile simply started failing
82
+ // with 401 once its access token aged out.
83
+ profile = await ensureFresh(profile);
84
+
78
85
  const url = new URL(path.startsWith('http') ? path : `${profile.siteUrl}/wp-json${path}`);
79
86
  for (const [k, v] of Object.entries(query || {})) {
80
87
  if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
@@ -117,6 +124,16 @@ export async function request(profile, path, {
117
124
  const data = await parse(res, maxOutput);
118
125
 
119
126
  if (!res.ok) {
127
+ // A token can be revoked server-side before it expires on the clock, so
128
+ // isExpired() will not have caught it. Refresh once and retry.
129
+ if (401 === res.status && 'oauth' === profile.auth && profile.tokens?.refreshToken && !_retried) {
130
+ const meta = await discover(profile.siteUrl);
131
+ if (meta) {
132
+ const tokens = await refreshTokens(meta, profile.clientId, profile.tokens.refreshToken);
133
+ updateTokens(profile.name, tokens);
134
+ return request({ ...profile, tokens }, path, { method, body, query, raw, timeout, maxOutput, _retried: true });
135
+ }
136
+ }
120
137
  throw fromRest(res.status, data);
121
138
  }
122
139
 
@@ -124,12 +141,12 @@ export async function request(profile, path, {
124
141
  }
125
142
 
126
143
  // Unauthenticated probe: what does this site actually support?
127
- export async function probe(siteUrl) {
144
+ export async function probe(siteUrl, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
128
145
  let res;
129
146
  try {
130
- res = await fetch(`${siteUrl}/wp-json/`, {
147
+ res = await fetchWithTimeout(`${siteUrl}/wp-json/`, {
131
148
  headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
132
- });
149
+ }, timeout);
133
150
  } catch (e) {
134
151
  throw reachError(siteUrl, e);
135
152
  }
@@ -162,9 +179,9 @@ export async function probe(siteUrl) {
162
179
 
163
180
  let oauth = null;
164
181
  try {
165
- const r = await fetch(`${siteUrl}/.well-known/oauth-authorization-server`, {
182
+ const r = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-authorization-server`, {
166
183
  headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
167
- });
184
+ }, timeout);
168
185
  if (r.ok) oauth = await r.json();
169
186
  } catch { /* not every site has one */ }
170
187
 
@@ -279,7 +296,7 @@ export function isReadOnly(ability) {
279
296
  */
280
297
  export async function contract(siteUrl, endpoint = '/wp-json/mcp/novamira-oauth') {
281
298
  try {
282
- const res = await fetch(`${siteUrl}/.well-known/oauth-protected-resource${endpoint}`, {
299
+ const res = await fetchWithTimeout(`${siteUrl}/.well-known/oauth-protected-resource${endpoint}`, {
283
300
  headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
284
301
  });
285
302
  if (!res.ok) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niranzwp",
3
- "version": "0.7.4",
3
+ "version": "0.8.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": {
@@ -28,6 +28,9 @@
28
28
  "seo",
29
29
  "site-management"
30
30
  ],
31
+ "scripts": {
32
+ "test": "node --test test/*.test.js"
33
+ },
31
34
  "engines": {
32
35
  "node": ">=22"
33
36
  },