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 +185 -6
- package/lib/auth.js +66 -2
- package/lib/oauth.js +41 -1
- package/lib/store.js +27 -3
- package/lib/wp.js +59 -10
- package/package.json +1 -1
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('--')) {
|
|
117
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
73
|
-
'(SSH, a VPS, a container)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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':
|
|
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':
|
|
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
|
-
|
|
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':
|
|
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();
|