niranzwp 0.8.2 → 0.8.4
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 +27 -4
- package/lib/auth.js +88 -1
- package/lib/errors.js +6 -1
- package/package.json +1 -1
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
|
|
|
@@ -164,7 +164,7 @@ const out = (flags, value, pretty) => {
|
|
|
164
164
|
|
|
165
165
|
async function main() {
|
|
166
166
|
const { _: pos, flags } = parseArgs(process.argv.slice(2));
|
|
167
|
-
|
|
167
|
+
let [cmd, sub, ...rest] = pos;
|
|
168
168
|
|
|
169
169
|
if (flags.version || cmd === 'version') {
|
|
170
170
|
console.log(`NiranzWP CLI v${VERSION}`);
|
|
@@ -174,6 +174,11 @@ async function main() {
|
|
|
174
174
|
|
|
175
175
|
if (!cmd || flags.help || cmd === 'help') { console.log(USAGE); return; }
|
|
176
176
|
|
|
177
|
+
// The first outside user's first command was "posts list". Plurals are
|
|
178
|
+
// what people type; refusing them over one letter is a papercut with no
|
|
179
|
+
// upside, so both forms work and the docs keep showing the singular.
|
|
180
|
+
cmd = { posts: 'post', pages: 'page', users: 'user', medias: 'media', setting: 'settings', site: 'sites' }[cmd] ?? cmd;
|
|
181
|
+
|
|
177
182
|
if (cmd === 'auth' && sub === 'login') {
|
|
178
183
|
const url = rest[0];
|
|
179
184
|
if (!url) die('usage: niranzwp auth login <url>');
|
|
@@ -205,9 +210,27 @@ async function main() {
|
|
|
205
210
|
return;
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
|
|
213
|
+
// --manual skips the loopback listener entirely: WordPress shows the
|
|
214
|
+
// password on its own success page and the user carries it here. The
|
|
215
|
+
// only route that works when the browser cannot reach this machine --
|
|
216
|
+
// SSH, a VPS, a phone browser against a CLI running elsewhere.
|
|
217
|
+
const creds = flags.manual === true
|
|
218
|
+
? await loginManual(site, { open: flags.open !== false })
|
|
219
|
+
: await loginWithAppPassword(site, { open: flags.open !== false });
|
|
220
|
+
|
|
221
|
+
// A pasted credential can be mistyped, so prove it works before
|
|
222
|
+
// storing it. The loopback flow's credential came from WordPress
|
|
223
|
+
// directly and needs no such check, but it costs one request.
|
|
209
224
|
const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
|
|
210
225
|
saveProfile(name, creds);
|
|
226
|
+
try {
|
|
227
|
+
await whoami(getProfile(name));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
deleteProfile(name);
|
|
230
|
+
throw new CliError('auth_denied', `The site rejected that credential: ${e.message}`, {
|
|
231
|
+
hint: 'Check the username, and paste the application password exactly as WordPress showed it (spaces are fine).',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
211
234
|
console.log(`Connected "${name}" -> ${creds.siteUrl} as ${creds.user}`);
|
|
212
235
|
console.log(`Credential stored in ${storageKind()}.`);
|
|
213
236
|
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
|
+
}
|
package/lib/errors.js
CHANGED
|
@@ -68,8 +68,13 @@ export function fromRest(status, body, fallback = 'usage_error') {
|
|
|
68
68
|
// capability failure, so keep the two hints apart.
|
|
69
69
|
const scoped = /route|scope/i.test(String(body?.message ?? ''));
|
|
70
70
|
return new CliError('insufficient_scope', body?.message || 'Not permitted.', {
|
|
71
|
+
// The first outside session hit this and the old hint only
|
|
72
|
+
// explained the problem. A hint that names no way forward is a
|
|
73
|
+
// dead end wearing a helpful tone.
|
|
71
74
|
hint: scoped
|
|
72
|
-
? 'This
|
|
75
|
+
? 'This profile\'s OAuth token only covers its provider\'s own routes (abilities, MCP), not core REST. ' +
|
|
76
|
+
'For core commands (post, page, media, settings), connect the same site with an application password: ' +
|
|
77
|
+
'niranzwp auth login <site> --app-password --name <new-profile> (add --manual if the browser is on another machine).'
|
|
73
78
|
: 'This credential belongs to a user without the required capability.',
|
|
74
79
|
});
|
|
75
80
|
}
|