niranzwp 0.8.1 → 0.8.3
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 +3 -3
- package/bin/niranzwp.js +21 -3
- package/lib/auth.js +88 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,9 +31,9 @@ client sites, you usually do not have that.
|
|
|
31
31
|
`niranzwp probe` reports which tiers a site offers before you connect.
|
|
32
32
|
|
|
33
33
|
```
|
|
34
|
-
$ niranzwp probe
|
|
35
|
-
|
|
36
|
-
https://
|
|
34
|
+
$ niranzwp probe mysite.com
|
|
35
|
+
My Site -- a WordPress magazine
|
|
36
|
+
https://mysite.com
|
|
37
37
|
Tier 1 (app passwords): yes
|
|
38
38
|
Tier 2 (abilities): yes
|
|
39
39
|
MCP endpoint: yes
|
package/bin/niranzwp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { loginWithAppPassword } from '../lib/auth.js';
|
|
2
|
+
import { loginWithAppPassword, loginManual } 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';
|
|
@@ -61,7 +61,7 @@ const same = (a, b) => JSON.stringify(decodeEntities(a)) === JSON.stringify(deco
|
|
|
61
61
|
const USAGE = `NiranzWP CLI v${VERSION} -- a CLI for WordPress
|
|
62
62
|
Built by Niranjan -- https://niranz.dev
|
|
63
63
|
|
|
64
|
-
niranzwp auth login <url> [--name <profile>] [--no-open] [--app-password]
|
|
64
|
+
niranzwp auth login <url> [--name <profile>] [--no-open] [--app-password] [--manual]
|
|
65
65
|
niranzwp auth status [--site <profile>]
|
|
66
66
|
niranzwp auth logout <profile> [--local] revoke on the site too, unless --local
|
|
67
67
|
|
|
@@ -205,9 +205,27 @@ async function main() {
|
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
// --manual skips the loopback listener entirely: WordPress shows the
|
|
209
|
+
// password on its own success page and the user carries it here. The
|
|
210
|
+
// only route that works when the browser cannot reach this machine --
|
|
211
|
+
// SSH, a VPS, a phone browser against a CLI running elsewhere.
|
|
212
|
+
const creds = flags.manual === true
|
|
213
|
+
? await loginManual(site, { open: flags.open !== false })
|
|
214
|
+
: await loginWithAppPassword(site, { open: flags.open !== false });
|
|
215
|
+
|
|
216
|
+
// A pasted credential can be mistyped, so prove it works before
|
|
217
|
+
// storing it. The loopback flow's credential came from WordPress
|
|
218
|
+
// directly and needs no such check, but it costs one request.
|
|
209
219
|
const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
|
|
210
220
|
saveProfile(name, creds);
|
|
221
|
+
try {
|
|
222
|
+
await whoami(getProfile(name));
|
|
223
|
+
} catch (e) {
|
|
224
|
+
deleteProfile(name);
|
|
225
|
+
throw new CliError('auth_denied', `The site rejected that credential: ${e.message}`, {
|
|
226
|
+
hint: 'Check the username, and paste the application password exactly as WordPress showed it (spaces are fine).',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
211
229
|
console.log(`Connected "${name}" -> ${creds.siteUrl} as ${creds.user}`);
|
|
212
230
|
console.log(`Credential stored in ${storageKind()}.`);
|
|
213
231
|
console.log(creds.info.tier2
|
package/lib/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { platform } from 'node:os';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
4
5
|
import { probe } from './wp.js';
|
|
5
6
|
|
|
6
7
|
// WordPress core ships an Application Password authorization screen at
|
|
@@ -67,7 +68,10 @@ export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI',
|
|
|
67
68
|
|
|
68
69
|
const timer = setTimeout(() => {
|
|
69
70
|
cleanup();
|
|
70
|
-
reject(new Error(
|
|
71
|
+
reject(new Error(
|
|
72
|
+
'Timed out waiting for authorization. If the browser is not on this machine ' +
|
|
73
|
+
'(SSH, a VPS, a container), it cannot reach this listener -- run again with --manual.'
|
|
74
|
+
));
|
|
71
75
|
}, timeoutMs);
|
|
72
76
|
|
|
73
77
|
function cleanup() {
|
|
@@ -92,3 +96,86 @@ export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI',
|
|
|
92
96
|
});
|
|
93
97
|
});
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The loopback flow's blind spot: the browser has to be able to reach this
|
|
102
|
+
* machine's 127.0.0.1, and over SSH, on a VPS or in a container it cannot.
|
|
103
|
+
*
|
|
104
|
+
* WordPress already handles this case itself. Sent to the authorization
|
|
105
|
+
* screen WITHOUT a success_url, it approves and then displays the generated
|
|
106
|
+
* password on its own success page -- server-side, in wp-admin, exactly the
|
|
107
|
+
* "connected" confirmation a person expects. The one manual step left is
|
|
108
|
+
* carrying that password back here, typed into a prompt that never echoes.
|
|
109
|
+
*/
|
|
110
|
+
export async function loginManual(siteUrl, { appName = 'NiranzWP CLI', open = true } = {}) {
|
|
111
|
+
const info = await probe(siteUrl);
|
|
112
|
+
|
|
113
|
+
if (!info.tier1 || !info.authorizeUrl) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'This site does not advertise Application Passwords. It needs WordPress 5.6+ over HTTPS, ' +
|
|
116
|
+
'or WP_ENVIRONMENT_TYPE set to "local". A security plugin may also be disabling them.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const authorize = new URL(info.authorizeUrl);
|
|
121
|
+
authorize.searchParams.set('app_name', appName);
|
|
122
|
+
|
|
123
|
+
console.error('Open this in any browser -- it does not have to be on this machine:');
|
|
124
|
+
console.error(` ${authorize}`);
|
|
125
|
+
console.error('');
|
|
126
|
+
console.error('Approve the connection. WordPress will show a password on screen;');
|
|
127
|
+
console.error('copy it and paste it below. It is shown exactly once.');
|
|
128
|
+
console.error('');
|
|
129
|
+
if (open) openBrowser(authorize.toString());
|
|
130
|
+
|
|
131
|
+
const { user, password } = await promptForCredentials();
|
|
132
|
+
|
|
133
|
+
if (!user.trim() || !password.trim()) {
|
|
134
|
+
throw new Error('A username and a password are both required.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { siteUrl: siteUrl.replace(/\/+$/, ''), user: user.trim(), password: password.trim(), info };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Both prompts on ONE readline interface.
|
|
142
|
+
*
|
|
143
|
+
* Two interfaces in sequence lose data on piped stdin: the first swallows the
|
|
144
|
+
* whole pipe buffer into its own state and closes, the second sees only EOF,
|
|
145
|
+
* its callback never fires, and the process exits 0 having done nothing --
|
|
146
|
+
* which is exactly how this failed in testing. EOF is also an answer a pipe
|
|
147
|
+
* can give, so it rejects loudly instead of hanging.
|
|
148
|
+
*/
|
|
149
|
+
function promptForCredentials() {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const isTTY = process.stdin.isTTY === true;
|
|
152
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: isTTY });
|
|
153
|
+
|
|
154
|
+
let done = false;
|
|
155
|
+
const finish = (fn, value) => {
|
|
156
|
+
if (done) return;
|
|
157
|
+
done = true;
|
|
158
|
+
rl.close();
|
|
159
|
+
fn(value);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
rl.on('close', () => finish(reject, new Error('Input ended before both answers were given.')));
|
|
163
|
+
rl.on('error', (e) => finish(reject, e));
|
|
164
|
+
|
|
165
|
+
rl.question('WordPress username: ', (user) => {
|
|
166
|
+
// Mute the echo for the password -- but only on a real terminal.
|
|
167
|
+
// On piped input there is no echo to hide, and the muting hook
|
|
168
|
+
// interferes with readline's buffering.
|
|
169
|
+
if (isTTY) {
|
|
170
|
+
process.stderr.write('Application password: ');
|
|
171
|
+
const write = rl._writeToOutput?.bind(rl);
|
|
172
|
+
rl._writeToOutput = (str) => {
|
|
173
|
+
if (str.includes('\n') || str.includes('\r')) write?.('\n');
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
rl.question(isTTY ? '' : 'Application password: ', (password) => {
|
|
177
|
+
finish(resolve, { user, password });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|