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 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
- const [cmd, sub, ...rest] = pos;
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
- const creds = await loginWithAppPassword(site, { open: flags.open !== false });
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('Timed out waiting for authorization.'));
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 token is scoped to a different endpoint. Novamira OAuth tokens work against its MCP endpoint, not the core abilities run route.'
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niranzwp",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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": {