niranzwp 0.7.3 → 0.8.0
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 +70 -10
- package/bin/niranzwp.js +124 -14
- package/lib/errors.js +5 -0
- package/lib/http.js +27 -0
- package/lib/mcp.js +2 -1
- package/lib/oauth.js +4 -3
- package/lib/store.js +224 -53
- package/lib/wp.js +24 -7
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -36,12 +36,14 @@ UAE Stories -- UAE's first people centric magazine
|
|
|
36
36
|
https://uaestories.com
|
|
37
37
|
Tier 1 (app passwords): yes
|
|
38
38
|
Tier 2 (abilities): yes
|
|
39
|
+
MCP endpoint: yes
|
|
40
|
+
OAuth server: yes
|
|
39
41
|
namespaces: 38
|
|
40
42
|
|
|
41
|
-
$ niranzwp probe
|
|
42
|
-
error [
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
$ niranzwp probe example.com
|
|
44
|
+
error [not_wordpress]: https://example.com did not return a WordPress REST
|
|
45
|
+
API (HTTP 200, got HTML). Either it is not WordPress, or /wp-json/ is
|
|
46
|
+
disabled or blocked.
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
|
|
@@ -49,6 +51,12 @@ abilities through the core API works — Novamira, your own plugin, anything els
|
|
|
49
51
|
|
|
50
52
|
## Install
|
|
51
53
|
|
|
54
|
+
```sh
|
|
55
|
+
curl -fsSL https://niranz.dev/install.sh | sh
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
or, if you would rather not pipe a script to a shell:
|
|
59
|
+
|
|
52
60
|
```sh
|
|
53
61
|
npm install -g niranzwp
|
|
54
62
|
```
|
|
@@ -62,16 +70,68 @@ Requires Node 22+. No runtime dependencies.
|
|
|
62
70
|
WordPress redirects back to a temporary loopback listener with the credential.
|
|
63
71
|
Nothing is typed or pasted, and no password crosses a third party.
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
Where a site runs an OAuth server, `niranzwp` uses it instead — dynamic client
|
|
74
|
+
registration, PKCE, the device grant, and rotating refresh tokens.
|
|
75
|
+
|
|
76
|
+
Credentials go to the strongest store the machine offers:
|
|
77
|
+
|
|
78
|
+
| | |
|
|
79
|
+
|---|---|
|
|
80
|
+
| macOS | Keychain |
|
|
81
|
+
| Linux | libsecret — GNOME Keyring or KWallet, via `secret-tool` |
|
|
82
|
+
| Windows | DPAPI, scoped to your Windows account |
|
|
83
|
+
| otherwise | a `0600` file, and `niranzwp sites` tells you so |
|
|
84
|
+
|
|
85
|
+
No secret is ever passed as a command-line argument, because process arguments
|
|
86
|
+
are visible to `ps`.
|
|
87
|
+
|
|
88
|
+
Profile metadata (site URL, username) lives in
|
|
89
|
+
`~/.config/niranzwp/profiles.json`; secrets never do.
|
|
90
|
+
|
|
91
|
+
`niranzwp auth logout <profile>` revokes the credential on the site and removes
|
|
92
|
+
the local copy. Pass `--local` to keep it on the site.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
auth login|status|logout connect, inspect, disconnect
|
|
98
|
+
probe <url> what a site supports, before connecting
|
|
99
|
+
doctor check every connected site
|
|
68
100
|
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
post|page list|get|create|update|delete
|
|
102
|
+
media list [--missing-alt] | set-alt <id> "<text>"
|
|
103
|
+
user list
|
|
104
|
+
settings get|set <key> <value>
|
|
105
|
+
|
|
106
|
+
seo audit | missing <field>
|
|
107
|
+
geo check | llms-txt [--write]
|
|
108
|
+
|
|
109
|
+
discover | describe <ability> | run <ability> [--input '<json>']
|
|
110
|
+
mcp servers|tools|call
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Anything that writes needs `--yes`. Anything that reads does not.
|
|
114
|
+
|
|
115
|
+
`--json` on any command gives machine-readable output — including on failure,
|
|
116
|
+
so a pipeline can branch on the error rather than parse a message.
|
|
117
|
+
|
|
118
|
+
## Tests
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
NIRANZWP_TEST_SITE=<profile> npm test
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
51 tests. They run the real binary as a subprocess against a real WordPress
|
|
125
|
+
site and assert on exit codes and printed output, because that is where the
|
|
126
|
+
bugs were. Point them at a throwaway install: the write tests create and delete
|
|
127
|
+
their own content and restore any setting they touch.
|
|
71
128
|
|
|
72
129
|
## Status
|
|
73
130
|
|
|
74
|
-
|
|
131
|
+
Usable. Everything in the command list above works and is covered by tests.
|
|
132
|
+
|
|
133
|
+
Not done yet: no OAuth *server* — that lives in a companion plugin and is not
|
|
134
|
+
written, so MCP clients still authenticate with an application password.
|
|
75
135
|
|
|
76
136
|
## License
|
|
77
137
|
|
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
|
-
|
|
42
|
+
/**
|
|
43
|
+
* WordPress stores option strings HTML-encoded, so a value written as
|
|
44
|
+
* "UAE's" reads back as "UAE'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(/"/g, '"')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/&/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
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 '), 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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
34
|
+
function has(bin, args = ['--version']) {
|
|
40
35
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
201
|
+
function writeBlobs(all) {
|
|
202
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
203
|
+
writeFileSync(secretsPath(), JSON.stringify(all), { mode: 0o600 });
|
|
71
204
|
}
|
|
72
205
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.8.0",
|
|
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
|
},
|